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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user