Skip to content

Commit 9660c49

Browse files
committed
feat: copy terminal selection on desktop
1 parent e6ce079 commit 9660c49

2 files changed

Lines changed: 247 additions & 7 deletions

File tree

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

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { localeAtom, themeAtom } from "../../../atoms/app-ui";
1313
import { wsClientAtom } from "../../../atoms/connection";
1414
import { JotaiProvider } from "../../../test-utils/jotai-provider";
1515
import type { TerminalReplayPayload, TerminalSnapshotPayload } from "../../../ws/client";
16+
import { toastsAtom } from "../../notifications/atoms";
1617
import { terminalOutputAtomFamily } from "../atoms";
1718
import type { HydrationRequestHandle, HydrationTier } from "../hydration-coordinator";
19+
import { terminalPreferencesAtom } from "../preferences";
1820
import { TERMINAL_REPLAY_TIMEOUT_MS } from "../replay-state";
1921
import { trimWrittenChunks, XtermHost } from "../views/shared/xterm-host";
2022

@@ -141,7 +143,10 @@ const mockTerminal = {
141143
open: vi.fn(),
142144
onData: vi.fn(() => vi.fn()), // Return dispose function
143145
onResize: vi.fn(() => vi.fn()),
146+
onSelectionChange: vi.fn(() => vi.fn()),
144147
attachCustomKeyEventHandler: vi.fn(),
148+
hasSelection: vi.fn(() => false),
149+
getSelection: vi.fn(() => ""),
145150
write: vi.fn(),
146151
writeln: vi.fn(),
147152
scrollLines: vi.fn(),
@@ -251,6 +256,180 @@ describe("XtermHost", () => {
251256
expect(hostContainer).toBeTruthy();
252257
});
253258

259+
it("copies the terminal selection on desktop pointerup when copy-on-select is enabled", async () => {
260+
const store = createStore();
261+
const writeText = vi.fn().mockResolvedValue(undefined);
262+
const clipboard = {
263+
writeText,
264+
} satisfies Pick<Clipboard, "writeText">;
265+
266+
store.set(localeAtom, "en");
267+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
268+
store.set(wsClientAtom, {
269+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
270+
subscribe: vi.fn(() => () => {}),
271+
getStatus: vi.fn(() => "connected"),
272+
onStatus: vi.fn(() => () => {}),
273+
} as never);
274+
Object.defineProperty(navigator, "clipboard", {
275+
configurable: true,
276+
value: clipboard,
277+
});
278+
279+
const { container } = render(
280+
<Provider store={store}>
281+
<XtermHost terminalId="copy-enabled-terminal" workspaceId="test-workspace" />
282+
</Provider>
283+
);
284+
285+
mockTerminal.hasSelection.mockReturnValue(true);
286+
mockTerminal.getSelection.mockReturnValue("selected text");
287+
288+
const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as
289+
| (() => void)
290+
| undefined;
291+
292+
await act(async () => {
293+
selectionHandler?.();
294+
});
295+
296+
fireEvent.pointerUp(container.querySelector(".xterm-host")!);
297+
298+
await waitFor(() => {
299+
expect(writeText).toHaveBeenCalledWith("selected text");
300+
});
301+
});
302+
303+
it("does not copy when copy-on-select is disabled", async () => {
304+
const store = createStore();
305+
const writeText = vi.fn().mockResolvedValue(undefined);
306+
307+
store.set(localeAtom, "en");
308+
store.set(terminalPreferencesAtom, { copyOnSelect: false });
309+
store.set(wsClientAtom, {
310+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
311+
subscribe: vi.fn(() => () => {}),
312+
getStatus: vi.fn(() => "connected"),
313+
onStatus: vi.fn(() => () => {}),
314+
} as never);
315+
Object.defineProperty(navigator, "clipboard", {
316+
configurable: true,
317+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
318+
});
319+
320+
const { container } = render(
321+
<Provider store={store}>
322+
<XtermHost terminalId="copy-disabled-terminal" workspaceId="test-workspace" />
323+
</Provider>
324+
);
325+
326+
mockTerminal.hasSelection.mockReturnValue(true);
327+
mockTerminal.getSelection.mockReturnValue("selected text");
328+
329+
const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as
330+
| (() => void)
331+
| undefined;
332+
333+
await act(async () => {
334+
selectionHandler?.();
335+
});
336+
337+
fireEvent.pointerUp(container.querySelector(".xterm-host")!);
338+
339+
await waitFor(() => {
340+
expect(writeText).not.toHaveBeenCalled();
341+
});
342+
});
343+
344+
it("does not copy on mobile even when the preference is enabled", async () => {
345+
viewportMocks.viewport = "mobile";
346+
347+
const store = createStore();
348+
const writeText = vi.fn().mockResolvedValue(undefined);
349+
350+
store.set(localeAtom, "en");
351+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
352+
store.set(wsClientAtom, {
353+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
354+
subscribe: vi.fn(() => () => {}),
355+
getStatus: vi.fn(() => "connected"),
356+
onStatus: vi.fn(() => () => {}),
357+
} as never);
358+
Object.defineProperty(navigator, "clipboard", {
359+
configurable: true,
360+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
361+
});
362+
363+
const { container } = render(
364+
<Provider store={store}>
365+
<XtermHost terminalId="copy-mobile-terminal" workspaceId="test-workspace" />
366+
</Provider>
367+
);
368+
369+
mockTerminal.hasSelection.mockReturnValue(true);
370+
mockTerminal.getSelection.mockReturnValue("selected text");
371+
372+
const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as
373+
| (() => void)
374+
| undefined;
375+
376+
await act(async () => {
377+
selectionHandler?.();
378+
});
379+
380+
fireEvent.pointerUp(container.querySelector(".xterm-host")!);
381+
382+
await waitFor(() => {
383+
expect(writeText).not.toHaveBeenCalled();
384+
});
385+
});
386+
387+
it("pushes only one error toast within the throttle window when clipboard writes fail", async () => {
388+
const store = createStore();
389+
const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed"));
390+
391+
store.set(localeAtom, "zh");
392+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
393+
store.set(wsClientAtom, {
394+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
395+
subscribe: vi.fn(() => () => {}),
396+
getStatus: vi.fn(() => "connected"),
397+
onStatus: vi.fn(() => () => {}),
398+
} as never);
399+
Object.defineProperty(navigator, "clipboard", {
400+
configurable: true,
401+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
402+
});
403+
404+
const { container } = render(
405+
<Provider store={store}>
406+
<XtermHost terminalId="copy-error-terminal" workspaceId="test-workspace" />
407+
</Provider>
408+
);
409+
410+
mockTerminal.hasSelection.mockReturnValue(true);
411+
mockTerminal.getSelection.mockReturnValue("selected text");
412+
413+
const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as
414+
| (() => void)
415+
| undefined;
416+
417+
await act(async () => {
418+
selectionHandler?.();
419+
});
420+
421+
fireEvent.pointerUp(container.querySelector(".xterm-host")!);
422+
fireEvent.pointerUp(container.querySelector(".xterm-host")!);
423+
424+
await waitFor(() => {
425+
expect(store.get(toastsAtom)).toHaveLength(1);
426+
});
427+
expect(store.get(toastsAtom)[0]).toMatchObject({
428+
kind: "error",
429+
title: "自动复制失败",
430+
});
431+
});
432+
254433
it("shows upload overlay and disables stdin while an upload is pending", async () => {
255434
uploadHookMocks.busy = true;
256435

packages/web/src/features/terminal-panel/views/shared/xterm-host.tsx

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,14 @@
1111
import { type TerminalInputActivity, Topics } from "@coder-studio/core";
1212
import { FitAddon } from "@xterm/addon-fit";
1313
import { Terminal } from "@xterm/xterm";
14-
import { useAtom, useAtomValue } from "jotai";
14+
import { useAtom, useAtomValue, useSetAtom } from "jotai";
1515
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
1616
import { themeAtom } from "../../../../atoms/app-ui";
1717
import { dispatchCommandAtom, wsClientAtom } from "../../../../atoms/connection";
1818
import { useViewport } from "../../../../hooks/use-viewport";
1919
import { useTranslation } from "../../../../lib/i18n";
20-
import type {
21-
ConnectionStatus,
22-
TerminalBinaryPayload,
23-
TerminalReplayPayload,
24-
TerminalSnapshotPayload,
25-
} from "../../../../ws/client";
20+
import type { ConnectionStatus, TerminalBinaryPayload } from "../../../../ws/client";
21+
import { pushToastAtom } from "../../../notifications/atoms";
2622
import type { OutputBuffer } from "../../atoms";
2723
import { terminalMetaAtomFamily, terminalOutputAtomFamily } from "../../atoms";
2824
import {
@@ -39,6 +35,7 @@ import {
3935
type SoftTerminalKeyId,
4036
toggleCtrlMode,
4137
} from "../../mobile/virtual-terminal-keys";
38+
import { terminalPreferencesAtom } from "../../preferences";
4239
import {
4340
classifyReplayFailure,
4441
TERMINAL_REPLAY_TIMEOUT_MS,
@@ -54,6 +51,7 @@ const MOBILE_TOUCH_MOMENTUM_STOP_VELOCITY_PX_PER_MS = 0.02;
5451
const MOBILE_TOUCH_MOMENTUM_FRICTION_PER_FRAME = 0.92;
5552
const MOBILE_TOUCH_MOMENTUM_FRAME_MS = 16;
5653
const TERMINAL_FOCUS_REPORTING_BYTES = new Set(["\x1b[I", "\x1b[O"]);
54+
const TERMINAL_COPY_ON_SELECT_ERROR_THROTTLE_MS = 3_000;
5755

5856
interface TerminalInputDraftState {
5957
nextDraft: string;
@@ -431,8 +429,10 @@ export function XtermHost({
431429
const t = useTranslation();
432430
const viewport = useViewport();
433431
const uiTheme = useAtomValue(themeAtom);
432+
const terminalPreferences = useAtomValue(terminalPreferencesAtom);
434433
const wsClient = useAtomValue(wsClientAtom);
435434
const dispatch = useAtomValue(dispatchCommandAtom);
435+
const pushToast = useSetAtom(pushToastAtom);
436436
const [outputAtom, setOutputAtom] = useAtom(terminalOutputAtomFamily(terminalId));
437437
const meta = useAtomValue(terminalMetaAtomFamily(terminalId));
438438
const terminalKind = terminalKindProp ?? meta?.kind ?? "shell";
@@ -472,6 +472,8 @@ export function XtermHost({
472472
const hydrationHandleRef = useRef<HydrationRequestHandle | null>(null);
473473
const hydrationReleasedRef = useRef(false);
474474
const reconnectRecoveryTriggerRef = useRef<(() => void) | null>(null);
475+
const selectedTextRef = useRef("");
476+
const lastCopyOnSelectFailureAtRef = useRef(0);
475477
const touchScrollStateRef = useRef<{
476478
activeTouchId: number | null;
477479
lastClientY: number;
@@ -856,6 +858,42 @@ export function XtermHost({
856858
terminalRef.current?.focus();
857859
}, []);
858860

861+
const pushCopyOnSelectFailureToast = useCallback(() => {
862+
const now = Date.now();
863+
if (now - lastCopyOnSelectFailureAtRef.current < TERMINAL_COPY_ON_SELECT_ERROR_THROTTLE_MS) {
864+
return;
865+
}
866+
867+
lastCopyOnSelectFailureAtRef.current = now;
868+
pushToast({
869+
kind: "error",
870+
title: t("settings.copy_on_select_failed_title"),
871+
body: t("settings.copy_on_select_failed_body"),
872+
});
873+
}, [pushToast, t]);
874+
875+
const copySelectionOnSelect = useCallback(async () => {
876+
if (viewport === "mobile" || !terminalPreferences.copyOnSelect) {
877+
return;
878+
}
879+
880+
const terminal = terminalRef.current;
881+
if (!terminal?.hasSelection()) {
882+
return;
883+
}
884+
885+
const selection = selectedTextRef.current || terminal.getSelection();
886+
if (!selection) {
887+
return;
888+
}
889+
890+
try {
891+
await navigator.clipboard.writeText(selection);
892+
} catch {
893+
pushCopyOnSelectFailureToast();
894+
}
895+
}, [pushCopyOnSelectFailureToast, terminalPreferences.copyOnSelect, viewport]);
896+
859897
/**
860898
* Handle user input - dispatch to server
861899
*/
@@ -1076,6 +1114,12 @@ export function XtermHost({
10761114
terminal.onData((data) => {
10771115
void handleInputRef.current(data);
10781116
});
1117+
const disposeSelectionChange =
1118+
typeof terminal.onSelectionChange === "function"
1119+
? terminal.onSelectionChange(() => {
1120+
selectedTextRef.current = terminal.hasSelection() ? terminal.getSelection() : "";
1121+
})
1122+
: undefined;
10791123
terminal.attachCustomKeyEventHandler((event) => !shouldBypassPtyForKeyboardPaste(event));
10801124

10811125
terminal.open(containerRef.current);
@@ -1733,6 +1777,7 @@ export function XtermHost({
17331777
terminalRef.current = null;
17341778
fitAddonRef.current = null;
17351779
}
1780+
disposeSelectionChange?.();
17361781
};
17371782
}, [
17381783
dispatch,
@@ -1746,6 +1791,22 @@ export function XtermHost({
17461791
wsClient,
17471792
]);
17481793

1794+
useEffect(() => {
1795+
const container = containerRef.current;
1796+
if (!container || viewport === "mobile" || !terminalPreferences.copyOnSelect) {
1797+
return;
1798+
}
1799+
1800+
const handlePointerUp = () => {
1801+
void copySelectionOnSelect();
1802+
};
1803+
1804+
container.addEventListener("pointerup", handlePointerUp);
1805+
return () => {
1806+
container.removeEventListener("pointerup", handlePointerUp);
1807+
};
1808+
}, [copySelectionOnSelect, terminalPreferences.copyOnSelect, viewport]);
1809+
17491810
useEffect(() => {
17501811
if (!wsClient || typeof wsClient.onStatus !== "function") {
17511812
return;

0 commit comments

Comments
 (0)