mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
92ecd84edf
* fix(ssh): per-host keepalive override + cloud-friendly defaults (#939, #581)
Issues #939 (cloud / Aliyun sessions silently freezing after 15-20 min idle
because no SSH keepalive packets are sent) and #581 (older routers like
NOKIA / ALCATEL being killed by ssh2 after a few unanswered keepalives) are
in direct tension at the global-setting level: cloud users want keepalive
ON, embedded-device users want it OFF, and any single global default hurts
the other group.
Resolves the conflict by moving keepalive to a per-host setting (mirroring
the existing `legacyAlgorithms` per-host pattern), with cloud-friendly
global defaults:
Domain:
- Host gains `keepaliveOverride?: boolean` + `keepaliveInterval?: number`
+ `keepaliveCountMax?: number`. When override is true, the host's
values are used; otherwise the global TerminalSettings values apply.
Per-field fallback so a host can override interval only or countMax only.
- TerminalSettings gains `keepaliveCountMax: number` so the second knob
(number of unanswered keepalives before declaring dead) is no longer
hardcoded at 3 in the bridge.
- DEFAULT_TERMINAL_SETTINGS: keepaliveInterval bumped from 0 to 30, and
keepaliveCountMax = 10. Cloud LBs / NAT tables stay populated; brief
network glitches don't trip the dead-connection check; an actually
dead session is detected within ~5 minutes. Existing users with 0
saved keep their value (no migration) — they were the #581 router
cohort and their setup still works untouched.
Plumbing:
- domain/host.ts adds resolveHostKeepalive(host, globalSettings) with
five unit tests covering both directions of the override flag and
per-field fallback.
- components/terminal/runtime/createTerminalSessionStarters.ts uses the
resolver when building startSSHSession options.
- electron/bridges/sshBridge.cjs reads keepaliveCountMax from options
(defaulting to 10) at both connection sites (direct + jump host) and
still routes interval=0 through to a fully disabled keepalive
(preserving #581's escape hatch).
UI:
- Settings → Terminal → Connection grows a second input next to the
existing interval: "Max unanswered keepalives".
- Host details panel gains a Keepalive section with a "Override global
keepalive" toggle that, when on, exposes per-host interval +
countMax inputs and an inline hint when interval = 0 (explaining
the implications). Same visual pattern as the existing Legacy
Algorithms section.
Sync:
- keepaliveCountMax added to SYNCABLE_TERMINAL_KEYS so the new global
field rides existing sync infrastructure. Per-host fields ride the
hosts array passthrough automatically (older clients receiving them
ignore unknown fields, per the existing lenient sync contract).
i18n: en + zh-CN strings for the new settings row, the host section
header, and the override toggle / inputs / disabled hint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): resolve keepalive per jump host, not just the final target
Addresses codex review on PR #947:
https://github.com/binaricat/Netcatty/pull/947#discussion_r3217027xxx
The first cut only resolved keepalive for the final target host and
forwarded a single interval/countMax pair across the whole start-SSH
call. connectThroughChain in sshBridge.cjs then applied that one pair
to every hop, so a chain like:
router (bastion, needs keepalive=0) → cloud target (needs 30s)
would either kill the router (with cloud-friendly defaults) or fail
to keep the target alive (with router-friendly 0). The per-host
override was effectively useless for bastion hosts.
Fix:
- NetcattyJumpHost gains optional keepaliveInterval / keepaliveCountMax.
- createTerminalSessionStarters runs resolveHostKeepalive() per
jumpHost when building the chain, so each hop carries its own
resolved pair.
- sshBridge.cjs's chain connector reads jump.keepaliveInterval /
jump.keepaliveCountMax for each hop, falling back to the call's
target-level options for backward compatibility with older
serializers that don't yet populate the per-hop fields.
The final target's keepalive path is unchanged — it still reads
options.keepaliveInterval / options.keepaliveCountMax that the
session starter resolves from the target host.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): per-host keepalive for SFTP + port forwarding too
Follow-up to the maintainer review on PR #947 — terminal SSH was the
only path that honored per-host keepalive overrides. SFTP and port
forwarding share the same NetcattyJumpHost type but their builders
weren't resolving keepalive per-hop, and their bridges hardcoded the
old 10s/3 defaults. Net result: a router-as-bastion in a chain still
got killed when reached via the SFTP file panel or a port-forwarding
tunnel, even though the user had toggled per-host override.
Plumbing:
- useSftpHostCredentials / buildSftpHostCredentials: accept optional
terminalSettings; call resolveHostKeepalive() for the target and
each jump entry; emit keepaliveInterval / keepaliveCountMax in the
returned NetcattySSHOptions.
- useSftpConnections + useSftpState + SftpStateOptions thread the
setting down. SftpSidePanel passes the global terminalSettings prop
it already has from TerminalLayer.
- portForwardingService.startPortForward: accepts terminalSettings
as an 8th argument, resolves per-host (target + each jump), and
populates the bridge payload.
- usePortForwardingState.startTunnel and usePortForwardingAutoStart
forward the new parameter; App.tsx supplies terminalSettings (via
a ref in the once-on-launch auto-start effect so changing global
keepalive later doesn't re-fire it).
Bridges:
- sftpBridge.cjs target connect: now also reads keepaliveCountMax
from options (was hardcoded 3). 10s/3 stays as the bridge-level
fallback to preserve the #669 protection when the renderer hasn't
supplied a value.
- sftpBridge.cjs jump hop: reads jump.keepaliveInterval /
jump.keepaliveCountMax, then falls back to the target-call options
(matches the symmetric SSH bridge change).
- portForwardingBridge.cjs: reads keepaliveInterval /
keepaliveCountMax from the IPC payload; same 10s/3 fallback.
Types:
- NetcattyJumpHost already grew keepalive fields earlier; this
commit also adds them to PortForwardOptions so the IPC contract
is explicit.
End-to-end: a chain `[router-as-bastion, cloud-host]` with the
router host's keepaliveOverride=true / interval=0 now correctly
disables keepalive on the router hop for terminal SSH AND SFTP AND
port forwarding, while the cloud target still gets the resolved
30s/10 default for each path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): honor explicit keepalive=0 in SFTP + port forwarding bridges
Addresses codex review on PR #947:
- https://github.com/binaricat/Netcatty/pull/947#discussion_r3217448xxx
- https://github.com/binaricat/Netcatty/pull/947#discussion_r3217449xxx
The previous follow-up commit (5c8bc923) plumbed per-host keepalive
into SFTP / port forwarding but kept the existing bridge-level
"if interval > 0 use it, else 10s" fallback. That collapsed two
semantically distinct inputs:
- "user explicitly resolved interval = 0" (host with keepaliveOverride
+ interval=0; the whole point of the override)
- "no value supplied at all" (legacy serializer)
Both ended up as 10s in the bridge, so a router-as-bastion / direct
router connection through SFTP or a port-forward tunnel still got
ssh2-killed after countMax unanswered probes — exactly the case
per-host override was supposed to fix.
Fix: bridges now distinguish on `== null`:
- positive value → honor it
- explicit 0 → truly disabled (0 ms, 0 countMax — ssh2 skips its
dead-connection check entirely on this connection)
- undefined / null → fall back to 10s/3 (preserves #669 idle-NAT
protection for older callers that pre-date per-host plumbing)
Applies to both SFTP target connect and SFTP jump hop builders, plus
the port forwarding target builder. Terminal SSH bridge is unchanged
since it already treated 0 as disabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): plumb terminalSettings to all remaining keepalive call sites
Addresses codex review on PR #947:
- PortForwardingNew + TrayPanel were not passing terminalSettings into
startTunnel, so tunnels started from the main port-forwarding UI or
from the tray menu silently used the FALLBACK 30/10 instead of the
user's actual global keepalive settings. Hosts inheriting global
policy could see different behavior depending on the entry point.
- SftpView was not threading terminalSettings into useSftpState, so
SFTP connections opened from the main tab UI also fell back to the
same hardcoded default and ignored the user's settings.
Wiring:
- PortForwardingProps gains `terminalSettings`; VaultView accepts it
on the same prop and forwards from its own new prop; App.tsx
supplies it from useSettingsState. The startTunnel call site uses
it directly and includes it in the useCallback dep list so the
handler updates when settings change.
- SftpViewProps gains `terminalSettings`; SftpViewMount accepts and
forwards it; the sftpOptions memo includes it in its dep list.
- TrayPanelContent gains a `terminalSettings` prop; the TrayPanel
wrapper (which already calls useSettingsState for uiLanguage)
passes it down so the standalone tray window agrees with the main
window's settings.
Also updates the explicit `startTunnel` signature in
UsePortForwardingStateResult so callers see the new 8th parameter
through the hook's return type, not just through the implementation.
Net result: every place that starts an SSH-derived connection
(terminal session, SFTP browse, port-forward tunnel) now consistently
sees the user's configured global keepalive policy and any per-host
overrides; the FALLBACK_KEEPALIVE constants in the service /
credentials builder are now only reached by genuinely-decoupled call
sites (tests, headless usage) rather than masking missing wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ssh): include terminalSettings keepalive fields in memo comparators
Addresses codex review on PR #947 — all three components that grew a
`terminalSettings` prop (SftpView, SftpSidePanel, VaultView) are wrapped
in React.memo with manual equality comparators, and none of those
comparators were updated to include the new prop. React would skip the
re-render when global keepalive changed, so new SFTP / port-forwarding
connections from those subtrees would silently keep using the old
keepalive policy until some other tracked prop happened to flip.
Each comparator now compares the keepalive fields directly rather than
the whole terminalSettings object — only those two fields drive
connection resolution in this subtree, and ignoring the rest avoids
unnecessary re-renders for unrelated terminal-setting changes (fonts,
themes, etc.) that already have their own targeted comparator entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
536 lines
20 KiB
TypeScript
536 lines
20 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 { HotkeyScheme, KeyBinding } from "../domain/models";
|
|
import { logger } from "../lib/logger";
|
|
import { useRenderTracker } from "../lib/useRenderTracker";
|
|
import { cn } from "../lib/utils";
|
|
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
|
import { Host, Identity, ProxyProfile, SSHKey } 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 "./sftp/hooks/useSftpFocusedPane";
|
|
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[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
groupConfigs?: import('../domain/models').GroupConfig[];
|
|
proxyProfiles?: ProxyProfile[];
|
|
updateHosts: (hosts: Host[]) => void;
|
|
sftpDefaultViewMode: "list" | "tree";
|
|
sftpDoubleClickBehavior: "open" | "transfer";
|
|
sftpAutoSync: boolean;
|
|
sftpShowHiddenFiles: boolean;
|
|
sftpUseCompressedUpload: boolean;
|
|
hotkeyScheme: HotkeyScheme;
|
|
keyBindings: KeyBinding[];
|
|
editorWordWrap: boolean;
|
|
setEditorWordWrap: (enabled: boolean) => void;
|
|
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
|
|
}
|
|
|
|
const SftpViewInner: React.FC<SftpViewProps> = ({
|
|
hosts,
|
|
keys,
|
|
identities,
|
|
groupConfigs = [],
|
|
proxyProfiles = [],
|
|
updateHosts,
|
|
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");
|
|
|
|
useInstantThemeSwitch(rootRef);
|
|
|
|
// 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 sftpOptions = useMemo(() => ({
|
|
...fileWatchHandlers,
|
|
useCompressedUpload: sftpUseCompressedUpload,
|
|
defaultShowHiddenFiles: sftpShowHiddenFiles,
|
|
terminalSettings,
|
|
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
|
|
|
|
// 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 sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
|
|
|
// Get backend helpers for file downloads and local filesystem writes.
|
|
const {
|
|
showSaveDialog,
|
|
selectDirectory,
|
|
startStreamTransfer,
|
|
listSftp,
|
|
mkdirLocal,
|
|
deleteLocalFile,
|
|
listLocalDir,
|
|
} = 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) =>
|
|
sftpRef.current.writeTextFileByConnection(connectionId, expectedHostId, filePath, content, encoding),
|
|
);
|
|
}, []);
|
|
|
|
// 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,
|
|
startStreamTransfer,
|
|
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
|
listLocalFiles: listLocalDir,
|
|
});
|
|
|
|
const visibleTransfers = useMemo(
|
|
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
|
|
[sftp.transfers],
|
|
);
|
|
|
|
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,
|
|
handleHostSelectLeft,
|
|
handleHostSelectRight,
|
|
} = useSftpViewTabs({ sftp, sftpRef });
|
|
|
|
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]);
|
|
|
|
return (
|
|
<SftpContextProvider
|
|
hosts={effectiveHosts}
|
|
writableHosts={hosts}
|
|
updateHosts={updateHosts}
|
|
draggedFiles={draggedFiles}
|
|
dragCallbacks={dragCallbacks}
|
|
leftCallbacks={leftCallbacks}
|
|
rightCallbacks={rightCallbacks}
|
|
>
|
|
<div
|
|
ref={rootRef}
|
|
className={cn(
|
|
"absolute inset-0 min-h-0 flex flex-col",
|
|
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}
|
|
/>
|
|
)}
|
|
<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}
|
|
/>
|
|
)}
|
|
<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}
|
|
sftp={sftp}
|
|
visibleTransfers={visibleTransfers}
|
|
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>
|
|
);
|
|
};
|
|
|
|
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
|
prev.hosts === next.hosts &&
|
|
prev.keys === next.keys &&
|
|
prev.identities === next.identities &&
|
|
prev.groupConfigs === next.groupConfigs &&
|
|
prev.proxyProfiles === next.proxyProfiles &&
|
|
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
|
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 the keepalive fields of terminalSettings 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?.keepaliveInterval === next.terminalSettings?.keepaliveInterval &&
|
|
prev.terminalSettings?.keepaliveCountMax === next.terminalSettings?.keepaliveCountMax;
|
|
|
|
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
|
|
SftpView.displayName = "SftpView";
|