дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
fix(project): expire stale file clipboard after OS replacement (#598)
Project Paste could reuse an earlier file copy after another app replaced the OS clipboard, and Cut files stayed dimmed. The tree now tracks the hand-off to the OS clipboard and rechecks it on window focus and just before Paste. A local Copy or Cut is cleared once the OS holds a different or empty file list; failed writes and clipboard read errors keep it. The native adapters now distinguish read failures from empty lists. In remote windows, Copy and Cut write newline-joined server paths as plain text, tree Paste keeps the server source, and a later OS file list replaces the remote copy and imports those client files. A plain-text replacement stays undetectable there without a clipboard read that may prompt. Verified on macOS, locally and against a remote host. The Windows clipboard code was not run.
Цей коміт міститься в:
1 джерело
4395975664
коміт
0458424f9c
8 files changed
+327
-44
No files matched your search
@@ -10,7 +10,7 @@ import { MessagesHost } from "@/workbench/messages";
|
||||
import { FileRefactorDialogHost } from "./FileRefactorDialogs";
|
||||
import { setRefactorBackendForTests } from "./fileRefactor";
|
||||
import { ProjectView } from "./ProjectView";
|
||||
import { beginNew } from "./operations";
|
||||
import { beginNew, canPaste } from "./operations";
|
||||
import { initProjectTree, setViewOption, useProjectTree } from "./store";
|
||||
import type { OutlineItem } from "@/bindings";
|
||||
import { _emitEditorEvent } from "@/editor/api";
|
||||
@@ -110,7 +110,7 @@ describe("ProjectView", () => {
|
||||
setOsClipboardForTests({
|
||||
files: async () => ["/elsewhere/Photo.png"],
|
||||
hasFiles: async () => true,
|
||||
setFiles: async () => {},
|
||||
setFiles: async () => false,
|
||||
});
|
||||
try {
|
||||
const { tree, row, rows } = setup();
|
||||
@@ -127,6 +127,40 @@ describe("ProjectView", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("clears cut dimming and disables Paste on focus after the OS clipboard becomes text", async () => {
|
||||
let files: string[] = [];
|
||||
setOsClipboardForTests({
|
||||
files: async () => files,
|
||||
hasFiles: async () => files.length > 0,
|
||||
setFiles: async (paths) => {
|
||||
files = paths;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { tree, row, rows } = setup();
|
||||
await waitFor(() => expect(rows()).toContain("README.md"));
|
||||
fireEvent.mouseDown(row("README.md"));
|
||||
tree.focus();
|
||||
fireEvent.keyDown(tree, { key: "x", code: "KeyX", metaKey: true });
|
||||
const content = row("README.md").querySelector("[data-project-path]")!;
|
||||
expect(content.className).toMatch(/cut/);
|
||||
await act(async () => {
|
||||
await useProjectTree.getState().clipboard?.osWrite;
|
||||
});
|
||||
files = [];
|
||||
fireEvent.focus(window);
|
||||
await waitFor(() => expect(content.className).not.toMatch(/cut/));
|
||||
expect(useProjectTree.getState().clipboard).toBeNull();
|
||||
expect(canPaste()).toBe(false);
|
||||
fireEvent.keyDown(tree, { key: "v", code: "KeyV", metaKey: true });
|
||||
expect(screen.queryByRole("dialog", { name: "Copy" })).toBeNull();
|
||||
expect(screen.queryByRole("dialog", { name: "Move" })).toBeNull();
|
||||
} 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"));
|
||||
|
||||
@@ -654,8 +654,7 @@ 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.
|
||||
// Reconcile external files and expired Copy/Cut state when returning from another app.
|
||||
useEffect(() => {
|
||||
void refreshOsClipboard();
|
||||
const refresh = () => void refreshOsClipboard();
|
||||
|
||||
@@ -11,8 +11,8 @@ export interface OsClipboard {
|
||||
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>;
|
||||
/** True only when the OS accepted the file list; false for unsupported hosts. */
|
||||
setFiles(paths: string[]): Promise<boolean>;
|
||||
}
|
||||
|
||||
const native: OsClipboard = {
|
||||
@@ -31,11 +31,12 @@ const native: OsClipboard = {
|
||||
},
|
||||
|
||||
async setFiles(paths) {
|
||||
if (!isTauri() || !paths.length) return;
|
||||
if (!isTauri() || !paths.length) return false;
|
||||
try {
|
||||
await call(commands.clipboardSetFileList(paths));
|
||||
return await call(commands.clipboardSetFileList(paths));
|
||||
} catch {
|
||||
// The internal clipboard still works; a failed hand-off is not an error.
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MEMBER_PREFIX, type ProjectNode } from "./model";
|
||||
import { importExternal, paste, selectionAfterDelete, transfer } from "./operations";
|
||||
import {
|
||||
canPaste,
|
||||
copySelection,
|
||||
importExternal,
|
||||
paste,
|
||||
refreshOsClipboard,
|
||||
selectionAfterDelete,
|
||||
transfer,
|
||||
} from "./operations";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { pendingFileRefactorDialog } from "./FileRefactorDialogs";
|
||||
import { closeFileRefactorDialog, pendingFileRefactorDialog } from "./FileRefactorDialogs";
|
||||
import { useRemote } from "@/platform/remote";
|
||||
import { provideNotificationService } from "@/workbench/contributions";
|
||||
import { MemoryFs, setProjectFsForTests } from "./fs";
|
||||
import { initProjectTree, loadDir, setClipboard, setSelection } from "./store";
|
||||
import { initProjectTree, loadDir, setClipboard, setSelection, useProjectTree } from "./store";
|
||||
import { setOsClipboardForTests } from "./clipboard";
|
||||
|
||||
let fs: MemoryFs;
|
||||
@@ -62,7 +70,7 @@ describe("OS file imports", () => {
|
||||
|
||||
it("does not paste into the project root when a member row is selected", async () => {
|
||||
const files = vi.fn(async () => ["/outside/local.txt"]);
|
||||
setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => {} });
|
||||
setOsClipboardForTests({ files, hasFiles: async () => true, setFiles: async () => false });
|
||||
const imported = vi.spyOn(fs, "importLocal");
|
||||
const key = `${MEMBER_PREFIX}/p/local.txt\0function:main`;
|
||||
setSelection(new Set([key]), key);
|
||||
@@ -78,7 +86,7 @@ describe("OS file imports", () => {
|
||||
setOsClipboardForTests({
|
||||
files: async () => ["/p/local.txt"],
|
||||
hasFiles: async () => true,
|
||||
setFiles: async () => {},
|
||||
setFiles: async () => false,
|
||||
});
|
||||
const imported = vi.spyOn(fs, "importLocal");
|
||||
for (let i = 0; i < 2; i++) {
|
||||
@@ -109,7 +117,7 @@ describe("OS file imports", () => {
|
||||
throw new Error("clipboard unavailable");
|
||||
},
|
||||
hasFiles: async () => true,
|
||||
setFiles: async () => {},
|
||||
setFiles: async () => false,
|
||||
});
|
||||
await paste("/p/target");
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
@@ -123,7 +131,7 @@ describe("OS file imports", () => {
|
||||
throw new Error("busy");
|
||||
},
|
||||
hasFiles: async () => false,
|
||||
setFiles: async () => {},
|
||||
setFiles: async () => false,
|
||||
});
|
||||
setClipboard({ paths: ["/p/local.txt"], cut });
|
||||
await paste("/p/target");
|
||||
@@ -156,3 +164,206 @@ describe("OS file imports", () => {
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("project clipboard ownership", () => {
|
||||
beforeEach(async () => {
|
||||
fs = new MemoryFs({ "/p/target": null, "/p/source.txt": "source", "/other.txt": "other" }, "/p");
|
||||
setProjectFsForTests(fs, "/p");
|
||||
initProjectTree("/p", "p");
|
||||
await loadDir("/p");
|
||||
setSelection(new Set(["/p/source.txt"]), "/p/source.txt");
|
||||
closeFileRefactorDialog();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
useRemote.setState({ session: null });
|
||||
setProjectFsForTests(null);
|
||||
setOsClipboardForTests(null);
|
||||
closeFileRefactorDialog();
|
||||
});
|
||||
|
||||
const cases = [false, true].flatMap((handedOff) =>
|
||||
["same", "other", "empty", "error"].flatMap((osList) =>
|
||||
[false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))),
|
||||
),
|
||||
);
|
||||
|
||||
it.each(cases)("hand-off=$handedOff OS=$osList cut=$cut on $entry", async ({ handedOff, osList, cut, entry }) => {
|
||||
const files = vi.fn(async () => {
|
||||
if (osList === "error") throw new Error("busy");
|
||||
return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : [];
|
||||
});
|
||||
const setFiles = vi.fn(async () => handedOff);
|
||||
setOsClipboardForTests({ files, hasFiles: async () => osList === "same" || osList === "other", setFiles });
|
||||
copySelection(cut);
|
||||
const clipboard = useProjectTree.getState().clipboard;
|
||||
expect(clipboard).toEqual(expect.objectContaining({ paths: ["/p/source.txt"], cut }));
|
||||
expect(setFiles).toHaveBeenCalledWith(["/p/source.txt"]);
|
||||
const stale = handedOff && (osList === "other" || osList === "empty");
|
||||
if (entry === "focus") await refreshOsClipboard();
|
||||
else await paste("/p/target");
|
||||
expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard);
|
||||
expect(canPaste()).toBe(!(handedOff && osList === "empty"));
|
||||
// canPaste only consults cached state, even when called repeatedly by menus.
|
||||
canPaste();
|
||||
expect(files).toHaveBeenCalledTimes(1);
|
||||
if (entry === "paste") {
|
||||
if (osList === "other") {
|
||||
expect(fs.nodes.has("/p/target/other.txt")).toBe(true);
|
||||
expect(fs.nodes.has("/p/target/source.txt")).toBe(false);
|
||||
expect(pendingFileRefactorDialog()).toBeNull();
|
||||
} else if (stale) {
|
||||
expect(pendingFileRefactorDialog()).toBeNull();
|
||||
expect(fs.nodes.has("/p/target/source.txt")).toBe(false);
|
||||
} else {
|
||||
expect(pendingFileRefactorDialog()).toEqual(
|
||||
expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] }),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps the internal clipboard after a rejected hand-off (cut=%s)", async (cut) => {
|
||||
setOsClipboardForTests({
|
||||
files: async () => [],
|
||||
hasFiles: async () => false,
|
||||
setFiles: async () => {
|
||||
throw new Error("unavailable");
|
||||
},
|
||||
});
|
||||
copySelection(cut);
|
||||
await refreshOsClipboard();
|
||||
await paste("/p/target");
|
||||
expect(pendingFileRefactorDialog()?.kind).toBe(cut ? "move" : "copy");
|
||||
});
|
||||
|
||||
const remoteCases = [false, true].flatMap((handedOff) =>
|
||||
["same", "other", "empty", "error"].flatMap((osList) =>
|
||||
[false, true].flatMap((cut) => ["focus", "paste"].map((entry) => ({ handedOff, osList, cut, entry }))),
|
||||
),
|
||||
);
|
||||
|
||||
it.each(remoteCases)(
|
||||
"remote hand-off=$handedOff OS=$osList cut=$cut on $entry",
|
||||
async ({ handedOff, osList, cut, entry }) => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
const writeText = vi.fn(async () => {
|
||||
if (!handedOff) throw new Error("unavailable");
|
||||
});
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const setFiles = vi.fn(async () => true);
|
||||
const files = vi.fn(async () => {
|
||||
if (osList === "error") throw new Error("busy");
|
||||
return osList === "same" ? ["/p/source.txt"] : osList === "other" ? ["/other.txt"] : [];
|
||||
});
|
||||
setOsClipboardForTests({ files, hasFiles: async () => false, setFiles });
|
||||
copySelection(cut);
|
||||
const clipboard = useProjectTree.getState().clipboard;
|
||||
const stale = handedOff && (osList === "same" || osList === "other");
|
||||
const copied = vi.spyOn(fs, "copy");
|
||||
const imported = vi.spyOn(fs, "importLocal").mockResolvedValue([]);
|
||||
if (entry === "focus") await refreshOsClipboard();
|
||||
else await paste("/p/target");
|
||||
expect(writeText).toHaveBeenCalledWith("/p/source.txt");
|
||||
expect(setFiles).not.toHaveBeenCalled();
|
||||
expect(files).toHaveBeenCalledTimes(1);
|
||||
expect(useProjectTree.getState().clipboard).toBe(stale ? null : clipboard);
|
||||
expect(canPaste()).toBe(true);
|
||||
if (entry === "paste") {
|
||||
if (stale) {
|
||||
expect(imported).toHaveBeenCalledWith(
|
||||
osList === "same" ? "/p/source.txt" : "/other.txt",
|
||||
osList === "same" ? "/p/target/source.txt" : "/p/target/other.txt",
|
||||
expect.anything(),
|
||||
);
|
||||
expect(pendingFileRefactorDialog()).toBeNull();
|
||||
expect(copied).not.toHaveBeenCalled();
|
||||
} else {
|
||||
const dialog = pendingFileRefactorDialog()!;
|
||||
expect(dialog).toEqual(expect.objectContaining({ kind: cut ? "move" : "copy", paths: ["/p/source.txt"] }));
|
||||
if (dialog.kind === "copy") {
|
||||
await dialog.copy("/p/target", null, false);
|
||||
expect(copied).toHaveBeenCalledWith("/p/source.txt", "/p/target/source.txt");
|
||||
}
|
||||
expect(imported).not.toHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("writes multiple remote paths as newline-joined text", async () => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
await fs.createFile("/p/second.txt");
|
||||
await loadDir("/p", true);
|
||||
setSelection(new Set(["/p/source.txt", "/p/second.txt"]), "/p/source.txt");
|
||||
const writeText = vi.fn(async () => {});
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
copySelection(false);
|
||||
expect(await useProjectTree.getState().clipboard?.osWrite).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith("/p/source.txt\n/p/second.txt");
|
||||
});
|
||||
|
||||
it("waits for a pending remote text write before reading file URLs", async () => {
|
||||
useRemote.setState({ session: { sshConfig: "ssh", root: "/p", host: "host", commands: ["fs_copy"] } });
|
||||
let finish!: () => void;
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: {
|
||||
writeText: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
},
|
||||
});
|
||||
const files = vi.fn(async () => []);
|
||||
setOsClipboardForTests({ files, hasFiles: async () => false, setFiles: async () => true });
|
||||
copySelection(false);
|
||||
const refreshing = refreshOsClipboard();
|
||||
await Promise.resolve();
|
||||
expect(files).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await refreshing;
|
||||
expect(useProjectTree.getState().clipboard?.remote).toBe(true);
|
||||
});
|
||||
|
||||
it("waits for the hand-off before reading the OS clipboard", async () => {
|
||||
let finish!: (written: boolean) => void;
|
||||
const files = vi.fn(async () => ["/p/source.txt"]);
|
||||
setOsClipboardForTests({
|
||||
files,
|
||||
hasFiles: async () => false,
|
||||
setFiles: () =>
|
||||
new Promise((r) => {
|
||||
finish = r;
|
||||
}),
|
||||
});
|
||||
copySelection(true);
|
||||
const refreshing = refreshOsClipboard();
|
||||
await Promise.resolve();
|
||||
expect(files).not.toHaveBeenCalled();
|
||||
finish(true);
|
||||
await refreshing;
|
||||
expect(useProjectTree.getState().clipboard?.cut).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a stale read that completes after a newer Copy", async () => {
|
||||
let finish!: (paths: string[]) => void;
|
||||
setOsClipboardForTests({
|
||||
files: () =>
|
||||
new Promise((r) => {
|
||||
finish = r;
|
||||
}),
|
||||
hasFiles: async () => true,
|
||||
setFiles: async () => true,
|
||||
});
|
||||
copySelection(true);
|
||||
const refreshing = refreshOsClipboard();
|
||||
await vi.waitFor(() => expect(finish).toBeTypeOf("function"));
|
||||
copySelection(false);
|
||||
const newer = useProjectTree.getState().clipboard;
|
||||
finish([]);
|
||||
await refreshing;
|
||||
expect(useProjectTree.getState().clipboard).toBe(newer);
|
||||
expect(canPaste()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -479,9 +479,14 @@ 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) return;
|
||||
setClipboard({ paths, cut });
|
||||
// Hand the files to the OS too, so Explorer can paste them (as IDEA does).
|
||||
void osClipboard().setFiles(paths);
|
||||
// Remote paths are useful as text, but do not name files on the client.
|
||||
const remote = isRemoteWindow();
|
||||
const osWrite = (async () => {
|
||||
if (!remote) return osClipboard().setFiles(paths);
|
||||
await navigator.clipboard.writeText(paths.join("\n"));
|
||||
return true;
|
||||
})().catch(() => false);
|
||||
setClipboard({ paths, cut, remote, osWrite });
|
||||
}
|
||||
|
||||
/** Files on the OS clipboard, cached: `canPaste` runs while a menu renders. */
|
||||
@@ -489,7 +494,25 @@ let osHasFiles = false;
|
||||
|
||||
/** Keeps Paste enabled for what Explorer copied; called when the window gains focus. */
|
||||
export async function refreshOsClipboard() {
|
||||
osHasFiles = await osClipboard().hasFiles();
|
||||
try {
|
||||
await readOsClipboard();
|
||||
} catch {
|
||||
// A busy clipboard is not evidence that the user's copy has changed.
|
||||
}
|
||||
}
|
||||
|
||||
async function readOsClipboard(): Promise<string[] | null> {
|
||||
const { clipboard } = get();
|
||||
const handedOff = await clipboard?.osWrite;
|
||||
const external = await osClipboard().files();
|
||||
// A newer Copy/Cut owns the state if this read (or its hand-off) was delayed.
|
||||
if (get().clipboard !== clipboard) return null;
|
||||
osHasFiles = external.length > 0;
|
||||
// Our remote text write removes file URLs, so any files were copied later.
|
||||
// A failed write leaves ownership uncertain; retain the remote copy then.
|
||||
if (handedOff && (clipboard?.remote ? external.length > 0 : !sameFiles(external, clipboard?.paths)))
|
||||
setClipboard(null);
|
||||
return external;
|
||||
}
|
||||
|
||||
export function canPaste(): boolean {
|
||||
@@ -501,20 +524,23 @@ export function canPaste(): boolean {
|
||||
* dialog (IDEA). Files copied in Explorer paste as copies.
|
||||
*/
|
||||
export async function paste(target = targetFolder()) {
|
||||
const { clipboard } = get();
|
||||
if (!target || isReadOnlyPath(target)) return;
|
||||
let external: string[] = [];
|
||||
try {
|
||||
external = await osClipboard().files();
|
||||
const files = await readOsClipboard();
|
||||
if (files === null) return;
|
||||
external = files;
|
||||
} catch (e) {
|
||||
if (!clipboard?.paths.length) {
|
||||
if (!get().clipboard?.paths.length) {
|
||||
fail("Cannot access the clipboard", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { clipboard } = get();
|
||||
// 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?.remote && external.length && !sameFiles(external, clipboard?.paths))
|
||||
return pasteFromOsClipboard(external, target);
|
||||
if (!clipboard?.paths.length) {
|
||||
notifications().notify({ level: "info", title: "No files on the clipboard" });
|
||||
return;
|
||||
|
||||
@@ -87,6 +87,10 @@ export type Editing =
|
||||
export interface Clipboard {
|
||||
paths: string[];
|
||||
cut: boolean;
|
||||
/** Server paths cannot be represented by the client's OS file list. */
|
||||
remote?: boolean;
|
||||
/** Pending OS hand-off, so a read cannot invalidate this copy before its write finishes. */
|
||||
osWrite?: Promise<boolean>;
|
||||
}
|
||||
|
||||
interface ProjectTreeState {
|
||||
|
||||
Reference in new issue
Заблокувати користувача