mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
1225 lines
44 KiB
TypeScript
1225 lines
44 KiB
TypeScript
/**
|
|
* 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).
|
|
*
|
|
* IMPORTANT: Does NOT use the global activeTabStore to avoid conflicts with
|
|
* the main SftpView tab. Instead manages pane visibility internally.
|
|
*
|
|
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
|
|
*/
|
|
|
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
|
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
|
import { formatHostPort } from "../domain/host";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useSftpState } from "../application/state/useSftpState";
|
|
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
|
import { editorTabStore } from "../application/state/editorTabStore";
|
|
import { releaseEditorTabSaveCoordinator } from "../application/state/editorTabSave";
|
|
import { useSftpBackend } from "../application/state/useSftpBackend";
|
|
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
|
import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils";
|
|
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
|
|
import { logger } from "../lib/logger";
|
|
import type { DropEntry } from "../lib/sftpFileUtils";
|
|
import { Host, Identity, KnownHost, SSHKey } from "../types";
|
|
import type { TransferTask } from "../types";
|
|
import { toast } from "./ui/toast";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
|
import { DistroAvatar } from "./DistroAvatar";
|
|
|
|
import { SftpPaneView } from "./sftp/SftpPaneView";
|
|
import { SftpOverlays } from "./sftp/SftpOverlays";
|
|
import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
|
|
import { SftpContextProvider } from "./sftp";
|
|
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
|
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
|
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
|
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
|
|
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
|
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
|
import {
|
|
mergeLatestFollowTerminalCwdHostSetting,
|
|
resolveHostFollowTerminalCwd,
|
|
shouldApplyFollowTerminalCwdSyncResult,
|
|
shouldClearBlockedFollowOnReach,
|
|
shouldFollowTerminalCwdNavigate,
|
|
type SftpFollowTerminalCwdBlock,
|
|
} from "./sftp/sftpFollowTerminalCwd";
|
|
import {
|
|
findReusableSftpSidePanelTab,
|
|
shouldResetSftpSidePanelSourceSession,
|
|
shouldSkipSftpSidePanelAutoConnect,
|
|
} from "./sftp/sftpSidePanelAutoConnect";
|
|
|
|
interface SftpSidePanelProps {
|
|
hosts: Host[];
|
|
writableHosts?: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
knownHosts?: KnownHost[];
|
|
updateHosts: (hosts: Host[]) => void;
|
|
onAddKnownHost?: (knownHost: KnownHost) => void;
|
|
sftpDefaultViewMode: "list" | "tree";
|
|
/** The host to connect to (follows focused terminal) */
|
|
activeHost: Host | null;
|
|
/** The terminal session id whose SSH connection can be reused for SFTP */
|
|
activeSessionId?: string | null;
|
|
initialLocation?: { hostId: string; path: string } | null;
|
|
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
|
onCurrentPathChange?: (location: { hostId: string; connectionKey: string; path: string }) => void;
|
|
showWorkspaceHostHeader?: boolean;
|
|
isVisible?: boolean;
|
|
renderOverlays?: boolean;
|
|
pendingUpload?: {
|
|
requestId: string;
|
|
hostId: string;
|
|
connectionKey: string;
|
|
targetPath?: string;
|
|
entries: DropEntry[];
|
|
} | null;
|
|
onPendingUploadHandled?: (requestId: string) => void;
|
|
sftpDoubleClickBehavior: "open" | "transfer";
|
|
sftpAutoSync: boolean;
|
|
sftpShowHiddenFiles: boolean;
|
|
sftpUseCompressedUpload: boolean;
|
|
hotkeyScheme: HotkeyScheme;
|
|
keyBindings: KeyBinding[];
|
|
editorWordWrap: boolean;
|
|
setEditorWordWrap: (value: boolean) => void;
|
|
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
|
|
activeTerminalCwd?: string | null;
|
|
sftpFollowTerminalCwd?: boolean;
|
|
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
|
onRequestTerminalFocus?: () => void;
|
|
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
|
}
|
|
|
|
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
|
hosts,
|
|
writableHosts,
|
|
keys,
|
|
identities,
|
|
knownHosts = [],
|
|
updateHosts,
|
|
onAddKnownHost,
|
|
sftpDefaultViewMode,
|
|
activeHost,
|
|
activeSessionId,
|
|
initialLocation,
|
|
onInitialLocationApplied,
|
|
onCurrentPathChange,
|
|
showWorkspaceHostHeader = false,
|
|
isVisible = true,
|
|
renderOverlays = true,
|
|
pendingUpload = null,
|
|
onPendingUploadHandled,
|
|
sftpDoubleClickBehavior,
|
|
sftpAutoSync,
|
|
sftpShowHiddenFiles,
|
|
sftpUseCompressedUpload,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
editorWordWrap,
|
|
setEditorWordWrap,
|
|
onGetTerminalCwd,
|
|
activeTerminalCwd = null,
|
|
sftpFollowTerminalCwd = false,
|
|
onSftpFollowTerminalCwdChange,
|
|
onRequestTerminalFocus,
|
|
terminalSettings,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const hostWriteSource = writableHosts ?? hosts;
|
|
|
|
const fileWatchHandlers = useMemo(() => ({
|
|
onFileWatchSynced: (payload: { remotePath: string }) => {
|
|
const fileName = payload.remotePath.split('/').pop() || payload.remotePath;
|
|
toast.success(t('sftp.autoSync.success', { fileName }));
|
|
logger.info("[SFTP] File auto-synced to remote", payload);
|
|
},
|
|
onFileWatchError: (payload: { error: string }) => {
|
|
toast.error(t('sftp.autoSync.error', { error: payload.error }));
|
|
logger.error("[SFTP] File auto-sync failed", payload);
|
|
},
|
|
}), [t]);
|
|
|
|
const sftpOptions = useMemo(() => ({
|
|
...fileWatchHandlers,
|
|
useCompressedUpload: sftpUseCompressedUpload,
|
|
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
|
autoConnectLocalOnMount: false,
|
|
terminalSettings,
|
|
knownHosts,
|
|
onAddKnownHost,
|
|
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
|
|
|
|
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
|
const {
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
startStreamTransfer,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
listLocalDir,
|
|
listDrives,
|
|
openPath,
|
|
} = useSftpBackend();
|
|
|
|
const sftpRef = useRef(sftp);
|
|
sftpRef.current = sftp;
|
|
|
|
// Register this instance's writeTextFileByConnection with the editor bridge
|
|
// so editor tabs promoted from SFTP files opened in a terminal side panel
|
|
// can still route saves through this useSftpState.
|
|
//
|
|
// Intentionally no deps — go through sftpRef so SFTP state churn (transfers,
|
|
// tab switches, listings) doesn't make this unregister+reregister on every
|
|
// re-render.
|
|
useEffect(() => {
|
|
return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding) =>
|
|
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
|
);
|
|
}, []);
|
|
|
|
// When this side panel unmounts (its hosting terminal tab was closed) we
|
|
// force-close any editor tabs bound to connections this panel owned — the
|
|
// save channel is gone with the SFTP session and there's no way to recover
|
|
// it. Dirty state is dropped intentionally; the user closed the terminal
|
|
// knowing the file was open.
|
|
//
|
|
// Collect every connection id across all left/right tabs — the panel can
|
|
// host multiple SFTP tabs per side, and an editor tab promoted from an
|
|
// inactive-pane tab would otherwise be stranded by the unmount.
|
|
useEffect(() => {
|
|
return () => {
|
|
const s = sftpRef.current;
|
|
if (!s) return;
|
|
const owned = new Set<string>();
|
|
for (const tab of s.leftTabs?.tabs ?? []) {
|
|
const id = tab.connection?.id;
|
|
if (id) owned.add(id);
|
|
}
|
|
for (const tab of s.rightTabs?.tabs ?? []) {
|
|
const id = tab.connection?.id;
|
|
if (id) owned.add(id);
|
|
}
|
|
if (owned.size === 0) return;
|
|
const closed = editorTabStore.forceCloseBySessions([...owned]);
|
|
closed.forEach(releaseEditorTabSaveCoordinator);
|
|
};
|
|
}, []);
|
|
|
|
const behaviorRef = useRef(sftpDoubleClickBehavior);
|
|
behaviorRef.current = sftpDoubleClickBehavior;
|
|
|
|
const autoSyncRef = useRef(sftpAutoSync);
|
|
autoSyncRef.current = sftpAutoSync;
|
|
|
|
const connectedKeyRef = useRef<string | null>(null);
|
|
const connectedHostObjRef = useRef<Host | null>(null);
|
|
const lastSourceSessionIdRef = useRef<string | null>(null);
|
|
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
|
const handledPendingUploadIdRef = useRef<string | null>(null);
|
|
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
|
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
|
|
const [sftpUiReady, setSftpUiReady] = useState(false);
|
|
|
|
const runAutoConnect = useCallback(() => {
|
|
if (!activeHost) return;
|
|
|
|
const s = sftpRef.current;
|
|
const hasActiveWork = interactiveWorkActive
|
|
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
|
|
|
|
const proto = activeHost.protocol;
|
|
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
|
|
connectedKeyRef.current = null;
|
|
return;
|
|
}
|
|
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
|
|
if (hasActiveWork) return;
|
|
const leftConn = s.leftPane.connection;
|
|
if (leftConn?.isLocal) {
|
|
connectedKeyRef.current = "local";
|
|
return;
|
|
}
|
|
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
|
|
tab.connection?.isLocal && tab.connection.status === "connected",
|
|
);
|
|
if (existingLocalTab) {
|
|
s.selectTab("left", existingLocalTab.id);
|
|
connectedKeyRef.current = "local";
|
|
return;
|
|
}
|
|
connectedKeyRef.current = "local";
|
|
const needsNewTab = !!(leftConn && leftConn.status === "connected");
|
|
if (needsNewTab) {
|
|
s.connect("left", "local", { forceNewTab: true });
|
|
} else if (leftConn) {
|
|
void s.disconnect("left").then(() => s.connect("left", "local"));
|
|
} else {
|
|
s.connect("left", "local");
|
|
}
|
|
return;
|
|
}
|
|
|
|
const connectionKey = buildCacheKey(
|
|
activeHost.id,
|
|
activeHost.hostname,
|
|
activeHost.port,
|
|
activeHost.protocol,
|
|
activeHost.sftpSudo,
|
|
activeHost.username,
|
|
);
|
|
const sessionChanged = shouldResetSftpSidePanelSourceSession(
|
|
lastSourceSessionIdRef.current,
|
|
activeSessionId,
|
|
);
|
|
if (sessionChanged) {
|
|
connectedKeyRef.current = null;
|
|
}
|
|
if (activeSessionId) {
|
|
lastSourceSessionIdRef.current = activeSessionId;
|
|
}
|
|
|
|
const hasBackendSession = (connectionId: string) => !!s.getSftpIdForConnection(connectionId);
|
|
const activeTab = s.leftTabs.tabs.find((tab) => tab.id === s.leftTabs.activeTabId) ?? null;
|
|
const activeConnectionId = activeTab?.connection?.id;
|
|
if (
|
|
!sessionChanged
|
|
&& shouldSkipSftpSidePanelAutoConnect(
|
|
connectionKey,
|
|
connectedKeyRef.current,
|
|
activeTab,
|
|
activeConnectionId ? hasBackendSession(activeConnectionId) : false,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (hasActiveWork) return;
|
|
|
|
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
|
hostId: activeHost.id,
|
|
hostLabel: activeHost.label,
|
|
protocol: activeHost.protocol,
|
|
hostname: activeHost.hostname,
|
|
});
|
|
|
|
const tabs = s.leftTabs.tabs;
|
|
const existingTab = sessionChanged
|
|
? null
|
|
: findReusableSftpSidePanelTab(
|
|
tabs,
|
|
activeHost.id,
|
|
connectionKey,
|
|
tabConnectionKeyMapRef.current,
|
|
hasBackendSession,
|
|
);
|
|
if (existingTab) {
|
|
s.selectTab("left", existingTab.id);
|
|
connectedKeyRef.current = connectionKey;
|
|
connectedHostObjRef.current = activeHost;
|
|
return;
|
|
}
|
|
|
|
const currentConn = s.leftPane.connection;
|
|
const needsNewTab = !!(currentConn && currentConn.status === "connected");
|
|
|
|
connectedKeyRef.current = connectionKey;
|
|
connectedHostObjRef.current = activeHost;
|
|
s.connect("left", activeHost, {
|
|
sourceSessionId: activeSessionId ?? undefined,
|
|
...(needsNewTab ? { forceNewTab: true } : undefined),
|
|
onTabCreated: (tabId) => {
|
|
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
|
},
|
|
});
|
|
}, [activeHost, activeSessionId, interactiveWorkActive]);
|
|
|
|
useEffect(() => {
|
|
if (!activeHost || !isVisible) return;
|
|
|
|
let cancelled = false;
|
|
const frameId = requestAnimationFrame(() => {
|
|
if (!cancelled) runAutoConnect();
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
cancelAnimationFrame(frameId);
|
|
};
|
|
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
|
|
|
|
useEffect(() => {
|
|
const connection = sftp.leftPane.connection;
|
|
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
|
connectedKeyRef.current = null;
|
|
if (sftp.activeFileWatchCountRef) {
|
|
sftp.activeFileWatchCountRef.current = 0;
|
|
}
|
|
}
|
|
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status, sftp.activeFileWatchCountRef]);
|
|
|
|
useEffect(() => {
|
|
if (!activeHost || !initialLocation) return;
|
|
if (initialLocation.hostId !== activeHost.id || !initialLocation.path) return;
|
|
|
|
const activePane = sftpRef.current.leftPane;
|
|
const connection = activePane.connection;
|
|
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
|
if (connection.status !== "connected") return;
|
|
|
|
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
|
|
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
|
|
|
lastAppliedInitialLocationKeyRef.current = locationKey;
|
|
onInitialLocationApplied?.(initialLocation);
|
|
|
|
if (connection.currentPath === initialLocation.path) {
|
|
return;
|
|
}
|
|
|
|
sftpRef.current.navigateTo("left", initialLocation.path);
|
|
}, [
|
|
activeHost,
|
|
initialLocation,
|
|
onInitialLocationApplied,
|
|
sftp.leftPane,
|
|
]);
|
|
|
|
const onCurrentPathChangeRef = useRef(onCurrentPathChange);
|
|
onCurrentPathChangeRef.current = onCurrentPathChange;
|
|
useEffect(() => {
|
|
const connection = sftp.leftPane.connection;
|
|
if (!connection || connection.isLocal) return;
|
|
if (connection.status !== "connected") return;
|
|
if (!connection.currentPath) return;
|
|
const connectionKey = tabConnectionKeyMapRef.current.get(sftp.leftPane.id);
|
|
if (!connectionKey) return;
|
|
onCurrentPathChangeRef.current?.({
|
|
hostId: connection.hostId,
|
|
connectionKey,
|
|
path: connection.currentPath,
|
|
});
|
|
}, [
|
|
sftp.leftPane.connection,
|
|
sftp.leftPane.connection?.currentPath,
|
|
sftp.leftPane.connection?.hostId,
|
|
sftp.leftPane.connection?.status,
|
|
sftp.leftPane.id,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingUpload || !activeHost) return;
|
|
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
|
if (pendingUpload.hostId !== activeHost.id) return;
|
|
|
|
const activePane = sftp.leftPane;
|
|
const connection = activePane.connection;
|
|
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
|
|
if (connection.status !== "connected") return;
|
|
|
|
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
|
|
|
const runUpload = async () => {
|
|
try {
|
|
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
|
|
targetPath: pendingUpload.targetPath,
|
|
});
|
|
if (results.some((result) => result.cancelled)) {
|
|
toast.info(t("sftp.upload.cancelled"), "SFTP");
|
|
return;
|
|
}
|
|
|
|
const failCount = results.filter((result) => !result.success && !result.cancelled).length;
|
|
const successCount = results.filter((result) => result.success).length;
|
|
|
|
if (failCount === 0) {
|
|
const message =
|
|
successCount === 1
|
|
? `${t("sftp.upload")}: ${results[0]?.fileName ?? ""}`
|
|
: `${t("sftp.uploadFiles")}: ${successCount}`;
|
|
toast.success(message, "SFTP");
|
|
} else {
|
|
const failedFiles = results.filter((result) => !result.success && !result.cancelled);
|
|
failedFiles.forEach((failed) => {
|
|
const errorMsg = failed.error ? ` - ${failed.error}` : "";
|
|
toast.error(
|
|
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
|
|
"SFTP",
|
|
);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logger.error("[SftpSidePanel] Failed to upload dropped files:", error);
|
|
handledPendingUploadIdRef.current = null;
|
|
toast.error(
|
|
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
|
"SFTP",
|
|
);
|
|
return;
|
|
} finally {
|
|
onPendingUploadHandled?.(pendingUpload.requestId);
|
|
}
|
|
};
|
|
|
|
void runUpload();
|
|
}, [
|
|
activeHost,
|
|
onPendingUploadHandled,
|
|
pendingUpload,
|
|
sftp.leftPane,
|
|
t,
|
|
]);
|
|
|
|
return (
|
|
<SftpSidePanelDeferredMount ready={sftpUiReady} onReady={() => setSftpUiReady(true)}>
|
|
<SftpSidePanelInteractiveBody
|
|
hosts={hosts}
|
|
hostWriteSource={hostWriteSource}
|
|
updateHosts={updateHosts}
|
|
sftp={sftp}
|
|
sftpRef={sftpRef}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
activeHost={activeHost}
|
|
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
|
renderOverlays={renderOverlays}
|
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
|
sftpAutoSync={sftpAutoSync}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
editorWordWrap={editorWordWrap}
|
|
setEditorWordWrap={setEditorWordWrap}
|
|
onGetTerminalCwd={onGetTerminalCwd}
|
|
activeTerminalCwd={activeTerminalCwd}
|
|
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
|
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
|
|
onRequestTerminalFocus={onRequestTerminalFocus}
|
|
isVisible={isVisible}
|
|
behaviorRef={behaviorRef}
|
|
autoSyncRef={autoSyncRef}
|
|
connectedHostObjRef={connectedHostObjRef}
|
|
connectedKeyRef={connectedKeyRef}
|
|
onInteractiveWorkChange={setInteractiveWorkActive}
|
|
listSftp={listSftp}
|
|
mkdirLocal={mkdirLocal}
|
|
deleteLocalFile={deleteLocalFile}
|
|
showSaveDialog={showSaveDialog}
|
|
selectDirectory={selectDirectory}
|
|
startStreamTransfer={startStreamTransfer}
|
|
listLocalDir={listLocalDir}
|
|
listDrives={listDrives}
|
|
openPath={openPath}
|
|
t={t}
|
|
/>
|
|
</SftpSidePanelDeferredMount>
|
|
);
|
|
};
|
|
|
|
type SftpSidePanelInteractiveBodyProps = {
|
|
hosts: Host[];
|
|
hostWriteSource: Host[];
|
|
updateHosts: (hosts: Host[]) => void;
|
|
sftp: ReturnType<typeof useSftpState>;
|
|
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
|
|
sftpDefaultViewMode: "list" | "tree";
|
|
activeHost: Host | null;
|
|
showWorkspaceHostHeader: boolean;
|
|
renderOverlays: boolean;
|
|
sftpDoubleClickBehavior: "open" | "transfer";
|
|
sftpAutoSync: boolean;
|
|
hotkeyScheme: HotkeyScheme;
|
|
keyBindings: KeyBinding[];
|
|
editorWordWrap: boolean;
|
|
setEditorWordWrap: (value: boolean) => void;
|
|
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
|
|
activeTerminalCwd?: string | null;
|
|
sftpFollowTerminalCwd: boolean;
|
|
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
|
onRequestTerminalFocus?: () => void;
|
|
isVisible: boolean;
|
|
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"];
|
|
deleteLocalFile: ReturnType<typeof useSftpBackend>["deleteLocalFile"];
|
|
showSaveDialog: ReturnType<typeof useSftpBackend>["showSaveDialog"];
|
|
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
|
|
startStreamTransfer: ReturnType<typeof useSftpBackend>["startStreamTransfer"];
|
|
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
|
|
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
|
|
openPath: ReturnType<typeof useSftpBackend>["openPath"];
|
|
t: ReturnType<typeof useI18n>["t"];
|
|
};
|
|
|
|
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
|
|
hosts,
|
|
hostWriteSource,
|
|
updateHosts,
|
|
sftp,
|
|
sftpRef,
|
|
sftpDefaultViewMode,
|
|
activeHost,
|
|
showWorkspaceHostHeader,
|
|
renderOverlays,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
editorWordWrap,
|
|
setEditorWordWrap,
|
|
onGetTerminalCwd,
|
|
activeTerminalCwd = null,
|
|
sftpFollowTerminalCwd,
|
|
onSftpFollowTerminalCwdChange,
|
|
onRequestTerminalFocus,
|
|
isVisible,
|
|
behaviorRef,
|
|
autoSyncRef,
|
|
connectedHostObjRef,
|
|
connectedKeyRef,
|
|
onInteractiveWorkChange,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
startStreamTransfer,
|
|
listLocalDir,
|
|
listDrives,
|
|
openPath,
|
|
t,
|
|
}) => {
|
|
const panelRootRef = useRef<HTMLDivElement>(null);
|
|
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
|
const [hasPaneFocus, setHasPaneFocus] = useState(false);
|
|
const [pendingFollowOverride, setPendingFollowOverride] = useState<{
|
|
hostId: string;
|
|
value: boolean;
|
|
} | null>(null);
|
|
|
|
useSftpKeyboardShortcuts({
|
|
keyBindings,
|
|
hotkeyScheme,
|
|
sftpRef,
|
|
dialogActionScopeId: dialogActionScopeIdRef.current,
|
|
isActive: hasPaneFocus,
|
|
});
|
|
|
|
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
|
const getOpenerForFileRef = useRef(getOpenerForFile);
|
|
getOpenerForFileRef.current = getOpenerForFile;
|
|
|
|
const handleToggleHiddenFiles = useCallback((paneId: string) => {
|
|
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
|
|
if (!pane) return;
|
|
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
|
|
}, [sftpRef]);
|
|
|
|
const syncFocusedSelection = useCallback((tabId: string | null) => {
|
|
if (tabId) {
|
|
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
|
|
return;
|
|
}
|
|
keepOnlyPaneSelections(sftpRef.current, null);
|
|
}, [sftpRef]);
|
|
|
|
const handlePaneFocus = useCallback(() => {
|
|
sftpFocusStore.setFocusedSide("left");
|
|
setHasPaneFocus(true);
|
|
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
|
}, [sftpRef, syncFocusedSelection]);
|
|
|
|
// NOTE: We intentionally do NOT sync to activeTabStore here.
|
|
// activeTabStore is a global singleton shared with SftpView.
|
|
// Writing to it here would corrupt SftpView's left pane visibility.
|
|
|
|
useEffect(() => {
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const target = event.target as Node | null;
|
|
const elementTarget = target instanceof Element ? target : null;
|
|
const isPortalInteraction = !!elementTarget?.closest(
|
|
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
|
|
);
|
|
if (isPortalInteraction) {
|
|
return;
|
|
}
|
|
|
|
if (panelRootRef.current?.contains(target)) {
|
|
sftpFocusStore.setFocusedSide("left");
|
|
setHasPaneFocus(true);
|
|
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
|
|
} else {
|
|
setHasPaneFocus(false);
|
|
syncFocusedSelection(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("pointerdown", handlePointerDown, true);
|
|
return () => {
|
|
document.removeEventListener("pointerdown", handlePointerDown, true);
|
|
};
|
|
}, [sftpRef, syncFocusedSelection]);
|
|
|
|
const {
|
|
leftCallbacks,
|
|
rightCallbacks,
|
|
dragCallbacks,
|
|
draggedFiles,
|
|
permissionsState,
|
|
setPermissionsState,
|
|
showTextEditor,
|
|
setShowTextEditor,
|
|
textEditorTarget,
|
|
setTextEditorTarget,
|
|
textEditorContent,
|
|
setTextEditorContent,
|
|
showFileOpenerDialog,
|
|
setShowFileOpenerDialog,
|
|
fileOpenerTarget,
|
|
setFileOpenerTarget,
|
|
handleSaveTextFile,
|
|
onPromoteToTab,
|
|
handleFileOpenerSelect,
|
|
handleSelectSystemApp,
|
|
} = useSftpViewPaneCallbacks({
|
|
sftpRef,
|
|
behaviorRef,
|
|
autoSyncRef,
|
|
getOpenerForFileRef,
|
|
setOpenerForExtension,
|
|
t,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
startStreamTransfer,
|
|
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
|
listLocalFiles: listLocalDir,
|
|
listDrives,
|
|
});
|
|
|
|
const {
|
|
leftPanes,
|
|
showHostPickerLeft,
|
|
showHostPickerRight,
|
|
hostSearchLeft,
|
|
hostSearchRight,
|
|
setShowHostPickerLeft,
|
|
setShowHostPickerRight,
|
|
setHostSearchLeft,
|
|
setHostSearchRight,
|
|
handleHostSelectLeft,
|
|
handleHostSelectRight,
|
|
} = useSftpViewTabs({ sftp, sftpRef });
|
|
|
|
useEffect(() => {
|
|
onInteractiveWorkChange(showTextEditor || !!permissionsState || showFileOpenerDialog);
|
|
}, [onInteractiveWorkChange, permissionsState, showFileOpenerDialog, showTextEditor]);
|
|
|
|
// When a host switch is deferred or the picker connects a different host,
|
|
// actions should follow the visible SFTP connection, not the incoming default.
|
|
const displayHost = useMemo(() => {
|
|
const conn = sftp.leftPane.connection;
|
|
if (conn && !conn.isLocal) {
|
|
const latestHost = hosts.find((h) => h.id === conn.hostId) ?? null;
|
|
const pendingFollowValue = pendingFollowOverride?.hostId === conn.hostId
|
|
? pendingFollowOverride.value
|
|
: undefined;
|
|
// Prefer the stored Host object from connect time — it preserves
|
|
// session-time overrides that the vault host may lack.
|
|
if (connectedHostObjRef.current && connectedHostObjRef.current.id === conn.hostId) {
|
|
return mergeLatestFollowTerminalCwdHostSetting(
|
|
connectedHostObjRef.current,
|
|
latestHost,
|
|
pendingFollowValue,
|
|
);
|
|
}
|
|
return latestHost ?? activeHost;
|
|
}
|
|
return activeHost;
|
|
}, [activeHost, connectedHostObjRef, hosts, pendingFollowOverride, sftp.leftPane.connection]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingFollowOverride) return;
|
|
const latestHost = hosts.find((host) => host.id === pendingFollowOverride.hostId);
|
|
if (latestHost?.sftpFollowTerminalCwd === pendingFollowOverride.value) {
|
|
setPendingFollowOverride(null);
|
|
}
|
|
}, [hosts, pendingFollowOverride]);
|
|
|
|
useEffect(() => {
|
|
setPendingFollowOverride(null);
|
|
}, [sftp.leftPane.connection?.id]);
|
|
|
|
const followTerminalCwdHost = useMemo(() => {
|
|
if (sftp.leftPane.connection?.isLocal) return null;
|
|
return displayHost;
|
|
}, [displayHost, sftp.leftPane.connection?.isLocal]);
|
|
|
|
const effectiveFollowTerminalCwd = resolveHostFollowTerminalCwd(
|
|
followTerminalCwdHost?.sftpFollowTerminalCwd,
|
|
sftpFollowTerminalCwd,
|
|
);
|
|
|
|
const canFollowTerminalCwd = useMemo(() => {
|
|
if (!onGetTerminalCwd || !followTerminalCwdHost) return false;
|
|
const proto = followTerminalCwdHost.protocol;
|
|
if (proto === "local" || proto === "serial") return false;
|
|
if (followTerminalCwdHost.id?.startsWith("local-") || followTerminalCwdHost.id?.startsWith("serial-")) return false;
|
|
return true;
|
|
}, [followTerminalCwdHost, onGetTerminalCwd]);
|
|
|
|
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|
|
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
|
|
|
|
const blockedFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
|
const handledFollowRef = useRef<SftpFollowTerminalCwdBlock | null>(null);
|
|
const followSyncGenerationRef = useRef(0);
|
|
const effectiveFollowTerminalCwdRef = useRef(effectiveFollowTerminalCwd);
|
|
const canFollowTerminalCwdRef = useRef(canFollowTerminalCwd);
|
|
const activeTerminalCwdRef = useRef(activeTerminalCwd);
|
|
const connectionId = sftp.leftPane.connection?.id ?? null;
|
|
const connectionIdRef = useRef(connectionId);
|
|
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
|
effectiveFollowTerminalCwdRef.current = effectiveFollowTerminalCwd;
|
|
canFollowTerminalCwdRef.current = canFollowTerminalCwd;
|
|
activeTerminalCwdRef.current = activeTerminalCwd;
|
|
connectionIdRef.current = connectionId;
|
|
|
|
const invalidateInFlightFollowSync = useCallback(() => {
|
|
followSyncGenerationRef.current += 1;
|
|
blockedFollowRef.current = null;
|
|
handledFollowRef.current = null;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
invalidateInFlightFollowSync();
|
|
}, [
|
|
activeTerminalCwd,
|
|
followTerminalCwdHost?.id,
|
|
connectionId,
|
|
invalidateInFlightFollowSync,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (effectiveFollowTerminalCwd) return;
|
|
invalidateInFlightFollowSync();
|
|
}, [effectiveFollowTerminalCwd, invalidateInFlightFollowSync]);
|
|
|
|
useEffect(() => {
|
|
const blockedFollow = blockedFollowRef.current;
|
|
if (
|
|
shouldClearBlockedFollowOnReach(
|
|
blockedFollow,
|
|
connectionId,
|
|
connectionPath,
|
|
sftp.leftPane.loading,
|
|
)
|
|
) {
|
|
blockedFollowRef.current = null;
|
|
handledFollowRef.current = blockedFollow;
|
|
}
|
|
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
|
|
|
const handleGoToTerminalCwd = useCallback(async () => {
|
|
if (!onGetTerminalCwd) return;
|
|
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
|
if (!cwd) return;
|
|
const navigateResult = await sftpRef.current.navigateTo("left", cwd);
|
|
if (navigateResult === "reached") {
|
|
blockedFollowRef.current = null;
|
|
const connection = sftpRef.current.leftPane.connection;
|
|
if (connection?.id) {
|
|
handledFollowRef.current = { connectionId: connection.id, terminalCwd: cwd };
|
|
}
|
|
}
|
|
}, [onGetTerminalCwd, sftpRef]);
|
|
|
|
const syncFollowToTerminalCwd = useCallback(async () => {
|
|
if (!onGetTerminalCwd || !effectiveFollowTerminalCwd || !canFollowTerminalCwd) {
|
|
return;
|
|
}
|
|
|
|
const syncGeneration = followSyncGenerationRef.current;
|
|
|
|
const usesLiveTerminalCwd = Boolean(activeTerminalCwd);
|
|
let terminalCwd = activeTerminalCwd;
|
|
if (!terminalCwd) {
|
|
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
|
|
}
|
|
if (!terminalCwd) return;
|
|
if (!shouldApplyFollowTerminalCwdSyncResult({
|
|
syncGeneration,
|
|
currentGeneration: followSyncGenerationRef.current,
|
|
followEnabled: effectiveFollowTerminalCwdRef.current,
|
|
canFollow: canFollowTerminalCwdRef.current,
|
|
})) {
|
|
return;
|
|
}
|
|
|
|
const connection = sftpRef.current.leftPane.connection;
|
|
if (!shouldFollowTerminalCwdNavigate({
|
|
followEnabled: effectiveFollowTerminalCwdRef.current,
|
|
isVisible,
|
|
terminalCwd,
|
|
currentPath: connection?.currentPath,
|
|
connectionId: connection?.id,
|
|
hasActiveWork,
|
|
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
|
|
blockedFollow: blockedFollowRef.current,
|
|
handledFollow: handledFollowRef.current,
|
|
})) {
|
|
if (
|
|
connection?.id
|
|
&& !connection.isLocal
|
|
&& connection.status === "connected"
|
|
&& connection.currentPath === terminalCwd
|
|
) {
|
|
handledFollowRef.current = { connectionId: connection.id, terminalCwd };
|
|
}
|
|
return;
|
|
}
|
|
|
|
const expectedConnectionId = connection?.id ?? null;
|
|
const shouldApplyCurrentFollowSync = () => (
|
|
shouldApplyFollowTerminalCwdSyncResult({
|
|
syncGeneration,
|
|
currentGeneration: followSyncGenerationRef.current,
|
|
followEnabled: effectiveFollowTerminalCwdRef.current,
|
|
canFollow: canFollowTerminalCwdRef.current,
|
|
expectedConnectionId,
|
|
liveConnectionId: connectionIdRef.current,
|
|
paneConnectionId: sftpRef.current.leftPane.connection?.id ?? null,
|
|
expectedTerminalCwd: terminalCwd,
|
|
liveTerminalCwd: activeTerminalCwdRef.current,
|
|
requireLiveTerminalCwd: usesLiveTerminalCwd,
|
|
})
|
|
);
|
|
const navigateResult = await sftpRef.current.navigateTo("left", terminalCwd, {
|
|
shouldApply: shouldApplyCurrentFollowSync,
|
|
});
|
|
if (!shouldApplyFollowTerminalCwdSyncResult({
|
|
syncGeneration,
|
|
currentGeneration: followSyncGenerationRef.current,
|
|
followEnabled: effectiveFollowTerminalCwdRef.current,
|
|
canFollow: canFollowTerminalCwdRef.current,
|
|
})) {
|
|
return;
|
|
}
|
|
|
|
const currentConnection = sftpRef.current.leftPane.connection;
|
|
if (!currentConnection || currentConnection.id !== connection?.id) {
|
|
return;
|
|
}
|
|
|
|
if (navigateResult === "failed" && currentConnection.id) {
|
|
blockedFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
|
} else if (navigateResult === "superseded" && currentConnection.id) {
|
|
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
|
} else if (navigateResult === "reached") {
|
|
blockedFollowRef.current = null;
|
|
handledFollowRef.current = { connectionId: currentConnection.id, terminalCwd };
|
|
}
|
|
}, [
|
|
activeTerminalCwd,
|
|
canFollowTerminalCwd,
|
|
effectiveFollowTerminalCwd,
|
|
hasActiveWork,
|
|
isVisible,
|
|
onGetTerminalCwd,
|
|
sftpRef,
|
|
]);
|
|
|
|
const handleToggleFollowTerminalCwd = useCallback(() => {
|
|
const nextEnabled = !effectiveFollowTerminalCwd;
|
|
invalidateInFlightFollowSync();
|
|
if (followTerminalCwdHost?.id) {
|
|
setPendingFollowOverride({ hostId: followTerminalCwdHost.id, value: nextEnabled });
|
|
}
|
|
onSftpFollowTerminalCwdChange?.(nextEnabled, followTerminalCwdHost);
|
|
}, [effectiveFollowTerminalCwd, followTerminalCwdHost, invalidateInFlightFollowSync, onSftpFollowTerminalCwdChange]);
|
|
|
|
useEffect(() => {
|
|
if (!effectiveFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
|
|
void syncFollowToTerminalCwd();
|
|
}, [
|
|
activeTerminalCwd,
|
|
canFollowTerminalCwd,
|
|
effectiveFollowTerminalCwd,
|
|
hasActiveWork,
|
|
isVisible,
|
|
connectionId,
|
|
sftp.leftPane.connection?.status,
|
|
sftp.leftPane.connection?.isLocal,
|
|
syncFollowToTerminalCwd,
|
|
]);
|
|
|
|
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,
|
|
// 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;
|
|
}
|
|
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
|
|
});
|
|
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
|
|
}, [sftp.transfers, sftp.leftPane.connection]);
|
|
|
|
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") {
|
|
try {
|
|
const result = await openPath(revealPath);
|
|
if (result.success) return;
|
|
} catch {
|
|
// Show the localized error below.
|
|
}
|
|
toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP");
|
|
return;
|
|
}
|
|
|
|
if (!connection || connection.isLocal) return;
|
|
|
|
await sftpRef.current.navigateTo("left", revealPath, { force: true });
|
|
},
|
|
[openPath, sftpRef, t],
|
|
);
|
|
|
|
const canRevealTransferTarget = useCallback(
|
|
(task: TransferTask) => {
|
|
if (task.status !== "completed") return false;
|
|
if (!isConcreteTransferTargetPath(task)) return false;
|
|
if (task.targetConnectionId === "local") {
|
|
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;
|
|
},
|
|
[connectedKeyRef, sftp.leftPane.connection],
|
|
);
|
|
|
|
const canCopyTransferTargetPath = useCallback(
|
|
(task: TransferTask) => task.status === "completed" && isConcreteTransferTargetPath(task),
|
|
[],
|
|
);
|
|
|
|
const handleCopyTransferTargetPath = useCallback(
|
|
async (task: TransferTask) => {
|
|
if (!isConcreteTransferTargetPath(task)) return;
|
|
try {
|
|
await navigator.clipboard.writeText(task.targetPath);
|
|
toast.success(t("sftp.transfers.copyTargetPathSuccess"), "SFTP");
|
|
} catch {
|
|
toast.error(t("sftp.transfers.copyTargetPathError"), "SFTP");
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Determine the active pane to render (without using global activeTabStore)
|
|
const activeLeftPaneId = sftp.leftTabs.activeTabId;
|
|
|
|
return (
|
|
<SftpContextProvider
|
|
hosts={hosts}
|
|
writableHosts={hostWriteSource}
|
|
updateHosts={updateHosts}
|
|
draggedFiles={draggedFiles}
|
|
dragCallbacks={dragCallbacks}
|
|
leftCallbacks={leftCallbacks}
|
|
rightCallbacks={rightCallbacks}
|
|
>
|
|
<div
|
|
ref={panelRootRef}
|
|
className="h-full flex flex-col bg-background overflow-hidden"
|
|
data-section="terminal-sftp-panel"
|
|
onClick={handlePaneFocus}
|
|
>
|
|
{showWorkspaceHostHeader && displayHost && (
|
|
<div
|
|
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
|
|
data-section="terminal-sftp-host-header"
|
|
>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<DistroAvatar
|
|
host={displayHost}
|
|
fallback={displayHost.label.slice(0, 2).toUpperCase()}
|
|
size="sm"
|
|
className="h-5 w-5 rounded-sm shrink-0"
|
|
/>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate cursor-default">
|
|
<span className="font-medium">
|
|
{displayHost.label}
|
|
</span>
|
|
<span className="mx-1 text-muted-foreground">·</span>
|
|
<span className="font-mono text-muted-foreground">
|
|
{(displayHost.username || "root")}@{displayHost.hostname}:{displayHost.port || 22}
|
|
</span>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</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;
|
|
|
|
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}
|
|
followTerminalCwd={canFollowTerminalCwd ? effectiveFollowTerminalCwd : undefined}
|
|
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<SftpTransferQueue
|
|
sftp={sftp}
|
|
visibleTransfers={visibleTransfers}
|
|
allTransfers={sftp.transfers}
|
|
canRevealTransferTarget={canRevealTransferTarget}
|
|
onRevealTransferTarget={handleRevealTransferTarget}
|
|
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
|
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
|
/>
|
|
</div>
|
|
|
|
{renderOverlays && (
|
|
<SftpOverlays
|
|
hosts={hosts}
|
|
sftp={sftp}
|
|
visibleTransfers={visibleTransfers}
|
|
showTransferQueue={false}
|
|
canRevealTransferTarget={canRevealTransferTarget}
|
|
onRevealTransferTarget={handleRevealTransferTarget}
|
|
canCopyTransferTargetPath={canCopyTransferTargetPath}
|
|
onCopyTransferTargetPath={handleCopyTransferTargetPath}
|
|
showHostPickerLeft={showHostPickerLeft}
|
|
showHostPickerRight={showHostPickerRight}
|
|
hostSearchLeft={hostSearchLeft}
|
|
hostSearchRight={hostSearchRight}
|
|
setShowHostPickerLeft={setShowHostPickerLeft}
|
|
setShowHostPickerRight={setShowHostPickerRight}
|
|
setHostSearchLeft={setHostSearchLeft}
|
|
setHostSearchRight={setHostSearchRight}
|
|
handleHostSelectLeft={handleHostSelectLeft}
|
|
handleHostSelectRight={handleHostSelectRight}
|
|
permissionsState={permissionsState}
|
|
setPermissionsState={setPermissionsState}
|
|
showTextEditor={showTextEditor}
|
|
setShowTextEditor={setShowTextEditor}
|
|
textEditorTarget={textEditorTarget}
|
|
setTextEditorTarget={setTextEditorTarget}
|
|
textEditorContent={textEditorContent}
|
|
setTextEditorContent={setTextEditorContent}
|
|
handleSaveTextFile={handleSaveTextFile}
|
|
editorWordWrap={editorWordWrap}
|
|
setEditorWordWrap={setEditorWordWrap}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
showFileOpenerDialog={showFileOpenerDialog}
|
|
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
|
fileOpenerTarget={fileOpenerTarget}
|
|
setFileOpenerTarget={setFileOpenerTarget}
|
|
handleFileOpenerSelect={handleFileOpenerSelect}
|
|
handleSelectSystemApp={handleSelectSystemApp}
|
|
onPromoteToTab={onPromoteToTab}
|
|
onRequestTerminalFocus={onRequestTerminalFocus}
|
|
t={t}
|
|
/>
|
|
)}
|
|
</SftpContextProvider>
|
|
);
|
|
};
|
|
|
|
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
|
prev.hosts === next.hosts &&
|
|
prev.writableHosts === next.writableHosts &&
|
|
prev.keys === next.keys &&
|
|
prev.identities === next.identities &&
|
|
prev.knownHosts === next.knownHosts &&
|
|
prev.updateHosts === next.updateHosts &&
|
|
prev.onAddKnownHost === next.onAddKnownHost &&
|
|
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
|
prev.activeHost === next.activeHost &&
|
|
prev.activeSessionId === next.activeSessionId &&
|
|
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
|
|
prev.isVisible === next.isVisible &&
|
|
prev.renderOverlays === next.renderOverlays &&
|
|
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
|
|
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
|
|
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
|
prev.sftpAutoSync === next.sftpAutoSync &&
|
|
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
|
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
|
prev.hotkeyScheme === next.hotkeyScheme &&
|
|
prev.keyBindings === next.keyBindings &&
|
|
prev.editorWordWrap === next.editorWordWrap &&
|
|
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
|
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
|
|
prev.activeTerminalCwd === next.activeTerminalCwd &&
|
|
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
|
|
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
|
|
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
|
prev.onCurrentPathChange === next.onCurrentPathChange &&
|
|
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
|
|
prev.initialLocation?.path === next.initialLocation?.path &&
|
|
// Only the keepalive fields of terminalSettings affect SFTP connection
|
|
// resolution today; compare them directly rather than the whole object.
|
|
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
|
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
|
|
|
export const SftpSidePanel = memo(SftpSidePanelInner, sidePanelAreEqual);
|
|
SftpSidePanel.displayName = "SftpSidePanel";
|