@@ -299,4 +299,212 @@ describe("Vertical scrollbar", () => {
299299 } ) ;
300300 }
301301 } ) ;
302+
303+ test ( "thumb drag scrolls content" , async ( ) => {
304+ // Create a file with many lines to ensure scrolling
305+ const before = Array . from ( { length : 100 } , ( _ , j ) => `line${ j + 1 } ` ) . join ( "\n" ) ;
306+ const after = before . replace ( "line50" , "line50modified" ) ;
307+
308+ const bootstrap : AppBootstrap = {
309+ input : {
310+ kind : "git" ,
311+ staged : false ,
312+ options : { mode : "split" } ,
313+ } ,
314+ changeset : {
315+ id : "drag-test" ,
316+ sourceLabel : "repo" ,
317+ title : "drag test" ,
318+ files : [ createDiffFile ( "drag" , "src/drag.ts" , before , after ) ] ,
319+ } ,
320+ initialMode : "split" ,
321+ initialTheme : "midnight" ,
322+ } ;
323+
324+ const setup = await testRender ( < App bootstrap = { bootstrap } /> , {
325+ width : 160 ,
326+ height : 20 , // Small viewport to force scrolling
327+ } ) ;
328+
329+ try {
330+ await flush ( setup ) ;
331+ await act ( async ( ) => {
332+ await Bun . sleep ( 100 ) ;
333+ } ) ;
334+
335+ // Get initial frame - app centers on the hunk at line 50
336+ const frame1 = setup . captureCharFrame ( ) ;
337+ expect ( frame1 ) . toContain ( "line50" ) ;
338+
339+ // Drag scrollbar thumb down (rightmost column is scrollbar at x=159, y ranges 0-19)
340+ // Thumb should be at some position, drag it down to scroll
341+ await act ( async ( ) => {
342+ // Drag from top area of scrollbar down
343+ await setup . mockMouse . drag ( 159 , 2 , 159 , 10 ) ;
344+ await flush ( setup ) ;
345+ await Bun . sleep ( 100 ) ;
346+ } ) ;
347+
348+ // After dragging down, we should see different content
349+ const frame2 = setup . captureCharFrame ( ) ;
350+ expect ( frame2 ) . toBeTruthy ( ) ;
351+
352+ // Drag back up
353+ await act ( async ( ) => {
354+ await setup . mockMouse . drag ( 159 , 10 , 159 , 2 ) ;
355+ await flush ( setup ) ;
356+ await Bun . sleep ( 100 ) ;
357+ } ) ;
358+
359+ const frame3 = setup . captureCharFrame ( ) ;
360+ expect ( frame3 ) . toBeTruthy ( ) ;
361+ } finally {
362+ await act ( async ( ) => {
363+ setup . renderer . destroy ( ) ;
364+ } ) ;
365+ }
366+ } ) ;
367+
368+ test ( "track click scrolls by one viewport" , async ( ) => {
369+ // Create a file with many lines to ensure scrolling
370+ const lines = Array . from ( { length : 80 } , ( _ , j ) => `line${ String ( j + 1 ) . padStart ( 3 , "0" ) } ` ) ;
371+ const before = lines . join ( "\n" ) ;
372+ const after = before . replace ( "line040" , "line040modified" ) ;
373+
374+ const bootstrap : AppBootstrap = {
375+ input : {
376+ kind : "git" ,
377+ staged : false ,
378+ options : { mode : "split" } ,
379+ } ,
380+ changeset : {
381+ id : "track-click-test" ,
382+ sourceLabel : "repo" ,
383+ title : "track click test" ,
384+ files : [ createDiffFile ( "track" , "src/track.ts" , before , after ) ] ,
385+ } ,
386+ initialMode : "split" ,
387+ initialTheme : "midnight" ,
388+ } ;
389+
390+ const setup = await testRender ( < App bootstrap = { bootstrap } /> , {
391+ width : 160 ,
392+ height : 15 , // Viewport of 15 lines
393+ } ) ;
394+
395+ try {
396+ await flush ( setup ) ;
397+ await act ( async ( ) => {
398+ await Bun . sleep ( 100 ) ;
399+ } ) ;
400+
401+ // Get initial content - app centers on the hunk at line 40
402+ const frame1 = setup . captureCharFrame ( ) ;
403+ expect ( frame1 ) . toContain ( "line040" ) ;
404+
405+ // First scroll down a bit to make scrollbar visible and move thumb down
406+ await act ( async ( ) => {
407+ for ( let i = 0 ; i < 5 ; i ++ ) {
408+ await setup . mockInput . pressArrow ( "down" ) ;
409+ }
410+ await flush ( setup ) ;
411+ await Bun . sleep ( 100 ) ;
412+ } ) ;
413+
414+ // Click on scrollbar track below thumb to page down
415+ // Scrollbar is at rightmost column (x=159), click near bottom
416+ await act ( async ( ) => {
417+ await setup . mockMouse . click ( 159 , 12 ) ;
418+ await flush ( setup ) ;
419+ await Bun . sleep ( 100 ) ;
420+ } ) ;
421+
422+ const frame2 = setup . captureCharFrame ( ) ;
423+ // Should have scrolled down further after track click
424+ expect ( frame2 ) . toBeTruthy ( ) ;
425+
426+ // Click on scrollbar track above thumb to page up
427+ await act ( async ( ) => {
428+ await setup . mockMouse . click ( 159 , 2 ) ;
429+ await flush ( setup ) ;
430+ await Bun . sleep ( 100 ) ;
431+ } ) ;
432+
433+ const frame3 = setup . captureCharFrame ( ) ;
434+ // Should have scrolled back up
435+ expect ( frame3 ) . toBeTruthy ( ) ;
436+ } finally {
437+ await act ( async ( ) => {
438+ setup . renderer . destroy ( ) ;
439+ } ) ;
440+ }
441+ } ) ;
442+
443+ test ( "handles edge case when content barely exceeds viewport" , async ( ) => {
444+ // Create content that's just slightly larger than viewport
445+ // This tests the division-by-zero guard in drag calculations
446+ // Use the same pattern as other tests which work correctly
447+ const before = Array . from (
448+ { length : 25 } ,
449+ ( _ , j ) => `export const line${ String ( j + 1 ) . padStart ( 2 , "0" ) } = ${ j + 1 } ;` ,
450+ ) . join ( "\n" ) ;
451+ const after = before . replace ( "line08 = 8;" , "line08 = 999; // modified" ) ;
452+
453+ const bootstrap : AppBootstrap = {
454+ input : {
455+ kind : "git" ,
456+ staged : false ,
457+ options : { mode : "split" } ,
458+ } ,
459+ changeset : {
460+ id : "edge-case-test" ,
461+ sourceLabel : "repo" ,
462+ title : "edge case test" ,
463+ files : [ createDiffFile ( "edge" , "src/edge.ts" , before , after ) ] ,
464+ } ,
465+ initialMode : "split" ,
466+ initialTheme : "midnight" ,
467+ } ;
468+
469+ const setup = await testRender ( < App bootstrap = { bootstrap } /> , {
470+ width : 160 ,
471+ height : 15 , // Small viewport to force scrolling (25 lines of content in 15-line viewport)
472+ } ) ;
473+
474+ try {
475+ await flush ( setup ) ;
476+ await act ( async ( ) => {
477+ await Bun . sleep ( 100 ) ;
478+ } ) ;
479+
480+ // Verify app renders with the hunk visible - look for the modified line
481+ const frame1 = setup . captureCharFrame ( ) ;
482+ expect ( frame1 ) . toContain ( "line08" ) ;
483+
484+ // Try to drag - should not crash with division by zero
485+ await act ( async ( ) => {
486+ await setup . mockMouse . drag ( 159 , 0 , 159 , 5 ) ;
487+ await flush ( setup ) ;
488+ await Bun . sleep ( 100 ) ;
489+ } ) ;
490+
491+ // App should still be responsive after drag attempt
492+ const frame2 = setup . captureCharFrame ( ) ;
493+ expect ( frame2 ) . toBeTruthy ( ) ;
494+
495+ // Try track click - should not crash
496+ await act ( async ( ) => {
497+ await setup . mockMouse . click ( 159 , 10 ) ;
498+ await flush ( setup ) ;
499+ await Bun . sleep ( 100 ) ;
500+ } ) ;
501+
502+ const frame3 = setup . captureCharFrame ( ) ;
503+ expect ( frame3 ) . toBeTruthy ( ) ;
504+ } finally {
505+ await act ( async ( ) => {
506+ setup . renderer . destroy ( ) ;
507+ } ) ;
508+ }
509+ } ) ;
302510} ) ;
0 commit comments