Compare commits

...
4 changed files with 320 additions and 80 deletions

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const source = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "SftpSidePanel.tsx"),
"utf8",
);
test("terminal SFTP side panel renders remote and local panes together", () => {
assert.match(source, /data-sftp-side="left"/);
assert.match(source, /data-sftp-side="right"/);
assert.match(source, /shouldEnsureSftpSidePanelCompanionLocal/);
assert.match(source, /rightPanes\.map/);
// Panel-width container query (not viewport min-*), so narrow side panels stack.
// @container must be on an ancestor of the @min-* flex element.
assert.match(
source,
/className="@container relative flex-1 min-h-0"[\s\S]*?className="h-full min-h-0 flex flex-col @min-\[420px\]:flex-row"/,
);
});
test("transfer queue scopes local downloads to active remotes and reveals either pane", () => {
assert.match(source, /matchesActiveRemoteSource/);
assert.match(
source,
/\(task\.targetConnectionId === "local" \|\| task\.targetConnectionId === connection\.id\)\s*&&\s*matchesActiveRemoteSource/,
);
assert.match(source, /findRemoteTransferTargetTab/);
assert.match(source, /isLocalFilesystemTransferTarget/);
assert.match(source, /pane\?\.connection\?\.isLocal/);
assert.match(source, /navigateTo\(targetTab\.side, revealPath/);
});
+207 -80
View File
@@ -2,7 +2,8 @@
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
* Reuses SftpView's components (SftpPaneView, SftpContextProvider, etc.)
* to provide a unified SFTP experience. Renders a single pane (left side only).
* to provide a unified SFTP experience. Renders remote (left) + local (right)
* so transfers work beside the terminal without opening the full SFTP page.
*
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
* the main SftpView tab. Instead manages pane visibility internally.
@@ -91,6 +92,7 @@ import {
resolveSftpSidePanelTrackedSourceStatusUpdate,
shouldAcceptPendingSftpUpload,
shouldDeferSftpSidePanelAutoConnectForSession,
shouldEnsureSftpSidePanelCompanionLocal,
shouldRebindSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
} from "./sftp/sftpSidePanelAutoConnect";
@@ -331,7 +333,34 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
const { getConnectionCacheKey, leftPane } = sftp;
const { getConnectionCacheKey, leftPane, rightPane } = sftp;
const ensureCompanionLocal = useCallback(() => {
const s = sftpRef.current;
if (
!shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: s.leftPane.connection,
companionConnection: s.rightPane.connection,
})
) {
return;
}
const rightConn = s.rightPane.connection;
if (rightConn) {
void s.disconnect("right").then(() => {
if (
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: sftpRef.current.leftPane.connection,
companionConnection: sftpRef.current.rightPane.connection,
})
) {
sftpRef.current.connect("right", "local");
}
});
return;
}
s.connect("right", "local");
}, []);
useEffect(() => {
/** Per-task locks so resume-all can prepare multiple transfers sequentially. */
@@ -632,6 +661,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
activeSessionId,
activeSessionStatus,
});
ensureCompanionLocal();
return;
}
// Defer advancing the session cursor while interactive work blocks rebind,
@@ -691,6 +721,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
path,
});
}
ensureCompanionLocal();
return;
}
@@ -784,7 +815,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, activeSessionId, initialLocation, interactiveWorkActive, sessions]);
ensureCompanionLocal();
}, [activeHost, activeSessionId, ensureCompanionLocal, initialLocation, interactiveWorkActive, sessions]);
useEffect(() => {
if (!activeHost || !isVisible) return;
@@ -799,6 +831,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
};
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
// Remote connect() is async; open the local companion once the left pane is live.
useEffect(() => {
if (!isVisible) return;
ensureCompanionLocal();
}, [
ensureCompanionLocal,
isVisible,
leftPane.connection?.isLocal,
leftPane.connection?.status,
rightPane.connection?.isLocal,
rightPane.connection?.status,
]);
useEffect(() => {
if (activeSessionId) return;
const trackedSessionId = lastSourceSessionIdRef.current;
@@ -991,7 +1036,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
behaviorRef={behaviorRef}
autoSyncRef={autoSyncRef}
connectedHostObjRef={connectedHostObjRef}
connectedKeyRef={connectedKeyRef}
onInteractiveWorkChange={setInteractiveWorkActive}
listSftp={listSftp}
mkdirLocal={mkdirLocal}
@@ -1039,7 +1083,6 @@ type SftpSidePanelInteractiveBodyProps = {
behaviorRef: MutableRefObject<"open" | "transfer">;
autoSyncRef: MutableRefObject<boolean>;
connectedHostObjRef: MutableRefObject<Host | null>;
connectedKeyRef: MutableRefObject<string | null>;
onInteractiveWorkChange: (active: boolean) => void;
listSftp: ReturnType<typeof useSftpBackend>["listSftp"];
mkdirLocal: ReturnType<typeof useSftpBackend>["mkdirLocal"];
@@ -1079,7 +1122,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
behaviorRef,
autoSyncRef,
connectedHostObjRef,
connectedKeyRef,
onInteractiveWorkChange,
listSftp,
mkdirLocal,
@@ -1095,6 +1137,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const terminalBackend = useTerminalBackend();
const [hasPaneFocus, setHasPaneFocus] = useState(false);
const [focusedSide, setFocusedSide] = useState<"left" | "right">("left");
const [pendingFollowOverride, setPendingFollowOverride] = useState<{
hostId: string;
value: boolean;
@@ -1112,24 +1155,26 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
const tabs = side === "left" ? sftpRef.current.leftTabs.tabs : sftpRef.current.rightTabs.tabs;
const pane = tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles);
}, [sftpRef]);
const syncFocusedSelection = useCallback((tabId: string | null) => {
const syncFocusedSelection = useCallback((side: "left" | "right", tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
keepOnlyPaneSelections(sftpRef.current, { side, tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, [sftpRef]);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
const handlePaneFocus = useCallback((side: "left" | "right") => {
sftpFocusStore.setFocusedSide(side);
setFocusedSide(side);
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
syncFocusedSelection(side, sftpRef.current.getActiveTabId(side));
}, [sftpRef, syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
@@ -1148,12 +1193,15 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
const sideAttr = elementTarget?.closest("[data-sftp-side]")?.getAttribute("data-sftp-side");
const side: "left" | "right" = sideAttr === "right" ? "right" : "left";
sftpFocusStore.setFocusedSide(side);
setFocusedSide(side);
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
syncFocusedSelection(side, sftpRef.current.getActiveTabId(side));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
syncFocusedSelection("left", null);
}
};
@@ -1203,6 +1251,7 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const {
leftPanes,
rightPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
@@ -1693,27 +1742,81 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(() => {
const connection = sftp.leftPane.connection;
if (!connection) return [];
// Filter transfers to those relevant to the active connection's host,
const leftConnection = sftp.leftPane.connection;
const rightConnection = sftp.rightPane.connection;
if (!leftConnection && !rightConnection) return [];
const panes = [leftConnection, rightConnection];
const remotes = panes.filter((connection): connection is NonNullable<typeof connection> => (
!!connection && !connection.isLocal
));
// Filter transfers to those relevant to the active connections' hosts,
// so workspace focus switches don't show transfers from other hosts.
const filtered = sftp.transfers.filter((t) => {
if (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
if (connection.isLocal) {
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
const filtered = sftp.transfers.filter((task) => {
if (task.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
const matchesActiveRemoteSource = remotes.some((remote) => (
task.sourceConnectionId === remote.id
|| (task.sourceHostId != null && task.sourceHostId === remote.hostId)
));
for (const connection of panes) {
if (!connection) continue;
if (connection.isLocal) {
if (task.sourceConnectionId === connection.id) {
return true;
}
// Downloads may use the shared "local" sentinel or the companion local
// pane UUID. Require an active remote source either way so another
// host's history does not leak after the left pane switches hosts.
if (
(task.targetConnectionId === "local" || task.targetConnectionId === connection.id)
&& matchesActiveRemoteSource
) {
return true;
}
continue;
}
if (
task.targetHostId === connection.hostId
|| task.sourceConnectionId === connection.id
|| task.targetConnectionId === connection.id
) {
return true;
}
}
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
return false;
});
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
}, [sftp.transfers, sftp.leftPane.connection]);
}, [sftp.transfers, sftp.leftPane.connection, sftp.rightPane.connection]);
const findRemoteTransferTargetTab = useCallback((task: TransferTask) => {
const state = sftpRef.current;
for (const side of ["left", "right"] as const) {
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
if (pane?.connection && !pane.connection.isLocal) {
return { side, tabId: pane.id };
}
}
return null;
}, [sftpRef]);
// Dual-pane downloads store the local pane connection UUID, not the "local" sentinel.
const isLocalFilesystemTransferTarget = useCallback((task: TransferTask) => {
if (task.targetConnectionId === "local") return true;
const state = sftpRef.current;
for (const side of ["left", "right"] as const) {
const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs;
const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId);
if (pane?.connection?.isLocal) return true;
}
return false;
}, [sftpRef]);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
if (!isConcreteTransferTargetPath(task)) return;
const connection = sftpRef.current.leftPane.connection;
const revealPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
if (task.targetConnectionId === "local") {
if (isLocalFilesystemTransferTarget(task)) {
try {
const result = await openPath(revealPath);
if (result.success) return;
@@ -1724,38 +1827,26 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
return;
}
if (!connection || connection.isLocal) return;
await sftpRef.current.navigateTo("left", revealPath, { force: true });
const targetTab = findRemoteTransferTargetTab(task);
if (!targetTab) return;
await sftpRef.current.navigateTo(targetTab.side, revealPath, {
force: true,
tabId: targetTab.tabId,
});
},
[openPath, sftpRef, t],
[findRemoteTransferTargetTab, isLocalFilesystemTransferTarget, openPath, sftpRef, t],
);
const canRevealTransferTarget = useCallback(
(task: TransferTask) => {
if (task.status !== "completed") return false;
if (!isConcreteTransferTargetPath(task)) return false;
if (task.targetConnectionId === "local") {
if (isLocalFilesystemTransferTarget(task)) {
return true;
}
if (task.direction !== "upload" && task.direction !== "remote-to-remote") return false;
const connection = sftp.leftPane.connection;
if (!connection || connection.isLocal) return false;
if (task.targetHostId) {
if (connection.hostId !== task.targetHostId) return false;
// If the transfer recorded a full endpoint key, use it to
// distinguish same-hostId uploads with different session overrides.
if (task.targetConnectionKey) {
return connectedKeyRef.current === task.targetConnectionKey;
}
return true;
}
return connection.id === task.targetConnectionId;
return !!findRemoteTransferTargetTab(task);
},
[connectedKeyRef, sftp.leftPane.connection],
[findRemoteTransferTargetTab, isLocalFilesystemTransferTarget],
);
const canCopyTransferTargetPath = useCallback(
@@ -1776,8 +1867,9 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
[t],
);
// Determine the active pane to render (without using global activeTabStore)
// Determine the active panes to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
const activeRightPaneId = sftp.rightTabs.activeTabId;
return (
<SftpContextProvider
@@ -1794,7 +1886,6 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div
@@ -1827,35 +1918,71 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
</div>
</div>
)}
{/* File browser pane - render only the active pane */}
<div className="relative flex-1 min-h-0">
{leftPanes.map((pane, idx) => {
// Manage visibility locally instead of via activeTabStore
const isActive = activeLeftPaneId
? pane.id === activeLeftPaneId
: idx === 0;
if (!isActive) return null;
{/* Remote (left) + local (right): stack when panel is narrow, side-by-side when wide.
Outer establishes @container; inner uses @min-* (queries an ancestor, not self). */}
<div className="@container relative flex-1 min-h-0">
<div className="h-full min-h-0 flex flex-col @min-[420px]:flex-row">
<div
data-sftp-side="left"
className="relative flex-1 min-h-0 border-b @min-[420px]:border-b-0 @min-[420px]:border-r border-border/70"
onClick={() => handlePaneFocus("left")}
>
{leftPanes.map((pane, idx) => {
const isActive = activeLeftPaneId
? pane.id === activeLeftPaneId
: idx === 0;
if (!isActive) return null;
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : undefined}
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
</div>
);
})}
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={hasPaneFocus && focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : undefined}
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
</div>
);
})}
</div>
<div
data-sftp-side="right"
className="relative flex-1 min-h-0"
onClick={() => handlePaneFocus("right")}
>
{rightPanes.map((pane, idx) => {
const isActive = activeRightPaneId
? pane.id === activeRightPaneId
: idx === 0;
if (!isActive) return null;
return (
<div key={pane.id} className="absolute inset-0 z-10">
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={hasPaneFocus && focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
/>
</div>
);
})}
</div>
</div>
</div>
<SftpTransferQueue
sftp={sftp}
@@ -10,6 +10,7 @@ import {
resolveSftpSidePanelTrackedSourceStatusUpdate,
shouldAcceptPendingSftpUpload,
shouldDeferSftpSidePanelAutoConnectForSession,
shouldEnsureSftpSidePanelCompanionLocal,
shouldRebindSftpSidePanelSourceSession,
shouldResetSftpSidePanelSourceSession,
shouldSkipSftpSidePanelAutoConnect,
@@ -372,3 +373,55 @@ test("session change still requires rebind even when the endpoint key matches",
tab,
);
});
test("shouldEnsureSftpSidePanelCompanionLocal opens local beside a connected remote", () => {
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connected" },
companionConnection: null,
}),
true,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connected" },
companionConnection: { isLocal: true, status: "disconnected" },
}),
true,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connected" },
companionConnection: { isLocal: true, status: "connected" },
}),
false,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connected" },
companionConnection: { isLocal: true, status: "connecting" },
}),
false,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connecting" },
companionConnection: null,
}),
false,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: true, status: "connected" },
companionConnection: null,
}),
false,
);
assert.equal(
shouldEnsureSftpSidePanelCompanionLocal({
remoteConnection: { isLocal: false, status: "connected" },
companionConnection: { isLocal: false, status: "connected" },
}),
false,
);
});
@@ -198,3 +198,28 @@ export function rememberSftpSidePanelSourceStatus(params: {
}
return params.previousStatus ?? null;
}
type CompanionConnectionHealth = {
isLocal?: boolean;
status?: string;
} | null | undefined;
/**
* Terminal side SFTP keeps the remote browse pane on the left. Open a local
* companion on the right so users can transfer without leaving the terminal.
* Never clobber an already-connected remote companion or an in-flight local.
*/
export function shouldEnsureSftpSidePanelCompanionLocal(params: {
remoteConnection: CompanionConnectionHealth;
companionConnection: CompanionConnectionHealth;
}): boolean {
const remote = params.remoteConnection;
if (!remote || remote.isLocal || remote.status !== "connected") return false;
const companion = params.companionConnection;
if (!companion) return true;
if (companion.status === "connecting") return false;
if (companion.isLocal && companion.status === "connected") return false;
if (!companion.isLocal && companion.status === "connected") return false;
return true;
}