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

This commit is contained in:
Sebastian Arena
2026-10-26 21:09:19 -04:00
parent 65ce4b590a
commit 1eea1381eb
27 changed files with 617 additions and 33 deletions
@@ -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:<json>, 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<string | null> {
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<string> {
const output = await terminalOutput(terminalId);
return [...output.matchAll(/PASTED:("(?:[^"\\]|\\.)*")/g)]
.map(([, json]) => JSON.parse(json) as string)
.join("");
}
// Exactly one ASCII space after each quoted path and nothing else between them,
// so the paths stay separate shell words.
async function expectSeparatePastedPaths(terminalId: string): Promise<void> {
await expect.poll(() => pastedText(terminalId)).toMatch(/^'[^']+' '[^']+' $/);
}
test("pastes images from two panes into their own terminals as uploaded host files", async ({
page,
}) => {
@@ -238,6 +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 {
@@ -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<Uint8Array>;
}
@@ -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 <TerminalActionButton {...props} variant="key" />;
}
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}
>
<Text style={textStyle}>{label}</Text>
{icon ?? <Text style={textStyle}>{label}</Text>}
</Pressable>
);
}
+39 -7
View File
@@ -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<TerminalInputSink>(
() => ({
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 <TerminalUploadButton key={controlId} sink={inputSink} placement="keybar" />;
case "keyboardToggle":
return (
<KeyboardToggleButton
+6 -1
View File
@@ -32,7 +32,12 @@ async function pickFilesWithDesktopDialog(): Promise<SelectedFile[] | null> {
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;
+3
View File
@@ -620,6 +620,9 @@ export const ar: TranslationResources = {
fileUploaded: "تم رفع الملف",
pasteFileFailed: "تعذر لصق الملف: {{reason}}",
uploadingFile: "جارٍ رفع الملف…",
actions: {
uploadFile: "رفع ملف",
},
},
sheet: {
openAsTab: "فتح كعلامة تبويب",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -626,6 +626,9 @@ export const ja: TranslationResources = {
fileUploaded: "ファイルをアップロードしました",
pasteFileFailed: "ファイルを貼り付けられませんでした: {{reason}}",
uploadingFile: "ファイルをアップロード中…",
actions: {
uploadFile: "ファイルをアップロード",
},
},
sheet: {
openAsTab: "タブで開く",
+3
View File
@@ -621,6 +621,9 @@ export const ko: TranslationResources = {
fileUploaded: "파일을 업로드했습니다",
pasteFileFailed: "파일을 붙여넣지 못했습니다: {{reason}}",
uploadingFile: "파일 업로드 중…",
actions: {
uploadFile: "파일 업로드",
},
},
sheet: {
openAsTab: "탭으로 열기",
+3
View File
@@ -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",
+3
View File
@@ -627,6 +627,9 @@ export const ru: TranslationResources = {
fileUploaded: "Файл загружен",
pasteFileFailed: "Не удалось вставить файл: {{reason}}",
uploadingFile: "Загрузка файла…",
actions: {
uploadFile: "Загрузить файл",
},
},
sheet: {
openAsTab: "Открыть во вкладке",
+3
View File
@@ -620,6 +620,9 @@ export const zhCN: TranslationResources = {
fileUploaded: "文件已上传",
pasteFileFailed: "无法粘贴文件:{{reason}}",
uploadingFile: "正在上传文件…",
actions: {
uploadFile: "上传文件",
},
},
sheet: {
openAsTab: "作为标签页打开",
@@ -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 (
<ToolbarControls style={styles.paneSplitActions}>
@@ -347,6 +356,7 @@ function WorkspacePaneToolbarActions({
)}
</ToolbarButton>
) : null}
{terminalSlot}
{splitActionsVisible && onSplitRight && onSplitDown ? (
<DropdownMenu>
<ToolbarButton
@@ -1017,6 +1027,16 @@ export function WorkspaceDesktopTabsRow(props: WorkspaceDesktopTabsRowProps) {
);
}
/** The terminal whose controls the tab-bar slot shows: the active tab's, on web and Electron. */
function resolveSlotTerminalId(tabs: readonly WorkspaceDesktopTabRowItem[]): string | null {
// Native tablets use the terminal's key bar instead.
if (isNative) {
return null;
}
const activeTab = tabs.find((item) => 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 ? (
<TerminalToolbarSlot
key={`${normalizedServerId}:${activeTerminalId}`}
serverId={normalizedServerId}
terminalId={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}
/>
</View>
);
@@ -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",
});
});
});
@@ -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(),
}),
};
}
@@ -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" ',
);
});
});
@@ -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("");
}
@@ -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);
});
@@ -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) {
@@ -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();
});
});
@@ -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<string, TerminalInputSink>;
}
const useTerminalInputSinkStore = create<TerminalInputSinkState>(() => ({ 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);
}
@@ -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");
@@ -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";
}
@@ -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 <TerminalUploadButton sink={sink} placement="toolbar" />;
}
@@ -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(
() => <ThemedPaperclip size={16} uniProps={keyBarIconColorMapping} />,
[],
);
if (placement === "toolbar") {
return (
<ToolbarButton label={label} testID="terminal-toolbar-upload" onPress={handlePress}>
<ThemedPaperclip size={14} uniProps={toolbarIconColorMapping} />
</ToolbarButton>
);
}
return (
<TerminalKeyBarIconAction
accessibilityLabel={label}
testID="terminal-upload"
icon={keyBarIcon}
onPress={handlePress}
/>
);
}