feat(project-tree): Exchange files with the OS clipboard on Windows (#41)

Exchange files with the OS clipboard on Windows: CF_HDROP read/write in a new ide-clipboard crate (Win32 FFI behind cfg(windows), stub elsewhere), three thin commands, and project-view copy/paste hooks. Pasting from Explorer always copies; our own hand-off falls back to the internal clipboard so Copy/Move dialogs and refactoring stay intact.
Verified: ide-clipboard round-trip tests, project-tree vitest (93), CI on all three OSes. Not verified on Windows hardware here.

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
kevinh
2026-10-27 10:38:45 +08:00
committed by GitHub
parent e150101244
commit eda8cae0a1
12 changed files with 481 additions and 4 deletions
Generated
+5
View File
@@ -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",
+1
View File
@@ -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"] }
+8
View File
@@ -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]
+293
View File
@@ -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<PathBuf> {
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<u8> {
let mut units: Vec<u16> = 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<PathBuf> {
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<u16> = 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(&current)));
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<PathBuf> {
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<PathBuf> {
Vec::new()
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn paths(list: &[&str]) -> Vec<PathBuf> {
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::<PathBuf>::new());
// A lone terminator is enough for the reader to stop.
assert_eq!(decode(&[0u8; HEADER]), Vec::<PathBuf>::new());
}
#[test]
fn a_truncated_or_foreign_block_reads_as_no_paths() {
assert_eq!(decode(&[]), Vec::<PathBuf>::new());
assert_eq!(decode(&[1, 2, 3]), Vec::<PathBuf>::new());
let mut far = encode(&paths(&["C:/a"]));
far[0] = 0xff; // pFiles past the end
assert_eq!(decode(&far), Vec::<PathBuf>::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"]));
}
}
+1
View File
@@ -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
+32
View File
@@ -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<Vec<String>> {
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<PathBuf>) -> IdeResult<bool> {
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('\\', "/")
}
+4
View File
@@ -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<tauri::Wry> {
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,
+9
View File
@@ -80,6 +80,15 @@ export const commands = {
* definition). Prefers the unsaved buffer when the file is open.
*/
fsReadText: (path: string) => typedError<string, IdeError>(__TAURI_INVOKE("fs_read_text", { path })),
/** Absolute paths on the clipboard, empty when it holds no file list. */
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
clipboardHasFiles: () => __TAURI_INVOKE<boolean>("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<boolean, IdeError>(__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).
@@ -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"));
+10
View File
@@ -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<HTMLDivElement>) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
const stroke = strokeOf(e.nativeEvent);
+57
View File
@@ -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<string[]>;
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
hasFiles(): Promise<boolean>;
/** Puts `paths` there, so Explorer can paste them. */
setFiles(paths: string[]): Promise<void>;
}
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;
}
+37 -4
View File
@@ -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);