/** * SftpView - SFTP File Browser (Refactored) * * This is the main SFTP view component that provides a dual-pane file browser * for transferring files between local and remote systems. * * Components have been extracted to: * - components/sftp/utils.ts - Utility functions * - components/sftp/SftpBreadcrumb.tsx - Path navigation * - components/sftp/SftpFileRow.tsx - File list row * - components/sftp/SftpTransferItem.tsx - Transfer queue item * - components/sftp/SftpConflictDialog.tsx - Conflict resolution * - components/sftp/SftpPermissionsDialog.tsx - Permissions editor * - components/sftp/SftpHostPicker.tsx - Host selection dialog */ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { activeTabStore as globalActiveTabStore, useIsSftpActive } from "../application/state/activeTabStore"; import { useSftpState } from "../application/state/useSftpState"; import { useSftpBackend } from "../application/state/useSftpBackend"; import { getParentPath, isConcreteTransferTargetPath } from "../application/state/sftp/utils"; import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache"; import { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models"; import { getPaneMagnificationShortcutLabel, resolveTwoPaneMagnificationStyle, type PaneMagnificationController, } from "../domain/paneMagnification"; import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts"; import { applyDualPaneSftpOpen, dualPaneTabFromPane } from "../domain/sftpDualPaneOpen"; import { consumePendingDualPaneSftpRequest, subscribeDualPaneSftpOpen, } from "../application/state/sftp/sftpDualPaneOpenStore"; import { logger } from "../lib/logger"; import { useRenderTracker } from "../lib/useRenderTracker"; import { cn } from "../lib/utils"; import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types"; import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig"; import { materializeHostProxyProfile } from "../domain/proxyProfiles"; import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations"; import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge"; import { toast } from "./ui/toast"; // Import extracted components import { SftpTabBar } from "./sftp"; import { SftpPaneView, SftpPaneWrapper } from "./sftp/SftpPaneView"; import { SftpOverlays } from "./sftp/SftpOverlays"; import { Loader2 } from "lucide-react"; // Import context hooks import { SftpContextProvider, activeTabStore } from "./sftp"; import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"; import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs"; import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts"; import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "../application/state/sftp/sftpFocusStore"; import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope"; // Wrapper component that subscribes to activeTabId for CSS visibility // This isolates the activeTabId subscription - only this component re-renders on tab switch // Uses visibility:hidden pattern from App.tsx for smooth tab switching // Main SftpView component interface SftpViewProps { hosts: Host[]; /** Vault-persisted hosts only; used for writes so ephemeral deep-link hosts stay out of vault. */ writableHosts?: Host[]; sessions?: TerminalSession[]; keys: SSHKey[]; identities: Identity[]; knownHosts?: KnownHost[]; groupConfigs?: import('../domain/models').GroupConfig[]; proxyProfiles?: ProxyProfile[]; updateHosts: (hosts: Host[]) => void; onAddKnownHost?: (knownHost: KnownHost) => void; sftpDefaultViewMode: "list" | "tree"; sftpDoubleClickBehavior: "open" | "transfer"; sftpAutoSync: boolean; sftpShowHiddenFiles: boolean; sftpUseCompressedUpload: boolean; hotkeyScheme: HotkeyScheme; keyBindings: KeyBinding[]; editorWordWrap: boolean; setEditorWordWrap: (enabled: boolean) => void; terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number }; paneMagnificationRef?: React.MutableRefObject; } const SftpViewInner: React.FC = ({ hosts, writableHosts, sessions = [], keys, identities, knownHosts = [], groupConfigs = [], proxyProfiles = [], updateHosts, onAddKnownHost, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings, editorWordWrap, setEditorWordWrap, terminalSettings, paneMagnificationRef, }) => { const { t } = useI18n(); const paneMagnificationShortcutLabel = getPaneMagnificationShortcutLabel(keyBindings, hotkeyScheme); const isActive = useIsSftpActive(); const rootRef = useRef(null); const dialogActionScopeIdRef = useRef("sftp-main-view"); // File watch event handlers (stable refs to avoid re-creating the useSftpState options) 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 resolveTransferSourceSessionId = useCallback((hostId: string, host?: Host) => { const hostsById = new Map(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]); const sftpOptions = useMemo(() => ({ ...fileWatchHandlers, transferOwnerId: "main-sftp-view", // Main SFTP page stays interactive while mounted so top-tab switches // (e.g. Terminal ↔ SFTP) must not soft-close every tab's session. // The terminal side panel parks only after the panel is closed (not when // switching History/System while the chrome stays open). // Bulk transfers use dedicated pool sessions regardless. interactive: true, useCompressedUpload: sftpUseCompressedUpload, defaultShowHiddenFiles: sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost, resolveTransferSourceSessionId, }), [ fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost, resolveTransferSourceSessionId, ]); // Pre-resolve group defaults so SFTP connections inherit group config const effectiveHosts = useMemo(() => { const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id)); return hosts.map(h => { const withGroupDefaults = h.group ? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds }) : applyGroupDefaults(h, {}, { validProxyProfileIds }); return materializeHostProxyProfile(withGroupDefaults, proxyProfiles); }); }, [hosts, groupConfigs, proxyProfiles]); const hostWriteSource = writableHosts ?? hosts; const connectedHosts = useMemo(() => { const hostsById = new Map( effectiveHosts.map((host) => [host.id, host]), ); return listSftpConnectedHosts(sessions, hostsById); }, [effectiveHosts, sessions]); const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions); // Get backend helpers for file downloads and local filesystem writes. const { showSaveDialog, selectDirectory, listSftp, mkdirLocal, deleteLocalFile, listLocalDir, listDrives, openPath, } = useSftpBackend(); // Store sftp in a ref so callbacks can access the latest instance // without needing to re-create when sftp changes const sftpRef = useRef(sftp); sftpRef.current = sftp; const effectiveHostsRef = useRef(effectiveHosts); effectiveHostsRef.current = effectiveHosts; useEffect(() => { const toTabs = (panes: Array<{ id: string; connection: { id: string; isLocal?: boolean; hostId?: string | null; status?: string | null } | null; }>, getEndpointKey: (connectionId: string) => string | null) => panes.map( (pane) => dualPaneTabFromPane( pane, pane.connection ? getEndpointKey(pane.connection.id) : null, ), ); const applyRequest = (request: { hostId: string } | null) => { if (!request) return; const host = effectiveHostsRef.current.find((candidate) => candidate.id === request.hostId); if (!host) return; const current = sftpRef.current; const hostEndpointKey = buildCacheKey( host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol, ); // External "Open SFTP" promises a visible local-left / host-right pair. // Clear a stale single-pane magnification before selecting or connecting. setMagnifiedSide(null); applyDualPaneSftpOpen( { leftTabs: toTabs(current.leftTabs.tabs, current.getConnectionCacheKey), rightTabs: toTabs(current.rightTabs.tabs, current.getConnectionCacheKey), selectTab: current.selectTab, connect: (side, nextHost, options) => { void current.connect(side, nextHost === "local" ? "local" : host, options); }, }, host, hostEndpointKey, ); }; applyRequest(consumePendingDualPaneSftpRequest()); return subscribeDualPaneSftpOpen(applyRequest); }, []); // Register this useSftpState's writeTextFileByConnection with the bridge so // the editor tab's save path can reach the active SFTP session. The bridge // supports multiple simultaneous writers (SftpSidePanel inside terminals // also registers its own instance) and dispatches by trying each until one // owns the target connectionId. // // Intentionally no deps: `sftp` identity churns on every SFTP state change // (transfers, pane updates, tab switches), which would make this effect // unregister+reregister constantly. Route through sftpRef so the closure // always reads the latest writeTextFileByConnection; that method is stable // across sftp re-renders (it's a methodsRef-backed dispatcher). useEffect(() => { return registerEditorSftpWriterScoped((connectionId, expectedHostId, filePath, content, encoding, sftpTabId) => sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding, sftpTabId), ); }, []); // Store behavior setting in ref for stable callbacks const behaviorRef = useRef(sftpDoubleClickBehavior); behaviorRef.current = sftpDoubleClickBehavior; // Store auto-sync setting in ref for stable callbacks const autoSyncRef = useRef(sftpAutoSync); autoSyncRef.current = sftpAutoSync; // SFTP keyboard shortcuts handler useSftpKeyboardShortcuts({ keyBindings, hotkeyScheme, sftpRef, dialogActionScopeId: dialogActionScopeIdRef.current, isActive, }); // Subscribe to focused side for visual indicator const focusedSide = useSftpFocusedSide(); const [magnifiedSide, setMagnifiedSide] = useState(null); const focusedSideRef = useRef(focusedSide); const magnifiedSideRef = useRef(magnifiedSide); focusedSideRef.current = focusedSide; magnifiedSideRef.current = magnifiedSide; const [isWideSplit, setIsWideSplit] = useState(true); const [showMagnificationHint, setShowMagnificationHint] = useState(false); const splitSurfaceRef = useRef(null); useLayoutEffect(() => { const surface = splitSurfaceRef.current; if (!surface) return undefined; const update = () => setIsWideSplit(surface.clientWidth >= 1024); update(); if (typeof ResizeObserver === 'undefined') return undefined; const observer = new ResizeObserver(update); observer.observe(surface); return () => observer.disconnect(); }, []); useEffect(() => { if (!magnifiedSide) { setShowMagnificationHint(false); return undefined; } setShowMagnificationHint(true); const timerId = window.setTimeout(() => setShowMagnificationHint(false), 1800); return () => window.clearTimeout(timerId); }, [magnifiedSide]); useEffect(() => { if (!paneMagnificationRef) return undefined; const controller: PaneMagnificationController = { getState: () => { if (globalActiveTabStore.getActiveTabId() !== 'sftp') return 'unavailable'; return magnifiedSideRef.current ? 'focused' : 'focusable'; }, focus: () => { if (globalActiveTabStore.getActiveTabId() !== 'sftp' || magnifiedSideRef.current) return false; setMagnifiedSide(focusedSideRef.current); return true; }, restore: () => { if (globalActiveTabStore.getActiveTabId() !== 'sftp' || !magnifiedSideRef.current) return false; setMagnifiedSide(null); return true; }, toggle: () => { if (globalActiveTabStore.getActiveTabId() !== 'sftp') return false; setMagnifiedSide((current) => current ? null : focusedSideRef.current); return true; }, }; paneMagnificationRef.current = controller; return () => { if (paneMagnificationRef.current === controller) paneMagnificationRef.current = null; }; }, [paneMagnificationRef]); // Handle pane focus when clicking on a pane container // Clear the opposite side's selection so file operations only affect the focused pane const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => { const prevSide = sftpFocusStore.getFocusedSide(); sftpFocusStore.setFocusedSide(side); setMagnifiedSide((current) => current ? side : null); if (prevSide !== side) { if (targetTabId) { keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId }); } else { // Focus side changed — clear other panes but keep the newly focused pane intact. keepOnlyActivePaneSelections(sftpRef.current, side); } } }, []); const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => { const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs; const pane = sideTabs.tabs.find((tab) => tab.id === paneId); if (!pane) return; sftpRef.current.setShowHiddenFiles(side, paneId, !pane.showHiddenFiles); }, []); // Sync activeTabId to external store (allows child components to subscribe without parent re-render) // Using useLayoutEffect to sync before paint useLayoutEffect(() => { activeTabStore.setActiveTabId("left", sftp.leftTabs.activeTabId); }, [sftp.leftTabs.activeTabId]); useLayoutEffect(() => { activeTabStore.setActiveTabId("right", sftp.rightTabs.activeTabId); }, [sftp.rightTabs.activeTabId]); // 渲染追踪 - 不追踪 activeTabId(现在通过 store 订阅) useRenderTracker("SftpViewInner", { isActive, hostsCount: hosts.length, leftTabsCount: sftp.leftTabs.tabs.length, rightTabsCount: sftp.rightTabs.tabs.length, }); const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations(); const getOpenerForFileRef = useRef(getOpenerForFile); getOpenerForFileRef.current = getOpenerForFile; const { leftCallbacks, rightCallbacks, dragCallbacks, draggedFiles, permissionsState, setPermissionsState, showTextEditor, setShowTextEditor, textEditorTarget, setTextEditorTarget, textEditorContent, setTextEditorContent, loadingTextContent, 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 visibleTransfers = useMemo( () => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5), [sftp.transfers], ); const getTransferTargetDirectory = useCallback( (task: TransferTask) => (task.isDirectory ? task.targetPath : getParentPath(task.targetPath)), [], ); const findRemoteTransferTargetTab = useCallback((task: TransferTask) => { const state = sftpRef.current; for (const side of ["left", "right"] as const) { const tabs = side === "left" ? state.leftTabs.tabs : state.rightTabs.tabs; const pane = tabs.find((tab) => tab.connection?.id === task.targetConnectionId); if (pane?.connection && !pane.connection.isLocal) { return { side, tabId: pane.id }; } } return null; }, []); const canRevealTransferTarget = useCallback( (task: TransferTask) => { if (task.status !== "completed") return false; if (!isConcreteTransferTargetPath(task)) return false; if (task.targetConnectionId === "local") { return true; } return !!findRemoteTransferTargetTab(task); }, [findRemoteTransferTargetTab], ); const handleRevealTransferTarget = useCallback( async (task: TransferTask) => { if (!isConcreteTransferTargetPath(task)) return; const targetDirectory = getTransferTargetDirectory(task); if (task.targetConnectionId === "local") { try { const result = await openPath(targetDirectory); if (result.success) return; } catch { // Show the localized error below. } toast.error(t("sftp.transfers.openTargetFolderError"), "SFTP"); return; } const targetTab = findRemoteTransferTargetTab(task); if (!targetTab) return; await sftpRef.current.navigateTo(targetTab.side, targetDirectory, { force: true, tabId: targetTab.tabId }); }, [findRemoteTransferTargetTab, getTransferTargetDirectory, openPath, t], ); 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], ); const containerStyle: React.CSSProperties = isActive ? {} : { visibility: "hidden", pointerEvents: "none", position: "absolute", zIndex: -1, }; // Don't read activeTabId here - let SftpTabBar and SftpPaneWrapper subscribe to store // This prevents SftpViewInner from re-rendering on tab switch const { leftPanes, rightPanes, leftTabsInfo, rightTabsInfo, showHostPickerLeft, showHostPickerRight, hostSearchLeft, hostSearchRight, setShowHostPickerLeft, setShowHostPickerRight, setHostSearchLeft, setHostSearchRight, handleAddTabLeft, handleAddTabRight, handleCloseTabLeft, handleCloseTabRight, handleSelectTabLeft, handleSelectTabRight, handleReorderTabsLeft, handleReorderTabsRight, handleMoveTabFromLeftToRight, handleMoveTabFromRightToLeft, handleDuplicateTabLeft, handleDuplicateTabRight, handleHostSelectLeft, handleHostSelectRight, } = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts }); const handleAddTabLeftWithFocus = useCallback(() => { const tabId = handleAddTabLeft(); handlePaneFocus("left", tabId); }, [handleAddTabLeft, handlePaneFocus]); const handleAddTabRightWithFocus = useCallback(() => { const tabId = handleAddTabRight(); handlePaneFocus("right", tabId); }, [handleAddTabRight, handlePaneFocus]); const handleSelectTabLeftWithFocus = useCallback((tabId: string) => { handleSelectTabLeft(tabId); handlePaneFocus("left", tabId); }, [handlePaneFocus, handleSelectTabLeft]); const handleSelectTabRightWithFocus = useCallback((tabId: string) => { handleSelectTabRight(tabId); handlePaneFocus("right", tabId); }, [handlePaneFocus, handleSelectTabRight]); const handleDuplicateTabLeftWithFocus = useCallback( async (...args: Parameters) => { const tabId = await handleDuplicateTabLeft(...args); if (tabId) { handlePaneFocus("left", tabId); } }, [handleDuplicateTabLeft, handlePaneFocus], ); const handleDuplicateTabRightWithFocus = useCallback( async (...args: Parameters) => { const tabId = await handleDuplicateTabRight(...args); if (tabId) { handlePaneFocus("right", tabId); } }, [handleDuplicateTabRight, handlePaneFocus], ); return (
{magnifiedSide && (