Files
NetMesh/components/terminalLayer/terminalLayerViewMemo.ts

512 lines
16 KiB
TypeScript
Raw Permalink Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import { aiPanelContextsEqual } from '../../domain/aiPanelContextsEqual';
import { sftpPickerSessionsEqual } from '../../domain/sftpConnectedHosts';
type Ctx = Record<string, any>;
function eq(prev: Ctx, next: Ctx, key: string): boolean {
return prev[key] === next[key];
}
function rectEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.w === b.w && a.h === b.h;
}
function rectRecordEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!rectEqual(a[key], b[key])) return false;
}
return true;
}
function workspaceRectsByIdEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!(a instanceof Map) || !(b instanceof Map)) return false;
if (a.size !== b.size) return false;
for (const [workspaceId, rects] of a) {
if (!rectRecordEqual(rects, b.get(workspaceId))) return false;
}
return true;
}
function resizerHandleEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.splitId === b.splitId
&& a.index === b.index
&& a.direction === b.direction
&& rectEqual(a.rect, b.rect)
&& rectEqual(a.splitArea, b.splitArea);
}
function resizerHandlesEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (!resizerHandleEqual(a[i], b[i])) return false;
}
return true;
}
function arrayEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i] !== b[i]) return false;
}
return true;
}
function workspaceNodeEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.id !== b.id || a.type !== b.type) return false;
if (a.type === 'pane') {
return a.sessionId === b.sessionId;
}
if (a.direction !== b.direction) return false;
if (!arrayEqual(a.sizes, b.sizes)) return false;
if (!Array.isArray(a.children) || !Array.isArray(b.children)) return false;
if (a.children.length !== b.children.length) return false;
for (let i = 0; i < a.children.length; i += 1) {
if (!workspaceNodeEqual(a.children[i], b.children[i])) return false;
}
return true;
}
function activeWorkspaceEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.title === b.title
&& a.viewMode === b.viewMode
&& a.focusedSessionId === b.focusedSessionId
&& a.snippetId === b.snippetId
&& arrayEqual(a.focusSessionOrder, b.focusSessionOrder)
&& workspaceNodeEqual(a.root, b.root);
}
function terminalThemeEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key !== 'terminalTheme') return prev[key] === next[key];
const a = prev.terminalTheme;
const b = next.terminalTheme;
if (a === b) return true;
if (!a || !b) return false;
return a.id === b.id
&& a.colors.background === b.colors.background
&& a.colors.foreground === b.colors.foreground
&& a.colors.cursor === b.colors.cursor;
}
function workspaceCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key === 'computeSplitHint' || key === 'handleWorkspaceDrop') {
if (!prev.draggingSessionId && !next.draggingSessionId) return true;
}
if (key === 'activeWorkspace') {
return activeWorkspaceEqual(prev.activeWorkspace, next.activeWorkspace);
}
if (key === 'workspaceRectsById') {
return workspaceRectsByIdEqual(prev.workspaceRectsById, next.workspaceRectsById);
}
if (key === 'activeResizers') {
return resizerHandlesEqual(prev.activeResizers, next.activeResizers);
}
if (key === 'terminalTheme') {
return terminalThemeEqual(prev, next, key);
}
return prev[key] === next[key];
}
function scriptRunsEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!Array.isArray(a) || !Array.isArray(b)) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
const prevRun = a[i];
const nextRun = b[i];
if (prevRun === nextRun) continue;
if (!prevRun || !nextRun) return false;
if (
prevRun.runId !== nextRun.runId
|| prevRun.status !== nextRun.status
|| prevRun.stepIndex !== nextRun.stepIndex
|| prevRun.waitingFor !== nextRun.waitingFor
|| prevRun.error !== nextRun.error
|| prevRun.logs?.length !== nextRun.logs?.length
) {
return false;
}
}
return true;
}
function sidePanelCtxKeyEqual(prev: Ctx, next: Ctx, key: string): boolean {
if (key === 'scriptRuns') {
return scriptRunsEqual(prev.scriptRuns, next.scriptRuns);
}
if (key === 'activeWorkspace') {
return activeWorkspaceEqual(prev.activeWorkspace, next.activeWorkspace);
}
if (key === 'terminalTheme') {
return terminalThemeEqual(prev, next, key);
}
if (key === 'sessions') {
// Connected picker and retained system panels need transport and tab ownership,
// while title/cwd churn can still be ignored.
if (prev.sessions === next.sessions) return true;
if (!sftpPickerSessionsEqual(prev.sessions, next.sessions)) return false;
if (!Array.isArray(prev.sessions) || !Array.isArray(next.sessions)) return false;
return prev.sessions.every((session: any, index: number) => (
session.workspaceId === next.sessions[index]?.workspaceId
));
}
if (key === 'workspaceById') {
// Focus-only workspace map identity changes must not invalidate every mounted
// side-panel slot; System reads focused session from sidePanelLiveStore.
return sidePanelWorkspaceByIdEqual(prev.workspaceById, next.workspaceById);
}
if (key === 'aiContextsByTabId') {
return aiPanelContextsEqual(prev.aiContextsByTabId, next.aiContextsByTabId);
}
return prev[key] === next[key];
}
function sidePanelWorkspaceByIdEqual(a: any, b: any): boolean {
if (a === b) return true;
if (!(a instanceof Map) || !(b instanceof Map)) return false;
if (a.size !== b.size) return false;
for (const [workspaceId, prevWorkspace] of a) {
const nextWorkspace = b.get(workspaceId);
if (!nextWorkspace) return false;
if (prevWorkspace === nextWorkspace) continue;
if (prevWorkspace.id !== nextWorkspace.id) return false;
if (prevWorkspace.title !== nextWorkspace.title) return false;
if (prevWorkspace.viewMode !== nextWorkspace.viewMode) return false;
if (prevWorkspace.snippetId !== nextWorkspace.snippetId) return false;
if (!workspaceNodeEqual(prevWorkspace.root, nextWorkspace.root)) return false;
}
return true;
}
// Live fields that are also published via sidePanelLiveStore (or dedicated
// stores like scriptRunsStore). Slots subscribe to those stores directly, so
// TerminalLayerView must NOT re-render on these ticks — only chrome-stable keys.
const SIDE_PANEL_LIVE_CTX_KEYS = [
'activeTerminalSessionForSystem',
'activeSystemSessionHost',
'focusedHost',
'focusedSessionId',
'historySessionId',
'resolvedPreviewTheme',
'previewedOrVisibleThemeId',
'sftpActiveHost',
'activeTerminalSessionIdForSftp',
'activeTerminalCwd',
'activeTerminalCwdTrusted',
'activeWorkspace',
'focusedFontFamilyId',
'focusedFontFamilyOverridden',
'focusedFontSize',
'focusedFontSizeOverridden',
'focusedFontWeight',
'focusedFontWeightOverridden',
'focusedThemeOverridden',
] as const;
const SIDE_PANEL_STABLE_CTX_KEYS = [
'mountedSftpTabIds',
'mountedAiTabIds',
'notesMountedTabIds',
'notesOpenNoteByTab',
'scriptsMountedTabIds',
'systemMountedTabIds',
'themeMountedTabIds',
'handleHistoryPaste',
'handleHistoryDelete',
'handleHistoryRun',
'handleOpenHistory',
'HistorySidePanel',
'History',
'sidePanelWidth',
'sidePanelPosition',
'sidePanelOpenTabs',
'sidePanelLayouts',
'sftpHostForTab',
'sftpPaneClosedTabIdsRef',
'sftpRetainedAfterCloseTabIdsRef',
'effectiveHosts',
'hosts',
// SFTP Connected picker reads live terminal sessions from stable ctx.
'sessions',
'sessionHostsMap',
'workspaceById',
'keys',
'identities',
'knownHosts',
'updateHosts',
'handleAddKnownHost',
'updateSnippets',
'updateSnippetPackages',
'sftpDefaultViewMode',
'sftpInitialLocationForTab',
'sftpPendingUploadsForTab',
'handleSftpCurrentPathChange',
'handleSftpActiveTransfersChange',
'handleSftpActiveExternalEditsChange',
'sftpDoubleClickBehavior',
'sftpAutoSync',
'sftpShowHiddenFiles',
'sftpUseCompressedUpload',
'sftpFollowTerminalCwd',
'setSftpFollowTerminalCwd',
'hotkeyScheme',
'keyBindings',
'editorWordWrap',
'setEditorWordWrap',
'getTerminalCwd',
'refocusActiveTerminalSession',
'terminalSettings',
'snippets',
'snippetPackages',
'handleSnippetFromPanel',
'handleRunScriptFromPanel',
'handleRunScriptOnWorkspace',
'handleStartRecordingFromPanel',
'handleStopScriptRun',
'handlePauseScriptRun',
'handleResumeScriptRun',
'followAppTerminalTheme',
'terminalTheme',
'terminalThemeId',
'terminalFontFamilyId',
'handleThemeChangeForFocusedSession',
'handleThemeResetForFocusedSession',
'handleFontFamilyChangeForFocusedSession',
'handleFontFamilyResetForFocusedSession',
'handleFontSizeChangeForFocusedSession',
'handleFontSizeResetForFocusedSession',
'handleFontWeightChangeForFocusedSession',
'handleFontWeightResetForFocusedSession',
'aiContextsByTabId',
'resolveAIExecutorContext',
'pendingTerminalSelectionForAI',
'handlePendingTerminalSelectionConsumed',
'setSidePanelWidth',
'persistSidePanelWidth',
'handleToggleSftpFromBar',
'handleOpenScripts',
'handleOpenTheme',
'handleOpenAI',
'handleOpenNotes',
'handleBackFromNotes',
'handleOpenHostFromNotes',
'handleOpenSystem',
'handleCloseSidePanel',
'handleFocusSidePanelPane',
'handleMagnifySidePanelPane',
'handleRestoreMagnifiedPane',
'handleSplitSidePanelPane',
'handleCloseSidePanelPane',
'handleResizeSidePanelSplit',
'setSidePanelPosition',
'handleSftpInitialLocationApplied',
'handlePendingUploadHandled',
'validAIScopeTargetIds',
'magnifiedPane',
'AISidePanelStateRoot',
'NotesManager',
// notes / noteGroups / updateNotes / updateNoteGroups come from notesStore.
'onOpenVaultNoteFromChat',
'onOpenVaultHostFromChat',
'onOpenVaultSectionFromChat',
'onOpenVaultSnippetFromChat',
't',
] as const;
const WORKSPACE_CTX_KEYS = [
'activeTabId',
'workspaceInnerRef',
'workspaceOuterRef',
'workspaceOverlayRef',
'draggingSessionId',
'isFocusMode',
'dropHint',
'setDropHint',
'computeSplitHint',
'handleWorkspaceDrop',
'sessions',
'sessionHostsMap',
'sessionChainHostsMap',
'sessionSudoAutofillPasswordsMap',
'sessionSudoAutofillCandidatesMap',
'workspaceById',
'workspaceRectsById',
'isTerminalLayerVisible',
'magnifiedPane',
'handleMagnifyTerminalPane',
'handleTerminalPaneInteraction',
'handleRestoreMagnifiedPane',
'workspaceFocusHandlersRef',
'workspaceBroadcastHandlersRef',
'splitHorizontalHandlersRef',
'splitVerticalHandlersRef',
'resolveSessionAppearance',
'hostMap',
'keys',
'identities',
'snippets',
'knownHosts',
'terminalFontFamilyId',
'fontSize',
'terminalTheme',
'followAppTerminalTheme',
// accentMode / customAccent come from appearanceChromeStore (Terminal leaf).
'terminalSettings',
'hotkeyScheme',
'disableTerminalFontZoom',
'restoreTerminalCwd',
'keyBindings',
'resizing',
'isComposeBarOpen',
'sessionLogConfig',
'sshDebugLogsEnabled',
'onHotkeyAction',
'handleTerminalFontSizeChange',
'handleOpenSftp',
'handleTerminalCwdChange',
'handleTerminalTitleChange',
'handleTerminalBell',
'handleTerminalOutput',
'handleTerminalContextReaderChange',
'handleOpenScripts',
'handleOpenHistory',
'handleOpenSystem',
'handleOpenTheme',
'handleCloseSession',
'handleStatusChange',
'handleSessionExit',
'handleTerminalDataCapture',
'handleOsDetected',
'handleUpdateHost',
'handleAddKnownHost',
'handleCommandExecuted',
'handleCommandSubmitted',
'onSetWorkspaceFocusedSession',
'onSplitSession',
'isBroadcastEnabled',
'handleBroadcastInput',
'handleBroadcastInterruptPriorityChange',
'handleToggleWorkspaceComposeBar',
'handleSnippetExecutorChange',
'handleProgrammaticCommandLogRewriteChange',
'handleAddSelectionToAI',
'activeResizers',
// activeWorkspace / focusedSessionId: TerminalLayerWorkspaceSection reads
// these from sidePanelLiveStore so tab switches do not invalidate workspace
// chrome solely because the active tab id changed.
'composeBarThemeColors',
'findSplitNode',
'handleComposeSend',
'handleSnippetFromPanel',
'refocusTerminalSession',
'setIsComposeBarOpen',
'TerminalComposeBar',
'setResizing',
'Array',
'cn',
'onStartSessionRename',
'onRemoveSessionFromWorkspace',
'onReorderTabs',
'onStartSessionDrag',
'onEndSessionDrag',
'isGlobalBroadcastEnabled',
'canUseGlobalBroadcast',
'onToggleGlobalBroadcast',
] as const;
export function terminalLayerSidePanelStableCtxEqual(prev: Ctx, next: Ctx): boolean {
for (const key of SIDE_PANEL_STABLE_CTX_KEYS as unknown as string[]) {
if (!sidePanelCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerSidePanelCtxEqual(prev: Ctx, next: Ctx): boolean {
if (!terminalLayerSidePanelStableCtxEqual(prev, next)) return false;
for (const key of SIDE_PANEL_LIVE_CTX_KEYS as unknown as string[]) {
if (!sidePanelCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerWorkspaceCtxEqual(prev: Ctx, next: Ctx): boolean {
for (const key of WORKSPACE_CTX_KEYS as unknown as string[]) {
if (!workspaceCtxKeyEqual(prev, next, key)) return false;
}
return true;
}
export function terminalLayerViewCtxEqual(prev: Ctx, next: Ctx): boolean {
if (prev.isTerminalLayerVisible !== next.isTerminalLayerVisible) return false;
if (prev.hibernateHiddenTabs !== next.hibernateHiddenTabs) return false;
if (prev.isComposeBarOpen !== next.isComposeBarOpen) return false;
// activeWorkspace / focusedSessionId intentionally omitted: live store +
// focus-sidebar equal handle those without forcing a full layer rebuild on
// every top-tab switch between terminal sessions.
if (prev.handleComposeSend !== next.handleComposeSend) return false;
if (prev.refocusTerminalSession !== next.refocusTerminalSession) return false;
if (prev.setIsComposeBarOpen !== next.setIsComposeBarOpen) return false;
if (prev.isBroadcastEnabled !== next.isBroadcastEnabled) return false;
if (prev.composeBarThemeColors !== next.composeBarThemeColors) return false;
if (prev.workspaceOuterRef !== next.workspaceOuterRef) return false;
// Use stable side-panel equal only: LIVE fields (cwd, theme focus, etc.) flow
// through sidePanelLiveStore and must not rebuild side-panel chrome.
return terminalLayerSidePanelStableCtxEqual(prev, next)
&& terminalLayerFocusSidebarPropsEqual(prev, next)
&& terminalLayerWorkspaceCtxEqual(prev, next);
}
export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boolean {
if (prev.isFocusMode !== next.isFocusMode) return false;
if (!prev.isFocusMode) return true;
const prevWs = prev.activeWorkspace;
const nextWs = next.activeWorkspace;
if (Boolean(prevWs) !== Boolean(nextWs)) return false;
if (prevWs && nextWs) {
if (prevWs.id !== nextWs.id) return false;
if (prevWs.viewMode !== nextWs.viewMode) return false;
if (prevWs.root !== nextWs.root) return false;
if (prevWs.focusSessionOrder !== nextWs.focusSessionOrder) return false;
}
return eq(prev, next, 'focusedSessionId')
&& eq(prev, next, 'resolvedPreviewTheme')
&& eq(prev, next, 'sessionHostsMap')
&& eq(prev, next, 'sessions')
&& prev.terminalSettings?.dynamicTabTitleMode === next.terminalSettings?.dynamicTabTitleMode
&& eq(prev, next, 't')
&& eq(prev, next, 'onReorderWorkspaceSessions')
&& eq(prev, next, 'onRequestAddToWorkspace')
&& eq(prev, next, 'onAppendHostToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onDuplicateSession')
&& eq(prev, next, 'onCopySessionToNewWindow')
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
&& eq(prev, next, 'onToggleWorkspaceViewMode')
&& eq(prev, next, 'onSubmitSessionRename');
}