From 5f57ce962e9be190676c86274c518ebd9a759380 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:14:56 +0800 Subject: [PATCH] fix(project): import local files into remote project trees (#587) Pasting or dropping OS files into a remote project tree sent a client-local source path to the server, so nothing was copied. Local-source imports are now carried separately from project copies and uploaded over the session SFTP channel: streamed in bounded chunks, created exclusively, cancellable across all selected paths under one task, with partial files removed on failure. Symlinks and Unix file modes are preserved, and special files are skipped and reported. Project-internal paste keeps working when the OS clipboard is briefly unavailable. Native drop positions were assumed to be physical pixels, which shifted the target far from the pointer on Retina displays and with IDE zoom. A shared helper converts them to CSS coordinates for the project tree, the editor and Markdown drop handling, and the agent composer. Composer image drops are now read on the client in remote windows. Verified on macOS against a live SSH host. Windows and Linux were not run: the Windows conversion is unchanged, the Linux one changed. Copy out from a remote tree to the local machine still needs a download workflow. --- crates/ide-remote/src/import.rs | 61 ++++++++++ crates/ide-remote/src/lib.rs | 1 + crates/ide-remote/tests/remote.rs | 69 +++++++++++ crates/ide-ssh/src/filesystem.rs | 68 ++++++++++- crates/ide-ssh/src/sftp.rs | 43 ++++++- crates/ide-ssh/src/testing.rs | 12 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/remote_dev.rs | 25 ++++ src/bindings.ts | 2 + src/features/agents/Composer.tsx | 7 +- src/features/project-tree/ProjectView.tsx | 5 +- src/features/project-tree/clipboard.ts | 7 +- src/features/project-tree/fs.test.ts | 77 ++++++++++++ src/features/project-tree/fs.ts | 65 ++++++++++- src/features/project-tree/operations.test.ts | 117 ++++++++++++++++++- src/features/project-tree/operations.ts | 72 ++++++++---- src/platform/locales/text/zh.json | 5 + src/platform/nativeDrop.test.ts | 29 +++++ src/platform/nativeDrop.ts | 21 ++++ src/workbench/fileDrop.ts | 5 +- 20 files changed, 651 insertions(+), 41 deletions(-) create mode 100644 crates/ide-remote/src/import.rs create mode 100644 src/features/project-tree/fs.test.ts create mode 100644 src/platform/nativeDrop.test.ts create mode 100644 src/platform/nativeDrop.ts diff --git a/crates/ide-remote/src/import.rs b/crates/ide-remote/src/import.rs new file mode 100644 index 000000000..0b2d02622 --- /dev/null +++ b/crates/ide-remote/src/import.rs @@ -0,0 +1,61 @@ +//! Imports from the client filesystem into a remote project, without deployment mappings. +use ide_core::{CancellationToken, IdeError, IdeResult}; +use ide_ssh::RemoteFs; +use std::path::Path; + +/// Copies entries, preserving symlinks and empty folders. Reports skipped special files. +pub async fn import_local(fs: &RemoteFs, from: &Path, to: &str, cancel: &CancellationToken) -> IdeResult> { + let mut skipped = Vec::new(); + import_entry(fs, from, to, cancel, &mut skipped).await?; + Ok(skipped) +} + +async fn import_entry( + fs: &RemoteFs, + from: &Path, + to: &str, + cancel: &CancellationToken, + skipped: &mut Vec, +) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + if fs.stat(to).await?.is_some() { + return Err(IdeError::new(ide_core::error::ErrorKind::AlreadyExists, format!("{to} already exists"))); + } + let meta = tokio::fs::symlink_metadata(from).await?; + if meta.is_symlink() { + let result = async { + let target = tokio::fs::read_link(from).await?; + let target = target.to_str().ok_or_else(|| IdeError::invalid("Link target is not valid UTF-8"))?; + fs.symlink(target, to).await + } + .await; + if let Err(e) = result { + skipped.push(format!("{}: {e}", from.display())); + } + return Ok(()); + } + if !meta.is_file() && !meta.is_dir() { + skipped.push(format!("{}: skipped special file", from.display())); + return Ok(()); + } + if meta.is_file() { + return fs.upload_new(from, to, cancel).await; + } + fs.create_dir(to).await?; + let result = async { + let mut entries = tokio::fs::read_dir(from).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_str().ok_or_else(|| IdeError::invalid("File name is not valid UTF-8"))?; + Box::pin(import_entry(fs, &entry.path(), &ide_ssh::sftp::join(to, name), cancel, skipped)).await?; + } + Ok(()) + } + .await; + if result.is_err() { + let _ = fs.remove(to).await; + } + result +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 2bdd2dbb4..0ea6407cb 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -2,6 +2,7 @@ //! configurations on run targets ([`target`]). pub mod deploy; +pub mod import; pub mod mapping; pub mod service; pub mod target; diff --git a/crates/ide-remote/tests/remote.rs b/crates/ide-remote/tests/remote.rs index 8d6f76013..ee7d052a3 100644 --- a/crates/ide-remote/tests/remote.rs +++ b/crates/ide-remote/tests/remote.rs @@ -58,6 +58,75 @@ fn no_progress() -> impl FnMut(&str, usize, usize) + Send { |_, _, _| {} } +#[tokio::test] +async fn client_imports_preserve_conflicts_and_report_failure_over_sftp_and_local() { + let env = env().await; + for sftp in [false, true] { + let fs = if sftp { RemoteFs::new(env.session.sftp().await.unwrap()) } else { RemoteFs::Local }; + let base = + if sftp { "/imports".to_owned() } else { env.server_root.join("local").to_string_lossy().into_owned() }; + fs.mkdir_all(&base).await.unwrap(); + let to = ide_ssh::sftp::join(&base, "copy"); + let cancel = CancellationToken::new(); + std::fs::create_dir_all(env.project.join("empty")).unwrap(); + ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap(); + assert_eq!(fs.read(&format!("{to}/src/main.py")).await.unwrap(), b"print('hi')\n"); + assert!(fs.stat(&format!("{to}/empty")).await.unwrap().unwrap().is_dir); + let err = ide_remote::import::import_local(&fs, &env.project, &to, &cancel).await.unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::AlreadyExists); + let file = format!("{to}/README.md"); + assert!(fs.upload_new(&env.project.join("src/main.py"), &file, &cancel).await.is_err()); + assert_eq!(fs.read(&file).await.unwrap(), b"# demo\n"); + let missing = + ide_remote::import::import_local(&fs, &env.project.join("missing"), &format!("{base}/missing"), &cancel) + .await + .unwrap_err(); + assert_eq!(missing.kind, ide_core::error::ErrorKind::NotFound); + cancel.cancel(); + let err = ide_remote::import::import_local(&fs, &env.project, &format!("{base}/cancelled"), &cancel) + .await + .unwrap_err(); + assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled); + assert!(fs.stat(&format!("{base}/cancelled")).await.unwrap().is_none()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let source = env.project.join("links-tree"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("run.sh"), "#!/bin/sh\necho imported\n").unwrap(); + std::fs::set_permissions(source.join("run.sh"), std::fs::Permissions::from_mode(0o751)).unwrap(); + std::os::unix::fs::symlink("run.sh", source.join("link")).unwrap(); + std::os::unix::fs::symlink("missing", source.join("dangling")).unwrap(); + std::os::unix::fs::symlink(".", source.join("cycle")).unwrap(); + let socket = std::os::unix::net::UnixListener::bind(source.join("socket")).unwrap(); + let dest = format!("{base}/links"); + let skipped = + ide_remote::import::import_local(&fs, &source, &dest, &CancellationToken::new()).await.unwrap(); + assert_eq!(skipped, vec![format!("{}: skipped special file", source.join("socket").display())]); + let on_disk = if sftp { env.server_root.join("imports/links") } else { std::path::PathBuf::from(&dest) }; + for (name, target) in [("link", "run.sh"), ("dangling", "missing"), ("cycle", ".")] { + assert_eq!(std::fs::read_link(on_disk.join(name)).unwrap(), std::path::PathBuf::from(target)); + } + assert_eq!(std::fs::metadata(on_disk.join("run.sh")).unwrap().permissions().mode() & 0o777, 0o751); + assert_eq!(fs.read(&format!("{dest}/run.sh")).await.unwrap(), b"#!/bin/sh\necho imported\n"); + assert!(!on_disk.join("socket").exists()); + let skipped = ide_remote::import::import_local( + &fs, + &source.join("socket"), + &format!("{base}/socket"), + &CancellationToken::new(), + ) + .await + .unwrap(); + assert_eq!(skipped.len(), 1); + assert!(fs.stat(&format!("{base}/socket")).await.unwrap().is_none()); + drop(socket); + std::fs::remove_dir_all(source).unwrap(); + } + fs.close().await; + } +} + #[tokio::test] async fn uploads_compares_syncs_and_downloads() { let env = env().await; diff --git a/crates/ide-ssh/src/filesystem.rs b/crates/ide-ssh/src/filesystem.rs index 04bdbed7c..95d98adea 100644 --- a/crates/ide-ssh/src/filesystem.rs +++ b/crates/ide-ssh/src/filesystem.rs @@ -1,6 +1,6 @@ //! Filesystem operations shared by deployment and remote development bootstrap. use crate::sftp::{RemoteEntry, SftpFs}; -use ide_core::{IdeError, IdeResult}; +use ide_core::{CancellationToken, IdeError, IdeResult}; use russh_sftp::client::SftpSession; use serde::{Deserialize, Serialize}; use specta::Type; @@ -115,6 +115,50 @@ impl RemoteFs { } } + /// Exclusive creation keeps imports from overwriting a concurrent copy. + pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> { + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + match self { + Self::Sftp(fs) => fs.upload_new(local, remote, cancel).await, + Self::Local => { + let mut input = tokio::fs::File::open(local).await?; + let mut output = tokio::fs::OpenOptions::new().write(true).create_new(true).open(remote).await?; + let result = async { + copy_stream(&mut input, &mut output, cancel).await?; + #[cfg(unix)] + output.set_permissions(input.metadata().await?.permissions()).await?; + Ok(()) + } + .await; + drop(output); + if result.is_err() { + let _ = tokio::fs::remove_file(remote).await; + } + result + } + } + } + + /// Creates the link itself, preserving a relative or dangling target. + pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.symlink(target, link).await, + #[cfg(unix)] + Self::Local => Ok(tokio::fs::symlink(target, link).await?), + #[cfg(not(unix))] + Self::Local => Err(IdeError::invalid("Cannot create a symbolic link on this client")), + } + } + + pub async fn create_dir(&self, path: &str) -> IdeResult<()> { + match self { + Self::Sftp(fs) => fs.create_dir(path).await, + Self::Local => Ok(tokio::fs::create_dir(path).await?), + } + } + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { match self { Self::Sftp(fs) => fs.download(remote, local).await, @@ -199,6 +243,28 @@ impl RemoteFs { } } +pub(crate) async fn copy_stream( + input: &mut (impl tokio::io::AsyncRead + Unpin), + output: &mut (impl tokio::io::AsyncWrite + Unpin), + cancel: &CancellationToken, +) -> IdeResult<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + tokio::select! { + biased; + _ = cancel.cancelled() => Err(IdeError::cancelled()), + result = async { + let mut buf = vec![0; 64 * 1024]; + loop { + let len = input.read(&mut buf).await?; + if len == 0 { break; } + output.write_all(&buf[..len]).await?; + } + output.flush().await?; + Ok(()) + } => result, + } +} + /// Copies without holding the file in memory: deployments can include large archives. async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> { if let Some(parent) = to.parent() { diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index 38a8fde74..c5522023a 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -1,9 +1,9 @@ //! Remote files over SFTP. Remote paths are POSIX strings. -use ide_core::{IdeError, IdeResult, error::ErrorKind}; +use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind}; use russh_sftp::client::SftpSession; use russh_sftp::client::error::Error as SftpError; -use russh_sftp::protocol::{FileAttributes, StatusCode}; +use russh_sftp::protocol::{FileAttributes, OpenFlags, StatusCode}; use serde::{Deserialize, Serialize}; use specta::Type; use std::path::Path; @@ -168,6 +168,45 @@ impl SftpFs { self.write(remote, &data, mtime).await } + /// Imports a local file without replacing an existing remote entry. + pub async fn upload_new(&self, local: &Path, remote: &str, cancel: &CancellationToken) -> IdeResult<()> { + let mut input = tokio::fs::File::open(local).await?; + let mut output = self + .sftp + .open_with_flags(remote, OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::EXCLUDE) + .await + .map_err(|e| err(remote, e))?; + let result = crate::filesystem::copy_stream(&mut input, &mut output, cancel).await; + let closed = output.shutdown().await.map_err(IdeError::from); + drop(output); + let result = async { + result.and(closed)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = input.metadata().await?.permissions().mode() & 0o7777; + self.sftp + .set_metadata(remote, FileAttributes { permissions: Some(mode), ..FileAttributes::empty() }) + .await + .map_err(|e| err(remote, e))?; + } + Ok(()) + } + .await; + if result.is_err() { + let _ = self.sftp.remove_file(remote).await; + } + result + } + + pub async fn symlink(&self, target: &str, link: &str) -> IdeResult<()> { + self.sftp.symlink(target, link).await.map_err(|e| err(link, e)) + } + + pub async fn create_dir(&self, path: &str) -> IdeResult<()> { + self.sftp.create_dir(path).await.map_err(|e| err(path, e)) + } + pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> { let data = self.read(remote).await?; if let Some(dir) = local.parent() { diff --git a/crates/ide-ssh/src/testing.rs b/crates/ide-ssh/src/testing.rs index 2ad98509f..68cf45d60 100644 --- a/crates/ide-ssh/src/testing.rs +++ b/crates/ide-ssh/src/testing.rs @@ -164,6 +164,7 @@ impl russh_sftp::server::Handler for Sftp { .read(pflags.contains(OpenFlags::READ)) .write(pflags.contains(OpenFlags::WRITE)) .create(pflags.contains(OpenFlags::CREATE)) + .create_new(pflags.contains(OpenFlags::EXCLUDE)) .truncate(pflags.contains(OpenFlags::TRUNCATE)) .open(path) .map_err(code)?; @@ -213,6 +214,11 @@ impl russh_sftp::server::Handler for Sftp { } async fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes) -> Result { + #[cfg(unix)] + if let Some(mode) = attrs.permissions { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(self.local(&path), std::fs::Permissions::from_mode(mode)).map_err(code)?; + } if let Some(t) = attrs.mtime { let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.into()); let f = std::fs::File::options().write(true).open(self.local(&path)).map_err(code)?; @@ -255,6 +261,12 @@ impl russh_sftp::server::Handler for Sftp { Ok(ok(id)) } + #[cfg(unix)] + async fn symlink(&mut self, id: u32, target: String, link: String) -> Result { + std::os::unix::fs::symlink(target, self.local(&link)).map_err(code)?; + Ok(ok(id)) + } + async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result { std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?; Ok(ok(id)) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 94d02f711..e327a2642 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -477,6 +477,7 @@ pub fn builder() -> tauri_specta::Builder { crate::remote_dev::remote_current, crate::remote_dev::remote_disconnect, crate::remote_dev::remote_invoke, + crate::remote_dev::remote_import_local, env::env_reload, db::db_test, db::db_connected, diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index e194a1910..5bcf0cb58 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -468,6 +468,31 @@ pub async fn remote_invoke( Ok(result.to_string()) } +/// A client OS file belongs to this machine even when its destination is remote. +#[tauri::command] +#[specta::specta] +pub async fn remote_import_local( + window: Window, + state: State<'_, AppState>, + from: std::path::PathBuf, + to: String, + request: ide_core::RequestId, +) -> IdeResult> { + let label = AppState::owner_window(window.label()); + let c = state.remote_dev.get(label).ok_or_else(|| IdeError::unavailable("Remote project is disconnected"))?; + c.authorize_command(label, "fs_copy").await?; + let cancel = state.host.begin_request(request); + let result = async { + let fs = ide_ssh::RemoteFs::new(c._session.sftp().await?); + let result = ide_remote::import::import_local(&fs, &from, &to, &cancel).await; + fs.close().await; + result + } + .await; + state.host.end_request(request); + result +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/bindings.ts b/src/bindings.ts index 7fbeb9845..de0462380 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1016,6 +1016,8 @@ export const commands = { * `on_channel` tagged with the id. */ remoteInvoke: (cmd: string, args: string, onChannel: Channel) => typedError(__TAURI_INVOKE("remote_invoke", { cmd, args, onChannel })), + /** A client OS file belongs to this machine even when its destination is remote. */ + remoteImportLocal: (from: string, to: string, request: RequestId) => typedError(__TAURI_INVOKE("remote_import_local", { from, to, request })), /** * Reload Environment: new child processes get the login shell's current environment * (and the project's `remoteEnv` on a remote host); running ones keep theirs. diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx index 4334e7fb4..21535d108 100644 --- a/src/features/agents/Composer.tsx +++ b/src/features/agents/Composer.tsx @@ -7,6 +7,8 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEve import clsx from "clsx"; import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings"; import { call, commands, isTauri } from "@/platform/ipc"; +import { nativeDropPoint } from "@/platform/nativeDrop"; +import { invokeLocal } from "@/platform/tauriCore"; import { useTx } from "@/platform/i18n"; import { basename } from "@/platform/project"; import { notifications, OWNS_FILE_DROP } from "@/workbench/contributions"; @@ -221,13 +223,12 @@ export function Composer(props: ComposerProps) { const p = event.payload; if (p.type !== "drop" || !p.paths.length) return; const box = boxRef.current?.getBoundingClientRect(); - const ratio = window.devicePixelRatio || 1; - const [x, y] = [p.position.x / ratio, p.position.y / ratio]; + const { x, y } = nativeDropPoint(p.position); if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return; const refs: FileRef[] = []; for (const path of p.paths) { if (IMAGE_EXTENSIONS.test(path)) { - void call(commands.fsReadImage(path)).then((url) => { + void invokeLocal("fs_read_image", { path }).then((url) => { const image = imageFromDataUrl(url, basename(path)); if (image) addImages([image]); }); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index af80a06bc..fcdc1e560 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -23,6 +23,7 @@ import { executeAction } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { tx, useTxTemplate } from "@/platform/i18n"; import { isTauri } from "@/platform/ipc"; +import { nativeDropPoint } from "@/platform/nativeDrop"; import type { RootFolder } from "@/platform/languageSettings"; import { useDetectedRoots, useEffectiveRoots } from "@/platform/projectRoots"; import { macStroke, strokeOf } from "@/platform/keymap"; @@ -550,9 +551,7 @@ export function ProjectView() { .onDragDropEvent((event) => { const p = event.payload; if (p.type === "leave") return setDropTarget(null); - const ratio = window.devicePixelRatio || 1; - const x = p.position.x / ratio, - y = p.position.y / ratio; + const { x, y } = nativeDropPoint(p.position); const target = folderAt(containerRef.current, x, y); if (p.type === "drop") { setDropTarget(null); diff --git a/src/features/project-tree/clipboard.ts b/src/features/project-tree/clipboard.ts index 58283303c..459700700 100644 --- a/src/features/project-tree/clipboard.ts +++ b/src/features/project-tree/clipboard.ts @@ -18,12 +18,7 @@ export interface OsClipboard { const native: OsClipboard = { async files() { if (!isTauri()) return []; - try { - return await call(commands.clipboardFileList()); - } catch { - // A clipboard another app holds for a moment is not an error worth showing. - return []; - } + return call(commands.clipboardFileList()); }, async hasFiles() { diff --git a/src/features/project-tree/fs.test.ts b/src/features/project-tree/fs.test.ts new file mode 100644 index 000000000..017cef334 --- /dev/null +++ b/src/features/project-tree/fs.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commands } from "@/platform/ipc"; +import { useRemote } from "@/platform/remote"; +import { invokeLocal } from "@/platform/tauriCore"; +import { useTasks } from "@/workbench/tasks"; +import { beginLocalImport, tauriFs } from "./fs"; + +vi.mock("@/platform/tauriCore", async (original) => ({ + ...(await original()), + invokeLocal: vi.fn(async () => undefined), +})); + +beforeEach(() => { + useTasks.setState({ tasks: [] }); + useRemote.setState({ + session: { sshConfig: "s", root: "/remote", host: "host", commands: ["fs_copy", "cancel_request"] }, + }); +}); +afterEach(() => { + vi.restoreAllMocks(); + useRemote.setState({ session: null }); + useTasks.setState({ tasks: [] }); +}); + +describe("client file routing", () => { + it("uploads OS files locally and cancels the client request rather than a remote one", async () => { + let finish!: (r: { status: "ok"; data: string[] }) => void; + const upload = vi.spyOn(commands, "remoteImportLocal").mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + const copy = vi.spyOn(commands, "fsCopy"); + const work = tauriFs.importLocal("/client/a", "/remote/a"); + const task = useTasks.getState().tasks[0]!; + const request = upload.mock.calls[0]![2]; + task.cancel!(); + expect(invokeLocal).toHaveBeenCalledWith("cancel_request", { id: request }); + finish({ status: "ok", data: [] }); + await work; + expect(upload).toHaveBeenCalledWith("/client/a", "/remote/a", request); + expect(copy).not.toHaveBeenCalled(); + expect(useTasks.getState().tasks[0]?.done).toBe(true); + }); + + it("surfaces a disconnected upload and finishes its task", async () => { + vi.spyOn(commands, "remoteImportLocal").mockResolvedValue({ + status: "error", + error: { kind: "unavailable", message: "Remote project is disconnected" }, + }); + await expect(tauriFs.importLocal("/client/a", "/remote/a")).rejects.toThrow("Remote project is disconnected"); + expect(useTasks.getState().tasks[0]?.done).toBe(true); + }); + + it("shares one task across paths and prevents starting another after Cancel", async () => { + const upload = vi.spyOn(commands, "remoteImportLocal").mockResolvedValue({ status: "ok", data: [] }); + const batch = beginLocalImport(); + await tauriFs.importLocal("/client/a", "/remote/a", batch); + await tauriFs.importLocal("/client/b", "/remote/b", batch); + expect(useTasks.getState().tasks).toHaveLength(1); + expect(useTasks.getState().tasks[0]?.done).toBe(false); + useTasks.getState().tasks[0]!.cancel!(); + await expect(tauriFs.importLocal("/client/c", "/remote/c", batch)).rejects.toMatchObject({ kind: "cancelled" }); + expect(upload).toHaveBeenCalledTimes(2); + batch.finish(); + expect(useTasks.getState().tasks[0]?.done).toBe(true); + }); + + it("uses project copy for a local destination", async () => { + useRemote.setState({ session: null }); + const copy = vi.spyOn(commands, "fsCopy").mockResolvedValue({ status: "ok", data: null }); + const upload = vi.spyOn(commands, "remoteImportLocal"); + await tauriFs.importLocal("/client/a", "/project/a"); + expect(copy).toHaveBeenCalledWith("/client/a", "/project/a"); + expect(upload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/project-tree/fs.ts b/src/features/project-tree/fs.ts index 6aee22bc2..ecceaa2d1 100644 --- a/src/features/project-tree/fs.ts +++ b/src/features/project-tree/fs.ts @@ -4,7 +4,11 @@ */ import type { FileEntry } from "@/bindings"; import { notifyFileOperation } from "@/platform/fileOps"; -import { call, commands, emitLocalIdeEvent, isTauri, IpcError } from "@/platform/ipc"; +import { call, commands, emitLocalIdeEvent, isTauri, IpcError, nextRequestId } from "@/platform/ipc"; +import { isRemoteWindow } from "@/platform/remote"; +import { invokeLocal } from "@/platform/tauriCore"; +import { tx } from "@/platform/i18n"; +import { upsertTask } from "@/workbench/tasks"; import { isInside, joinPath, nameOf, parentOf } from "./paths"; export interface ProjectFs { @@ -15,6 +19,7 @@ export interface ProjectFs { /** Moves to the trash when possible. */ delete(path: string): Promise; copy(from: string, to: string): Promise; + importLocal(from: string, to: string, upload?: LocalImport): Promise; } export const tauriFs: ProjectFs = { @@ -33,8 +38,61 @@ export const tauriFs: ProjectFs = { await call(commands.fsCopy(from, to)); notifyFileOperation({ kind: "created", paths: [to] }); }, + importLocal: async (from, to, upload) => { + if (!isRemoteWindow()) { + await tauriFs.copy(from, to); + return []; + } + const batch = upload ?? beginLocalImport(); + try { + const request = batch.start(from); + const skipped = await call(commands.remoteImportLocal(from, to, request)); + notifyFileOperation({ kind: "created", paths: [to] }); + return skipped; + } finally { + if (!upload) batch.finish(); + } + }, }; +interface LocalImport { + readonly cancelled: boolean; + start(from: string): number; + finish(): void; +} + +/** One task spans every selected path; cancellation also stops paths not started yet. */ +export function beginLocalImport(): LocalImport { + let cancelled = false; + let request = nextRequestId(); + const task = { + id: `file-import-${request}`, + title: tx("Uploading files"), + message: null as string | null, + fraction: null, + cancel: () => { + cancelled = true; + void invokeLocal("cancel_request", { id: request }).catch(() => {}); + }, + }; + upsertTask({ ...task, done: false }); + return { + get cancelled() { + return cancelled; + }, + start(from) { + if (cancelled) throw new IpcError({ kind: "cancelled", message: "Cancelled" }); + request = nextRequestId(); + task.message = nameOf(from); + upsertTask({ ...task, done: false }); + return request; + }, + finish() { + upsertTask({ ...task, done: true }); + }, + }; +} + type MemNode = { kind: "file"; size: number } | { kind: "dir" }; const err = (kind: "notFound" | "alreadyExists" | "invalidInput", message: string) => new IpcError({ kind, message }); @@ -138,6 +196,11 @@ export class MemoryFs implements ProjectFs { } this.emit({ created: [to] }); } + + async importLocal(from: string, to: string): Promise { + await this.copy(from, to); + return []; + } } /** Sample project for plain-browser development (matches the design mock). */ diff --git a/src/features/project-tree/operations.test.ts b/src/features/project-tree/operations.test.ts index b4c7887b4..9bb42a2a3 100644 --- a/src/features/project-tree/operations.test.ts +++ b/src/features/project-tree/operations.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectNode } from "./model"; -import { selectionAfterDelete } from "./operations"; +import { importExternal, paste, selectionAfterDelete, transfer } from "./operations"; +import { IpcError } from "@/platform/ipc"; +import { pendingFileRefactorDialog } from "./FileRefactorDialogs"; +import { useRemote } from "@/platform/remote"; +import { provideNotificationService } from "@/workbench/contributions"; +import { MemoryFs, setProjectFsForTests } from "./fs"; +import { initProjectTree, loadDir, setClipboard } from "./store"; +import { setOsClipboardForTests } from "./clipboard"; + +let fs: MemoryFs; +const notify = vi.fn(); const node = (path: string, dir = false): ProjectNode => ({ key: path, @@ -31,3 +41,106 @@ describe("selectionAfterDelete", () => { expect(selectionAfterDelete(rows, ["/elsewhere"])).toBeNull(); }); }); + +// Local OS paths and remote project paths may be identical strings. +describe("OS file imports", () => { + beforeEach(async () => { + fs = new MemoryFs({ "/p/target": null, "/p/local.txt": "client" }, "/p"); + setProjectFsForTests(fs, "/p"); + initProjectTree("/p", "p"); + await loadDir("/p"); + useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } }); + provideNotificationService({ notify }); + notify.mockClear(); + }); + + afterEach(() => { + useRemote.setState({ session: null }); + setProjectFsForTests(null); + setOsClipboardForTests(null); + }); + + it.each(["paste", "drop"])( + "%s imports a client path under the remote root and keeps conflict names", + async (entry) => { + setOsClipboardForTests({ + files: async () => ["/p/local.txt"], + hasFiles: async () => true, + setFiles: async () => {}, + }); + const imported = vi.spyOn(fs, "importLocal"); + for (let i = 0; i < 2; i++) { + if (entry === "paste") await paste("/p/target"); + else await importExternal(["/p/local.txt"], "/p/target"); + } + expect(imported.mock.calls).toEqual([ + ["/p/local.txt", "/p/target/local.txt", expect.anything()], + ["/p/local.txt", "/p/target/local copy.txt", expect.anything()], + ]); + expect(notify).not.toHaveBeenCalled(); + }, + ); + + it("reports upload failure and continues with the next file", async () => { + const imported = vi.spyOn(fs, "importLocal").mockRejectedValueOnce(new Error("SSH disconnected")); + await importExternal(["/missing.txt", "/p/local.txt"], "/p/target"); + expect(imported).toHaveBeenCalledTimes(2); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "Some items could not be copied", body: "missing.txt: SSH disconnected" }), + ); + expect(fs.nodes.has("/p/target/local.txt")).toBe(true); + }); + + it("reports a clipboard read failure", async () => { + setOsClipboardForTests({ + files: async () => { + throw new Error("clipboard unavailable"); + }, + hasFiles: async () => true, + setFiles: async () => {}, + }); + await paste("/p/target"); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "Cannot access the clipboard", body: "clipboard unavailable" }), + ); + }); + + it.each([false, true])("falls back to internal clipboard (cut=%s) when another app holds it", async (cut) => { + setOsClipboardForTests({ + files: async () => { + throw new Error("busy"); + }, + hasFiles: async () => false, + setFiles: async () => {}, + }); + setClipboard({ paths: ["/p/local.txt"], cut }); + await paste("/p/target"); + expect(pendingFileRefactorDialog()).toEqual( + expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/local.txt"] }), + ); + expect(notify).not.toHaveBeenCalled(); + }); + + it("stops a multi-path import quietly on cancellation", async () => { + const imported = vi + .spyOn(fs, "importLocal") + .mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Cancelled" })); + await importExternal(["/p/local.txt", "/p/another.txt"], "/p/target"); + expect(imported).toHaveBeenCalledTimes(1); + expect(notify).not.toHaveBeenCalled(); + }); + + it("reports skipped entries while keeping the imported folder", async () => { + vi.spyOn(fs, "importLocal").mockResolvedValueOnce(["bundle/pipe: skipped special file"]); + expect(await transfer(["/bundle"], "/p/target", "copy", undefined, "local")).toBe("/p/target/bundle"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ body: "bundle/pipe: skipped special file" })); + }); + + it("keeps internal remote copies on the project filesystem", async () => { + const imported = vi.spyOn(fs, "importLocal"); + const copied = vi.spyOn(fs, "copy"); + await transfer(["/p/local.txt"], "/p/target", "copy"); + expect(copied).toHaveBeenCalledWith("/p/local.txt", "/p/target/local.txt"); + expect(imported).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 4767fce96..1e5fe0990 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -10,7 +10,8 @@ import { ask, confirmChecked } from "@/workbench/messages"; import { osClipboard } from "./clipboard"; import { runFileRefactoring } from "./fileRefactor"; import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs"; -import { projectFs } from "./fs"; +import { beginLocalImport, projectFs } from "./fs"; +import { isCancelled } from "@/platform/ipc"; import type { ProjectNode } from "./model"; import { isInside, @@ -38,6 +39,7 @@ import { useProjectTree, } from "./store"; import { tx } from "@/platform/i18n"; +import { isRemoteWindow } from "@/platform/remote"; /** Nodes that may be renamed, moved or deleted (not roots, groups or library sources). */ const mutable = (n: ProjectNode) => !n.root && !n.placeholder && !n.readOnly; @@ -500,11 +502,22 @@ export function canPaste(): boolean { export async function paste(target = targetFolder()) { const { clipboard } = get(); if (!target || isReadOnlyPath(target)) return; - const external = await osClipboard().files(); + let external: string[] = []; + try { + external = await osClipboard().files(); + } catch (e) { + if (!clipboard?.paths.length) { + fail("Cannot access the clipboard", e); + return; + } + } // The last copy is the one the user expects to paste, so another app's files // win over ours — unless they are the copy we handed to the OS ourselves. if (external.length && !sameFiles(external, clipboard?.paths)) return pasteFromOsClipboard(external, target); - if (!clipboard?.paths.length) return; + if (!clipboard?.paths.length) { + notifications().notify({ level: "info", title: "No files on the clipboard" }); + return; + } const isDir = (p: string) => get().listings.has(p) || currentModel().byKey.get(currentModel().keyOfPath.get(p) ?? p)?.dir === true; const single = clipboard.paths.length === 1 && isDir(clipboard.paths[0]!); @@ -519,7 +532,7 @@ export async function paste(target = targetFolder()) { /** Pasting from Explorer copies, whatever the source: never a move or a refactoring. */ async function pasteFromOsClipboard(paths: string[], target: string) { osHasFiles = true; - await transfer(paths, target, "copy"); + await transfer(paths, target, "copy", undefined, "local"); } /** The same files, as the clipboard keeps them (`/`-separated, any order). */ @@ -559,6 +572,7 @@ export async function transfer( target: string, mode: "move" | "copy", name?: string, + source: "project" | "local" = "project", ): Promise { if (mode === "move") return moveInto(paths, target); const { root } = get(); @@ -571,23 +585,38 @@ export async function transfer( const errors: string[] = []; let first: string | null = null; const newName = name; - for (const src of paths) { - const name = nameOf(src); - if (isSameOrInside(target, src) && target !== parentOf(src) && src !== target) { - errors.push(`${name}: cannot copy a folder into itself`); - continue; - } - const srcIsDir = - get().listings.has(src) || currentModel().byKey.get(currentModel().keyOfPath.get(src) ?? src)?.dir === true; - const finalName = paths.length === 1 && newName ? newName : uniqueName(name, isTaken, srcIsDir); - const to = joinPath(target, finalName); - try { - await fs.copy(src, to); - taken.add(caseInsensitive ? finalName.toLowerCase() : finalName); - first ??= to; - } catch (e) { - errors.push(`${name}: ${errorMessage(e)}`); + const upload = source === "local" && isRemoteWindow() ? beginLocalImport() : undefined; + try { + for (const src of paths) { + if (upload?.cancelled) break; + const name = nameOf(src); + if ( + !(source === "local" && isRemoteWindow()) && + isSameOrInside(target, src) && + target !== parentOf(src) && + src !== target + ) { + errors.push(`${name}: cannot copy a folder into itself`); + continue; + } + const srcIsDir = + get().listings.has(src) || currentModel().byKey.get(currentModel().keyOfPath.get(src) ?? src)?.dir === true; + const finalName = paths.length === 1 && newName ? newName : uniqueName(name, isTaken, srcIsDir); + const to = joinPath(target, finalName); + try { + if (source === "local") { + const skipped = await fs.importLocal(src, to, upload); + errors.push(...skipped); + } else await fs.copy(src, to); + taken.add(caseInsensitive ? finalName.toLowerCase() : finalName); + first ??= to; + } catch (e) { + if (isCancelled(e)) break; + errors.push(`${name}: ${errorMessage(e)}`); + } } + } finally { + upload?.finish(); } if (errors.length) notifications().notify({ level: "error", title: "Some items could not be copied", body: errors.join("\n") }); @@ -650,10 +679,11 @@ export async function dropPaths(paths: string[], target: string, copy: boolean) export async function importExternal(paths: string[], target: string) { const { root } = get(); if (!root || !paths.length || isReadOnlyPath(target)) return; + if (isRemoteWindow()) return void (await transfer(paths, target, "copy", undefined, "local")); const inside = paths.filter((p) => isSameOrInside(p, root)); const outside = paths.filter((p) => !isSameOrInside(p, root)); if (inside.length) await dropPaths(inside, target, false); - if (outside.length) await transfer(outside, target, "copy"); + if (outside.length) await transfer(outside, target, "copy", undefined, "local"); } // --------------------------------------------------------------------------- diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 5878ce621..c0c4290c2 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,9 @@ { + "Uploading files": "正在上传文件", + "No files on the clipboard": "剪贴板中没有文件", + "Remote project is disconnected": "远程项目已断开连接", + "File name is not valid UTF-8": "文件名不是有效的 UTF-8", + "Cannot import a symbolic link or special file: {path}": "无法导入符号链接或特殊文件:{path}", "Project opening cancelled": "已取消打开项目", "Project is in Restricted Mode": "项目处于受限模式", "Project is in Restricted Mode. Trust the project to enable execution.": "项目处于受限模式。信任项目后可启用执行功能。", diff --git a/src/platform/nativeDrop.test.ts b/src/platform/nativeDrop.test.ts new file mode 100644 index 000000000..bc0973f73 --- /dev/null +++ b/src/platform/nativeDrop.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { nativeDropPoint } from "./nativeDrop"; + +describe("native drop coordinates", () => { + for (const os of ["macos", "linux", "windows"]) { + for (const scale of [1, 1.5, 2, 3]) { + for (const zoom of [0.5, 1, 1.25, 2, 3]) { + it(`${os}, display scale ${scale}, zoom ${zoom}`, () => { + // Native Cocoa/GTK locations are unzoomed logical points; Win32 is physical. + const unit = os === "windows" ? scale * zoom : zoom; + const dpr = os === "windows" ? scale * zoom : scale; + for (const point of [ + { x: 122, y: 116 }, + { x: 122, y: 236 }, + { x: 700, y: 800 }, + ]) { + const native = { x: point.x * unit, y: point.y * unit }; + expect(nativeDropPoint(native, os, dpr, zoom)).toEqual(point); + } + }); + } + } + } + + it("matches measured Retina macOS positions at 100% and 125%", () => { + expect(nativeDropPoint({ x: 122, y: 236 }, "macos", 2, 1)).toEqual({ x: 122, y: 236 }); + expect(nativeDropPoint({ x: 152, y: 295 }, "macos", 2, 1.25)).toEqual({ x: 121.6, y: 236 }); + }); +}); diff --git a/src/platform/nativeDrop.ts b/src/platform/nativeDrop.ts new file mode 100644 index 000000000..25a8129b8 --- /dev/null +++ b/src/platform/nativeDrop.ts @@ -0,0 +1,21 @@ +import { usePlatform } from "./platformInfo"; +import { useProject } from "./project"; +import { clampZoom } from "./zoom"; + +interface Point { + x: number; + y: number; +} + +/** Wry Cocoa/GTK supply logical points; Win32 supplies physical client pixels. + * WebView2's DPR includes zoom; WKWebView's does not. All consumers need CSS pixels. + */ +export function nativeDropPoint( + point: Point, + os = usePlatform.getState().os, + dpr = window.devicePixelRatio || 1, + zoom = clampZoom(useProject.getState().settings?.appearance?.zoom) / 100, +): Point { + const divisor = os === "windows" ? dpr : zoom; + return { x: point.x / divisor, y: point.y / divisor }; +} diff --git a/src/workbench/fileDrop.ts b/src/workbench/fileDrop.ts index 80bd23311..8cb666060 100644 --- a/src/workbench/fileDrop.ts +++ b/src/workbench/fileDrop.ts @@ -8,6 +8,7 @@ import { executeAction } from "@/platform/actions"; import { call, commands, isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; +import { nativeDropPoint } from "@/platform/nativeDrop"; import { editorService, OWNS_FILE_DROP } from "./contributions"; const OWNER_ATTR = Object.keys(OWNS_FILE_DROP)[0]!; @@ -71,8 +72,8 @@ export function installFileDrop(): () => void { .onDragDropEvent((event) => { const p = event.payload; if (p.type !== "drop") return; - const ratio = window.devicePixelRatio || 1; - void handleFileDrop(p.paths, p.position.x / ratio, p.position.y / ratio); + const { x, y } = nativeDropPoint(p.position); + void handleFileDrop(p.paths, x, y); }) .then((u) => { if (disposed) u();