fix(chat): a file-manager copy pasted on a local browser attaches by its own path

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).
This commit is contained in:
2026-10-22 13:47:17 +07:00
parent 57f02f0d89
commit 8d2765313c
4 changed files with 99 additions and 18 deletions
+10
View File
@@ -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
+2 -15
View File
@@ -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;
+44 -1
View File
@@ -3,7 +3,7 @@
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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<string, string>) =>
({ 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();
});
});
+43 -2
View File
@@ -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ĩ <vinnt@smartgift.vn>
@@ -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