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.
This commit is contained in:
l0ng-ai
2026-11-01 08:32:33 +08:00
committed by GitHub
parent 4395975664
commit 0458424f9c
8 changed files with 327 additions and 44 deletions
+28 -20
View File
@@ -22,7 +22,8 @@ use std::path::PathBuf;
const HEADER: usize = 20;
/// The absolute paths on the clipboard, empty when it holds no file list.
pub fn files() -> Vec<PathBuf> {
/// Read failures remain errors so callers can retain an internal copy.
pub fn files() -> std::io::Result<Vec<PathBuf>> {
imp::files()
}
@@ -31,9 +32,8 @@ 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.
/// Replaces the clipboard with `paths`. True only when the file list was
/// accepted; writing just the accompanying text is not a successful hand-off.
pub fn set_files(paths: &[PathBuf]) -> bool {
if paths.is_empty() {
return false;
@@ -160,25 +160,29 @@ mod imp {
(unsafe { IsClipboardFormatAvailable(CF_HDROP) }) != 0
}
pub fn files() -> Vec<PathBuf> {
if !has_files() || !open() {
return Vec::new();
pub fn files() -> std::io::Result<Vec<PathBuf>> {
if !open() {
return Err(std::io::Error::last_os_error());
}
if !has_files() {
unsafe { CloseClipboard() };
return Ok(Vec::new());
}
let memory = unsafe { GetClipboardData(CF_HDROP) };
let paths = if memory.is_null() {
Vec::new()
Err(std::io::Error::last_os_error())
} 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()
Err(std::io::Error::last_os_error())
} else {
let bytes = std::slice::from_raw_parts(data as *const u8, GlobalSize(memory));
let paths = decode(bytes);
GlobalUnlock(memory);
paths
Ok(paths)
}
}
};
@@ -241,7 +245,7 @@ mod imp {
GlobalFree(memory);
return false;
}
let mut taken = !SetClipboardData(CF_HDROP, memory).is_null();
let taken = !SetClipboardData(CF_HDROP, memory).is_null();
if !taken {
GlobalFree(memory);
}
@@ -249,7 +253,6 @@ mod imp {
if !text_memory.is_null() {
// The text alone still pastes somewhere useful.
let text_taken = !SetClipboardData(CF_UNICODETEXT, text_memory).is_null();
taken |= text_taken;
if !text_taken {
GlobalFree(text_memory);
}
@@ -296,19 +299,23 @@ mod imp {
})
}
pub fn files() -> Vec<PathBuf> {
pub fn files() -> std::io::Result<Vec<PathBuf>> {
read_file_urls(|board, classes, options| {
// SAFETY: as in `has_files`; every object read for `NSURL` is one.
let Some(objects) = (unsafe { board.readObjectsForClasses_options(classes, Some(options)) }) else {
return Vec::new();
return if unsafe { board.canReadObjectForClasses_options(classes, Some(options)) } {
Err(std::io::Error::other("Cannot read clipboard files"))
} else {
Ok(Vec::new())
};
};
objects
Ok(objects
.iter()
.filter_map(|object| object.downcast::<NSURL>().ok())
.filter_map(|url| url.to_file_path())
// Finder ends a folder's URL in `/`; the project view names it without.
.map(|path| path.components().collect())
.collect()
.collect())
})
}
@@ -329,11 +336,12 @@ mod imp {
// the paths' own text is set back with it, so an editor paste
// inserts them (IDEA writes the list and the text together).
board.clearContents();
board.writeObjects(&NSArray::from_slice(&writers));
let written = board.writeObjects(&NSArray::from_slice(&writers));
let text = NSString::from_str(
&paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::<Vec<_>>().join("\n"),
);
board.setString_forType(&text, unsafe { NSPasteboardTypeString })
board.setString_forType(&text, unsafe { NSPasteboardTypeString });
written
})
}
}
@@ -346,8 +354,8 @@ mod imp {
false
}
pub fn files() -> Vec<PathBuf> {
Vec::new()
pub fn files() -> std::io::Result<Vec<PathBuf>> {
Ok(Vec::new())
}
pub fn set_files(_paths: &[PathBuf]) -> bool {
+1 -1
View File
@@ -8,7 +8,7 @@ use std::path::{Path, PathBuf};
#[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
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.
+36 -2
View File
@@ -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"));
+1 -2
View File
@@ -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();
+5 -4
View File
@@ -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;
}
},
};
+218 -7
View File
@@ -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);
});
});
+34 -8
View File
@@ -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;
+4
View File
@@ -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 {