From 1eea1381eb2eeaa1cee441095a2a8f00784c5d02 Mon Sep 17 00:00:00 2001 From: Sebastian Arena Date: Mon, 26 Oct 2026 21:09:19 -0400 Subject: [PATCH] M1: terminal input-sink bridge, upload button in tab-bar slot and key bar, picked-file routing, space-separated pasted paths --- .../browser/terminal-protocol-query.spec.ts | 103 +++++++++++++++++- packages/app/src/attachments/selected-file.ts | 2 + .../terminal-copy-paste-actions.tsx | 22 +++- packages/app/src/components/terminal-pane.tsx | 46 ++++++-- packages/app/src/hooks/use-file-picker.ts | 7 +- packages/app/src/i18n/resources/ar.ts | 3 + packages/app/src/i18n/resources/en.ts | 3 + packages/app/src/i18n/resources/es.ts | 3 + packages/app/src/i18n/resources/fr.ts | 3 + packages/app/src/i18n/resources/ja.ts | 3 + packages/app/src/i18n/resources/ko.ts | 3 + packages/app/src/i18n/resources/pt-BR.ts | 3 + packages/app/src/i18n/resources/ru.ts | 3 + packages/app/src/i18n/resources/zh-CN.ts | 3 + .../workspace/workspace-desktop-tabs-row.tsx | 47 +++++++- .../terminal/drop/terminal-file-drop.test.ts | 46 +++++++- .../src/terminal/drop/terminal-file-drop.ts | 24 +++- .../src/terminal/drop/terminal-path.test.ts | 11 +- .../app/src/terminal/drop/terminal-path.ts | 8 +- .../app/src/terminal/file-paste/index.test.ts | 9 +- packages/app/src/terminal/file-paste/index.ts | 4 +- .../src/terminal/input-sink/index.test.tsx | 97 +++++++++++++++++ packages/app/src/terminal/input-sink/index.ts | 81 ++++++++++++++ .../runtime/terminal-virtual-keyboard.test.ts | 15 ++- .../runtime/terminal-virtual-keyboard.ts | 12 +- .../toolbar/terminal-toolbar-slot.tsx | 20 ++++ .../toolbar/terminal-upload-button.tsx | 69 ++++++++++++ 27 files changed, 617 insertions(+), 33 deletions(-) create mode 100644 packages/app/src/terminal/input-sink/index.test.tsx create mode 100644 packages/app/src/terminal/input-sink/index.ts create mode 100644 packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx create mode 100644 packages/app/src/terminal/toolbar/terminal-upload-button.tsx diff --git a/packages/app/e2e/browser/terminal-protocol-query.spec.ts b/packages/app/e2e/browser/terminal-protocol-query.spec.ts index d5a0071189..1ce18155ea 100644 --- a/packages/app/e2e/browser/terminal-protocol-query.spec.ts +++ b/packages/app/e2e/browser/terminal-protocol-query.spec.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import path from "node:path"; -import type { Page } from "@playwright/test"; +import type { Locator, 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"; @@ -32,7 +32,8 @@ 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. +// the terminal and where the file was read from. Each paste body is also echoed +// as PASTED:, whitespace escaped so it survives the capture's line joins. const FILE_RECEIVER_SCRIPT = String.raw` const fs = require("node:fs"); const crypto = require("node:crypto"); @@ -45,6 +46,11 @@ process.stdin.on("data", (chunk) => { for (;;) { const paste = /\x1b\[200~([^]*?)\x1b\[201~/.exec(buffer); if (!paste) break; + const pasted = JSON.stringify(paste[1]).replace( + /\s/g, + (space) => "\\u" + space.charCodeAt(0).toString(16).padStart(4, "0"), + ); + process.stdout.write("PASTED:" + pasted + "\r\n"); for (const [, path] of paste[1].matchAll(/'([^']*)'/g)) { const digest = crypto.createHash("sha256").update(fs.readFileSync(path)).digest("hex"); const location = /[\\/]uploads[\\/]upload_/.test(path) ? "upload" : "local"; @@ -79,6 +85,22 @@ function terminalSurface(page: Page) { return page.getByTestId("terminal-surface").filter({ visible: true }); } +function visibleTestId(page: Page, testId: string) { + return page.getByTestId(testId).filter({ visible: true }).first(); +} + +/** The test ID of the toolbar control rendered immediately before `control`. */ +async function controlBefore(control: Locator): Promise { + return await control.evaluate((element) => { + let group = element.parentElement; + while (group && group.querySelectorAll('[role="button"]').length < 2) { + group = group.parentElement; + } + const buttons = Array.from(group?.querySelectorAll('[role="button"]') ?? []); + return buttons[buttons.indexOf(element) - 1]?.getAttribute("data-testid") ?? null; + }); +} + function terminalInput(page: Page) { return terminalSurface(page).locator(".xterm-helper-textarea"); } @@ -202,6 +224,21 @@ test.describe("Terminal protocol queries", () => { await expect.poll(() => terminalOutput(terminalId), { timeout: 10_000 }).toContain(text); } + // Every bracketed paste body the receiver consumed, in order and joined: what a + // shell would read as one command line. + async function pastedText(terminalId: string): Promise { + const output = await terminalOutput(terminalId); + return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)] + .map(([, json]) => JSON.parse(json) as string) + .join(""); + } + + // Exactly one ASCII space after each quoted path and nothing else between them, + // so the paths stay separate shell words. + async function expectSeparatePastedPaths(terminalId: string): Promise { + await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/); + } + test("pastes images from two panes into their own terminals as uploaded host files", async ({ page, }) => { @@ -238,6 +275,67 @@ test.describe("Terminal protocol queries", () => { await expectTerminalOutput(terminal.id, fileRead(pngBytes(3), "upload")); await expectTerminalOutput(terminal.id, fileRead(Buffer.from("remote notes"), "upload")); + await expectSeparatePastedPaths(terminal.id); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("keeps the paths of two consecutive drops to another machine apart", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-drop-remote-twice"); + const first = { name: "first.png", type: "image/png", bytes: pngBytes(10) }; + const second = { name: "second.txt", type: "text/plain", bytes: Buffer.from("second drop") }; + try { + await dropFiles(page, [first]); + await expectTerminalOutput(terminal.id, fileRead(first.bytes, "upload")); + await dropFiles(page, [second]); + await expectTerminalOutput(terminal.id, fileRead(second.bytes, "upload")); + + await expectSeparatePastedPaths(terminal.id); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("upload button uploads picked files when the host is another machine", async ({ page }) => { + const terminal = await openFileReceiver(page, "file-pick-remote"); + const files = [ + { name: "picked.png", mimeType: "image/png", buffer: pngBytes(11) }, + { name: "picked.txt", mimeType: "text/plain", buffer: Buffer.from("picked notes") }, + ]; + try { + const upload = visibleTestId(page, "terminal-toolbar-upload"); + await expect(upload).toHaveCount(1); + expect(await controlBefore(visibleTestId(page, "workspace-split-pane-menu"))).toBe( + "terminal-toolbar-upload", + ); + + const chooser = page.waitForEvent("filechooser"); + await upload.click(); + await (await chooser).setFiles(files); + + await expectTerminalOutput(terminal.id, fileRead(files[0].buffer, "upload")); + await expectTerminalOutput(terminal.id, fileRead(files[1].buffer, "upload")); + await expectSeparatePastedPaths(terminal.id); + } finally { + await harness.killTerminal(terminal.id); + } + }); + + test("shows the upload button only while the pane's active tab is a terminal", async ({ + page, + }) => { + const terminal = await openFileReceiver(page, "file-pick-slot"); + try { + await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1); + + await visibleTestId(page, "workspace-new-tab-button").click(); + await visibleTestId(page, "workspace-new-tab-menu-diff").click(); + await expect(visibleTestId(page, "working-diff-panel")).toBeVisible(); + await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(0); + + await visibleTestId(page, `workspace-tab-terminal_${terminal.id}`).click(); + await expect(visibleTestId(page, "terminal-toolbar-upload")).toHaveCount(1); } finally { await harness.killTerminal(terminal.id); } @@ -258,6 +356,7 @@ test.describe("Terminal protocol queries", () => { await expectTerminalOutput(terminal.id, fileRead(pngBytes(9), "local")); await expectTerminalOutput(terminal.id, fileRead(Buffer.from("local notes"), "local")); + await expectSeparatePastedPaths(terminal.id); expect(await terminalOutput(terminal.id)).not.toContain(":upload"); await expect(page.getByTestId("terminal-file-uploading")).toHaveCount(0); } finally { diff --git a/packages/app/src/attachments/selected-file.ts b/packages/app/src/attachments/selected-file.ts index f0746ab760..d59fbecd30 100644 --- a/packages/app/src/attachments/selected-file.ts +++ b/packages/app/src/attachments/selected-file.ts @@ -5,6 +5,8 @@ import { readDesktopFileBase64 } from "@/desktop/attachments/desktop-preview-url export interface SelectedFile { fileName: string; mimeType: string; + /** Where the file sits on this machine's disk; set only by the desktop app's file dialog. */ + desktopPath?: string; readBytes(): Promise; } diff --git a/packages/app/src/components/terminal-copy-paste-actions.tsx b/packages/app/src/components/terminal-copy-paste-actions.tsx index bf5f86eedb..0370a7c6f1 100644 --- a/packages/app/src/components/terminal-copy-paste-actions.tsx +++ b/packages/app/src/components/terminal-copy-paste-actions.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, type ReactNode } from "react"; import { Pressable, Text, type PressableStateCallbackType } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -45,8 +45,23 @@ export function TerminalFloatingCopyAction({ ); } +export interface TerminalKeyBarIconActionProps { + accessibilityLabel: string; + testID: string; + icon: ReactNode; + disabled?: boolean; + onPress: () => void; +} + +/** A key-bar action shown as an icon, styled like the bar's other keys. */ +export function TerminalKeyBarIconAction(props: TerminalKeyBarIconActionProps) { + return ; +} + interface TerminalActionButtonProps { - label: string; + /** Text shown when no icon is given. */ + label?: string; + icon?: ReactNode; accessibilityLabel: string; testID: string; disabled?: boolean; @@ -56,6 +71,7 @@ interface TerminalActionButtonProps { function TerminalActionButton({ label, + icon, accessibilityLabel, testID, disabled = false, @@ -94,7 +110,7 @@ function TerminalActionButton({ onPress={handlePress} style={pressableStyle} > - {label} + {icon ?? {label}} ); } diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 6c1561bc5b..c87bcb4f07 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -57,8 +57,15 @@ 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 { + routeTerminalDrop, + routeTerminalPickedFile, + type TerminalDropDelivery, +} from "@/terminal/drop/terminal-file-drop"; +import { preparePathsForTerminalInput } from "@/terminal/drop/terminal-path"; +import { useRegisterTerminalInputSink, type TerminalInputSink } from "@/terminal/input-sink"; +import type { SelectedFile } from "@/attachments/selected-file"; +import { TerminalUploadButton } from "@/terminal/toolbar/terminal-upload-button"; import { toErrorMessage } from "@/utils/error-messages"; import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session"; import { @@ -913,11 +920,11 @@ export function TerminalPane({ }); }); - const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { - const bridge = getDesktopHost(); + // Shared by drops and picks: own paths are pasted together at once, uploads go + // through the paste queue in gesture order. + const deliverToTerminal = useStableEvent((deliveries: TerminalDropDelivery[]) => { const paths: string[] = []; - for (const item of items) { - const delivery = routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge }); + for (const delivery of deliveries) { if (delivery.kind === "path") { paths.push(delivery.path); } else { @@ -925,11 +932,34 @@ export function TerminalPane({ } } if (paths.length > 0) { - pasteTerminalText(prepareDroppedPathsForTerminal(paths)); + pasteTerminalText(preparePathsForTerminalInput(paths)); } requestTerminalFocus(); }); + const handleDroppedFiles = useStableEvent((items: DroppedItem[]) => { + const bridge = getDesktopHost(); + deliverToTerminal( + items.map((item) => routeTerminalDrop(item, { hostIsLocal: isLocalHost, bridge })), + ); + }); + + const deliverPickedFiles = useStableEvent((files: SelectedFile[]) => { + deliverToTerminal( + files.map((file) => routeTerminalPickedFile(file, { hostIsLocal: isLocalHost })), + ); + }); + + const inputSink = useMemo( + () => ({ + pasteText: pasteTerminalText, + deliverFiles: deliverPickedFiles, + focus: requestTerminalFocus, + }), + [pasteTerminalText, deliverPickedFiles, requestTerminalFocus], + ); + useRegisterTerminalInputSink(serverId, terminalId, inputSink); + const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => { const nextSize = { rows: input.rows, cols: input.cols }; const claim = resolveTerminalResizeClaim({ @@ -1171,6 +1201,8 @@ export function TerminalPane({ onPaste={handleTerminalPaste} /> ) : null; + case "upload": + return ; case "keyboardToggle": return ( { for (const filePath of paths) { const fileName = filePath.split("/").pop() ?? filePath.split("\\").pop() ?? filePath; const mimeType = getMimeTypeFromPath(filePath); - result.push({ fileName, mimeType, readBytes: () => readDesktopFileBytes(filePath) }); + result.push({ + fileName, + mimeType, + desktopPath: filePath, + readBytes: () => readDesktopFileBytes(filePath), + }); } return result; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 108fad41d4..e26ef022a0 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -620,6 +620,9 @@ export const ar: TranslationResources = { fileUploaded: "تم رفع الملف", pasteFileFailed: "تعذر لصق الملف: {{reason}}", uploadingFile: "جارٍ رفع الملف…", + actions: { + uploadFile: "رفع ملف", + }, }, sheet: { openAsTab: "فتح كعلامة تبويب", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16bddd9cf9..eca04f7720 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -617,6 +617,9 @@ export const en = { fileUploaded: "File uploaded", pasteFileFailed: "Failed to paste file: {{reason}}", uploadingFile: "Uploading file…", + actions: { + uploadFile: "Upload 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 2226583781..3d13c60e31 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -626,6 +626,9 @@ export const es: TranslationResources = { fileUploaded: "Archivo subido", pasteFileFailed: "No se pudo pegar el archivo: {{reason}}", uploadingFile: "Subiendo archivo…", + actions: { + uploadFile: "Subir 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 65d72d405b..0cd7d0163e 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -626,6 +626,9 @@ export const fr: TranslationResources = { fileUploaded: "Fichier envoyé", pasteFileFailed: "Impossible de coller le fichier : {{reason}}", uploadingFile: "Envoi du fichier…", + actions: { + uploadFile: "Envoyer un 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 5829bf4fa3..761f1428ea 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -626,6 +626,9 @@ export const ja: TranslationResources = { fileUploaded: "ファイルをアップロードしました", pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}", uploadingFile: "ファイルをアップロード中…", + actions: { + uploadFile: "ファイルをアップロード", + }, }, sheet: { openAsTab: "タブで開く", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 36a27fc84e..5a01781a73 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -621,6 +621,9 @@ export const ko: TranslationResources = { fileUploaded: "파일을 업로드했습니다", pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}", uploadingFile: "파일 업로드 중…", + actions: { + uploadFile: "파일 업로드", + }, }, sheet: { openAsTab: "탭으로 열기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5a83b4345c..a97fb721f4 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -625,6 +625,9 @@ export const ptBR: TranslationResources = { fileUploaded: "Arquivo enviado", pasteFileFailed: "Falha ao colar o arquivo: {{reason}}", uploadingFile: "Enviando arquivo…", + actions: { + uploadFile: "Enviar 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 135d5fe2f9..7d8328ab17 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -627,6 +627,9 @@ export const ru: TranslationResources = { fileUploaded: "Файл загружен", pasteFileFailed: "Не удалось вставить файл: {{reason}}", uploadingFile: "Загрузка файла…", + actions: { + uploadFile: "Загрузить файл", + }, }, sheet: { openAsTab: "Открыть во вкладке", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 78b0f06442..cc2e885edb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -620,6 +620,9 @@ export const zhCN: TranslationResources = { fileUploaded: "文件已上传", pasteFileFailed: "无法粘贴文件:{{reason}}", uploadingFile: "正在上传文件…", + actions: { + uploadFile: "上传文件", + }, }, sheet: { openAsTab: "作为标签页打开", diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index f7645ac168..7fec8bdc2d 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -8,6 +8,7 @@ import React, { useRef, useState, type Dispatch, + type ReactNode, type SetStateAction, } from "react"; import { Pressable, Text, View, type LayoutChangeEvent, type ScrollView } from "react-native"; @@ -73,6 +74,7 @@ import { type WorkspaceTabRailSurface, } from "@/screens/workspace/workspace-tab-chip-backdrop"; import type { Theme } from "@/styles/theme"; +import { TerminalToolbarSlot } from "@/terminal/toolbar/terminal-toolbar-slot"; import { RenderProfile } from "@/utils/render-profiler"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; @@ -103,6 +105,8 @@ const PANE_SPLIT_ACTIONS_RESERVED_WIDTH = PANE_SPLIT_ACTIONS_HORIZONTAL_PADDING * 2 + PANE_SPLIT_ACTIONS_OUTER_MARGIN; const PANE_MAXIMIZE_ACTION_RESERVED_WIDTH = smallIconButtonChromeFrameSize(false) + 1; +// The terminal slot holds one icon button (upload). +const TERMINAL_SLOT_RESERVED_WIDTH = smallIconButtonChromeFrameSize(false) + 1; // Chip geometry. `layoutMetrics` measures tabs from these same numbers, so a chip that changes // shape without changing them mis-measures and drops the row into the overflow-scroll fallback at // the wrong width. Keep them together. @@ -278,6 +282,7 @@ function WorkspacePaneToolbarActions({ onSplitRight, onSplitDown, onTogglePaneMaximized, + terminalSlot, }: { panePanelKinds: PanePanelKinds; host: PaneHost; @@ -292,6 +297,8 @@ function WorkspacePaneToolbarActions({ onSplitRight?: () => void; onSplitDown?: () => void; onTogglePaneMaximized?: () => void; + /** The active terminal tab's controls, rendered immediately before "...". */ + terminalSlot?: ReactNode; }) { const { t } = useTranslation(); const splitRightKeys = useShortcutKeys("workspace-pane-split-right"); @@ -314,7 +321,9 @@ function WorkspacePaneToolbarActions({ [splitDownKeys], ); const maximizeActionVisible = showMaximizeAction && Boolean(onTogglePaneMaximized); - if (!showNewTabButton && !splitActionsVisible && !maximizeActionVisible) return null; + if (!showNewTabButton && !splitActionsVisible && !maximizeActionVisible && !terminalSlot) { + return null; + } return ( @@ -347,6 +356,7 @@ function WorkspacePaneToolbarActions({ )} ) : null} + {terminalSlot} {splitActionsVisible && onSplitRight && onSplitDown ? ( item.isActive)?.tab; + return activeTab?.target.kind === "terminal" ? activeTab.target.terminalId : null; +} + function ResolvedWorkspaceDesktopTabsRow({ host = "main", launchPurpose = "primary", @@ -1072,6 +1092,19 @@ function ResolvedWorkspaceDesktopTabsRow({ updateMeasuredWidth(setExitFocusModeWidth, event); }, []); + const activeTerminalId = resolveSlotTerminalId(tabs); + const terminalSlot = useMemo( + () => + activeTerminalId ? ( + + ) : null, + [activeTerminalId, normalizedServerId], + ); + const layoutMetrics = useMemo( () => ({ rowHorizontalInset: 0, @@ -1080,7 +1113,8 @@ function ResolvedWorkspaceDesktopTabsRow({ DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH + (focusModeEnabled ? exitFocusModeWidth : 0) + (showPaneSplitActions ? PANE_SPLIT_ACTIONS_RESERVED_WIDTH : 0) + - (showPaneMaximizeAction ? PANE_MAXIMIZE_ACTION_RESERVED_WIDTH : 0), + (showPaneMaximizeAction ? PANE_MAXIMIZE_ACTION_RESERVED_WIDTH : 0) + + (activeTerminalId ? TERMINAL_SLOT_RESERVED_WIDTH : 0), ), rowPaddingHorizontal: TAB_ROW_PADDING_HORIZONTAL, tabGap: TAB_CHIP_GAP, @@ -1091,7 +1125,13 @@ function ResolvedWorkspaceDesktopTabsRow({ tabHorizontalPadding: TAB_CHIP_HORIZONTAL_PADDING, closeButtonWidth: TAB_CLOSE_BUTTON_RESERVED_WIDTH, }), - [exitFocusModeWidth, focusModeEnabled, showPaneMaximizeAction, showPaneSplitActions], + [ + activeTerminalId, + exitFocusModeWidth, + focusModeEnabled, + showPaneMaximizeAction, + showPaneSplitActions, + ], ); const panePanelKinds = useMemo(() => tabs.map(({ tab }) => tab.kind), [tabs]); @@ -1446,6 +1486,7 @@ function ResolvedWorkspaceDesktopTabsRow({ onSplitRight={onSplitRight} onSplitDown={onSplitDown} onTogglePaneMaximized={onTogglePaneMaximized} + terminalSlot={terminalSlot} /> ); 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 5a439f316b..356bbdb567 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.test.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { DesktopHostBridge } from "@/desktop/host"; -import { routeTerminalDrop } from "./terminal-file-drop"; +import type { SelectedFile } from "@/attachments/selected-file"; +import { routeTerminalDrop, routeTerminalPickedFile } from "./terminal-file-drop"; function fakeFile(input: { name: string; type?: string; legacyPath?: string }): File { const file = { name: input.name, type: input.type ?? "" } as unknown as File; @@ -68,3 +69,46 @@ describe("terminal file drop routing", () => { }); }); }); + +describe("terminal picked file routing", () => { + const bytes = new Uint8Array([7, 8, 9]); + + function picked(input: { desktopPath?: string } = {}): SelectedFile { + return { + fileName: "report.pdf", + mimeType: "application/pdf", + ...input, + readBytes: async () => bytes, + }; + } + + it("pastes the desktop dialog's own path when the host is the client's machine", () => { + expect( + routeTerminalPickedFile(picked({ desktopPath: "/Users/me/report.pdf" }), { + hostIsLocal: true, + }), + ).toEqual({ kind: "path", path: "/Users/me/report.pdf" }); + }); + + it("uploads the desktop dialog's file when the host is another machine", async () => { + const delivery = routeTerminalPickedFile(picked({ desktopPath: "/Users/me/report.pdf" }), { + hostIsLocal: false, + }); + + expect(delivery.kind).toBe("upload"); + await expect(delivery.kind === "upload" ? delivery.read() : null).resolves.toEqual({ + fileName: "report.pdf", + mimeType: "application/pdf", + bytes, + }); + }); + + it("uploads browser and native document-picker files, which carry no path", () => { + expect(routeTerminalPickedFile(picked(), { hostIsLocal: true })).toMatchObject({ + kind: "upload", + }); + expect(routeTerminalPickedFile(picked(), { hostIsLocal: false })).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 18221271e5..f65c36ea32 100644 --- a/packages/app/src/terminal/drop/terminal-file-drop.ts +++ b/packages/app/src/terminal/drop/terminal-file-drop.ts @@ -1,5 +1,5 @@ import { getMimeTypeFromPath } from "@/attachments/file-types"; -import { readDesktopFileBytes } from "@/attachments/selected-file"; +import { readDesktopFileBytes, type SelectedFile } from "@/attachments/selected-file"; import type { DroppedItem } from "@/components/file-drop/types"; import type { DesktopHostBridge } from "@/desktop/host"; import { readTerminalFile, type TerminalFile } from "../file-paste"; @@ -61,3 +61,25 @@ export function routeTerminalDrop( ? { kind: "path", path: clientPath } : { kind: "upload", read: () => readTerminalFile(item.file) }; } + +/** + * A picked file follows the same rule as a dropped one: its own path when the + * host is this machine and the picker knows where the file is (the desktop app's + * dialog), otherwise its bytes are uploaded. + */ +export function routeTerminalPickedFile( + file: SelectedFile, + input: { hostIsLocal: boolean }, +): TerminalDropDelivery { + if (input.hostIsLocal && file.desktopPath) { + return { kind: "path", path: file.desktopPath }; + } + return { + kind: "upload", + read: async () => ({ + fileName: file.fileName, + mimeType: file.mimeType, + bytes: await file.readBytes(), + }), + }; +} diff --git a/packages/app/src/terminal/drop/terminal-path.test.ts b/packages/app/src/terminal/drop/terminal-path.test.ts index 6c4b580e48..b034027412 100644 --- a/packages/app/src/terminal/drop/terminal-path.test.ts +++ b/packages/app/src/terminal/drop/terminal-path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { prepareDroppedPathsForTerminal, prepareTerminalPath } from "./terminal-path"; +import { prepareTerminalPath, preparePathsForTerminalInput } from "./terminal-path"; describe("terminal path quoting", () => { it("prepares POSIX paths with conservative escaping", () => { @@ -18,9 +18,12 @@ describe("terminal path quoting", () => { ); }); - 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'", + it("ends every path with one space so consecutive pastes stay separate words", () => { + expect(preparePathsForTerminalInput(["/tmp/a.png", "/tmp/b c.png"])).toBe( + "'/tmp/a.png' '/tmp/b c.png' ", + ); + expect(preparePathsForTerminalInput(["C:\\Users\\me\\photo one.png"])).toBe( + '"C:\\Users\\me\\photo one.png" ', ); }); }); diff --git a/packages/app/src/terminal/drop/terminal-path.ts b/packages/app/src/terminal/drop/terminal-path.ts index bc29edd54b..7ff2a3c2fd 100644 --- a/packages/app/src/terminal/drop/terminal-path.ts +++ b/packages/app/src/terminal/drop/terminal-path.ts @@ -35,6 +35,10 @@ export function prepareTerminalPath(path: string): string { return escapeNonWindowsPath(path); } -export function prepareDroppedPathsForTerminal(paths: readonly string[]): string { - return paths.map(prepareTerminalPath).join(" "); +/** + * Paths as terminal input: each quoted and followed by one space, so paths pasted + * together or by consecutive gestures stay separate shell words. Never Enter. + */ +export function preparePathsForTerminalInput(paths: readonly string[]): string { + return paths.map((path) => `${prepareTerminalPath(path)} `).join(""); } diff --git a/packages/app/src/terminal/file-paste/index.test.ts b/packages/app/src/terminal/file-paste/index.test.ts index 63a1e5e73d..1934b311d2 100644 --- a/packages/app/src/terminal/file-paste/index.test.ts +++ b/packages/app/src/terminal/file-paste/index.test.ts @@ -74,7 +74,8 @@ describe("terminal file paste queue", () => { firstUpload.resolve("/host/uploads/a.png"); await settle(); - expect(host.pasted).toEqual(["'/host/uploads/a.png'", "'/host/uploads/b.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/a.png' ", "'/host/uploads/b.png' "]); + expect(host.pasted.join("")).toBe("'/host/uploads/a.png' '/host/uploads/b.png' "); expect(events).toEqual({ uploading: [true, false, true, false], uploaded: 2, failures: [] }); }); @@ -85,7 +86,7 @@ describe("terminal file paste queue", () => { queue.add(Promise.resolve(file("shot.png")), host); await settle(); - expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png"']); + expect(host.pasted).toEqual(['"C:\\Users\\Jo Doe\\.paseo\\uploads\\upload_1\\shot.png" ']); }); it("uploads under a file name that needs no shell quoting", async () => { @@ -120,7 +121,7 @@ describe("terminal file paste queue", () => { expect(events.failures).toEqual(["disk full"]); expect(events.uploaded).toBe(1); - expect(host.pasted).toEqual(["'/host/uploads/b.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/b.png' "]); }); it("reports unreadable and oversized files without uploading them", async () => { @@ -166,7 +167,7 @@ describe("terminal file paste queue", () => { queue.add(Promise.resolve(file("next.png")), host); await settle(); - expect(host.pasted).toEqual(["'/host/uploads/next.png'"]); + expect(host.pasted).toEqual(["'/host/uploads/next.png' "]); expect(events.uploading).toEqual([true, false, true, false]); expect(queue.cancel()).toBe(false); }); diff --git a/packages/app/src/terminal/file-paste/index.ts b/packages/app/src/terminal/file-paste/index.ts index bc96b17c5d..d157b83d96 100644 --- a/packages/app/src/terminal/file-paste/index.ts +++ b/packages/app/src/terminal/file-paste/index.ts @@ -1,5 +1,5 @@ import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit"; -import { prepareTerminalPath } from "../drop/terminal-path"; +import { preparePathsForTerminalInput } from "../drop/terminal-path"; /** File bytes from a paste or drop gesture, before they become a file on the host. */ export interface TerminalFile { @@ -75,7 +75,7 @@ export class TerminalFilePasteQueue { this.setUploading(true); const path = await target.upload({ ...file, fileName: toShellSafeFileName(file.fileName) }); if (isCurrent()) { - target.paste(prepareTerminalPath(path)); + target.paste(preparePathsForTerminalInput([path])); this.events.onUploaded(); } } catch (error) { diff --git a/packages/app/src/terminal/input-sink/index.test.tsx b/packages/app/src/terminal/input-sink/index.test.tsx new file mode 100644 index 0000000000..d2a7bbcffd --- /dev/null +++ b/packages/app/src/terminal/input-sink/index.test.tsx @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getTerminalInputSink, + registerTerminalInputSink, + useRegisterTerminalInputSink, + useTerminalInputSink, + type TerminalInputSink, +} from "./index"; + +function fakeSink(): TerminalInputSink { + return { pasteText: () => {}, deliverFiles: () => {}, focus: () => {} }; +} + +const cleanups: Array<() => void> = []; + +function register(serverId: string, terminalId: string, sink: TerminalInputSink): () => void { + let cleanup = () => {}; + act(() => { + cleanup = registerTerminalInputSink(serverId, terminalId, sink); + }); + const remove = () => act(() => cleanup()); + cleanups.push(remove); + return remove; +} + +afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } +}); + +describe("terminal input sinks", () => { + it("finds a registered sink by its exact server and terminal", () => { + const sink = fakeSink(); + register("server-a", "term-1", sink); + + expect(getTerminalInputSink("server-a", "term-1")).toBe(sink); + expect(getTerminalInputSink("server-a", "term-2")).toBeNull(); + expect(getTerminalInputSink("server-b", "term-1")).toBeNull(); + }); + + it("lets a newer pane replace a terminal's sink and ignores the older pane's late cleanup", () => { + const older = fakeSink(); + const newer = fakeSink(); + const removeOlder = register("server-a", "term-1", older); + const removeNewer = register("server-a", "term-1", newer); + + expect(getTerminalInputSink("server-a", "term-1")).toBe(newer); + removeOlder(); + expect(getTerminalInputSink("server-a", "term-1")).toBe(newer); + removeNewer(); + expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); + }); + + it("removes only its own terminal's entry", () => { + const first = fakeSink(); + const second = fakeSink(); + const removeFirst = register("server-a", "term-1", first); + register("server-a", "term-2", second); + + removeFirst(); + + expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); + expect(getTerminalInputSink("server-a", "term-2")).toBe(second); + }); + + it("re-renders a terminal's controls only for changes to that terminal", () => { + let renders = 0; + const { result } = renderHook(() => { + renders += 1; + return useTerminalInputSink("server-a", "term-1"); + }); + expect(result.current).toBeNull(); + const rendersBefore = renders; + + register("server-a", "term-2", fakeSink()); + expect(renders).toBe(rendersBefore); + + const sink = fakeSink(); + register("server-a", "term-1", sink); + expect(result.current).toBe(sink); + expect(renders).toBe(rendersBefore + 1); + }); + + it("registers a pane's sink while mounted and removes it on unmount", () => { + const sink = fakeSink(); + const { unmount } = renderHook(() => useRegisterTerminalInputSink("server-a", "term-1", sink)); + + expect(getTerminalInputSink("server-a", "term-1")).toBe(sink); + unmount(); + expect(getTerminalInputSink("server-a", "term-1")).toBeNull(); + }); +}); diff --git a/packages/app/src/terminal/input-sink/index.ts b/packages/app/src/terminal/input-sink/index.ts new file mode 100644 index 0000000000..cc7a804304 --- /dev/null +++ b/packages/app/src/terminal/input-sink/index.ts @@ -0,0 +1,81 @@ +import { useEffect } from "react"; +import { create } from "zustand"; +import type { SelectedFile } from "@/attachments/selected-file"; + +/** + * What a mounted terminal pane accepts from controls rendered outside it: the + * pane's tab-bar slot and its key bar. Those controls are siblings of the pane + * under three different hosts (split pane, fallback row, explorer sidebar), so + * they find the pane here by terminal identity instead of through props. + */ +export interface TerminalInputSink { + /** Pastes text the way the terminal's TUI expects it (bracketed when enabled). */ + pasteText(text: string): void; + /** Delivers picked files: own paths when the host can read them, uploads otherwise. */ + deliverFiles(files: SelectedFile[]): void; + /** Returns keyboard focus to the terminal. */ + focus(): void; +} + +interface TerminalInputSinkState { + sinks: Record; +} + +const useTerminalInputSinkStore = create(() => ({ sinks: {} })); + +export function terminalInputSinkKey(serverId: string, terminalId: string): string { + return `${serverId}:${terminalId}`; +} + +/** + * Registers `sink` under the terminal's key. The returned cleanup removes the + * entry only while it is still this sink, so a retained pane that unmounts after + * a newer pane registered the same terminal never removes the newer one. + */ +export function registerTerminalInputSink( + serverId: string, + terminalId: string, + sink: TerminalInputSink, +): () => void { + const key = terminalInputSinkKey(serverId, terminalId); + useTerminalInputSinkStore.setState((state) => ({ sinks: { ...state.sinks, [key]: sink } })); + return () => { + useTerminalInputSinkStore.setState((state) => { + if (state.sinks[key] !== sink) { + return state; + } + const { [key]: _removed, ...sinks } = state.sinks; + return { sinks }; + }); + }; +} + +export function getTerminalInputSink( + serverId: string, + terminalId: string, +): TerminalInputSink | null { + return ( + useTerminalInputSinkStore.getState().sinks[terminalInputSinkKey(serverId, terminalId)] ?? null + ); +} + +/** Registers the pane's sink while it is mounted. Pass a memoized sink. */ +export function useRegisterTerminalInputSink( + serverId: string, + terminalId: string, + sink: TerminalInputSink, +): void { + useEffect( + () => registerTerminalInputSink(serverId, terminalId, sink), + [serverId, terminalId, sink], + ); +} + +/** The mounted pane's sink for this terminal; re-renders only when that entry changes. */ +export function useTerminalInputSink( + serverId: string, + terminalId: string, +): TerminalInputSink | null { + const key = terminalInputSinkKey(serverId, terminalId); + return useTerminalInputSinkStore((state) => state.sinks[key] ?? null); +} diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts index d2ef76fefd..5702f6c815 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.test.ts @@ -84,9 +84,10 @@ describe("terminal virtual keyboard policy", () => { "terminal-key-ctrl", "terminal-key-up", "terminal-key-shift", - "terminal-keyboard-toggle", "terminal-key-alt", + "terminal-keyboard-toggle", "terminal-paste", + "terminal-upload", "terminal-key-left", "terminal-key-down", "terminal-key-right", @@ -110,6 +111,18 @@ describe("terminal virtual keyboard policy", () => { }); }); + it("keeps Paste and Upload next to each other in every arrangement", () => { + for (const rows of [ + TERMINAL_VIRTUAL_KEYBOARD_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS, + TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS, + ]) { + const rowIds = rows.map((row) => row.map(getTerminalVirtualKeyboardControlId)); + const ids = rowIds.find((row) => row.includes("terminal-paste")) ?? []; + expect(ids.indexOf("terminal-upload")).toBe(ids.indexOf("terminal-paste") + 1); + } + }); + it("keeps Paste in the keyboard and Copy out of the permanent row", () => { expect(controlsByType("paste")).toHaveLength(1); expect(controlIds()).toContain("terminal-paste"); diff --git a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts index 3dd244fa95..c7c673372a 100644 --- a/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts +++ b/packages/app/src/terminal/runtime/terminal-virtual-keyboard.ts @@ -18,10 +18,14 @@ export type TerminalVirtualKeyboardControl = | { type: "paste"; } + | { + type: "upload"; + } | { type: "keyboardToggle"; }; +// Paste and upload stay next to each other in every arrangement. export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ [ { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.esc }, @@ -29,11 +33,12 @@ export const TERMINAL_VIRTUAL_KEYBOARD_ROWS = [ { type: "modifier", modifier: "ctrl" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, { type: "modifier", modifier: "shift" }, + { type: "modifier", modifier: "alt" }, { type: "keyboardToggle" }, ], [ - { type: "modifier", modifier: "alt" }, { type: "paste" }, + { type: "upload" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.left }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.down }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, @@ -53,6 +58,7 @@ export const TERMINAL_VIRTUAL_KEYBOARD_WIDE_ROWS = [ { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.right }, { type: "paste" }, + { type: "upload" }, { type: "keyboardToggle" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, ], @@ -69,8 +75,8 @@ export const TERMINAL_VIRTUAL_KEYBOARD_THREE_COLUMN_ROWS = [ { type: "modifier", modifier: "shift" }, { type: "keyboardToggle" }, ], + [{ type: "paste" }, { type: "upload" }], [ - { type: "paste" }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.up }, { type: "key", button: TERMINAL_VIRTUAL_KEY_BUTTONS.enter }, ], @@ -113,6 +119,8 @@ export function getTerminalVirtualKeyboardControlId( return `terminal-key-${control.modifier}`; case "paste": return "terminal-paste"; + case "upload": + return "terminal-upload"; case "keyboardToggle": return "terminal-keyboard-toggle"; } diff --git a/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx b/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx new file mode 100644 index 0000000000..3ed169802c --- /dev/null +++ b/packages/app/src/terminal/toolbar/terminal-toolbar-slot.tsx @@ -0,0 +1,20 @@ +import { useTerminalInputSink } from "@/terminal/input-sink"; +import { TerminalUploadButton } from "./terminal-upload-button"; + +interface TerminalToolbarSlotProps { + serverId: string; + terminalId: string; +} + +/** + * The terminal controls in a pane's tab bar, shown while the pane's active tab is + * a terminal. A view over the mounted pane's input sink: it owns no terminal + * state and renders nothing until the pane has registered. + */ +export function TerminalToolbarSlot({ serverId, terminalId }: TerminalToolbarSlotProps) { + const sink = useTerminalInputSink(serverId, terminalId); + if (!sink) { + return null; + } + return ; +} diff --git a/packages/app/src/terminal/toolbar/terminal-upload-button.tsx b/packages/app/src/terminal/toolbar/terminal-upload-button.tsx new file mode 100644 index 0000000000..8d4dcbd075 --- /dev/null +++ b/packages/app/src/terminal/toolbar/terminal-upload-button.tsx @@ -0,0 +1,69 @@ +import { useCallback, useMemo } from "react"; +import { Paperclip } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; +import { ToolbarButton } from "@/components/ui/pane-content-toolbar"; +import { TerminalKeyBarIconAction } from "@/components/terminal-copy-paste-actions"; +import { useToast } from "@/contexts/toast-context"; +import { useFilePicker } from "@/hooks/use-file-picker"; +import { toErrorMessage } from "@/utils/error-messages"; +import type { SelectedFile } from "@/attachments/selected-file"; +import type { TerminalInputSink } from "@/terminal/input-sink"; + +const ThemedPaperclip = withUnistyles(Paperclip); +const toolbarIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); +const keyBarIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +interface TerminalUploadButtonProps { + sink: TerminalInputSink; + placement: "toolbar" | "keybar"; +} + +/** + * Picks files and hands them to the terminal's sink. The sink is the one this + * button rendered with when pressed, so files picked for a terminal reach it even + * if the pane switched to another terminal while the picker was open. + */ +export function TerminalUploadButton({ sink, placement }: TerminalUploadButtonProps) { + const { t } = useTranslation(); + const toast = useToast(); + const { pickFiles } = useFilePicker(); + const label = t("workspace.terminal.actions.uploadFile"); + + const handlePress = useCallback(async () => { + let files: SelectedFile[] | null; + try { + files = await pickFiles(); + } catch (error) { + toast.error(t("workspace.terminal.pasteFileFailed", { reason: toErrorMessage(error) })); + return; + } + if (files && files.length > 0) { + sink.deliverFiles(files); + } else { + sink.focus(); + } + }, [pickFiles, sink, t, toast]); + const keyBarIcon = useMemo( + () => , + [], + ); + + if (placement === "toolbar") { + return ( + + + + ); + } + + return ( + + ); +}