diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e632cb4b96..099f9efc0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index 023bf12b9d..8f297dfb17 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const capture = await harness.client.captureTerminal(terminalId); + return capture.lines.join("").replace(/\s/g, ""); + } + + async function expectTerminalOutput(terminalId: string, text: string): Promise { + 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); + } + }); }); diff --git a/packages/app/e2e/support/helpers/file-upload-gate.ts b/packages/app/e2e/support/helpers/file-upload-gate.ts new file mode 100644 index 0000000000..c33b49f637 --- /dev/null +++ b/packages/app/e2e/support/helpers/file-upload-gate.ts @@ -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((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" }), + }; +} diff --git a/packages/app/e2e/support/helpers/local-desktop-host.ts b/packages/app/e2e/support/helpers/local-desktop-host.ts new file mode 100644 index 0000000000..863857e947 --- /dev/null +++ b/packages/app/e2e/support/helpers/local-desktop-host.ts @@ -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 { + 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 }, + ); +} diff --git a/packages/app/package.json b/packages/app/package.json index dc3deb047d..5eaea08838 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs index d3b02336b2..238b2fd6fe 100644 --- a/packages/app/scripts/build-terminal-webview-html.mjs +++ b/packages/app/scripts/build-terminal-webview-html.mjs @@ -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)`); +} diff --git a/packages/app/src/attachments/upload-limit.ts b/packages/app/src/attachments/upload-limit.ts new file mode 100644 index 0000000000..8a81043f4b --- /dev/null +++ b/packages/app/src/attachments/upload-limit.ts @@ -0,0 +1,2 @@ +/** Largest file the app sends through `client.uploadFile`. */ +export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024; diff --git a/packages/app/src/components/file-drop/file-drop-backdrop.tsx b/packages/app/src/components/file-drop/file-drop-backdrop.tsx index bafc9c0227..fa381bf545 100644 --- a/packages/app/src/components/file-drop/file-drop-backdrop.tsx +++ b/packages/app/src/components/file-drop/file-drop-backdrop.tsx @@ -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 ( - + diff --git a/packages/app/src/components/file-drop/types.ts b/packages/app/src/components/file-drop/types.ts index 89bbbca7c4..e9b80a00be 100644 --- a/packages/app/src/components/file-drop/types.ts +++ b/packages/app/src/components/file-drop/types.ts @@ -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; } diff --git a/packages/app/src/components/file-drop/use-drop-listeners.ts b/packages/app/src/components/file-drop/use-drop-listeners.ts index 947c95d91e..2e3dc320b4 100644 --- a/packages/app/src/components/file-drop/use-drop-listeners.ts +++ b/packages/app/src/components/file-drop/use-drop-listeners.ts @@ -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); } diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index 26bba48aad..bf5f86eedb 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -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 ( diff --git a/packages/app/src/components/terminal-emulator-contract.ts b/packages/app/src/components/terminal-emulator-contract.ts index 3575e21500..c4c7080b83 100644 --- a/packages/app/src/components/terminal-emulator-contract.ts +++ b/packages/app/src/components/terminal-emulator-contract.ts @@ -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) => void; } diff --git a/packages/app/src/components/terminal-emulator-webview.native.tsx b/packages/app/src/components/terminal-emulator-webview.native.tsx index dbaa0175d4..163e335381 100644 --- a/packages/app/src/components/terminal-emulator-webview.native.tsx +++ b/packages/app/src/components/terminal-emulator-webview.native.tsx @@ -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], diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index be12355bea..3d272fb75e 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -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; onRendererReadyChange?: (change: TerminalRendererReadyChange) => void; + onFilePaste?: (file: Promise) => 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 | null>(null); const domBridgeRef = useRef(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) => { - if (!isTerminalFileDrag(event.dataTransfer)) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - if ( - !isTerminalDragLeaveOutside({ - currentTarget: event.currentTarget, - relatedTarget: event.relatedTarget, - }) - ) { - return; - } - clearTerminalDropActive(); - }, - [clearTerminalDropActive], - ); - const rootDivStyle = useMemo( () => ({ position: "relative", @@ -702,19 +593,6 @@ export default function TerminalEmulator({ }), [xtermTheme.background], ); - const dropOverlayStyle = useMemo( - () => ({ - 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 (
-
); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 4b37470b30..6c1561bc5b 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -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 { + 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(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(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(null); const findRef = useRef(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) => { + 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 ? ( ) : null; @@ -1065,7 +1200,8 @@ export function TerminalPane({ return ( - + + ) : null} - + {streamError ? ( diff --git a/packages/app/src/components/toast-host.test.tsx b/packages/app/src/components/toast-host.test.tsx new file mode 100644 index 0000000000..32f1bfb8a7 --- /dev/null +++ b/packages/app/src/components/toast-host.test.tsx @@ -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"); + }); +}); diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 6b9009dd6a..d9c8001f50 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -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(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( diff --git a/packages/app/src/composer/actions.ts b/packages/app/src/composer/actions.ts index 7e197b4f13..f810ef19c6 100644 --- a/packages/app/src/composer/actions.ts +++ b/packages/app/src/composer/actions.ts @@ -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 }), ); diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 292b54ab87..1da68b4edf 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -56,6 +56,7 @@ function createToast(): FakeToast { shown, show: (message, options) => { shown.push({ message, options }); + return () => {}; }, copied: () => {}, error: () => {}, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c44c7f8ffc..c3ee235c29 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -615,6 +615,9 @@ export const ar: TranslationResources = { hostDisconnected: "Host غير متصل", updateHost: "حدّث المضيف لاستخدام عارض الطرفية الأصلي.", unableToSubscribe: "غير قادر على الاشتراك في المحطة", + fileUploaded: "تم رفع الملف", + pasteFileFailed: "تعذر لصق الملف: {{reason}}", + uploadingFile: "جارٍ رفع الملف…", }, sheet: { openAsTab: "فتح كعلامة تبويب", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index d752c89372..6649bd8bde 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index f5eda9f6b9..81dd72cec0 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 7ffeaf8d59..52d8a9206a 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 1ba8dac613..0dd14ea5a3 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -621,6 +621,9 @@ export const ja: TranslationResources = { hostDisconnected: "ホストが接続されていません", updateHost: "ネイティブターミナルを使用するにはホストを更新してください。", unableToSubscribe: "ターミナルに接続できません", + fileUploaded: "ファイルをアップロードしました", + pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", + uploadingFile: "ファイルをアップロード中…", }, sheet: { openAsTab: "タブで開く", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index f59d03a8e9..0bfb1be599 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -616,6 +616,9 @@ export const ko: TranslationResources = { hostDisconnected: "호스트가 연결되어 있지 않습니다", updateHost: "기본 터미널 렌더러를 사용하도록 호스트를 업데이트합니다.", unableToSubscribe: "터미널을 구독할 수 없습니다", + fileUploaded: "파일을 업로드했습니다", + pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", + uploadingFile: "파일 업로드 중…", }, sheet: { openAsTab: "탭으로 열기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8a98a32487..295c1acfaf 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index b5e519baad..36dd91a9c5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -622,6 +622,9 @@ export const ru: TranslationResources = { hostDisconnected: "Хост не подключён", updateHost: "Обновите хост, чтобы использовать нативный рендерер терминала.", unableToSubscribe: "Не удалось подключиться к терминалу", + fileUploaded: "Файл загружен", + pasteFileFailed: "Не удалось вставить файл: {{reason}}", + uploadingFile: "Загрузка файла…", }, sheet: { openAsTab: "Открыть во вкладке", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2d5fbca085..7afb29ca30 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -615,6 +615,9 @@ export const zhCN: TranslationResources = { hostDisconnected: "Host 未连接", updateHost: "请更新主机以使用原生终端渲染器。", unableToSubscribe: "无法订阅 Terminal", + fileUploaded: "文件已上传", + pasteFileFailed: "无法粘贴文件:{{reason}}", + uploadingFile: "正在上传文件…", }, sheet: { openAsTab: "作为标签页打开", diff --git a/packages/app/src/panels/pull-request/activity.integration.test.tsx b/packages/app/src/panels/pull-request/activity.integration.test.tsx index 2f2b97aab2..0cb403f9b7 100644 --- a/packages/app/src/panels/pull-request/activity.integration.test.tsx +++ b/packages/app/src/panels/pull-request/activity.integration.test.tsx @@ -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[] = []; function render(children: ReactNode) { const container = document.createElement("div"); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.test.ts b/packages/app/src/terminal/drop/terminal-file-drop.test.ts index 01147eb7b9..5a439f316b 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -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", + }); }); }); diff --git a/packages/app/src/terminal/drop/terminal-file-drop.ts b/packages/app/src/terminal/drop/terminal-file-drop.ts index 44dab5b144..18221271e5 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -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 { + return { + fileName: fileNameFromPath(path), + mimeType: getMimeTypeFromPath(path), + bytes: await readDesktopFileBytes(path), + }; } -function asContainsTarget(value: EventTarget | null): ContainsTarget | null { - if (!value || typeof (value as Partial).contains !== "function") { - return null; +/** How a dropped item reaches the terminal. */ +export type TerminalDropDelivery = + | { kind: "path"; path: string } + | { kind: "upload"; read: () => Promise }; + +/** + * 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) }; } diff --git a/packages/app/src/terminal/drop/terminal-path.test.ts b/packages/app/src/terminal/drop/terminal-path.test.ts new file mode 100644 index 0000000000..6c4b580e48 --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.test.ts @@ -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'", + ); + }); +}); diff --git a/packages/app/src/terminal/drop/terminal-path.ts b/packages/app/src/terminal/drop/terminal-path.ts new file mode 100644 index 0000000000..bc29edd54b --- /dev/null +++ b/packages/app/src/terminal/drop/terminal-path.ts @@ -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(" "); +} diff --git a/packages/app/src/terminal/file-paste/index.test.ts b/packages/app/src/terminal/file-paste/index.test.ts new file mode 100644 index 0000000000..63a1e5e73d --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.test.ts @@ -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() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((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 { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +class FakeHost implements TerminalFilePasteTarget { + readonly uploads: TerminalFile[] = []; + readonly pasted: string[] = []; + private readonly results = new Map>(); + + holdUpload(fileName: string): ReturnType> { + const result = deferred(); + this.results.set(fileName, result.promise); + return result; + } + + async upload(input: TerminalFile): Promise { + 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(); + 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); + }); +}); diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts new file mode 100644 index 0000000000..b94990b1bc --- /dev/null +++ b/packages/app/src/terminal/file-paste/index.ts @@ -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; + 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 = 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, 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, + target: TerminalFilePasteTarget, + generation: number, + ): Promise { + 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 { + 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"; +} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index b173545abc..5dcefb8ebf 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -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[]; +} + +class FakeClipboard implements TerminalClipboard { + text = ""; + image: { mimeType: string; bytes: number[] } | null = null; + imageReads = 0; + + async readText(): Promise { + return this.text; + } + + async read(): Promise[]> { + 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 { + 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[] = []; 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 }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index cc4462ff7b..0a55cd7126 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -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; onInputModeChange?: (state: TerminalInputModeState) => Promise | void; + /** A paste gesture that can carry a file; resolves to null when it carried none. */ + onFilePaste?: (file: Promise) => 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; + read(): Promise[]>; + writeText(text: string): Promise; +} + +const browserClipboard: TerminalClipboard = { + readText: () => navigator.clipboard.readText(), + read: () => navigator.clipboard.read(), + writeText: (text) => navigator.clipboard.writeText(text), +}; + +async function readClipboardImage(clipboard: TerminalClipboard): Promise { + 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): 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); diff --git a/packages/app/src/terminal/runtime/terminal-paste.test.ts b/packages/app/src/terminal/runtime/terminal-paste.test.ts index 48832003c9..b052a62c94 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.test.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.test.ts @@ -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); }); }); diff --git a/packages/app/src/terminal/runtime/terminal-paste.ts b/packages/app/src/terminal/runtime/terminal-paste.ts index f02d301e07..8830ff0b6f 100644 --- a/packages/app/src/terminal/runtime/terminal-paste.ts +++ b/packages/app/src/terminal/runtime/terminal-paste.ts @@ -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 { +/** Pastes clipboard text. Returns whether there was text to paste. */ +export async function pasteTerminalClipboard(input: PasteTerminalClipboardInput): Promise { const text = await input.clipboard.readText(); if (text.length === 0) { - return; + return false; } input.terminal.paste(text); + return true; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index 50f080b37c..d1825d7eb7 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -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 = 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): void { + this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file)); + } + + private async sendFilePaste( + streamKey: string, + file: Promise, + ): Promise { + 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; } diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index c7fc27652e..e7a9cfd8d1 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n'; diff --git a/packages/app/src/terminal/webview/terminal-find.browser.test.ts b/packages/app/src/terminal/webview/terminal-find.browser.test.ts index 37e83c6ba9..24176331d2 100644 --- a/packages/app/src/terminal/webview/terminal-find.browser.test.ts +++ b/packages/app/src/terminal/webview/terminal-find.browser.test.ts @@ -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" }); +});