From f517c85d073d3580afdeecd4ece9b4b8a38bb38f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 3 Feb 2026 01:45:53 +0800 Subject: [PATCH] feat: Add SFTP keyboard shortcuts for copy, paste, cut, select all, rename, delete (#180) * Initial plan * feat: Add SFTP keyboard shortcuts support for copy, paste, cut, select all, rename, delete, refresh, and new folder operations Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com> * feat: Add keyboard shortcuts support to SFTPModal for select all, rename, delete, refresh, and new folder Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com> * fix: Address code review feedback - optimize useMemo deps and add same-pane paste notification Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com> * Fix SFTP paste source path * fix: delete sources after SFTP cut paste * Fix SFTP delete key matching * Fix cut delete after successful SFTP transfers * fix: finalize cut-paste deletes after conflicts * fix: track original names for cut transfers * Fix delete key matching for shortcuts * fix: respect visible files for sftp select all * Fix modal selection and cut cleanup * Throw on missing SFTP delete prerequisites * Fix dialog action handler memo deps --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com> --- application/i18n/locales/en.ts | 1 + application/i18n/locales/zh-CN.ts | 1 + application/state/sftp/useSftpPaneActions.ts | 89 ++++++ application/state/sftp/useSftpTransfers.ts | 119 +++++++- application/state/useSftpState.ts | 5 + components/SFTPModal.tsx | 53 +++- components/SftpView.tsx | 29 +- .../settings/tabs/SettingsShortcutsTab.tsx | 2 +- .../hooks/useSftpModalKeyboardShortcuts.ts | 155 ++++++++++ components/sftp/SftpPaneView.tsx | 30 +- components/sftp/hooks/useSftpClipboard.ts | 124 ++++++++ components/sftp/hooks/useSftpDialogAction.ts | 120 ++++++++ components/sftp/hooks/useSftpFocusedPane.ts | 54 ++++ .../sftp/hooks/useSftpKeyboardShortcuts.ts | 289 ++++++++++++++++++ domain/models.ts | 45 ++- package-lock.json | 32 +- 16 files changed, 1097 insertions(+), 51 deletions(-) create mode 100644 components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts create mode 100644 components/sftp/hooks/useSftpClipboard.ts create mode 100644 components/sftp/hooks/useSftpDialogAction.ts create mode 100644 components/sftp/hooks/useSftpFocusedPane.ts create mode 100644 components/sftp/hooks/useSftpKeyboardShortcuts.ts diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index 8112a8339..04f0acad8 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -253,6 +253,7 @@ const en: Messages = { 'settings.shortcuts.category.terminal': 'Terminal', 'settings.shortcuts.category.navigation': 'Navigation', 'settings.shortcuts.category.app': 'App', + 'settings.shortcuts.category.sftp': 'SFTP', // Context menus / common actions 'action.newHost': 'New Host', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 5c5d9dd40..faa6b5200 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -1048,6 +1048,7 @@ const zhCN: Messages = { 'settings.shortcuts.category.terminal': '终端', 'settings.shortcuts.category.navigation': '导航', 'settings.shortcuts.category.app': '应用', + 'settings.shortcuts.category.sftp': 'SFTP', // Host Details (sub-panels) 'hostDetails.proxyPanel.title': 'Proxy', diff --git a/application/state/sftp/useSftpPaneActions.ts b/application/state/sftp/useSftpPaneActions.ts index cc6972e7c..e3162d4be 100644 --- a/application/state/sftp/useSftpPaneActions.ts +++ b/application/state/sftp/useSftpPaneActions.ts @@ -39,6 +39,12 @@ interface UseSftpPaneActionsResult { createDirectory: (side: "left" | "right", name: string) => Promise; createFile: (side: "left" | "right", 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; changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise; } @@ -452,6 +458,88 @@ export const useSftpPaneActions = ({ [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 { + for (const name of fileNames) { + const fullPath = joinPath(path, name); + + if (pane.connection.isLocal) { + if (!bridge.deleteLocalFile) { + throw new Error("Local delete unavailable"); + } + await bridge.deleteLocalFile(fullPath); + } 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 bridge.deleteSftp(sftpId, fullPath, 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); @@ -529,6 +617,7 @@ export const useSftpPaneActions = ({ createDirectory, createFile, deleteFiles, + deleteFilesAtPath, renameFile, changePermissions, }; diff --git a/application/state/sftp/useSftpTransfers.ts b/application/state/sftp/useSftpTransfers.ts index 145510e73..63545cc17 100644 --- a/application/state/sftp/useSftpTransfers.ts +++ b/application/state/sftp/useSftpTransfers.ts @@ -29,7 +29,13 @@ interface UseSftpTransfersResult { sourceFiles: { name: string; isDirectory: boolean }[], sourceSide: "left" | "right", targetSide: "left" | "right", - ) => Promise; + options?: { + sourcePane?: SftpPane; + sourcePath?: string; + sourceConnectionId?: string; + onTransferComplete?: (result: TransferResult) => void | Promise; + }, + ) => Promise; addExternalUpload: (task: TransferTask) => void; updateExternalUpload: (taskId: string, updates: Partial) => void; cancelTransfer: (transferId: string) => Promise; @@ -39,6 +45,13 @@ interface UseSftpTransfersResult { resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise; } +interface TransferResult { + id: string; + fileName: string; + originalFileName?: string; + status: TransferStatus; +} + export const useSftpTransfers = ({ getActivePane, refresh, @@ -53,6 +66,7 @@ export const useSftpTransfers = ({ const progressIntervalsRef = useRef>(new Map()); // Track cancelled task IDs for checking during async operations const cancelledTasksRef = useRef>(new Set()); + const completionHandlersRef = useRef void | Promise>>(new Map()); useEffect(() => { const intervalsRef = progressIntervalsRef.current; @@ -268,6 +282,7 @@ export const useSftpTransfers = ({ ...task, id: crypto.randomUUID(), fileName: file.name, + originalFileName: file.name, sourcePath: joinPath(task.sourcePath, file.name), targetPath: joinPath(task.targetPath, file.name), isDirectory: file.type === "directory", @@ -305,7 +320,7 @@ export const useSftpTransfers = ({ sourcePane: SftpPane, targetPane: SftpPane, targetSide: "left" | "right", - ) => { + ): Promise => { const updateTask = (updates: Partial) => { setTransfers((prev) => prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)), @@ -461,7 +476,7 @@ export const useSftpTransfers = ({ status: "pending", totalBytes: sourceStat?.size || estimatedSize, }); - return; + return "pending"; } } @@ -507,6 +522,20 @@ export const useSftpTransfers = ({ ); await refresh(targetSide); + const completionHandler = completionHandlersRef.current.get(task.id); + if (completionHandler) { + try { + await completionHandler({ + id: task.id, + fileName: task.fileName, + originalFileName: task.originalFileName ?? task.fileName, + status: "completed", + }); + } finally { + completionHandlersRef.current.delete(task.id); + } + } + return "completed"; } catch (err) { if (useSimulatedProgress) { stopProgressSimulation(task.id); @@ -518,7 +547,20 @@ export const useSftpTransfers = ({ if (isCancelled) { // Don't update status - cancelTransfer already set it to cancelled - return; + const completionHandler = completionHandlersRef.current.get(task.id); + if (completionHandler) { + try { + await completionHandler({ + id: task.id, + fileName: task.fileName, + originalFileName: task.originalFileName ?? task.fileName, + status: "cancelled", + }); + } finally { + completionHandlersRef.current.delete(task.id); + } + } + return "cancelled"; } updateTask({ @@ -527,6 +569,20 @@ export const useSftpTransfers = ({ endTime: Date.now(), speed: 0, }); + const completionHandler = completionHandlersRef.current.get(task.id); + if (completionHandler) { + try { + await completionHandler({ + id: task.id, + fileName: task.fileName, + originalFileName: task.originalFileName ?? task.fileName, + status: "failed", + }); + } finally { + completionHandlersRef.current.delete(task.id); + } + } + return "failed"; } }; @@ -534,23 +590,30 @@ export const useSftpTransfers = ({ async ( sourceFiles: { name: string; isDirectory: boolean }[], sourceSide: "left" | "right", - targetSide: "left" | "right", - ) => { - const sourcePane = getActivePane(sourceSide); + targetSide: "left" | "right", + options?: { + sourcePane?: SftpPane; + sourcePath?: string; + sourceConnectionId?: string; + onTransferComplete?: (result: TransferResult) => void | Promise; + }, + ) => { + const sourcePane = options?.sourcePane ?? getActivePane(sourceSide); const targetPane = getActivePane(targetSide); - if (!sourcePane?.connection || !targetPane?.connection) return; + if (!sourcePane?.connection || !targetPane?.connection) return []; const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal ? "auto" : sourcePane.filenameEncoding || "auto"; - const sourcePath = sourcePane.connection.currentPath; + const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath; const targetPath = targetPane.connection.currentPath; + const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id; const sourceSftpId = sourcePane.connection.isLocal ? null - : sftpSessionsRef.current.get(sourcePane.connection.id); + : sftpSessionsRef.current.get(sourceConnectionId); const newTasks: TransferTask[] = []; @@ -585,9 +648,10 @@ export const useSftpTransfers = ({ newTasks.push({ id: crypto.randomUUID(), fileName: file.name, + originalFileName: file.name, sourcePath: joinPath(sourcePath, file.name), targetPath: joinPath(targetPath, file.name), - sourceConnectionId: sourcePane.connection!.id, + sourceConnectionId, targetConnectionId: targetPane.connection!.id, direction, status: "pending" as TransferStatus, @@ -601,9 +665,25 @@ export const useSftpTransfers = ({ setTransfers((prev) => [...prev, ...newTasks]); - for (const task of newTasks) { - await processTransfer(task, sourcePane, targetPane, targetSide); + if (options?.onTransferComplete) { + for (const task of newTasks) { + completionHandlersRef.current.set(task.id, options.onTransferComplete); + } } + + const results: TransferResult[] = []; + + for (const task of newTasks) { + const status = await processTransfer(task, sourcePane, targetPane, targetSide); + results.push({ + id: task.id, + fileName: task.fileName, + originalFileName: task.originalFileName ?? task.fileName, + status, + }); + } + + return results; }, // eslint-disable-next-line react-hooks/exhaustive-deps [getActivePane, sftpSessionsRef], @@ -715,6 +795,19 @@ export const useSftpTransfers = ({ : t, ), ); + const completionHandler = completionHandlersRef.current.get(conflictId); + if (completionHandler) { + try { + await completionHandler({ + id: task.id, + fileName: task.fileName, + originalFileName: task.originalFileName ?? task.fileName, + status: "cancelled", + }); + } finally { + completionHandlersRef.current.delete(conflictId); + } + } return; } diff --git a/application/state/useSftpState.ts b/application/state/useSftpState.ts index 67efb1ea1..dcb730ab7 100644 --- a/application/state/useSftpState.ts +++ b/application/state/useSftpState.ts @@ -160,6 +160,7 @@ export const useSftpState = ( createDirectory, createFile, deleteFiles, + deleteFilesAtPath, renameFile, changePermissions, } = useSftpPaneActions({ @@ -269,6 +270,7 @@ export const useSftpState = ( createDirectory, createFile, deleteFiles, + deleteFilesAtPath, renameFile, changePermissions, readTextFile, @@ -313,6 +315,7 @@ export const useSftpState = ( createDirectory, createFile, deleteFiles, + deleteFilesAtPath, renameFile, changePermissions, readTextFile, @@ -361,6 +364,8 @@ export const useSftpState = ( createDirectory: (...args: Parameters) => methodsRef.current.createDirectory(...args), createFile: (...args: Parameters) => methodsRef.current.createFile(...args), deleteFiles: (...args: Parameters) => methodsRef.current.deleteFiles(...args), + deleteFilesAtPath: (...args: Parameters) => + methodsRef.current.deleteFilesAtPath(...args), renameFile: (...args: Parameters) => methodsRef.current.renameFile(...args), changePermissions: (...args: Parameters) => methodsRef.current.changePermissions(...args), readTextFile: (...args: Parameters) => methodsRef.current.readTextFile(...args), diff --git a/components/SFTPModal.tsx b/components/SFTPModal.tsx index 6dc098127..294a21898 100644 --- a/components/SFTPModal.tsx +++ b/components/SFTPModal.tsx @@ -21,6 +21,7 @@ import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath"; import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection"; import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession"; import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions"; +import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts"; import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils"; import { toast } from "./ui/toast"; import { Dialog, DialogContent } from "./ui/dialog"; @@ -85,7 +86,7 @@ const SFTPModal: React.FC = ({ showSaveDialog, } = useSftpBackend(); const { t } = useI18n(); - const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState(); + const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState(); const isLocalSession = host.protocol === "local"; const [filenameEncoding, setFilenameEncoding] = useState( host.sftpEncoding ?? "auto" @@ -508,6 +509,56 @@ const SFTPModal: React.FC = ({ onNavigateUp: handleUp, }); + // Keyboard shortcuts for modal + const handleKeyboardRename = useCallback((file: RemoteFile) => { + openRenameDialog(file); + }, [openRenameDialog]); + + const handleKeyboardDelete = useCallback((fileNames: string[]) => { + // Find the files to pass to confirm dialog + if (fileNames.length === 0) return; + if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return; + + // Delete files + (async () => { + try { + for (const fileName of fileNames) { + const fullPath = joinPathForSession(currentPath, fileName); + if (isLocalSession) { + await deleteLocalFile(fullPath); + } else { + await deleteSftpWithEncoding(await ensureSftp(), fullPath); + } + } + await loadFiles(currentPath, { force: true }); + setSelectedFiles(new Set()); + } catch (e) { + toast.error( + e instanceof Error ? e.message : t("sftp.error.deleteFailed"), + "SFTP", + ); + } + })(); + }, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]); + + const handleKeyboardNewFolder = useCallback(() => { + handleCreateFolder(); + }, [handleCreateFolder]); + + useSftpModalKeyboardShortcuts({ + keyBindings, + hotkeyScheme, + open, + files, + visibleFiles: displayFiles, + selectedFiles, + setSelectedFiles, + onRefresh: () => loadFiles(currentPath, { force: true }), + onRename: handleKeyboardRename, + onDelete: handleKeyboardDelete, + onNewFolder: handleKeyboardNewFolder, + }); + const handleDeleteSelected = async () => { if (selectedFiles.size === 0) return; const fileNames = Array.from(selectedFiles); diff --git a/components/SftpView.tsx b/components/SftpView.tsx index c5c30ded8..fde378cfe 100644 --- a/components/SftpView.tsx +++ b/components/SftpView.tsx @@ -14,7 +14,7 @@ * - components/sftp/SftpHostPicker.tsx - Host selection dialog */ -import React, { memo, useLayoutEffect, useMemo, useRef } from "react"; +import React, { memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useI18n } from "../application/i18n/I18nProvider"; import { useIsSftpActive } from "../application/state/activeTabStore"; import { useSftpState } from "../application/state/useSftpState"; @@ -37,6 +37,8 @@ import { Loader2 } from "lucide-react"; 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 } from "./sftp/hooks/useSftpFocusedPane"; // Wrapper component that subscribes to activeTabId for CSS visibility // This isolates the activeTabId subscription - only this component re-renders on tab switch @@ -51,7 +53,7 @@ interface SftpViewProps { const SftpViewInner: React.FC = ({ hosts, keys, identities }) => { const { t } = useI18n(); const isActive = useIsSftpActive(); - const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState(); + const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState(); // File watch event handlers (stable refs to avoid re-creating the useSftpState options) const fileWatchHandlers = useMemo(() => ({ @@ -84,6 +86,19 @@ const SftpViewInner: React.FC = ({ hosts, keys, identities }) => const autoSyncRef = useRef(sftpAutoSync); autoSyncRef.current = sftpAutoSync; + // SFTP keyboard shortcuts handler + useSftpKeyboardShortcuts({ + keyBindings, + hotkeyScheme, + sftpRef, + isActive, + }); + + // Handle pane focus when clicking on a pane container + const handlePaneFocus = useCallback((side: SftpFocusedSide) => { + sftpFocusStore.setFocusedSide(side); + }, []); + // Sync activeTabId to external store (allows child components to subscribe without parent re-render) // Using useLayoutEffect to sync before paint useLayoutEffect(() => { @@ -200,7 +215,10 @@ const SftpViewInner: React.FC = ({ hosts, keys, identities }) => style={containerStyle} >
-
+
handlePaneFocus("left")} + > {/* Left side tab bar - only show when there are tabs */} {leftTabsInfo.length > 0 && ( = ({ hosts, keys, identities }) => )}
-
+
handlePaneFocus("right")} + > {/* Right side tab bar - only show when there are tabs */} {rightTabsInfo.length > 0 && ( ["tabs", "terminal", "navigation", "app"] as const, []); + const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []); return ( diff --git a/components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts b/components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts new file mode 100644 index 000000000..45eb6ff7b --- /dev/null +++ b/components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts @@ -0,0 +1,155 @@ +/** + * useSftpModalKeyboardShortcuts + * + * Hook that handles keyboard shortcuts for SFTPModal operations. + * Supports select all, rename, delete, refresh, and new folder. + * Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view. + */ + +import { useCallback, useEffect } from "react"; +import { KeyBinding, matchesKeyBinding } from "../../../domain/models"; +import type { RemoteFile } from "../../../types"; + +// SFTP Modal action names that we handle (subset of main SFTP actions) +const SFTP_MODAL_ACTIONS = new Set([ + "sftpSelectAll", + "sftpRename", + "sftpDelete", + "sftpRefresh", + "sftpNewFolder", +]); + +interface UseSftpModalKeyboardShortcutsParams { + keyBindings: KeyBinding[]; + hotkeyScheme: "disabled" | "mac" | "pc"; + open: boolean; + files: RemoteFile[]; + visibleFiles: RemoteFile[]; + selectedFiles: Set; + setSelectedFiles: (files: Set) => void; + onRefresh: () => void; + onRename?: (file: RemoteFile) => void; + onDelete?: (fileNames: string[]) => void; + onNewFolder?: () => void; +} + +/** + * Check if a keyboard event matches any SFTP action + */ +const matchSftpAction = ( + e: KeyboardEvent, + keyBindings: KeyBinding[], + isMac: boolean +): { action: string; binding: KeyBinding } | null => { + for (const binding of keyBindings) { + if (binding.category !== "sftp") continue; + const keyStr = isMac ? binding.mac : binding.pc; + if (matchesKeyBinding(e, keyStr, isMac)) { + return { action: binding.action, binding }; + } + } + return null; +}; + +export const useSftpModalKeyboardShortcuts = ({ + keyBindings, + hotkeyScheme, + open, + files, + visibleFiles, + selectedFiles, + setSelectedFiles, + onRefresh, + onRename, + onDelete, + onNewFolder, +}: UseSftpModalKeyboardShortcutsParams) => { + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Skip if shortcuts are disabled or modal is not open + if (hotkeyScheme === "disabled" || !open) return; + + // Skip if focus is on an input element + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + const isMac = hotkeyScheme === "mac"; + const matched = matchSftpAction(e, keyBindings, isMac); + if (!matched) return; + + const { action } = matched; + if (!SFTP_MODAL_ACTIONS.has(action)) return; + + // Prevent default behavior + e.preventDefault(); + e.stopPropagation(); + + switch (action) { + case "sftpSelectAll": { + // Select all files + const allFileNames = new Set( + visibleFiles.filter((f) => f.name !== "..").map((f) => f.name) + ); + setSelectedFiles(allFileNames); + break; + } + + case "sftpRename": { + // Trigger rename for the first selected file + const selectedArray = Array.from(selectedFiles); + if (selectedArray.length !== 1) return; + const file = files.find((f) => f.name === selectedArray[0]); + if (file && onRename) { + onRename(file); + } + break; + } + + case "sftpDelete": { + // Delete selected files + const selectedArray = Array.from(selectedFiles); + if (selectedArray.length === 0) return; + onDelete?.(selectedArray); + break; + } + + case "sftpRefresh": { + // Refresh file list + onRefresh(); + break; + } + + case "sftpNewFolder": { + // Create new folder + onNewFolder?.(); + break; + } + } + }, + [ + hotkeyScheme, + open, + files, + visibleFiles, + selectedFiles, + setSelectedFiles, + onRefresh, + onRename, + onDelete, + onNewFolder, + keyBindings, + ] + ); + + useEffect(() => { + // Use capture phase to intercept before other handlers + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [handleKeyDown]); +}; diff --git a/components/sftp/SftpPaneView.tsx b/components/sftp/SftpPaneView.tsx index d594d0bc1..0b217b7b9 100644 --- a/components/sftp/SftpPaneView.tsx +++ b/components/sftp/SftpPaneView.tsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useRef, useState, useTransition } from "react"; +import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useI18n } from "../../application/i18n/I18nProvider"; import { logger } from "../../lib/logger"; import { useRenderTracker } from "../../lib/useRenderTracker"; @@ -21,6 +21,7 @@ import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles"; import { useSftpPanePath } from "./hooks/useSftpPanePath"; import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting"; import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList"; +import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction"; interface SftpPaneWrapperProps { side: "left" | "right"; @@ -195,6 +196,33 @@ const SftpPaneViewInner: React.FC = ({ sortedDisplayFiles, }); + // Handle keyboard shortcut dialog actions + const dialogActionHandlers = useMemo( + () => ({ + onRename: (fileName: string) => openRenameDialog(fileName), + onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames), + onNewFolder: () => setShowNewFolderDialog(true), + onNewFile: () => { + const defaultName = getNextUntitledName(pane.files.map(f => f.name)); + setNewFileName(defaultName); + setFileNameError(null); + setShowNewFileDialog(true); + }, + }), + [ + getNextUntitledName, + openDeleteConfirm, + openRenameDialog, + pane.files, + setFileNameError, + setNewFileName, + setShowNewFileDialog, + setShowNewFolderDialog, + ], + ); + + useSftpDialogActionHandler(side, dialogActionHandlers); + const handleSortWithTransition = (field: typeof sortField) => { startTransition(() => handleSort(field)); }; diff --git a/components/sftp/hooks/useSftpClipboard.ts b/components/sftp/hooks/useSftpClipboard.ts new file mode 100644 index 000000000..8f08785c1 --- /dev/null +++ b/components/sftp/hooks/useSftpClipboard.ts @@ -0,0 +1,124 @@ +/** + * SFTP Clipboard Store + * + * Manages clipboard state for SFTP file operations (copy/cut/paste) + * This is a simple store that holds the clipboard state and operation type. + */ + +import { useSyncExternalStore } from "react"; + +export type SftpClipboardOperation = "copy" | "cut"; + +export interface SftpClipboardFile { + name: string; + isDirectory: boolean; +} + +export interface SftpClipboardState { + files: SftpClipboardFile[]; + sourcePath: string; + sourceConnectionId: string; + sourceSide: "left" | "right"; + operation: SftpClipboardOperation; +} + +type ClipboardListener = () => void; + +let clipboardState: SftpClipboardState | null = null; +const clipboardListeners = new Set(); + +const notifyListeners = () => { + clipboardListeners.forEach((listener) => listener()); +}; + +export const sftpClipboardStore = { + getSnapshot: (): SftpClipboardState | null => clipboardState, + + subscribe: (listener: ClipboardListener) => { + clipboardListeners.add(listener); + return () => clipboardListeners.delete(listener); + }, + + /** + * Copy files to clipboard + */ + copy: ( + files: SftpClipboardFile[], + sourcePath: string, + sourceConnectionId: string, + sourceSide: "left" | "right" + ) => { + clipboardState = { + files, + sourcePath, + sourceConnectionId, + sourceSide, + operation: "copy", + }; + notifyListeners(); + }, + + /** + * Cut files to clipboard + */ + cut: ( + files: SftpClipboardFile[], + sourcePath: string, + sourceConnectionId: string, + sourceSide: "left" | "right" + ) => { + clipboardState = { + files, + sourcePath, + sourceConnectionId, + sourceSide, + operation: "cut", + }; + notifyListeners(); + }, + + /** + * Clear clipboard (called after paste for cut operation) + */ + clear: () => { + clipboardState = null; + notifyListeners(); + }, + + /** + * Update clipboard file list (used for partial cut transfers) + */ + updateFiles: (files: SftpClipboardFile[]) => { + if (!clipboardState) return; + if (files.length === 0) { + clipboardState = null; + } else { + clipboardState = { + ...clipboardState, + files, + }; + } + notifyListeners(); + }, + + /** + * Check if there are files in the clipboard + */ + hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0, + + /** + * Get the clipboard state + */ + get: (): SftpClipboardState | null => clipboardState, +}; + +/** + * React hook to subscribe to clipboard state changes + */ +export const useSftpClipboard = (): SftpClipboardState | null => { + return useSyncExternalStore( + sftpClipboardStore.subscribe, + sftpClipboardStore.getSnapshot, + sftpClipboardStore.getSnapshot + ); +}; diff --git a/components/sftp/hooks/useSftpDialogAction.ts b/components/sftp/hooks/useSftpDialogAction.ts new file mode 100644 index 000000000..0cd4b233f --- /dev/null +++ b/components/sftp/hooks/useSftpDialogAction.ts @@ -0,0 +1,120 @@ +/** + * SFTP Dialog Action Store + * + * Manages dialog action triggers for SFTP operations. + * This store allows keyboard shortcuts to trigger dialogs in the appropriate pane. + */ + +import { useSyncExternalStore, useCallback, useEffect } from "react"; +import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane"; + +export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null; + +export interface SftpDialogAction { + type: SftpDialogActionType; + targetSide: SftpFocusedSide; + targetFiles?: string[]; // For rename (single file) or delete (multiple files) + timestamp: number; // To distinguish different triggers of the same action +} + +type ActionListener = () => void; + +let dialogAction: SftpDialogAction | null = null; +const actionListeners = new Set(); + +const notifyListeners = () => { + actionListeners.forEach((listener) => listener()); +}; + +export const sftpDialogActionStore = { + getSnapshot: (): SftpDialogAction | null => dialogAction, + + subscribe: (listener: ActionListener) => { + actionListeners.add(listener); + return () => actionListeners.delete(listener); + }, + + /** + * Trigger a dialog action + */ + trigger: (type: SftpDialogActionType, targetFiles?: string[]) => { + if (!type) { + dialogAction = null; + } else { + dialogAction = { + type, + targetSide: sftpFocusStore.getFocusedSide(), + targetFiles, + timestamp: Date.now(), + }; + } + notifyListeners(); + }, + + /** + * Clear the current action (called after a pane handles it) + */ + clear: () => { + dialogAction = null; + notifyListeners(); + }, + + /** + * Get the current action + */ + get: (): SftpDialogAction | null => dialogAction, +}; + +/** + * React hook to subscribe to dialog action changes + */ +export const useSftpDialogAction = (): SftpDialogAction | null => { + return useSyncExternalStore( + sftpDialogActionStore.subscribe, + sftpDialogActionStore.getSnapshot, + sftpDialogActionStore.getSnapshot + ); +}; + +/** + * React hook for a pane to respond to dialog actions + * Only the pane matching the targetSide will execute the callback + */ +export const useSftpDialogActionHandler = ( + side: SftpFocusedSide, + handlers: { + onRename?: (fileName: string) => void; + onDelete?: (fileNames: string[]) => void; + onNewFolder?: () => void; + onNewFile?: () => void; + } +) => { + const action = useSftpDialogAction(); + + useEffect(() => { + if (!action || action.targetSide !== side) return; + + // Handle the action and clear it + switch (action.type) { + case "rename": + if (handlers.onRename && action.targetFiles?.[0]) { + handlers.onRename(action.targetFiles[0]); + } + break; + case "delete": + if (handlers.onDelete && action.targetFiles) { + handlers.onDelete(action.targetFiles); + } + break; + case "newFolder": + handlers.onNewFolder?.(); + break; + case "newFile": + handlers.onNewFile?.(); + break; + } + + // Clear the action after handling + sftpDialogActionStore.clear(); + }, [action, side, handlers]); +}; diff --git a/components/sftp/hooks/useSftpFocusedPane.ts b/components/sftp/hooks/useSftpFocusedPane.ts new file mode 100644 index 000000000..69f9bc50c --- /dev/null +++ b/components/sftp/hooks/useSftpFocusedPane.ts @@ -0,0 +1,54 @@ +/** + * SFTP Focused Pane Store + * + * Tracks which SFTP pane (left or right) is currently focused. + * This is used to determine which pane should receive keyboard shortcut actions. + */ + +import { useSyncExternalStore } from "react"; + +export type SftpFocusedSide = "left" | "right"; + +type FocusListener = () => void; + +let focusedSide: SftpFocusedSide = "left"; +const focusListeners = new Set(); + +const notifyListeners = () => { + focusListeners.forEach((listener) => listener()); +}; + +export const sftpFocusStore = { + getSnapshot: (): SftpFocusedSide => focusedSide, + + subscribe: (listener: FocusListener) => { + focusListeners.add(listener); + return () => focusListeners.delete(listener); + }, + + /** + * Set the focused side + */ + setFocusedSide: (side: SftpFocusedSide) => { + if (focusedSide !== side) { + focusedSide = side; + notifyListeners(); + } + }, + + /** + * Get the current focused side + */ + getFocusedSide: (): SftpFocusedSide => focusedSide, +}; + +/** + * React hook to subscribe to focused side changes + */ +export const useSftpFocusedSide = (): SftpFocusedSide => { + return useSyncExternalStore( + sftpFocusStore.subscribe, + sftpFocusStore.getSnapshot, + sftpFocusStore.getSnapshot + ); +}; diff --git a/components/sftp/hooks/useSftpKeyboardShortcuts.ts b/components/sftp/hooks/useSftpKeyboardShortcuts.ts new file mode 100644 index 000000000..4330293e8 --- /dev/null +++ b/components/sftp/hooks/useSftpKeyboardShortcuts.ts @@ -0,0 +1,289 @@ +/** + * useSftpKeyboardShortcuts + * + * Hook that handles keyboard shortcuts for SFTP operations. + * Supports copy, cut, paste, select all, rename, delete, refresh, and new folder. + */ + +import { useCallback, useEffect } from "react"; +import type { MutableRefObject } from "react"; +import { KeyBinding, matchesKeyBinding } from "../../../domain/models"; +import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard"; +import { sftpFocusStore } from "./useSftpFocusedPane"; +import { sftpDialogActionStore } from "./useSftpDialogAction"; +import type { SftpStateApi } from "../../../application/state/useSftpState"; +import { filterHiddenFiles, isNavigableDirectory, useSftpShowHiddenFiles } from "../index"; +import { toast } from "../../ui/toast"; + +// SFTP action names that we handle +const SFTP_ACTIONS = new Set([ + "sftpCopy", + "sftpCut", + "sftpPaste", + "sftpSelectAll", + "sftpRename", + "sftpDelete", + "sftpRefresh", + "sftpNewFolder", +]); + +interface UseSftpKeyboardShortcutsParams { + keyBindings: KeyBinding[]; + hotkeyScheme: "disabled" | "mac" | "pc"; + sftpRef: MutableRefObject; + isActive: boolean; +} + +/** + * Check if a keyboard event matches any SFTP action + */ +const matchSftpAction = ( + e: KeyboardEvent, + keyBindings: KeyBinding[], + isMac: boolean +): { action: string; binding: KeyBinding } | null => { + for (const binding of keyBindings) { + if (binding.category !== "sftp") continue; + const keyStr = isMac ? binding.mac : binding.pc; + if (matchesKeyBinding(e, keyStr, isMac)) { + return { action: binding.action, binding }; + } + } + return null; +}; + +export const useSftpKeyboardShortcuts = ({ + keyBindings, + hotkeyScheme, + sftpRef, + isActive, +}: UseSftpKeyboardShortcutsParams) => { + const showHiddenFiles = useSftpShowHiddenFiles(); + const handleKeyDown = useCallback( + async (e: KeyboardEvent) => { + // Skip if shortcuts are disabled or SFTP is not active + if (hotkeyScheme === "disabled" || !isActive) return; + + // Skip if focus is on an input element + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + const isMac = hotkeyScheme === "mac"; + const matched = matchSftpAction(e, keyBindings, isMac); + if (!matched) return; + + const { action } = matched; + if (!SFTP_ACTIONS.has(action)) return; + + // Prevent default behavior + e.preventDefault(); + e.stopPropagation(); + + const sftp = sftpRef.current; + const focusedSide = sftpFocusStore.getFocusedSide(); + + // Get the active pane for the focused side + const pane = focusedSide === "left" + ? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId) + : sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId); + + if (!pane || !pane.connection) return; + + switch (action) { + case "sftpCopy": { + // Copy selected files to clipboard + const selectedFiles = Array.from(pane.selectedFiles) as string[]; + if (selectedFiles.length === 0) return; + + const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => { + const file = pane.files.find((f) => f.name === name); + return { + name, + isDirectory: file ? isNavigableDirectory(file) : false, + }; + }); + + sftpClipboardStore.copy( + clipboardFiles, + pane.connection.currentPath, + pane.connection.id, + focusedSide + ); + break; + } + + case "sftpCut": { + // Cut selected files to clipboard + const selectedFiles = Array.from(pane.selectedFiles) as string[]; + if (selectedFiles.length === 0) return; + + const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => { + const file = pane.files.find((f) => f.name === name); + return { + name, + isDirectory: file ? isNavigableDirectory(file) : false, + }; + }); + + sftpClipboardStore.cut( + clipboardFiles, + pane.connection.currentPath, + pane.connection.id, + focusedSide + ); + break; + } + + case "sftpPaste": { + // Paste files from clipboard + const clipboard = sftpClipboardStore.get(); + if (!clipboard || clipboard.files.length === 0) return; + + // Use startTransfer to paste files from source to current pane + // The transfer direction is determined by clipboard sourceSide and current focusedSide + if (clipboard.sourceSide !== focusedSide) { + const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs; + const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId); + + if (!sourcePane?.connection) { + toast.info("Paste source is no longer available.", "SFTP"); + return; + } + + // Cross-pane paste - use startTransfer + try { + const isCut = clipboard.operation === "cut"; + const pendingNames = new Set(clipboard.files.map((file) => file.name)); + const completedNames = new Set(); + const failedNames = new Set(); + + const updateClipboardAfterCompletion = (showToast: boolean) => { + if (!isCut) return; + const current = sftpClipboardStore.get(); + if ( + !current || + current.operation !== "cut" || + current.sourceConnectionId !== clipboard.sourceConnectionId || + current.sourcePath !== clipboard.sourcePath || + current.sourceSide !== clipboard.sourceSide + ) { + return; + } + + const remainingFiles = current.files.filter((file) => !completedNames.has(file.name)); + if (remainingFiles.length === 0) { + sftpClipboardStore.clear(); + } else { + sftpClipboardStore.updateFiles(remainingFiles); + } + + if (showToast && failedNames.size > 0) { + toast.info("Some items could not be transferred and were kept in the clipboard.", "SFTP"); + } + }; + + const handleTransferComplete = async (result: { + fileName: string; + originalFileName?: string; + status: string; + }) => { + if (!isCut) return; + const sourceFileName = result.originalFileName ?? result.fileName; + if (!pendingNames.has(sourceFileName)) return; + pendingNames.delete(sourceFileName); + + if (result.status === "completed") { + try { + await sftp.deleteFilesAtPath( + clipboard.sourceSide, + clipboard.sourceConnectionId, + clipboard.sourcePath, + [sourceFileName], + ); + completedNames.add(sourceFileName); + } catch { + failedNames.add(sourceFileName); + } + } else { + failedNames.add(sourceFileName); + } + + updateClipboardAfterCompletion(pendingNames.size === 0); + }; + + await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, { + sourcePane, + sourcePath: clipboard.sourcePath, + sourceConnectionId: clipboard.sourceConnectionId, + onTransferComplete: handleTransferComplete, + }); + } catch (error) { + toast.error("Paste failed. Please try again.", "SFTP"); + } + } else { + // Same-pane paste is not supported - show info toast + toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP"); + } + break; + } + + case "sftpSelectAll": { + // Select all files in the current pane + const term = pane.filter.trim().toLowerCase(); + let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles); + if (term) { + visibleFiles = visibleFiles.filter( + (f) => f.name === ".." || f.name.toLowerCase().includes(term), + ); + } + const allFileNames = visibleFiles + .filter((f) => f.name !== "..") + .map((f) => f.name); + sftp.rangeSelect(focusedSide, allFileNames); + break; + } + + case "sftpRename": { + // Trigger rename for the first selected file + const selectedFiles = Array.from(pane.selectedFiles) as string[]; + if (selectedFiles.length !== 1) return; + sftpDialogActionStore.trigger("rename", selectedFiles); + break; + } + + case "sftpDelete": { + // Delete selected files + const selectedFiles = Array.from(pane.selectedFiles) as string[]; + if (selectedFiles.length === 0) return; + sftpDialogActionStore.trigger("delete", selectedFiles); + break; + } + + case "sftpRefresh": { + // Refresh the current pane + sftp.refresh(focusedSide); + break; + } + + case "sftpNewFolder": { + // Create new folder + sftpDialogActionStore.trigger("newFolder"); + break; + } + } + }, + [hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles] + ); + + useEffect(() => { + // Use capture phase to intercept before other handlers + window.addEventListener("keydown", handleKeyDown, true); + return () => window.removeEventListener("keydown", handleKeyDown, true); + }, [handleKeyDown]); +}; diff --git a/domain/models.ts b/domain/models.ts index bf6ba51b2..46ff48231 100755 --- a/domain/models.ts +++ b/domain/models.ts @@ -174,7 +174,7 @@ export interface KeyBinding { label: string; mac: string; // e.g., '⌘+1', '⌘+⌥+arrows' pc: string; // e.g., 'Ctrl+1', 'Ctrl+Alt+arrows' - category: 'tabs' | 'terminal' | 'navigation' | 'app'; + category: 'tabs' | 'terminal' | 'navigation' | 'app' | 'sftp'; } // User's custom key bindings - only stores overrides from defaults @@ -292,18 +292,26 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole if (e.metaKey !== needMeta) return false; } - // Check key - let eventKey = e.key; - if (eventKey === ' ') eventKey = 'Space'; - else if (eventKey === 'ArrowUp') eventKey = '↑'; - else if (eventKey === 'ArrowDown') eventKey = '↓'; - else if (eventKey === 'ArrowLeft') eventKey = '←'; - else if (eventKey === 'ArrowRight') eventKey = '→'; - else if (eventKey === 'Escape') eventKey = 'Esc'; - else if (eventKey === '[') eventKey = '['; - else if (eventKey === ']') eventKey = ']'; - - return eventKey.toLowerCase() === key.toLowerCase(); + const normalizeKey = (rawKey: string): string => { + let normalizedKey = rawKey; + if (normalizedKey === ' ') normalizedKey = 'Space'; + else if (normalizedKey === 'ArrowUp') normalizedKey = '↑'; + else if (normalizedKey === 'ArrowDown') normalizedKey = '↓'; + else if (normalizedKey === 'ArrowLeft') normalizedKey = '←'; + else if (normalizedKey === 'ArrowRight') normalizedKey = '→'; + else if (normalizedKey === 'Escape') normalizedKey = 'Esc'; + else if (normalizedKey === 'Backspace') normalizedKey = '⌫'; + else if (normalizedKey === 'Delete') normalizedKey = 'Del'; + else if (normalizedKey === '[') normalizedKey = '['; + else if (normalizedKey === ']') normalizedKey = ']'; + else if (normalizedKey === 'Del') normalizedKey = 'Del'; + return normalizedKey; + }; + + const eventKey = normalizeKey(e.key); + const parsedKey = normalizeKey(key); + + return eventKey.toLowerCase() === parsedKey.toLowerCase(); }; export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [ @@ -335,6 +343,16 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [ { id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' }, { id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' }, { id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' }, + + // SFTP Operations + { id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' }, + { id: 'sftp-cut', action: 'sftpCut', label: 'Cut Files', mac: '⌘ + X', pc: 'Ctrl + X', category: 'sftp' }, + { id: 'sftp-paste', action: 'sftpPaste', label: 'Paste Files', mac: '⌘ + V', pc: 'Ctrl + V', category: 'sftp' }, + { id: 'sftp-select-all', action: 'sftpSelectAll', label: 'Select All Files', mac: '⌘ + A', pc: 'Ctrl + A', category: 'sftp' }, + { id: 'sftp-rename', action: 'sftpRename', label: 'Rename File', mac: 'F2', pc: 'F2', category: 'sftp' }, + { id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' }, + { id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' }, + { id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' }, ]; // Terminal appearance settings @@ -560,6 +578,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo export interface TransferTask { id: string; fileName: string; + originalFileName?: string; sourcePath: string; targetPath: string; sourceConnectionId: string; diff --git a/package-lock.json b/package-lock.json index 0d02e5005..dfeb51e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1008,7 +1008,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1655,6 +1654,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1676,6 +1676,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1692,6 +1693,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1706,6 +1708,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -5671,7 +5674,6 @@ "integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", @@ -5701,7 +5703,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -5980,8 +5981,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@yarnpkg/lockfile": { "version": "1.1.0", @@ -6013,7 +6013,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6046,7 +6045,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6454,7 +6452,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7114,7 +7111,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -7354,7 +7352,6 @@ "integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.4.0", "builder-util": "26.3.4", @@ -7680,6 +7677,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -7700,6 +7698,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -7917,7 +7916,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10200,7 +10198,6 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", - "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -10826,7 +10823,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10885,6 +10881,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -10902,6 +10899,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -11002,7 +11000,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11012,7 +11009,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11941,6 +11937,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -12004,6 +12001,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -12018,6 +12016,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12179,7 +12178,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12382,7 +12380,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -12737,7 +12734,6 @@ "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }