1056 lines
38 KiB
TypeScript
1056 lines
38 KiB
TypeScript
|
|
import React, { useCallback, useRef } from "react";
|
||
|
|
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
|
||
|
|
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||
|
|
import { logger } from "../../../lib/logger";
|
||
|
|
import { SftpPane } from "./types";
|
||
|
|
import {
|
||
|
|
getFileName,
|
||
|
|
getParentPath,
|
||
|
|
getSftpFilterAfterPathChange,
|
||
|
|
getSftpFilterAfterPathChangeError,
|
||
|
|
isNavigableDirectory,
|
||
|
|
isSftpDescendantPath,
|
||
|
|
isSameSftpPath,
|
||
|
|
isWindowsRoot,
|
||
|
|
joinPath,
|
||
|
|
normalizeSftpPaneNavigationPath,
|
||
|
|
resolveSftpWindowsPathOptions,
|
||
|
|
shouldClearSftpFilterForPathChange,
|
||
|
|
} from "./utils";
|
||
|
|
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||
|
|
import {
|
||
|
|
getDirectoryCacheEntry,
|
||
|
|
setDirectoryCacheEntry,
|
||
|
|
type DirectoryListingCache,
|
||
|
|
} from "./directoryListingCache";
|
||
|
|
|
||
|
|
/** Shared empty set for navigation resets — never mutate this. */
|
||
|
|
const EMPTY_SET = new Set<string>();
|
||
|
|
|
||
|
|
interface UseSftpPaneActionsParams {
|
||
|
|
hosts: Host[];
|
||
|
|
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||
|
|
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
|
||
|
|
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
|
||
|
|
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||
|
|
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
|
||
|
|
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
|
||
|
|
dirCacheRef: React.MutableRefObject<DirectoryListingCache>;
|
||
|
|
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||
|
|
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
|
||
|
|
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
|
||
|
|
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
|
||
|
|
clearCacheForConnection: (connectionId: string) => void;
|
||
|
|
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
|
||
|
|
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
|
||
|
|
handleSessionError: (side: "left" | "right", error: Error) => void;
|
||
|
|
releaseConnection: (connectionId: string) => Promise<void>;
|
||
|
|
isSessionError: (err: unknown) => boolean;
|
||
|
|
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
|
||
|
|
dirCacheTtlMs: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
|
||
|
|
export type SftpNavigateOptions = {
|
||
|
|
force?: boolean;
|
||
|
|
tabId?: string;
|
||
|
|
shouldApply?: () => boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
interface UseSftpPaneActionsResult {
|
||
|
|
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
|
||
|
|
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
|
||
|
|
navigateUp: (side: "left" | "right") => Promise<void>;
|
||
|
|
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
|
||
|
|
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
|
||
|
|
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
|
||
|
|
clearSelection: (side: "left" | "right") => void;
|
||
|
|
selectAll: (side: "left" | "right") => void;
|
||
|
|
setFilter: (side: "left" | "right", filter: string) => void;
|
||
|
|
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
|
||
|
|
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||
|
|
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||
|
|
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||
|
|
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
|
||
|
|
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||
|
|
deleteFilesAtPath: (
|
||
|
|
side: "left" | "right",
|
||
|
|
connectionId: string,
|
||
|
|
path: string,
|
||
|
|
fileNames: string[],
|
||
|
|
) => Promise<void>;
|
||
|
|
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||
|
|
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
|
||
|
|
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
|
||
|
|
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const useSftpPaneActions = ({
|
||
|
|
hosts,
|
||
|
|
getActivePane,
|
||
|
|
updateTab,
|
||
|
|
updateActiveTab,
|
||
|
|
leftTabsRef,
|
||
|
|
rightTabsRef,
|
||
|
|
navSeqRef,
|
||
|
|
dirCacheRef,
|
||
|
|
sftpSessionsRef,
|
||
|
|
lastConnectedHostRef,
|
||
|
|
connectionCacheKeyMapRef,
|
||
|
|
makeCacheKey,
|
||
|
|
clearCacheForConnection,
|
||
|
|
listLocalFiles,
|
||
|
|
listRemoteFiles,
|
||
|
|
handleSessionError,
|
||
|
|
releaseConnection,
|
||
|
|
isSessionError,
|
||
|
|
clearSelectionsExcept,
|
||
|
|
dirCacheTtlMs,
|
||
|
|
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
|
||
|
|
const isSamePath = useCallback((a: string, b: string): boolean => {
|
||
|
|
return isSameSftpPath(a, b);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// Build the shared cache key for the active pane. Prefer the last connected
|
||
|
|
// host (which includes session-time overrides), fall back to the vault hosts list.
|
||
|
|
const hostsRef = useRef(hosts);
|
||
|
|
hostsRef.current = hosts;
|
||
|
|
const getActivePaneCacheKey = useCallback((side: "left" | "right", hostId: string, connectionId?: string): string => {
|
||
|
|
// Prefer the per-connection cache key — it's set at connect time and
|
||
|
|
// correctly identifies the endpoint even when multiple tabs share the
|
||
|
|
// same hostId with different session-time overrides.
|
||
|
|
if (connectionId) {
|
||
|
|
const perConnKey = connectionCacheKeyMapRef.current.get(connectionId);
|
||
|
|
if (perConnKey) return perConnKey;
|
||
|
|
}
|
||
|
|
// Fallback: lastConnectedHostRef (per-side, may be stale for multi-tab)
|
||
|
|
const connHost = lastConnectedHostRef.current[side];
|
||
|
|
if (connHost && connHost !== "local" && connHost.id === hostId) {
|
||
|
|
return buildCacheKey(connHost.id, connHost.hostname, connHost.port, connHost.protocol, connHost.sftpSudo, connHost.username, connHost.sftpFileProtocol);
|
||
|
|
}
|
||
|
|
// Fall back to vault host
|
||
|
|
const host = hostsRef.current.find(h => h.id === hostId);
|
||
|
|
if (host) {
|
||
|
|
return buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username, host.sftpFileProtocol);
|
||
|
|
}
|
||
|
|
return hostId;
|
||
|
|
}, [connectionCacheKeyMapRef, lastConnectedHostRef]);
|
||
|
|
|
||
|
|
// Track the latest navigation request ID per tab, so we can distinguish
|
||
|
|
// whether a superseded request was superseded by the same tab or a different tab.
|
||
|
|
const tabNavSeqRef = useRef(new Map<string, number>());
|
||
|
|
|
||
|
|
// Track the last confirmed (successfully loaded) state per tab, so that
|
||
|
|
// restore-on-error/supersede always reverts to a known-good state rather
|
||
|
|
// than an intermediate optimistic state from another in-flight navigation.
|
||
|
|
// Includes connectionId so stale entries from a previous host are ignored.
|
||
|
|
const lastConfirmedRef = useRef(
|
||
|
|
new Map<string, { connectionId: string; path: string; files: SftpFileEntry[]; selectedFiles: Set<string>; filter: string }>(),
|
||
|
|
);
|
||
|
|
|
||
|
|
const navigateTo = useCallback(
|
||
|
|
async (
|
||
|
|
side: "left" | "right",
|
||
|
|
path: string,
|
||
|
|
options?: SftpNavigateOptions,
|
||
|
|
): Promise<SftpNavigateResult> => {
|
||
|
|
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||
|
|
// When tabId is specified, target that specific tab instead of the active one.
|
||
|
|
// This allows refreshing a background tab (e.g. after a transfer completes
|
||
|
|
// while focus has switched to another host).
|
||
|
|
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
|
||
|
|
const pane = options?.tabId
|
||
|
|
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||
|
|
: getActivePane(side);
|
||
|
|
|
||
|
|
if (!pane?.connection || !targetTabId) {
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
|
||
|
|
if (options?.shouldApply?.() === false) {
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Bookmark / reopen / typed paths can still carry //host/share; normalize
|
||
|
|
// with pane Windows context so UNC roots stay intact for Up / "..".
|
||
|
|
const normalizedPath = normalizeSftpPaneNavigationPath(
|
||
|
|
path,
|
||
|
|
pane.connection.currentPath,
|
||
|
|
pane.connection.homeDir,
|
||
|
|
);
|
||
|
|
|
||
|
|
const connectionId = pane.connection.id;
|
||
|
|
const requestId = ++navSeqRef.current[side];
|
||
|
|
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
|
||
|
|
const clearFilterForPathChange = shouldClearSftpFilterForPathChange(pane.connection.currentPath, normalizedPath);
|
||
|
|
const nextConfirmedFilter = getSftpFilterAfterPathChange(pane.connection.currentPath, normalizedPath, pane.filter);
|
||
|
|
const cached = options?.force
|
||
|
|
? undefined
|
||
|
|
: getDirectoryCacheEntry(dirCacheRef.current, cacheKey, Date.now(), dirCacheTtlMs);
|
||
|
|
|
||
|
|
if (
|
||
|
|
cached &&
|
||
|
|
Date.now() - cached.timestamp < dirCacheTtlMs &&
|
||
|
|
cached.files
|
||
|
|
) {
|
||
|
|
tabNavSeqRef.current.set(targetTabId, requestId);
|
||
|
|
lastConfirmedRef.current.set(targetTabId, {
|
||
|
|
connectionId,
|
||
|
|
path: normalizedPath,
|
||
|
|
files: cached.files,
|
||
|
|
selectedFiles: EMPTY_SET,
|
||
|
|
filter: nextConfirmedFilter,
|
||
|
|
});
|
||
|
|
updateTab(side, targetTabId, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
connection: prev.connection
|
||
|
|
? { ...prev.connection, currentPath: normalizedPath }
|
||
|
|
: null,
|
||
|
|
files: cached.files,
|
||
|
|
loading: false,
|
||
|
|
error: null,
|
||
|
|
selectedFiles: EMPTY_SET,
|
||
|
|
filter: clearFilterForPathChange ? "" : prev.filter,
|
||
|
|
}));
|
||
|
|
if (!pane.connection.isLocal) {
|
||
|
|
// Use hostId as the shared cache key — this is safe because the
|
||
|
|
// shared cache is a best-effort optimization and hostId uniquely
|
||
|
|
// identifies the connection in the common case. Session-time
|
||
|
|
// overrides create separate connections with distinct cache keys
|
||
|
|
// at the connect() layer.
|
||
|
|
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||
|
|
path: normalizedPath,
|
||
|
|
homeDir: pane.connection.homeDir ?? normalizedPath,
|
||
|
|
files: cached.files,
|
||
|
|
filenameEncoding: pane.filenameEncoding,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return "reached";
|
||
|
|
}
|
||
|
|
|
||
|
|
// Re-seed confirmed state whenever the pane is settled (not loading), or
|
||
|
|
// when the connection has changed. This captures post-mutation state from
|
||
|
|
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
|
||
|
|
// doesn't resurrect deleted items.
|
||
|
|
const existing = lastConfirmedRef.current.get(targetTabId);
|
||
|
|
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
|
||
|
|
lastConfirmedRef.current.set(targetTabId, {
|
||
|
|
connectionId,
|
||
|
|
path: pane.connection.currentPath,
|
||
|
|
files: pane.files,
|
||
|
|
selectedFiles: pane.selectedFiles,
|
||
|
|
filter: pane.filter,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
|
||
|
|
const previousPath = confirmed.path;
|
||
|
|
const previousFiles = confirmed.files;
|
||
|
|
const previousSelection = confirmed.selectedFiles;
|
||
|
|
const previousFilter = confirmed.filter;
|
||
|
|
const previousError = pane.error;
|
||
|
|
const isTargetRequestCurrent = () => tabNavSeqRef.current.get(targetTabId) === requestId;
|
||
|
|
const getTargetPane = () => {
|
||
|
|
const currentSideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||
|
|
return currentSideTabs.tabs.find((t) => t.id === targetTabId) ?? null;
|
||
|
|
};
|
||
|
|
const shouldApplyNavigationState = () => (
|
||
|
|
getTargetPane()?.connection?.id === connectionId
|
||
|
|
&& (options?.shouldApply?.() ?? true)
|
||
|
|
);
|
||
|
|
const restoreConfirmedStateIfCurrent = () => {
|
||
|
|
if (!isTargetRequestCurrent()) return;
|
||
|
|
updateTab(side, targetTabId, (prev) => {
|
||
|
|
if (prev.connection?.id !== connectionId || !isTargetRequestCurrent()) return prev;
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
connection: { ...prev.connection, currentPath: previousPath },
|
||
|
|
files: previousFiles,
|
||
|
|
selectedFiles: previousSelection,
|
||
|
|
filter: previousFilter,
|
||
|
|
error: previousError,
|
||
|
|
loading: false,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
};
|
||
|
|
tabNavSeqRef.current.set(targetTabId, requestId);
|
||
|
|
// Keep existing files visible during loading — the loading overlay
|
||
|
|
// (pointer-events-none) prevents interaction. This avoids blanking a tab
|
||
|
|
// that gets superseded by another tab navigating on the same side.
|
||
|
|
updateTab(side, targetTabId, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
connection: prev.connection
|
||
|
|
? { ...prev.connection, currentPath: normalizedPath }
|
||
|
|
: null,
|
||
|
|
selectedFiles: EMPTY_SET,
|
||
|
|
filter: clearFilterForPathChange ? "" : prev.filter,
|
||
|
|
loading: true,
|
||
|
|
error: null,
|
||
|
|
}));
|
||
|
|
|
||
|
|
try {
|
||
|
|
let files: SftpFileEntry[];
|
||
|
|
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
files = await listLocalFiles(normalizedPath);
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
if (!isTargetRequestCurrent()) {
|
||
|
|
return "superseded";
|
||
|
|
}
|
||
|
|
if (!shouldApplyNavigationState()) {
|
||
|
|
restoreConfirmedStateIfCurrent();
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
clearCacheForConnection(pane.connection.id);
|
||
|
|
// For background tabs (explicit tabId), update that tab directly
|
||
|
|
// instead of handleSessionError which targets the active tab.
|
||
|
|
if (options?.tabId) {
|
||
|
|
updateTab(side, targetTabId, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
error: "sftp.error.sessionLost",
|
||
|
|
loading: false,
|
||
|
|
}));
|
||
|
|
} else {
|
||
|
|
handleSessionError(side, new Error("SFTP session lost"));
|
||
|
|
}
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
files = await listRemoteFiles(sftpId, normalizedPath, pane.filenameEncoding);
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
if (!isTargetRequestCurrent()) {
|
||
|
|
return "superseded";
|
||
|
|
}
|
||
|
|
if (!shouldApplyNavigationState()) {
|
||
|
|
restoreConfirmedStateIfCurrent();
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
await releaseConnection(pane.connection.id);
|
||
|
|
if (options?.tabId) {
|
||
|
|
updateTab(side, targetTabId, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
error: "sftp.error.sessionLost",
|
||
|
|
loading: false,
|
||
|
|
}));
|
||
|
|
} else {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
}
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
throw err as Error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (navSeqRef.current[side] !== requestId) {
|
||
|
|
// Side-level sequence was bumped by another tab's navigation or
|
||
|
|
// a connect/disconnect. Check if THIS tab's request is still current.
|
||
|
|
if (!isTargetRequestCurrent()) {
|
||
|
|
// This tab also has a newer navigation — drop completely.
|
||
|
|
return "superseded";
|
||
|
|
}
|
||
|
|
// Side was superseded by another tab, but this tab's request is
|
||
|
|
// still current. The fetched files are valid — fall through to
|
||
|
|
// apply them instead of restoring previousPath.
|
||
|
|
}
|
||
|
|
if (!shouldApplyNavigationState()) {
|
||
|
|
restoreConfirmedStateIfCurrent();
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
|
||
|
|
setDirectoryCacheEntry(dirCacheRef.current, cacheKey, {
|
||
|
|
files,
|
||
|
|
timestamp: Date.now(),
|
||
|
|
}, { ttlMs: dirCacheTtlMs });
|
||
|
|
|
||
|
|
lastConfirmedRef.current.set(targetTabId, {
|
||
|
|
connectionId,
|
||
|
|
path: normalizedPath,
|
||
|
|
files,
|
||
|
|
selectedFiles: EMPTY_SET,
|
||
|
|
filter: nextConfirmedFilter,
|
||
|
|
});
|
||
|
|
|
||
|
|
updateTab(side, targetTabId, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
connection: prev.connection
|
||
|
|
? { ...prev.connection, currentPath: normalizedPath }
|
||
|
|
: null,
|
||
|
|
files,
|
||
|
|
loading: false,
|
||
|
|
selectedFiles: EMPTY_SET,
|
||
|
|
filter: clearFilterForPathChange ? "" : prev.filter,
|
||
|
|
}));
|
||
|
|
if (!pane.connection.isLocal) {
|
||
|
|
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
|
||
|
|
path: normalizedPath,
|
||
|
|
homeDir: pane.connection.homeDir ?? normalizedPath,
|
||
|
|
files,
|
||
|
|
filenameEncoding: pane.filenameEncoding,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return "reached";
|
||
|
|
} catch (err) {
|
||
|
|
if (navSeqRef.current[side] !== requestId) {
|
||
|
|
if (!isTargetRequestCurrent()) {
|
||
|
|
return "superseded";
|
||
|
|
}
|
||
|
|
// Side superseded by another tab, but this tab's request is
|
||
|
|
// current — fall through to show the error on this tab.
|
||
|
|
}
|
||
|
|
if (!shouldApplyNavigationState()) {
|
||
|
|
restoreConfirmedStateIfCurrent();
|
||
|
|
return "aborted";
|
||
|
|
}
|
||
|
|
let navigationFailed = false;
|
||
|
|
updateTab(side, targetTabId, (prev) => {
|
||
|
|
if (prev.connection?.id !== connectionId) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
navigationFailed = true;
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
connection: { ...prev.connection, currentPath: previousPath },
|
||
|
|
files: previousFiles,
|
||
|
|
selectedFiles: previousSelection,
|
||
|
|
filter: getSftpFilterAfterPathChangeError(clearFilterForPathChange, previousFilter, prev.filter),
|
||
|
|
error:
|
||
|
|
err instanceof Error ? err.message : "Failed to list directory",
|
||
|
|
loading: false,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return navigationFailed ? "failed" : "aborted";
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[
|
||
|
|
getActivePane,
|
||
|
|
getActivePaneCacheKey,
|
||
|
|
updateTab,
|
||
|
|
leftTabsRef,
|
||
|
|
rightTabsRef,
|
||
|
|
navSeqRef,
|
||
|
|
dirCacheRef,
|
||
|
|
makeCacheKey,
|
||
|
|
dirCacheTtlMs,
|
||
|
|
listLocalFiles,
|
||
|
|
listRemoteFiles,
|
||
|
|
sftpSessionsRef,
|
||
|
|
clearCacheForConnection,
|
||
|
|
handleSessionError,
|
||
|
|
releaseConnection,
|
||
|
|
isSessionError,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
const refresh = useCallback(
|
||
|
|
async (side: "left" | "right", options?: { tabId?: string }) => {
|
||
|
|
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||
|
|
const pane = options?.tabId
|
||
|
|
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
|
||
|
|
: getActivePane(side);
|
||
|
|
if (pane?.connection) {
|
||
|
|
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
|
||
|
|
if (!hasRemoteSession) {
|
||
|
|
if (options?.tabId) return;
|
||
|
|
// Same policy as session-error handling: reconnect when we still know
|
||
|
|
// the host (lastHost or connection identity), never collapse to host picker.
|
||
|
|
const lastHost = lastConnectedHostRef.current[side];
|
||
|
|
const canReconnect = !!(
|
||
|
|
lastHost
|
||
|
|
|| pane.connection.isLocal
|
||
|
|
|| (!pane.connection.isLocal && pane.connection.hostId)
|
||
|
|
);
|
||
|
|
if (canReconnect) {
|
||
|
|
updateActiveTab(side, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
reconnecting: true,
|
||
|
|
error: "sftp.reconnecting.title",
|
||
|
|
}));
|
||
|
|
} else if (!canReconnect) {
|
||
|
|
updateActiveTab(side, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
error: "sftp.error.connectionLostManual",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
|
||
|
|
} else if (!pane?.connection && pane?.error) {
|
||
|
|
// For background tabs, don't trigger reconnection (it operates on
|
||
|
|
// the active tab). Just leave the error state for the user to see
|
||
|
|
// when they switch back to that tab.
|
||
|
|
if (options?.tabId) return;
|
||
|
|
const lastHost = lastConnectedHostRef.current[side];
|
||
|
|
if (lastHost) {
|
||
|
|
updateActiveTab(side, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
reconnecting: true,
|
||
|
|
error: "sftp.reconnecting.title",
|
||
|
|
}));
|
||
|
|
} else if (!lastHost) {
|
||
|
|
updateActiveTab(side, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
error: "sftp.error.connectionLostManual",
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, sftpSessionsRef],
|
||
|
|
);
|
||
|
|
|
||
|
|
const navigateUp = useCallback(
|
||
|
|
async (side: "left" | "right") => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
const currentPath = pane.connection.currentPath;
|
||
|
|
const windowsOpts = resolveSftpWindowsPathOptions(
|
||
|
|
currentPath,
|
||
|
|
pane.connection.homeDir,
|
||
|
|
);
|
||
|
|
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath, windowsOpts);
|
||
|
|
|
||
|
|
if (!isAtRoot) {
|
||
|
|
const parentPath = getParentPath(currentPath, windowsOpts);
|
||
|
|
await navigateTo(side, parentPath);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, navigateTo],
|
||
|
|
);
|
||
|
|
|
||
|
|
const openEntry = useCallback(
|
||
|
|
async (side: "left" | "right", entry: SftpFileEntry) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
|
||
|
|
if (!pane?.connection) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (entry.name === "..") {
|
||
|
|
const currentPath = pane.connection.currentPath;
|
||
|
|
const windowsOpts = resolveSftpWindowsPathOptions(
|
||
|
|
currentPath,
|
||
|
|
pane.connection.homeDir,
|
||
|
|
);
|
||
|
|
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath, windowsOpts);
|
||
|
|
if (!isAtRoot) {
|
||
|
|
const parentPath = getParentPath(currentPath, windowsOpts);
|
||
|
|
await navigateTo(side, parentPath);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isNavigableDirectory(entry)) {
|
||
|
|
const newPath = joinPath(pane.connection.currentPath, entry.name);
|
||
|
|
await navigateTo(side, newPath);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, navigateTo],
|
||
|
|
);
|
||
|
|
|
||
|
|
const toggleSelection = useCallback(
|
||
|
|
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
|
||
|
|
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||
|
|
if (activeTabId) {
|
||
|
|
clearSelectionsExcept({ side, tabId: activeTabId });
|
||
|
|
}
|
||
|
|
updateActiveTab(side, (prev) => {
|
||
|
|
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
|
||
|
|
if (newSelection.has(fileName)) {
|
||
|
|
newSelection.delete(fileName);
|
||
|
|
} else {
|
||
|
|
newSelection.add(fileName);
|
||
|
|
}
|
||
|
|
return { ...prev, selectedFiles: newSelection };
|
||
|
|
});
|
||
|
|
},
|
||
|
|
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||
|
|
);
|
||
|
|
|
||
|
|
const rangeSelect = useCallback(
|
||
|
|
(side: "left" | "right", fileNames: string[]) => {
|
||
|
|
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
|
||
|
|
if (activeTabId) {
|
||
|
|
clearSelectionsExcept({ side, tabId: activeTabId });
|
||
|
|
}
|
||
|
|
const newSelection = new Set<string>();
|
||
|
|
for (const name of fileNames) {
|
||
|
|
if (name && name !== "..") {
|
||
|
|
newSelection.add(name);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
|
||
|
|
},
|
||
|
|
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
|
||
|
|
);
|
||
|
|
|
||
|
|
const clearSelection = useCallback((side: "left" | "right") => {
|
||
|
|
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
|
||
|
|
}, [updateActiveTab]);
|
||
|
|
|
||
|
|
const selectAll = useCallback(
|
||
|
|
(side: "left" | "right") => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane) return;
|
||
|
|
|
||
|
|
updateActiveTab(side, (prev) => ({
|
||
|
|
...prev,
|
||
|
|
selectedFiles: new Set(
|
||
|
|
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
|
||
|
|
),
|
||
|
|
}));
|
||
|
|
},
|
||
|
|
[getActivePane, updateActiveTab],
|
||
|
|
);
|
||
|
|
|
||
|
|
const setFilter = useCallback((side: "left" | "right", filter: string) => {
|
||
|
|
updateActiveTab(side, (prev) => ({ ...prev, filter }));
|
||
|
|
}, [updateActiveTab]);
|
||
|
|
|
||
|
|
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
|
||
|
|
const term = pane.filter.trim().toLowerCase();
|
||
|
|
if (!term) return pane.files;
|
||
|
|
return pane.files.filter(
|
||
|
|
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||
|
|
);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const createDirectoryAtPath = useCallback(
|
||
|
|
async (side: "left" | "right", path: string, name: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
const fullPath = joinPath(path, name);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
|
||
|
|
}
|
||
|
|
if (pane.connection.currentPath === path) {
|
||
|
|
await refresh(side);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
const createDirectory = useCallback(
|
||
|
|
async (side: "left" | "right", name: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
await createDirectoryAtPath(side, pane.connection.currentPath, name);
|
||
|
|
},
|
||
|
|
[createDirectoryAtPath, getActivePane],
|
||
|
|
);
|
||
|
|
|
||
|
|
const createFileAtPath = useCallback(
|
||
|
|
async (side: "left" | "right", path: string, name: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
const fullPath = joinPath(path, name);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
const bridge = netcattyBridge.get();
|
||
|
|
if (bridge?.writeLocalFile) {
|
||
|
|
const emptyBuffer = new ArrayBuffer(0);
|
||
|
|
await bridge.writeLocalFile(fullPath, emptyBuffer);
|
||
|
|
} else {
|
||
|
|
throw new Error("Local file writing not supported");
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const bridge = netcattyBridge.get();
|
||
|
|
if (bridge?.writeSftpBinary) {
|
||
|
|
const emptyBuffer = new ArrayBuffer(0);
|
||
|
|
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
|
||
|
|
} else if (bridge?.writeSftp) {
|
||
|
|
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
|
||
|
|
} else {
|
||
|
|
throw new Error("No write method available");
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (pane.connection.currentPath === path) {
|
||
|
|
await refresh(side);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
const createFile = useCallback(
|
||
|
|
async (side: "left" | "right", name: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
await createFileAtPath(side, pane.connection.currentPath, name);
|
||
|
|
},
|
||
|
|
[createFileAtPath, getActivePane],
|
||
|
|
);
|
||
|
|
|
||
|
|
const deleteFiles = useCallback(
|
||
|
|
async (side: "left" | "right", fileNames: string[]) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Parallel deletes — sequential await made multi-select feel like a
|
||
|
|
// recursive crawl even for flat files.
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
await Promise.all(fileNames.map(async (name) => {
|
||
|
|
const fullPath = joinPath(pane.connection!.currentPath, name);
|
||
|
|
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
|
||
|
|
}));
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await Promise.all(fileNames.map(async (name) => {
|
||
|
|
const fullPath = joinPath(pane.connection!.currentPath, name);
|
||
|
|
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
await refresh(side);
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
const deleteFilesAtPath = useCallback(
|
||
|
|
async (
|
||
|
|
side: "left" | "right",
|
||
|
|
connectionId: string,
|
||
|
|
path: string,
|
||
|
|
fileNames: string[],
|
||
|
|
) => {
|
||
|
|
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||
|
|
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
|
||
|
|
if (!pane?.connection) {
|
||
|
|
throw new Error("Source pane is no longer available");
|
||
|
|
}
|
||
|
|
const bridge = netcattyBridge.get();
|
||
|
|
if (!bridge) {
|
||
|
|
throw new Error("Netcatty bridge not available");
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Fire deletes in parallel. Each directory is still one server-side
|
||
|
|
// recursive remove (rm -rf / rmdir -r), not a renderer-side walk.
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
if (!bridge.deleteLocalFile) {
|
||
|
|
throw new Error("Local delete unavailable");
|
||
|
|
}
|
||
|
|
await Promise.all(fileNames.map(async (name) => {
|
||
|
|
await bridge.deleteLocalFile!(joinPath(path, name));
|
||
|
|
}));
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
const error = new Error("SFTP session not found");
|
||
|
|
handleSessionError(side, error);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
if (!bridge.deleteSftp) {
|
||
|
|
throw new Error("SFTP delete unavailable");
|
||
|
|
}
|
||
|
|
await Promise.all(fileNames.map(async (name) => {
|
||
|
|
await bridge.deleteSftp!(sftpId, joinPath(path, name), pane.filenameEncoding);
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
clearCacheForConnection(pane.connection.id);
|
||
|
|
|
||
|
|
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
|
||
|
|
await refresh(side);
|
||
|
|
} else {
|
||
|
|
updateTab(side, pane.id, (prev) => {
|
||
|
|
if (!prev.connection || prev.connection.id !== connectionId) return prev;
|
||
|
|
if (prev.connection.currentPath !== path) return prev;
|
||
|
|
|
||
|
|
const removeSet = new Set(fileNames);
|
||
|
|
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
|
||
|
|
const nextSelection = new Set(prev.selectedFiles);
|
||
|
|
for (const name of fileNames) {
|
||
|
|
nextSelection.delete(name);
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
files: filteredFiles,
|
||
|
|
selectedFiles: nextSelection,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[
|
||
|
|
clearCacheForConnection,
|
||
|
|
handleSessionError,
|
||
|
|
isSessionError,
|
||
|
|
leftTabsRef,
|
||
|
|
refresh,
|
||
|
|
rightTabsRef,
|
||
|
|
sftpSessionsRef,
|
||
|
|
updateTab,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
const renameFile = useCallback(
|
||
|
|
async (side: "left" | "right", oldName: string, newName: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
const oldPath = joinPath(pane.connection.currentPath, oldName);
|
||
|
|
const newPath = joinPath(pane.connection.currentPath, newName);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||
|
|
}
|
||
|
|
await refresh(side);
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
// Rename using a full source path (for tree view where entryPath is already absolute).
|
||
|
|
// newName is still a basename; the new path is built as joinPath(parent, newName).
|
||
|
|
const renameFileAtPath = useCallback(
|
||
|
|
async (side: "left" | "right", oldPath: string, newName: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection) return;
|
||
|
|
|
||
|
|
const parentPath = getParentPath(oldPath);
|
||
|
|
const newPath = joinPath(parentPath, newName);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
|
||
|
|
}
|
||
|
|
if (pane.connection.currentPath === parentPath) {
|
||
|
|
await refresh(side);
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
const moveEntriesToPath = useCallback(
|
||
|
|
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection || sourcePaths.length === 0) return;
|
||
|
|
|
||
|
|
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
|
||
|
|
const filteredSources = uniqueSources
|
||
|
|
.sort((a, b) => a.length - b.length)
|
||
|
|
.filter((path, index, arr) =>
|
||
|
|
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isSftpDescendantPath(path, otherPath)),
|
||
|
|
);
|
||
|
|
|
||
|
|
const movableSources = filteredSources.filter((sourcePath) => {
|
||
|
|
if (isSamePath(sourcePath, targetPath)) return false;
|
||
|
|
if (isSftpDescendantPath(targetPath, sourcePath)) return false;
|
||
|
|
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||
|
|
return !isSamePath(destinationPath, sourcePath);
|
||
|
|
});
|
||
|
|
|
||
|
|
if (movableSources.length === 0) return;
|
||
|
|
|
||
|
|
const sourceParentNames = new Map<string, string[]>();
|
||
|
|
for (const sourcePath of movableSources) {
|
||
|
|
const parentPath = getParentPath(sourcePath);
|
||
|
|
const names = sourceParentNames.get(parentPath) ?? [];
|
||
|
|
names.push(getFileName(sourcePath));
|
||
|
|
sourceParentNames.set(parentPath, names);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (pane.connection.isLocal) {
|
||
|
|
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
|
||
|
|
if (!renameLocalFile) {
|
||
|
|
throw new Error("Local rename unavailable");
|
||
|
|
}
|
||
|
|
for (const sourcePath of movableSources) {
|
||
|
|
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||
|
|
await renameLocalFile(sourcePath, destinationPath);
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const renameSftp = netcattyBridge.get()?.renameSftp;
|
||
|
|
if (!renameSftp) {
|
||
|
|
throw new Error("SFTP rename unavailable");
|
||
|
|
}
|
||
|
|
for (const sourcePath of movableSources) {
|
||
|
|
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
|
||
|
|
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
clearCacheForConnection(pane.connection.id);
|
||
|
|
const currentPath = pane.connection.currentPath;
|
||
|
|
const sourceParents = Array.from(sourceParentNames.keys());
|
||
|
|
const currentPathAffected =
|
||
|
|
sourceParents.some((path) => isSamePath(path, currentPath)) ||
|
||
|
|
isSamePath(targetPath, currentPath);
|
||
|
|
|
||
|
|
if (currentPathAffected) {
|
||
|
|
await refresh(side);
|
||
|
|
} else {
|
||
|
|
updateActiveTab(side, (prev) => {
|
||
|
|
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
|
||
|
|
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
|
||
|
|
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
|
||
|
|
return prev;
|
||
|
|
}
|
||
|
|
|
||
|
|
const removeSet = new Set(namesInCurrentPath);
|
||
|
|
const nextSelection = new Set(prev.selectedFiles);
|
||
|
|
for (const name of removeSet) {
|
||
|
|
nextSelection.delete(name);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
...prev,
|
||
|
|
files: prev.files.filter((file) => !removeSet.has(file.name)),
|
||
|
|
selectedFiles: nextSelection,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[clearCacheForConnection, getActivePane, handleSessionError, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
|
||
|
|
);
|
||
|
|
|
||
|
|
const changePermissions = useCallback(
|
||
|
|
async (
|
||
|
|
side: "left" | "right",
|
||
|
|
filePath: string,
|
||
|
|
mode: string,
|
||
|
|
) => {
|
||
|
|
const pane = getActivePane(side);
|
||
|
|
if (!pane?.connection || pane.connection.isLocal) {
|
||
|
|
logger.warn("Cannot change permissions on local files");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||
|
|
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
|
||
|
|
handleSessionError(side, new Error("SFTP session not found"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
|
||
|
|
await refresh(side);
|
||
|
|
} catch (err) {
|
||
|
|
if (isSessionError(err)) {
|
||
|
|
handleSessionError(side, err as Error);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
logger.error("Failed to change permissions:", err);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
navigateTo,
|
||
|
|
refresh,
|
||
|
|
navigateUp,
|
||
|
|
openEntry,
|
||
|
|
toggleSelection,
|
||
|
|
rangeSelect,
|
||
|
|
clearSelection,
|
||
|
|
selectAll,
|
||
|
|
setFilter,
|
||
|
|
getFilteredFiles,
|
||
|
|
createDirectory,
|
||
|
|
createDirectoryAtPath,
|
||
|
|
createFile,
|
||
|
|
createFileAtPath,
|
||
|
|
deleteFiles,
|
||
|
|
deleteFilesAtPath,
|
||
|
|
renameFile,
|
||
|
|
renameFileAtPath,
|
||
|
|
moveEntriesToPath,
|
||
|
|
changePermissions,
|
||
|
|
};
|
||
|
|
};
|