feat(terminal): support image paste into agent TUIs, local and remote (#3795)

* feat(terminal): support image paste into agent TUIs, local and remote (#3725)

Codex and OpenCode receive image pastes by binding Ctrl+V (\x16) and
reading the OS clipboard of the machine they run on; no image bytes
travel through the pty. Paseo's terminal swallowed that keystroke and
only ever pasted text, so image paste silently did nothing.

- web/Electron runtime: forward \x16 when the clipboard holds no text,
  keep the existing text-paste path otherwise (bash quoted-insert stays
  intact)
- new terminal.clipboard.write_image RPC ships client clipboard bytes to
  the daemon, which writes them onto its host clipboard (osascript /
  wl-copy / xclip / Set-Clipboard) so remote clients work like local
  ones; gated by server_info.features.terminalClipboardImage with a
  bare-\x16 fallback for older daemons
- clipboard tools are waited on via their exit event, not stream close:
  wl-copy and xclip fork a selection owner that inherits our pipes, so
  waiting for stream closure hung every Linux write until timeout
- headless hosts without a clipboard get a temp-file fallback: the
  daemon materializes the image under os.tmpdir() and returns its path;
  the client injects it as bracket-pasted text
- all four renderers covered (web DOM, Electron, native grid, WebView
  bridge); mobile Paste button enables for image clipboards and toasts
  failures in all 10 locales

* fix(terminal): bind image paste to private retained files

* fix(terminal): recover interrupted image metadata safely

* fix(terminal): retire image files with background lifecycle maintenance

* fix(terminal): check live paste modes and refresh native bridge

* fix(terminal): order image paste input and isolate failures

* fix(terminal): normalize paste and release failed creation reservations

* fix(terminal): validate missing Windows launch paths before spawn

* fix(terminal): keep input live during cancellable image uploads

* test(terminal): follow streamed input-mode updates

* feat(terminal): upload dropped images through the paste flow

* feat(terminal): paste and drop images through file uploads

Terminal image paste and drop now reuse the existing upload path
(client.uploadFile -> file.upload.request -> FileUploadStore) and paste
the quoted host path through the pane's input, bracketed when the app
asked for it. This replaces the PR's daemon-side image store, RPCs,
paste permits and file lifecycle, so the daemon, protocol and client
diffs for this feature are empty.

- terminal/image-paste owns a per-pane serial queue: gesture order,
  keystrokes stay live, Ctrl+C and pane/connection teardown cancel
  pending pastes, and a cancelled path is never pasted.
- Clipboard text wins over an image; any image/* type is accepted up
  to the composer's 50MB upload limit.
- Host paths are quoted for the host OS by path shape.
- One failure toast; i18n keeps two keys.
- Browser e2e covers two panes pasting distinct PNGs, drop, ordering,
  Ctrl+C cancel and disconnect against a real daemon.

Co-authored-by: Gerard Balaoro <miogerard@yahoo.com>

* fix(terminal): translate the disconnected image upload error

* feat(terminal): report image uploads through toasts

Show a lasting "Uploading image…" toast while an image uploads, replace
it with "Image uploaded" or a failure toast that carries the reason, and
dismiss it when Ctrl+C cancels the upload. Toast show now returns a dismiss
scoped to that toast, so the pane can clear its own toast without
removing a newer one.

* build(terminal): regenerate the WebView HTML for the toast events

* fix(terminal): keep i18n out of the terminal WebView bundle

The image paste queue imported i18n for its size-limit message, which pulled
every translation into the WebView HTML. The queue now reports a typed
TerminalImageTooLargeError and the pane words it.

* refactor(terminal): route dropped files by host locality and share the drop overlay

A dropped file pastes its own path only when the host is the desktop app's
own daemon (useIsLocalDaemon); anywhere else the host cannot read the
client's disk, so the file uploads through client.uploadFile and the host
path is pasted. File type no longer plays a part: images and other files
take the same route, and clipboard images always upload because they have
no path.

The terminal pane now sits in the shared FileDropZone, so dragging files
over it shows the agent panels' "Drop files here" overlay. The emulator's
own drag listeners, blue tint and 180 ms timeout are gone.
FileDropSink.onFiles is optional so a sink can take raw files without
persisting raster images as attachments.

terminal/image-paste becomes terminal/file-paste; the queue, callbacks,
toasts and i18n keys say "file".

* test(terminal): keep the fake desktop bridge in an e2e support helper

The CI contract test forbids browser specs from installing paseoDesktop
directly; the local-host drop scenario now imports it from support.

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Gerard Balaoro
2026-10-22 17:40:01 +07:00
committed by GitHub
co-authored by Mohamed Boudra
parent 4ef4a95a42
commit 9d908d678b
42 changed files with 1356 additions and 348 deletions
+3 -1
View File
@@ -415,7 +415,9 @@ jobs:
run: npm run build:app-deps
- name: Run app unit tests
run: npm run test --workspace=@getpaseo/app
run: |
npm run check:terminal-webview --workspace=@getpaseo/app
npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
@@ -1,6 +1,10 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { holdFileUploadResponse } from "../support/helpers/file-upload-gate";
import { installLocalDesktopHost } from "../support/helpers/local-desktop-host";
import { TerminalE2EHarness } from "../support/helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "../support/helpers/terminal-perf";
@@ -26,12 +30,125 @@ process.stdin.on("data", (chunk) => {
setTimeout(finish, 700);
`;
// A TUI stand-in: turns on bracketed paste, reads every quoted path in each
// bracketed paste, and echoes typed lines, so the PTY output proves what reached
// the terminal and where the file was read from.
const FILE_RECEIVER_SCRIPT = String.raw`
const fs = require("node:fs");
const crypto = require("node:crypto");
let buffer = "";
process.stdin.setRawMode(true);
process.stdin.resume();
process.stdout.write("\x1b[?2004hFILE_RECEIVER_READY\r\n");
process.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer);
if (!paste) break;
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";
process.stdout.write("FILE_READ:" + digest + ":" + location + "\r\n");
}
buffer = buffer.slice(0, paste.index) + buffer.slice(paste.index + paste[0].length);
}
if (buffer.includes("\x03")) {
process.stdout.write("INTERRUPTED\r\n");
buffer = buffer.replaceAll("\x03", "");
}
const enter = buffer.indexOf("\r");
if (enter >= 0 && !buffer.includes("'")) {
process.stdout.write("TYPED:" + buffer.slice(0, enter) + "\r\n");
buffer = buffer.slice(enter + 1);
}
});
`;
function pngBytes(seed: number): Buffer {
return Buffer.concat([Buffer.from("iVBORw0KGgo=", "base64"), Buffer.from([seed])]);
}
function fileRead(bytes: Buffer, location: "upload" | "local"): string {
const digest = createHash("sha256").update(bytes).digest("hex");
return `FILE_READ:${digest}:${location}`;
}
function terminalInput(page: Page) {
return page.locator('[data-testid="terminal-surface"] .xterm-helper-textarea');
}
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
await terminalInput(page).evaluate(
(element, data) => {
(element as HTMLTextAreaElement).focus();
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([new Uint8Array(data)], "image.png", { type: "image/png" }));
element.dispatchEvent(
new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }),
);
},
[...bytes],
);
}
interface DroppedFile {
name: string;
type: string;
bytes: Buffer;
}
async function dragTransfer(page: Page, files: DroppedFile[]) {
return await page.evaluateHandle(
(entries) => {
const dataTransfer = new DataTransfer();
for (const entry of entries) {
dataTransfer.items.add(
new File([new Uint8Array(entry.bytes)], entry.name, { type: entry.type }),
);
}
return dataTransfer;
},
files.map((file) => ({ ...file, bytes: [...file.bytes] })),
);
}
async function dragFilesOver(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("dragenter", { dataTransfer });
await terminalInput(page).dispatchEvent("dragover", { dataTransfer });
await dataTransfer.dispose();
}
async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
const dataTransfer = await dragTransfer(page, files);
await terminalInput(page).dispatchEvent("drop", { dataTransfer });
await dataTransfer.dispose();
}
/** The shared "Drop files here" overlay of the zone around the terminal. */
function terminalDropOverlay(page: Page) {
return page
.getByTestId("terminal-surface")
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
.getByTestId("file-drop-backdrop");
}
/**
* Makes the app treat the e2e daemon as the desktop app's own daemon, so dropped
* files resolve to paths inside `directory` instead of uploading.
*/
async function typeLine(page: Page, text: string): Promise<void> {
await terminalInput(page).pressSequentially(text);
await terminalInput(page).press("Enter");
}
test.describe("Terminal protocol queries", () => {
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-protocol-query-" });
await writeFile(path.join(harness.tempRepo.path, "osc11-capture.cjs"), OSC11_CAPTURE_SCRIPT);
await writeFile(path.join(harness.tempRepo.path, "file-receiver.cjs"), FILE_RECEIVER_SCRIPT);
});
test.afterAll(async () => {
@@ -58,4 +175,156 @@ test.describe("Terminal protocol queries", () => {
await harness.killTerminal(terminalInstance.id);
}
});
async function openFileReceiver(page: Page, name: string) {
const terminal = await harness.createTerminal({
name,
command: process.execPath,
args: ["file-receiver.cjs"],
});
await harness.openTerminal(page, { terminalId: terminal.id });
await expectTerminalOutput(terminal.id, "FILE_RECEIVER_READY");
return terminal;
}
// Reads the PTY itself, so output from one pane cannot satisfy another's check.
async function terminalOutput(terminalId: string): Promise<string> {
const capture = await harness.client.captureTerminal(terminalId);
return capture.lines.join("").replace(/\s/g, "");
}
async function expectTerminalOutput(terminalId: string, text: string): Promise<void> {
await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
const secondPage = await page.context().newPage();
const first = await openFileReceiver(page, "image-paste-a");
const second = await openFileReceiver(secondPage, "image-paste-b");
try {
await Promise.all([pasteImage(page, pngBytes(1)), pasteImage(secondPage, pngBytes(2))]);
await expectTerminalOutput(first.id, fileRead(pngBytes(1), "upload"));
await expectTerminalOutput(second.id, fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(first.id)).not.toContain(fileRead(pngBytes(2), "upload"));
expect(await terminalOutput(second.id)).not.toContain(fileRead(pngBytes(1), "upload"));
} finally {
await secondPage.close();
await harness.killTerminal(first.id);
await harness.killTerminal(second.id);
}
});
test("uploads dropped files of any type when the host is another machine", async ({ page }) => {
const terminal = await openFileReceiver(page, "file-drop-remote");
const files = [
{ name: "shot.png", type: "image/png", bytes: pngBytes(3) },
{ name: "notes.txt", type: "text/plain", bytes: Buffer.from("remote notes") },
];
try {
await dragFilesOver(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "1");
await expect(terminalDropOverlay(page)).toHaveText("Drop files here");
await dropFiles(page, files);
await expect(terminalDropOverlay(page)).toHaveCSS("opacity", "0");
await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("pastes dropped files' own paths when the host is this machine", async ({ page }) => {
const files = [
{ name: "local-shot.png", type: "image/png", bytes: pngBytes(9) },
{ name: "local-notes.txt", type: "text/plain", bytes: Buffer.from("local notes") },
];
for (const file of files) {
await writeFile(path.join(harness.tempRepo.path, file.name), file.bytes);
}
await installLocalDesktopHost(page, harness.tempRepo.path);
const terminal = await openFileReceiver(page, "file-drop-local");
try {
await dropFiles(page, files);
await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local"));
await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local"));
expect(await terminalOutput(terminal.id)).not.toContain(":upload");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
} finally {
await harness.killTerminal(terminal.id);
}
});
test("lands image pastes in gesture order while typing stays live", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-order");
try {
await pasteImage(page, pngBytes(4));
await upload.waitUntilHeld();
await pasteImage(page, pngBytes(5));
await expect(page.getByTestId("terminal-file-uploading")).toHaveText("Uploading file…");
await typeLine(page, "describe");
await expectTerminalOutput(terminal.id, "TYPED:describe");
upload.release();
await expectTerminalOutput(terminal.id, fileRead(pngBytes(5), "upload"));
const output = await terminalOutput(terminal.id);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeGreaterThanOrEqual(0);
expect(output.indexOf(fileRead(pngBytes(4), "upload"))).toBeLessThan(
output.indexOf(fileRead(pngBytes(5), "upload")),
);
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
await expect(page.getByTestId("app-toast-message")).toHaveText("File uploaded");
} finally {
await harness.killTerminal(terminal.id);
}
});
test("Ctrl+C abandons an image paste that is still uploading", async ({ page }) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-cancel");
try {
await pasteImage(page, pngBytes(6));
await upload.waitUntilHeld();
await terminalInput(page).press("Control+c");
await expectTerminalOutput(terminal.id, "INTERRUPTED");
await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0);
upload.release();
await pasteImage(page, pngBytes(7));
await expectTerminalOutput(terminal.id, fileRead(pngBytes(7), "upload"));
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(6), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
test("a disconnect during upload reports one failure and input keeps working", async ({
page,
}) => {
const upload = await holdFileUploadResponse(page);
const terminal = await openFileReceiver(page, "image-disconnect");
try {
await pasteImage(page, pngBytes(8));
await upload.waitUntilHeld();
await upload.disconnect();
await expect(page.getByTestId("app-toast-message")).toHaveText(
"Failed to paste file: Host is not connected",
);
await typeLine(page, "after");
await expectTerminalOutput(terminal.id, "TYPED:after");
expect(await terminalOutput(terminal.id)).not.toContain(fileRead(pngBytes(8), "upload"));
} finally {
await harness.killTerminal(terminal.id);
}
});
});
@@ -0,0 +1,44 @@
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
type WebSocketMessage = string | Buffer;
function isFileUploadResponse(message: WebSocketMessage): boolean {
if (typeof message !== "string") return false;
try {
const envelope = JSON.parse(message) as { type?: unknown; message?: { type?: unknown } };
return (envelope.message ?? envelope).type === "file.upload.response";
} catch {
return false;
}
}
/**
* Holds the daemon's first `file.upload.response`, so the upload is stored on
* the host but the app still sees it in flight.
*/
export async function holdFileUploadResponse(page: Page) {
let held: { browser: WebSocketRoute; message: WebSocketMessage } | null = null;
let resolveHeld: (() => void) | null = null;
const heldResponse = new Promise<void>((resolve) => {
resolveHeld = resolve;
});
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => server.send(message));
server.onMessage((message) => {
if (held || !isFileUploadResponse(message)) {
browser.send(message);
return;
}
held = { browser, message };
resolveHeld?.();
});
});
return {
waitUntilHeld: () => heldResponse,
release: () => held?.browser.send(held.message),
/** Drops the app's connection while the upload is in flight. */
disconnect: () => held?.browser.close({ code: 1001, reason: "Upload gate disconnect" }),
};
}
@@ -0,0 +1,38 @@
import type { Page } from "@playwright/test";
// Poses as the desktop app running the e2e daemon as its own managed daemon, so
// `useIsLocalDaemon` reports the host as this machine. `directory` is where the
// desktop bridge claims dropped files live on disk.
export async function installLocalDesktopHost(page: Page, directory: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set");
}
await page.addInitScript(
(localHost: { serverId: string; directory: string }) => {
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
platform: "linux",
invoke: async (command: string) => {
if (command === "desktop_daemon_status") {
return {
serverId: localHost.serverId,
status: "running",
listen: null,
hostname: null,
pid: null,
home: "",
version: null,
desktopManaged: true,
error: null,
};
}
return null;
},
getPendingOpenProject: async () => null,
events: { on: async () => () => undefined },
webUtils: { getPathForFile: (file: File) => `${localHost.directory}/${file.name}` },
};
},
{ serverId, directory },
);
}
+1
View File
@@ -36,6 +36,7 @@
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"check:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs --check",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
@@ -47,6 +47,7 @@ const aliasPlugin = {
};
const result = await esbuild.build({
absWorkingDir: repoRoot,
entryPoints: [entry],
bundle: true,
write: false,
@@ -85,5 +86,18 @@ const contents = `// Generated by packages/app/scripts/build-terminal-webview-ht
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
if (process.argv.includes("--check")) {
// Compare parsed modules so repository formatting does not create false drift.
const options = { loader: "ts", format: "esm" };
const expected = await esbuild.transform(contents, options);
const actual = await esbuild.transform(await fs.readFile(output, "utf8"), options);
if (actual.code !== expected.code) {
throw new Error(
"Stale terminal WebView HTML. Run npm run build:terminal-webview --workspace=@getpaseo/app",
);
}
console.log("Terminal WebView HTML is current");
} else {
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
}
@@ -0,0 +1,2 @@
/** Largest file the app sends through `client.uploadFile`. */
export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
@@ -34,7 +34,7 @@ export function FileDropBackdrop() {
// non-animated children (applying themed Unistyles styles to an Animated.View crashes
// on theme change — see docs/unistyles.md).
return (
<Animated.View style={overlayStyle} pointerEvents="none">
<Animated.View style={overlayStyle} pointerEvents="none" testID="file-drop-backdrop">
<View style={styles.backdrop} />
<View style={styles.content}>
<ThemedUpload size={32} uniProps={primaryIconColorMapping} />
@@ -13,11 +13,11 @@ export type DroppedItem = DroppedFileItem | DroppedPathItem;
/**
* What a consumer (e.g. a composer) registers to receive files dropped onto the
* surrounding FileDropZone. Raster images arrive already persisted via `onFiles`;
* everything else arrives raw via `onGenericFiles`.
* surrounding FileDropZone. Every file arrives raw via `onGenericFiles`; a sink that
* also provides `onFiles` gets its raster images persisted as attachments.
*/
export interface FileDropSink {
onFiles: (images: ImageAttachment[]) => void;
onFiles?: (images: ImageAttachment[]) => void;
onGenericFiles?: (items: DroppedItem[]) => void;
onWorkspaceFile?: (payload: WorkspaceFileDragPayload) => void;
}
@@ -148,8 +148,9 @@ export function useDropListeners({
sink.onGenericFiles(items);
}
const imagePaths = payload.paths.filter(isRasterImagePath);
if (imagePaths.length === 0) {
const onFiles = sink.onFiles;
const imagePaths = onFiles ? payload.paths.filter(isRasterImagePath) : [];
if (!onFiles || imagePaths.length === 0) {
return;
}
@@ -162,7 +163,7 @@ export function useDropListeners({
// composer the user dropped on (matches the web path below). No post-persist busy
// re-check: a mixed drop's own generic upload flips the busy flag, and re-checking
// would discard the image from the same drop.
sink.onFiles(attachments);
onFiles(attachments);
return;
})
.catch((error) => {
@@ -262,16 +263,17 @@ export function useDropListeners({
sink.onGenericFiles(genericItems);
}
const imageFiles = files.filter(isRasterImageFile);
const onFiles = sink.onFiles;
const imageFiles = onFiles ? files.filter(isRasterImageFile) : [];
if (imageFiles.length === 0) return;
if (!onFiles || imageFiles.length === 0) return;
try {
const attachments = await Promise.all(imageFiles.map(fileToImageAttachment));
// No post-persist busy re-check: a mixed drop's own generic upload flips the busy flag,
// and re-checking would discard the image from the same drop. The guard at drop start
// already rejects drops that begin while busy.
sink.onFiles(attachments);
onFiles(attachments);
} catch (error) {
console.error("[useDropListeners] Failed to process dropped files:", error);
}
@@ -3,17 +3,18 @@ import { Pressable, Text, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export interface TerminalPasteActionProps {
hasClipboardText: boolean;
/** Clipboard holds text or an image, so a paste attempt can do something. */
hasClipboardContent: boolean;
onPaste: () => void;
}
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
export function TerminalPasteAction({ hasClipboardContent, onPaste }: TerminalPasteActionProps) {
return (
<TerminalActionButton
label="Paste"
accessibilityLabel="Paste"
testID="terminal-paste"
disabled={!hasClipboardText}
disabled={!hasClipboardContent}
onPress={onPaste}
variant="key"
/>
@@ -12,6 +12,7 @@ import type {
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalFile } from "../terminal/file-paste";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
@@ -75,4 +76,5 @@ export interface TerminalEmulatorProps {
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
}
@@ -27,6 +27,7 @@ import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalFileFromBase64 } from "../terminal/file-paste";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { openExternalUrl } from "../utils/open-external-url";
@@ -95,6 +96,7 @@ type BridgeOutboundMessage =
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
@@ -181,6 +183,7 @@ export default function WebViewTerminalEmulator({
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
onFilePaste,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -230,6 +233,7 @@ export default function WebViewTerminalEmulator({
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
onSwipeLeft,
onSwipeRight,
});
@@ -245,6 +249,7 @@ export default function WebViewTerminalEmulator({
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
onSwipeLeft,
onSwipeRight,
};
@@ -590,6 +595,10 @@ export default function WebViewTerminalEmulator({
callbacksRef.current.onFindResult?.(message.result);
return;
}
if (message.type === "filePaste") {
callbacksRef.current.onFilePaste?.(Promise.resolve(terminalFileFromBase64(message)));
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage, streamKey],
@@ -11,9 +11,7 @@ import {
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type DragEvent as ReactDragEvent,
type MouseEvent as ReactMouseEvent,
type Ref,
} from "react";
@@ -29,6 +27,7 @@ import {
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type { TerminalFile } from "../terminal/file-paste";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
@@ -37,13 +36,6 @@ import type { TerminalClipboardWriter } from "../terminal/native-renderer/termin
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
import {
extractTerminalDropPaths,
isTerminalDragLeaveOutside,
isTerminalFileDrag,
prepareDroppedPathsForTerminal,
} from "../terminal/drop/terminal-file-drop";
import { getDesktopHost } from "@/desktop/host";
export interface TerminalEmulatorHandle {
find?: TerminalFindHandle;
@@ -145,6 +137,7 @@ interface TerminalEmulatorProps {
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
@@ -191,6 +184,7 @@ export default function TerminalEmulator({
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
onFilePaste,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
@@ -220,6 +214,7 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
});
mountCallbacksRef.current = {
onFindRequest,
@@ -231,13 +226,12 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
};
const initialSnapshotRef = useRef(initialSnapshot);
initialSnapshotRef.current = initialSnapshot;
const pendingModifiersRef = useRef(pendingModifiers);
pendingModifiersRef.current = pendingModifiers;
const [isDropActive, setIsDropActive] = useState(false);
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
const pasteText = useCallback((text: string) => {
@@ -506,11 +500,13 @@ export default function TerminalEmulator({
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
onOpenExternalUrl: openExternalUrl,
},
});
}, [
onFindRequest,
onFilePaste,
onFindResult,
onInput,
onInputModeChange,
@@ -582,111 +578,6 @@ export default function TerminalEmulator({
[showTerminalContextMenu],
);
const clearDropActiveTimeout = useCallback(() => {
if (dropActiveTimeoutRef.current === null) {
return;
}
clearTimeout(dropActiveTimeoutRef.current);
dropActiveTimeoutRef.current = null;
}, []);
const clearTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(false);
}, [clearDropActiveTimeout]);
const keepTerminalDropActive = useCallback(() => {
clearDropActiveTimeout();
setIsDropActive(true);
dropActiveTimeoutRef.current = setTimeout(() => {
dropActiveTimeoutRef.current = null;
setIsDropActive(false);
}, 180);
}, [clearDropActiveTimeout]);
useEffect(() => {
const root = rootRef.current;
if (!root) {
return () => {};
}
const handleDragEnter = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
keepTerminalDropActive();
};
const handleDragOver = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
keepTerminalDropActive();
};
const handleDrop = (event: DragEvent) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
clearTerminalDropActive();
const bridge = getDesktopHost();
const paths = extractTerminalDropPaths(event.dataTransfer, bridge);
if (paths.length === 0) {
return;
}
runtimeRef.current?.focus();
mountCallbacksRef.current.onInput?.(prepareDroppedPathsForTerminal(paths, bridge));
};
root.addEventListener("dragenter", handleDragEnter, { capture: true });
root.addEventListener("dragover", handleDragOver, { capture: true });
root.addEventListener("drop", handleDrop, { capture: true });
window.addEventListener("dragend", clearTerminalDropActive);
window.addEventListener("drop", clearTerminalDropActive);
return () => {
root.removeEventListener("dragenter", handleDragEnter, { capture: true });
root.removeEventListener("dragover", handleDragOver, { capture: true });
root.removeEventListener("drop", handleDrop, { capture: true });
window.removeEventListener("dragend", clearTerminalDropActive);
window.removeEventListener("drop", clearTerminalDropActive);
clearDropActiveTimeout();
};
}, [clearDropActiveTimeout, clearTerminalDropActive, keepTerminalDropActive]);
const handleRootDragLeave = useCallback(
(event: ReactDragEvent<HTMLDivElement>) => {
if (!isTerminalFileDrag(event.dataTransfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
if (
!isTerminalDragLeaveOutside({
currentTarget: event.currentTarget,
relatedTarget: event.relatedTarget,
})
) {
return;
}
clearTerminalDropActive();
},
[clearTerminalDropActive],
);
const rootDivStyle = useMemo<CSSProperties>(
() => ({
position: "relative",
@@ -702,19 +593,6 @@ export default function TerminalEmulator({
}),
[xtermTheme.background],
);
const dropOverlayStyle = useMemo<CSSProperties>(
() => ({
position: "absolute",
inset: 0,
zIndex: 9,
border: "1px solid rgba(78, 161, 255, 0.72)",
backgroundColor: "rgba(78, 161, 255, 0.16)",
opacity: isDropActive ? 1 : 0,
pointerEvents: "none",
transition: "opacity 120ms ease-out",
}),
[isDropActive],
);
return (
<div
ref={rootRef}
@@ -723,10 +601,8 @@ export default function TerminalEmulator({
style={rootDivStyle}
onPointerDown={handleRootPointerDown}
onContextMenu={handleRootContextMenu}
onDragLeave={handleRootDragLeave}
>
<div ref={hostRef} style={HOST_DIV_STYLE} />
<div style={dropOverlayStyle} />
</div>
);
}
+146 -9
View File
@@ -43,7 +43,23 @@ import {
shouldShowTerminalPasteAction,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import { encodeTerminalPaste, pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import {
TerminalFilePasteQueue,
TerminalFileTooLargeError,
terminalFileFromBase64,
type TerminalFile,
} from "@/terminal/file-paste";
import { useToast } from "@/contexts/toast-context";
import type { DismissToast } from "@/components/toast-host";
import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { getDesktopHost } from "@/desktop/host";
import { routeTerminalDrop } from "@/terminal/drop/terminal-file-drop";
import { prepareDroppedPathsForTerminal } from "@/terminal/drop/terminal-path";
import { toErrorMessage } from "@/utils/error-messages";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
@@ -122,6 +138,24 @@ type PendingTerminalInput =
input: TerminalKeyInput;
};
async function readNativeClipboardImage(): Promise<TerminalFile | null> {
const image = await Clipboard.getImageAsync({ format: "png" });
if (!image) {
return null;
}
return terminalFileFromBase64({
fileName: "clipboard.png",
mimeType: "image/png",
data: image.data.slice(image.data.indexOf(",") + 1),
});
}
/** Receives files dropped on the pane's FileDropZone; the pane routes them by host locality. */
function TerminalFileDropSink({ onFiles }: { onFiles: (items: DroppedItem[]) => void }) {
useFileDrop({ onGenericFiles: onFiles });
return null;
}
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -292,9 +326,47 @@ export function TerminalPane({
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
const [hasClipboardImage, setHasClipboardImage] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const toast = useToast();
const dismissUploadingToastRef = useRef<DismissToast | null>(null);
const showFileUploading = useStableEvent((uploading: boolean) => {
dismissUploadingToastRef.current?.();
dismissUploadingToastRef.current = uploading
? toast.show(t("workspace.terminal.uploadingFile"), {
durationMs: null,
testID: "terminal-file-uploading",
})
: null;
});
const reportFileUploaded = useStableEvent(() => {
toast.show(t("workspace.terminal.fileUploaded"), { variant: "success" });
});
const reportFilePasteFailure = useStableEvent((error: unknown) => {
const reason =
error instanceof TerminalFileTooLargeError
? t("composer.errors.fileTooLarge", { size: "50MB", fileName: error.fileName })
: toErrorMessage(error);
toast.error(t("workspace.terminal.pasteFileFailed", { reason }));
});
const [filePastes] = useState(
() =>
new TerminalFilePasteQueue({
onUploadingChange: showFileUploading,
onUploaded: reportFileUploaded,
onFailure: reportFilePasteFailure,
}),
);
useEffect(() => () => void filePastes.cancel(), [filePastes, terminalStreamKey]);
useEffect(() => {
if (!isConnected && filePastes.cancel()) {
reportFilePasteFailure(new Error(t("workspace.terminal.hostDisconnected")));
}
}, [filePastes, isConnected, reportFilePasteFailure, t]);
// Only the desktop app's own daemon can read files on this machine by path.
const isLocalHost = useIsLocalDaemon(serverId);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const findRef = useRef<TerminalPaneFindHandle>(null);
@@ -325,10 +397,15 @@ export function TerminalPane({
return;
}
try {
const hasText = await Clipboard.hasStringAsync();
const [hasText, hasImage] = await Promise.all([
Clipboard.hasStringAsync(),
Clipboard.hasImageAsync(),
]);
setHasClipboardText(hasText);
setHasClipboardImage(hasImage);
} catch {
setHasClipboardText(false);
setHasClipboardImage(false);
}
}, [showVirtualKeyBar]);
@@ -705,6 +782,9 @@ export function TerminalPane({
alt: boolean;
meta?: boolean;
}): boolean => {
if (input.ctrl && input.key.toLowerCase() === "c") {
filePastes.cancel();
}
if (!client || !terminalIdRef.current) {
enqueuePendingTerminalInput({
type: "key",
@@ -740,7 +820,7 @@ export function TerminalPane({
}
return true;
},
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput],
[client, dispatchTerminalInputEntry, enqueuePendingTerminalInput, filePastes],
);
const handleTerminalData = useCallback(
@@ -748,6 +828,10 @@ export function TerminalPane({
if (data.length === 0) {
return;
}
// Ctrl+C abandons file pastes that have not landed yet.
if (data.includes("\x03")) {
filePastes.cancel();
}
if (hasPendingTerminalModifiers(modifiers)) {
const pendingResolution = resolvePendingModifierDataInput({
@@ -793,12 +877,59 @@ export function TerminalPane({
clearPendingModifiers,
client,
dispatchTerminalInputEntry,
filePastes,
modifiers,
sendTerminalKey,
enqueuePendingTerminalInput,
],
);
const pasteTerminalText = useStableEvent((text: string) => {
const entry: PendingTerminalInput = {
type: "data",
data: encodeTerminalPaste({
text,
bracketedPaste: inputModeRef.current.bracketedPaste ?? false,
}),
};
if (!dispatchTerminalInputEntry(entry)) {
enqueuePendingTerminalInput(entry);
}
});
const handleFilePaste = useStableEvent((file: Promise<TerminalFile | null>) => {
filePastes.add(file, {
upload: async (upload) => {
if (!client || !isConnected) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const response = await client.uploadFile(upload);
if (!response.file) {
throw new Error(response.error ?? "Upload failed");
}
return response.file.path;
},
paste: pasteTerminalText,
});
});
const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => {
const bridge = getDesktopHost();
const paths: string[] = [];
for (const item of items) {
const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge });
if (delivery.kind === "path") {
paths.push(delivery.path);
} else {
handleFilePaste(delivery.read());
}
}
if (paths.length > 0) {
pasteTerminalText(prepareDroppedPathsForTerminal(paths));
}
requestTerminalFocus();
});
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
const nextSize = { rows: input.rows, cols: input.cols };
const claim = resolveTerminalResizeClaim({
@@ -876,11 +1007,15 @@ export function TerminalPane({
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
void pasteTerminalClipboard({
const image = pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
}).then(() => refreshClipboardAvailability());
}, [requestTerminalReflow, refreshClipboardAvailability]);
})
.catch(() => false)
.then((pastedText) => (pastedText ? null : readNativeClipboardImage()));
handleFilePaste(image);
void image.catch(() => null).then(() => refreshClipboardAvailability());
}, [handleFilePaste, requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
@@ -1032,7 +1167,7 @@ export function TerminalPane({
return showPasteAction ? (
<TerminalPasteAction
key={controlId}
hasClipboardText={hasClipboardText}
hasClipboardContent={hasClipboardText || hasClipboardImage}
onPaste={handleTerminalPaste}
/>
) : null;
@@ -1065,7 +1200,8 @@ export function TerminalPane({
return (
<Animated.View style={containerStyle}>
<View style={styles.outputContainer}>
<FileDropZone style={styles.outputContainer}>
<TerminalFileDropSink onFiles={handleDroppedFiles} />
<View style={styles.terminalGestureContainer}>
<TerminalEmulator
ref={emulatorRef}
@@ -1090,6 +1226,7 @@ export function TerminalPane({
onFocus={handleTerminalFocus}
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onFilePaste={handleFilePaste}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
@@ -1122,7 +1259,7 @@ export function TerminalPane({
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
</View>
) : null}
</View>
</FileDropZone>
{streamError ? (
<View style={styles.errorRow}>
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { useToastHost } from "./toast-host";
describe("useToastHost", () => {
it("dismisses the toast that show returned the dismiss for", () => {
const { result } = renderHook(() => useToastHost());
let dismiss = () => {};
act(() => {
dismiss = result.current.api.show("Uploading image…", { durationMs: null });
});
expect(result.current.toast?.content).toBe("Uploading image…");
act(() => dismiss());
expect(result.current.toast).toBeNull();
});
it("leaves a newer toast in place when an older one is dismissed", () => {
const { result } = renderHook(() => useToastHost());
let dismissUploading = () => {};
act(() => {
dismissUploading = result.current.api.show("Uploading image…", { durationMs: null });
});
act(() => {
result.current.api.show("Image uploaded", { variant: "success" });
});
act(() => dismissUploading());
expect(result.current.toast?.content).toBe("Image uploaded");
});
});
+11 -5
View File
@@ -43,8 +43,11 @@ export interface ToastState {
testID?: string;
}
/** Dismisses the toast it was returned for; a no-op once another toast replaced it. */
export type DismissToast = () => void;
export interface ToastApi {
show: (content: ReactNode, options?: ToastShowOptions) => void;
show: (content: ReactNode, options?: ToastShowOptions) => DismissToast;
copied: (label?: string) => void;
error: (message: string) => void;
}
@@ -52,6 +55,7 @@ export interface ToastApi {
type ToastViewportPlacement = "app-shell" | "panel";
const DEFAULT_DURATION_MS = 2200;
const noopDismiss: DismissToast = () => {};
const TOAST_MAX_WIDTH = 480;
const toastEntering = FadeIn.duration(140);
const toastExiting = FadeOut.duration(140);
@@ -73,10 +77,10 @@ export function useToastHost(): {
const [toast, setToast] = useState<ToastState | null>(null);
const idRef = useRef(0);
const show = useCallback((content: ReactNode, options?: ToastShowOptions) => {
const show = useCallback((content: ReactNode, options?: ToastShowOptions): DismissToast => {
const nativeMessage = typeof content === "string" ? content.trim() : null;
if (!content || nativeMessage === "") {
return;
return noopDismiss;
}
const variant = options?.variant ?? "default";
@@ -87,12 +91,13 @@ export function useToastHost(): {
const duration =
durationMs !== null && durationMs <= 2500 ? ToastAndroid.SHORT : ToastAndroid.LONG;
ToastAndroid.showWithGravity(nativeMessage, duration, ToastAndroid.TOP);
return;
return noopDismiss;
}
idRef.current += 1;
const id = idRef.current;
setToast({
id: idRef.current,
id,
content,
nativeMessage,
icon: options?.icon,
@@ -100,6 +105,7 @@ export function useToastHost(): {
durationMs,
testID: options?.testID,
});
return () => setToast((current) => (current?.id === id ? null : current));
}, []);
const api = useMemo<ToastApi>(
+2 -1
View File
@@ -1,4 +1,5 @@
import type { SelectedFile } from "@/attachments/selected-file";
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type {
@@ -131,7 +132,7 @@ export async function uploadFileAttachments(input: {
for (const file of input.files) {
const bytes = await file.readBytes();
if (bytes.byteLength > 50 * 1024 * 1024) {
if (bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { size: "50MB", fileName: file.fileName }),
);
@@ -56,6 +56,7 @@ function createToast(): FakeToast {
shown,
show: (message, options) => {
shown.push({ message, options });
return () => {};
},
copied: () => {},
error: () => {},
+3
View File
@@ -615,6 +615,9 @@ export const ar: TranslationResources = {
hostDisconnected: "Host غير متصل",
updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.",
unableToSubscribe: "غير قادر على الاشتراك في المحطة",
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
uploadingFile: "جارٍ رفع الملف…",
},
sheet: {
openAsTab: "فتح كعلامة تبويب",
+3
View File
@@ -612,6 +612,9 @@ export const en = {
hostDisconnected: "Host is not connected",
updateHost: "Update the host to use the native terminal renderer.",
unableToSubscribe: "Unable to subscribe to terminal",
fileUploaded: "File uploaded",
pasteFileFailed: "Failed to paste file: {{reason}}",
uploadingFile: "Uploading file…",
},
sheet: {
openAsTab: "Open as tab",
+3
View File
@@ -621,6 +621,9 @@ export const es: TranslationResources = {
hostDisconnected: "Hostno está conectado",
updateHost: "Actualiza el host para usar el renderizador de terminal nativo.",
unableToSubscribe: "No se puede suscribir al terminal",
fileUploaded: "Archivo subido",
pasteFileFailed: "No se pudo pegar el archivo: {{reason}}",
uploadingFile: "Subiendo archivo…",
},
sheet: {
openAsTab: "Abrir como pestaña",
+3
View File
@@ -621,6 +621,9 @@ export const fr: TranslationResources = {
hostDisconnected: "L’hôte n’est pas connecté",
updateHost: "Mettez à jour l’hôte pour utiliser le rendu natif du terminal.",
unableToSubscribe: "Impossible de s’abonner au terminal",
fileUploaded: "Fichier envoyé",
pasteFileFailed: "Impossible de coller le fichier : {{reason}}",
uploadingFile: "Envoi du fichier…",
},
sheet: {
openAsTab: "Ouvrir dans un onglet",
+3
View File
@@ -621,6 +621,9 @@ export const ja: TranslationResources = {
hostDisconnected: "ホストが接続されていません",
updateHost: "ネイティブターミナルを使用するにはホストを更新してください。",
unableToSubscribe: "ターミナルに接続できません",
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
uploadingFile: "ファイルをアップロード中…",
},
sheet: {
openAsTab: "タブで開く",
+3
View File
@@ -616,6 +616,9 @@ export const ko: TranslationResources = {
hostDisconnected: "호스트가 연결되어 있지 않습니다",
updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.",
unableToSubscribe: "터미널을 구독할 수 없습니다",
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
uploadingFile: "파일 업로드 중…",
},
sheet: {
openAsTab: "탭으로 열기",
+3
View File
@@ -620,6 +620,9 @@ export const ptBR: TranslationResources = {
hostDisconnected: "Host não está conectado",
updateHost: "Atualize o host para usar o renderizador de terminal nativo.",
unableToSubscribe: "Não foi possível assinar o terminal",
fileUploaded: "Arquivo enviado",
pasteFileFailed: "Falha ao colar o arquivo: {{reason}}",
uploadingFile: "Enviando arquivo…",
},
sheet: {
openAsTab: "Abrir como aba",
+3
View File
@@ -622,6 +622,9 @@ export const ru: TranslationResources = {
hostDisconnected: "Хост не подключён",
updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.",
unableToSubscribe: "Не удалось подключиться к терминалу",
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
uploadingFile: "Загрузка файла…",
},
sheet: {
openAsTab: "Открыть во вкладке",
+3
View File
@@ -615,6 +615,9 @@ export const zhCN: TranslationResources = {
hostDisconnected: "Host 未连接",
updateHost: "请更新主机以使用原生终端渲染器。",
unableToSubscribe: "无法订阅 Terminal",
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
uploadingFile: "正在上传文件…",
},
sheet: {
openAsTab: "作为标签页打开",
@@ -5,7 +5,7 @@ import { createRoot } from "react-dom/client";
import { waitFor } from "@testing-library/dom";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const noop = () => {};
const toastApi = { show: noop, error: noop, copied: noop };
const toastApi = { show: () => noop, error: noop, copied: noop };
const roots: ReturnType<typeof createRoot>[] = [];
function render(children: ReactNode) {
const container = document.createElement("div");
@@ -1,22 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { DesktopHostBridge } from "@/desktop/host";
import {
extractTerminalDropPaths,
isTerminalDragLeaveOutside,
isTerminalFileDrag,
prepareDroppedPathForTerminal,
prepareDroppedPathsForTerminal,
} from "./terminal-file-drop";
import { routeTerminalDrop } from "./terminal-file-drop";
function dataTransfer(input: { types?: string[]; files?: File[] }): DataTransfer {
return {
types: input.types ?? [],
files: input.files ?? [],
} as unknown as DataTransfer;
}
function fakeFile(input: { name: string; legacyPath?: string }): File {
const file = { name: input.name } as unknown as File;
function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File {
const file = { name: input.name, type: input.type ?? "" } as unknown as File;
if (input.legacyPath !== undefined) {
Object.defineProperty(file, "path", {
configurable: true,
@@ -26,45 +13,34 @@ function fakeFile(input: { name: string; legacyPath?: string }): File {
return file;
}
function makeNode(children: readonly EventTarget[] = []): EventTarget {
return {
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
contains: (other: EventTarget | null) => other !== null && children.includes(other),
} as unknown as EventTarget;
}
describe("terminal file drop", () => {
it("detects file drags", () => {
expect(isTerminalFileDrag(dataTransfer({ types: ["Files"] }))).toBe(true);
expect(isTerminalFileDrag(dataTransfer({ types: ["text/plain"] }))).toBe(false);
expect(isTerminalFileDrag(null)).toBe(false);
});
it("keeps drag highlight active when moving between terminal children", () => {
const child = makeNode();
const outside = makeNode();
const root = makeNode([child]);
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: child })).toBe(false);
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: outside })).toBe(true);
expect(isTerminalDragLeaveOutside({ currentTarget: root, relatedTarget: null })).toBe(true);
expect(isTerminalDragLeaveOutside({ currentTarget: null, relatedTarget: child })).toBe(true);
});
it("extracts paths through Electron webUtils", () => {
const file = fakeFile({ name: "photo.png" });
const getPathForFile = vi.fn(() => "/Users/me/Desktop/photo.png");
const bridge: DesktopHostBridge = { webUtils: { getPathForFile } };
describe("terminal file drop routing", () => {
const notes = fakeFile({ name: "notes.txt", type: "text/plain" });
const electron: DesktopHostBridge = {
webUtils: { getPathForFile: (file) => `/Users/me/Desktop/${file.name}` },
};
it("pastes the file's own path when the host is the client's machine", () => {
expect(
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge),
).toEqual(["/Users/me/Desktop/photo.png"]);
expect(getPathForFile).toHaveBeenCalledWith(file);
routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: electron }),
).toEqual({ kind: "path", path: "/Users/me/Desktop/notes.txt" });
});
it("falls back to legacy Electron file paths", () => {
it("uploads the same file when the host is another machine", () => {
expect(
routeTerminalDrop(
{ kind: "web-file", file: notes },
{ hostIsLocal: false, bridge: electron },
),
).toMatchObject({ kind: "upload" });
});
it("uploads browser files, which have no path on any machine", () => {
expect(
routeTerminalDrop({ kind: "web-file", file: notes }, { hostIsLocal: true, bridge: null }),
).toMatchObject({ kind: "upload" });
});
it("falls back to legacy Electron file paths on a local host", () => {
const file = fakeFile({ name: "photo.png", legacyPath: "/tmp/legacy-photo.png" });
const bridge: DesktopHostBridge = {
webUtils: {
@@ -74,45 +50,21 @@ describe("terminal file drop", () => {
},
};
expect(
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), bridge),
).toEqual(["/tmp/legacy-photo.png"]);
expect(routeTerminalDrop({ kind: "web-file", file }, { hostIsLocal: true, bridge })).toEqual({
kind: "path",
path: "/tmp/legacy-photo.png",
});
});
it("drops browser files that have no filesystem path", () => {
const file = fakeFile({ name: "photo.png" });
it("routes desktop paths by host locality too", () => {
const item = { kind: "desktop-path", path: "/tmp/report.pdf" } as const;
expect(
extractTerminalDropPaths(dataTransfer({ types: ["Files"], files: [file] }), null),
).toEqual([]);
});
it("prepares POSIX paths with conservative escaping", () => {
const bridge: DesktopHostBridge = { platform: "darwin" };
expect(prepareDroppedPathForTerminal("/tmp/my image.png", bridge)).toBe("'/tmp/my image.png'");
expect(prepareDroppedPathForTerminal("/tmp/a$(touch bad).png", bridge)).toBe(
"'/tmp/a(touch bad).png'",
);
expect(prepareDroppedPathForTerminal("/tmp/it's.png", bridge)).toBe("'/tmp/it\\'s.png'");
});
it("prepares Windows paths with space quoting", () => {
const bridge: DesktopHostBridge = { platform: "win32" };
expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo.png", bridge)).toBe(
"C:\\Users\\me\\photo.png",
);
expect(prepareDroppedPathForTerminal("C:\\Users\\me\\photo one.png", bridge)).toBe(
'"C:\\Users\\me\\photo one.png"',
);
});
it("joins multiple dropped paths for one terminal input", () => {
const bridge: DesktopHostBridge = { platform: "darwin" };
expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"], bridge)).toBe(
"'/tmp/a.png' '/tmp/b c.png'",
);
expect(routeTerminalDrop(item, { hostIsLocal: true, bridge: null })).toEqual({
kind: "path",
path: "/tmp/report.pdf",
});
expect(routeTerminalDrop(item, { hostIsLocal: false, bridge: null })).toMatchObject({
kind: "upload",
});
});
});
@@ -1,13 +1,16 @@
import { getMimeTypeFromPath } from "@/attachments/file-types";
import { readDesktopFileBytes } from "@/attachments/selected-file";
import type { DroppedItem } from "@/components/file-drop/types";
import type { DesktopHostBridge } from "@/desktop/host";
const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g;
import { readTerminalFile, type TerminalFile } from "../file-paste";
function getLegacyFilePath(file: File): string | null {
const path = Reflect.get(file, "path");
return typeof path === "string" && path.length > 0 ? path : null;
}
function getFilePath(file: File, bridge: DesktopHostBridge | null): string | null {
/** Where the client's own filesystem holds a dropped file; null for files that are only bytes. */
function getClientFilePath(file: File, bridge: DesktopHostBridge | null): string | null {
const getPathForFile = bridge?.webUtils?.getPathForFile;
if (typeof getPathForFile === "function") {
try {
@@ -22,87 +25,39 @@ function getFilePath(file: File, bridge: DesktopHostBridge | null): string | nul
return getLegacyFilePath(file);
}
export function isTerminalFileDrag(dataTransfer: DataTransfer | null): boolean {
return Boolean(dataTransfer && Array.from(dataTransfer.types).includes("Files"));
function fileNameFromPath(path: string): string {
return path.split(/[/\\]/).findLast((segment) => segment.length > 0) ?? path;
}
interface ContainsTarget {
contains: (other: EventTarget | null) => boolean;
async function readDesktopFile(path: string): Promise<TerminalFile> {
return {
fileName: fileNameFromPath(path),
mimeType: getMimeTypeFromPath(path),
bytes: await readDesktopFileBytes(path),
};
}
function asContainsTarget(value: EventTarget | null): ContainsTarget | null {
if (!value || typeof (value as Partial<ContainsTarget>).contains !== "function") {
return null;
/** How a dropped item reaches the terminal. */
export type TerminalDropDelivery =
| { kind: "path"; path: string }
| { kind: "upload"; read: () => Promise<TerminalFile> };
/**
* A dropped file pastes its own path only when the host is the machine the file
* sits on. Anywhere else the host cannot read the client's disk, so the bytes are
* uploaded and the host path is pasted instead. The file's type plays no part.
*/
export function routeTerminalDrop(
item: DroppedItem,
input: { hostIsLocal: boolean; bridge: DesktopHostBridge | null },
): TerminalDropDelivery {
if (item.kind === "desktop-path") {
return input.hostIsLocal
? { kind: "path", path: item.path }
: { kind: "upload", read: () => readDesktopFile(item.path) };
}
return value as unknown as ContainsTarget;
}
export function isTerminalDragLeaveOutside(input: {
currentTarget: EventTarget | null;
relatedTarget: EventTarget | null;
}): boolean {
const currentTarget = asContainsTarget(input.currentTarget);
if (!currentTarget || !input.relatedTarget) {
return true;
}
return !currentTarget.contains(input.relatedTarget);
}
export function extractTerminalDropPaths(
dataTransfer: DataTransfer | null,
bridge: DesktopHostBridge | null,
): string[] {
if (!dataTransfer) {
return [];
}
const paths: string[] = [];
for (const file of Array.from(dataTransfer.files)) {
const path = getFilePath(file, bridge);
if (path) {
paths.push(path);
}
}
return paths;
}
function escapeNonWindowsPath(path: string): string {
let nextPath = path;
if (nextPath.includes("\\")) {
nextPath = nextPath.replace(/\\/g, "\\\\");
}
nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, "");
if (nextPath.includes("'") && nextPath.includes('"')) {
return `$'${nextPath.replace(/'/g, "\\'")}'`;
}
if (nextPath.includes("'")) {
return `'${nextPath.replace(/'/g, "\\'")}'`;
}
return `'${nextPath}'`;
}
function escapeWindowsPath(path: string): string {
if (!path.includes(" ")) {
return path;
}
return `"${path}"`;
}
export function prepareDroppedPathForTerminal(
path: string,
bridge: DesktopHostBridge | null,
): string {
if (bridge?.platform === "win32") {
return escapeWindowsPath(path);
}
return escapeNonWindowsPath(path);
}
export function prepareDroppedPathsForTerminal(
paths: readonly string[],
bridge: DesktopHostBridge | null,
): string {
return paths.map((path) => prepareDroppedPathForTerminal(path, bridge)).join(" ");
const clientPath = input.hostIsLocal ? getClientFilePath(item.file, input.bridge) : null;
return clientPath
? { kind: "path", path: clientPath }
: { kind: "upload", read: () => readTerminalFile(item.file) };
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path";
describe("terminal path quoting", () => {
it("prepares POSIX paths with conservative escaping", () => {
expect(prepareTerminalPath("/tmp/my image.png")).toBe("'/tmp/my image.png'");
expect(prepareTerminalPath("/tmp/a$(touch bad).png")).toBe("'/tmp/a(touch bad).png'");
expect(prepareTerminalPath("/tmp/it's.png")).toBe("'/tmp/it\\'s.png'");
});
it("prepares Windows paths with space quoting", () => {
expect(prepareTerminalPath("C:\\Users\\me\\photo.png")).toBe("C:\\Users\\me\\photo.png");
expect(prepareTerminalPath("C:\\Users\\me\\photo one.png")).toBe(
'"C:\\Users\\me\\photo one.png"',
);
expect(prepareTerminalPath("\\\\server\\share\\photo one.png")).toBe(
'"\\\\server\\share\\photo one.png"',
);
});
it("joins multiple dropped paths for one terminal input", () => {
expect(prepareDroppedPathsForTerminal(["/tmp/a.png", "/tmp/b c.png"])).toBe(
"'/tmp/a.png' '/tmp/b c.png'",
);
});
});
@@ -0,0 +1,40 @@
// Bundled into the terminal WebView as well as the app: keep this module dependency-free.
const DANGEROUS_NON_WINDOWS_PATH_CHARS = /[`$|&>~#!^*;<]/g;
function escapeNonWindowsPath(path: string): string {
let nextPath = path;
if (nextPath.includes("\\")) {
nextPath = nextPath.replace(/\\/g, "\\\\");
}
nextPath = nextPath.replace(DANGEROUS_NON_WINDOWS_PATH_CHARS, "");
if (nextPath.includes("'") && nextPath.includes('"')) {
return `$'${nextPath.replace(/'/g, "\\'")}'`;
}
if (nextPath.includes("'")) {
return `'${nextPath.replace(/'/g, "\\'")}'`;
}
return `'${nextPath}'`;
}
function escapeWindowsPath(path: string): string {
if (!path.includes(" ")) {
return path;
}
return `"${path}"`;
}
const WINDOWS_PATH = /^(?:[A-Za-z]:[\\/]|\\\\)/;
/** Quotes a path for the shell of the machine it belongs to, identified by the path's shape. */
export function prepareTerminalPath(path: string): string {
if (WINDOWS_PATH.test(path)) {
return escapeWindowsPath(path);
}
return escapeNonWindowsPath(path);
}
export function prepareDroppedPathsForTerminal(paths: readonly string[]): string {
return paths.map(prepareTerminalPath).join(" ");
}
@@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import { TerminalFilePasteQueue, type TerminalFile, type TerminalFilePasteTarget } from "./index";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
function file(fileName: string, bytes: number[] = [1, 2, 3]): TerminalFile {
return { fileName, mimeType: "image/png", bytes: new Uint8Array(bytes) };
}
function settle(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
class FakeHost implements TerminalFilePasteTarget {
readonly uploads: TerminalFile[] = [];
readonly pasted: string[] = [];
private readonly results = new Map<string, Promise<string>>();
holdUpload(fileName: string): ReturnType<typeof deferred<string>> {
const result = deferred<string>();
this.results.set(fileName, result.promise);
return result;
}
async upload(input: TerminalFile): Promise<string> {
this.uploads.push(input);
return this.results.get(input.fileName) ?? `/host/uploads/${input.fileName}`;
}
paste(text: string): void {
this.pasted.push(text);
}
}
function createQueue() {
const events = { uploading: [] as boolean[], uploaded: 0, failures: [] as string[] };
const queue = new TerminalFilePasteQueue({
onUploadingChange: (uploading) => {
events.uploading.push(uploading);
},
onUploaded: () => {
events.uploaded += 1;
},
onFailure: (error) => {
events.failures.push(error instanceof Error ? error.message : String(error));
},
});
return { queue, events, host: new FakeHost() };
}
describe("terminal file paste queue", () => {
it("pastes each uploaded host path in gesture order", async () => {
const { queue, host, events } = createQueue();
const firstRead = deferred<TerminalFile | null>();
const firstUpload = host.holdUpload("a.png");
queue.add(firstRead.promise, host);
queue.add(Promise.resolve(file("b.png")), host);
await settle();
expect(host.uploads).toEqual([]);
firstRead.resolve(file("a.png"));
await settle();
expect(host.uploads.map((upload) => upload.fileName)).toEqual(["a.png"]);
firstUpload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]);
expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] });
});
it("quotes Windows host paths for the host shell", async () => {
const { queue, host } = createQueue();
host.holdUpload("shot.png").resolve("C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png");
queue.add(Promise.resolve(file("shot.png")), host);
await settle();
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 () => {
const { queue, host } = createQueue();
queue.add(Promise.resolve(file("Screen Shot #1 (it's $HOME).png")), host);
await settle();
expect(host.uploads.map((upload) => upload.fileName)).toEqual([
"Screen_Shot__1__it_s__HOME_.png",
]);
});
it("does nothing for a gesture that carried no file", async () => {
const { queue, host, events } = createQueue();
queue.add(Promise.resolve(null), host);
await settle();
expect(host.uploads).toEqual([]);
expect(host.pasted).toEqual([]);
expect(events).toEqual({ uploading: [], uploaded: 0, failures: [] });
});
it("reports a failed upload and keeps pasting later files", async () => {
const { queue, host, events } = createQueue();
host.holdUpload("a.png").reject(new Error("disk full"));
queue.add(Promise.resolve(file("a.png")), host);
queue.add(Promise.resolve(file("b.png")), host);
await settle();
expect(events.failures).toEqual(["disk full"]);
expect(events.uploaded).toBe(1);
expect(host.pasted).toEqual(["'/host/uploads/b.png'"]);
});
it("reports unreadable and oversized files without uploading them", async () => {
const { queue, host, events } = createQueue();
const oversized = {
...file("big.png"),
bytes: new Uint8Array(FILE_UPLOAD_MAX_BYTES + 1),
};
queue.add(Promise.reject(new Error("unreadable")), host);
queue.add(Promise.resolve(oversized), host);
await settle();
expect(events.failures).toEqual(["unreadable", "big.png exceeds the upload limit"]);
expect(host.uploads).toEqual([]);
});
it("never pastes the path of an upload cancelled mid-flight", async () => {
const { queue, host, events } = createQueue();
const upload = host.holdUpload("a.png");
queue.add(Promise.resolve(file("a.png")), host);
queue.add(Promise.resolve(file("b.png")), host);
await settle();
expect(queue.cancel()).toBe(true);
expect(events.uploading).toEqual([true, false]);
upload.resolve("/host/uploads/a.png");
await settle();
expect(host.pasted).toEqual([]);
expect(host.uploads.map((entry) => entry.fileName)).toEqual(["a.png"]);
expect(events.failures).toEqual([]);
expect(events.uploaded).toBe(0);
});
it("does not hold new pastes behind a cancelled upload that never settles", async () => {
const { queue, host, events } = createQueue();
host.holdUpload("stuck.png");
queue.add(Promise.resolve(file("stuck.png")), host);
await settle();
queue.cancel();
queue.add(Promise.resolve(file("next.png")), host);
await settle();
expect(host.pasted).toEqual(["'/host/uploads/next.png'"]);
expect(events.uploading).toEqual([true, false, true, false]);
expect(queue.cancel()).toBe(false);
});
});
@@ -0,0 +1,146 @@
// Bundled into the terminal WebView as well as the app: keep i18n and React out.
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import { prepareTerminalPath } from "../drop/terminal-path";
/** File bytes from a paste or drop gesture, before they become a file on the host. */
export interface TerminalFile {
fileName: string;
mimeType: string;
bytes: Uint8Array;
}
/** A file over the upload limit; the pane words it for the user. */
export class TerminalFileTooLargeError extends Error {
constructor(readonly fileName: string) {
super(`${fileName} exceeds the upload limit`);
}
}
/** The pane's host and terminal: where a file is stored and where its path is pasted. */
export interface TerminalFilePasteTarget {
/** Stores the file on the host and returns its host path. */
upload(file: TerminalFile): Promise<string>;
paste(text: string): void;
}
interface TerminalFilePasteEvents {
onUploadingChange(uploading: boolean): void;
onUploaded(): void;
onFailure(error: unknown): void;
}
/**
* Turns a pane's file gestures into pasted host paths, one at a time and in
* gesture order. Ordinary input never waits on it.
*/
export class TerminalFilePasteQueue {
private tail: Promise<void> = Promise.resolve();
private generation = 0;
private pending = 0;
private uploading = false;
constructor(private readonly events: TerminalFilePasteEvents) {}
/** Queues a gesture. `file` resolves to null when the gesture carried no file. */
add(file: Promise<TerminalFile | null>, target: TerminalFilePasteTarget): void {
const generation = this.generation;
this.pending += 1;
this.tail = this.tail.then(() => this.paste(file, target, generation));
}
/** Drops queued and in-flight gestures. Returns whether any were dropped. */
cancel(): boolean {
const dropped = this.pending > 0;
this.generation += 1;
this.pending = 0;
// A cancelled upload can stay unsettled until its connection resolves.
this.tail = Promise.resolve();
this.setUploading(false);
return dropped;
}
private async paste(
read: Promise<TerminalFile | null>,
target: TerminalFilePasteTarget,
generation: number,
): Promise<void> {
const isCurrent = () => generation === this.generation;
try {
const file = await read;
if (!file || !isCurrent()) {
return;
}
if (file.bytes.byteLength > FILE_UPLOAD_MAX_BYTES) {
throw new TerminalFileTooLargeError(file.fileName);
}
this.setUploading(true);
const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) });
if (isCurrent()) {
target.paste(prepareTerminalPath(path));
this.events.onUploaded();
}
} catch (error) {
if (isCurrent()) {
this.events.onFailure(error);
}
} finally {
if (isCurrent()) {
this.pending -= 1;
this.setUploading(false);
}
}
}
private setUploading(uploading: boolean): void {
if (this.uploading === uploading) {
return;
}
this.uploading = uploading;
this.events.onUploadingChange(uploading);
}
}
// The host path is pasted into a shell or TUI; keep its last segment unquotable.
function toShellSafeFileName(fileName: string): string {
return fileName.replace(/[^\w.-]/g, "_");
}
export async function readTerminalFile(file: File): Promise<TerminalFile> {
return {
fileName: file.name,
mimeType: file.type,
bytes: new Uint8Array(await file.arrayBuffer()),
};
}
export function terminalFileFromBase64(input: {
fileName: string;
mimeType: string;
data: string;
}): TerminalFile {
const binary = atob(input.data);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return { fileName: input.fileName, mimeType: input.mimeType, bytes };
}
const BASE64_CHUNK_SIZE = 0x8000;
export function terminalFileToBase64(file: TerminalFile): string {
let binary = "";
for (let offset = 0; offset < file.bytes.length; offset += BASE64_CHUNK_SIZE) {
binary += String.fromCharCode(...file.bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
}
return btoa(binary);
}
/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */
export function imageExtension(mimeType: string): string {
const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? "";
if (subtype === "jpeg") {
return "jpg";
}
return subtype || "png";
}
@@ -1,7 +1,12 @@
import { page, userEvent } from "@vitest/browser/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime";
import type { TerminalFile } from "../file-paste";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
type TerminalClipboard,
} from "./terminal-emulator-runtime";
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: class WebglAddon {
@@ -41,6 +46,36 @@ interface MountedTerminal {
terminalKeys: TerminalKeyRecord[];
inputModeChanges: TerminalInputModeState[];
openedUrls: string[];
clipboard: FakeClipboard;
filePastes: Promise<TerminalFile | null>[];
}
class FakeClipboard implements TerminalClipboard {
text = "";
image: { mimeType: string; bytes: number[] } | null = null;
imageReads = 0;
async readText(): Promise<string> {
return this.text;
}
async read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]> {
this.imageReads += 1;
const image = this.image;
if (!image) {
return [];
}
return [
{
types: ["text/html", image.mimeType],
getType: async (type: string) => new Blob([new Uint8Array(image.bytes)], { type }),
},
];
}
async writeText(text: string): Promise<void> {
this.text = text;
}
}
const mountedTerminals: MountedTerminal[] = [];
@@ -100,8 +135,11 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
const terminalKeys: TerminalKeyRecord[] = [];
const inputModeChanges: TerminalInputModeState[] = [];
const openedUrls: string[] = [];
const clipboard = new FakeClipboard();
const filePastes: Promise<TerminalFile | null>[] = [];
const runtime = new TerminalEmulatorRuntime(
input.isMac === undefined ? undefined : { isMac: input.isMac },
clipboard,
);
runtime.setCallbacks({
callbacks: {
@@ -120,6 +158,9 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
onOpenExternalUrl: (url) => {
openedUrls.push(url);
},
onFilePaste: (file) => {
filePastes.push(file);
},
},
});
runtime.mount({
@@ -143,6 +184,8 @@ function createTerminalHost(input: CreateTerminalHostInput): MountedTerminal {
terminalKeys,
inputModeChanges,
openedUrls,
clipboard,
filePastes,
};
mountedTerminals.push(mounted);
return mounted;
@@ -413,6 +456,78 @@ describe("terminal emulator runtime in a real browser", () => {
expect(mounted.inputs).toEqual(["legacy renderer paste"]);
});
it("pastes clipboard text on Ctrl+V without reading the clipboard image", async () => {
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.clipboard.text = "clipboard text";
mounted.clipboard.image = { mimeType: "image/png", bytes: [1, 2, 3] };
dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true });
expect(mounted.filePastes).toHaveLength(1);
await expect(mounted.filePastes[0]).resolves.toBeNull();
expect(mounted.inputs).toEqual(["clipboard text"]);
expect(mounted.clipboard.imageReads).toBe(0);
});
it("hands the clipboard image to onFilePaste on Ctrl+V when there is no text", async () => {
const mounted = createTerminalHost({ width: 720, height: 360, isMac: false });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
mounted.clipboard.image = { mimeType: "image/jpeg", bytes: [1, 2, 3] };
dispatchTerminalKey({ host: mounted.host, key: "v", ctrlKey: true });
expect(mounted.filePastes).toHaveLength(1);
await expect(mounted.filePastes[0]).resolves.toEqual({
fileName: "clipboard.jpg",
mimeType: "image/jpeg",
bytes: new Uint8Array([1, 2, 3]),
});
expect(mounted.inputs).toEqual([]);
});
it("takes file-only paste events at xterm's input before xterm pastes empty text", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array([1, 2, 3])], "shot.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", {
clipboardData: transfer,
bubbles: true,
cancelable: true,
});
mounted.host.querySelector(".xterm-helper-textarea")!.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(mounted.filePastes).toHaveLength(1);
await expect(mounted.filePastes[0]).resolves.toEqual({
fileName: "shot.png",
mimeType: "image/png",
bytes: new Uint8Array([1, 2, 3]),
});
await nextFrame();
expect(mounted.inputs).toEqual([]);
});
it("leaves paste events with text to xterm", async () => {
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
const transfer = new DataTransfer();
transfer.setData("text/plain", "echo hi");
transfer.items.add(new File([new Uint8Array([1])], "shot.png", { type: "image/png" }));
mounted.host
.querySelector(".xterm-helper-textarea")!
.dispatchEvent(
new ClipboardEvent("paste", { clipboardData: transfer, bubbles: true, cancelable: true }),
);
await waitFor({ predicate: () => mounted.inputs.length > 0 });
expect(mounted.inputs).toEqual(["echo hi"]);
expect(mounted.filePastes).toEqual([]);
});
it("refreshes visible rows on a forced same-size resize", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
@@ -33,6 +33,7 @@ import {
import { isFindShortcut, type FindShortcutPlatform } from "@/pane-find/find-shortcut";
import { isMacUserAgent } from "@/utils/mac-user-agent";
import { resolveTerminalFontFamily, resolveTerminalFontSize } from "./terminal-font";
import { imageExtension, readTerminalFile, type TerminalFile } from "../file-paste";
export type TerminalOutputData = Uint8Array;
@@ -83,6 +84,8 @@ export interface TerminalEmulatorRuntimeCallbacks {
disposition: "main" | "side",
) => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
/** A paste gesture that can carry a file; resolves to null when it carried none. */
onFilePaste?: (file: Promise<TerminalFile | null>) => void;
}
export interface TerminalResizeEvent {
@@ -122,6 +125,7 @@ interface TerminalEmulatorRuntimeDisposables {
clearFitTimeouts: () => void;
removeFontListeners: () => void;
removeTouchListeners: () => void;
removeFilePasteListener: () => void;
restoreDocumentStyles: () => void;
restoreViewportStyles: () => void;
disposeFitAddon: () => void;
@@ -180,8 +184,51 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
};
}
export interface TerminalClipboard {
readText(): Promise<string>;
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
writeText(text: string): Promise<void>;
}
const browserClipboard: TerminalClipboard = {
readText: () => navigator.clipboard.readText(),
read: () => navigator.clipboard.read(),
writeText: (text) => navigator.clipboard.writeText(text),
};
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
for (const item of await clipboard.read()) {
const mimeType = item.types.find((type) => type.startsWith("image/"));
if (mimeType) {
const blob = await item.getType(mimeType);
return {
fileName: `clipboard.${imageExtension(mimeType)}`,
mimeType,
bytes: new Uint8Array(await blob.arrayBuffer()),
};
}
}
return null;
}
/** The file of a paste that carries no text. xterm would paste it as empty text. */
function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
if (!transfer || transfer.getData("text/plain").length > 0) {
return null;
}
for (const item of Array.from(transfer.items)) {
if (item.kind === "file") {
return item.getAsFile();
}
}
return null;
}
export class TerminalEmulatorRuntime {
constructor(private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() }) {}
constructor(
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
private readonly clipboard: TerminalClipboard = browserClipboard,
) {}
private callbacks: TerminalEmulatorRuntimeCallbacks = {};
private pendingModifiers: PendingTerminalModifiers = {
@@ -327,19 +374,14 @@ export class TerminalEmulatorRuntime {
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
if (key === "c" && terminal.hasSelection()) {
void navigator.clipboard.writeText(terminal.getSelection());
void this.clipboard.writeText(terminal.getSelection());
return false;
}
// Ctrl+V: paste from clipboard into terminal
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
if (key === "v") {
event.preventDefault();
void navigator.clipboard.readText().then((text) => {
if (text) {
terminal.paste(text);
}
return;
});
this.pasteClipboard(terminal);
return false;
}
@@ -666,6 +708,20 @@ export class TerminalEmulatorRuntime {
this.attachKeyEventHandler(terminal);
// Cmd+V and context-menu paste arrive as DOM paste events. Take file-only
// pastes before xterm's own listener consumes them as empty text.
const filePasteHandler = (event: ClipboardEvent): void => {
const onFilePaste = this.callbacks.onFilePaste;
const file = findFileOnlyPaste(event.clipboardData);
if (!onFilePaste || !file) {
return;
}
event.preventDefault();
event.stopPropagation();
onFilePaste(readTerminalFile(file));
};
input.host.addEventListener("paste", filePasteHandler, true);
const removeTouchListeners = this.setupTouchScrollHandlers({
root: input.root,
host: input.host,
@@ -755,6 +811,9 @@ export class TerminalEmulatorRuntime {
fontSet?.removeEventListener?.("loadingdone", fontReadyHandler);
},
removeTouchListeners,
removeFilePasteListener: () => {
input.host.removeEventListener("paste", filePasteHandler, true);
},
restoreDocumentStyles,
restoreViewportStyles,
disposeFitAddon: () => {
@@ -784,6 +843,7 @@ export class TerminalEmulatorRuntime {
disposables.clearFitTimeouts();
disposables.removeFontListeners();
disposables.removeTouchListeners();
disposables.removeFilePasteListener();
disposables.disposeFitAddon();
disposables.disposeWebglAddon();
disposables.disposeTerminal();
@@ -824,6 +884,20 @@ export class TerminalEmulatorRuntime {
this.terminal?.paste(text);
}
private pasteClipboard(terminal: Pick<Terminal, "paste">): void {
const text = this.clipboard.readText().catch(() => "");
const onFilePaste = this.callbacks.onFilePaste;
// Report the gesture now so it keeps its place among earlier file pastes.
const file = text.then((value) => {
if (value) {
terminal.paste(value);
return null;
}
return onFilePaste ? readClipboardImage(this.clipboard).catch(() => null) : null;
});
onFilePaste?.(file);
}
renderSnapshot(input: { state: TerminalState | null; onCommitted?: () => void }): void {
if (!input.state) {
this.clear(input);
@@ -27,22 +27,24 @@ describe("terminal paste encoding", () => {
it("reads clipboard through the explicit paste adapter", async () => {
const pasted: string[] = [];
await pasteTerminalClipboard({
const result = await pasteTerminalClipboard({
clipboard: { readText: async () => "printf one\nprintf two" },
terminal: { paste: (text) => pasted.push(text) },
});
expect(pasted).toEqual(["printf one\nprintf two"]);
expect(result).toBe(true);
});
it("does not dispatch terminal input when clipboard is empty", async () => {
const pasted: string[] = [];
await pasteTerminalClipboard({
const result = await pasteTerminalClipboard({
clipboard: { readText: async () => "" },
terminal: { paste: (text) => pasted.push(text) },
});
expect(pasted).toEqual([]);
expect(result).toBe(false);
});
});
@@ -28,11 +28,13 @@ export function encodeTerminalPaste(input: TerminalPasteInput): string {
return `${BRACKETED_PASTE_START}${payload}${BRACKETED_PASTE_END}`;
}
export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise<void> {
/** Pastes clipboard text. Returns whether there was text to paste. */
export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise<boolean> {
const text = await input.clipboard.readText();
if (text.length === 0) {
return;
return false;
}
input.terminal.paste(text);
return true;
}
@@ -12,6 +12,7 @@ import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
import { terminalFileToBase64, type TerminalFile } from "../file-paste";
interface MountMessage {
type: "mount";
@@ -76,6 +77,7 @@ type OutboundMessage =
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| { type: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
@@ -182,6 +184,7 @@ class TerminalWebViewBridge {
number,
(target: TerminalLocalFileLinkTarget | null) => void
>();
private filePastes: Promise<void> = Promise.resolve();
private swipeGesturesEnabled = false;
private trackingSwipe = false;
private activePointerId: number | null = null;
@@ -353,6 +356,7 @@ class TerminalWebViewBridge {
target,
disposition,
}),
onFilePaste: (file) => this.forwardFilePaste(message.streamKey, file),
},
});
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
@@ -394,6 +398,27 @@ class TerminalWebViewBridge {
}
}
// Forward in gesture order: an earlier file can finish reading after a later one.
private forwardFilePaste(streamKey: string, file: Promise<TerminalFile | null>): void {
this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file));
}
private async sendFilePaste(
streamKey: string,
file: Promise<TerminalFile | null>,
): Promise<void> {
const read = await file.catch(() => null);
if (read && this.matches(streamKey)) {
sendToNative({
type: "filePaste",
streamKey,
fileName: read.fileName,
mimeType: read.mimeType,
data: terminalFileToBase64(read),
});
}
}
private matches(streamKey: string): boolean {
return this.streamKey === streamKey;
}
File diff suppressed because one or more lines are too long
@@ -7,6 +7,8 @@ interface Message {
streamKey?: string;
result?: { resultCount: number; resultIndex: number };
data?: string;
fileName?: string;
mimeType?: string;
}
interface TerminalFrameWindow extends Window {
__PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void;
@@ -15,7 +17,7 @@ interface TerminalFrameWindow extends Window {
let frame: HTMLIFrameElement;
afterEach(() => frame?.remove());
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
async function mountWebView() {
const messages: Message[] = [];
frame = document.createElement("iframe");
frame.style.width = "500px";
@@ -42,6 +44,11 @@ test("the generated WebView searches its mounted stream and keeps Find commands
await expect
.poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned"))
.toBe(true);
return { win, send, messages };
}
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
const { win, send, messages } = await mountWebView();
send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" });
await expect
.poll(() => {
@@ -71,3 +78,27 @@ test("the generated WebView searches its mounted stream and keeps Find commands
expect(messages.filter((m) => m.type === "input")).toEqual([]);
send({ type: "unmount" });
});
test("the generated WebView forwards file-only pastes to native as file bytes", async () => {
const { win, send, messages } = await mountWebView();
const clipboardData = new DataTransfer();
clipboardData.items.add(
new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }),
);
win.document
.querySelector(".xterm-helper-textarea")!
.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }));
await expect
.poll(() => messages.find((m) => m.type === "filePaste"))
.toEqual({
type: "filePaste",
streamKey: "owned",
fileName: "image.png",
mimeType: "image/png",
data: "iVBORw==",
});
expect(messages.filter((m) => m.type === "input")).toEqual([]);
send({ type: "unmount" });
});