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
Цей коміт міститься в:
kevinh authored and GitHub committed 2026-10-27 10:38:45 +08:00
1 джерело e150101244
коміт eda8cae0a1
12 files changed
+481 -4

No files matched your search

+24
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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
Переглянути файл
@@ -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);