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();