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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-01 00:14:56 +08:00
1 джерело 95b45c095d
коміт 5f57ce962e
20 files changed
+651 -41

No files matched your search

+61
Переглянути файл
@@ -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<Vec<String>> {
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<String>,
) -> 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
}
+1
Переглянути файл
@@ -2,6 +2,7 @@
//! configurations on run targets ([`target`]).
pub mod deploy;
pub mod import;
pub mod mapping;
pub mod service;
pub mod target;
+69
Переглянути файл
@@ -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;
+67 -1
Переглянути файл
@@ -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() {
+41 -2
Переглянути файл
@@ -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() {
+12
Переглянути файл
@@ -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<Status, Self::Error> {
#[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<Status, Self::Error> {
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<Status, Self::Error> {
std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?;
Ok(ok(id))
+1
Переглянути файл
@@ -477,6 +477,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+25
Переглянути файл
@@ -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<Vec<String>> {
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::*;
+2
Переглянути файл
@@ -1016,6 +1016,8 @@ export const commands = {
* `on_channel` tagged with the id.
*/
remoteInvoke: (cmd: string, args: string, onChannel: Channel<ChannelFrame>) => typedError<string, IdeError>(__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<string[], IdeError>(__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.
+4 -3
Переглянути файл
@@ -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<string>("fs_read_image", { path }).then((url) => {
const image = imageFromDataUrl(url, basename(path));
if (image) addImages([image]);
});
+2 -3
Переглянути файл
@@ -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);
+1 -6
Переглянути файл
@@ -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() {
+77
Переглянути файл
@@ -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<typeof import("@/platform/tauriCore")>()),
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();
});
});
+64 -1
Переглянути файл
@@ -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<void>;
copy(from: string, to: string): Promise<void>;
importLocal(from: string, to: string, upload?: LocalImport): Promise<string[]>;
}
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<string[]> {
await this.copy(from, to);
return [];
}
}
/** Sample project for plain-browser development (matches the design mock). */
+115 -2
Переглянути файл
@@ -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();
});
});
+51 -21
Переглянути файл
@@ -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<string | boolean> {
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");
}
// ---------------------------------------------------------------------------
+5
Переглянути файл
@@ -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.": "项目处于受限模式。信任项目后可启用执行功能。",
+29
Переглянути файл
@@ -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 });
});
});
+21
Переглянути файл
@@ -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 };
}
+3 -2
Переглянути файл
@@ -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();