@@ -13,8 +13,10 @@ import { localeAtom, themeAtom } from "../../../atoms/app-ui";
1313import { wsClientAtom } from "../../../atoms/connection" ;
1414import { JotaiProvider } from "../../../test-utils/jotai-provider" ;
1515import type { TerminalReplayPayload , TerminalSnapshotPayload } from "../../../ws/client" ;
16+ import { toastsAtom } from "../../notifications/atoms" ;
1617import { terminalOutputAtomFamily } from "../atoms" ;
1718import type { HydrationRequestHandle , HydrationTier } from "../hydration-coordinator" ;
19+ import { terminalPreferencesAtom } from "../preferences" ;
1820import { TERMINAL_REPLAY_TIMEOUT_MS } from "../replay-state" ;
1921import { 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
0 commit comments