/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { memo, useCallback, useRef, useSyncExternalStore } from 'react'; import { createPortal } from 'react-dom'; import { activeTabStore } from '../../application/state/activeTabStore'; import { getSftpCurrentPathMemoryKey } from '../../application/state/sftp/sftpReopenLocation'; import { getSidePanelLiveSnapshot, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, subscribeSidePanelLiveSnapshot, } from '../../application/state/sidePanelLiveStore'; import { getEmptyNotesSnapshot, getNotesSnapshot, subscribeNotes, subscribeNotesNoop, useNotesStore, } from '../../application/state/notesStore'; import { getShellHistorySnapshot, subscribeShellHistory, } from '../../application/state/shellHistoryStore'; import { useScriptExecution } from '../../application/state/useScriptExecution'; import { useRemoteHistoryState } from '../../application/state/useRemoteHistoryState'; import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession'; import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHibernate'; import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts'; import type { Host, TerminalSession, Workspace } from '../../types'; import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel'; import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd'; import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics'; import type { SidePanelTab } from './TerminalLayerSupport'; import { collectSidePanelPanes, sidePanelLayoutHasTool, type SidePanelLayout, } from '../../domain/sidePanelLayout'; import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper'; import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle'; type SidePanelStableContext = Record & { sftpPaneClosedTabIdsRef: React.MutableRefObject>; }; const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : ''; const EMPTY_VAULT_NOTES: never[] = []; const EMPTY_VAULT_HOSTS: never[] = []; const EMPTY_VAULT_SNIPPETS: never[] = []; const EMPTY_SHELL_HISTORY = Object.freeze([]) as readonly never[]; const subscribeShellHistoryNoop = () => () => {}; const getEmptyShellHistorySnapshot = () => EMPTY_SHELL_HISTORY; function useSidePanelLiveSnapshotForTab(tabId: string, subscribe: boolean) { const retainedSnapshot = useRef({ tabId, snapshot: SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT }); const getSnapshot = useCallback(() => { if (!subscribe) return SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT; const snapshot = getSidePanelLiveSnapshot(true); const snapshotTabId = snapshot.activeWorkspace?.id ?? snapshot.focusedSessionId; // Tab visibility changes before the live publisher commits its next snapshot. // Keep this panel's last context until its own terminal/workspace catches up. if (snapshotTabId === tabId) retainedSnapshot.current = { tabId, snapshot }; return retainedSnapshot.current.tabId === tabId ? retainedSnapshot.current.snapshot : SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT; }, [subscribe, tabId]); return useSyncExternalStore( (listener) => subscribeSidePanelLiveSnapshot(subscribe, listener), getSnapshot, getSnapshot, ); } function SidePanelSftpSlotInner({ tabId, ctx, isVisible, ownerPanelOpen, }: { tabId: string; ctx: SidePanelStableContext; isVisible: boolean; /** Side panel still open for this tab (may be showing another tool). */ ownerPanelOpen: boolean; }) { const live = useSidePanelLiveSnapshotForTab(tabId, isVisible); const { SftpSidePanel, effectiveHosts, hosts, sessions, keys, identities, knownHosts, updateHosts, handleAddKnownHost, sftpDefaultViewMode, sftpHostForTab, sftpInitialLocationForTab, sftpPendingUploadsForTab, handleSftpInitialLocationApplied, handleSftpCurrentPathChange, handleSftpActiveTransfersChange, handleSftpActiveExternalEditsChange, handlePendingUploadHandled, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings, editorWordWrap, setEditorWordWrap, getTerminalCwd, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, refocusActiveTerminalSession, terminalSettings, } = ctx; const storedSftpHost = sftpHostForTab.get(tabId) ?? null; const panelActiveHost = isVisible ? (live.sftpActiveHost ?? storedSftpHost) : storedSftpHost; const panelActiveSessionId = isVisible ? live.activeTerminalSessionIdForSftp : null; const panelFocusedSessionId = isVisible ? live.focusedSessionId : null; // Only the head of the per-tab pending upload queue is surfaced at a time; // the panel advances the queue by reporting each request as handled. const pendingUpload = sftpPendingUploadsForTab.get(tabId)?.[0] ?? null; const handleFollowTerminalCwdChange = useCallback((enabled: boolean, visibleHost?: Host | null) => { const isActive = activeTabStore.getActiveTabId() === tabId; const stored = (sftpHostForTab as Map).get(tabId) ?? null; const snapshot = getSidePanelLiveSnapshot(isActive); const activeHost = isActive ? (snapshot.sftpActiveHost ?? stored) : stored; const targetHost = resolveSftpFollowTerminalCwdTargetHost(visibleHost, activeHost); if (!targetHost?.id) { setSftpFollowTerminalCwd(enabled); return; } let updated = false; const nextHosts = (hosts as Host[]).map((host) => { if (host.id !== targetHost.id) return host; updated = true; return { ...host, sftpFollowTerminalCwd: enabled }; }); if (updated) { updateHosts(nextHosts); } else { setSftpFollowTerminalCwd(enabled); } }, [hosts, sftpHostForTab, setSftpFollowTerminalCwd, tabId, updateHosts]); const handleInitialLocationApplied = useCallback( (location: { hostId: string; path: string }) => { handleSftpInitialLocationApplied(tabId, location); }, [handleSftpInitialLocationApplied, tabId], ); const handlePendingUploadHandledForTab = useCallback( (requestId: string) => { handlePendingUploadHandled(tabId, requestId); }, [handlePendingUploadHandled, tabId], ); const handleCurrentPathChange = useCallback( (location: { hostId: string; connectionKey: string; path: string }) => { handleSftpCurrentPathChange( getSftpCurrentPathMemoryKey({ tabId, activeTerminalSessionIdForSftp: panelActiveSessionId, focusedSessionId: panelFocusedSessionId, }), location, ); }, [handleSftpCurrentPathChange, panelActiveSessionId, panelFocusedSessionId, tabId], ); const handleActiveTransfersChange = useCallback( (count: number) => { handleSftpActiveTransfersChange(tabId, count); }, [handleSftpActiveTransfersChange, tabId], ); const handleActiveExternalEditsChange = useCallback( (count: number) => { handleSftpActiveExternalEditsChange(tabId, count); }, [handleSftpActiveExternalEditsChange, tabId], ); return (
); } export const SidePanelSftpSlot = memo(SidePanelSftpSlotInner); SidePanelSftpSlot.displayName = 'SidePanelSftpSlot'; function SidePanelSystemSlotInner({ tabId, ctx, isTabActive, isVisible, isSelected, }: { tabId: string; ctx: SidePanelStableContext; isTabActive: boolean; isVisible: boolean; isSelected: boolean; }) { // When this tab is active, prefer live store so focus changes do not require // workspaceById identity churn through the stable side-panel ctx. const live = useSidePanelLiveSnapshotForTab(tabId, isVisible); const sessions = ctx.sessions as TerminalSession[]; const sessionHostsMap = ctx.sessionHostsMap as Map; const workspace = (ctx.workspaceById as Map).get(tabId); const standaloneSession = sessions.find((session) => session.id === tabId); const resolvedSession = resolveSystemSidebarSession( sessions, workspace, workspace?.focusedSessionId, standaloneSession, ); const systemSession = (isVisible ? (live.activeTerminalSessionForSystem ?? resolvedSession) : resolvedSession) ?? null; const systemHost = (isVisible && live.activeSystemSessionHost) ? live.activeSystemSessionHost : (systemSession ? sessionHostsMap.get(systemSession.id) ?? null : null); const keepSystemWorkActive = isSelected && shouldKeepTerminalBackgroundWorkActive( ctx.terminalSettings, systemHost?.protocol, isTabActive, ); const { refocusActiveTerminalSession, snippets, terminalSettings, } = ctx; return (
); } export const SidePanelSystemSlot = memo(SidePanelSystemSlotInner); SidePanelSystemSlot.displayName = 'SidePanelSystemSlot'; function SidePanelScriptsSlotInner({ tabId, ctx, isVisible, }: { tabId: string; ctx: SidePanelStableContext; isVisible: boolean; }) { const live = useSidePanelLiveSnapshotForTab(tabId, isVisible); // Subscribe only while visible so retained scripts slots skip log thrash. const { runs: scriptRuns } = useScriptExecution({ enabled: isVisible }); const { ScriptsSidePanel, snippets, snippetPackages, updateSnippets, updateSnippetPackages, handleSnippetFromPanel, handleRunScriptFromPanel, handleRunScriptOnWorkspace, handleStartRecordingFromPanel, handleStopScriptRun, handlePauseScriptRun, handleResumeScriptRun, } = ctx; return (
); } export const SidePanelScriptsSlot = memo(SidePanelScriptsSlotInner); SidePanelScriptsSlot.displayName = 'SidePanelScriptsSlot'; function SidePanelThemeSlotInner({ tabId, ctx, isVisible, }: { tabId: string; ctx: SidePanelStableContext; isVisible: boolean; }) { // Only subscribe while the theme panel is visible — not merely tab-active — // so cwd/focus live ticks do not thrash a retained ThemeSidePanel. const live = useSidePanelLiveSnapshotForTab(tabId, isVisible); const { ThemeSidePanel, followAppTerminalTheme, terminalTheme, terminalThemeId, terminalFontFamilyId, handleThemeChangeForFocusedSession, handleThemeResetForFocusedSession, handleFontFamilyChangeForFocusedSession, handleFontFamilyResetForFocusedSession, handleFontSizeChangeForFocusedSession, handleFontSizeResetForFocusedSession, handleFontWeightChangeForFocusedSession, handleFontWeightResetForFocusedSession, } = ctx; return (
); } export const SidePanelThemeSlot = memo(SidePanelThemeSlotInner); SidePanelThemeSlot.displayName = 'SidePanelThemeSlot'; function SidePanelNotesSlotInner({ tabId, ctx, isVisible, }: { tabId: string; ctx: SidePanelStableContext; isVisible: boolean; }) { const openNoteRequest = (ctx.notesOpenNoteByTab as Map).get(tabId) ?? null; // Gate subscription while Notes is hidden so vault edits (and the full-page // notebook) do not re-render this retained side-panel mount. const { notes, noteGroups, updateNotes, updateNoteGroups, } = useNotesStore({ enabled: isVisible }); const { NotesManager, hosts, handleOpenHostFromNotes, } = ctx; return (
); } export const SidePanelNotesSlot = memo(SidePanelNotesSlotInner); SidePanelNotesSlot.displayName = 'SidePanelNotesSlot'; function SidePanelHistorySlotInner({ activeTabId, ctx, isVisible, }: { activeTabId: string | null; ctx: SidePanelStableContext; isVisible: boolean; }) { const live = useSidePanelLiveSnapshotForTab(activeTabId ?? '', isVisible); // Own remote-history state here so fetch/loading does not re-render TerminalLayer. const remoteHistory = useRemoteHistoryState(); // Gate store subscription while History is hidden so command appends do not // re-render this retained mount (panel still mounts for fast reopen). const shellHistory = useSyncExternalStore( isVisible ? subscribeShellHistory : subscribeShellHistoryNoop, isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot, isVisible ? getShellHistorySnapshot : getEmptyShellHistorySnapshot, ); const { HistorySidePanel, handleHistoryPaste, handleHistoryDelete, handleHistoryRun, } = ctx; if (!isVisible) return null; return (
); } export const SidePanelHistorySlot = memo(SidePanelHistorySlotInner); SidePanelHistorySlot.displayName = 'SidePanelHistorySlot'; function SidePanelAiSlotInner({ activeTabId, ctx, isVisible, }: { activeTabId: string | null; ctx: SidePanelStableContext; isVisible: boolean; }) { const { AIChatPanelsHost, AISidePanelStateRoot, mountedAiTabIds, aiContextsByTabId, resolveAIExecutorContext, pendingTerminalSelectionForAI, handlePendingTerminalSelectionConsumed, hosts, snippets, onOpenVaultNoteFromChat, onOpenVaultHostFromChat, onOpenVaultSectionFromChat, onOpenVaultSnippetFromChat, validAIScopeTargetIds, } = ctx; const workspaces = ctx.workspaces as Workspace[]; // Gate notes subscription while AI is hidden so note edits do not re-render // retained AI mounts (panel still mounts for fast reopen). const notesSnapshot = useSyncExternalStore( isVisible ? subscribeNotes : subscribeNotesNoop, isVisible ? getNotesSnapshot : getEmptyNotesSnapshot, isVisible ? getNotesSnapshot : getEmptyNotesSnapshot, ); const activeLayout = activeTabId ? (ctx.sidePanelLayouts as Map).get(activeTabId) : null; const hideVisibleShell = ( AI_PANEL_FORCE_HIDE_SHELL && isVisible && (!activeLayout || collectSidePanelPanes(activeLayout.root).length <= 1) ); // Keep the AI state root mounted even with zero panel hosts so workspace // merge/detach can seed and hand off scoped chats before orphan cleanup. if (mountedAiTabIds.length === 0 || hideVisibleShell) { return ( {null} ); } // Only the visible AI panel needs vault catalogs for artifact navigation. // Hidden retained panels keep session state without re-binding huge hosts/notes. const injectVaultCatalog = isVisible; const notes = injectVaultCatalog ? (notesSnapshot.notes as import('../../domain/models').VaultNote[]) : EMPTY_VAULT_NOTES; return ( ); } export const SidePanelAiSlot = memo(SidePanelAiSlotInner); SidePanelAiSlot.displayName = 'SidePanelAiSlot'; export function getFocusedPortalDescendant( mountNode: HTMLElement, activeElement: Element | null, ): HTMLElement | null { return activeElement instanceof HTMLElement && mountNode.contains(activeElement) ? activeElement : null; } export function movePersistentPortalNode( mountNode: HTMLElement, target: HTMLElement, focusToRestore: HTMLElement | null, ) { target.appendChild(mountNode); if (focusToRestore && document.activeElement !== focusToRestore) { focusToRestore.focus({ preventScroll: true }); } } function PersistentSidePanelPortal({ portalKey, target, children, }: { portalKey: string; target: HTMLElement | null; children: React.ReactNode; }) { const [mountNode] = React.useState(() => { const node = document.createElement('div'); node.className = 'absolute inset-0 overflow-hidden'; node.dataset.sidePanelPortal = portalKey; return node; }); const focusRestoreRef = React.useRef(null); // The React portal always targets the same detached node. Moving that node // between a pane host and the hidden parking host preserves the mounted // subtree (including active SFTP/AI state) instead of remounting it whenever // the focused pane changes. React.useLayoutEffect(() => { if (!target) return; const activeElement = document.activeElement; const focusedDescendant = focusRestoreRef.current ?? getFocusedPortalDescendant(mountNode, activeElement); movePersistentPortalNode(mountNode, target, focusedDescendant); focusRestoreRef.current = null; return () => { if (mountNode.parentNode !== target) return; const currentActiveElement = document.activeElement; focusRestoreRef.current = getFocusedPortalDescendant(mountNode, currentActiveElement); mountNode.remove(); }; }, [mountNode, target]); return createPortal(children, mountNode, portalKey); } export function resolveSidePanelPortalTarget( isVisible: boolean, paneHost: T | null | undefined, parkingHost: T | null, ): T | null { return isVisible ? (paneHost ?? parkingHost) : parkingHost; } export function SidePanelMountedContent({ ctx, paneHosts, parkingHost, }: { ctx: SidePanelStableContext; paneHosts: ReadonlyMap; parkingHost: HTMLElement | null; }) { const { mountedSftpTabIds, systemMountedTabIds, scriptsMountedTabIds, themeMountedTabIds, notesMountedTabIds, } = ctx; const activeTabId = useSyncExternalStore( activeTabStore.subscribe, activeTabStore.getActiveTabId, activeTabStore.getActiveTabId, ); const layouts = ctx.sidePanelLayouts as Map; const openTabs = ctx.sidePanelOpenTabs as Map; const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as | React.MutableRefObject> | undefined; const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef; const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({ sidePanelOpen: openTabs.has(tabId), retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false, sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId), }); const isToolVisible = (tabId: string, tool: SidePanelTab) => ( activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool) ); const portalTarget = (tabId: string, tool: SidePanelTab) => ( resolveSidePanelPortalTarget(isToolVisible(tabId, tool), paneHosts.get(tool), parkingHost) ); const activeLayout = activeTabId ? layouts.get(activeTabId) : undefined; const historyVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'history'); const aiVisible = !!activeTabId && sidePanelLayoutHasTool(activeLayout, 'ai'); return ( <> {mountedSftpTabIds.map((tabId: string) => ( ))} {systemMountedTabIds.map((tabId: string) => { const isSelected = sidePanelLayoutHasTool(layouts.get(tabId), 'system'); const isVisible = activeTabId === tabId && isSelected; return ( ); })} {scriptsMountedTabIds.map((tabId: string) => ( ))} {themeMountedTabIds.map((tabId: string) => ( ))} {notesMountedTabIds.map((tabId: string) => ( ))} ); }