From 95b45c095d87d8787f22e74c30baa1e09f5e2883 Mon Sep 17 00:00:00 2001 From: arookieofc <2128194521@qq.com> Date: Sun, 1 Nov 2026 00:13:18 +0800 Subject: [PATCH] fix(project): paste copied files into editors and terminals as paths (#576) Copying files in the project view wrote only the file list to the OS clipboard, so pasting into an editor did nothing. The copy now also writes the newline-separated paths as text, which other applications can paste. WKWebView hides that text when file URLs are present and delivers a paste event with only Files, so the IDE editor and terminal still received nothing. A shared helper now reads the OS file list when a paste has files but no text, and inserts the paths. It covers Cmd+V and context-menu Paste in the editor, the terminal and embedded terminals. Markdown image paste runs first and keeps its behaviour. Verified on macOS. The Windows clipboard change was tested by the contributor at the clipboard level only. Refs #573 Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-clipboard/src/lib.rs | 64 +++++++++++++++++--- src/editor/core/cm/clipboard.test.ts | 84 ++++++++++++++++++++++++++ src/editor/core/cm/clipboard.ts | 29 +++++++++ src/editor/core/cm/setup.ts | 2 + src/editor/core/controller.ts | 14 +---- src/features/terminal/TerminalView.tsx | 8 +-- src/features/terminal/embedded.ts | 3 + src/features/terminal/paste.test.ts | 80 ++++++++++++++++++++++++ src/features/terminal/paste.ts | 24 ++++++++ src/features/terminal/session.ts | 8 +-- src/platform/clipboard.test.ts | 62 +++++++++++++++++++ src/platform/clipboard.ts | 29 +++++++++ 12 files changed, 376 insertions(+), 31 deletions(-) create mode 100644 src/editor/core/cm/clipboard.test.ts create mode 100644 src/editor/core/cm/clipboard.ts create mode 100644 src/features/terminal/paste.test.ts create mode 100644 src/features/terminal/paste.ts create mode 100644 src/platform/clipboard.test.ts create mode 100644 src/platform/clipboard.ts diff --git a/crates/ide-clipboard/src/lib.rs b/crates/ide-clipboard/src/lib.rs index 1c64c30b7..05bc3590d 100644 --- a/crates/ide-clipboard/src/lib.rs +++ b/crates/ide-clipboard/src/lib.rs @@ -1,6 +1,8 @@ //! The file list on the OS clipboard: what Explorer copies when the user //! copies files, so the project view can paste them, and what the project view -//! writes so Explorer can paste the files it copied (IDEA does both). +//! writes so Explorer can paste the files it copied (IDEA does both). The +//! written list comes with the paths as text, so a paste into an editor +//! inserts them instead of doing nothing. //! //! Windows keeps it as `CF_HDROP`: a `DROPFILES` header and a double-NUL //! terminated list of absolute paths, wide when `fWide` is set. The data is set @@ -111,6 +113,8 @@ mod imp { /// `CF_HDROP`, the clipboard format holding a file list. const CF_HDROP: u32 = 15; + /// `CF_UNICODETEXT`, so an editor paste of copied files inserts the paths. + const CF_UNICODETEXT: u32 = 13; /// An `HGLOBAL` or a `HANDLE`: both are opaque pointers here. type Handle = *mut c_void; @@ -182,12 +186,37 @@ mod imp { paths } + /// An `HGLOBAL` holding `text` as UTF-16 with a terminating NUL, and its + /// byte size. Null when the allocation failed. + fn global_text(text: &str) -> (Handle, usize) { + let mut units: Vec = text.encode_utf16().collect(); + units.push(0); + let bytes = units.len() * 2; + unsafe { + let memory = GlobalAlloc(GMEM_MOVEABLE, bytes); + if memory.is_null() { + return (ptr::null_mut(), 0); + } + let data = GlobalLock(memory); + if data.is_null() { + GlobalFree(memory); + return (ptr::null_mut(), 0); + } + ptr::copy_nonoverlapping(units.as_ptr() as *const u8, data as *mut u8, bytes); + GlobalUnlock(memory); + (memory, bytes) + } + } + pub fn set_files(paths: &[PathBuf]) -> bool { let bytes = encode(paths); if bytes.len() <= HEADER { return false; } - // SAFETY: the block is allocated, filled, handed to the clipboard and + // The paths as text, newline-separated, so pasting into an editor + // inserts them (IDEA writes the list and the text together). + let text = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\r\n"); + // SAFETY: the blocks are allocated, filled, handed to the clipboard and // freed only on the paths where the clipboard did not take ownership. unsafe { let memory = GlobalAlloc(GMEM_MOVEABLE, bytes.len()); @@ -205,17 +234,27 @@ mod imp { GlobalFree(memory); return false; } - // Replacing the list also drops the text the user copied before. + // Replacing the list also drops the text the user copied before; + // the paths' own text is set back with it. if EmptyClipboard() == 0 { CloseClipboard(); GlobalFree(memory); return false; } - let taken = !SetClipboardData(CF_HDROP, memory).is_null(); - CloseClipboard(); + let mut taken = !SetClipboardData(CF_HDROP, memory).is_null(); if !taken { GlobalFree(memory); } + let (text_memory, _) = global_text(&text); + 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); + } + } + CloseClipboard(); taken } } @@ -227,9 +266,10 @@ mod imp { use objc2::rc::{Retained, autoreleasepool}; use objc2::runtime::{AnyClass, AnyObject, ProtocolObject}; use objc2_app_kit::{ - NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardURLReadingFileURLsOnlyKey, NSPasteboardWriting, + NSPasteboard, NSPasteboardReadingOptionKey, NSPasteboardTypeString, NSPasteboardURLReadingFileURLsOnlyKey, + NSPasteboardWriting, }; - use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSURL}; + use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSURL}; use std::path::PathBuf; /// `NSURL` as the one class to read, limited to file URLs: a web link the @@ -285,9 +325,15 @@ mod imp { let writers: Vec<&ProtocolObject> = urls.iter().map(|url| ProtocolObject::from_ref(&**url)).collect(); let board = NSPasteboard::generalPasteboard(); - // Replacing the list also drops the text the user copied before. + // Replacing the list also drops the text the user copied before; + // 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)) + board.writeObjects(&NSArray::from_slice(&writers)); + let text = NSString::from_str( + &paths.iter().map(|p| p.to_string_lossy().into_owned()).collect::>().join("\n"), + ); + board.setString_forType(&text, unsafe { NSPasteboardTypeString }) }) } } diff --git a/src/editor/core/cm/clipboard.test.ts b/src/editor/core/cm/clipboard.test.ts new file mode 100644 index 000000000..19b32b961 --- /dev/null +++ b/src/editor/core/cm/clipboard.test.ts @@ -0,0 +1,84 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { commands } from "@/platform/ipc"; +import { clipboardFilesExtension, pasteEditorClipboard } from "./clipboard"; +import { markdownImages } from "./markdownImages"; + +const views: EditorView[] = []; +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + views.splice(0).forEach((view) => view.destroy()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +for (const route of ["event", "menu"]) { + it.each([ + { name: "text present", text: "plain", paths: ["/stale"], expected: "plain", calls: 0 }, + { name: "files plus list", text: "", paths: ["/a b", "/中文"], expected: "/a b\n/中文", calls: 1 }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, + ])(`${route}: $name`, async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + const view = new EditorView({ extensions: [clipboardFilesExtension], parent: document.body }); + views.push(view); + if (route === "event") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => text, files: [], types: ["Files"] }, + }); + view.contentDOM.dispatchEvent(event); + } else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + await pasteEditorClipboard(view); + } + await vi.waitFor(() => expect(view.state.doc.toString()).toBe(expected)); + expect(list).toHaveBeenCalledTimes(calls); + }); +} + +it.each([true, false])("keeps copied PNG and screenshot Markdown image handling (file: %s)", async (hasFile) => { + vi.stubGlobal( + "Worker", + class { + onmessage?: (e: { data: { data: number[] } }) => void; + terminate() {} + postMessage() { + queueMicrotask(() => this.onmessage?.({ data: { data: [137, 80, 78, 71] } })); + } + }, + ); + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ + status: "ok", + data: hasFile ? ["/source/image.png"] : [], + }); + const create = vi.spyOn(commands, "fsCreateFileBytes").mockResolvedValue({ status: "ok", data: null }); + const view = new EditorView({ + extensions: [ + clipboardFilesExtension, + markdownImages({ path: "/p/a.md", folder: "assets", pattern: "image-{timestamp}" }), + ], + parent: document.body, + }); + views.push(view); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => "", files: [new File(["png"], "image.png", { type: "image/png" })], types: ["Files"] }, + }); + view.contentDOM.dispatchEvent(event); + await vi.waitFor(() => expect(view.state.doc.toString()).toMatch(/^!\[\]\(assets\/image.*\.png\)$/)); + expect(create.mock.calls[0]![0]).toMatch(hasFile ? /assets\/image\.png$/ : /assets\/image-\d{8}-\d{6}\.png$/); +}); + +it("does not insert into a read-only editor", async () => { + const list = vi.spyOn(commands, "clipboardFileList"); + const view = new EditorView({ extensions: [clipboardFilesExtension, EditorState.readOnly.of(true)] }); + views.push(view); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "text" } }); + await pasteEditorClipboard(view); + expect(view.state.doc.toString()).toBe(""); + expect(list).not.toHaveBeenCalled(); +}); diff --git a/src/editor/core/cm/clipboard.ts b/src/editor/core/cm/clipboard.ts new file mode 100644 index 000000000..34bc3eaa7 --- /dev/null +++ b/src/editor/core/cm/clipboard.ts @@ -0,0 +1,29 @@ +import { Prec } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; + +export function pasteEditorText(view: EditorView, text: string) { + if (!text || !view.dom.isConnected || view.state.readOnly) return; + view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); + view.focus(); +} + +export async function pasteEditorClipboard(view: EditorView) { + try { + pasteEditorText(view, await readClipboardText()); + } catch { + if (!view.dom.isConnected || view.state.readOnly) return; + view.focus(); + document.execCommand("paste"); + } +} + +// Markdown image handlers get first refusal, including copied image files. +export const clipboardFilesExtension = Prec.lowest( + EditorView.domEventHandlers({ + paste(event, view) { + if (view.state.readOnly) return false; + return pasteClipboardFiles(event, (text) => pasteEditorText(view, text)); + }, + }), +); diff --git a/src/editor/core/cm/setup.ts b/src/editor/core/cm/setup.ts index 152930b45..87f761815 100644 --- a/src/editor/core/cm/setup.ts +++ b/src/editor/core/cm/setup.ts @@ -51,6 +51,7 @@ import { editorBaseKeymap } from "./baseKeymap"; import { occurrenceSearch } from "./editing"; import { gutterLineDrag } from "./gutterDrag"; import { overwriteField } from "./overwrite"; +import { clipboardFilesExtension } from "./clipboard"; import { clipboardHistoryExtension } from "../clipboardHistory"; import type { LanguageInfo } from "../backend"; import { tx } from "@/platform/i18n"; @@ -336,6 +337,7 @@ export function baseExtensions(o: SetupOptions): Extension { overwriteField, gutterLineDrag, clipboardHistoryExtension, + clipboardFilesExtension, indentOnInput(), syntaxHighlighting(openideaHighlightStyle), syntaxHighlightField, diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index cab61a97f..abc244eb5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -3,6 +3,7 @@ * status, navigation history, autosave. The React views and actions talk to * this singleton; other features use the EditorService and editor/api. */ +import { pasteEditorClipboard } from "./cm/clipboard"; import { EditorSelection } from "@codemirror/state"; import { create } from "zustand"; import { setContext } from "@/platform/context"; @@ -641,18 +642,7 @@ export class EditorController implements SlotHost { icon: "paste", shortcut: "⌘V", disabled: view.state.readOnly, - run: () => { - void navigator.clipboard - ?.readText() - .then((text) => { - view.dispatch(view.state.replaceSelection(text), { userEvent: "input.paste", scrollIntoView: true }); - view.focus(); - }) - .catch(() => { - view.focus(); - document.execCommand("paste"); - }); - }, + run: () => void pasteEditorClipboard(view), }, { kind: "separator" }, { diff --git a/src/features/terminal/TerminalView.tsx b/src/features/terminal/TerminalView.tsx index 6b35883ab..f9868e1a4 100644 --- a/src/features/terminal/TerminalView.tsx +++ b/src/features/terminal/TerminalView.tsx @@ -31,6 +31,7 @@ import { type PaneLayout, type TerminalTab, } from "./store"; +import { pasteTerminalClipboard } from "./paste"; import { TerminalFindBar } from "./FindBar"; import styles from "./Terminal.module.css"; @@ -102,12 +103,7 @@ function paneMenu(e: MouseEvent, key: string) { { label: "Paste", shortcut: mac ? "⌘V" : "Ctrl+Shift+V", - run: () => { - void navigator.clipboard - ?.readText() - .then((t) => t && session.paste(t)) - .catch(() => undefined); - }, + run: () => void pasteTerminalClipboard((text) => session.paste(text)), }, { label: "Select All", run: () => session.term.selectAll() }, { kind: "separator" }, diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index 4a1b5cad4..1fed7649f 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -4,6 +4,7 @@ * and size go to the owner instead of a terminal PTY (a Run console emulating * a terminal feeds its process). */ +import { installTerminalPaste } from "./paste"; import { Terminal, type IDisposable } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; @@ -83,6 +84,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal }, }), ]; + const removePaste = installTerminalPaste(host, (text) => term.paste(text)); term.open(host); if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]); @@ -109,6 +111,7 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal fitter.cancel(); processResize.cancel(); observer.disconnect(); + removePaste(); for (const d of disposables) d.dispose(); live.delete(term); term.dispose(); diff --git a/src/features/terminal/paste.test.ts b/src/features/terminal/paste.test.ts new file mode 100644 index 000000000..27dad6f53 --- /dev/null +++ b/src/features/terminal/paste.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { Terminal } from "@xterm/xterm"; +import { commands } from "@/platform/ipc"; +import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; + +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +for (const route of ["event", "menu"]) { + it.each([ + { name: "text present", text: "plain", paths: ["/stale"], expected: "plain", calls: 0 }, + { name: "files plus list", text: "", paths: ["/a b", "/中文"], expected: "/a b\n/中文", calls: 1 }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, + ])(`${route}: $name`, async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + const host = document.createElement("div"); + const textarea = document.createElement("textarea"); + host.append(textarea); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + // Model xterm's bubble listener: file recovery must preempt its empty paste. + textarea.addEventListener("paste", (event) => + paste((event as ClipboardEvent).clipboardData!.getData("text/plain")), + ); + if (route === "event") { + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: { getData: () => text, files: [], types: ["Files"] } }); + textarea.dispatchEvent(event); + expect(event.defaultPrevented).toBe(!text); + } else { + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + await pasteTerminalClipboard(paste); + } + await vi.waitFor(() => expect(list).toHaveBeenCalledTimes(calls)); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(paste.mock.calls).toEqual(expected ? [[expected]] : []); + remove(); + }); +} + +it("ignores clipboard resolution after the terminal is disposed", async () => { + let finish!: (value: { status: "ok"; data: string[] }) => void; + vi.spyOn(commands, "clipboardFileList").mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const host = document.createElement("div"); + const paste = vi.fn(); + const remove = installTerminalPaste(host, paste); + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { value: { getData: () => "", files: [], types: ["Files"] } }); + host.dispatchEvent(event); + remove(); + finish({ status: "ok", data: ["/late"] }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(paste).not.toHaveBeenCalled(); +}); + +it("passes recovered multiline paths through xterm bracketed paste", async () => { + vi.spyOn(commands, "clipboardFileList").mockResolvedValue({ status: "ok", data: ["/a b", "/中文"] }); + const term = new Terminal({ allowProposedApi: true }); + const data: string[] = []; + term.onData((text) => data.push(text)); + try { + await new Promise((resolve) => term.write("\x1b[?2004h", resolve)); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "" } }); + await pasteTerminalClipboard((text) => term.paste(text)); + expect(data).toEqual(["\x1b[200~/a b\r/中文\x1b[201~"]); + } finally { + term.dispose(); + } +}); diff --git a/src/features/terminal/paste.ts b/src/features/terminal/paste.ts new file mode 100644 index 000000000..2ce06e9ac --- /dev/null +++ b/src/features/terminal/paste.ts @@ -0,0 +1,24 @@ +import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard"; + +export function installTerminalPaste(host: HTMLElement, paste: (text: string) => void): () => void { + let alive = true; + const onPaste = (event: ClipboardEvent) => { + pasteClipboardFiles(event, (text) => { + if (alive) paste(text); + }); + }; + host.addEventListener("paste", onPaste, true); + return () => { + alive = false; + host.removeEventListener("paste", onPaste, true); + }; +} + +export async function pasteTerminalClipboard(paste: (text: string) => void) { + try { + const text = await readClipboardText(); + if (text) paste(text); + } catch { + /* Clipboard access may be denied. */ + } +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 955f14af5..ace17df1e 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -11,6 +11,7 @@ import { Channel } from "@tauri-apps/api/core"; import { call, commands, isTauri, type TerminalId } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { fitScheduler, settledResize } from "./fit"; +import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; import { ImeCommitGate } from "./imeCommit"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; @@ -275,16 +276,14 @@ export class TerminalSession { if (atBottom !== this.snapshot.follow) this.update({ follow: atBottom }); }), ); + this.disposables.push({ dispose: installTerminalPaste(this.host, (text) => this.paste(text)) }); this.host.addEventListener("mousedown", this.onMiddleClick); } private onMiddleClick = (e: MouseEvent) => { if (e.button !== 1 || !workflowSettings().terminal.pasteOnMiddleClick) return; e.preventDefault(); - void navigator.clipboard - ?.readText() - .then((t) => t && this.paste(t)) - .catch(() => undefined); + void pasteTerminalClipboard((text) => this.paste(text)); }; /** Applies Settings › Tools › Terminal changes to a live session. */ @@ -552,6 +551,7 @@ export class TerminalSession { } paste(text: string) { + if (this.disposed) return; this.term.paste(text); } diff --git a/src/platform/clipboard.test.ts b/src/platform/clipboard.test.ts new file mode 100644 index 000000000..9f19f7414 --- /dev/null +++ b/src/platform/clipboard.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { commands } from "./ipc"; +import { pasteClipboardFiles, readClipboardText } from "./clipboard"; + +beforeEach(() => vi.stubGlobal("__TAURI_INTERNALS__", {})); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function transfer(text: string, files = true): DataTransfer { + return { getData: () => text, files: [], types: files ? ["Files"] : ["text/plain"] } as unknown as DataTransfer; +} + +const cases = [ + { name: "text present", text: "plain text", paths: ["/stale"], expected: "plain text", calls: 0 }, + { + name: "files plus list", + text: "", + paths: ["/a b", "/中文", "C:\\folder"], + expected: "/a b\n/中文\nC:\\folder", + calls: 1, + }, + { name: "files without list", text: "", paths: [], expected: "", calls: 1 }, + { name: "backend error", text: "", paths: null, expected: "", calls: 1 }, +]; + +describe("clipboard text", () => { + it.each(cases)("$name", async ({ text, paths, expected, calls }) => { + const list = vi.spyOn(commands, "clipboardFileList"); + if (paths) list.mockResolvedValue({ status: "ok", data: paths }); + else list.mockRejectedValue(new Error("busy")); + expect(await readClipboardText(transfer(text))).toBe(expected); + expect(list).toHaveBeenCalledTimes(calls); + list.mockClear(); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => text } }); + expect(await readClipboardText()).toBe(expected); + expect(list).toHaveBeenCalledTimes(calls); + }); + + it("does not consult files for an empty text-only event or a browser", async () => { + const list = vi.spyOn(commands, "clipboardFileList"); + expect(await readClipboardText(transfer("", false))).toBe(""); + vi.unstubAllGlobals(); + expect(await readClipboardText(transfer(""))).toBe(""); + expect(list).not.toHaveBeenCalled(); + }); + + it("does not intercept text, already handled images, or an absent transfer", () => { + const paste = vi.fn(); + for (const data of [transfer("text"), null]) { + const event = new Event("paste", { cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { value: data }); + expect(pasteClipboardFiles(event, paste)).toBe(false); + } + const event = new Event("paste", { cancelable: true }) as ClipboardEvent; + Object.defineProperty(event, "clipboardData", { value: transfer("") }); + event.preventDefault(); + expect(pasteClipboardFiles(event, paste)).toBe(false); + expect(paste).not.toHaveBeenCalled(); + }); +}); diff --git a/src/platform/clipboard.ts b/src/platform/clipboard.ts new file mode 100644 index 000000000..850a235f2 --- /dev/null +++ b/src/platform/clipboard.ts @@ -0,0 +1,29 @@ +import { call, commands, isTauri } from "./ipc"; + +/** WebKit exposes native file URLs as Files, hiding the pasteboard's text flavor. */ +export async function readClipboardText(transfer?: DataTransfer | null): Promise { + const text = transfer ? transfer.getData("text/plain") : ((await navigator.clipboard?.readText()) ?? ""); + if (text || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text; + try { + return (await call(commands.clipboardFileList())).join("\n"); + } catch { + return ""; + } +} + +function hasClipboardFiles(transfer: DataTransfer): boolean { + return !!transfer.files.length || Array.from(transfer.types).includes("Files"); +} + +/** Claim synchronously so the native handler cannot emit an empty paste while IPC runs. */ +export function pasteClipboardFiles(event: ClipboardEvent, paste: (text: string) => void): boolean { + const transfer = event.clipboardData; + if (event.defaultPrevented || !transfer || transfer.getData("text/plain") || !hasClipboardFiles(transfer)) + return false; + event.preventDefault(); + event.stopImmediatePropagation(); + void readClipboardText(transfer).then((text) => { + if (text) paste(text); + }); + return true; +}