Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions application/state/sftp/browseSessionLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 12 additions & 3 deletions application/state/sftp/browseSessionLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
9 changes: 5 additions & 4 deletions application/state/useSftpState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions components/SftpSidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,6 +182,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onActiveExternalEditsChange,
showWorkspaceHostHeader = false,
isVisible = true,
ownerPanelOpen = false,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
Expand Down Expand Up @@ -258,8 +264,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// 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,
}),
Expand All @@ -275,6 +284,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
fileWatchHandlers,
hasOwnedEditorTab,
isVisible,
ownerPanelOpen,
transferOwnerId,
sftpUseCompressedUpload,
sftpShowHiddenFiles,
Expand Down Expand Up @@ -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 &&
Expand Down
5 changes: 3 additions & 2 deletions components/SftpView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...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,
Expand Down
24 changes: 22 additions & 2 deletions components/TerminalLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from './terminalLayer/sftpPanelLifecycle';
Expand Down Expand Up @@ -411,6 +412,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
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
? {
Expand Down Expand Up @@ -578,6 +580,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
const sftpPaneClosedTabIdsRef = useRef<Set<string>>(new Set());
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
const sftpLastPathForSourceRef = useRef<Map<string, SftpRememberedLocation>>(new Map());
Expand Down Expand Up @@ -613,6 +616,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
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;
Expand Down Expand Up @@ -830,6 +834,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);

setSidePanelOpenTabs(prev => {
const next = new Map(prev);
Expand Down Expand Up @@ -1329,6 +1334,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
}

// If switching to SFTP and no host is stored yet, resolve it
Expand Down Expand Up @@ -1410,10 +1416,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
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;
Expand Down Expand Up @@ -2155,6 +2173,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
sftpRetainedAfterCloseTabIdsRef,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
Expand Down
2 changes: 2 additions & 0 deletions components/terminalLayer/TerminalLayerTabBridge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
83 changes: 81 additions & 2 deletions components/terminalLayer/sftpPanelLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -13,6 +19,8 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldKeepSftpBrowseSessionInteractive,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from "./sftpPanelLifecycle.ts";
Expand Down Expand Up @@ -43,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<TransferTask> & Pick<TransferTask, "id" | "status">,
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
Expand Down Expand Up @@ -74,6 +91,62 @@ 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,
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);
});

test("a transfer retained by close keeps its history after completion", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
Expand Down Expand Up @@ -214,6 +287,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/);
Expand All @@ -228,10 +302,15 @@ 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, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(slotsSource, /sftpPaneClosedTabIdsRef/);
assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/);
});
22 changes: 22 additions & 0 deletions components/terminalLayer/sftpPanelLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ 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;
sftpPaneClosed: boolean;
}): boolean {
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: {
shouldKeepOpen: boolean;
isOpen: boolean;
Expand Down
Loading