Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1998 lines
74 KiB
TypeScript
1998 lines
74 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,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type MutableRefObject,
|
|
} from "react";
|
|
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
|
|
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from "./terminalLayer/terminalSidePanelChrome";
|
|
import { formatHostPort } from "../domain/host";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useSftpState } from "../application/state/useSftpState";
|
|
import {
|
|
useReportSftpTransferOwnerActivity,
|
|
} from "../application/state/sftp/useSftpTransferLifecycle";
|
|
import { useSftpFollowTerminalCwd } from "../application/state/sftp/useSftpFollowTerminalCwd";
|
|
import { usePendingSftpUploadRebind } from "../application/state/sftp/usePendingSftpUploadRebind";
|
|
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
|
import {
|
|
editorTabStore,
|
|
useEditorTabPresenceRevision,
|
|
} 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 { resolveSftpAutoConnectPath } from "../application/state/sftp/sftpReopenLocation";
|
|
import {
|
|
isBrowseSessionInteractive,
|
|
listRemoteBrowseConnectionIds,
|
|
listRemoteBrowseSftpTabIds,
|
|
} from "../application/state/sftp/browseSessionLifecycle";
|
|
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 { reportSftpUploadResults } from "./sftp/reportSftpUploadResults";
|
|
|
|
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 "../application/state/sftp/sftpFocusStore";
|
|
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
|
|
import { KeyBinding, HotkeyScheme } from "../domain/models";
|
|
import {
|
|
mergeLatestFollowTerminalCwdHostSetting,
|
|
resolveHostFollowTerminalCwd,
|
|
} from "../domain/sftpFollowTerminalCwd";
|
|
import {
|
|
canLocateSftpPathInTerminal,
|
|
resolveLocateSftpPathInTerminalAction,
|
|
resolveLocateSftpPathSessionId,
|
|
} from "../domain/sftpLocatePathInTerminal";
|
|
import { classifyDistroId } from "../domain/host";
|
|
import { useTerminalBackend } from "../application/state/useTerminalBackend";
|
|
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
|
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
|
import { getNextSftpToolbarDisplayPath } from "./sftp/SftpPaneToolbar";
|
|
import { scheduleDeferredTerminalFocus } from "./systemManager/tmuxActionFocus";
|
|
import {
|
|
connectionKeyMatchesHost,
|
|
findPendingSftpRebindTargetPane,
|
|
findReusableSftpSidePanelTab,
|
|
isPendingSameEndpointSshSession,
|
|
rememberSftpSidePanelSourceStatus,
|
|
resolvePendingSftpUploadCancellation,
|
|
resolveSftpSidePanelTrackedSourceStatusUpdate,
|
|
shouldAcceptPendingSftpUpload,
|
|
shouldBlockPendingSftpUploadForSourceRebind,
|
|
shouldCancelPendingSftpUpload,
|
|
shouldCancelSettledPendingSftpRebindWithoutTarget,
|
|
shouldDeferPendingSftpUploadForOriginFocus,
|
|
shouldDeferSftpSidePanelAutoConnectForSession,
|
|
shouldRebindSftpSidePanelSourceSession,
|
|
shouldSkipSftpSidePanelAutoConnect,
|
|
shouldStartPendingSftpUploadRebind,
|
|
shouldWaitForPendingSftpRebind,
|
|
} from "./sftp/sftpSidePanelAutoConnect";
|
|
import {
|
|
canApplySftpSidePanelInitialLocation,
|
|
pruneSftpSidePanelState,
|
|
recallSftpSidePanelPath,
|
|
rememberSftpSidePanelPath,
|
|
} from "./sftp/sftpSidePanelConnectionMemory";
|
|
import {
|
|
listSftpConnectedHosts,
|
|
resolveSftpTransferSourceSessionId,
|
|
sftpHostEndpointsEqual,
|
|
sftpPickerSessionsEqual,
|
|
} from "../domain/sftpConnectedHosts";
|
|
import type { TerminalSession } from "../domain/models";
|
|
|
|
interface SftpSidePanelProps {
|
|
transferOwnerId: string;
|
|
hosts: Host[];
|
|
writableHosts?: Host[];
|
|
sessions?: TerminalSession[];
|
|
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;
|
|
/** Linked same-endpoint SSH session id (may be reconnecting; reuse only when connected) */
|
|
activeSessionId?: string | null;
|
|
/** Focused terminal session (includes mosh/et/local) for locate-path writes */
|
|
focusedSessionId?: string | null;
|
|
initialLocation?: { hostId: string; path: string } | null;
|
|
onInitialLocationApplied?: (location: { hostId: string; path: string }) => void;
|
|
onCurrentPathChange?: (location: {
|
|
hostId: string;
|
|
connectionKey: string;
|
|
path: string;
|
|
}) => void;
|
|
onActiveTransfersChange?: (count: number) => void;
|
|
/** External-editor temps that must keep this owner mounted after panel close. */
|
|
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;
|
|
hostId: string;
|
|
connectionKey: string;
|
|
originSessionId?: string;
|
|
sourceSessionId?: 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;
|
|
allowRendererFallback?: boolean;
|
|
requireActiveShellCwd?: boolean;
|
|
}) => Promise<string | null>;
|
|
activeTerminalCwd?: string | null;
|
|
activeTerminalCwdTrusted?: boolean;
|
|
sftpFollowTerminalCwd?: boolean;
|
|
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
|
onRequestTerminalFocus?: () => void;
|
|
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
|
}
|
|
|
|
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
|
transferOwnerId,
|
|
hosts,
|
|
writableHosts,
|
|
sessions = [],
|
|
keys,
|
|
identities,
|
|
knownHosts = [],
|
|
updateHosts,
|
|
onAddKnownHost,
|
|
sftpDefaultViewMode,
|
|
activeHost,
|
|
activeSessionId,
|
|
focusedSessionId = null,
|
|
initialLocation,
|
|
onInitialLocationApplied,
|
|
onCurrentPathChange,
|
|
onActiveTransfersChange,
|
|
onActiveExternalEditsChange,
|
|
showWorkspaceHostHeader = false,
|
|
isVisible = true,
|
|
ownerPanelOpen = false,
|
|
renderOverlays = true,
|
|
pendingUpload = null,
|
|
onPendingUploadHandled,
|
|
sftpDoubleClickBehavior,
|
|
sftpAutoSync,
|
|
sftpShowHiddenFiles,
|
|
sftpUseCompressedUpload,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
editorWordWrap,
|
|
setEditorWordWrap,
|
|
onGetTerminalCwd,
|
|
activeTerminalCwd = null,
|
|
activeTerminalCwdTrusted = false,
|
|
sftpFollowTerminalCwd = false,
|
|
onSftpFollowTerminalCwdChange,
|
|
onRequestTerminalFocus,
|
|
terminalSettings,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
|
|
const hostWriteSource = writableHosts ?? hosts;
|
|
const connectedHosts = useMemo(() => {
|
|
const hostsById = new Map<string, Host>(
|
|
hosts.map((host) => [host.id, host]),
|
|
);
|
|
return listSftpConnectedHosts(sessions, hostsById);
|
|
}, [hosts, sessions]);
|
|
|
|
const resolveTransferSourceSessionId = useCallback((hostId: string, host?: Host) => {
|
|
const hostsById = new Map<string, Host>(hosts.map((h) => [h.id, h]));
|
|
// Walk all sessions (not the picker one-per-hostId list) so multi-tab
|
|
// same hostId with different live endpoints can still match.
|
|
return resolveSftpTransferSourceSessionId(sessions, hostsById, hostId, host);
|
|
}, [hosts, sessions]);
|
|
|
|
// Browse restore can run before the session list reflects the focused tab;
|
|
// prefer the active source when its visible endpoint matches the pane host
|
|
// and the SSH transport is already up (linked id survives reconnect phases).
|
|
const resolveBrowseSourceSessionId = useCallback((hostId: string, host?: Host) => {
|
|
if (
|
|
activeSessionId
|
|
&& activeHost
|
|
&& activeHost.id === hostId
|
|
&& (!host || sftpHostEndpointsEqual(activeHost, host))
|
|
) {
|
|
const linkedSession = sessions.find((session) => session.id === activeSessionId);
|
|
if (linkedSession?.status === "connected") {
|
|
return activeSessionId;
|
|
}
|
|
}
|
|
return resolveTransferSourceSessionId(hostId, host);
|
|
}, [activeHost, activeSessionId, resolveTransferSourceSessionId, sessions]);
|
|
|
|
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 ownedEditorSessionIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
const ownedEditorSftpTabIdsRef = useRef<ReadonlySet<string>>(new Set());
|
|
const activeExternalEditCountRef = useRef(0);
|
|
// Re-render on tab open/close/session remap only — not on every editor keystroke.
|
|
useEditorTabPresenceRevision();
|
|
const hasOwnedEditorTab = editorTabStore.hasOwnedEditorForSftpOwner({
|
|
sessionIds: ownedEditorSessionIdsRef.current,
|
|
sftpTabIds: ownedEditorSftpTabIdsRef.current,
|
|
});
|
|
|
|
const sftpOptions = useMemo(() => ({
|
|
...fileWatchHandlers,
|
|
transferOwnerId,
|
|
canPrepareTransferAdoption: isVisible,
|
|
// Drive progress React paints: false while retained-but-hidden after close.
|
|
surfaceVisible: isVisible,
|
|
// A promoted editor still saves through this owner after the side panel
|
|
// 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,
|
|
}),
|
|
useCompressedUpload: sftpUseCompressedUpload,
|
|
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
|
autoConnectLocalOnMount: false,
|
|
terminalSettings,
|
|
knownHosts,
|
|
onAddKnownHost,
|
|
resolveTransferSourceSessionId,
|
|
resolveBrowseSourceSessionId,
|
|
}), [
|
|
fileWatchHandlers,
|
|
hasOwnedEditorTab,
|
|
isVisible,
|
|
ownerPanelOpen,
|
|
transferOwnerId,
|
|
sftpUseCompressedUpload,
|
|
sftpShowHiddenFiles,
|
|
terminalSettings,
|
|
knownHosts,
|
|
onAddKnownHost,
|
|
resolveTransferSourceSessionId,
|
|
resolveBrowseSourceSessionId,
|
|
]);
|
|
|
|
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
|
activeExternalEditCountRef.current = sftp.activeExternalEditCount ?? 0;
|
|
ownedEditorSessionIdsRef.current = new Set(
|
|
listRemoteBrowseConnectionIds([
|
|
...sftp.leftTabs.tabs,
|
|
...sftp.rightTabs.tabs,
|
|
]),
|
|
);
|
|
ownedEditorSftpTabIdsRef.current = new Set(
|
|
listRemoteBrowseSftpTabIds([
|
|
...sftp.leftTabs.tabs,
|
|
...sftp.rightTabs.tabs,
|
|
]),
|
|
);
|
|
const {
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
listLocalDir,
|
|
listDrives,
|
|
openPath,
|
|
} = useSftpBackend();
|
|
|
|
const sftpRef = useRef(sftp);
|
|
sftpRef.current = sftp;
|
|
|
|
const { getConnectionCacheKey, leftPane } = sftp;
|
|
|
|
useEffect(() => {
|
|
/** Per-task locks so resume-all can prepare multiple transfers sequentially. */
|
|
const connectingTaskIds = new Set<string>();
|
|
const queue: Array<() => Promise<void>> = [];
|
|
let draining = false;
|
|
|
|
const drain = async () => {
|
|
if (draining) return;
|
|
draining = true;
|
|
try {
|
|
while (queue.length > 0) {
|
|
const job = queue.shift();
|
|
if (job) await job();
|
|
}
|
|
} finally {
|
|
draining = false;
|
|
}
|
|
};
|
|
|
|
const handler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{
|
|
task: TransferTask;
|
|
targetOwnerId: string;
|
|
reportFailure?: (error: string) => void;
|
|
}>).detail;
|
|
if (detail?.targetOwnerId !== transferOwnerId) return;
|
|
const task = detail.task;
|
|
if (!task) return;
|
|
if (connectingTaskIds.has(task.id)) return;
|
|
|
|
queue.push(async () => {
|
|
connectingTaskIds.add(task.id);
|
|
try {
|
|
const resolveHost = (hostId?: string, hostLabel?: string) => {
|
|
if (!hostId && !hostLabel) return "local" as const;
|
|
const byId = hostId ? hosts.find((host) => host.id === hostId) : undefined;
|
|
if (byId) return byId;
|
|
const needle = (hostLabel || "").trim().toLowerCase();
|
|
if (!needle) return undefined;
|
|
return hosts.find((host) => (
|
|
(host.label || "").trim().toLowerCase() === needle
|
|
|| (host.hostname || "").trim().toLowerCase() === needle
|
|
));
|
|
};
|
|
const source = resolveHost(task.sourceHostId, task.sourceHostLabel);
|
|
const target = resolveHost(task.targetHostId, task.targetHostLabel);
|
|
if (!source || !target) {
|
|
const missingEndpoint = !source ? "source" : "target";
|
|
detail.reportFailure?.(
|
|
`Cannot find the ${missingEndpoint} host in your vault. Resume will try a dedicated connection, or re-add the host.`,
|
|
);
|
|
return;
|
|
}
|
|
const sourceDirectory = task.isDirectory ? task.sourcePath : getParentPath(task.sourcePath);
|
|
const targetDirectory = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
|
|
// Downloads only need the remote source; still open local on the other
|
|
// pane so adoption can match both endpoints for stream restarts.
|
|
if (source !== "local") {
|
|
await sftpRef.current.connect("left", source, {
|
|
forceNewTab: true,
|
|
initialPath: sourceDirectory,
|
|
});
|
|
const sourcePane = sftpRef.current.leftPane;
|
|
if (sourcePane.connection?.status !== "connected") {
|
|
throw new Error(sourcePane.connection?.error || sourcePane.error || "Source server authentication failed");
|
|
}
|
|
} else {
|
|
await sftpRef.current.connect("left", "local", {
|
|
forceNewTab: true,
|
|
initialPath: sourceDirectory,
|
|
});
|
|
}
|
|
if (target !== "local") {
|
|
await sftpRef.current.connect("right", target, {
|
|
forceNewTab: true,
|
|
initialPath: targetDirectory,
|
|
});
|
|
const targetPane = sftpRef.current.rightPane;
|
|
if (targetPane.connection?.status !== "connected") {
|
|
throw new Error(targetPane.connection?.error || targetPane.error || "Target server authentication failed");
|
|
}
|
|
} else {
|
|
await sftpRef.current.connect("right", "local", {
|
|
forceNewTab: true,
|
|
initialPath: targetDirectory,
|
|
});
|
|
const targetPane = sftpRef.current.rightPane;
|
|
if (targetPane.connection?.status !== "connected") {
|
|
throw new Error(targetPane.connection?.error || targetPane.error || "Local folder is unavailable");
|
|
}
|
|
}
|
|
} catch (error) {
|
|
detail.reportFailure?.(error instanceof Error ? error.message : String(error));
|
|
} finally {
|
|
connectingTaskIds.delete(task.id);
|
|
}
|
|
});
|
|
void drain();
|
|
};
|
|
window.addEventListener("netcatty:prepare-sftp-transfer-resume", handler);
|
|
return () => window.removeEventListener("netcatty:prepare-sftp-transfer-resume", handler);
|
|
}, [hosts, transferOwnerId]);
|
|
|
|
useReportSftpTransferOwnerActivity({
|
|
ownerId: transferOwnerId,
|
|
activeTransfersCount: sftp.activeTransfersCount,
|
|
onActiveTransfersChange,
|
|
});
|
|
|
|
// Parent retain-on-close only sees transfers unless we also publish external
|
|
// editor temp activity (closeSftp deletes those files on unmount).
|
|
const onActiveExternalEditsChangeRef = useRef(onActiveExternalEditsChange);
|
|
onActiveExternalEditsChangeRef.current = onActiveExternalEditsChange;
|
|
useLayoutEffect(() => {
|
|
onActiveExternalEditsChangeRef.current?.(sftp.activeExternalEditCount ?? 0);
|
|
}, [sftp.activeExternalEditCount]);
|
|
|
|
// 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, sftpTabId) =>
|
|
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding, sftpTabId),
|
|
);
|
|
}, []);
|
|
|
|
// 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 ownedSessionIds: string[] = [];
|
|
const ownedSftpTabIds: string[] = [];
|
|
for (const tab of [...(s.leftTabs?.tabs ?? []), ...(s.rightTabs?.tabs ?? [])]) {
|
|
ownedSftpTabIds.push(tab.id);
|
|
const id = tab.connection?.id;
|
|
if (id) ownedSessionIds.push(id);
|
|
}
|
|
if (ownedSessionIds.length === 0 && ownedSftpTabIds.length === 0) return;
|
|
const closed = editorTabStore.forceCloseByOwners({
|
|
sessionIds: ownedSessionIds,
|
|
sftpTabIds: ownedSftpTabIds,
|
|
});
|
|
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 lastSourceSessionStatusRef = useRef<string | null>(null);
|
|
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
|
|
const handledPendingUploadIdRef = useRef<string | null>(null);
|
|
const pendingUploadFocusSeenRef = useRef<{
|
|
requestId: string;
|
|
originFocused: boolean;
|
|
sourceActive: boolean;
|
|
} | null>(null);
|
|
const {
|
|
barrierRef: pendingUploadRebindBarrierRef,
|
|
bindTarget: bindPendingUploadRebindTarget,
|
|
clearBarrier: clearPendingUploadRebindBarrier,
|
|
reset: resetPendingUploadRebind,
|
|
settledRequestId: pendingUploadRebindSettledRequestId,
|
|
start: startPendingUploadRebind,
|
|
startedRequestIdRef: pendingUploadRebindStartedIdRef,
|
|
} = usePendingSftpUploadRebind();
|
|
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
|
|
/** Last browsed path per endpoint — survives session switches while the panel stays open. */
|
|
const lastBrowsedPathByConnectionKeyRef = useRef<Map<string, string>>(new Map());
|
|
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
|
|
const [sftpUiReady, setSftpUiReady] = useState(false);
|
|
|
|
useEffect(() => {
|
|
pruneSftpSidePanelState(
|
|
tabConnectionKeyMapRef.current,
|
|
[
|
|
...sftp.leftTabs.tabs.map((tab) => tab.id),
|
|
...sftp.rightTabs.tabs.map((tab) => tab.id),
|
|
],
|
|
);
|
|
}, [sftp.leftTabs.tabs, sftp.rightTabs.tabs]);
|
|
|
|
const runAutoConnect = useCallback(() => {
|
|
if (!activeHost) return;
|
|
|
|
const s = sftpRef.current;
|
|
const hasActiveWork = interactiveWorkActive
|
|
|| (s.activeFileWatchCountRef?.current ?? 0) > 0
|
|
|| (s.activeExternalEditCount ?? 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,
|
|
activeHost.sftpFileProtocol,
|
|
);
|
|
const pendingMatchesTarget = Boolean(
|
|
pendingUpload?.hostId === activeHost.id
|
|
&& pendingUpload.connectionKey === connectionKey
|
|
);
|
|
const pendingStrictSourceSessionId = (
|
|
pendingMatchesTarget
|
|
&& pendingUpload?.sourceSessionId
|
|
&& pendingUpload.sourceSessionId === activeSessionId
|
|
) ? pendingUpload.sourceSessionId : undefined;
|
|
if (
|
|
pendingUpload?.hostId === activeHost.id
|
|
&& pendingUpload.connectionKey === connectionKey
|
|
&& pendingUpload.sourceSessionId
|
|
&& pendingUpload.sourceSessionId !== activeSessionId
|
|
) return;
|
|
if (shouldDeferPendingSftpUploadForOriginFocus({
|
|
originSessionId: pendingUpload?.originSessionId,
|
|
focusedSessionId,
|
|
})) return;
|
|
if (
|
|
pendingUpload
|
|
&& pendingUploadRebindStartedIdRef.current === pendingUpload.requestId
|
|
&& pendingUploadRebindSettledRequestId !== pendingUpload.requestId
|
|
) return;
|
|
const pendingRequiresForcedRebind = pendingUpload
|
|
? shouldStartPendingSftpUploadRebind({
|
|
pendingMatchesTarget,
|
|
requestId: pendingUpload.requestId,
|
|
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
|
originSessionId: pendingUpload.originSessionId,
|
|
sourceSessionId: pendingUpload.sourceSessionId,
|
|
})
|
|
: false;
|
|
const pendingSameEndpointSession = sessions.find((session) => (
|
|
isPendingSameEndpointSshSession(session, activeHost)
|
|
));
|
|
if (!activeSessionId && pendingSameEndpointSession) {
|
|
return;
|
|
}
|
|
const activeSession = activeSessionId
|
|
? sessions.find((session) => session.id === activeSessionId) ?? null
|
|
: null;
|
|
const activeSessionStatus = activeSession?.status ?? null;
|
|
if (
|
|
shouldDeferSftpSidePanelAutoConnectForSession({
|
|
activeSessionId,
|
|
sessionStatus: activeSessionStatus,
|
|
})
|
|
) {
|
|
// Remember the non-connected status so the later connected transition
|
|
// is treated as a transport rebind (same session id after Start over).
|
|
lastSourceSessionStatusRef.current = activeSessionStatus;
|
|
if (activeSessionId) {
|
|
lastSourceSessionIdRef.current = activeSessionId;
|
|
}
|
|
return;
|
|
}
|
|
const sessionChanged = shouldRebindSftpSidePanelSourceSession({
|
|
previousSessionId: lastSourceSessionIdRef.current,
|
|
nextSessionId: activeSessionId,
|
|
previousStatus: lastSourceSessionStatusRef.current,
|
|
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
|
|
});
|
|
|
|
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;
|
|
const liveConnectionKey = activeConnectionId
|
|
? s.getConnectionCacheKey?.(activeConnectionId) ?? null
|
|
: null;
|
|
const activeTabConnectionKey = liveConnectionKey
|
|
?? (activeTab ? tabConnectionKeyMapRef.current.get(activeTab.id) ?? null : null);
|
|
if (activeTab && activeTabConnectionKey) {
|
|
tabConnectionKeyMapRef.current.set(activeTab.id, activeTabConnectionKey);
|
|
}
|
|
// Rebind when the focused terminal SSH session changes: saved host keys can
|
|
// lag live session endpoints (edited host / unsaved user). Still keep the
|
|
// browsed path sticky via remembered initialPath below.
|
|
if (
|
|
!sessionChanged
|
|
&& !pendingRequiresForcedRebind
|
|
&& shouldSkipSftpSidePanelAutoConnect(
|
|
connectionKey,
|
|
connectedKeyRef.current,
|
|
activeTab,
|
|
activeConnectionId ? hasBackendSession(activeConnectionId) : false,
|
|
activeTabConnectionKey,
|
|
)
|
|
) {
|
|
if (activeSessionId) {
|
|
lastSourceSessionIdRef.current = activeSessionId;
|
|
}
|
|
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
|
|
previousStatus: lastSourceSessionStatusRef.current,
|
|
activeSessionId,
|
|
activeSessionStatus,
|
|
});
|
|
return;
|
|
}
|
|
// Defer advancing the session cursor while interactive work blocks rebind,
|
|
// so sessionChanged stays true once the editor/dialog closes.
|
|
if (hasActiveWork) return;
|
|
if (activeSessionId) {
|
|
lastSourceSessionIdRef.current = activeSessionId;
|
|
}
|
|
lastSourceSessionStatusRef.current = rememberSftpSidePanelSourceStatus({
|
|
previousStatus: lastSourceSessionStatusRef.current,
|
|
activeSessionId,
|
|
activeSessionStatus,
|
|
});
|
|
|
|
logger.info("[SftpSidePanel] Auto-connect triggered", {
|
|
hostId: activeHost.id,
|
|
hostLabel: activeHost.label,
|
|
protocol: activeHost.protocol,
|
|
hostname: activeHost.hostname,
|
|
sessionChanged,
|
|
});
|
|
|
|
const tabs = s.leftTabs.tabs;
|
|
// Session focus changes must rebind SFTP onto the new terminal SSH session
|
|
// (proxy/jump path can differ even when hostId/hostname/port/user match).
|
|
// Same-endpoint rebind happens in place below with remembered initialPath so
|
|
// we keep the browsed directory without stacking tabs.
|
|
const existingTab = sessionChanged || pendingRequiresForcedRebind
|
|
? null
|
|
: findReusableSftpSidePanelTab(
|
|
tabs,
|
|
activeHost.id,
|
|
connectionKey,
|
|
tabConnectionKeyMapRef.current,
|
|
hasBackendSession,
|
|
(connectionId) => s.getConnectionCacheKey?.(connectionId) ?? null,
|
|
);
|
|
if (existingTab) {
|
|
s.selectTab("left", existingTab.id);
|
|
// selectTab does not update reconnect metadata; keep lastConnectedHost
|
|
// aligned with the tab we just activated so channel drops rebind correctly.
|
|
// Pass tab id explicitly — selectTab has not flushed activeTabId yet.
|
|
s.setLastConnectedHost?.("left", activeHost, existingTab.id);
|
|
connectedKeyRef.current = connectionKey;
|
|
connectedHostObjRef.current = activeHost;
|
|
// Session memory keys are per terminal session; republish the visible
|
|
// path so reopening SFTP from the newly focused session keeps this dir.
|
|
const path = existingTab.connection?.currentPath;
|
|
if (
|
|
path
|
|
&& existingTab.connection
|
|
&& !existingTab.connection.isLocal
|
|
) {
|
|
onCurrentPathChangeRef.current?.({
|
|
hostId: existingTab.connection.hostId,
|
|
connectionKey,
|
|
path,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Capture the visible path before rebind so session switches keep it even
|
|
// if the path-memory effect has not written this endpoint yet.
|
|
if (
|
|
(sessionChanged || pendingRequiresForcedRebind)
|
|
&& activeTab?.connection
|
|
&& !activeTab.connection.isLocal
|
|
&& activeTab.connection.status === "connected"
|
|
&& activeTab.connection.currentPath
|
|
&& activeTabConnectionKey === connectionKey
|
|
) {
|
|
rememberSftpSidePanelPath(
|
|
lastBrowsedPathByConnectionKeyRef.current,
|
|
connectionKey,
|
|
activeTab.connection.currentPath,
|
|
);
|
|
onCurrentPathChangeRef.current?.({
|
|
hostId: activeTab.connection.hostId,
|
|
connectionKey,
|
|
path: activeTab.connection.currentPath,
|
|
});
|
|
}
|
|
|
|
const currentConn = s.leftPane.connection;
|
|
// Replace in place only when it is safe. Keep the old tab when:
|
|
// - local is active (distinct endpoint)
|
|
// - the target endpoint key differs
|
|
// - same-endpoint rebind would drop a connection still used by promoted
|
|
// editor tabs (they save via the old connection id)
|
|
const currentConnectionKey = currentConn && !currentConn.isLocal
|
|
? (
|
|
s.getConnectionCacheKey?.(currentConn.id)
|
|
?? tabConnectionKeyMapRef.current.get(s.leftPane.id)
|
|
?? null
|
|
)
|
|
: null;
|
|
const hasEditorBoundToCurrentConnection = !!(
|
|
currentConn
|
|
&& editorTabStore.getTabs().some((tab) =>
|
|
tab.sessionId === currentConn.id || tab.sftpTabId === s.leftPane.id,
|
|
)
|
|
);
|
|
const hasActiveTransferOnCurrentConnection = !!(
|
|
currentConn
|
|
&& s.transfers.some((task) => (
|
|
(task.status === "pending" || task.status === "transferring")
|
|
&& (
|
|
task.sourceConnectionId === currentConn.id
|
|
|| task.targetConnectionId === currentConn.id
|
|
)
|
|
))
|
|
);
|
|
const needsNewTab = !!(
|
|
currentConn
|
|
&& currentConn.status === "connected"
|
|
&& (
|
|
currentConn.isLocal
|
|
|| (
|
|
currentConnectionKey
|
|
&& currentConnectionKey !== connectionKey
|
|
)
|
|
// Same-endpoint rebind closes the old connection in place; keep a tab
|
|
// when editors or in-flight transfers still depend on that connection id.
|
|
|| (
|
|
(sessionChanged || pendingRequiresForcedRebind)
|
|
&& (hasEditorBoundToCurrentConnection || hasActiveTransferOnCurrentConnection)
|
|
)
|
|
)
|
|
);
|
|
const rememberedPath = recallSftpSidePanelPath(
|
|
lastBrowsedPathByConnectionKeyRef.current,
|
|
connectionKey,
|
|
);
|
|
const initialPath = resolveSftpAutoConnectPath({
|
|
explicitPath:
|
|
initialLocation?.hostId === activeHost.id ? initialLocation.path : null,
|
|
rememberedPath,
|
|
});
|
|
|
|
connectedKeyRef.current = connectionKey;
|
|
connectedHostObjRef.current = activeHost;
|
|
const connect = (
|
|
connectRequestKey?: string,
|
|
onConnectionCreated?: (target: { tabId: string; connectionId: string }) => void,
|
|
) => s.connect("left", activeHost, {
|
|
sourceSessionId: pendingStrictSourceSessionId
|
|
?? (activeSessionStatus === "connected" ? (activeSessionId ?? undefined) : undefined),
|
|
requireSourceSessionReuse: Boolean(pendingStrictSourceSessionId),
|
|
...(connectRequestKey ? { connectRequestKey } : undefined),
|
|
onConnectionCreated,
|
|
...(initialPath ? { initialPath } : undefined),
|
|
...(needsNewTab ? { forceNewTab: true } : undefined),
|
|
onTabCreated: (tabId) => {
|
|
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
|
|
},
|
|
});
|
|
if (pendingRequiresForcedRebind && pendingUpload) {
|
|
startPendingUploadRebind({
|
|
requestId: pendingUpload.requestId,
|
|
previousConnectionId: currentConn?.id ?? null,
|
|
connect: () => connect(
|
|
pendingUpload.requestId,
|
|
(target) => bindPendingUploadRebindTarget(pendingUpload.requestId, target),
|
|
),
|
|
});
|
|
return;
|
|
}
|
|
void connect();
|
|
}, [
|
|
activeHost,
|
|
activeSessionId,
|
|
bindPendingUploadRebindTarget,
|
|
focusedSessionId,
|
|
initialLocation,
|
|
interactiveWorkActive,
|
|
pendingUpload,
|
|
pendingUploadRebindSettledRequestId,
|
|
pendingUploadRebindStartedIdRef,
|
|
sessions,
|
|
startPendingUploadRebind,
|
|
]);
|
|
|
|
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(() => {
|
|
if (activeSessionId) return;
|
|
const trackedSessionId = lastSourceSessionIdRef.current;
|
|
const trackedSession = trackedSessionId
|
|
? sessions.find((candidate) => candidate.id === trackedSessionId) ?? null
|
|
: null;
|
|
const update = resolveSftpSidePanelTrackedSourceStatusUpdate({
|
|
trackedSessionId,
|
|
sessionStatus: trackedSession?.status ?? null,
|
|
});
|
|
if (!update) return;
|
|
lastSourceSessionStatusRef.current = update.status;
|
|
}, [activeSessionId, sessions]);
|
|
|
|
useEffect(() => {
|
|
const connection = sftp.leftPane.connection;
|
|
if (!connection || connection.status === "error" || connection.status === "disconnected") {
|
|
connectedKeyRef.current = null;
|
|
}
|
|
}, [sftp.leftPane.connection, sftp.leftPane.connection?.status]);
|
|
|
|
useEffect(() => {
|
|
if (!activeHost || !initialLocation) return;
|
|
const s = sftpRef.current;
|
|
const activePendingUpload = pendingUpload;
|
|
const pendingRequiresExactTarget = Boolean(
|
|
activePendingUpload
|
|
&& (activePendingUpload.originSessionId || activePendingUpload.sourceSessionId)
|
|
);
|
|
const rebindBarrier = pendingUploadRebindBarrierRef.current;
|
|
const pendingTargetPane = pendingRequiresExactTarget
|
|
&& rebindBarrier?.requestId === activePendingUpload?.requestId
|
|
? findPendingSftpRebindTargetPane(
|
|
s.leftTabs.tabs,
|
|
s.rightTabs.tabs,
|
|
rebindBarrier.targetTabId,
|
|
rebindBarrier.targetConnectionId,
|
|
)
|
|
: null;
|
|
const targetPane = pendingRequiresExactTarget ? pendingTargetPane : s.leftPane;
|
|
const connection = targetPane?.connection;
|
|
const targetSide = targetPane && s.rightTabs.tabs.some((tab) => tab.id === targetPane.id)
|
|
? "right"
|
|
: "left";
|
|
const expectedConnectionKey = buildCacheKey(
|
|
activeHost.id,
|
|
activeHost.hostname,
|
|
activeHost.port,
|
|
activeHost.protocol,
|
|
activeHost.sftpSudo,
|
|
activeHost.username,
|
|
activeHost.sftpFileProtocol,
|
|
);
|
|
const actualConnectionKey = connection
|
|
? (
|
|
s.getConnectionCacheKey?.(connection.id)
|
|
?? (targetPane ? tabConnectionKeyMapRef.current.get(targetPane.id) ?? null : null)
|
|
)
|
|
: null;
|
|
if (!canApplySftpSidePanelInitialLocation({
|
|
activeHostId: activeHost.id,
|
|
initialLocation,
|
|
expectedConnectionKey,
|
|
actualConnectionKey,
|
|
pendingRequiresExactTarget,
|
|
pendingTargetConnectionId: pendingRequiresExactTarget
|
|
? rebindBarrier?.targetConnectionId ?? null
|
|
: null,
|
|
connection,
|
|
})) return;
|
|
|
|
const locationKey = `${connection!.id}:${initialLocation.path}`;
|
|
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
|
|
|
|
lastAppliedInitialLocationKeyRef.current = locationKey;
|
|
onInitialLocationApplied?.(initialLocation);
|
|
|
|
if (connection.currentPath === initialLocation.path) {
|
|
return;
|
|
}
|
|
|
|
void s.navigateTo(targetSide, initialLocation.path, { tabId: targetPane!.id });
|
|
}, [
|
|
activeHost,
|
|
initialLocation,
|
|
onInitialLocationApplied,
|
|
pendingUpload,
|
|
pendingUploadRebindBarrierRef,
|
|
sftp.leftPane,
|
|
sftp.leftTabs.tabs,
|
|
sftp.rightTabs.tabs,
|
|
]);
|
|
|
|
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;
|
|
|
|
// Prefer the connect-time endpoint map (includes session overrides / picker
|
|
// switches). Fall back to rebuilding from the host object only when missing.
|
|
let connectionKey =
|
|
sftp.getConnectionCacheKey?.(connection.id)
|
|
?? tabConnectionKeyMapRef.current.get(sftp.leftPane.id)
|
|
?? null;
|
|
if (!connectionKeyMatchesHost(connectionKey, connection.hostId)) {
|
|
const host =
|
|
(activeHost?.id === connection.hostId ? activeHost : null)
|
|
?? hosts.find((candidate) => candidate.id === connection.hostId)
|
|
?? null;
|
|
if (!host) return;
|
|
connectionKey = buildCacheKey(
|
|
host.id,
|
|
host.hostname,
|
|
host.port,
|
|
host.protocol,
|
|
host.sftpSudo,
|
|
host.username,
|
|
host.sftpFileProtocol,
|
|
);
|
|
}
|
|
tabConnectionKeyMapRef.current.set(sftp.leftPane.id, connectionKey);
|
|
|
|
rememberSftpSidePanelPath(
|
|
lastBrowsedPathByConnectionKeyRef.current,
|
|
connectionKey,
|
|
connection.currentPath,
|
|
);
|
|
onCurrentPathChangeRef.current?.({
|
|
hostId: connection.hostId,
|
|
connectionKey,
|
|
path: connection.currentPath,
|
|
});
|
|
}, [
|
|
activeHost,
|
|
hosts,
|
|
sftp,
|
|
sftp.leftPane.connection,
|
|
sftp.leftPane.connection?.currentPath,
|
|
sftp.leftPane.connection?.hostId,
|
|
sftp.leftPane.connection?.status,
|
|
sftp.leftPane.id,
|
|
onCurrentPathChange,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingUpload) return;
|
|
if (handledPendingUploadIdRef.current === pendingUpload.requestId) return;
|
|
|
|
const rebindBarrier = pendingUploadRebindBarrierRef.current;
|
|
const forcedRebindTargetPane = (
|
|
rebindBarrier?.requestId === pendingUpload.requestId
|
|
&& pendingUploadRebindSettledRequestId === pendingUpload.requestId
|
|
)
|
|
? findPendingSftpRebindTargetPane(
|
|
sftpRef.current.leftTabs.tabs,
|
|
sftpRef.current.rightTabs.tabs,
|
|
rebindBarrier.targetTabId,
|
|
rebindBarrier.targetConnectionId,
|
|
)
|
|
: null;
|
|
const activePane = forcedRebindTargetPane ?? leftPane;
|
|
const connection = activePane.connection;
|
|
const activeSessionStatus = activeSessionId
|
|
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
|
|
: null;
|
|
const originSessionStatus = pendingUpload.originSessionId
|
|
? sessions.find((session) => session.id === pendingUpload.originSessionId)?.status ?? null
|
|
: undefined;
|
|
if (pendingUploadFocusSeenRef.current?.requestId !== pendingUpload.requestId) {
|
|
pendingUploadFocusSeenRef.current = {
|
|
requestId: pendingUpload.requestId,
|
|
originFocused: false,
|
|
sourceActive: false,
|
|
};
|
|
}
|
|
const focusSeen = pendingUploadFocusSeenRef.current;
|
|
if (
|
|
pendingUpload.originSessionId
|
|
&& focusedSessionId === pendingUpload.originSessionId
|
|
) {
|
|
focusSeen.originFocused = true;
|
|
}
|
|
if (
|
|
pendingUpload.sourceSessionId
|
|
&& activeSessionId === pendingUpload.sourceSessionId
|
|
) {
|
|
focusSeen.sourceActive = true;
|
|
}
|
|
const waitingForOriginFocus = Boolean(
|
|
pendingUpload.originSessionId
|
|
&& focusedSessionId
|
|
&& focusedSessionId !== pendingUpload.originSessionId
|
|
&& !focusSeen.originFocused
|
|
&& originSessionStatus !== "disconnected"
|
|
&& originSessionStatus !== null
|
|
);
|
|
const waitingForSourceSession = Boolean(
|
|
pendingUpload.sourceSessionId
|
|
&& activeSessionId
|
|
&& activeSessionId !== pendingUpload.sourceSessionId
|
|
&& !focusSeen.sourceActive
|
|
);
|
|
const cancellationReason = resolvePendingSftpUploadCancellation({
|
|
pendingHostId: pendingUpload.hostId,
|
|
pendingOriginSessionId: pendingUpload.originSessionId,
|
|
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
|
originSessionStatus,
|
|
activeHostId: activeHost?.id ?? null,
|
|
activeSessionId: activeSessionId ?? null,
|
|
focusedSessionId: focusedSessionId ?? null,
|
|
panelVisible: isVisible,
|
|
waitingForOriginFocus,
|
|
waitingForSourceSession,
|
|
connection,
|
|
});
|
|
const cancelPendingUpload = () => {
|
|
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
|
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
|
toast.error(t("terminal.dragDrop.uploadCancelled"), "SFTP");
|
|
onPendingUploadHandled?.(pendingUpload.requestId);
|
|
};
|
|
if (shouldCancelPendingSftpUpload(cancellationReason, true)) {
|
|
cancelPendingUpload();
|
|
return;
|
|
}
|
|
if (waitingForOriginFocus || waitingForSourceSession) return;
|
|
if (shouldCancelSettledPendingSftpRebindWithoutTarget({
|
|
pendingRequiresRebind: Boolean(
|
|
pendingUpload.originSessionId || pendingUpload.sourceSessionId
|
|
),
|
|
requestId: pendingUpload.requestId,
|
|
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
|
settledRequestId: pendingUploadRebindSettledRequestId,
|
|
barrierRequestId: rebindBarrier?.requestId,
|
|
targetTabId: rebindBarrier?.targetTabId,
|
|
targetConnectionId: rebindBarrier?.targetConnectionId,
|
|
targetExists: Boolean(forcedRebindTargetPane),
|
|
})) {
|
|
cancelPendingUpload();
|
|
return;
|
|
}
|
|
if (!activeHost) return;
|
|
if (shouldBlockPendingSftpUploadForSourceRebind({
|
|
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
|
previousSessionId: lastSourceSessionIdRef.current,
|
|
activeSessionId,
|
|
previousStatus: lastSourceSessionStatusRef.current,
|
|
activeStatus: activeSessionStatus,
|
|
})) return;
|
|
const waitingForForcedRebind = shouldWaitForPendingSftpRebind({
|
|
pendingRequiresRebind: Boolean(
|
|
pendingUpload.originSessionId || pendingUpload.sourceSessionId
|
|
),
|
|
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
|
requestId: pendingUpload.requestId,
|
|
startedRequestId: pendingUploadRebindStartedIdRef.current,
|
|
settledRequestId: pendingUploadRebindSettledRequestId,
|
|
tabId: activePane.id,
|
|
connectionId: connection?.id,
|
|
barrierRequestId: rebindBarrier?.requestId,
|
|
previousConnectionId: rebindBarrier?.previousConnectionId,
|
|
targetTabId: rebindBarrier?.targetTabId,
|
|
targetConnectionId: rebindBarrier?.targetConnectionId,
|
|
});
|
|
if (shouldCancelPendingSftpUpload(cancellationReason, waitingForForcedRebind)) {
|
|
// Only fail a strict drop after its replacement connection (not the old
|
|
// disconnected pane) is the connection being evaluated.
|
|
cancelPendingUpload();
|
|
return;
|
|
}
|
|
if (waitingForForcedRebind) return;
|
|
// Prefer the live connection cache key (includes session overrides). Fall
|
|
// back to the tab map only when the connect-time stamp is not yet readable.
|
|
const paneConnectionKey = connection && !connection.isLocal
|
|
? (
|
|
getConnectionCacheKey?.(connection.id)
|
|
?? tabConnectionKeyMapRef.current.get(activePane.id)
|
|
?? null
|
|
)
|
|
: null;
|
|
if (!shouldAcceptPendingSftpUpload({
|
|
ownerPanelOpen,
|
|
pendingHostId: pendingUpload.hostId,
|
|
pendingConnectionKey: pendingUpload.connectionKey,
|
|
pendingSourceSessionId: pendingUpload.sourceSessionId,
|
|
activeHostId: activeHost.id,
|
|
connection,
|
|
paneConnectionKey,
|
|
}) || !connection) {
|
|
return;
|
|
}
|
|
|
|
if (rebindBarrier?.requestId === pendingUpload.requestId) {
|
|
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
|
}
|
|
|
|
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
|
|
|
const pinnedConnectionId = connection.id;
|
|
const pinnedTabId = activePane.id;
|
|
const runUpload = async () => {
|
|
try {
|
|
const results = await sftpRef.current.uploadExternalEntries("left", pendingUpload.entries, {
|
|
targetPath: pendingUpload.targetPath,
|
|
connectionId: pinnedConnectionId,
|
|
tabId: pinnedTabId,
|
|
strictConnectionPin: true,
|
|
});
|
|
reportSftpUploadResults({
|
|
results,
|
|
targetPath: pendingUpload.targetPath,
|
|
t,
|
|
toast,
|
|
});
|
|
} 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,
|
|
activeSessionId,
|
|
clearPendingUploadRebindBarrier,
|
|
focusedSessionId,
|
|
getConnectionCacheKey,
|
|
isVisible,
|
|
leftPane,
|
|
onPendingUploadHandled,
|
|
ownerPanelOpen,
|
|
pendingUpload,
|
|
pendingUploadRebindBarrierRef,
|
|
pendingUploadRebindSettledRequestId,
|
|
pendingUploadRebindStartedIdRef,
|
|
sessions,
|
|
t,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!pendingUpload) {
|
|
pendingUploadFocusSeenRef.current = null;
|
|
resetPendingUploadRebind();
|
|
return;
|
|
}
|
|
if (!ownerPanelOpen) {
|
|
if (handledPendingUploadIdRef.current !== pendingUpload.requestId) {
|
|
handledPendingUploadIdRef.current = pendingUpload.requestId;
|
|
clearPendingUploadRebindBarrier(pendingUpload.requestId);
|
|
toast.error(t("terminal.dragDrop.uploadCancelled"), "SFTP");
|
|
onPendingUploadHandled?.(pendingUpload.requestId);
|
|
}
|
|
}
|
|
}, [
|
|
clearPendingUploadRebindBarrier,
|
|
onPendingUploadHandled,
|
|
ownerPanelOpen,
|
|
pendingUpload,
|
|
resetPendingUploadRebind,
|
|
t,
|
|
]);
|
|
|
|
return (
|
|
<SftpSidePanelDeferredMount ready={sftpUiReady} onReady={() => setSftpUiReady(true)}>
|
|
<SftpSidePanelInteractiveBody
|
|
hosts={hosts}
|
|
hostWriteSource={hostWriteSource}
|
|
connectedHosts={connectedHosts}
|
|
sessions={sessions}
|
|
updateHosts={updateHosts}
|
|
sftp={sftp}
|
|
sftpRef={sftpRef}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
activeHost={activeHost}
|
|
activeSessionId={activeSessionId}
|
|
focusedSessionId={focusedSessionId}
|
|
showWorkspaceHostHeader={showWorkspaceHostHeader}
|
|
renderOverlays={renderOverlays}
|
|
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
|
|
sftpAutoSync={sftpAutoSync}
|
|
hotkeyScheme={hotkeyScheme}
|
|
keyBindings={keyBindings}
|
|
editorWordWrap={editorWordWrap}
|
|
setEditorWordWrap={setEditorWordWrap}
|
|
onGetTerminalCwd={onGetTerminalCwd}
|
|
activeTerminalCwd={activeTerminalCwd}
|
|
activeTerminalCwdTrusted={activeTerminalCwdTrusted}
|
|
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
|
|
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
|
|
onRequestTerminalFocus={onRequestTerminalFocus}
|
|
isVisible={isVisible}
|
|
ownerPanelOpen={ownerPanelOpen}
|
|
behaviorRef={behaviorRef}
|
|
autoSyncRef={autoSyncRef}
|
|
connectedHostObjRef={connectedHostObjRef}
|
|
connectedKeyRef={connectedKeyRef}
|
|
onInteractiveWorkChange={setInteractiveWorkActive}
|
|
listSftp={listSftp}
|
|
mkdirLocal={mkdirLocal}
|
|
deleteLocalFile={deleteLocalFile}
|
|
showSaveDialog={showSaveDialog}
|
|
selectDirectory={selectDirectory}
|
|
listLocalDir={listLocalDir}
|
|
listDrives={listDrives}
|
|
openPath={openPath}
|
|
t={t}
|
|
/>
|
|
</SftpSidePanelDeferredMount>
|
|
);
|
|
};
|
|
|
|
type SftpSidePanelInteractiveBodyProps = {
|
|
hosts: Host[];
|
|
hostWriteSource: Host[];
|
|
connectedHosts: import("../domain/sftpConnectedHosts").SftpConnectedHostEntry[];
|
|
sessions: TerminalSession[];
|
|
updateHosts: (hosts: Host[]) => void;
|
|
sftp: ReturnType<typeof useSftpState>;
|
|
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
|
|
sftpDefaultViewMode: "list" | "tree";
|
|
activeHost: Host | null;
|
|
activeSessionId?: string | null;
|
|
focusedSessionId?: string | null;
|
|
showWorkspaceHostHeader: boolean;
|
|
renderOverlays: boolean;
|
|
sftpDoubleClickBehavior: "open" | "transfer";
|
|
sftpAutoSync: boolean;
|
|
hotkeyScheme: HotkeyScheme;
|
|
keyBindings: KeyBinding[];
|
|
editorWordWrap: boolean;
|
|
setEditorWordWrap: (value: boolean) => void;
|
|
onGetTerminalCwd?: (options?: {
|
|
preferFreshBackend?: boolean;
|
|
allowRendererFallback?: boolean;
|
|
requireActiveShellCwd?: boolean;
|
|
}) => Promise<string | null>;
|
|
activeTerminalCwd?: string | null;
|
|
activeTerminalCwdTrusted: boolean;
|
|
sftpFollowTerminalCwd: boolean;
|
|
onSftpFollowTerminalCwdChange?: (enabled: boolean, host?: Host | null) => void;
|
|
onRequestTerminalFocus?: () => void;
|
|
isVisible: boolean;
|
|
/** Side panel still open for this terminal tab (another tool/tab may have focus). */
|
|
ownerPanelOpen: 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"];
|
|
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,
|
|
connectedHosts,
|
|
sessions,
|
|
updateHosts,
|
|
sftp,
|
|
sftpRef,
|
|
sftpDefaultViewMode,
|
|
activeHost,
|
|
activeSessionId = null,
|
|
focusedSessionId = null,
|
|
showWorkspaceHostHeader,
|
|
renderOverlays,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
editorWordWrap,
|
|
setEditorWordWrap,
|
|
onGetTerminalCwd,
|
|
activeTerminalCwd = null,
|
|
activeTerminalCwdTrusted,
|
|
sftpFollowTerminalCwd,
|
|
onSftpFollowTerminalCwdChange,
|
|
onRequestTerminalFocus,
|
|
isVisible,
|
|
ownerPanelOpen,
|
|
behaviorRef,
|
|
autoSyncRef,
|
|
connectedHostObjRef,
|
|
connectedKeyRef,
|
|
onInteractiveWorkChange,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
listLocalDir,
|
|
listDrives,
|
|
openPath,
|
|
t,
|
|
}) => {
|
|
const panelRootRef = useRef<HTMLDivElement>(null);
|
|
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
|
|
const terminalBackend = useTerminalBackend();
|
|
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,
|
|
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
|
|
|| (sftp.activeExternalEditCount ?? 0) > 0;
|
|
const connectionId = sftp.leftPane.connection?.id ?? null;
|
|
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
|
|
|
|
const {
|
|
handleGoToTerminalCwd,
|
|
handleToggleFollowTerminalCwd,
|
|
} = useSftpFollowTerminalCwd({
|
|
activeSessionId,
|
|
focusedSessionId,
|
|
activeTerminalCwd,
|
|
activeTerminalCwdTrusted,
|
|
canFollowTerminalCwd,
|
|
connectionId,
|
|
connectionIsLocal: sftp.leftPane.connection?.isLocal,
|
|
connectionLoading: sftp.leftPane.loading,
|
|
connectionPath,
|
|
connectionStatus: sftp.leftPane.connection?.status,
|
|
effectiveFollowTerminalCwd,
|
|
followTerminalCwdHost,
|
|
hasActiveWork,
|
|
isVisible,
|
|
ownerPanelOpen,
|
|
onGetTerminalCwd,
|
|
onPendingFollowOverride: setPendingFollowOverride,
|
|
onSftpFollowTerminalCwdChange,
|
|
sftpRef,
|
|
});
|
|
|
|
// Match toolbar path semantics: keep the last confirmed path while navigateTo
|
|
// has optimistically replaced connection.currentPath during an uncached load.
|
|
const confirmedLocatePathRef = useRef(connectionPath ?? "");
|
|
const prevLocateConnectionIdRef = useRef(connectionId ?? undefined);
|
|
const [confirmedLocatePath, setConfirmedLocatePath] = useState(connectionPath ?? "");
|
|
useEffect(() => {
|
|
const previousConnectionId = prevLocateConnectionIdRef.current;
|
|
prevLocateConnectionIdRef.current = connectionId ?? undefined;
|
|
setConfirmedLocatePath((previousDisplayPath) => {
|
|
const next = getNextSftpToolbarDisplayPath({
|
|
previousDisplayPath,
|
|
previousConnectionId,
|
|
connectionId: connectionId ?? undefined,
|
|
currentPath: connectionPath ?? undefined,
|
|
loading: sftp.leftPane.loading,
|
|
});
|
|
confirmedLocatePathRef.current = next;
|
|
return next;
|
|
});
|
|
}, [connectionId, connectionPath, sftp.leftPane.loading]);
|
|
|
|
const locatePathInTerminalContext = useMemo(() => {
|
|
const connection = sftp.leftPane.connection;
|
|
const locateSessionId = resolveLocateSftpPathSessionId({
|
|
activeSessionId,
|
|
focusedSessionId,
|
|
});
|
|
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
|
|
const host = displayHost ?? activeHost;
|
|
const isNetworkDevice = host?.deviceType === "network"
|
|
|| classifyDistroId(host?.distro) === "network-device";
|
|
return {
|
|
path: confirmedLocatePath || connection?.currentPath,
|
|
sessionId: locateSessionId,
|
|
sessionStatus: session?.status,
|
|
sessionHostId: session?.hostId,
|
|
sftpHostId: connection?.hostId,
|
|
sftpIsLocal: Boolean(connection?.isLocal),
|
|
protocol: session?.protocol ?? host?.protocol,
|
|
shellType: session?.shellType,
|
|
isNetworkDevice,
|
|
moshEnabled: session?.moshEnabled,
|
|
etEnabled: session?.etEnabled,
|
|
sessionHostname: session?.hostname,
|
|
sessionUsername: session?.username,
|
|
sessionPort: session?.port,
|
|
sftpHostname: host?.hostname,
|
|
sftpUsername: host?.username,
|
|
sftpPort: host?.port,
|
|
};
|
|
}, [
|
|
activeHost,
|
|
activeSessionId,
|
|
confirmedLocatePath,
|
|
displayHost,
|
|
focusedSessionId,
|
|
sessions,
|
|
sftp.leftPane.connection,
|
|
]);
|
|
|
|
const canLocatePathInTerminal = canLocateSftpPathInTerminal(locatePathInTerminalContext);
|
|
|
|
const handleLocatePathInTerminal = useCallback(() => {
|
|
const connection = sftpRef.current.leftPane.connection;
|
|
const locateSessionId = resolveLocateSftpPathSessionId({
|
|
activeSessionId,
|
|
focusedSessionId,
|
|
});
|
|
const session = sessions.find((candidate) => candidate.id === locateSessionId) ?? null;
|
|
const host = displayHost ?? activeHost;
|
|
const isNetworkDevice = host?.deviceType === "network"
|
|
|| classifyDistroId(host?.distro) === "network-device";
|
|
const action = resolveLocateSftpPathInTerminalAction({
|
|
// Prefer the path shown in the toolbar, not an in-flight optimistic cwd.
|
|
path: confirmedLocatePathRef.current || connection?.currentPath,
|
|
sessionId: locateSessionId,
|
|
sessionStatus: session?.status,
|
|
sessionHostId: session?.hostId,
|
|
sftpHostId: connection?.hostId,
|
|
sftpIsLocal: Boolean(connection?.isLocal),
|
|
protocol: session?.protocol ?? host?.protocol,
|
|
shellType: session?.shellType,
|
|
isNetworkDevice,
|
|
moshEnabled: session?.moshEnabled,
|
|
etEnabled: session?.etEnabled,
|
|
sessionHostname: session?.hostname,
|
|
sessionUsername: session?.username,
|
|
sessionPort: session?.port,
|
|
sftpHostname: host?.hostname,
|
|
sftpUsername: host?.username,
|
|
sftpPort: host?.port,
|
|
});
|
|
if (!action) return;
|
|
// Never inject cd into a password/sudo prompt (same guard as snippets/broadcast).
|
|
if (isTerminalSensitiveInputActive(action.sessionId)) return;
|
|
// Only submit at an idle shell prompt -- never append into typed input or a TUI.
|
|
if (!isTerminalReadyForCommandInjection(action.sessionId)) return;
|
|
terminalBackend.writeToSession(action.sessionId, action.data, { automated: true });
|
|
scheduleDeferredTerminalFocus(onRequestTerminalFocus);
|
|
}, [
|
|
activeHost,
|
|
activeSessionId,
|
|
displayHost,
|
|
focusedSessionId,
|
|
onRequestTerminalFocus,
|
|
sessions,
|
|
sftpRef,
|
|
terminalBackend,
|
|
]);
|
|
|
|
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}
|
|
connectedHosts={connectedHosts}
|
|
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={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
|
|
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}
|
|
onLocatePathInTerminal={canLocatePathInTerminal ? handleLocatePathInTerminal : 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}
|
|
connectedHosts={connectedHosts}
|
|
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 &&
|
|
sftpPickerSessionsEqual(prev.sessions, next.sessions) &&
|
|
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.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 &&
|
|
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.activeTerminalCwdTrusted === next.activeTerminalCwdTrusted &&
|
|
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
|
|
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
|
|
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
|
|
prev.onCurrentPathChange === next.onCurrentPathChange &&
|
|
prev.onActiveTransfersChange === next.onActiveTransfersChange &&
|
|
prev.onActiveExternalEditsChange === next.onActiveExternalEditsChange &&
|
|
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";
|