Keep uploaded terminal file paths separate (#8946)

* M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths

* M2: terminal dictation: mic in tab-bar slot and key bar, recording pill and overlay, per-terminal recording lifetime incl. pending mic acquisition, Cmd+D on macOS by keyboard focus, i18n

* M2: review fixes: retry attempt guard, cancellable native pending start, measured terminal slot width, compact-web Cmd+D for terminal dictation

* fix(terminal): separate uploaded file paths when pasting

Keep the remote file-drop regression and its test coverage from the contributor change, without introducing new terminal controls. Each uploaded path needs a trailing separator because adjacent quoted paths are one shell word.

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>

---------

Co-authored-by: Sebastian Arena <sebastian@walkofcode.io>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-11-06 18:57:29 +00:00
committed by GitHub
co-authored by Sebastian Arena
parent 79f2db8d56
commit 9f68af0274
3 changed files with 43 additions and 5 deletions
@@ -45,6 +45,11 @@ process.stdin.on("data", (chunk) => {
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
const pasted = JSON.stringify(paste[1]).replace(
/\s/g,
(space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"),
);
process.stdout.write("PASTED:" + pasted + "\r\n");
for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) {
const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex");
const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local";
@@ -202,6 +207,21 @@ test.describe("Terminal protocol queries", () => {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
// Every bracketed paste body the receiver consumed, in order and joined: what a
// shell would read as one command line.
async function pastedText(terminalId: string): Promise<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +258,23 @@ test.describe("Terminal protocol queries", () => {
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote-twice");
const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) };
const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") };
try {
await dropFiles(page, [first]);
await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload"));
await dropFiles(page, [second]);
await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload"));
await expectSeparatePastedPaths(terminal.id);
} finally {
await harness.killTerminal(terminal.id);
}
@@ -74,7 +74,8 @@ describe("terminal file paste queue", () => {
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]);
expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' ");
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
@@ -85,7 +86,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']);
expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']);
});
it("uploads under a file name that needs no shell quoting", async () => {
@@ -120,7 +121,7 @@ describe("terminal file paste queue", () => {
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/b.png' "]);
});
it("reports unreadable and oversized files without uploading them", async () => {
@@ -166,7 +167,7 @@ describe("terminal file paste queue", () => {
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(host.pasted).toEqual(["'/host/uploads/next.png' "]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
@@ -75,7 +75,7 @@ export class TerminalFilePasteQueue {
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(prepareTerminalPath(path));
target.paste(`${prepareTerminalPath(path)} `);
this.events.onUploaded();
}
} catch (error) {