From ac3993b7893d5c420b98d0f8f62c0bc3706ef90b Mon Sep 17 00:00:00 2001 From: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:47:29 +0000 Subject: [PATCH 1/4] fix(#2882): automated Cursor CLI fix --- .../state/sftp/browseSessionLifecycle.test.ts | 26 +++++++++++++++++++ .../state/sftp/browseSessionLifecycle.ts | 15 ++++++++--- application/state/useSftpState.ts | 9 ++++--- components/SftpSidePanel.tsx | 11 ++++++++ components/SftpView.tsx | 5 ++-- .../terminalLayer/sftpPanelLifecycle.test.ts | 8 ++++-- .../terminalLayerSidePanelSlots.tsx | 12 ++++++++- 7 files changed, 74 insertions(+), 12 deletions(-) diff --git a/application/state/sftp/browseSessionLifecycle.test.ts b/application/state/sftp/browseSessionLifecycle.test.ts index d81ba1d14d..cfe5533609 100644 --- a/application/state/sftp/browseSessionLifecycle.test.ts +++ b/application/state/sftp/browseSessionLifecycle.test.ts @@ -39,6 +39,32 @@ test("keeps a hidden SFTP owner interactive while its promoted editor tab is ope assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false); }); +test("keeps browse warm while the terminal side panel stays open on another tool", () => { + // SFTP→History/System replaces the focused pane tool, so surfaceVisible is + // false, but the owner stays mounted for instant switch-back. Parking here + // forces reconnect + directory reload (and intermittent blank lists). + const interactive = isBrowseSessionInteractive({ + surfaceVisible: false, + ownerPanelOpen: true, + hasOwnedEditorTab: false, + }); + + assert.equal(interactive, true); + assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false); +}); + +test("parks browse when the side panel is closed and nothing else retains it", () => { + const interactive = isBrowseSessionInteractive({ + surfaceVisible: false, + ownerPanelOpen: false, + hasOwnedEditorTab: false, + hasActiveExternalEdit: false, + }); + + assert.equal(interactive, false); + assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true); +}); + test("parks browse only when the interactive surface hides and not already parked", () => { assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true); assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false); diff --git a/application/state/sftp/browseSessionLifecycle.ts b/application/state/sftp/browseSessionLifecycle.ts index ba9c7a10b8..6fad407520 100644 --- a/application/state/sftp/browseSessionLifecycle.ts +++ b/application/state/sftp/browseSessionLifecycle.ts @@ -1,18 +1,27 @@ /** * Browse vs transfer session lifecycle helpers. * - * FileZilla model: the interactive browser can soft-close its SFTP channels - * while bulk transfers keep dedicated pool connections (and any leased browse - * sessions held by in-flight streams). + * FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse + * SFTP channels while bulk transfers keep dedicated pool connections (and any + * leased browse sessions held by in-flight streams). Switching to another side + * panel tool (History / System / …) must keep browse warm — the owner stays + * mounted for instant switch-back, and parking would force reconnect + reload. */ export function isBrowseSessionInteractive(params: { surfaceVisible: boolean; + /** + * Terminal side panel still open for this owner (another tool may be focused). + * Keeps browse sessions alive across tool switches without treating that as a + * full panel dismiss. + */ + ownerPanelOpen?: boolean; hasOwnedEditorTab: boolean; /** External editor temp files (Notepad++ etc.) still need the browse session. */ hasActiveExternalEdit?: boolean; }): boolean { return params.surfaceVisible + || !!params.ownerPanelOpen || params.hasOwnedEditorTab || !!params.hasActiveExternalEdit; } diff --git a/application/state/useSftpState.ts b/application/state/useSftpState.ts index 03d179fd5f..faa0917275 100644 --- a/application/state/useSftpState.ts +++ b/application/state/useSftpState.ts @@ -643,10 +643,11 @@ export const useSftpState = ( [resolveTransferConflict, resolveUploadConflict, uploadConflicts], ); - // FileZilla-style: when the browser UI is hidden, soft-close browse SFTP - // channels. Defer park while this owner still has unfinished transfers so - // pre-lease prep (conflict/stat) cannot race a hard-close of the browse id. - // In-flight streams also soft-close via leases; pool handles bulk I/O. + // FileZilla-style: when the side panel is closed (not merely showing another + // tool), soft-close browse SFTP channels. Defer park while this owner still + // has unfinished transfers so pre-lease prep (conflict/stat) cannot race a + // hard-close of the browse id. In-flight streams also soft-close via leases; + // pool handles bulk I/O. const interactive = options?.interactive !== false; useEffect(() => { const gen = ++browseLifecycleGenRef.current; diff --git a/components/SftpSidePanel.tsx b/components/SftpSidePanel.tsx index 2224a53799..94dccbfe16 100644 --- a/components/SftpSidePanel.tsx +++ b/components/SftpSidePanel.tsx @@ -128,6 +128,11 @@ interface SftpSidePanelProps { onActiveExternalEditsChange?: (count: number) => void; showWorkspaceHostHeader?: boolean; isVisible?: boolean; + /** + * Side panel chrome still open for this terminal tab (another tool may be + * focused). Keeps browse SFTP sessions warm across History/System switches. + */ + ownerPanelOpen?: boolean; renderOverlays?: boolean; pendingUpload?: { requestId: string; @@ -177,6 +182,7 @@ const SftpSidePanelInner: React.FC = ({ onActiveExternalEditsChange, showWorkspaceHostHeader = false, isVisible = true, + ownerPanelOpen = false, renderOverlays = true, pendingUpload = null, onPendingUploadHandled, @@ -258,8 +264,11 @@ const SftpSidePanelInner: React.FC = ({ // becomes hidden, so its browse channel must stay alive until the editor closes. // External editor temps (Notepad++ etc.) likewise need the session: parking // calls closeSftp which deletes those local files. + // Keep browse warm while the side panel stays open on another tool + // (History / System / …) so switch-back does not reconnect + reload. interactive: isBrowseSessionInteractive({ surfaceVisible: isVisible, + ownerPanelOpen, hasOwnedEditorTab, hasActiveExternalEdit: activeExternalEditCountRef.current > 0, }), @@ -275,6 +284,7 @@ const SftpSidePanelInner: React.FC = ({ fileWatchHandlers, hasOwnedEditorTab, isVisible, + ownerPanelOpen, transferOwnerId, sftpUseCompressedUpload, sftpShowHiddenFiles, @@ -1861,6 +1871,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): prev.focusedSessionId === next.focusedSessionId && prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader && prev.isVisible === next.isVisible && + prev.ownerPanelOpen === next.ownerPanelOpen && prev.renderOverlays === next.renderOverlays && prev.pendingUpload?.requestId === next.pendingUpload?.requestId && prev.onPendingUploadHandled === next.onPendingUploadHandled && diff --git a/components/SftpView.tsx b/components/SftpView.tsx index 245cd93ff0..c7e437522a 100644 --- a/components/SftpView.tsx +++ b/components/SftpView.tsx @@ -126,8 +126,9 @@ const SftpViewInner: React.FC = ({ ...fileWatchHandlers, transferOwnerId: "main-sftp-view", // Main SFTP page stays interactive while mounted so top-tab switches - // (e.g. Terminal ↔ SFTP) must not soft-close every tab's session; - // the terminal side panel still parks when its panel is hidden. + // (e.g. Terminal ↔ SFTP) must not soft-close every tab's session. + // The terminal side panel parks only after the panel is closed (not when + // switching History/System while the chrome stays open). // Bulk transfers use dedicated pool sessions regardless. interactive: true, useCompressedUpload: sftpUseCompressedUpload, diff --git a/components/terminalLayer/sftpPanelLifecycle.test.ts b/components/terminalLayer/sftpPanelLifecycle.test.ts index b0f1d50e3d..57c615ec57 100644 --- a/components/terminalLayer/sftpPanelLifecycle.test.ts +++ b/components/terminalLayer/sftpPanelLifecycle.test.ts @@ -214,6 +214,7 @@ test("terminal side panel reports transfer activity and uses store-backed retain assert.doesNotMatch(panelSource, /useEffect\(\(\) => \(\) => \{\s*onActiveTransfersChange\?\.\(0\);\s*\}, \[onActiveTransfersChange\]\)/); assert.match(panelSource, /interactive:\s*isBrowseSessionInteractive\(\{/); assert.match(panelSource, /surfaceVisible:\s*isVisible/); + assert.match(panelSource, /ownerPanelOpen/); assert.match(panelSource, /useEditorTabPresenceRevision\(\)/); assert.match(panelSource, /hasOwnedEditorTab/); assert.match(panelSource, /hasActiveExternalEdit/); @@ -228,10 +229,13 @@ test("terminal side panel reports transfer activity and uses store-backed retain assert.match(layerSource, /sftpActiveExternalEditsByTabRef/); assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/); assert.match(layerSource, /sftpRetainedCleanupTimersRef/); - // Hidden UI parks browse channels; transfers keep pool / leased sessions. - // External editor temps must also block park (closeSftp deletes those files). + // Hidden UI parks browse channels only after the side panel closes; + // tool switches keep browse warm via ownerPanelOpen. Transfers keep pool / + // leased sessions. External editor temps must also block park (closeSftp + // deletes those files). assert.match(stateSource, /shouldParkBrowseSessions/); assert.match(stateSource, /activeExternalEditCount/); assert.match(stateSource, /takeBrowseSessionsForClose/); assert.match(stateSource, /shouldRestoreBrowseSessions/); + assert.match(slotsSource, /ownerPanelOpen=\{openTabs\.has\(tabId\)\}/); }); diff --git a/components/terminalLayer/terminalLayerSidePanelSlots.tsx b/components/terminalLayer/terminalLayerSidePanelSlots.tsx index 0f51fba313..eb7bb1135e 100644 --- a/components/terminalLayer/terminalLayerSidePanelSlots.tsx +++ b/components/terminalLayer/terminalLayerSidePanelSlots.tsx @@ -61,10 +61,13 @@ 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); @@ -195,6 +198,7 @@ function SidePanelSftpSlotInner({ onActiveExternalEditsChange={handleActiveExternalEditsChange} showWorkspaceHostHeader={isVisible && !!live.activeWorkspace} isVisible={isVisible} + ownerPanelOpen={ownerPanelOpen} renderOverlays={isVisible} pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null} onPendingUploadHandled={handlePendingUploadHandledForTab} @@ -648,6 +652,7 @@ export function SidePanelMountedContent({ activeTabStore.getActiveTabId, ); const layouts = ctx.sidePanelLayouts as Map; + const openTabs = ctx.sidePanelOpenTabs as Map; const isToolVisible = (tabId: string, tool: SidePanelTab) => ( activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool) ); @@ -666,7 +671,12 @@ export function SidePanelMountedContent({ portalKey={`sftp-${tabId}`} target={portalTarget(tabId, 'sftp')} > - + ))} {systemMountedTabIds.map((tabId: string) => { From 89b3e273299886d9fe9f2e08cab0df2f3118e223 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:24:51 +0800 Subject: [PATCH 2/4] fix(sftp): preserve close lifecycle when switching tools --- components/TerminalLayer.tsx | 1 + .../terminalLayer/sftpPanelLifecycle.test.ts | 19 ++++++++++++++++++- .../terminalLayer/sftpPanelLifecycle.ts | 12 ++++++++++++ .../terminalLayerSidePanelSlots.tsx | 10 +++++++++- .../terminalLayer/terminalLayerViewMemo.ts | 1 + 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 463ff73cc9..33f302dd03 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -2155,6 +2155,7 @@ const TerminalLayerInner: React.FC = ({ sftpHostForTab, sftpInitialLocationForTab, sftpPendingUploadsForTab, + sftpRetainedAfterCloseTabIdsRef, sftpShowHiddenFiles, SftpSidePanel, sftpUseCompressedUpload, diff --git a/components/terminalLayer/sftpPanelLifecycle.test.ts b/components/terminalLayer/sftpPanelLifecycle.test.ts index 57c615ec57..e3f6c0021b 100644 --- a/components/terminalLayer/sftpPanelLifecycle.test.ts +++ b/components/terminalLayer/sftpPanelLifecycle.test.ts @@ -13,6 +13,7 @@ import { shouldCloseSftpSidePanel, shouldClearSftpPanelAfterTransferChange, shouldKeepSftpMountedAfterClose, + shouldKeepSftpBrowseSessionInteractive, shouldScheduleSftpRetainedPanelCleanup, terminalSftpTransferOwnerId, } from "./sftpPanelLifecycle.ts"; @@ -74,6 +75,21 @@ test("closing an idle panel still releases its SFTP state", () => { assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false); }); +test("closing SFTP keeps another tool from reviving its browse session", () => { + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: true, + retainedAfterClose: false, + }), true); + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: true, + retainedAfterClose: true, + }), false); + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: false, + retainedAfterClose: true, + }), false); +}); + test("a transfer retained by close keeps its history after completion", () => { assert.equal(shouldClearSftpPanelAfterTransferChange({ activeTransfersCount: 0, @@ -237,5 +253,6 @@ test("terminal side panel reports transfer activity and uses store-backed retain assert.match(stateSource, /activeExternalEditCount/); assert.match(stateSource, /takeBrowseSessionsForClose/); assert.match(stateSource, /shouldRestoreBrowseSessions/); - assert.match(slotsSource, /ownerPanelOpen=\{openTabs\.has\(tabId\)\}/); + assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/); + assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/); }); diff --git a/components/terminalLayer/sftpPanelLifecycle.ts b/components/terminalLayer/sftpPanelLifecycle.ts index debc3fe701..0b1208a265 100644 --- a/components/terminalLayer/sftpPanelLifecycle.ts +++ b/components/terminalLayer/sftpPanelLifecycle.ts @@ -65,6 +65,18 @@ export function shouldKeepSftpMountedAfterClose(params: { || (params.activeExternalEditCount ?? 0) > 0; } +/** + * A different side-panel tool keeps SFTP warm only when the SFTP owner was + * never closed. A retained-after-close mount is kept for transfers/editor + * cleanup, but its browse session must still be allowed to park. + */ +export function shouldKeepSftpBrowseSessionInteractive(params: { + sidePanelOpen: boolean; + retainedAfterClose: boolean; +}): boolean { + return params.sidePanelOpen && !params.retainedAfterClose; +} + export function shouldCloseSftpSidePanel(params: { shouldKeepOpen: boolean; isOpen: boolean; diff --git a/components/terminalLayer/terminalLayerSidePanelSlots.tsx b/components/terminalLayer/terminalLayerSidePanelSlots.tsx index eb7bb1135e..223e92e0a1 100644 --- a/components/terminalLayer/terminalLayerSidePanelSlots.tsx +++ b/components/terminalLayer/terminalLayerSidePanelSlots.tsx @@ -35,6 +35,7 @@ import { type SidePanelLayout, } from '../../domain/sidePanelLayout'; import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper'; +import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle'; type SidePanelStableContext = Record; const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : ''; @@ -653,6 +654,13 @@ export function SidePanelMountedContent({ ); const layouts = ctx.sidePanelLayouts as Map; const openTabs = ctx.sidePanelOpenTabs as Map; + const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as + | React.MutableRefObject> + | undefined; + const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: openTabs.has(tabId), + retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false, + }); const isToolVisible = (tabId: string, tool: SidePanelTab) => ( activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool) ); @@ -675,7 +683,7 @@ export function SidePanelMountedContent({ tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'sftp')} - ownerPanelOpen={openTabs.has(tabId)} + ownerPanelOpen={isSftpOwnerPanelOpen(tabId)} /> ))} diff --git a/components/terminalLayer/terminalLayerViewMemo.ts b/components/terminalLayer/terminalLayerViewMemo.ts index c1a631a259..2e3ef308e8 100644 --- a/components/terminalLayer/terminalLayerViewMemo.ts +++ b/components/terminalLayer/terminalLayerViewMemo.ts @@ -242,6 +242,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [ 'sidePanelOpenTabs', 'sidePanelLayouts', 'sftpHostForTab', + 'sftpRetainedAfterCloseTabIdsRef', 'effectiveHosts', 'hosts', // SFTP Connected picker reads live terminal sessions from stable ctx. From bbfa2ac6bfaadab00ed27b2c27bc06613d4a3c65 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:04:04 +0800 Subject: [PATCH 3/4] fix(sftp): preserve split-pane close lifecycle --- components/TerminalLayer.tsx | 23 +++++++- .../terminalLayer/TerminalLayerTabBridge.tsx | 2 + .../terminalLayer/sftpPanelLifecycle.test.ts | 58 +++++++++++++++++++ .../terminalLayer/sftpPanelLifecycle.ts | 12 +++- .../terminalLayerSidePanelSlots.tsx | 6 +- .../terminalLayer/terminalLayerViewMemo.ts | 1 + .../terminalLayer/useTerminalLayerEffects.ts | 19 +++++- domain/workspaceSidePanelTabRemap.test.ts | 46 +++++++++++++++ domain/workspaceSidePanelTabRemap.ts | 38 ++++++++++++ 9 files changed, 199 insertions(+), 6 deletions(-) diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 33f302dd03..70a89f821c 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -93,6 +93,7 @@ import { shouldCloseSftpSidePanel, shouldClearSftpPanelAfterTransferChange, shouldKeepSftpMountedAfterClose, + shouldMarkSftpPaneClosed, shouldScheduleSftpRetainedPanelCleanup, terminalSftpTransferOwnerId, } from './terminalLayer/sftpPanelLifecycle'; @@ -411,6 +412,7 @@ const TerminalLayerInner: React.FC = ({ lastSidePanelTabRef.current.set(tabId, targetPanel); if (targetPanel === 'sftp') { + sftpPaneClosedTabIdsRef.current.delete(tabId); const host = hostsRef.current.find(h => h.id === session.hostId); const hostWithOverrides: Host = host ? { @@ -578,6 +580,7 @@ const TerminalLayerInner: React.FC = ({ const sftpActiveTransfersByTabRef = useRef>(new Map()); const sftpActiveExternalEditsByTabRef = useRef>(new Map()); const sftpRetainedAfterCloseTabIdsRef = useRef>(new Set()); + const sftpPaneClosedTabIdsRef = useRef>(new Set()); const sftpOpeningTabIdsRef = useRef>(new Set()); const sftpRetainedCleanupTimersRef = useRef>(new Map()); const sftpLastPathForSourceRef = useRef>(new Map()); @@ -613,6 +616,7 @@ const TerminalLayerInner: React.FC = ({ sftpActiveTransfersByTabRef.current.delete(tabId); sftpActiveExternalEditsByTabRef.current.delete(tabId); sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); + sftpPaneClosedTabIdsRef.current.delete(tabId); sftpOpeningTabIdsRef.current.delete(tabId); setSftpHostForTab(prev => { if (!prev.has(tabId)) return prev; @@ -830,6 +834,7 @@ const TerminalLayerInner: React.FC = ({ sftpRetainedCleanupTimersRef.current.delete(tabId); } sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); + sftpPaneClosedTabIdsRef.current.delete(tabId); setSidePanelOpenTabs(prev => { const next = new Map(prev); @@ -1329,6 +1334,7 @@ const TerminalLayerInner: React.FC = ({ sftpRetainedCleanupTimersRef.current.delete(tabId); } sftpRetainedAfterCloseTabIdsRef.current.delete(tabId); + sftpPaneClosedTabIdsRef.current.delete(tabId); } // If switching to SFTP and no host is stored yet, resolve it @@ -1410,10 +1416,22 @@ const TerminalLayerInner: React.FC = ({ const handleCloseSidePanelPane = useCallback((paneId: string) => { const tabId = activeTabIdRef.current; if (!tabId) return; - if (closeSidePanelPaneForTab(tabId, paneId)) { + const layout = sidePanelLayoutsRef.current.get(tabId); + const closingPane = layout + ? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId) + : undefined; + const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId); + if (closesWholePanel) { handleCloseSidePanel(); + return; + } + if (shouldMarkSftpPaneClosed({ + closingPaneTool: closingPane?.tool, + closesWholePanel: closesWholePanel, + })) { + sftpPaneClosedTabIdsRef.current.add(tabId); } - }, [closeSidePanelPaneForTab, handleCloseSidePanel]); + }, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]); const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => { const tabId = activeTabIdRef.current; @@ -2155,6 +2173,7 @@ const TerminalLayerInner: React.FC = ({ sftpHostForTab, sftpInitialLocationForTab, sftpPendingUploadsForTab, + sftpPaneClosedTabIdsRef, sftpRetainedAfterCloseTabIdsRef, sftpShowHiddenFiles, SftpSidePanel, diff --git a/components/terminalLayer/TerminalLayerTabBridge.tsx b/components/terminalLayer/TerminalLayerTabBridge.tsx index 97b955f52c..c05e03be39 100644 --- a/components/terminalLayer/TerminalLayerTabBridge.tsx +++ b/components/terminalLayer/TerminalLayerTabBridge.tsx @@ -377,6 +377,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef }) sidePanelWidth: s.sidePanelWidth, sftpActiveHost, sftpHostForTab, + sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef, shouldMarkSessionActivity: s.shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef: s.splitHorizontalHandlersRef, @@ -581,6 +582,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef }) sftpFollowTerminalCwd: s.sftpFollowTerminalCwd, sftpInitialLocationForTab: s.sftpInitialLocationForTab, sftpPendingUploadsForTab: s.sftpPendingUploadsForTab, + sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef, sftpShowHiddenFiles: s.sftpShowHiddenFiles, SftpSidePanel: s.SftpSidePanel, sftpUseCompressedUpload: s.sftpUseCompressedUpload, diff --git a/components/terminalLayer/sftpPanelLifecycle.test.ts b/components/terminalLayer/sftpPanelLifecycle.test.ts index e3f6c0021b..83b835c166 100644 --- a/components/terminalLayer/sftpPanelLifecycle.test.ts +++ b/components/terminalLayer/sftpPanelLifecycle.test.ts @@ -3,6 +3,12 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import type { TransferTask } from "../../domain/models"; +import { + closeSidePanelPane, + collectSidePanelPanes, + createSidePanelLayout, + splitSidePanelPane, +} from "../../domain/sidePanelLayout.ts"; import { SFTP_TRANSFER_HISTORY_RETENTION_MS, countTransfersRetainingSftpOwner, @@ -14,6 +20,7 @@ import { shouldClearSftpPanelAfterTransferChange, shouldKeepSftpMountedAfterClose, shouldKeepSftpBrowseSessionInteractive, + shouldMarkSftpPaneClosed, shouldScheduleSftpRetainedPanelCleanup, terminalSftpTransferOwnerId, } from "./sftpPanelLifecycle.ts"; @@ -44,6 +51,15 @@ test("single-pane SFTP close uses the shared full-panel cleanup and stops openin assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/); }); +test("all SFTP reopen paths clear the split-close marker", () => { + const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8"); + const effectsSource = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8"); + + assert.match(layerSource, /if \(targetPanel === 'sftp'\) \{\s*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/); + assert.match(effectsSource, /const applySftpTargetOnTab[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/); + assert.match(effectsSource, /navigation\.kind === 'local-copy-panel'[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(currentTabId!\)/); +}); + function task( partial: Partial & Pick, ): Pick { @@ -79,14 +95,55 @@ test("closing SFTP keeps another tool from reviving its browse session", () => { assert.equal(shouldKeepSftpBrowseSessionInteractive({ sidePanelOpen: true, retainedAfterClose: false, + sftpPaneClosed: false, }), true); assert.equal(shouldKeepSftpBrowseSessionInteractive({ sidePanelOpen: true, retainedAfterClose: true, + sftpPaneClosed: false, + }), false); + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: true, + retainedAfterClose: false, + sftpPaneClosed: true, }), false); assert.equal(shouldKeepSftpBrowseSessionInteractive({ sidePanelOpen: false, retainedAfterClose: true, + sftpPaneClosed: false, + }), false); +}); + +test("closing only the SFTP split pane parks its browse session while the other pane stays open", () => { + let layout = createSidePanelLayout("sftp", "pane-sftp"); + layout = splitSidePanelPane(layout, "pane-sftp", "history", "vertical", { + paneId: "pane-history", + splitId: "split-root", + }, 400); + + const sftpPane = collectSidePanelPanes(layout.root).find((pane) => pane.tool === "sftp"); + assert.ok(sftpPane); + const remaining = closeSidePanelPane(layout, sftpPane.id); + assert.ok(remaining); + assert.deepEqual(collectSidePanelPanes(remaining.root).map((pane) => pane.tool), ["history"]); + let sftpPaneClosed = shouldMarkSftpPaneClosed({ + closingPaneTool: sftpPane.tool, + closesWholePanel: false, + }); + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: true, + retainedAfterClose: false, + sftpPaneClosed, + }), false); + sftpPaneClosed = false; + assert.equal(shouldKeepSftpBrowseSessionInteractive({ + sidePanelOpen: true, + retainedAfterClose: false, + sftpPaneClosed, + }), true); + assert.equal(shouldMarkSftpPaneClosed({ + closingPaneTool: sftpPane.tool, + closesWholePanel: true, }), false); }); @@ -254,5 +311,6 @@ test("terminal side panel reports transfer activity and uses store-backed retain assert.match(stateSource, /takeBrowseSessionsForClose/); assert.match(stateSource, /shouldRestoreBrowseSessions/); assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/); + assert.match(slotsSource, /sftpPaneClosedTabIdsRef/); assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/); }); diff --git a/components/terminalLayer/sftpPanelLifecycle.ts b/components/terminalLayer/sftpPanelLifecycle.ts index 0b1208a265..d070620748 100644 --- a/components/terminalLayer/sftpPanelLifecycle.ts +++ b/components/terminalLayer/sftpPanelLifecycle.ts @@ -73,8 +73,18 @@ export function shouldKeepSftpMountedAfterClose(params: { export function shouldKeepSftpBrowseSessionInteractive(params: { sidePanelOpen: boolean; retainedAfterClose: boolean; + sftpPaneClosed: boolean; }): boolean { - return params.sidePanelOpen && !params.retainedAfterClose; + return params.sidePanelOpen + && !params.retainedAfterClose + && !params.sftpPaneClosed; +} + +export function shouldMarkSftpPaneClosed(params: { + closingPaneTool: string | null | undefined; + closesWholePanel: boolean; +}): boolean { + return !params.closesWholePanel && params.closingPaneTool === 'sftp'; } export function shouldCloseSftpSidePanel(params: { diff --git a/components/terminalLayer/terminalLayerSidePanelSlots.tsx b/components/terminalLayer/terminalLayerSidePanelSlots.tsx index 223e92e0a1..d85634b339 100644 --- a/components/terminalLayer/terminalLayerSidePanelSlots.tsx +++ b/components/terminalLayer/terminalLayerSidePanelSlots.tsx @@ -37,7 +37,9 @@ import { import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper'; import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle'; -type SidePanelStableContext = Record; +type SidePanelStableContext = Record & { + sftpPaneClosedTabIdsRef: React.MutableRefObject>; +}; const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : ''; const EMPTY_VAULT_NOTES: never[] = []; const EMPTY_VAULT_HOSTS: never[] = []; @@ -657,9 +659,11 @@ export function SidePanelMountedContent({ 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) diff --git a/components/terminalLayer/terminalLayerViewMemo.ts b/components/terminalLayer/terminalLayerViewMemo.ts index 2e3ef308e8..8a32cf5d2f 100644 --- a/components/terminalLayer/terminalLayerViewMemo.ts +++ b/components/terminalLayer/terminalLayerViewMemo.ts @@ -242,6 +242,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [ 'sidePanelOpenTabs', 'sidePanelLayouts', 'sftpHostForTab', + 'sftpPaneClosedTabIdsRef', 'sftpRetainedAfterCloseTabIdsRef', 'effectiveHosts', 'hosts', diff --git a/components/terminalLayer/useTerminalLayerEffects.ts b/components/terminalLayer/useTerminalLayerEffects.ts index f13b7a83fb..d39d5e0702 100644 --- a/components/terminalLayer/useTerminalLayerEffects.ts +++ b/components/terminalLayer/useTerminalLayerEffects.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */ import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; +import type { MutableRefObject } from 'react'; import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore'; import { terminalCwdStore } from '../../application/state/terminalCwdStore'; @@ -15,6 +16,7 @@ import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/side import { collectSessionIds } from '../../domain/workspace'; import { moveSidePanelTabMap, + moveSidePanelTabSet, remapMountedSidePanelTabIds, remapSidePanelTabMap, type SidePanelTabRemap, @@ -23,7 +25,9 @@ import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics'; import { toast } from '../ui/toast'; import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection'; -type TerminalLayerEffectsContext = Record; +type TerminalLayerEffectsContext = Record & { + sftpPaneClosedTabIdsRef: MutableRefObject>; +}; type RuntimeStateRef = { current: Map }; @@ -100,7 +104,7 @@ export function pruneTerminalTabMemoryState( export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) { const { openPath } = useSftpBackend(); - const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx; + const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx; const activeWorkspaceId = activeWorkspace?.id; const activeWorkspaceViewMode = activeWorkspace?.viewMode; @@ -205,6 +209,15 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) { } return next; }); + let sftpOwners = sftpHostForTab as ReadonlyMap; + for (const remap of remaps) { + sftpPaneClosedTabIdsRef.current = moveSidePanelTabSet( + sftpPaneClosedTabIdsRef.current, + remap, + { ownerTabIds: new Set(sftpOwners.keys()) }, + ); + sftpOwners = moveSidePanelTabMap(sftpOwners, remap); + } setAiMountedTabIds((prev: string[]) => { let next = prev; for (const remap of remaps) { @@ -495,6 +508,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) { useEffect(() => { const applySftpTargetOnTab = (tabId: string, host: any, targetDirectory: string) => { + sftpPaneClosedTabIdsRef.current.delete(tabId); // Bump initialLocation even when the host is already selected so the // path-navigation effect re-runs after reopen. setSftpHostForTab((prev: Map) => new Map(prev).set(tabId, host)); @@ -577,6 +591,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) { toast.error('Open a terminal tab first to browse this transfer', 'SFTP'); return; } + sftpPaneClosedTabIdsRef.current.delete(currentTabId!); setSidePanelOpenTabs((prev: Map) => new Map(prev).set(currentTabId!, 'sftp')); return; } diff --git a/domain/workspaceSidePanelTabRemap.test.ts b/domain/workspaceSidePanelTabRemap.test.ts index 10cf26d33e..7ba8e60bc3 100644 --- a/domain/workspaceSidePanelTabRemap.test.ts +++ b/domain/workspaceSidePanelTabRemap.test.ts @@ -3,6 +3,7 @@ import test from 'node:test'; import { moveSidePanelTabMap, + moveSidePanelTabSet, remapMountedSidePanelTabIds, remapSidePanelTabMap, } from './workspaceSidePanelTabRemap.ts'; @@ -145,6 +146,51 @@ test('moveSidePanelTabMap demote relocates the workspace mount onto the survivor ); }); +test('moveSidePanelTabSet promotes and demotes the closed-pane marker with ownership', () => { + const promoted = moveSidePanelTabSet(new Set(['term-b']), { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }); + assert.deepEqual([...promoted], ['ws-1']); + + const demoted = moveSidePanelTabSet(new Set(['ws-1']), { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + }); + assert.deepEqual([...demoted], ['term-a']); +}); + +test('moveSidePanelTabSet does not bind a marker to a different promoted SFTP owner', () => { + const marker = moveSidePanelTabSet(new Set(['term-b']), { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }, { ownerTabIds: new Set(['term-a', 'term-b']) }); + + assert.deepEqual([...marker], ['term-b']); + + const preferredMarker = moveSidePanelTabSet(new Set(['term-a']), { + kind: 'promote', + fromTabIds: ['term-a', 'term-b'], + toTabId: 'ws-1', + preferredFromTabId: 'term-a', + }, { ownerTabIds: new Set(['term-a', 'term-b']) }); + assert.deepEqual([...preferredMarker], ['ws-1']); + + const nonOwnerDemote = moveSidePanelTabSet(new Set(['term-b']), { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + }, { ownerTabIds: new Set(['term-a']) }); + assert.deepEqual([...nonOwnerDemote], ['term-b']); +}); + test('mounted tab ids gain the destination tab when the source was mounted', () => { assert.deepEqual( remapMountedSidePanelTabIds(['term-a'], { diff --git a/domain/workspaceSidePanelTabRemap.ts b/domain/workspaceSidePanelTabRemap.ts index 497759e6a6..e674f68890 100644 --- a/domain/workspaceSidePanelTabRemap.ts +++ b/domain/workspaceSidePanelTabRemap.ts @@ -134,6 +134,44 @@ export function moveSidePanelTabMap( return next; } +/** + * Move a tab-owned marker across the same workspace/session remaps as the + * ownership maps. Markers are not cloned: after a promote or demote, the old + * tab id must not keep an obsolete lifecycle decision alive. + */ +export function moveSidePanelTabSet( + source: ReadonlySet, + remap: SidePanelTabRemap, + options?: { ownerTabIds?: ReadonlySet }, +): Set { + const next = new Set(source); + const ownerTabIds = options?.ownerTabIds ?? source; + + if (remap.kind === 'promote') { + if (ownerTabIds.has(remap.toTabId)) { + for (const tabId of remap.fromTabIds) next.delete(tabId); + return next; + } + const fromId = pickPreferredSourceId( + remap.fromTabIds, + remap.preferredFromTabId, + (tabId) => ownerTabIds.has(tabId), + ); + if (fromId && source.has(fromId)) next.add(remap.toTabId); + if (fromId) next.delete(fromId); + return next; + } + + if (!ownerTabIds.has(remap.fromTabId)) return next; + const preferredTo = remap.preferredToTabId + && remap.toTabIds.includes(remap.preferredToTabId) + ? remap.preferredToTabId + : remap.toTabIds.find(Boolean); + if (preferredTo) next.add(preferredTo); + next.delete(remap.fromTabId); + return next; +} + export function remapMountedSidePanelTabIds( mountedTabIds: readonly string[], remap: SidePanelTabRemap, From 43534c34f1f41bf4d4f82be9d4d8d5bacb6e054c Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:12:44 +0800 Subject: [PATCH 4/4] fix(sftp): preserve absent close markers on remap --- domain/workspaceSidePanelTabRemap.test.ts | 8 ++++++++ domain/workspaceSidePanelTabRemap.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/domain/workspaceSidePanelTabRemap.test.ts b/domain/workspaceSidePanelTabRemap.test.ts index 7ba8e60bc3..4e68346ff9 100644 --- a/domain/workspaceSidePanelTabRemap.test.ts +++ b/domain/workspaceSidePanelTabRemap.test.ts @@ -189,6 +189,14 @@ test('moveSidePanelTabSet does not bind a marker to a different promoted SFTP ow preferredToTabId: 'term-a', }, { ownerTabIds: new Set(['term-a']) }); assert.deepEqual([...nonOwnerDemote], ['term-b']); + + const absentMarkerDemote = moveSidePanelTabSet(new Set(), { + kind: 'demote', + fromTabId: 'ws-1', + toTabIds: ['term-a', 'term-b'], + preferredToTabId: 'term-a', + }, { ownerTabIds: new Set(['ws-1']) }); + assert.deepEqual([...absentMarkerDemote], []); }); test('mounted tab ids gain the destination tab when the source was mounted', () => { diff --git a/domain/workspaceSidePanelTabRemap.ts b/domain/workspaceSidePanelTabRemap.ts index e674f68890..ba11179295 100644 --- a/domain/workspaceSidePanelTabRemap.ts +++ b/domain/workspaceSidePanelTabRemap.ts @@ -167,7 +167,7 @@ export function moveSidePanelTabSet( && remap.toTabIds.includes(remap.preferredToTabId) ? remap.preferredToTabId : remap.toTabIds.find(Boolean); - if (preferredTo) next.add(preferredTo); + if (source.has(remap.fromTabId) && preferredTo) next.add(preferredTo); next.delete(remap.fromTabId); return next; }