mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 16:08:05 +00: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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Block a user