From d662f459f3e0f7fbfa969bd967f2ca9576f549f7 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:12:13 +0800 Subject: [PATCH 01/15] feat(clipboard): add command to read file paths from OS clipboard --- src-tauri/src/cmd/file_clipboard.rs | 130 ++++++++++++++++++++++++++++ src-tauri/src/cmd/mod.rs | 1 + src-tauri/src/lib.rs | 1 + src/lib/clipboard.ts | 4 + 4 files changed, 136 insertions(+) create mode 100644 src-tauri/src/cmd/file_clipboard.rs diff --git a/src-tauri/src/cmd/file_clipboard.rs b/src-tauri/src/cmd/file_clipboard.rs new file mode 100644 index 000000000..7c345a588 --- /dev/null +++ b/src-tauri/src/cmd/file_clipboard.rs @@ -0,0 +1,130 @@ +use std::time::Duration; + +#[cfg(not(target_os = "windows"))] +use std::path::PathBuf; + +const CLIPBOARD_TIMEOUT: Duration = Duration::from_secs(1); + +/// Read the local file paths currently held on the OS clipboard (e.g. files +/// copied/cut in the system file manager). Unlike `read_clipboard_path_payload`, +/// this returns ALL paths, not just images, so SFTP paste can upload arbitrary +/// files. Returns an empty vector when the clipboard holds no file paths. +#[tauri::command] +pub async fn read_clipboard_file_paths() -> Vec { + let result = tokio::time::timeout( + CLIPBOARD_TIMEOUT, + tokio::task::spawn_blocking(read_clipboard_file_paths_blocking), + ) + .await; + + match result { + Ok(Ok(paths)) => paths, + _ => Vec::new(), + } +} + +fn read_clipboard_file_paths_blocking() -> Vec { + #[cfg(target_os = "windows")] + { + if let Some(paths) = read_windows_clipboard_file_paths() { + return paths; + } + } + + #[cfg(not(target_os = "windows"))] + { + if let Some(paths) = read_clipboard_text_file_paths() { + return paths; + } + } + + Vec::new() +} + +#[cfg(not(target_os = "windows"))] +fn read_clipboard_text_file_paths() -> Option> { + let mut clipboard = arboard::Clipboard::new().ok()?; + let text = clipboard.get_text().ok()?; + Some( + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(parse_clipboard_path_text_line) + .filter(|path| path.exists()) + .map(|path| path.to_string_lossy().to_string()) + .collect(), + ) +} + +#[cfg(not(target_os = "windows"))] +fn parse_clipboard_path_text_line(line: &str) -> Option { + let unwrapped = line + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .or_else(|| { + line.strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + }) + .unwrap_or(line); + + if let Some(uri_path) = unwrapped.strip_prefix("file://") { + let local_uri_path = uri_path.strip_prefix("localhost/").unwrap_or(uri_path); + let decoded = urlencoding::decode(local_uri_path).ok()?; + return Some(PathBuf::from(decoded.as_ref())); + } + + let path = PathBuf::from(unwrapped); + if path.is_absolute() { Some(path) } else { None } +} + +#[cfg(target_os = "windows")] +fn read_windows_clipboard_file_paths() -> Option> { + use windows::Win32::{ + System::{ + DataExchange::{CloseClipboard, GetClipboardData, OpenClipboard}, + Ole::CF_HDROP, + }, + UI::Shell::{DragQueryFileW, HDROP}, + }; + + struct ClipboardGuard; + impl Drop for ClipboardGuard { + fn drop(&mut self) { + unsafe { + let _ = CloseClipboard(); + } + } + } + + unsafe { + OpenClipboard(None).ok()?; + let _guard = ClipboardGuard; + let handle = GetClipboardData(u32::from(CF_HDROP.0)).ok()?; + let hdrop = HDROP(handle.0); + let count = DragQueryFileW(hdrop, u32::MAX, None); + if count == 0 { + return Some(Vec::new()); + } + + let mut paths = Vec::new(); + for index in 0..count { + let char_count = DragQueryFileW(hdrop, index, None); + if char_count == 0 { + continue; + } + + let mut buffer = vec![0u16; char_count as usize + 1]; + let written = DragQueryFileW(hdrop, index, Some(&mut buffer)); + if written == 0 { + continue; + } + + let path = String::from_utf16_lossy(&buffer[..written as usize]); + if !path.trim().is_empty() { + paths.push(path); + } + } + + Some(paths) + } +} diff --git a/src-tauri/src/cmd/mod.rs b/src-tauri/src/cmd/mod.rs index e27b1b272..abd4339b7 100644 --- a/src-tauri/src/cmd/mod.rs +++ b/src-tauri/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod connection; pub mod credential; pub mod docker; pub mod external_open; +pub mod file_clipboard; pub mod gpu; pub mod importer; pub mod local_fs; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e6ae28c40..a0630f4f0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,6 +150,7 @@ pub fn run() { cmd::clipboard::write_clipboard_text, cmd::clipboard::read_clipboard_path_payload, cmd::clipboard::upload_clipboard_image_to_ssh, + cmd::file_clipboard::read_clipboard_file_paths, cmd::log::append_frontend_logs, cmd::log::export_diagnostics, cmd::note::list_note_tree, diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts index 9085277a1..a61684f0c 100644 --- a/src/lib/clipboard.ts +++ b/src/lib/clipboard.ts @@ -40,6 +40,10 @@ export async function readClipboardPathPayload(): Promise("read_clipboard_path_payload"); } +export async function readClipboardFilePaths(): Promise { + return invoke("read_clipboard_file_paths"); +} + export async function uploadClipboardImageToSsh( sessionId: string, ): Promise { From a2e4314047dc46d95420d551e7b30316d462d6f8 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:12:23 +0800 Subject: [PATCH 02/15] feat(file-explorer): add SFTP clipboard with last-copy-wins detection --- src/lib/sftpClipboard.ts | 160 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 src/lib/sftpClipboard.ts diff --git a/src/lib/sftpClipboard.ts b/src/lib/sftpClipboard.ts new file mode 100644 index 000000000..613cd5ef8 --- /dev/null +++ b/src/lib/sftpClipboard.ts @@ -0,0 +1,160 @@ +/** + * In-memory SFTP clipboard shared across file browser panes and windows. + * + * Copy/cut stores a lightweight reference (session + path list) without + * touching the OS clipboard, so pasting behaves like `cp` (copy) or `mv` + * (cut) on the same remote endpoint. + */ + +import { readClipboardFilePaths } from "@/lib/clipboard"; + +export type SftpClipboardMode = "copy" | "cut"; + +export interface SftpClipboardEntry { + name: string; + path: string; + isDirectory: boolean; +} + +export interface SftpClipboardState { + sessionId: string; + mode: SftpClipboardMode; + entries: SftpClipboardEntry[]; +} + +let currentState: SftpClipboardState | null = null; +const listeners = new Set<() => void>(); +/** When the current SFTP clipboard entry was last copied/cut (for "last copy wins"). */ +let sftpClipboardSetAt = 0; + +export function getSftpClipboard(): SftpClipboardState | null { + return currentState; +} + +export function setSftpClipboard(state: SftpClipboardState | null): void { + currentState = state; + sftpClipboardSetAt = Date.now(); + for (const listener of listeners) { + listener(); + } +} + +export function clearSftpClipboard(): void { + setSftpClipboard(null); +} + +export function subscribeSftpClipboard(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Tracks whether the OS clipboard currently holds file paths (e.g. files + * copied/cut in the system file manager), so paste actions can be disabled + * when there is nothing to paste. + * + * Polling only runs while at least one subscriber is active and the document + * has focus, to avoid contending with the system clipboard while the user is + * copying in another application. + */ + +export interface OsClipboardObservation { + hasFiles: boolean; + paths: string[]; + isNewerThanSftp: boolean; +} + +const OS_CLIPBOARD_POLL_INTERVAL_MS = 1500; + +let osClipboardHasFiles = false; +const osClipboardListeners = new Set<() => void>(); +let osClipboardPollTimer: ReturnType | null = null; +let osClipboardRefreshInFlight = false; +/** Key of the last observed OS clipboard file paths, used to detect new copies. */ +let osPathsKey = ""; +/** When the current OS clipboard file paths were first observed. */ +let osPathsObservedAt = 0; + +export function getOsClipboardHasFiles(): boolean { + return osClipboardHasFiles; +} + +export function subscribeOsClipboard(listener: () => void): () => void { + osClipboardListeners.add(listener); + if (osClipboardListeners.size === 1) { + void refreshOsClipboardHasFiles(); + osClipboardPollTimer = setInterval(() => { + if (document.hasFocus()) { + void refreshOsClipboardHasFiles(); + } + }, OS_CLIPBOARD_POLL_INTERVAL_MS); + window.addEventListener("focus", handleOsClipboardWindowFocus); + } + return () => { + osClipboardListeners.delete(listener); + if (osClipboardListeners.size === 0) { + if (osClipboardPollTimer) { + clearInterval(osClipboardPollTimer); + osClipboardPollTimer = null; + } + window.removeEventListener("focus", handleOsClipboardWindowFocus); + } + }; +} + +function handleOsClipboardWindowFocus(): void { + void refreshOsClipboardHasFiles(); +} + +/** + * Read the OS clipboard and record the observation so paste can decide which + * clipboard is newer. Safe to call from the paste handler directly (always + * performs a fresh read). + */ +export async function observeOsClipboard(): Promise { + let paths: string[] = []; + try { + paths = await readClipboardFilePaths(); + } catch { + /* transient clipboard read failure: treat as empty */ + return { hasFiles: false, paths: [], isNewerThanSftp: false }; + } + + const key = buildOsPathsKey(paths); + if (key !== osPathsKey) { + osPathsKey = key; + osPathsObservedAt = Date.now(); + } + const hasFiles = paths.length > 0; + if (hasFiles !== osClipboardHasFiles) { + osClipboardHasFiles = hasFiles; + for (const listener of osClipboardListeners) { + listener(); + } + } + return { + hasFiles, + paths, + isNewerThanSftp: hasFiles && osPathsObservedAt > sftpClipboardSetAt, + }; +} + +function buildOsPathsKey(paths: string[]): string { + return paths + .map((path) => path.replace(/[\\/]+$/, "")) + .filter(Boolean) + .sort() + .join("\n"); +} + +async function refreshOsClipboardHasFiles(): Promise { + if (osClipboardRefreshInFlight) return; + osClipboardRefreshInFlight = true; + try { + await observeOsClipboard(); + } finally { + osClipboardRefreshInFlight = false; + } +} From 1238abd15ee9990c4069e9f89f7f604429e0b3f4 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:12:34 +0800 Subject: [PATCH 03/15] feat(file-explorer): register copy, cut and paste shortcuts --- src/lib/shortcutRegistry.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/lib/shortcutRegistry.ts b/src/lib/shortcutRegistry.ts index 387c3803f..a330b705a 100644 --- a/src/lib/shortcutRegistry.ts +++ b/src/lib/shortcutRegistry.ts @@ -210,6 +210,27 @@ export const SHORTCUT_REGISTRY: ShortcutDefinition[] = [ }, // --- File Explorer --- + { + id: "fileExplorer.copy", + category: "fileExplorer", + labelKey: "settings.shortcutLabels.copyFiles", + defaultKeys: "ctrl+c, meta+c", + contextual: true, + }, + { + id: "fileExplorer.cut", + category: "fileExplorer", + labelKey: "settings.shortcutLabels.cutFiles", + defaultKeys: "ctrl+x, meta+x", + contextual: true, + }, + { + id: "fileExplorer.paste", + category: "fileExplorer", + labelKey: "settings.shortcutLabels.pasteFiles", + defaultKeys: "ctrl+v, meta+v", + contextual: true, + }, { id: "fileExplorer.rename", category: "fileExplorer", From 671a929bfc247396805f63067905bed867c156e2 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:12:50 +0800 Subject: [PATCH 04/15] feat(file-explorer): add paste confirmation dialog --- .../file-explorer/PasteConfirmDialog.tsx | 123 ++++++++++++++++++ .../file-explorer/FileExplorerDialogs.tsx | 3 + src/lib/pasteConfirmPrompt.ts | 48 +++++++ 3 files changed, 174 insertions(+) create mode 100644 src/components/dialog/file-explorer/PasteConfirmDialog.tsx create mode 100644 src/lib/pasteConfirmPrompt.ts diff --git a/src/components/dialog/file-explorer/PasteConfirmDialog.tsx b/src/components/dialog/file-explorer/PasteConfirmDialog.tsx new file mode 100644 index 000000000..8c435e490 --- /dev/null +++ b/src/components/dialog/file-explorer/PasteConfirmDialog.tsx @@ -0,0 +1,123 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { + type PasteConfirmRequest, + resolvePasteConfirm, + subscribePasteConfirm, +} from "@/lib/pasteConfirmPrompt"; + +export function PasteConfirmDialog() { + const { t } = useTranslation(); + const [request, setRequest] = useState(null); + + useEffect(() => subscribePasteConfirm(setRequest), []); + + const handleConfirm = () => { + resolvePasteConfirm(true); + }; + + const handleCancel = () => { + resolvePasteConfirm(false); + }; + + const titleKey = + request?.action === "upload" + ? "fileExplorer.pasteConfirmUploadTitle" + : request?.action === "copy" + ? "fileExplorer.pasteConfirmCopyTitle" + : "fileExplorer.pasteConfirmMoveTitle"; + const descriptionKey = + request?.action === "upload" + ? "fileExplorer.pasteConfirmUploadDesc" + : request?.action === "copy" + ? "fileExplorer.pasteConfirmCopyDesc" + : "fileExplorer.pasteConfirmMoveDesc"; + const actionKey = + request?.action === "upload" + ? "fileExplorer.pasteConfirmUploadAction" + : request?.action === "copy" + ? "fileExplorer.pasteConfirmCopyAction" + : "fileExplorer.pasteConfirmMoveAction"; + + return ( + { + if (!open && request) { + handleCancel(); + } + }} + > + { + if (event.key === "Enter" && request) { + event.preventDefault(); + handleConfirm(); + } + }} + > + + {t(titleKey)} + + {t(descriptionKey, { count: request?.count ?? 0 })} + + + + {request?.fileNames && request.fileNames.length > 0 && ( +
+ {request.fileNames.map((name) => ( +
+ {name} +
+ ))} +
+ )} + + {request?.targetDir && ( +
+ {request.targetDir} +
+ )} + + + { + event.preventDefault(); + handleCancel(); + }} + > + {t("common.cancel")} + + { + event.preventDefault(); + handleConfirm(); + }} + > + {t(actionKey)} + + +
+
+ ); +} diff --git a/src/components/panel/file-explorer/FileExplorerDialogs.tsx b/src/components/panel/file-explorer/FileExplorerDialogs.tsx index 0a6d550a9..227733471 100644 --- a/src/components/panel/file-explorer/FileExplorerDialogs.tsx +++ b/src/components/panel/file-explorer/FileExplorerDialogs.tsx @@ -8,6 +8,7 @@ import NewItemDialog, { import NewSymlinkDialog, { type NewSymlinkDialogData, } from "@/components/dialog/file-explorer/NewSymlinkDialog"; +import { PasteConfirmDialog } from "@/components/dialog/file-explorer/PasteConfirmDialog"; import PropertiesDialog, { type PropertiesDialogData, } from "@/components/dialog/file-explorer/PropertiesDialog"; @@ -120,6 +121,8 @@ export function FileExplorerDialogs({ onOpenInternal={onOpenUnknownFileInternal} /> )} + + ); } diff --git a/src/lib/pasteConfirmPrompt.ts b/src/lib/pasteConfirmPrompt.ts new file mode 100644 index 000000000..658f53fd4 --- /dev/null +++ b/src/lib/pasteConfirmPrompt.ts @@ -0,0 +1,48 @@ +export type PasteConfirmAction = "upload" | "copy" | "move"; + +export interface PasteConfirmRequest { + action: PasteConfirmAction; + count: number; + targetDir: string; + fileNames: string[]; +} + +type PasteConfirmListener = (request: PasteConfirmRequest | null) => void; + +let activeRequest: PasteConfirmRequest | null = null; +let localResolver: ((confirmed: boolean) => void) | null = null; +const listeners = new Set(); + +function notifyListeners() { + for (const listener of listeners) { + listener(activeRequest); + } +} + +export function subscribePasteConfirm(listener: PasteConfirmListener): () => void { + listeners.add(listener); + listener(activeRequest); + return () => { + listeners.delete(listener); + }; +} + +export function showPasteConfirm(request: PasteConfirmRequest): Promise { + if (localResolver) { + localResolver(false); + } + + return new Promise((resolve) => { + localResolver = resolve; + activeRequest = request; + notifyListeners(); + }); +} + +export function resolvePasteConfirm(confirmed: boolean): void { + const resolver = localResolver; + localResolver = null; + activeRequest = null; + notifyListeners(); + resolver?.(confirmed); +} From 9f8060c5c2a98384edac6465917488c69475143e Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:13:00 +0800 Subject: [PATCH 05/15] feat(file-explorer): resolve duplicates when moving pasted files --- src/lib/transferDuplicateResolution.ts | 155 +++++++++++++++++++++---- 1 file changed, 130 insertions(+), 25 deletions(-) diff --git a/src/lib/transferDuplicateResolution.ts b/src/lib/transferDuplicateResolution.ts index 4024ca8b5..5e3af837b 100644 --- a/src/lib/transferDuplicateResolution.ts +++ b/src/lib/transferDuplicateResolution.ts @@ -1,44 +1,36 @@ -import { getRemoteParentDirectory } from "@/components/panel/file-explorer/model"; +import { getRemoteParentDirectory, joinExplorerPath } from "@/components/panel/file-explorer/model"; import type { EnqueueUploadRequest } from "@/context/TransferContext"; import { invoke } from "@/lib/invoke"; import { showTransferDuplicatePrompt, type TransferDuplicatePromptChoice, } from "@/lib/transferDuplicatePrompt"; -import type { FileEntry, FileProperties } from "@/types/global"; +import type { FileEntry } from "@/types/global"; async function remotePathExists( sessionId: string, path: string, ): Promise<{ exists: boolean; isDirectory: boolean }> { + const parentDir = getRemoteParentDirectory(path); + const fileName = path.split("/").filter(Boolean).pop() ?? ""; + if (!fileName) { + return { exists: false, isDirectory: false }; + } + try { - const props = await invoke("get_file_properties", { + const entries = await invoke("list_remote_dir", { sessionId, - path, + path: parentDir, }); - return { exists: true, isDirectory: props.is_dir }; - } catch { - const parentDir = getRemoteParentDirectory(path); - const fileName = path.split("/").filter(Boolean).pop() ?? ""; - if (!fileName) { - return { exists: false, isDirectory: false }; + const entry = entries.find((item) => item.name === fileName); + if (entry) { + return { exists: true, isDirectory: entry.is_dir }; } - - try { - const entries = await invoke("list_remote_dir", { - sessionId, - path: parentDir, - }); - const entry = entries.find((item) => item.name === fileName); - if (entry) { - return { exists: true, isDirectory: entry.is_dir }; - } - } catch { - // Fall through to "does not exist". - } - - return { exists: false, isDirectory: false }; + } catch { + // Fall through to "does not exist". } + + return { exists: false, isDirectory: false }; } async function resolveDuplicateChoice(params: { @@ -147,3 +139,116 @@ export async function filterEnqueueUploadRequests( return filtered; } + +export interface ResolvedRemoteMove { + oldPath: string; + newPath: string; +} + +export interface RemoteMoveTargetParams { + sessionId: string; + targetDir: string; + entries: Array<{ name: string; path: string; isDirectory: boolean }>; + duplicateStrategy: string; +} + +/** + * Resolve a set of cut→paste moves against the target directory, applying the + * configured duplicate strategy (skip / overwrite / rename / ask). Returns the + * moves that should actually be performed. + */ +export async function resolveRemoteMoveTargets( + params: RemoteMoveTargetParams, +): Promise { + const { sessionId, targetDir, entries, duplicateStrategy } = params; + if (entries.length === 0) { + return []; + } + + if (duplicateStrategy !== "ask" && duplicateStrategy !== "skip") { + const moves: ResolvedRemoteMove[] = entries.map((entry) => ({ + oldPath: entry.path, + newPath: joinExplorerPath(targetDir, entry.name, "remote"), + })); + if (duplicateStrategy === "rename") { + const existingNames = await listRemoteDirNames(sessionId, targetDir); + const used = new Set(existingNames); + for (const move of moves) { + if (used.has(move.newPath.split("/").filter(Boolean).pop() ?? "")) { + const uniqueName = nextAvailableRemoteName( + move.newPath.split("/").filter(Boolean).pop() ?? "", + used, + ); + used.add(uniqueName); + move.newPath = joinExplorerPath(targetDir, uniqueName, "remote"); + } + } + } + return moves; + } + + const moves: ResolvedRemoteMove[] = []; + const allowApplyToTask = duplicateStrategy === "ask" && entries.length > 1; + let overwriteRemainingForTask = false; + + for (const entry of entries) { + if (overwriteRemainingForTask) { + moves.push({ + oldPath: entry.path, + newPath: joinExplorerPath(targetDir, entry.name, "remote"), + }); + continue; + } + + const targetPath = joinExplorerPath(targetDir, entry.name, "remote"); + const { exists, isDirectory } = await remotePathExists(sessionId, targetPath); + if (!exists) { + moves.push({ oldPath: entry.path, newPath: targetPath }); + continue; + } + + const choice = await resolveDuplicateChoice({ + sessionId, + remotePath: targetPath, + fileName: entry.name, + isDirectory: exists ? isDirectory : entry.isDirectory, + duplicateStrategy, + allowApplyToTask, + }); + + if (choice === "skip") { + continue; + } + if (choice === "overwriteAllForTask") { + overwriteRemainingForTask = true; + } + moves.push({ oldPath: entry.path, newPath: targetPath }); + } + + return moves; +} + +async function listRemoteDirNames(sessionId: string, dirPath: string): Promise> { + try { + const entries = await invoke("list_remote_dir", { + sessionId, + path: dirPath, + }); + return new Set(entries.map((entry) => entry.name)); + } catch { + return new Set(); + } +} + +function nextAvailableRemoteName(baseName: string, used: Set): string { + const dot = baseName.lastIndexOf("."); + const stem = dot > 0 ? baseName.slice(0, dot) : baseName; + const ext = dot > 0 ? baseName.slice(dot) : ""; + for (let i = 1; i <= 999; i++) { + const candidate = `${stem} (${i})${ext}`; + if (!used.has(candidate)) { + return candidate; + } + } + return `${stem} (${Date.now()})${ext}`; +} From 837c105bc2898d6ea0d8766ca302068280337612 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:13:16 +0800 Subject: [PATCH 06/15] feat(file-explorer): implement paste handler with confirmation and duplicate checks --- .../panel/file-explorer/FileExplorer.tsx | 297 +++++++++++++++++- .../panel/file-explorer/FileListItem.tsx | 29 ++ 2 files changed, 325 insertions(+), 1 deletion(-) diff --git a/src/components/panel/file-explorer/FileExplorer.tsx b/src/components/panel/file-explorer/FileExplorer.tsx index 949f5d45a..c3bc8d846 100644 --- a/src/components/panel/file-explorer/FileExplorer.tsx +++ b/src/components/panel/file-explorer/FileExplorer.tsx @@ -24,6 +24,7 @@ import { MdArrowDropUp, MdClose, MdContentCopy, + MdContentPaste, MdCreateNewFolder, MdDriveFolderUpload, MdFolderOff, @@ -71,9 +72,23 @@ import { openAIAssistant } from "@/lib/aiEvents"; import { getErrorMessage } from "@/lib/errors"; import { invoke } from "@/lib/invoke"; import { logger } from "@/lib/logger"; +import { showPasteConfirm } from "@/lib/pasteConfirmPrompt"; import { sendSessionInput, sendSessionInputWithSync } from "@/lib/sessionInput"; +import { + clearSftpClipboard, + getOsClipboardHasFiles, + getSftpClipboard, + observeOsClipboard, + type SftpClipboardEntry, + type SftpClipboardMode, + type SftpClipboardState, + setSftpClipboard, + subscribeOsClipboard, + subscribeSftpClipboard, +} from "@/lib/sftpClipboard"; import { matchesKeyEvent } from "@/lib/shortcutRegistry"; import { getSessionInputPeerIds } from "@/lib/syncInputGroups"; +import { resolveRemoteMoveTargets } from "@/lib/transferDuplicateResolution"; import { cn, formatSize } from "@/lib/utils"; import type { FileWindowTarget } from "@/lib/windowManager"; import { openAutoUpload, openFilePreview, openRemoteFileEditor } from "@/lib/windowManager"; @@ -621,7 +636,7 @@ function FileExplorerPane({ }: FileExplorerPaneProps) { const { t } = useTranslation(); const { appSettings, updateUi, savedConnections, tabs, syncGroups, broadcastToAll } = useApp(); - const { enqueueDownloads, enqueueUploads } = useTransfer(); + const { enqueueDownloads, enqueueUploads, enqueueCopies, transfers } = useTransfer(); const hasSshSession = !!activeSessionId && activeSessionType === "SSH"; const hasLocalSession = !!activeSessionId && activeSessionType === "Local"; const explorerBackend: FileExplorerBackendKind = hasLocalSession ? "local" : "remote"; @@ -698,6 +713,7 @@ function FileExplorerPane({ const [listScrollTop, setListScrollTop] = useState(0); const [listViewportHeight, setListViewportHeight] = useState(0); const refreshUploadCompletionTimerRef = useRef | null>(null); + const pendingPasteCopyRefreshRef = useRef>([]); filesRef.current = files; activeSessionIdRef.current = activeSessionId; @@ -1378,6 +1394,35 @@ function FileExplorerPane({ }; }, [refreshCurrentDirectory]); + useEffect(() => { + const pending = pendingPasteCopyRefreshRef.current; + if (pending.length === 0) { + return; + } + + const statusById = new Map(transfers.map((transfer) => [transfer.id, transfer.status])); + const terminalStates = new Set(["completed", "error", "cancelled"]); + const stillPending: Array<{ ids: string[]; targetDir: string }> = []; + let changed = false; + + for (const item of pending) { + const allTerminal = item.ids.every( + (id) => terminalStates.has(statusById.get(id) ?? "") || !statusById.has(id), + ); + if (!allTerminal) { + stillPending.push(item); + continue; + } + changed = true; + clearDirectoryChildrenCacheForPath(activeSessionId, "remote", item.targetDir); + void refreshCurrentDirectory(); + } + + if (changed) { + pendingPasteCopyRefreshRef.current = stillPending; + } + }, [activeSessionId, refreshCurrentDirectory, transfers]); + const visibleFiles = useMemo( () => (showHiddenFiles ? files : files.filter((entry) => !entry.name.startsWith("."))), [files, showHiddenFiles], @@ -1780,6 +1825,204 @@ function FileExplorerPane({ })); }, [updateUi]); + const isRemoteFileBrowser = explorerBackend === "remote"; + const [activeSftpClipboard, setActiveSftpClipboard] = useState(() => + getSftpClipboard(), + ); + useEffect(() => subscribeSftpClipboard(() => setActiveSftpClipboard(getSftpClipboard())), []); + const [osClipboardHasFiles, setOsClipboardHasFiles] = useState(() => getOsClipboardHasFiles()); + useEffect(() => { + if (!isRemoteFileBrowser) { + setOsClipboardHasFiles(false); + return; + } + return subscribeOsClipboard(() => setOsClipboardHasFiles(getOsClipboardHasFiles())); + }, [isRemoteFileBrowser]); + const canPaste = + isRemoteFileBrowser && ((activeSftpClipboard?.entries.length ?? 0) > 0 || osClipboardHasFiles); + + const handleCopyEntries = useCallback( + (entries: FileEntry[], mode: SftpClipboardMode) => { + if (!activeSessionId || entries.length === 0 || isParentDirectoryEntry(entries[0])) return; + const clipboardEntries: SftpClipboardEntry[] = entries.map((entry) => ({ + name: entry.name, + path: joinExplorerPath(currentPathRef.current, entry.name, "remote"), + isDirectory: entry.is_dir, + })); + setSftpClipboard({ + sessionId: activeSessionId, + mode, + entries: clipboardEntries, + }); + }, + [activeSessionId], + ); + + const handleCopyFromContextMenu = useCallback( + (entry: FileEntry) => { + const entries = isParentDirectoryEntry(entry) + ? [] + : selectedFiles.size > 1 && selectedFiles.has(entry.name) + ? filteredSortedFiles.filter((file) => selectedFiles.has(file.name)) + : [entry]; + handleCopyEntries(entries, "copy"); + }, + [filteredSortedFiles, handleCopyEntries, selectedFiles], + ); + + const handleCutFromContextMenu = useCallback( + (entry: FileEntry) => { + const entries = isParentDirectoryEntry(entry) + ? [] + : selectedFiles.size > 1 && selectedFiles.has(entry.name) + ? filteredSortedFiles.filter((file) => selectedFiles.has(file.name)) + : [entry]; + handleCopyEntries(entries, "cut"); + }, + [filteredSortedFiles, handleCopyEntries, selectedFiles], + ); + + const handleCopySelected = useCallback(() => { + handleCopyEntries(selectedRealFiles, "copy"); + }, [handleCopyEntries, selectedRealFiles]); + + const handleCutSelected = useCallback(() => { + handleCopyEntries(selectedRealFiles, "cut"); + }, [handleCopyEntries, selectedRealFiles]); + + const handlePaste = useCallback(async () => { + if (!activeSessionId || !canBrowseFiles) return; + const backend = explorerBackendRef.current; + const targetDir = normalizeDirectoryPath(currentPathRef.current) || homeDirRef.current || "/"; + if (backend !== "remote") { + toast.error(t("fileExplorer.pasteRemoteOnly")); + return; + } + + // 1. SFTP in-memory clipboard (cp / mv semantics), unless the OS clipboard + // holds file paths copied more recently, in which case it wins. + const sftpClipboard = getSftpClipboard(); + const osObservation = await observeOsClipboard(); + const isSftpPaste = + sftpClipboard && sftpClipboard.entries.length > 0 && !osObservation.isNewerThanSftp; + + if (isSftpPaste) { + const { sessionId: sourceSessionId, mode, entries } = sftpClipboard; + if (sourceSessionId === activeSessionId) { + const sourceParent = getExplorerParentDirectory(entries[0].path, "remote"); + if (sourceParent && sourceParent === normalizeExplorerPath(targetDir, "remote")) { + toast.info(t("fileExplorer.pasteSameDirectory")); + return; + } + } + if (mode === "cut" && sourceSessionId !== activeSessionId) { + toast.error(t("fileExplorer.pasteCrossSessionMoveUnsupported")); + return; + } + } else if (osObservation.hasFiles) { + const confirmedUpload = await showPasteConfirm({ + action: "upload", + count: osObservation.paths.length, + targetDir, + fileNames: osObservation.paths.map((path) => getLocalPathName(path, path)), + }); + if (!confirmedUpload) { + return; + } + } + + if (isSftpPaste) { + const { sessionId: sourceSessionId, mode, entries } = sftpClipboard; + const confirmed = await showPasteConfirm({ + action: mode === "cut" ? "move" : "copy", + count: entries.length, + targetDir, + fileNames: entries.map((entry) => entry.name), + }); + if (!confirmed) { + return; + } + + if (mode === "cut") { + try { + const moves = await resolveRemoteMoveTargets({ + sessionId: sourceSessionId, + targetDir, + entries, + duplicateStrategy: appSettings.transfer.duplicate_strategy, + }); + for (const move of moves) { + await invoke("rename_remote_file", { + sessionId: sourceSessionId, + oldPath: move.oldPath, + newPath: move.newPath, + }); + } + } catch (error) { + toast.error(getErrorMessage(error) || String(error)); + } finally { + clearSftpClipboard(); + invalidateDirectoryChildrenCache(targetDir); + await loadDirectory(targetDir, { history: "preserve" }); + void refreshCurrentDirectory(); + } + return; + } + + const copyIds = enqueueCopies( + entries.map((entry) => ({ + fileName: entry.name, + kind: entry.isDirectory ? "directory" : "file", + source: { + sessionId: sourceSessionId, + kind: "remote", + path: entry.path, + }, + target: { + sessionId: activeSessionId, + kind: "remote", + path: targetDir, + }, + })), + ); + if (copyIds.length > 0) { + pendingPasteCopyRefreshRef.current.push({ ids: copyIds, targetDir }); + } + return; + } + + // 2. OS clipboard file paths → upload to this directory. + if (osObservation.hasFiles) { + try { + const resolved = await resolveLocalDropPaths(osObservation.paths); + if (resolved.length === 0) { + toast.error(t("fileExplorer.externalDropPathsRequired")); + return; + } + uploadLocalEntriesToTarget( + { sessionId: activeSessionId, remoteDir: targetDir }, + resolved.map((entry) => ({ path: entry.path, isDir: entry.isDir })), + ); + } catch (error) { + toast.error(getErrorMessage(error) || String(error)); + } + return; + } + + toast.info(t("fileExplorer.pasteClipboardEmpty")); + }, [ + activeSessionId, + appSettings.transfer.duplicate_strategy, + canBrowseFiles, + enqueueCopies, + invalidateDirectoryChildrenCache, + loadDirectory, + refreshCurrentDirectory, + resolveLocalDropPaths, + t, + uploadLocalEntriesToTarget, + ]); + const handleListKeyDown = (event: ReactKeyboardEvent) => { const target = event.target; if ( @@ -1855,6 +2098,44 @@ function FileExplorerPane({ return; } + if (isRemoteFileBrowser && selectedRealFiles.length > 0) { + if ( + matchesKeyEvent( + resolveShortcutKeys("fileExplorer.copy", appSettings.keybindings), + event.nativeEvent, + ) + ) { + event.preventDefault(); + event.stopPropagation(); + handleCopySelected(); + return; + } + if ( + matchesKeyEvent( + resolveShortcutKeys("fileExplorer.cut", appSettings.keybindings), + event.nativeEvent, + ) + ) { + event.preventDefault(); + event.stopPropagation(); + handleCutSelected(); + return; + } + } + + if ( + isRemoteFileBrowser && + matchesKeyEvent( + resolveShortcutKeys("fileExplorer.paste", appSettings.keybindings), + event.nativeEvent, + ) + ) { + event.preventDefault(); + event.stopPropagation(); + void handlePaste(); + return; + } + if ( event.key !== "Delete" || event.altKey || @@ -2843,6 +3124,11 @@ function FileExplorerPane({ onUpload={handleUploadFiles} onUploadFolder={handleUploadFolder} onDownload={handleDownloadFromContextMenu} + onCopy={handleCopyFromContextMenu} + onCut={handleCutFromContextMenu} + onPaste={() => void handlePaste()} + showSftpClipboardActions={isRemoteFileBrowser} + pasteDisabled={!canPaste} showPeerSendAction={!!peerEndpoint && !!onSendEntries} onSendToPeer={handleSendToPeer} sendTargetOptions={sendTargetOptions} @@ -2941,6 +3227,15 @@ function FileExplorerPane({ {t("fileExplorer.newSymlink")} )} + {isRemoteFileBrowser && ( + <> + + void handlePaste()}> + + {t("fileExplorer.cmPaste")} + + + )} diff --git a/src/components/panel/file-explorer/FileListItem.tsx b/src/components/panel/file-explorer/FileListItem.tsx index e6a78c367..39055a041 100644 --- a/src/components/panel/file-explorer/FileListItem.tsx +++ b/src/components/panel/file-explorer/FileListItem.tsx @@ -4,6 +4,8 @@ import { MdAutoAwesome, MdBookmarkAdd, MdContentCopy, + MdContentCut, + MdContentPaste, MdCopyAll, MdDelete, MdDownload, @@ -59,6 +61,11 @@ interface FileListItemProps { onUpload: () => void; onUploadFolder: () => void; onDownload: (entry: FileEntry) => void; + onCopy: (entry: FileEntry) => void; + onCut: (entry: FileEntry) => void; + onPaste: () => void; + showSftpClipboardActions: boolean; + pasteDisabled: boolean; showPeerSendAction?: boolean; onSendToPeer?: (entry: FileEntry) => void; sendTargetOptions?: Array<{ @@ -119,6 +126,11 @@ export function FileListItem({ onUpload, onUploadFolder, onDownload, + onCopy, + onCut, + onPaste, + showSftpClipboardActions, + pasteDisabled, showPeerSendAction = false, onSendToPeer, sendTargetOptions = [], @@ -452,6 +464,23 @@ export function FileListItem({ )} + {showSftpClipboardActions && ( + <> + onCopy(entry)}> + + {t("fileExplorer.cmCopy")} + + onCut(entry)}> + + {t("fileExplorer.cmCut")} + + + + {t("fileExplorer.cmPaste")} + + + + )} {sendTargetOptions.length > 0 && onSendToTarget && ( <> From 719599ffd65de733351e2a14bb1021947fa670cc Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:13:32 +0800 Subject: [PATCH 07/15] chore(i18n): add file explorer copy, cut and paste localization strings --- src/i18n/locales/en.json | 19 +++++++++++++++++++ src/i18n/locales/ko.json | 19 +++++++++++++++++++ src/i18n/locales/zh-CN.json | 19 +++++++++++++++++++ src/i18n/locales/zh-TW.json | 19 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 646f7d331..194e2d856 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -782,9 +782,11 @@ "breadcrumbOverflow": "Hidden path segments", "childDirectoriesFailed": "Unable to read directory", "clearSearch": "Clear search", + "cmCopy": "Copy", "cmCopyDirPath": "Copy Dir Path", "cmCopyName": "Copy Name", "cmCopyPath": "Copy Path", + "cmCut": "Cut", "cmDelete": "Delete", "cmDownload": "Download...", "cmMove": "Move to...", @@ -792,6 +794,7 @@ "cmOpenDefault": "Open Default", "cmOpenExternalEditor": "Open with External Editor", "cmOpenInternalEditor": "Open with Built-in Editor", + "cmPaste": "Paste", "cmProperties": "Properties...", "cmRefresh": "Refresh", "cmRename": "Rename...", @@ -855,6 +858,19 @@ "owner": "Owner", "ownerGroupRequired": "Owner and group cannot be empty.", "ownership": "Ownership", + "pasteClipboardEmpty": "Clipboard does not contain any file paths", + "pasteConfirmCopyAction": "Copy", + "pasteConfirmCopyDesc": "Copy {{count}} item(s) to this directory?", + "pasteConfirmCopyTitle": "Copy files", + "pasteConfirmMoveAction": "Move", + "pasteConfirmMoveDesc": "Move {{count}} item(s) to this directory?", + "pasteConfirmMoveTitle": "Move files", + "pasteConfirmUploadAction": "Upload", + "pasteConfirmUploadDesc": "Upload {{count}} item(s) to this directory?", + "pasteConfirmUploadTitle": "Upload files", + "pasteCrossSessionMoveUnsupported": "Moving files across sessions is not supported", + "pasteRemoteOnly": "Paste is only supported in the remote file browser", + "pasteSameDirectory": "Cannot paste into the same directory", "permGroup": "Group", "permOther": "Other", "permSticky": "Sticky", @@ -2110,7 +2126,9 @@ }, "shortcutLabels": { "closeTab": "Close Active Tab", + "copyFiles": "Copy Files", "copySelectedSavedConnections": "Copy Selected Saved Connections", + "cutFiles": "Cut Files", "duplicateSession": "Duplicate Session", "duplicateSessionWithCommand": "Duplicate Session and Run Command", "lockScreen": "Lock Screen", @@ -2122,6 +2140,7 @@ "nextTab": "Next Tab", "openChat": "Open Chat", "openSettings": "Open Settings", + "pasteFiles": "Paste Files", "prevTab": "Previous Tab", "quickSwitch": "Open Command Palette", "renameFile": "Rename File/Folder", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 15aebe44f..0bf616bc4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -778,9 +778,11 @@ "breadcrumbOverflow": "숨겨진 경로 구간", "childDirectoriesFailed": "디렉터리를 읽을 수 없습니다", "clearSearch": "검색 지우기", + "cmCopy": "복사", "cmCopyDirPath": "디렉터리 경로 복사", "cmCopyName": "이름 복사", "cmCopyPath": "경로 복사", + "cmCut": "잘라내기", "cmDelete": "삭제", "cmDownload": "다운로드...", "cmMove": "이동...", @@ -788,6 +790,7 @@ "cmOpenDefault": "기본값으로 열기", "cmOpenExternalEditor": "외부 편집기로 열기", "cmOpenInternalEditor": "내장 편집기로 열기", + "cmPaste": "붙여넣기", "cmProperties": "속성...", "cmRefresh": "새로고침", "cmRename": "이름 바꾸기...", @@ -851,6 +854,19 @@ "owner": "소유자", "ownerGroupRequired": "소유자와 그룹은 비워둘 수 없습니다.", "ownership": "소유권", + "pasteClipboardEmpty": "클립보드에 파일 경로가 없습니다", + "pasteConfirmCopyAction": "복사", + "pasteConfirmCopyDesc": "{{count}}개 항목을 이 디렉터리에 복사하시겠습니까?", + "pasteConfirmCopyTitle": "파일 복사", + "pasteConfirmMoveAction": "이동", + "pasteConfirmMoveDesc": "{{count}}개 항목을 이 디렉터리로 이동하시겠습니까?", + "pasteConfirmMoveTitle": "파일 이동", + "pasteConfirmUploadAction": "업로드", + "pasteConfirmUploadDesc": "{{count}}개 항목을 이 디렉터리에 업로드하시겠습니까?", + "pasteConfirmUploadTitle": "파일 업로드", + "pasteCrossSessionMoveUnsupported": "세션 간 파일 이동은 지원되지 않습니다", + "pasteRemoteOnly": "붙여넣기는 원격 파일 브라우저에서만 지원됩니다", + "pasteSameDirectory": "같은 디렉터리에 붙여넣을 수 없습니다", "permGroup": "그룹", "permOther": "기타", "permSticky": "스티키", @@ -2106,7 +2122,9 @@ }, "shortcutLabels": { "closeTab": "활성 탭 닫기", + "copyFiles": "파일 복사", "copySelectedSavedConnections": "선택한 저장된 연결 복사", + "cutFiles": "파일 잘라내기", "duplicateSession": "세션 복제", "duplicateSessionWithCommand": "세션 복제 및 명령 실행", "lockScreen": "화면 잠금", @@ -2118,6 +2136,7 @@ "nextTab": "다음 탭", "openChat": "채팅 열기", "openSettings": "설정 열기", + "pasteFiles": "파일 붙여넣기", "prevTab": "이전 탭", "quickSwitch": "명령 팔레트 열기", "renameFile": "파일/폴더 이름 바꾸기", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index efc9a857a..07c4f7e06 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -781,9 +781,11 @@ "breadcrumbOverflow": "隐藏的路径节点", "childDirectoriesFailed": "无法读取目录", "clearSearch": "清除搜索", + "cmCopy": "复制", "cmCopyDirPath": "复制目录路径", "cmCopyName": "复制名称", "cmCopyPath": "复制路径", + "cmCut": "剪切", "cmDelete": "删除", "cmDownload": "下载...", "cmMove": "移动到...", @@ -791,6 +793,7 @@ "cmOpenDefault": "使用默认编辑器打开", "cmOpenExternalEditor": "使用外部编辑器打开", "cmOpenInternalEditor": "使用内置编辑器打开", + "cmPaste": "粘贴", "cmProperties": "属性...", "cmRefresh": "刷新", "cmRename": "重命名...", @@ -854,6 +857,19 @@ "owner": "所有者", "ownerGroupRequired": "所有者和用户组不能为空。", "ownership": "所有权", + "pasteClipboardEmpty": "剪贴板中没有文件路径", + "pasteConfirmCopyAction": "复制", + "pasteConfirmCopyDesc": "将 {{count}} 项复制到此目录?", + "pasteConfirmCopyTitle": "复制文件", + "pasteConfirmMoveAction": "移动", + "pasteConfirmMoveDesc": "将 {{count}} 项移动到此目录?", + "pasteConfirmMoveTitle": "移动文件", + "pasteConfirmUploadAction": "上传", + "pasteConfirmUploadDesc": "将 {{count}} 项上传到此目录?", + "pasteConfirmUploadTitle": "上传文件", + "pasteCrossSessionMoveUnsupported": "不支持跨会话移动文件", + "pasteRemoteOnly": "粘贴仅支持远程文件浏览器", + "pasteSameDirectory": "不能粘贴到同一目录", "permGroup": "组", "permOther": "其他", "permSticky": "粘性", @@ -2109,7 +2125,9 @@ }, "shortcutLabels": { "closeTab": "关闭当前标签", + "copyFiles": "复制文件", "copySelectedSavedConnections": "复制选中的已保存连接", + "cutFiles": "剪切文件", "duplicateSession": "复制会话", "duplicateSessionWithCommand": "复制会话并执行命令", "lockScreen": "锁定屏幕", @@ -2121,6 +2139,7 @@ "nextTab": "下一个标签", "openChat": "打开聊天", "openSettings": "打开设置", + "pasteFiles": "粘贴文件", "prevTab": "上一个标签", "quickSwitch": "打开命令面板", "renameFile": "重命名文件/目录", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index a9a57de40..444b15ae9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -781,9 +781,11 @@ "breadcrumbOverflow": "隱藏的路徑節點", "childDirectoriesFailed": "無法讀取目錄", "clearSearch": "清除搜尋", + "cmCopy": "複製", "cmCopyDirPath": "複製目錄路徑", "cmCopyName": "複製名稱", "cmCopyPath": "複製路徑", + "cmCut": "剪下", "cmDelete": "刪除", "cmDownload": "下載...", "cmMove": "移動到...", @@ -791,6 +793,7 @@ "cmOpenDefault": "使用預設編輯器開啟", "cmOpenExternalEditor": "使用外部編輯器開啟", "cmOpenInternalEditor": "使用內建編輯器開啟", + "cmPaste": "貼上", "cmProperties": "屬性...", "cmRefresh": "重新整理", "cmRename": "重新命名...", @@ -854,6 +857,19 @@ "owner": "擁有者", "ownerGroupRequired": "擁有者和使用者群組不能為空。", "ownership": "所有權", + "pasteClipboardEmpty": "剪貼簿中沒有檔案路徑", + "pasteConfirmCopyAction": "複製", + "pasteConfirmCopyDesc": "將 {{count}} 項複製到此目錄?", + "pasteConfirmCopyTitle": "複製檔案", + "pasteConfirmMoveAction": "移動", + "pasteConfirmMoveDesc": "將 {{count}} 項移動到此目錄?", + "pasteConfirmMoveTitle": "移動檔案", + "pasteConfirmUploadAction": "上傳", + "pasteConfirmUploadDesc": "將 {{count}} 項上傳到此目錄?", + "pasteConfirmUploadTitle": "上傳檔案", + "pasteCrossSessionMoveUnsupported": "不支援跨工作階段移動檔案", + "pasteRemoteOnly": "貼上僅支援遠端檔案瀏覽器", + "pasteSameDirectory": "不能貼上到同一目錄", "permGroup": "組", "permOther": "其他", "permSticky": "粘性", @@ -2104,7 +2120,9 @@ }, "shortcutLabels": { "closeTab": "關閉目前標籤", + "copyFiles": "複製檔案", "copySelectedSavedConnections": "複製選取的已儲存連線", + "cutFiles": "剪下檔案", "duplicateSession": "複製工作階段", "duplicateSessionWithCommand": "複製工作階段並執行命令", "lockScreen": "鎖定螢幕", @@ -2116,6 +2134,7 @@ "nextTab": "下一個標籤", "openChat": "開啟聊天", "openSettings": "開啟設定", + "pasteFiles": "貼上檔案", "prevTab": "上一個標籤", "quickSwitch": "開啟命令面板", "renameFile": "重新命名檔案/目錄", From f0d73746b51e2c6d1a758aa3bad71cdc03eb8258 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Sun, 9 Aug 2026 22:44:24 +0800 Subject: [PATCH 08/15] refactor(file-explorer): simplify paste handling and dedupe copy/cut context menu --- .../panel/file-explorer/FileExplorer.tsx | 47 ++++++------------- 1 file changed, 15 insertions(+), 32 deletions(-) diff --git a/src/components/panel/file-explorer/FileExplorer.tsx b/src/components/panel/file-explorer/FileExplorer.tsx index c3bc8d846..1a22d529b 100644 --- a/src/components/panel/file-explorer/FileExplorer.tsx +++ b/src/components/panel/file-explorer/FileExplorer.tsx @@ -1858,26 +1858,14 @@ function FileExplorerPane({ [activeSessionId], ); - const handleCopyFromContextMenu = useCallback( - (entry: FileEntry) => { + const handleCopyCutFromContextMenu = useCallback( + (entry: FileEntry, mode: SftpClipboardMode) => { const entries = isParentDirectoryEntry(entry) ? [] : selectedFiles.size > 1 && selectedFiles.has(entry.name) ? filteredSortedFiles.filter((file) => selectedFiles.has(file.name)) : [entry]; - handleCopyEntries(entries, "copy"); - }, - [filteredSortedFiles, handleCopyEntries, selectedFiles], - ); - - const handleCutFromContextMenu = useCallback( - (entry: FileEntry) => { - const entries = isParentDirectoryEntry(entry) - ? [] - : selectedFiles.size > 1 && selectedFiles.has(entry.name) - ? filteredSortedFiles.filter((file) => selectedFiles.has(file.name)) - : [entry]; - handleCopyEntries(entries, "cut"); + handleCopyEntries(entries, mode); }, [filteredSortedFiles, handleCopyEntries, selectedFiles], ); @@ -1919,20 +1907,7 @@ function FileExplorerPane({ toast.error(t("fileExplorer.pasteCrossSessionMoveUnsupported")); return; } - } else if (osObservation.hasFiles) { - const confirmedUpload = await showPasteConfirm({ - action: "upload", - count: osObservation.paths.length, - targetDir, - fileNames: osObservation.paths.map((path) => getLocalPathName(path, path)), - }); - if (!confirmedUpload) { - return; - } - } - if (isSftpPaste) { - const { sessionId: sourceSessionId, mode, entries } = sftpClipboard; const confirmed = await showPasteConfirm({ action: mode === "cut" ? "move" : "copy", count: entries.length, @@ -1964,7 +1939,6 @@ function FileExplorerPane({ clearSftpClipboard(); invalidateDirectoryChildrenCache(targetDir); await loadDirectory(targetDir, { history: "preserve" }); - void refreshCurrentDirectory(); } return; } @@ -1993,6 +1967,16 @@ function FileExplorerPane({ // 2. OS clipboard file paths → upload to this directory. if (osObservation.hasFiles) { + const confirmedUpload = await showPasteConfirm({ + action: "upload", + count: osObservation.paths.length, + targetDir, + fileNames: osObservation.paths.map((path) => getLocalPathName(path, path)), + }); + if (!confirmedUpload) { + return; + } + try { const resolved = await resolveLocalDropPaths(osObservation.paths); if (resolved.length === 0) { @@ -2017,7 +2001,6 @@ function FileExplorerPane({ enqueueCopies, invalidateDirectoryChildrenCache, loadDirectory, - refreshCurrentDirectory, resolveLocalDropPaths, t, uploadLocalEntriesToTarget, @@ -3124,8 +3107,8 @@ function FileExplorerPane({ onUpload={handleUploadFiles} onUploadFolder={handleUploadFolder} onDownload={handleDownloadFromContextMenu} - onCopy={handleCopyFromContextMenu} - onCut={handleCutFromContextMenu} + onCopy={(entry) => handleCopyCutFromContextMenu(entry, "copy")} + onCut={(entry) => handleCopyCutFromContextMenu(entry, "cut")} onPaste={() => void handlePaste()} showSftpClipboardActions={isRemoteFileBrowser} pasteDisabled={!canPaste} From fe31a46a4431b6aeb4d0b87d3e145f5e6937d401 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Mon, 10 Aug 2026 00:17:59 +0800 Subject: [PATCH 09/15] feat(file-explorer): validate paste sources still exist before enqueuing --- .../panel/file-explorer/FileExplorer.tsx | 53 +++++++++++++------ src/lib/transferDuplicateResolution.ts | 34 ++++++++++++ 2 files changed, 71 insertions(+), 16 deletions(-) diff --git a/src/components/panel/file-explorer/FileExplorer.tsx b/src/components/panel/file-explorer/FileExplorer.tsx index 1a22d529b..eccf932a1 100644 --- a/src/components/panel/file-explorer/FileExplorer.tsx +++ b/src/components/panel/file-explorer/FileExplorer.tsx @@ -88,7 +88,10 @@ import { } from "@/lib/sftpClipboard"; import { matchesKeyEvent } from "@/lib/shortcutRegistry"; import { getSessionInputPeerIds } from "@/lib/syncInputGroups"; -import { resolveRemoteMoveTargets } from "@/lib/transferDuplicateResolution"; +import { + findMissingRemoteEntries, + resolveRemoteMoveTargets, +} from "@/lib/transferDuplicateResolution"; import { cn, formatSize } from "@/lib/utils"; import type { FileWindowTarget } from "@/lib/windowManager"; import { openAutoUpload, openFilePreview, openRemoteFileEditor } from "@/lib/windowManager"; @@ -1908,6 +1911,18 @@ function FileExplorerPane({ return; } + const missingSources = await findMissingRemoteEntries(sourceSessionId, entries); + if (missingSources.length > 0) { + toast.error( + t("fileExplorer.pasteSourceMissing", { + count: missingSources.length, + names: missingSources.map((entry) => entry.name).join(", "), + }), + ); + clearSftpClipboard(); + return; + } + const confirmed = await showPasteConfirm({ action: mode === "cut" ? "move" : "copy", count: entries.length, @@ -1967,29 +1982,35 @@ function FileExplorerPane({ // 2. OS clipboard file paths → upload to this directory. if (osObservation.hasFiles) { + let resolved: ResolvedLocalDropPathEntry[] = []; + try { + resolved = await resolveLocalDropPaths(osObservation.paths); + } catch (error) { + toast.error(getErrorMessage(error) || String(error)); + return; + } + const skippedCount = osObservation.paths.length - resolved.length; + if (skippedCount > 0) { + toast.warning(t("fileExplorer.pasteSourceMissingSkipped", { count: skippedCount })); + } + if (resolved.length === 0) { + return; + } + const confirmedUpload = await showPasteConfirm({ action: "upload", - count: osObservation.paths.length, + count: resolved.length, targetDir, - fileNames: osObservation.paths.map((path) => getLocalPathName(path, path)), + fileNames: resolved.map((entry) => getLocalPathName(entry.path, entry.path)), }); if (!confirmedUpload) { return; } - try { - const resolved = await resolveLocalDropPaths(osObservation.paths); - if (resolved.length === 0) { - toast.error(t("fileExplorer.externalDropPathsRequired")); - return; - } - uploadLocalEntriesToTarget( - { sessionId: activeSessionId, remoteDir: targetDir }, - resolved.map((entry) => ({ path: entry.path, isDir: entry.isDir })), - ); - } catch (error) { - toast.error(getErrorMessage(error) || String(error)); - } + uploadLocalEntriesToTarget( + { sessionId: activeSessionId, remoteDir: targetDir }, + resolved.map((entry) => ({ path: entry.path, isDir: entry.isDir })), + ); return; } diff --git a/src/lib/transferDuplicateResolution.ts b/src/lib/transferDuplicateResolution.ts index 5e3af837b..35687569a 100644 --- a/src/lib/transferDuplicateResolution.ts +++ b/src/lib/transferDuplicateResolution.ts @@ -228,6 +228,40 @@ export async function resolveRemoteMoveTargets( return moves; } +/** + * Check which of the given remote entries no longer exist on the source + * session. A failed `list_remote_dir` (e.g. transient network error) is treated + * as "cannot verify" and the entry is NOT reported missing, to avoid blocking a + * paste on a flaky connection. Returns entries whose parent directory was + * listed successfully but that were not found in it. + */ +export async function findMissingRemoteEntries( + sessionId: string, + entries: Array<{ name: string; path: string }>, +): Promise> { + if (entries.length === 0) { + return []; + } + + const missing: Array<{ name: string; path: string }> = []; + for (const entry of entries) { + const parentDir = getRemoteParentDirectory(entry.path); + try { + const listed = await invoke("list_remote_dir", { + sessionId, + path: parentDir, + }); + if (!listed.some((item) => item.name === entry.name)) { + missing.push(entry); + } + } catch { + // Cannot verify existence: do not report as missing. + } + } + + return missing; +} + async function listRemoteDirNames(sessionId: string, dirPath: string): Promise> { try { const entries = await invoke("list_remote_dir", { From 2415f93c4ce4a5bb0edc26e3e9ab7201a554b56b Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Mon, 10 Aug 2026 00:18:00 +0800 Subject: [PATCH 10/15] chore(i18n): add paste source missing localization strings --- src/i18n/locales/en.json | 2 ++ src/i18n/locales/ko.json | 2 ++ src/i18n/locales/zh-CN.json | 2 ++ src/i18n/locales/zh-TW.json | 2 ++ 4 files changed, 8 insertions(+) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 194e2d856..7af56869f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -871,6 +871,8 @@ "pasteCrossSessionMoveUnsupported": "Moving files across sessions is not supported", "pasteRemoteOnly": "Paste is only supported in the remote file browser", "pasteSameDirectory": "Cannot paste into the same directory", + "pasteSourceMissing": "{{count}} source item(s) no longer exist: {{names}}", + "pasteSourceMissingSkipped": "{{count}} file(s) no longer exist and were skipped", "permGroup": "Group", "permOther": "Other", "permSticky": "Sticky", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 0bf616bc4..b4ad47fce 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -867,6 +867,8 @@ "pasteCrossSessionMoveUnsupported": "세션 간 파일 이동은 지원되지 않습니다", "pasteRemoteOnly": "붙여넣기는 원격 파일 브라우저에서만 지원됩니다", "pasteSameDirectory": "같은 디렉터리에 붙여넣을 수 없습니다", + "pasteSourceMissing": "원본 항목 {{count}}개가 더 이상 존재하지 않습니다: {{names}}", + "pasteSourceMissingSkipped": "파일 {{count}}개가 더 이상 존재하지 않아 건너뛰었습니다", "permGroup": "그룹", "permOther": "기타", "permSticky": "스티키", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 07c4f7e06..75a2c987b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -870,6 +870,8 @@ "pasteCrossSessionMoveUnsupported": "不支持跨会话移动文件", "pasteRemoteOnly": "粘贴仅支持远程文件浏览器", "pasteSameDirectory": "不能粘贴到同一目录", + "pasteSourceMissing": "{{count}} 个源文件已不存在:{{names}}", + "pasteSourceMissingSkipped": "{{count}} 个文件已不存在,已跳过", "permGroup": "组", "permOther": "其他", "permSticky": "粘性", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 444b15ae9..564f7031f 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -870,6 +870,8 @@ "pasteCrossSessionMoveUnsupported": "不支援跨工作階段移動檔案", "pasteRemoteOnly": "貼上僅支援遠端檔案瀏覽器", "pasteSameDirectory": "不能貼上到同一目錄", + "pasteSourceMissing": "{{count}} 個來源檔案已不存在:{{names}}", + "pasteSourceMissingSkipped": "{{count}} 個檔案已不存在,已略過", "permGroup": "組", "permOther": "其他", "permSticky": "粘性", From 039a823b82c7d78046cb708e4d03699774c8b685 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Mon, 10 Aug 2026 20:49:07 +0800 Subject: [PATCH 11/15] fix(file-explorer): let focused dialog button handle Enter in paste confirmation --- src/components/dialog/file-explorer/PasteConfirmDialog.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/dialog/file-explorer/PasteConfirmDialog.tsx b/src/components/dialog/file-explorer/PasteConfirmDialog.tsx index 8c435e490..374dd52d9 100644 --- a/src/components/dialog/file-explorer/PasteConfirmDialog.tsx +++ b/src/components/dialog/file-explorer/PasteConfirmDialog.tsx @@ -63,6 +63,10 @@ export function PasteConfirmDialog() { className="w-[min(22rem,calc(100vw-2rem))] sm:max-w-md" onKeyDown={(event) => { if (event.key === "Enter" && request) { + if (event.target instanceof Element && event.target.closest("button")) { + // Let the focused button run its own action (e.g. Cancel). + return; + } event.preventDefault(); handleConfirm(); } From 8b3ed4e33066c3fc307e253c809f9a9c64cc4db2 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Mon, 10 Aug 2026 20:49:07 +0800 Subject: [PATCH 12/15] fix(clipboard): preserve root slash when parsing localhost file URIs --- src-tauri/src/cmd/file_clipboard.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cmd/file_clipboard.rs b/src-tauri/src/cmd/file_clipboard.rs index 7c345a588..e0b43c282 100644 --- a/src-tauri/src/cmd/file_clipboard.rs +++ b/src-tauri/src/cmd/file_clipboard.rs @@ -68,8 +68,11 @@ fn parse_clipboard_path_text_line(line: &str) -> Option { .unwrap_or(line); if let Some(uri_path) = unwrapped.strip_prefix("file://") { - let local_uri_path = uri_path.strip_prefix("localhost/").unwrap_or(uri_path); - let decoded = urlencoding::decode(local_uri_path).ok()?; + let local_uri_path = match uri_path.strip_prefix("localhost/") { + Some(path) => format!("/{path}"), + None => uri_path.to_string(), + }; + let decoded = urlencoding::decode(&local_uri_path).ok()?; return Some(PathBuf::from(decoded.as_ref())); } From bc042aa285f1f3f63617e5427456bb0de3e82226 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Mon, 10 Aug 2026 20:57:50 +0800 Subject: [PATCH 13/15] fix(clipboard): read native file list from clipboard on macOS --- src-tauri/src/cmd/file_clipboard.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src-tauri/src/cmd/file_clipboard.rs b/src-tauri/src/cmd/file_clipboard.rs index e0b43c282..179891957 100644 --- a/src-tauri/src/cmd/file_clipboard.rs +++ b/src-tauri/src/cmd/file_clipboard.rs @@ -33,6 +33,9 @@ fn read_clipboard_file_paths_blocking() -> Vec { #[cfg(not(target_os = "windows"))] { + if let Some(paths) = read_clipboard_native_file_paths() { + return paths; + } if let Some(paths) = read_clipboard_text_file_paths() { return paths; } @@ -41,6 +44,26 @@ fn read_clipboard_file_paths_blocking() -> Vec { Vec::new() } +/// Read the native file list from the clipboard. On macOS, Finder puts file +/// URLs on the pasteboard rather than plain text, so the text parser alone +/// would never see them. Returns `None` when the clipboard holds no native +/// file list (e.g. plain text or images). +#[cfg(not(target_os = "windows"))] +fn read_clipboard_native_file_paths() -> Option> { + let mut clipboard = arboard::Clipboard::new().ok()?; + match clipboard.get().ok()? { + arboard::Content::Files(files) => Some( + files + .into_iter() + .filter_map(|file| file.path) + .filter(|path| path.exists()) + .map(|path| path.to_string_lossy().to_string()) + .collect(), + ), + _ => None, + } +} + #[cfg(not(target_os = "windows"))] fn read_clipboard_text_file_paths() -> Option> { let mut clipboard = arboard::Clipboard::new().ok()?; From c2accaedcd44be7bc22fdcc7d90141265b865dc2 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Tue, 11 Aug 2026 01:26:43 +0800 Subject: [PATCH 14/15] feat(file-explorer): move cut-paste via copy-then-delete backend command --- src-tauri/src/cmd/sftp.rs | 22 ++++++ src-tauri/src/core/sftp/mod.rs | 71 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + .../panel/file-explorer/FileExplorer.tsx | 27 +++---- 4 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/cmd/sftp.rs b/src-tauri/src/cmd/sftp.rs index b9d1183cd..d735f6d00 100644 --- a/src-tauri/src/cmd/sftp.rs +++ b/src-tauri/src/cmd/sftp.rs @@ -296,6 +296,28 @@ pub async fn copy_file_entry( sftp::copy_file_entry(app, state.inner().clone(), request).await } +#[tauri::command] +pub async fn move_remote_entries( + app: tauri::AppHandle, + state: tauri::State<'_, Arc>, + source_session_id: String, + target_session_id: String, + target_dir: String, + entries: Vec, + duplicate_strategy: Option, +) -> AppResult<()> { + sftp::move_remote_entries( + app, + state.inner().clone(), + &source_session_id, + &target_session_id, + &target_dir, + entries, + duplicate_strategy, + ) + .await +} + #[tauri::command] pub async fn pause_transfer(app: tauri::AppHandle, transfer_id: String) -> AppResult<()> { sftp::pause_transfer(app, &transfer_id).await diff --git a/src-tauri/src/core/sftp/mod.rs b/src-tauri/src/core/sftp/mod.rs index 564c5f1bf..c66a04507 100644 --- a/src-tauri/src/core/sftp/mod.rs +++ b/src-tauri/src/core/sftp/mod.rs @@ -72,6 +72,16 @@ pub struct CopyFileEntryRequest { pub duplicate_strategy_override: Option, } +/// A remote entry to move (cut → paste). Mirrors the frontend clipboard entry +/// shape so the frontend can pass its in-memory clipboard directly. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteMoveEntry { + pub name: String, + pub path: String, + pub is_directory: bool, +} + fn is_remote_delete_not_found(error: &AppError) -> bool { match error { AppError::Sftp(SftpError::Status(status)) => status.status_code == StatusCode::NoSuchFile, @@ -2083,6 +2093,67 @@ pub async fn delete_remote_file( Ok(()) } +/// Move (cut → paste) remote entries into `target_dir`. +/// +/// Unlike a plain `rename`, this supports both same-session and cross-session +/// moves and uses copy-then-delete semantics: each entry is first copied with +/// the same merge/overwrite behaviour as `copy_file_entry` (existing files are +/// overwritten, existing directories are merged recursively), and only after a +/// successful copy is the source entry deleted. +/// +/// `duplicate_strategy` is forwarded to the copy pipeline so the user's +/// configured conflict behaviour (skip / rename / ask with an overwrite prompt) +/// still applies — in particular an "ask" strategy re-prompts before +/// overwriting an existing file or merging into an existing directory. +/// +/// The copy is performed via the existing `copy_file_entry` pipeline so +/// transfer progress events flow through the regular transfer UI. If any entry +/// fails to copy, previously copied/deleted entries are NOT rolled back (the +/// caller pre-validates source existence); the error is returned so the +/// frontend can surface what happened. +pub async fn move_remote_entries( + app: tauri::AppHandle, + manager: Arc, + source_session_id: &str, + target_session_id: &str, + target_dir: &str, + entries: Vec, + duplicate_strategy: Option, +) -> AppResult<()> { + ensure_local_session_kind(&manager, source_session_id, &CopyEndpointKind::Remote).await?; + ensure_local_session_kind(&manager, target_session_id, &CopyEndpointKind::Remote).await?; + + for entry in entries { + let source_path = entry.path.clone(); + let request = CopyFileEntryRequest { + source: CopyEndpoint { + session_id: source_session_id.to_string(), + kind: CopyEndpointKind::Remote, + path: source_path.clone(), + }, + target: CopyEndpoint { + session_id: target_session_id.to_string(), + kind: CopyEndpointKind::Remote, + path: target_dir.to_string(), + }, + file_name: entry.name.clone(), + is_directory: entry.is_directory, + transfer_id: None, + duplicate_strategy_override: duplicate_strategy.clone(), + }; + copy_file_entry(app.clone(), manager.clone(), request).await?; + delete_remote_file( + manager.clone(), + source_session_id, + &source_path, + None, + ) + .await?; + } + + Ok(()) +} + pub async fn rename_remote_file( manager: Arc, session_id: &str, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0630f4f0..f54c4506c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { cmd::sftp::download_remote_directory, cmd::sftp::upload_local_directory, cmd::sftp::copy_file_entry, + cmd::sftp::move_remote_entries, cmd::sftp::pause_transfer, cmd::sftp::resume_transfer, cmd::sftp::cancel_transfer, diff --git a/src/components/panel/file-explorer/FileExplorer.tsx b/src/components/panel/file-explorer/FileExplorer.tsx index eccf932a1..1556cb3df 100644 --- a/src/components/panel/file-explorer/FileExplorer.tsx +++ b/src/components/panel/file-explorer/FileExplorer.tsx @@ -88,10 +88,7 @@ import { } from "@/lib/sftpClipboard"; import { matchesKeyEvent } from "@/lib/shortcutRegistry"; import { getSessionInputPeerIds } from "@/lib/syncInputGroups"; -import { - findMissingRemoteEntries, - resolveRemoteMoveTargets, -} from "@/lib/transferDuplicateResolution"; +import { findMissingRemoteEntries } from "@/lib/transferDuplicateResolution"; import { cn, formatSize } from "@/lib/utils"; import type { FileWindowTarget } from "@/lib/windowManager"; import { openAutoUpload, openFilePreview, openRemoteFileEditor } from "@/lib/windowManager"; @@ -1906,10 +1903,6 @@ function FileExplorerPane({ return; } } - if (mode === "cut" && sourceSessionId !== activeSessionId) { - toast.error(t("fileExplorer.pasteCrossSessionMoveUnsupported")); - return; - } const missingSources = await findMissingRemoteEntries(sourceSessionId, entries); if (missingSources.length > 0) { @@ -1935,19 +1928,17 @@ function FileExplorerPane({ if (mode === "cut") { try { - const moves = await resolveRemoteMoveTargets({ - sessionId: sourceSessionId, + await invoke("move_remote_entries", { + sourceSessionId, + targetSessionId: activeSessionId, targetDir, - entries, + entries: entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDirectory: entry.isDirectory, + })), duplicateStrategy: appSettings.transfer.duplicate_strategy, }); - for (const move of moves) { - await invoke("rename_remote_file", { - sessionId: sourceSessionId, - oldPath: move.oldPath, - newPath: move.newPath, - }); - } } catch (error) { toast.error(getErrorMessage(error) || String(error)); } finally { From 7cf731ede170903e6f58f09f6062300bec34a0b0 Mon Sep 17 00:00:00 2001 From: kaka <1771143956@qq.com> Date: Tue, 11 Aug 2026 01:27:02 +0800 Subject: [PATCH 15/15] chore(i18n): remove unused paste cross-session move copy --- src/i18n/locales/en.json | 1 - src/i18n/locales/ko.json | 1 - src/i18n/locales/zh-CN.json | 1 - src/i18n/locales/zh-TW.json | 1 - 4 files changed, 4 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7af56869f..aeaaa1c25 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -868,7 +868,6 @@ "pasteConfirmUploadAction": "Upload", "pasteConfirmUploadDesc": "Upload {{count}} item(s) to this directory?", "pasteConfirmUploadTitle": "Upload files", - "pasteCrossSessionMoveUnsupported": "Moving files across sessions is not supported", "pasteRemoteOnly": "Paste is only supported in the remote file browser", "pasteSameDirectory": "Cannot paste into the same directory", "pasteSourceMissing": "{{count}} source item(s) no longer exist: {{names}}", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index b4ad47fce..cdc9033f4 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -864,7 +864,6 @@ "pasteConfirmUploadAction": "업로드", "pasteConfirmUploadDesc": "{{count}}개 항목을 이 디렉터리에 업로드하시겠습니까?", "pasteConfirmUploadTitle": "파일 업로드", - "pasteCrossSessionMoveUnsupported": "세션 간 파일 이동은 지원되지 않습니다", "pasteRemoteOnly": "붙여넣기는 원격 파일 브라우저에서만 지원됩니다", "pasteSameDirectory": "같은 디렉터리에 붙여넣을 수 없습니다", "pasteSourceMissing": "원본 항목 {{count}}개가 더 이상 존재하지 않습니다: {{names}}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 75a2c987b..0baa4e095 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -867,7 +867,6 @@ "pasteConfirmUploadAction": "上传", "pasteConfirmUploadDesc": "将 {{count}} 项上传到此目录?", "pasteConfirmUploadTitle": "上传文件", - "pasteCrossSessionMoveUnsupported": "不支持跨会话移动文件", "pasteRemoteOnly": "粘贴仅支持远程文件浏览器", "pasteSameDirectory": "不能粘贴到同一目录", "pasteSourceMissing": "{{count}} 个源文件已不存在:{{names}}", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 564f7031f..ed45cd8b0 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -867,7 +867,6 @@ "pasteConfirmUploadAction": "上傳", "pasteConfirmUploadDesc": "將 {{count}} 項上傳到此目錄?", "pasteConfirmUploadTitle": "上傳檔案", - "pasteCrossSessionMoveUnsupported": "不支援跨工作階段移動檔案", "pasteRemoteOnly": "貼上僅支援遠端檔案瀏覽器", "pasteSameDirectory": "不能貼上到同一目錄", "pasteSourceMissing": "{{count}} 個來源檔案已不存在:{{names}}",