mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 14:30:13 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37d3a6dea1 | ||
|
|
05f1963eed | ||
|
|
16dfb46662 | ||
|
|
9edc428249 |
No files matched your search
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user