@@ -181,6 +181,57 @@ function dispatchTouchEvent(
181181 target . dispatchEvent ( event ) ;
182182}
183183
184+ function createMockDomRect ( {
185+ x,
186+ y,
187+ width,
188+ height,
189+ } : {
190+ x : number ;
191+ y : number ;
192+ width : number ;
193+ height : number ;
194+ } ) : DOMRect {
195+ return {
196+ x,
197+ y,
198+ width,
199+ height,
200+ top : y ,
201+ left : x ,
202+ right : x + width ,
203+ bottom : y + height ,
204+ toJSON : ( ) => ( { } ) ,
205+ } as DOMRect ;
206+ }
207+
208+ function stubRowsGeometry (
209+ host : HTMLDivElement ,
210+ rowsElement : HTMLDivElement ,
211+ rowElements : HTMLDivElement [ ] ,
212+ options : {
213+ hostRect ?: { x : number ; y : number ; width : number ; height : number } ;
214+ rowsRect : { x : number ; y : number ; width : number ; height : number } ;
215+ rowHeight : number ;
216+ }
217+ ) {
218+ vi . spyOn ( host , "getBoundingClientRect" ) . mockReturnValue (
219+ createMockDomRect (
220+ options . hostRect ?? { x : 0 , y : 0 , width : options . rowsRect . width , height : 240 }
221+ )
222+ ) ;
223+ rowsElement . getBoundingClientRect = ( ) => createMockDomRect ( options . rowsRect ) ;
224+ rowElements . forEach ( ( rowElement , index ) => {
225+ rowElement . getBoundingClientRect = ( ) =>
226+ createMockDomRect ( {
227+ x : options . rowsRect . x ,
228+ y : options . rowsRect . y + index * options . rowHeight ,
229+ width : options . rowsRect . width ,
230+ height : options . rowHeight ,
231+ } ) ;
232+ } ) ;
233+ }
234+
184235const mockTerminal = {
185236 open : vi . fn ( ) ,
186237 onData : vi . fn ( ( ) => vi . fn ( ) ) , // Return dispose function
@@ -7006,9 +7057,12 @@ describe("XtermHost", () => {
70067057
70077058 rowsElement . append ( firstRow , secondRow , thirdRow ) ;
70087059 host ! . appendChild ( rowsElement ) ;
7060+ stubRowsGeometry ( host ! , rowsElement , [ firstRow , secondRow , thirdRow ] , {
7061+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 60 } ,
7062+ rowHeight : 20 ,
7063+ } ) ;
70097064
7010- const target = thirdRow . querySelector ( "span span" ) as HTMLSpanElement ;
7011- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7065+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 150 } ] ) ;
70127066
70137067 await act ( async ( ) => {
70147068 vi . advanceTimersByTime ( 500 ) ;
@@ -7086,9 +7140,12 @@ describe("XtermHost", () => {
70867140 rowsElement . className = "xterm-rows" ;
70877141 rowsElement . innerHTML = "<div><span> </span></div>" ;
70887142 host ! . appendChild ( rowsElement ) ;
7143+ stubRowsGeometry ( host ! , rowsElement , [ rowsElement . firstElementChild as HTMLDivElement ] , {
7144+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7145+ rowHeight : 20 ,
7146+ } ) ;
70897147
7090- const target = rowsElement . querySelector ( "span" ) as HTMLSpanElement ;
7091- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7148+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 110 } ] ) ;
70927149
70937150 await act ( async ( ) => {
70947151 vi . advanceTimersByTime ( 500 ) ;
@@ -7166,9 +7223,12 @@ describe("XtermHost", () => {
71667223 rowsElement . className = "xterm-rows" ;
71677224 rowsElement . innerHTML = "<div><span>disabled line</span></div>" ;
71687225 host ! . appendChild ( rowsElement ) ;
7226+ stubRowsGeometry ( host ! , rowsElement , [ rowsElement . firstElementChild as HTMLDivElement ] , {
7227+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7228+ rowHeight : 20 ,
7229+ } ) ;
71697230
7170- const target = rowsElement . querySelector ( "span" ) as HTMLSpanElement ;
7171- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7231+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 110 } ] ) ;
71727232
71737233 await act ( async ( ) => {
71747234 vi . advanceTimersByTime ( 500 ) ;
@@ -7226,10 +7286,18 @@ describe("XtermHost", () => {
72267286 rowsElement . className = "xterm-rows" ;
72277287 rowsElement . innerHTML = "<div><span>scroll line</span></div>" ;
72287288 ( host as HTMLDivElement ) . appendChild ( rowsElement ) ;
7289+ stubRowsGeometry (
7290+ host as HTMLDivElement ,
7291+ rowsElement ,
7292+ [ rowsElement . firstElementChild as HTMLDivElement ] ,
7293+ {
7294+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7295+ rowHeight : 20 ,
7296+ }
7297+ ) ;
72297298
7230- const target = rowsElement . querySelector ( "span" ) as HTMLSpanElement ;
7231- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7232- dispatchTouchEvent ( host ! , "touchmove" , [ { identifier : 1 , clientX : 40 , clientY : 88 , target } ] ) ;
7299+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 110 } ] ) ;
7300+ dispatchTouchEvent ( host ! , "touchmove" , [ { identifier : 1 , clientX : 40 , clientY : 88 } ] ) ;
72337301
72347302 await act ( async ( ) => {
72357303 vi . advanceTimersByTime ( 500 ) ;
@@ -7295,10 +7363,18 @@ describe("XtermHost", () => {
72957363 rowsElement . className = "xterm-rows" ;
72967364 rowsElement . innerHTML = "<div><span>drift line</span></div>" ;
72977365 ( host as HTMLDivElement ) . appendChild ( rowsElement ) ;
7366+ stubRowsGeometry (
7367+ host as HTMLDivElement ,
7368+ rowsElement ,
7369+ [ rowsElement . firstElementChild as HTMLDivElement ] ,
7370+ {
7371+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7372+ rowHeight : 20 ,
7373+ }
7374+ ) ;
72987375
7299- const target = rowsElement . querySelector ( "span" ) as HTMLSpanElement ;
7300- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7301- dispatchTouchEvent ( host ! , "touchmove" , [ { identifier : 1 , clientX : 56 , clientY : 120 , target } ] ) ;
7376+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 110 } ] ) ;
7377+ dispatchTouchEvent ( host ! , "touchmove" , [ { identifier : 1 , clientX : 56 , clientY : 110 } ] ) ;
73027378
73037379 await act ( async ( ) => {
73047380 vi . advanceTimersByTime ( 500 ) ;
@@ -7392,7 +7468,7 @@ describe("XtermHost", () => {
73927468 vi . useRealTimers ( ) ;
73937469 } ) ;
73947470
7395- it ( "mobile line copy shows the existing copy-on-select failure toast when clipboard write fails" , async ( ) => {
7471+ it ( "mobile line copy shows a mobile-specific failure toast when clipboard write fails" , async ( ) => {
73967472 vi . useFakeTimers ( ) ;
73977473 viewportMocks . viewport = "mobile" ;
73987474
@@ -7447,9 +7523,12 @@ describe("XtermHost", () => {
74477523 rowsElement . className = "xterm-rows" ;
74487524 rowsElement . innerHTML = "<div><span>toast line</span></div>" ;
74497525 host ! . appendChild ( rowsElement ) ;
7526+ stubRowsGeometry ( host ! , rowsElement , [ rowsElement . firstElementChild as HTMLDivElement ] , {
7527+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 20 } ,
7528+ rowHeight : 20 ,
7529+ } ) ;
74507530
7451- const target = rowsElement . querySelector ( "span" ) as HTMLSpanElement ;
7452- dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 120 , target } ] ) ;
7531+ dispatchTouchEvent ( host ! , "touchstart" , [ { identifier : 1 , clientX : 40 , clientY : 110 } ] ) ;
74537532
74547533 await act ( async ( ) => {
74557534 vi . advanceTimersByTime ( 500 ) ;
@@ -7462,6 +7541,7 @@ describe("XtermHost", () => {
74627541 expect . objectContaining ( {
74637542 kind : "error" ,
74647543 title : "自动复制失败" ,
7544+ body : "请重试长按当前行" ,
74657545 } ) ,
74667546 ] )
74677547 ) ;
@@ -7471,6 +7551,172 @@ describe("XtermHost", () => {
74717551 vi . useRealTimers ( ) ;
74727552 } ) ;
74737553
7554+ it ( "mobile line copy still succeeds when the touch target is only the host element" , async ( ) => {
7555+ vi . useFakeTimers ( ) ;
7556+ viewportMocks . viewport = "mobile" ;
7557+
7558+ const originalMatchMedia = window . matchMedia ;
7559+ const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
7560+ const vibrate = vi . fn ( ) ;
7561+ const store = createStore ( ) ;
7562+
7563+ mockTerminal . buffer . active . viewportY = 9 ;
7564+ setMockBufferLines ( [ [ 10 , "from coords" , false ] ] ) ;
7565+
7566+ window . matchMedia = vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
7567+ matches : query === "(pointer: coarse)" ,
7568+ media : query ,
7569+ onchange : null ,
7570+ addListener : vi . fn ( ) ,
7571+ removeListener : vi . fn ( ) ,
7572+ addEventListener : vi . fn ( ) ,
7573+ removeEventListener : vi . fn ( ) ,
7574+ dispatchEvent : vi . fn ( ) ,
7575+ } ) ) as typeof window . matchMedia ;
7576+
7577+ Object . defineProperty ( navigator , "clipboard" , {
7578+ configurable : true ,
7579+ value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
7580+ } ) ;
7581+ Object . defineProperty ( navigator , "vibrate" , {
7582+ configurable : true ,
7583+ value : vibrate ,
7584+ } ) ;
7585+
7586+ store . set ( localeAtom , "en" ) ;
7587+ store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
7588+ store . set ( wsClientAtom , {
7589+ sendCommand : vi . fn ( ) . mockResolvedValue ( { ok : true , data : { status : "ok" } } ) ,
7590+ subscribe : vi . fn ( ( ) => ( ) => { } ) ,
7591+ getStatus : vi . fn ( ( ) => "connected" ) ,
7592+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
7593+ sendTerminalInput : vi . fn ( ) . mockResolvedValue ( undefined ) ,
7594+ } as never ) ;
7595+
7596+ const { container } = render (
7597+ < Provider store = { store } >
7598+ < XtermHost
7599+ terminalId = "mobile-line-copy-host-target-terminal"
7600+ workspaceId = "test-workspace"
7601+ />
7602+ </ Provider >
7603+ ) ;
7604+
7605+ const host = container . querySelector ( ".xterm-host" ) as HTMLDivElement | null ;
7606+ expect ( host ) . toBeTruthy ( ) ;
7607+
7608+ const rowsElement = document . createElement ( "div" ) ;
7609+ rowsElement . className = "xterm-rows" ;
7610+ const firstRow = document . createElement ( "div" ) ;
7611+ firstRow . innerHTML = "<span>ignored</span>" ;
7612+ const secondRow = document . createElement ( "div" ) ;
7613+ secondRow . innerHTML = "<span>from coords</span>" ;
7614+ rowsElement . append ( firstRow , secondRow ) ;
7615+ host ! . appendChild ( rowsElement ) ;
7616+ stubRowsGeometry ( host ! , rowsElement , [ firstRow , secondRow ] , {
7617+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 40 } ,
7618+ rowHeight : 20 ,
7619+ } ) ;
7620+
7621+ dispatchTouchEvent ( host ! , "touchstart" , [
7622+ { identifier : 1 , clientX : 40 , clientY : 130 , target : host } ,
7623+ ] ) ;
7624+
7625+ await act ( async ( ) => {
7626+ vi . advanceTimersByTime ( 500 ) ;
7627+ await Promise . resolve ( ) ;
7628+ await Promise . resolve ( ) ;
7629+ } ) ;
7630+
7631+ expect ( writeText ) . toHaveBeenCalledWith ( "from coords" ) ;
7632+ expect ( vibrate ) . toHaveBeenCalledWith ( 10 ) ;
7633+
7634+ window . matchMedia = originalMatchMedia ;
7635+ vi . useRealTimers ( ) ;
7636+ } ) ;
7637+
7638+ it ( "mobile line copy still succeeds after the row DOM is replaced before long press matures" , async ( ) => {
7639+ vi . useFakeTimers ( ) ;
7640+ viewportMocks . viewport = "mobile" ;
7641+
7642+ const originalMatchMedia = window . matchMedia ;
7643+ const writeText = vi . fn ( ) . mockResolvedValue ( undefined ) ;
7644+ const store = createStore ( ) ;
7645+
7646+ mockTerminal . buffer . active . viewportY = 4 ;
7647+ setMockBufferLines ( [ [ 5 , "stable row text" , false ] ] ) ;
7648+
7649+ window . matchMedia = vi . fn ( ) . mockImplementation ( ( query : string ) => ( {
7650+ matches : query === "(pointer: coarse)" ,
7651+ media : query ,
7652+ onchange : null ,
7653+ addListener : vi . fn ( ) ,
7654+ removeListener : vi . fn ( ) ,
7655+ addEventListener : vi . fn ( ) ,
7656+ removeEventListener : vi . fn ( ) ,
7657+ dispatchEvent : vi . fn ( ) ,
7658+ } ) ) as typeof window . matchMedia ;
7659+
7660+ Object . defineProperty ( navigator , "clipboard" , {
7661+ configurable : true ,
7662+ value : { writeText } satisfies Pick < Clipboard , "writeText" > ,
7663+ } ) ;
7664+
7665+ store . set ( localeAtom , "en" ) ;
7666+ store . set ( terminalPreferencesAtom , { copyOnSelect : true } ) ;
7667+ store . set ( wsClientAtom , {
7668+ sendCommand : vi . fn ( ) . mockResolvedValue ( { ok : true , data : { status : "ok" } } ) ,
7669+ subscribe : vi . fn ( ( ) => ( ) => { } ) ,
7670+ getStatus : vi . fn ( ( ) => "connected" ) ,
7671+ onStatus : vi . fn ( ( ) => ( ) => { } ) ,
7672+ sendTerminalInput : vi . fn ( ) . mockResolvedValue ( undefined ) ,
7673+ } as never ) ;
7674+
7675+ const { container } = render (
7676+ < Provider store = { store } >
7677+ < XtermHost terminalId = "mobile-line-copy-redraw-terminal" workspaceId = "test-workspace" />
7678+ </ Provider >
7679+ ) ;
7680+
7681+ const host = container . querySelector ( ".xterm-host" ) as HTMLDivElement | null ;
7682+ expect ( host ) . toBeTruthy ( ) ;
7683+
7684+ const rowsElement = document . createElement ( "div" ) ;
7685+ rowsElement . className = "xterm-rows" ;
7686+ const firstRow = document . createElement ( "div" ) ;
7687+ firstRow . innerHTML = "<span>ignored</span>" ;
7688+ const secondRow = document . createElement ( "div" ) ;
7689+ secondRow . innerHTML = "<span><span>stable row text</span></span>" ;
7690+ rowsElement . append ( firstRow , secondRow ) ;
7691+ host ! . appendChild ( rowsElement ) ;
7692+ stubRowsGeometry ( host ! , rowsElement , [ firstRow , secondRow ] , {
7693+ rowsRect : { x : 0 , y : 100 , width : 320 , height : 40 } ,
7694+ rowHeight : 20 ,
7695+ } ) ;
7696+
7697+ dispatchTouchEvent ( host ! , "touchstart" , [
7698+ {
7699+ identifier : 1 ,
7700+ clientX : 40 ,
7701+ clientY : 130 ,
7702+ target : secondRow . querySelector ( "span span" ) as HTMLSpanElement ,
7703+ } ,
7704+ ] ) ;
7705+
7706+ secondRow . replaceChildren ( document . createElement ( "span" ) ) ;
7707+
7708+ await act ( async ( ) => {
7709+ vi . advanceTimersByTime ( 500 ) ;
7710+ await Promise . resolve ( ) ;
7711+ await Promise . resolve ( ) ;
7712+ } ) ;
7713+
7714+ expect ( writeText ) . toHaveBeenCalledWith ( "stable row text" ) ;
7715+
7716+ window . matchMedia = originalMatchMedia ;
7717+ vi . useRealTimers ( ) ;
7718+ } ) ;
7719+
74747720 it ( "syncs xterm resize events back to the server PTY" , async ( ) => {
74757721 const store = createStore ( ) ;
74767722 const dispatchCommand = vi . fn ( ) . mockResolvedValue ( { ok : true , data : { status : "ok" } } ) ;
0 commit comments