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(); 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; sftpSessionsRef: React.MutableRefObject>; lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>; connectionCacheKeyMapRef: React.MutableRefObject>; makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string; clearCacheForConnection: (connectionId: string) => void; listLocalFiles: (path: string) => Promise; listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise; handleSessionError: (side: "left" | "right", error: Error) => void; releaseConnection: (connectionId: string) => Promise; 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; refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise; navigateUp: (side: "left" | "right") => Promise; openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise; 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; createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise; createFile: (side: "left" | "right", name: string) => Promise; createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise; deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise; deleteFilesAtPath: ( side: "left" | "right", connectionId: string, path: string, fileNames: string[], ) => Promise; renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise; renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise; moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise; changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise; } 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()); // 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; filter: string }>(), ); const navigateTo = useCallback( async ( side: "left" | "right", path: string, options?: SftpNavigateOptions, ): Promise => { 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(); 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(); 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, }; };