mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 17:00:22 +00:00
* refactor: migrate architecture-debt hooks into application/state Move SFTP Wave A stores/hooks and related vault/AI/terminal/systemManager hooks out of components into application/state, update importers, and keep thin deprecated re-exports at the old paths. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * refactor(sftp): align local bookmarks store API with global bookmarks Export subscribe/get/set/rehydrate helpers from localSftpBookmarks to match the globalSftpBookmarks external-store pattern. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(electron): reduce baseline memory via spellcheck/GPU/prewarm guards Disable spellcheck on BrowserWindows and the default session, drop spare renderers, gate aggressive GPU switches behind NETCATTY_COMPAT_GPU, and make settings prewarm opt-in via NETCATTY_PREWARM_SETTINGS. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * Isolate notes and customAccent from TerminalLayer hot props Route vault notes through notesStore (shellHistoryStore pattern) and app accent through appearanceChromeStore so note edits and accent-drag ticks no longer rebuild TerminalLayer. Notes/AI slots and Terminal leaves subscribe locally; VaultView and open-note callbacks stay unchanged. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(ui): cut unnecessary AppView/VaultView/settings re-renders Stabilize TopTabs close handler, memo AppHostTreeLayer, exclude accent from settingsVersion, ignore connectionLogs off the logs section, share lastConnected host refs, and keep rename dialog drafts local so chrome domain stays stable. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * chore: package size, dead code, and consolidation cleanup Remove unused direct deps, exclude Vite-bundled renderer packages from electron-builder, delete high-confidence dead code/no-op transfer warm wiring, consolidate cloud provider IDs and KnownHost/SFTP helpers, and land UI/AGENTS quick wins (rAF text batching, notes draft debounce, streaming plain text, tempDirBridge, diagnostics adapter, External MCP setters). Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: stabilize rAF text batching and diagnostics tests Polyfill requestAnimationFrame for Node, flush pending text before steer buffering, bypass rAF while steer is in flight, and stub global localStorage for AI panel diagnostics adapter tests. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: correct import paths after application/state hook migrations Migrated vault/AI/system hooks still used components/-relative imports; point them at repo-root domain/components/types/lib instead. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: address Bugbot/Codex regressions from perf cleanup - Keep blank Enter from closing RenameDraftDialog; only reset rename state after a non-empty submit - Flush note drafts before vault mutations so pending title/body edits are not overwritten - Re-apply appearanceChromeStore accent on appearanceTheme in Terminal, and keep layer themes unaccented Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(notes): flush draft before markdown export paths Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep editor-close hotkey ref fresh during render Assign handleRequestCloseEditorTabRef in render instead of useEffect so Cmd/Ctrl+W never hits the App stub before the effect runs. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep accent in chrome themes without TerminalLayer thrash Restore accent in globalAppearance for chrome/injection consumers. Keep resolveFocusedAppearance identity stable across accent drag via accent ref, while Terminal leaf still re-applies appearanceChromeStore. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep compose chrome accent live and merge note renames Subscribe theme-panel focusedAppearance to appearanceChromeStore so compose-bar chrome updates during color-picker drag even when resolveFocusedAppearance identity stays stable. Tree renames now merge title into the post-flush note instead of replacing from a stale row. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: clear terminal accent when switching back to theme mode Re-resolve appearanceTheme by catalog id before applyCustomAccent so a stale baked custom cursor/selection cannot stick after accentMode returns to theme while TerminalLayer memo ignores accent props. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: register unsaved prompt singleton during render AppView close handlers call promptUnsavedChanges outside the old render-prop. Assign the singleton during render so Cmd/Ctrl+W and tab close never hit the pre-effect null window that auto-cancels. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep vault import hooks UI-free and flush notes on teardown Move ImportOptions into application/state and inject a notifier so useVaultImportHandlers no longer imports components. Notes drafts also flush on pagehide/beforeunload when Electron tears down without React unmount. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: move AI terminal session context builders into domain buildAITerminalSessionInfo and AIPanelContext now live under domain/ so application hooks no longer import TerminalLayerSupport and create a UI → application → UI cycle. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: drop duplicate AIPanelContext type from TerminalLayerSupport The type is re-exported from domain; the local definition conflicted. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: keep migrated state hooks free of UI-layer imports Move aiChatStreamingSupport into infrastructure/ai and poll list helpers into domain/systemManager so useAIChatStreaming / useSystemManager no longer import from components. Leave thin re-exports at the old paths. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: move remaining migrated-hook deps out of components Host-key verification types/helpers now live in domain/, and SFTP column layout types live in application/state/sftp/columnLayout. Application hooks no longer import those definitions from the UI layer. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix: clear lint blockers and flush notes when panels hide Move AI panel diagnostics persistence into application/state so components stop importing localStorageAdapter. Flush note drafts when retained NotesManager mounts become inactive, and silence related lint warnings in theme/host-key helpers. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> * fix(notes): publish notesStore sync on vault note updates updateNotes/updateNoteGroups now publishNotesSnapshot immediately so AI/notes consumers do not briefly read a stale catalog after a hide flush. Flush inactive drafts in useLayoutEffect before paint. Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
682 lines
26 KiB
TypeScript
682 lines
26 KiB
TypeScript
/**
|
|
* 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 } from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { 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 { HotkeyScheme, KeyBinding, TerminalSession } from "../domain/models";
|
|
import { listSftpConnectedHosts, resolveSftpTransferSourceSessionId, sftpPickerSessionsEqual } from "../domain/sftpConnectedHosts";
|
|
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 };
|
|
}
|
|
|
|
const SftpViewInner: React.FC<SftpViewProps> = ({
|
|
hosts,
|
|
writableHosts,
|
|
sessions = [],
|
|
keys,
|
|
identities,
|
|
knownHosts = [],
|
|
groupConfigs = [],
|
|
proxyProfiles = [],
|
|
updateHosts,
|
|
onAddKnownHost,
|
|
sftpDefaultViewMode,
|
|
sftpDoubleClickBehavior,
|
|
sftpAutoSync,
|
|
sftpShowHiddenFiles,
|
|
sftpUseCompressedUpload,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
editorWordWrap,
|
|
setEditorWordWrap,
|
|
terminalSettings,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const isActive = useIsSftpActive();
|
|
const rootRef = useRef<HTMLDivElement>(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<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]);
|
|
|
|
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 still parks when its panel is hidden.
|
|
// 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<string, Host>(
|
|
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;
|
|
|
|
// 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();
|
|
|
|
// 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);
|
|
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<typeof handleDuplicateTabLeft>) => {
|
|
const tabId = await handleDuplicateTabLeft(...args);
|
|
if (tabId) {
|
|
handlePaneFocus("left", tabId);
|
|
}
|
|
},
|
|
[handleDuplicateTabLeft, handlePaneFocus],
|
|
);
|
|
|
|
const handleDuplicateTabRightWithFocus = useCallback(
|
|
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
|
|
const tabId = await handleDuplicateTabRight(...args);
|
|
if (tabId) {
|
|
handlePaneFocus("right", tabId);
|
|
}
|
|
},
|
|
[handleDuplicateTabRight, handlePaneFocus],
|
|
);
|
|
|
|
return (
|
|
<SftpContextProvider
|
|
hosts={effectiveHosts}
|
|
connectedHosts={connectedHosts}
|
|
writableHosts={hostWriteSource}
|
|
updateHosts={updateHosts}
|
|
draggedFiles={draggedFiles}
|
|
dragCallbacks={dragCallbacks}
|
|
leftCallbacks={leftCallbacks}
|
|
rightCallbacks={rightCallbacks}
|
|
>
|
|
<div
|
|
ref={rootRef}
|
|
className={cn(
|
|
"absolute inset-0 min-h-0 flex flex-col bg-background",
|
|
isActive ? "z-20" : "",
|
|
)}
|
|
style={containerStyle}
|
|
>
|
|
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
|
|
<div
|
|
className="relative border-r border-border/70 flex flex-col"
|
|
onClick={() => handlePaneFocus("left")}
|
|
>
|
|
{/* Focus indicator triangle */}
|
|
{focusedSide === "left" && (
|
|
<div
|
|
className="absolute top-0 left-0 z-50 pointer-events-none"
|
|
style={{
|
|
width: 0,
|
|
height: 0,
|
|
borderStyle: 'solid',
|
|
borderWidth: '12px 12px 0 0',
|
|
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Left side tab bar - only show when there are tabs */}
|
|
{leftTabsInfo.length > 0 && (
|
|
<SftpTabBar
|
|
tabs={leftTabsInfo}
|
|
side="left"
|
|
onSelectTab={handleSelectTabLeftWithFocus}
|
|
onCloseTab={handleCloseTabLeft}
|
|
onAddTab={handleAddTabLeftWithFocus}
|
|
onReorderTabs={handleReorderTabsLeft}
|
|
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
|
|
onDuplicateTab={handleDuplicateTabLeftWithFocus}
|
|
/>
|
|
)}
|
|
<div className="relative flex-1 min-h-0">
|
|
{leftPanes.map((pane, idx) => (
|
|
<SftpPaneWrapper
|
|
key={pane.id}
|
|
side="left"
|
|
paneId={pane.id}
|
|
isFirstPane={idx === 0}
|
|
>
|
|
<SftpPaneView
|
|
side="left"
|
|
pane={pane}
|
|
dialogActionScopeId={dialogActionScopeIdRef.current}
|
|
isPaneFocused={focusedSide === "left"}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
showHeader
|
|
showEmptyHeader={false}
|
|
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
|
|
/>
|
|
</SftpPaneWrapper>
|
|
))}
|
|
{/* Loading overlay for left pane - shown when loading text content */}
|
|
{loadingTextContent && textEditorTarget?.side === "left" && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-30">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">{t("sftp.status.loading")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="relative flex flex-col"
|
|
onClick={() => handlePaneFocus("right")}
|
|
>
|
|
{/* Focus indicator triangle */}
|
|
{focusedSide === "right" && (
|
|
<div
|
|
className="absolute top-0 left-0 z-50 pointer-events-none"
|
|
style={{
|
|
width: 0,
|
|
height: 0,
|
|
borderStyle: 'solid',
|
|
borderWidth: '12px 12px 0 0',
|
|
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
|
}}
|
|
/>
|
|
)}
|
|
{/* Right side tab bar - only show when there are tabs */}
|
|
{rightTabsInfo.length > 0 && (
|
|
<SftpTabBar
|
|
tabs={rightTabsInfo}
|
|
side="right"
|
|
onSelectTab={handleSelectTabRightWithFocus}
|
|
onCloseTab={handleCloseTabRight}
|
|
onAddTab={handleAddTabRightWithFocus}
|
|
onReorderTabs={handleReorderTabsRight}
|
|
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
|
|
onDuplicateTab={handleDuplicateTabRightWithFocus}
|
|
/>
|
|
)}
|
|
<div className="relative flex-1 min-h-0">
|
|
{rightPanes.map((pane, idx) => (
|
|
<SftpPaneWrapper
|
|
key={pane.id}
|
|
side="right"
|
|
paneId={pane.id}
|
|
isFirstPane={idx === 0}
|
|
>
|
|
<SftpPaneView
|
|
side="right"
|
|
pane={pane}
|
|
dialogActionScopeId={dialogActionScopeIdRef.current}
|
|
isPaneFocused={focusedSide === "right"}
|
|
sftpDefaultViewMode={sftpDefaultViewMode}
|
|
showHeader
|
|
showEmptyHeader={false}
|
|
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
|
|
/>
|
|
</SftpPaneWrapper>
|
|
))}
|
|
{/* Loading overlay for right pane - shown when loading text content */}
|
|
{loadingTextContent && textEditorTarget?.side === "right" && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-30">
|
|
<div className="flex flex-col items-center gap-2">
|
|
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">{t("sftp.status.loading")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<SftpOverlays
|
|
hosts={effectiveHosts}
|
|
connectedHosts={connectedHosts}
|
|
sftp={sftp}
|
|
visibleTransfers={visibleTransfers}
|
|
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}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
</SftpContextProvider>
|
|
);
|
|
};
|
|
|
|
export const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): 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.groupConfigs === next.groupConfigs &&
|
|
prev.proxyProfiles === next.proxyProfiles &&
|
|
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
|
prev.onAddKnownHost === next.onAddKnownHost &&
|
|
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 &&
|
|
// Only these terminal connection settings affect SFTP connection
|
|
// resolution today; compare them directly rather than the whole object
|
|
// so unrelated terminal-setting changes don't tear the panel down.
|
|
prev.terminalSettings?.verifyHostKeys === next.terminalSettings?.verifyHostKeys &&
|
|
prev.terminalSettings?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
|
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
|
|
|
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
|
SftpView.displayName = "SftpView";
|