@@ -428,6 +428,60 @@ describe("XtermHost", () => {
428428 } ) ;
429429 } ) ;
430430
431+ it ( "falls back to document.execCommand when desktop clipboard writeText is rejected" , async ( ) => {
432+ const store = createStore ( ) ;
433+ const writeText = vi . fn ( ) . mockRejectedValue ( new Error ( "clipboard rejected" ) ) ;
434+ const execCommand = vi . fn ( ) . mockReturnValue ( true ) ;
435+
436+ store . set ( localeAtom , "en" ) ;
437+ store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
438+ store . set ( wsClientAtom , {
439+ sendCommand : vi . fn ( ) . mockResolvedValue ( { status : "ok" } ) ,
440+ subscribe : vi . fn ( ( ) => ( ) => { } ) ,
441+ getStatus : vi . fn ( ( ) => "connected" ) ,
442+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
443+ } as never ) ;
444+ Object . defineProperty ( navigator , "clipboard" , {
445+ configurable : true ,
446+ value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
447+ } ) ;
448+ Object . defineProperty ( document , "execCommand" , {
449+ configurable : true ,
450+ value : execCommand ,
451+ } ) ;
452+
453+ const { container } = render (
454+ < Provider store = { store } >
455+ < XtermHost terminalId = "copy-fallback-terminal" workspaceId = "test-workspace" />
456+ </ Provider >
457+ ) ;
458+
459+ mockTerminal . hasSelection . mockReturnValue ( true ) ;
460+ mockTerminal . getSelection . mockReturnValue ( "selected text" ) ;
461+
462+ const selectionHandler = mockTerminal . onSelectionChange . mock . calls [ 0 ] ?. [ 0 ] as
463+ | ( ( ) => void )
464+ | undefined ;
465+
466+ await act ( async ( ) => {
467+ selectionHandler ?.( ) ;
468+ } ) ;
469+
470+ fireEvent . pointerDown ( container . querySelector ( ".xterm-host" ) ! , {
471+ pointerType : "mouse" ,
472+ pointerId : 1 ,
473+ } ) ;
474+ fireEvent . pointerUp ( container . querySelector ( ".xterm-host" ) ! , {
475+ pointerType : "mouse" ,
476+ pointerId : 1 ,
477+ } ) ;
478+
479+ await waitFor ( ( ) => {
480+ expect ( writeText ) . toHaveBeenCalledWith ( "selected text" ) ;
481+ expect ( execCommand ) . toHaveBeenCalledWith ( "copy" ) ;
482+ } ) ;
483+ } ) ;
484+
431485 it ( "copies the terminal selection when desktop mouse pointerup ends outside the host" , async ( ) => {
432486 const store = createStore ( ) ;
433487 const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
@@ -664,6 +718,7 @@ describe("XtermHost", () => {
664718 it ( "pushes only one error toast within the throttle window when clipboard writes fail" , async ( ) => {
665719 const store = createStore ( ) ;
666720 const writeText = vi . fn ( ) . mockRejectedValue ( new Error ( "clipboard failed" ) ) ;
721+ const execCommand = vi . fn ( ) . mockReturnValue ( false ) ;
667722
668723 store . set ( localeAtom , "zh" ) ;
669724 store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
@@ -677,6 +732,10 @@ describe("XtermHost", () => {
677732 configurable : true ,
678733 value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
679734 } ) ;
735+ Object . defineProperty ( document , "execCommand" , {
736+ configurable : true ,
737+ value : execCommand ,
738+ } ) ;
680739
681740 const { container } = render (
682741 < Provider store = { store } >
@@ -7077,6 +7136,13 @@ describe("XtermHost", () => {
70777136 await act ( async ( ) => {
70787137 vi . advanceTimersByTime ( 500 ) ;
70797138 await Promise . resolve ( ) ;
7139+ } ) ;
7140+
7141+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
7142+
7143+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 6 , clientY : 150 } ] ) ;
7144+
7145+ await act ( async ( ) => {
70807146 await Promise . resolve ( ) ;
70817147 } ) ;
70827148
@@ -7161,6 +7227,13 @@ describe("XtermHost", () => {
71617227 await act ( async ( ) => {
71627228 vi . advanceTimersByTime ( 500 ) ;
71637229 await Promise . resolve ( ) ;
7230+ } ) ;
7231+
7232+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
7233+
7234+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 20 , clientY : 110 } ] ) ;
7235+
7236+ await act ( async ( ) => {
71647237 await Promise . resolve ( ) ;
71657238 } ) ;
71667239
@@ -7488,6 +7561,7 @@ describe("XtermHost", () => {
74887561
74897562 const originalMatchMedia = window . matchMedia ;
74907563 const writeText = vi . fn ( ) . mockRejectedValue ( new Error ( "clipboard failed" ) ) ;
7564+ const execCommand = vi . fn ( ) . mockReturnValue ( false ) ;
74917565 const vibrate = vi . fn ( ) ;
74927566 const store = createStore ( ) ;
74937567
@@ -7514,6 +7588,10 @@ describe("XtermHost", () => {
75147588 configurable : true ,
75157589 value : vibrate ,
75167590 } ) ;
7591+ Object . defineProperty ( document , "execCommand" , {
7592+ configurable : true ,
7593+ value : execCommand ,
7594+ } ) ;
75177595
75187596 store . set ( localeAtom , "zh" ) ;
75197597 store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
@@ -7548,6 +7626,13 @@ describe("XtermHost", () => {
75487626 await act ( async ( ) => {
75497627 vi . advanceTimersByTime ( 500 ) ;
75507628 await Promise . resolve ( ) ;
7629+ } ) ;
7630+
7631+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
7632+
7633+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 20 , clientY : 110 } ] ) ;
7634+
7635+ await act ( async ( ) => {
75517636 await Promise . resolve ( ) ;
75527637 } ) ;
75537638
@@ -7566,6 +7651,103 @@ describe("XtermHost", () => {
75667651 vi . useRealTimers ( ) ;
75677652 } ) ;
75687653
7654+ it ( "mobile line copy falls back to document.execCommand when clipboard writeText is rejected" , async ( ) => {
7655+ vi . useFakeTimers ( ) ;
7656+ viewportMocks . viewport = "mobile" ;
7657+
7658+ const originalMatchMedia = window . matchMedia ;
7659+ const writeText = vi . fn ( ) . mockRejectedValue ( new Error ( "clipboard rejected" ) ) ;
7660+ const execCommand = vi . fn ( ) . mockReturnValue ( true ) ;
7661+ const vibrate = vi . fn ( ) ;
7662+ const store = createStore ( ) ;
7663+
7664+ mockTerminal . cols = 80 ;
7665+ mockTerminal . buffer . active . viewportY = 8 ;
7666+ setMockBufferLines ( [ [ 8 , "fallback line" , false ] ] ) ;
7667+
7668+ window . matchMedia = vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
7669+ matches : query === "(pointer: coarse)" ,
7670+ media : query ,
7671+ onchange : null ,
7672+ addListener : vi . fn ( ) ,
7673+ removeListener : vi . fn ( ) ,
7674+ addEventListener : vi . fn ( ) ,
7675+ removeEventListener : vi . fn ( ) ,
7676+ dispatchEvent : vi . fn ( ) ,
7677+ } ) ) as typeof window . matchMedia ;
7678+
7679+ Object . defineProperty ( navigator , "clipboard" , {
7680+ configurable : true ,
7681+ value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
7682+ } ) ;
7683+ Object . defineProperty ( navigator , "vibrate" , {
7684+ configurable : true ,
7685+ value : vibrate ,
7686+ } ) ;
7687+ Object . defineProperty ( document , "execCommand" , {
7688+ configurable : true ,
7689+ value : execCommand ,
7690+ } ) ;
7691+
7692+ store . set ( localeAtom , "en" ) ;
7693+ store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
7694+ store . set ( wsClientAtom , {
7695+ sendCommand : vi . fn ( ) . mockResolvedValue ( { ok : true , data : { status : "ok" } } ) ,
7696+ subscribe : vi . fn ( ( ) => ( ) => { } ) ,
7697+ getStatus : vi . fn ( ( ) => "connected" ) ,
7698+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
7699+ sendTerminalInput : vi . fn ( ) . mockResolvedValue ( undefined ) ,
7700+ } as never ) ;
7701+
7702+ const { container } = render (
7703+ < Provider store = { store } >
7704+ < XtermHost terminalId = "mobile-line-copy-fallback-terminal" workspaceId = "test-workspace" />
7705+ </ Provider >
7706+ ) ;
7707+
7708+ const host = container . querySelector ( ".xterm-host" ) as HTMLDivElement | null ;
7709+ expect ( host ) . toBeTruthy ( ) ;
7710+
7711+ const rowsElement = document . createElement ( "div" ) ;
7712+ rowsElement . className = "xterm-rows" ;
7713+ rowsElement . innerHTML = "<div><span>fallback line</span></div>" ;
7714+ host ! . appendChild ( rowsElement ) ;
7715+ stubRowsGeometry ( host ! , rowsElement , [ rowsElement . firstElementChild as HTMLDivElement ] , {
7716+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7717+ rowHeight : 20 ,
7718+ } ) ;
7719+
7720+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 20 , clientY : 110 } ] ) ;
7721+
7722+ await act ( async ( ) => {
7723+ vi . advanceTimersByTime ( 500 ) ;
7724+ await Promise . resolve ( ) ;
7725+ } ) ;
7726+
7727+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
7728+
7729+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 20 , clientY : 110 } ] ) ;
7730+
7731+ await act ( async ( ) => {
7732+ await Promise . resolve ( ) ;
7733+ } ) ;
7734+
7735+ expect ( writeText ) . toHaveBeenCalledWith ( "fallback line" ) ;
7736+ expect ( execCommand ) . toHaveBeenCalledWith ( "copy" ) ;
7737+ expect ( store . get ( toastsAtom ) ) . toEqual (
7738+ expect . arrayContaining ( [
7739+ expect . objectContaining ( {
7740+ kind : "success" ,
7741+ title : "Copied current line" ,
7742+ } ) ,
7743+ ] )
7744+ ) ;
7745+ expect ( vibrate ) . toHaveBeenCalledWith ( 10 ) ;
7746+
7747+ window . matchMedia = originalMatchMedia ;
7748+ vi . useRealTimers ( ) ;
7749+ } ) ;
7750+
75697751 it ( "mobile line copy still succeeds when the touch target is only the host element" , async ( ) => {
75707752 vi . useFakeTimers ( ) ;
75717753 viewportMocks . viewport = "mobile" ;
@@ -7641,6 +7823,13 @@ describe("XtermHost", () => {
76417823 await act ( async ( ) => {
76427824 vi . advanceTimersByTime ( 500 ) ;
76437825 await Promise . resolve ( ) ;
7826+ } ) ;
7827+
7828+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
7829+
7830+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 40 , clientY : 130 } ] ) ;
7831+
7832+ await act ( async ( ) => {
76447833 await Promise . resolve ( ) ;
76457834 } ) ;
76467835
@@ -7816,6 +8005,91 @@ describe("XtermHost", () => {
78168005 vi . useRealTimers ( ) ;
78178006 } ) ;
78188007
8008+ it ( "mobile line copy does not copy an empty row when a long press lands in the right gutter outside the xterm screen grid" , async ( ) => {
8009+ vi . useFakeTimers ( ) ;
8010+ viewportMocks . viewport = "mobile" ;
8011+
8012+ const originalMatchMedia = window . matchMedia ;
8013+ const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
8014+ const vibrate = vi . fn ( ) ;
8015+ const store = createStore ( ) ;
8016+
8017+ mockTerminal . cols = 80 ;
8018+ mockTerminal . buffer . active . viewportY = 16 ;
8019+ setMockBufferLines ( [ [ 16 , "" , false ] ] ) ;
8020+
8021+ window . matchMedia = vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
8022+ matches : query === "(pointer: coarse)" ,
8023+ media : query ,
8024+ onchange : null ,
8025+ addListener : vi . fn ( ) ,
8026+ removeListener : vi . fn ( ) ,
8027+ addEventListener : vi . fn ( ) ,
8028+ removeEventListener : vi . fn ( ) ,
8029+ dispatchEvent : vi . fn ( ) ,
8030+ } ) ) as typeof window . matchMedia ;
8031+
8032+ Object . defineProperty ( navigator , "clipboard" , {
8033+ configurable : true ,
8034+ value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
8035+ } ) ;
8036+ Object . defineProperty ( navigator , "vibrate" , {
8037+ configurable : true ,
8038+ value : vibrate ,
8039+ } ) ;
8040+
8041+ store . set ( localeAtom , "en" ) ;
8042+ store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
8043+ store . set ( wsClientAtom , {
8044+ sendCommand : vi . fn ( ) . mockResolvedValue ( { ok : true , data : { status : "ok" } } ) ,
8045+ subscribe : vi . fn ( ( ) => ( ) => { } ) ,
8046+ getStatus : vi . fn ( ( ) => "connected" ) ,
8047+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
8048+ sendTerminalInput : vi . fn ( ) . mockResolvedValue ( undefined ) ,
8049+ } as never ) ;
8050+
8051+ const { container } = render (
8052+ < Provider store = { store } >
8053+ < XtermHost
8054+ terminalId = "mobile-line-copy-empty-right-gutter-terminal"
8055+ workspaceId = "test-workspace"
8056+ />
8057+ </ Provider >
8058+ ) ;
8059+
8060+ const host = container . querySelector ( ".xterm-host" ) as HTMLDivElement | null ;
8061+ expect ( host ) . toBeTruthy ( ) ;
8062+
8063+ const screenElement = document . createElement ( "div" ) ;
8064+ screenElement . className = "xterm-screen" ;
8065+ host ! . appendChild ( screenElement ) ;
8066+
8067+ const rowsElement = document . createElement ( "div" ) ;
8068+ rowsElement . className = "xterm-rows" ;
8069+ rowsElement . innerHTML = "<div><span> </span></div>" ;
8070+ host ! . appendChild ( rowsElement ) ;
8071+ stubRowsGeometry ( host ! , rowsElement , [ rowsElement . firstElementChild as HTMLDivElement ] , {
8072+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
8073+ screenRect : { x : 0 , y : 100 , width : 280 , height : 20 } ,
8074+ rowHeight : 20 ,
8075+ } ) ;
8076+
8077+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 300 , clientY : 110 } ] ) ;
8078+
8079+ await act ( async ( ) => {
8080+ vi . advanceTimersByTime ( 500 ) ;
8081+ await Promise . resolve ( ) ;
8082+ await Promise . resolve ( ) ;
8083+ } ) ;
8084+
8085+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
8086+ expect ( vibrate ) . not . toHaveBeenCalled ( ) ;
8087+ expect ( store . get ( toastsAtom ) ) . toEqual ( [ ] ) ;
8088+
8089+ window . matchMedia = originalMatchMedia ;
8090+ vi . useRealTimers ( ) ;
8091+ } ) ;
8092+
78198093 it ( "mobile line copy still succeeds after the row DOM is replaced before long press matures" , async ( ) => {
78208094 vi . useFakeTimers ( ) ;
78218095 viewportMocks . viewport = "mobile" ;
@@ -7890,6 +8164,13 @@ describe("XtermHost", () => {
78908164 await act ( async ( ) => {
78918165 vi . advanceTimersByTime ( 500 ) ;
78928166 await Promise . resolve ( ) ;
8167+ } ) ;
8168+
8169+ expect ( writeText ) . not . toHaveBeenCalled ( ) ;
8170+
8171+ dispatchTouchEvent ( host ! , "touchend" , [ ] , [ { identifier : 1 , clientX : 40 , clientY : 130 } ] ) ;
8172+
8173+ await act ( async ( ) => {
78938174 await Promise . resolve ( ) ;
78948175 } ) ;
78958176
0 commit comments