diff --git a/Cargo.lock b/Cargo.lock index d3d54906..23f8ecb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3989,6 +3989,10 @@ dependencies = [ "tracing", ] +[[package]] +name = "ide-clipboard" +version = "0.1.14" + [[package]] name = "ide-core" version = "0.1.14" @@ -5858,6 +5862,7 @@ dependencies = [ "dunce", "ide-analysis", "ide-build", + "ide-clipboard", "ide-core", "ide-coverage", "ide-dap", diff --git a/Cargo.toml b/Cargo.toml index 6a8155c5..110b9994 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ ide-http = { path = "crates/ide-http" } ide-db = { path = "crates/ide-db" } ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } +ide-clipboard = { path = "crates/ide-clipboard" } # serialization & IPC typing serde = { version = "1", features = ["derive"] } diff --git a/crates/ide-clipboard/Cargo.toml b/crates/ide-clipboard/Cargo.toml new file mode 100644 index 00000000..4f20ecc3 --- /dev/null +++ b/crates/ide-clipboard/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "ide-clipboard" +description = "The OS clipboard's file list (Explorer <-> project view): Windows CF_HDROP read/write, no window or message loop" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs new file mode 100644 index 00000000..04dd050c --- /dev/null +++ b/crates/ide-clipboard/src/lib.rs @@ -0,0 +1,293 @@ +//! The file list on the OS clipboard: what Explorer copies when the user +//! copies files, so the project view can paste them, and what the project view +//! writes so Explorer can paste the files it copied (IDEA does both). +//! +//! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL +//! terminated list of absolute paths, wide when `fWide` is set. The data is set +//! in full rather than rendered on demand, so no window or message loop is +//! needed and the list survives after `CloseClipboard`. +//! +//! Other platforms keep file lists elsewhere (`public.file-url` on macOS, +//! `text/uri-list` on X11) and are not wired up: [`files`] is empty and +//! [`set_files`] fails there. + +use std::path::PathBuf; + +/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`. +const HEADER: usize = 20; + +/// The absolute paths on the clipboard, empty when it holds no file list. +pub fn files() -> Vec { + imp::files() +} + +/// Whether the clipboard holds a file list, without reading it. +pub fn has_files() -> bool { + imp::has_files() +} + +/// Replaces the clipboard with `paths`. False when it is busy or holds nothing +/// to write; the clipboard is a shared resource, so callers treat this as +/// best-effort. +pub fn set_files(paths: &[PathBuf]) -> bool { + if paths.is_empty() { + return false; + } + imp::set_files(paths) +} + +/// `DROPFILES` for `paths`, in the wide form so names outside the ANSI code +/// page survive. Empty paths are skipped. +pub fn encode(paths: &[PathBuf]) -> Vec { + let mut units: Vec = Vec::new(); + for path in paths { + let path = path.to_string_lossy(); + if path.is_empty() { + continue; + } + units.extend(path.encode_utf16()); + units.push(0); + } + // The list ends with an empty string: with one path, two NULs in a row. + units.push(0); + let mut out = Vec::with_capacity(HEADER + units.len() * 2); + out.extend_from_slice(&(HEADER as u32).to_le_bytes()); // pFiles: list starts after the header + out.extend_from_slice(&[0u8; 8]); // pt: the drop point, unused + out.extend_from_slice(&0u32.to_le_bytes()); // fNC: not a file manager drop + out.extend_from_slice(&1u32.to_le_bytes()); // fWide: UTF-16 paths + for unit in units { + out.extend_from_slice(&unit.to_le_bytes()); + } + out +} + +/// The paths of a `DROPFILES` block, stopping at the empty string that ends the +/// list. An ANSI list (pre-Unicode writers) is decoded as UTF-8, lossily. +pub fn decode(bytes: &[u8]) -> Vec { + if bytes.len() < HEADER { + return Vec::new(); + } + let offset = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; + let wide = u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]) != 0; + if offset >= bytes.len() { + return Vec::new(); + } + let body = &bytes[offset..]; + let mut out = Vec::new(); + if wide { + let mut current: Vec = Vec::new(); + for chunk in body.as_chunks::<2>().0 { + let unit = u16::from_le_bytes(*chunk); + if unit != 0 { + current.push(unit); + } else if current.is_empty() { + break; + } else { + out.push(PathBuf::from(String::from_utf16_lossy(¤t))); + current.clear(); + } + } + } else { + for part in body.split(|b| *b == 0) { + if part.is_empty() { + break; + } + out.push(PathBuf::from(String::from_utf8_lossy(part).into_owned())); + } + } + out +} + +#[cfg(windows)] +mod imp { + use super::{HEADER, decode, encode}; + use std::ffi::c_void; + use std::path::PathBuf; + use std::ptr; + use std::time::Duration; + + /// `CF_HDROP`, the clipboard format holding a file list. + const CF_HDROP: u32 = 15; + + /// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here. + type Handle = *mut c_void; + + const GMEM_MOVEABLE: u32 = 0x0002; + /// Another process holds the clipboard only while it copies; retrying beats + /// reporting "no files" for a paste the user just made. + const OPEN_ATTEMPTS: usize = 10; + const OPEN_RETRY: Duration = Duration::from_millis(20); + + #[link(name = "user32")] + unsafe extern "system" { + fn OpenClipboard(owner: Handle) -> i32; + fn CloseClipboard() -> i32; + fn EmptyClipboard() -> i32; + fn GetClipboardData(format: u32) -> Handle; + fn SetClipboardData(format: u32, memory: Handle) -> Handle; + fn IsClipboardFormatAvailable(format: u32) -> i32; + } + + #[link(name = "kernel32")] + unsafe extern "system" { + fn GlobalAlloc(flags: u32, bytes: usize) -> Handle; + fn GlobalLock(memory: Handle) -> *mut c_void; + fn GlobalUnlock(memory: Handle) -> i32; + fn GlobalFree(memory: Handle) -> Handle; + fn GlobalSize(memory: Handle) -> usize; + } + + /// Opens the clipboard as its owner: the list is written in full, so no + /// owning window is needed. + fn open() -> bool { + for _ in 0..OPEN_ATTEMPTS { + if unsafe { OpenClipboard(ptr::null_mut()) } != 0 { + return true; + } + std::thread::sleep(OPEN_RETRY); + } + false + } + + pub fn has_files() -> bool { + (unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0 + } + + pub fn files() -> Vec { + if !has_files() || !open() { + return Vec::new(); + } + let memory = unsafe { GetClipboardData(CF_HDROP) }; + let paths = if memory.is_null() { + Vec::new() + } else { + // SAFETY: the clipboard keeps the block alive while it is open, and + // `GlobalSize` bounds the bytes read from it. + unsafe { + let data = GlobalLock(memory); + if data.is_null() { + Vec::new() + } else { + let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory)); + let paths = decode(bytes); + GlobalUnlock(memory); + paths + } + } + }; + unsafe { CloseClipboard() }; + paths + } + + pub fn set_files(paths: &[PathBuf]) -> bool { + let bytes = encode(paths); + if bytes.len() <= HEADER { + return false; + } + // SAFETY: the block is allocated, filled, handed to the clipboard and + // freed only on the paths where the clipboard did not take ownership. + unsafe { + let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len()); + if memory.is_null() { + return false; + } + let data = GlobalLock(memory); + if data.is_null() { + GlobalFree(memory); + return false; + } + ptr::copy_nonoverlapping(bytes.as_ptr(), data as *mut u8, bytes.len()); + GlobalUnlock(memory); + if !open() { + GlobalFree(memory); + return false; + } + // Replacing the list also drops the text the user copied before. + if EmptyClipboard() == 0 { + CloseClipboard(); + GlobalFree(memory); + return false; + } + let taken = !SetClipboardData(CF_HDROP, memory).is_null(); + CloseClipboard(); + if !taken { + GlobalFree(memory); + } + taken + } + } +} + +#[cfg(not(windows))] +mod imp { + use std::path::PathBuf; + + pub fn has_files() -> bool { + false + } + + pub fn files() -> Vec { + Vec::new() + } + + pub fn set_files(_paths: &[PathBuf]) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn paths(list: &[&str]) -> Vec { + list.iter().map(PathBuf::from).collect() + } + + #[test] + fn a_written_list_reads_back_unchanged() { + let list = paths(&["C:/work/project/a.txt", "C:/work/project/sub"]); + assert_eq!(decode(&encode(&list)), list); + } + + #[test] + fn the_header_says_where_the_paths_start_and_that_they_are_wide() { + let bytes = encode(&paths(&["C:/a"])); + assert_eq!(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), HEADER as u32); + assert_eq!(u32::from_le_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]), 1); + // `C:/a` plus its NUL, then the empty string that ends the list. + assert_eq!(bytes.len(), HEADER + (4 + 1 + 1) * 2); + assert_eq!(&bytes[HEADER + 6..HEADER + 8], b"a\0"); + assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); + } + + #[test] + fn names_outside_the_ansi_code_page_survive() { + let list = paths(&["C:/工作/文档/测试.txt", "C:/a/"]); + assert_eq!(decode(&encode(&list)), list); + } + + #[test] + fn an_empty_list_has_no_paths() { + assert_eq!(decode(&encode(&[])), Vec::::new()); + // A lone terminator is enough for the reader to stop. + assert_eq!(decode(&[0u8; HEADER]), Vec::::new()); + } + + #[test] + fn a_truncated_or_foreign_block_reads_as_no_paths() { + assert_eq!(decode(&[]), Vec::::new()); + assert_eq!(decode(&[1, 2, 3]), Vec::::new()); + let mut far = encode(&paths(&["C:/a"])); + far[0] = 0xff; // pFiles past the end + assert_eq!(decode(&far), Vec::::new()); + } + + #[test] + fn an_ansi_list_is_still_read_before_the_terminator() { + let mut bytes = vec![0u8; HEADER]; + bytes[..4].copy_from_slice(&(HEADER as u32).to_le_bytes()); + bytes[16] = 0; // fWide: the list is single-byte + bytes.extend_from_slice(b"C:\\a\\b.txt\0C:\\c\0\0"); + assert_eq!(decode(&bytes), paths(&["C:\\a\\b.txt", "C:\\c"])); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 795196c0..e9f3d7f9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,7 @@ dirs.workspace = true ide-core.workspace = true dunce.workspace = true +ide-clipboard.workspace = true ide-text.workspace = true ide-vfs.workspace = true ide-workspace.workspace = true diff --git a/src-tauri/src/commands/clipboard.rs b/src-tauri/src/commands/clipboard.rs new file mode 100644 index 00000000..0cc2ce6f --- /dev/null +++ b/src-tauri/src/commands/clipboard.rs @@ -0,0 +1,32 @@ +//! The OS clipboard's file list, so the project view and Explorer exchange files. + +use crate::commands::doc::blocking; +use ide_core::IdeResult; +use std::path::{Path, PathBuf}; + +/// Absolute paths on the clipboard, empty when it holds no file list. +#[tauri::command] +#[specta::specta] +pub async fn clipboard_file_list() -> IdeResult> { + blocking(|| Ok(ide_clipboard::files().iter().map(|p| slash(p)).collect())).await +} + +/// Whether the clipboard holds files: cheap, for the Paste item's enabled state. +#[tauri::command] +#[specta::specta] +pub fn clipboard_has_files() -> bool { + ide_clipboard::has_files() +} + +/// Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort: +/// false when the clipboard stayed busy; the caller keeps working either way. +#[tauri::command] +#[specta::specta] +pub async fn clipboard_set_file_list(paths: Vec) -> IdeResult { + blocking(move || Ok(ide_clipboard::set_files(&paths))).await +} + +/// Paths cross the IPC boundary `/`-separated, as every other backend path does. +fn slash(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7247d0b8..19de6527 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod app; pub mod build; pub mod bytes; pub mod caches; +pub mod clipboard; pub mod coverage; pub mod db; pub mod debug; @@ -69,6 +70,9 @@ pub fn builder() -> tauri_specta::Builder { fs::fs_delete, fs::fs_copy, fs::fs_read_text, + clipboard::clipboard_file_list, + clipboard::clipboard_has_files, + clipboard::clipboard_set_file_list, doc::doc_open, doc::doc_open_virtual, doc::doc_close, diff --git a/src/bindings.ts b/src/bindings.ts index 6e5e9505..76160eb5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -80,6 +80,15 @@ export const commands = { * definition). Prefers the unsaved buffer when the file is open. */ fsReadText: (path: string) => typedError(__TAURI_INVOKE("fs_read_text", { path })), + /** Absolute paths on the clipboard, empty when it holds no file list. */ + clipboardFileList: () => typedError(__TAURI_INVOKE("clipboard_file_list")), + /** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */ + clipboardHasFiles: () => __TAURI_INVOKE("clipboard_has_files"), + /** + * Replaces the clipboard with `paths`, so Explorer can paste them. Best-effort: + * false when the clipboard stayed busy; the caller keeps working either way. + */ + clipboardSetFileList: (paths: string[]) => typedError(__TAURI_INVOKE("clipboard_set_file_list", { paths })), /** * Opens a document. Async: reading and decoding a large file must not block * the main thread (synchronous commands run on it). diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 4e6b1198..2770da91 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -4,6 +4,7 @@ import { emitLocalIdeEvent } from "@/platform/ipc"; import { getContext } from "@/platform/context"; import { provideEditorService, type EditorService } from "@/workbench/contributions"; import { activate } from "./index"; +import { setOsClipboardForTests } from "./clipboard"; import { MemoryFs, setProjectFsForTests } from "./fs"; import { MessagesHost } from "@/workbench/messages"; import { FileRefactorDialogHost } from "./FileRefactorDialogs"; @@ -71,6 +72,29 @@ describe("ProjectView", () => { await waitFor(() => expect(rows()).toEqual(["acme/w/acme", "build", "src", "App.kt", "Util.kt", "README.md"])); }); + it("pastes the files Explorer copied into the selected folder, as copies", async () => { + // What Explorer leaves on the clipboard: a path outside the project. + fs.nodes.set("/elsewhere/Photo.png", { kind: "file", size: 3 }); + setOsClipboardForTests({ + files: async () => ["/elsewhere/Photo.png"], + hasFiles: async () => true, + setFiles: async () => {}, + }); + try { + const { tree, row, rows } = setup(); + await waitFor(() => expect(rows()).toContain("README.md")); + fireEvent.mouseDown(row("src")); + tree.focus(); + // ⌘ is the primary modifier here, as in the undo test below. + fireEvent.keyDown(tree, { key: "v", code: "KeyV", metaKey: true }); + await waitFor(() => expect(fs.nodes.has("/w/acme/src/Photo.png")).toBe(true)); + // A copy, not a move: the dropped-from path stays where it was. + expect(fs.nodes.has("/elsewhere/Photo.png")).toBe(true); + } finally { + setOsClipboardForTests(null); + } + }); + it("opens files on double click and previews on single click only when enabled", async () => { const { row, rows } = setup(); await waitFor(() => expect(rows()).toContain("README.md")); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 891232f0..04cca951 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -59,6 +59,7 @@ import { openRenameDialog, openSelection, paste, + refreshOsClipboard, targetFolder, } from "./operations"; import { isInside, nameOf, parentOf, splitExtension, tildify } from "./paths"; @@ -504,6 +505,15 @@ export function ProjectView() { }; }, []); + // Files copied in Explorer keep Paste usable: the OS clipboard can change + // while this window is in the background, so re-ask whenever it comes back. + useEffect(() => { + void refreshOsClipboard(); + const refresh = () => void refreshOsClipboard(); + window.addEventListener("focus", refresh); + return () => window.removeEventListener("focus", refresh); + }, []); + const onKeyDown = (e: ReactKeyboardEvent) => { if ((e.target as HTMLElement).tagName === "INPUT") return; const stroke = strokeOf(e.nativeEvent); diff --git a/src/features/project-tree/clipboard.ts b/src/features/project-tree/clipboard.ts new file mode 100644 index 00000000..58283303 --- /dev/null +++ b/src/features/project-tree/clipboard.ts @@ -0,0 +1,57 @@ +/** + * The OS clipboard's file list: what Explorer copies, and what the project view + * puts there so Explorer can paste it back. The format is native (Windows + * `CF_HDROP`), so the backend owns it; a plain browser has nothing to exchange + * and every call degrades to "no files". + */ +import { call, commands, isTauri } from "@/platform/ipc"; + +export interface OsClipboard { + /** Files Explorer copied; empty when the clipboard holds none. */ + files(): Promise; + /** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */ + hasFiles(): Promise; + /** Puts `paths` there, so Explorer can paste them. */ + setFiles(paths: string[]): Promise; +} + +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 []; + } + }, + + async hasFiles() { + if (!isTauri()) return false; + try { + return await commands.clipboardHasFiles(); + } catch { + return false; + } + }, + + async setFiles(paths) { + if (!isTauri() || !paths.length) return; + try { + await call(commands.clipboardSetFileList(paths)); + } catch { + // The internal clipboard still works; a failed hand-off is not an error. + } + }, +}; + +let current = native; + +export function osClipboard(): OsClipboard { + return current; +} + +/** Tests: force a clipboard implementation. */ +export function setOsClipboardForTests(clipboard: OsClipboard | null) { + current = clipboard ?? native; +} diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index 7277429e..47793ff6 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -7,6 +7,7 @@ import { askUsagesDetected, fileUsages, viewUsages } from "@/editor/refactor"; import { executeAction, getAction } from "@/platform/actions"; import { maybeEditorService, notifications } from "@/workbench/contributions"; import { ask, confirmChecked } from "@/workbench/messages"; +import { osClipboard } from "./clipboard"; import { runFileRefactoring } from "./fileRefactor"; import { openFileRefactorDialog, rememberedOptions } from "./FileRefactorDialogs"; import { projectFs } from "./fs"; @@ -455,17 +456,36 @@ export async function deleteSelection(nodes: ProjectNode[] = selectedNodes()) { export function copySelection(cut: boolean) { const paths = topLevelPaths(selectedNodes().filter((n) => (cut ? mutable(n) : !n.root && !n.group))); - if (paths.length) setClipboard({ paths, cut }); + if (!paths.length) return; + setClipboard({ paths, cut }); + // Hand the files to the OS too, so Explorer can paste them (as IDEA does). + void osClipboard().setFiles(paths); +} + +/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */ +let osHasFiles = false; + +/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */ +export async function refreshOsClipboard() { + osHasFiles = await osClipboard().hasFiles(); } export function canPaste(): boolean { - return !!get().clipboard?.paths.length; + return !!get().clipboard?.paths.length || osHasFiles; } -/** Paste: cut items go through the Move dialog, copied ones through the Copy dialog (IDEA). */ +/** + * Paste: cut items go through the Move dialog, copied ones through the Copy + * dialog (IDEA). Files copied in Explorer paste as copies. + */ export async function paste(target = targetFolder()) { const { clipboard } = get(); - if (!clipboard || !target || isReadOnlyPath(target)) return; + if (!target || isReadOnlyPath(target)) return; + const external = await osClipboard().files(); + // 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; 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]!); if (!clipboard.cut) return openCopyDialogFor(clipboard.paths, target, single); @@ -476,6 +496,19 @@ 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"); +} + +/** The same files, as the clipboard keeps them (`/`-separated, any order). */ +function sameFiles(files: string[], paths: string[] | undefined): boolean { + if (!paths || files.length !== paths.length) return false; + const key = (list: string[]) => list.map((p) => (caseInsensitive ? p.toLowerCase() : p)).sort().join("\n"); + return key(files) === key(paths); +} + function openCopyDialogFor(paths: string[], target: string, dir?: boolean) { if (!paths.length) return; void loadDir(target);