дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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
Цей коміт міститься в:
1 джерело
e150101244
коміт
eda8cae0a1
12 files changed
+481
-4
No files matched your search
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача