From 8d2765313cad88cf42dbd562491fa5179852e9d0 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 22 Oct 2026 13:47:17 +0700 Subject: [PATCH] fix(chat): a file-manager copy pasted on a local browser attaches by its own path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome gives a PASTE of a file manager's copy (Ctrl+C in Nautilus, Ctrl+V in the chat) the files and their paths as plain text, but no text/uri-list — only a drag carries that — so localCandidates found no path and every such file was uploaded, and refused past the upload limit. Plain-text paths (or file:// lines) whose names match the pasted files one for one are now candidates too; sameFilesHere still has the server confirm each is the same file (size) on this machine, so server mode and remote browsers upload as before. The prompt box also attaches a file:// text paste with no file by path, as the terminal already did (copiedFilePaths moved to promptAttach). --- client/src/components/run/RunSetup.tsx | 10 +++++ client/src/components/run/TerminalView.tsx | 17 +------ client/src/lib/__tests__/promptAttach.test.ts | 45 ++++++++++++++++++- client/src/lib/promptAttach.ts | 45 ++++++++++++++++++- 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index 3a28ac5a..111402f9 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -70,6 +70,7 @@ import { ATTACH_MIME, addAttachments, claudeMention, + copiedFilePaths, droppedAttachments, droppedSkill, droppedText, @@ -452,6 +453,14 @@ export function PromptEditor({ }); return true; }; + /** A file manager's copy pasted with no file beside it, only `file://` + * text: attached by those paths, as the terminal pastes them. */ + const pastedLocalFiles = (dt: DataTransfer | null | undefined) => { + const paths = isLocalBrowser() ? copiedFilePaths(dt) : null; + if (!paths) return false; + onImagesChange((prev) => addAttachments(prev, paths.map(localAttachment))); + return true; + }; /** Dropped or pasted files: by their own paths when the browser is on the * dashboard's machine and the server finds the same files there (a file * manager hands over `file://` paths beside the bytes), else uploaded. */ @@ -675,6 +684,7 @@ export function PromptEditor({ onKeyDown={handleKeyDown} onPaste={(e) => { if (attachDropped(e.clipboardData, e.clipboardData?.files)) e.preventDefault(); + else if (pastedLocalFiles(e.clipboardData)) e.preventDefault(); else { // A very long paste goes in as a text file, like Claude Desktop: // the prompt then carries its path, never the text (#68: a CLI's diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index e1728efa..38529fe7 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -65,14 +65,10 @@ import { useTheme } from "../../hooks/useTheme"; import { terminalThemeFor } from "../../lib/terminalTheme"; import { api } from "../../lib/api"; import { writeClipboard } from "../../lib/clipboard"; -import { - MENU_CURSOR_ROW, - notePaneFocus, - notePaneOutput, - registerPane, -} from "../../lib/panePaste"; +import { MENU_CURSOR_ROW, notePaneFocus, notePaneOutput, registerPane } from "../../lib/panePaste"; import { claudePastes, + copiedFilePaths, droppedFilePaths, isLocalBrowser, localCandidates, @@ -160,15 +156,6 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi const hasText = (e: DragEvent) => !hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain"); -/** The `file://` paths a paste carries as text — a file manager's copy — or - * null: its uri-list, else its plain text when every line is one. */ -function copiedFilePaths(dt: DataTransfer | null): string[] | null { - const text = dt?.getData("text/plain") ?? ""; - if (droppedFilePaths(dt)) return droppedFilePaths(dt); - if (!/^file:\/\//.test(text.trim())) return null; - return droppedFilePaths({ getData: () => text } as unknown as DataTransfer); -} - interface TerminalViewProps { runId: string; wsBaseUrl: string; diff --git a/client/src/lib/__tests__/promptAttach.test.ts b/client/src/lib/__tests__/promptAttach.test.ts index 07d2beaa..67ff01a4 100644 --- a/client/src/lib/__tests__/promptAttach.test.ts +++ b/client/src/lib/__tests__/promptAttach.test.ts @@ -3,7 +3,7 @@ * @author Nguyễn Ngọc Trí Vĩ */ import { describe, expect, it } from "vitest"; -import { claudePastes } from "../promptAttach"; +import { claudePastes, copiedFilePaths, localCandidates } from "../promptAttach"; describe("claudePastes", () => { it("pastes each image as its bare path and the other files as one @ mention paste", () => { @@ -18,3 +18,46 @@ describe("claudePastes", () => { ]); }); }); + +/** A clipboard as the browser hands it to a paste handler. */ +const clip = (data: Record) => + ({ getData: (t: string) => data[t] ?? "" }) as unknown as DataTransfer; +const file = (name: string) => new File(["x"], name); + +describe("localCandidates (a file manager's copy, pasted)", () => { + it("takes the plain-text paths when their names match the pasted files", () => { + expect(window.location.hostname).toBe("localhost"); + const dt = clip({ "text/plain": "/home/u/Videos/big.mp4\n/home/u/b c.pdf\n" }); + expect(localCandidates(dt, [file("b c.pdf"), file("big.mp4")])).toEqual([ + "/home/u/Videos/big.mp4", + "/home/u/b c.pdf", + ]); + }); + + it("takes file:// lines too, decoded", () => { + const dt = clip({ "text/plain": "file:///home/u/a%20b.zip" }); + expect(localCandidates(dt, [file("a b.zip")])).toEqual(["/home/u/a b.zip"]); + }); + + it("refuses text that does not name exactly the pasted files", () => { + expect(localCandidates(clip({ "text/plain": "/etc/passwd" }), [file("big.mp4")])).toBeNull(); + expect(localCandidates(clip({ "text/plain": "hello" }), [file("hello")])).toBeNull(); + expect( + localCandidates(clip({ "text/plain": "/a/x.txt" }), [file("x.txt"), file("y.txt")]) + ).toBeNull(); + }); + + it("prefers a uri-list (a drag) as before", () => { + const dt = clip({ "text/uri-list": "file:///tmp/z.bin", "text/plain": "/other/z.bin" }); + expect(localCandidates(dt, [file("z.bin")])).toEqual(["/tmp/z.bin"]); + }); +}); + +describe("copiedFilePaths (no file beside the text)", () => { + it("takes file:// text, never bare absolute paths, which stay text to paste", () => { + expect(copiedFilePaths(clip({ "text/plain": "file:///home/u/a.txt" }))).toEqual([ + "/home/u/a.txt", + ]); + expect(copiedFilePaths(clip({ "text/plain": "/home/u/a.txt" }))).toBeNull(); + }); +}); diff --git a/client/src/lib/promptAttach.ts b/client/src/lib/promptAttach.ts index 5e10e36d..1fd862d2 100644 --- a/client/src/lib/promptAttach.ts +++ b/client/src/lib/promptAttach.ts @@ -7,7 +7,8 @@ * last day (`recentUploads`, kept in this browser), and `claudeMention`, the * `@` form a file is handed to `claude` in, and what a browser on the * dashboard's own machine attaches by path instead of uploading - * (`localCandidates`, checked by the server in `sameFilesHere` — + * (`localCandidates` — a drag's uri-list, or the plain-text paths a pasted + * file-manager copy carries —, checked by the server in `sameFilesHere` — * `POST /api/run/local-paths` —, `localAttachment`). An attachment is a path * sent with the prompt; that check is the one call here to the server. * @author Nguyễn Ngọc Trí Vĩ @@ -85,7 +86,47 @@ export function localCandidates( ): string[] | null { if (!isLocalBrowser()) return null; const paths = droppedFilePaths(dt); - return paths && paths.length === files.length ? paths : null; + if (paths) return paths.length === files.length ? paths : null; + // A PASTE of a file manager's copy has no uri-list: Chrome hands over the + // files and the paths as plain text only. Kept only when the names match + // the files one for one — `sameFilesHere` then checks the sizes. + const copied = textFilePaths(dt); + if (!copied || copied.length !== files.length) return null; + const base = (p: string) => p.split(/[\\/]/).pop() ?? ""; + const want = files.map((f) => f.name).sort(); + const have = copied.map(base).sort(); + return have.every((n, i) => n === want[i]) ? copied : null; +} + +/** A paste's plain text as absolute paths — every non-empty line a `file://` + * URI or an absolute path (a file manager's copy) — else null. */ +function textFilePaths(dt: DataTransfer | null | undefined): string[] | null { + let lines: string[]; + try { + lines = (dt?.getData("text/plain") ?? "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean); + } catch { + return null; + } + if (lines.length === 0) return null; + if (lines.every((l) => l.startsWith("file://"))) { + return droppedFilePaths({ getData: () => lines.join("\n") } as unknown as DataTransfer); + } + return lines.every((l) => /^(\/|[A-Za-z]:[\\/])/.test(l)) ? lines : null; +} + +/** The `file://` paths a paste carries as text with no file beside it — a + * file manager's copy, as some browsers paste it — or null: its uri-list, + * else its plain text when every line is one. Plain absolute paths are NOT + * taken here: with no file to compare against, they are text to paste. */ +export function copiedFilePaths(dt: DataTransfer | null | undefined): string[] | null { + const uris = droppedFilePaths(dt); + if (uris) return uris; + const text = dt?.getData("text/plain") ?? ""; + if (!/^file:\/\//.test(text.trim())) return null; + return textFilePaths(dt); } /** Whether `paths` are the dropped `files` as the dashboard's machine sees