Skip to content

Commit edf052c

Browse files
committed
Add mobile terminal paste and upload actions
1 parent d903e09 commit edf052c

9 files changed

Lines changed: 473 additions & 36 deletions

File tree

packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ const hydrationCoordinatorMocks = vi.hoisted(() => {
7171

7272
const uploadHookMocks = vi.hoisted(() => ({
7373
busy: false,
74+
handleClipboardPaste: vi.fn().mockResolvedValue(undefined),
75+
handleFiles: vi.fn().mockResolvedValue(undefined),
7476
}));
7577

7678
vi.mock("../../../hooks/use-viewport", () => ({
@@ -91,7 +93,11 @@ vi.mock("../hydration-coordinator", async () => {
9193
});
9294

9395
vi.mock("../uploads/use-paste-drop-upload", () => ({
94-
usePasteDropUpload: vi.fn(() => ({ busy: uploadHookMocks.busy })),
96+
usePasteDropUpload: vi.fn(() => ({
97+
busy: uploadHookMocks.busy,
98+
handleClipboardPaste: uploadHookMocks.handleClipboardPaste,
99+
handleFiles: uploadHookMocks.handleFiles,
100+
})),
95101
}));
96102

97103
function expectReplayCall(mock: ReturnType<typeof vi.fn>, terminalId: string, lastSeq: number) {
@@ -201,6 +207,10 @@ describe("XtermHost", () => {
201207
hydrationCoordinatorMocks.listeners = new Set();
202208
hydrationCoordinatorMocks.resolveGranted = () => {};
203209
uploadHookMocks.busy = false;
210+
uploadHookMocks.handleClipboardPaste.mockReset();
211+
uploadHookMocks.handleClipboardPaste.mockResolvedValue(undefined);
212+
uploadHookMocks.handleFiles.mockReset();
213+
uploadHookMocks.handleFiles.mockResolvedValue(undefined);
204214
mockTerminal.options = {};
205215
mockTerminal.cols = undefined;
206216
mockTerminal.rows = undefined;
@@ -1483,6 +1493,70 @@ describe("XtermHost", () => {
14831493
expect(screen.getByRole("button", { name: "Escape" })).toBeInTheDocument();
14841494
expect(screen.getByRole("button", { name: "Shift" })).toBeInTheDocument();
14851495
expect(screen.getByRole("button", { name: "Ctrl" })).toBeInTheDocument();
1496+
expect(screen.getByRole("button", { name: "Paste" })).toBeInTheDocument();
1497+
expect(screen.getByRole("button", { name: "Upload" })).toBeInTheDocument();
1498+
});
1499+
1500+
it("routes the mobile paste button through the upload hook clipboard handler", async () => {
1501+
viewportMocks.viewport = "mobile";
1502+
const store = createStore();
1503+
const user = userEvent.setup();
1504+
1505+
store.set(localeAtom, "en");
1506+
store.set(wsClientAtom, {
1507+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
1508+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
1509+
subscribe: vi.fn(() => () => {}),
1510+
getStatus: vi.fn(() => "connected"),
1511+
onStatus: vi.fn(() => () => {}),
1512+
} as never);
1513+
1514+
render(
1515+
<Provider store={store}>
1516+
<XtermHost terminalId="mobile-paste-terminal" workspaceId="test-workspace" />
1517+
</Provider>
1518+
);
1519+
1520+
await user.click(screen.getByRole("button", { name: "Paste" }));
1521+
1522+
expect(uploadHookMocks.handleClipboardPaste).toHaveBeenCalledTimes(1);
1523+
});
1524+
1525+
it("opens the hidden file picker from the mobile upload button and forwards selected files", async () => {
1526+
viewportMocks.viewport = "mobile";
1527+
const store = createStore();
1528+
const user = userEvent.setup();
1529+
const filePickerClickSpy = vi.spyOn(HTMLInputElement.prototype, "click");
1530+
1531+
store.set(localeAtom, "en");
1532+
store.set(wsClientAtom, {
1533+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
1534+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
1535+
subscribe: vi.fn(() => () => {}),
1536+
getStatus: vi.fn(() => "connected"),
1537+
onStatus: vi.fn(() => () => {}),
1538+
} as never);
1539+
1540+
render(
1541+
<Provider store={store}>
1542+
<XtermHost terminalId="mobile-upload-terminal" workspaceId="test-workspace" />
1543+
</Provider>
1544+
);
1545+
1546+
await user.click(screen.getByRole("button", { name: "Upload" }));
1547+
1548+
expect(filePickerClickSpy).toHaveBeenCalledTimes(1);
1549+
1550+
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
1551+
expect(input).not.toBeNull();
1552+
const file = new File(["hello"], "clip.png", { type: "image/png" });
1553+
1554+
await act(async () => {
1555+
fireEvent.change(input!, { target: { files: [file] } });
1556+
});
1557+
1558+
expect(uploadHookMocks.handleFiles).toHaveBeenCalledWith([file]);
1559+
expect(input?.value).toBe("");
14861560
});
14871561

14881562
it("does not render the mobile soft-key handle when the terminal is read-only", () => {

packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.test.tsx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { act, fireEvent, render, screen } from "@testing-library/react";
1+
import { act, fireEvent, render, screen, within } from "@testing-library/react";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33
import { MobileTerminalInputBar } from "./mobile-terminal-input-bar";
44

55
const labels = {
6+
paste: "Paste",
7+
upload: "Upload",
68
shortcuts: "Terminal shortcut keys",
79
ctrl: "Ctrl",
810
ctrlArmed: "Ctrl armed",
@@ -37,6 +39,8 @@ describe("MobileTerminalInputBar", () => {
3739
onCtrlTap={vi.fn()}
3840
onCtrlLongPress={vi.fn()}
3941
onShiftTap={vi.fn()}
42+
onPaste={vi.fn()}
43+
onUpload={vi.fn()}
4044
/>
4145
);
4246

@@ -67,6 +71,8 @@ describe("MobileTerminalInputBar", () => {
6771
onCtrlTap={vi.fn()}
6872
onCtrlLongPress={vi.fn()}
6973
onShiftTap={vi.fn()}
74+
onPaste={vi.fn()}
75+
onUpload={vi.fn()}
7076
/>
7177
);
7278

@@ -84,6 +90,8 @@ describe("MobileTerminalInputBar", () => {
8490
const onKeyPress = vi.fn();
8591
const onCtrlTap = vi.fn();
8692
const onShiftTap = vi.fn();
93+
const onPaste = vi.fn();
94+
const onUpload = vi.fn();
8795

8896
render(
8997
<MobileTerminalInputBar
@@ -94,6 +102,8 @@ describe("MobileTerminalInputBar", () => {
94102
onCtrlTap={onCtrlTap}
95103
onCtrlLongPress={vi.fn()}
96104
onShiftTap={onShiftTap}
105+
onPaste={onPaste}
106+
onUpload={onUpload}
97107
/>
98108
);
99109

@@ -109,6 +119,41 @@ describe("MobileTerminalInputBar", () => {
109119
expect(onCtrlTap).toHaveBeenCalledTimes(1);
110120
fireEvent.click(screen.getByRole("button", { name: labels.shift }));
111121
expect(onShiftTap).toHaveBeenCalledTimes(1);
122+
123+
fireEvent.click(screen.getByRole("button", { name: labels.paste }));
124+
fireEvent.click(screen.getByRole("button", { name: labels.upload }));
125+
126+
expect(onPaste).toHaveBeenCalledTimes(1);
127+
expect(onUpload).toHaveBeenCalledTimes(1);
128+
});
129+
130+
it("keeps mobile actions disabled with the rest of the bar", () => {
131+
const onPaste = vi.fn();
132+
const onUpload = vi.fn();
133+
134+
render(
135+
<MobileTerminalInputBar
136+
ctrlMode="off"
137+
shiftArmed={false}
138+
disabled
139+
labels={labels}
140+
onKeyPress={vi.fn()}
141+
onCtrlTap={vi.fn()}
142+
onCtrlLongPress={vi.fn()}
143+
onShiftTap={vi.fn()}
144+
onPaste={onPaste}
145+
onUpload={onUpload}
146+
/>
147+
);
148+
149+
expect(screen.getByRole("button", { name: labels.paste })).toBeDisabled();
150+
expect(screen.getByRole("button", { name: labels.upload })).toBeDisabled();
151+
152+
fireEvent.click(screen.getByRole("button", { name: labels.paste }));
153+
fireEvent.click(screen.getByRole("button", { name: labels.upload }));
154+
155+
expect(onPaste).not.toHaveBeenCalled();
156+
expect(onUpload).not.toHaveBeenCalled();
112157
});
113158

114159
it("locks ctrl on long press and switches ctrl labels by mode", () => {
@@ -124,6 +169,8 @@ describe("MobileTerminalInputBar", () => {
124169
onCtrlTap={onCtrlTap}
125170
onCtrlLongPress={onCtrlLongPress}
126171
onShiftTap={vi.fn()}
172+
onPaste={vi.fn()}
173+
onUpload={vi.fn()}
127174
/>
128175
);
129176

@@ -146,6 +193,8 @@ describe("MobileTerminalInputBar", () => {
146193
onCtrlTap={onCtrlTap}
147194
onCtrlLongPress={onCtrlLongPress}
148195
onShiftTap={vi.fn()}
196+
onPaste={vi.fn()}
197+
onUpload={vi.fn()}
149198
/>
150199
);
151200

@@ -167,6 +216,8 @@ describe("MobileTerminalInputBar", () => {
167216
onCtrlTap={vi.fn()}
168217
onCtrlLongPress={onCtrlLongPress}
169218
onShiftTap={vi.fn()}
219+
onPaste={vi.fn()}
220+
onUpload={vi.fn()}
170221
/>
171222
);
172223

@@ -182,6 +233,8 @@ describe("MobileTerminalInputBar", () => {
182233
onCtrlTap={vi.fn()}
183234
onCtrlLongPress={onCtrlLongPress}
184235
onShiftTap={vi.fn()}
236+
onPaste={vi.fn()}
237+
onUpload={vi.fn()}
185238
/>
186239
);
187240

@@ -205,6 +258,8 @@ describe("MobileTerminalInputBar", () => {
205258
onCtrlTap={onCtrlTap}
206259
onCtrlLongPress={onCtrlLongPress}
207260
onShiftTap={vi.fn()}
261+
onPaste={vi.fn()}
262+
onUpload={vi.fn()}
208263
/>
209264
);
210265

@@ -234,6 +289,8 @@ describe("MobileTerminalInputBar", () => {
234289
onCtrlTap={vi.fn()}
235290
onCtrlLongPress={vi.fn()}
236291
onShiftTap={vi.fn()}
292+
onPaste={vi.fn()}
293+
onUpload={vi.fn()}
237294
/>
238295
);
239296

@@ -252,6 +309,8 @@ describe("MobileTerminalInputBar", () => {
252309
onCtrlTap={vi.fn()}
253310
onCtrlLongPress={vi.fn()}
254311
onShiftTap={vi.fn()}
312+
onPaste={vi.fn()}
313+
onUpload={vi.fn()}
255314
/>
256315
);
257316

@@ -280,6 +339,8 @@ describe("MobileTerminalInputBar", () => {
280339
onCtrlTap={vi.fn()}
281340
onCtrlLongPress={vi.fn()}
282341
onShiftTap={onShiftTap}
342+
onPaste={vi.fn()}
343+
onUpload={vi.fn()}
283344
/>
284345
);
285346

@@ -310,6 +371,8 @@ describe("MobileTerminalInputBar", () => {
310371
onCtrlTap={onCtrlTap}
311372
onCtrlLongPress={onCtrlLongPress}
312373
onShiftTap={vi.fn()}
374+
onPaste={vi.fn()}
375+
onUpload={vi.fn()}
313376
/>
314377
);
315378

@@ -345,6 +408,8 @@ describe("MobileTerminalInputBar", () => {
345408
onCtrlTap={vi.fn()}
346409
onCtrlLongPress={vi.fn()}
347410
onShiftTap={vi.fn()}
411+
onPaste={vi.fn()}
412+
onUpload={vi.fn()}
348413
/>
349414
);
350415

@@ -369,6 +434,8 @@ describe("MobileTerminalInputBar", () => {
369434
onCtrlTap={vi.fn()}
370435
onCtrlLongPress={vi.fn()}
371436
onShiftTap={vi.fn()}
437+
onPaste={vi.fn()}
438+
onUpload={vi.fn()}
372439
/>
373440
);
374441

@@ -396,6 +463,8 @@ describe("MobileTerminalInputBar", () => {
396463
onCtrlTap={onCtrlTap}
397464
onCtrlLongPress={onCtrlLongPress}
398465
onShiftTap={onShiftTap}
466+
onPaste={vi.fn()}
467+
onUpload={vi.fn()}
399468
/>
400469
);
401470

@@ -430,6 +499,8 @@ describe("MobileTerminalInputBar", () => {
430499
onCtrlTap={vi.fn()}
431500
onCtrlLongPress={vi.fn()}
432501
onShiftTap={vi.fn()}
502+
onPaste={vi.fn()}
503+
onUpload={vi.fn()}
433504
/>
434505
);
435506

@@ -448,10 +519,12 @@ describe("MobileTerminalInputBar", () => {
448519
onCtrlTap={vi.fn()}
449520
onCtrlLongPress={vi.fn()}
450521
onShiftTap={vi.fn()}
522+
onPaste={vi.fn()}
523+
onUpload={vi.fn()}
451524
/>
452525
);
453526

454-
const buttons = screen
527+
const buttons = within(screen.getByRole("group", { name: labels.shortcuts }))
455528
.getAllByRole("button")
456529
.map((button) => button.getAttribute("aria-label"));
457530

packages/web/src/features/terminal-panel/mobile/mobile-terminal-input-bar.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const SOFT_KEY_LAYOUT: Array<{ id: SoftTerminalKeyId; text: string }> = [
2020
];
2121

2222
export interface MobileTerminalInputBarLabels {
23+
paste: string;
24+
upload: string;
2325
shortcuts: string;
2426
ctrl: string;
2527
ctrlArmed: string;
@@ -44,6 +46,8 @@ interface MobileTerminalInputBarProps {
4446
onCtrlTap: () => void;
4547
onCtrlLongPress: () => void;
4648
onShiftTap: () => void;
49+
onPaste: () => void;
50+
onUpload: () => void;
4751
}
4852

4953
type TouchLikeGestureTarget = "ctrl" | "shift" | SoftTerminalKeyId;
@@ -93,6 +97,8 @@ export function MobileTerminalInputBar({
9397
onCtrlTap,
9498
onCtrlLongPress,
9599
onShiftTap,
100+
onPaste,
101+
onUpload,
96102
}: MobileTerminalInputBarProps) {
97103
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
98104
const suppressedClickResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -331,6 +337,34 @@ export function MobileTerminalInputBar({
331337
data-expanded="true"
332338
data-disabled={disabled ? "true" : "false"}
333339
>
340+
<div className="mobile-terminal-input-bar__actions">
341+
<button
342+
type="button"
343+
className="mobile-terminal-input-bar__action"
344+
aria-label={labels.paste}
345+
disabled={commandKeysDisabled}
346+
onClick={() => {
347+
if (!commandKeysDisabled) {
348+
onPaste();
349+
}
350+
}}
351+
>
352+
{labels.paste}
353+
</button>
354+
<button
355+
type="button"
356+
className="mobile-terminal-input-bar__action"
357+
aria-label={labels.upload}
358+
disabled={commandKeysDisabled}
359+
onClick={() => {
360+
if (!commandKeysDisabled) {
361+
onUpload();
362+
}
363+
}}
364+
>
365+
{labels.upload}
366+
</button>
367+
</div>
334368
<div className="mobile-terminal-input-bar__keys" role="group" aria-label={labels.shortcuts}>
335369
<button
336370
type="button"

0 commit comments

Comments
 (0)