Skip to content

Commit 05416a3

Browse files
authored
feat(review): add horizontal code-column scrolling (#171)
1 parent c636903 commit 05416a3

13 files changed

Lines changed: 683 additions & 53 deletions

src/ui/App.tsx

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ import { StatusBar } from "./components/chrome/StatusBar";
1414
import { DiffPane } from "./components/panes/DiffPane";
1515
import { SidebarPane } from "./components/panes/SidebarPane";
1616
import { PaneDivider } from "./components/panes/PaneDivider";
17+
import {
18+
findMaxLineNumber,
19+
maxFileCodeLineWidth,
20+
resolveCodeViewportWidth,
21+
} from "./diff/codeColumns";
1722
import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts";
1823
import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge";
1924
import { useMenuController } from "./hooks/useMenuController";
@@ -26,6 +31,8 @@ import { resolveTheme, THEMES } from "./themes";
2631

2732
type FocusArea = "files" | "filter";
2833

34+
const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8;
35+
2936
const LazyHelpDialog = lazy(async () => ({
3037
default: (await import("./components/chrome/HelpDialog")).HelpDialog,
3138
}));
@@ -99,6 +106,7 @@ export function App({
99106
const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false);
100107
const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true);
101108
const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false);
109+
const [codeHorizontalOffset, setCodeHorizontalOffset] = useState(0);
102110
const [showHunkHeaders, setShowHunkHeaders] = useState(bootstrap.initialShowHunkHeaders ?? true);
103111
const [sidebarVisible, setSidebarVisible] = useState(true);
104112
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
@@ -168,6 +176,26 @@ export function App({
168176
const diffPaneWidth = renderSidebar
169177
? Math.max(DIFF_MIN_WIDTH, availableCenterWidth - clampedSidebarWidth)
170178
: Math.max(0, availableCenterWidth);
179+
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
180+
const maxVisibleLineNumber = useMemo(
181+
() =>
182+
filteredFiles.reduce(
183+
(maxLineNumber, file) => Math.max(maxLineNumber, findMaxLineNumber(file)),
184+
1,
185+
),
186+
[filteredFiles],
187+
);
188+
const maxLineNumberDigits = String(maxVisibleLineNumber).length;
189+
const codeViewportWidth = useMemo(
190+
() =>
191+
resolveCodeViewportWidth(
192+
resolvedLayout,
193+
diffContentWidth,
194+
maxLineNumberDigits,
195+
showLineNumbers,
196+
),
197+
[diffContentWidth, maxLineNumberDigits, resolvedLayout, showLineNumbers],
198+
);
171199
const isResizingSidebar = resizeDragOriginX !== null && resizeStartWidth !== null;
172200
const dividerHitLeft = Math.max(
173201
1,
@@ -219,6 +247,34 @@ export function App({
219247
diffScrollRef.current?.scrollBy(delta, unit);
220248
};
221249

250+
const maxCodeHorizontalOffset = useMemo(
251+
() =>
252+
Math.max(
253+
0,
254+
filteredFiles.reduce(
255+
(maxWidth, file) => Math.max(maxWidth, maxFileCodeLineWidth(file)),
256+
0,
257+
) - codeViewportWidth,
258+
),
259+
[codeViewportWidth, filteredFiles],
260+
);
261+
262+
useEffect(() => {
263+
setCodeHorizontalOffset((current) => clamp(current, 0, maxCodeHorizontalOffset));
264+
}, [maxCodeHorizontalOffset]);
265+
266+
/** Shift the visible code columns horizontally without moving gutters or headers. */
267+
const scrollCodeHorizontally = useCallback(
268+
(delta: number) => {
269+
if (wrapLines || delta === 0 || maxCodeHorizontalOffset <= 0) {
270+
return;
271+
}
272+
273+
setCodeHorizontalOffset((current) => clamp(current + delta, 0, maxCodeHorizontalOffset));
274+
},
275+
[maxCodeHorizontalOffset, wrapLines],
276+
);
277+
222278
/** Toggle the global agent note layer on or off. */
223279
const toggleAgentNotes = () => {
224280
setShowAgentNotes((current) => !current);
@@ -234,6 +290,7 @@ export function App({
234290
// Capture the pre-toggle viewport position synchronously so DiffPane can restore the same
235291
// top-most source row after wrapped row heights change.
236292
wrapToggleScrollTopRef.current = diffScrollRef.current?.scrollTop ?? 0;
293+
setCodeHorizontalOffset(0);
237294
setWrapLines((current) => !current);
238295
};
239296

@@ -491,6 +548,7 @@ export function App({
491548
openMenu,
492549
pagerMode,
493550
requestQuit,
551+
scrollCodeHorizontally,
494552
scrollDiff,
495553
selectLayoutMode: setLayoutMode,
496554
showHelp,
@@ -559,7 +617,6 @@ export function App({
559617
);
560618
const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`;
561619
const sidebarTextWidth = Math.max(8, clampedSidebarWidth - 2);
562-
const diffContentWidth = Math.max(12, diffPaneWidth - 2);
563620
const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3)));
564621
const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1);
565622
const diffSeparatorWidth = Math.max(4, diffContentWidth - 2);
@@ -636,6 +693,7 @@ export function App({
636693
) : null}
637694

638695
<DiffPane
696+
codeHorizontalOffset={codeHorizontalOffset}
639697
diffContentWidth={diffContentWidth}
640698
files={filteredFiles}
641699
pagerMode={pagerMode}
@@ -656,6 +714,9 @@ export function App({
656714
theme={activeTheme}
657715
width={diffPaneWidth}
658716
onOpenAgentNotesAtHunk={openAgentNotesAtHunk}
717+
onScrollCodeHorizontally={(delta) => {
718+
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
719+
}}
659720
onSelectFile={jumpToFile}
660721
/>
661722
</box>

src/ui/AppHost.interactions.test.tsx

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@ function firstVisibleAddedLine(frame: string) {
297297
return frame.match(/line\d{2} = 1\d{2}/)?.[0] ?? null;
298298
}
299299

300+
function firstVisibleAddedLineNumber(frame: string) {
301+
return frame.match(/\s*(\d+)\s+\+/)?.[1] ?? null;
302+
}
303+
300304
describe("App interactions", () => {
301305
test("keyboard shortcuts toggle notes, line numbers, and hunk metadata", async () => {
302306
const setup = await testRender(<AppHost bootstrap={createSingleFileBootstrap()} />, {
@@ -450,6 +454,235 @@ describe("App interactions", () => {
450454
}
451455
});
452456

457+
test("left and right arrows can reveal offscreen code columns in nowrap mode", async () => {
458+
const setup = await testRender(<AppHost bootstrap={createWrapBootstrap()} />, {
459+
width: 92,
460+
height: 20,
461+
});
462+
463+
try {
464+
await flush(setup);
465+
466+
let frame = setup.captureCharFrame();
467+
expect(frame).toContain("this is a very");
468+
expect(frame).not.toContain("interaction coverage");
469+
470+
for (let index = 0; index < 64; index += 1) {
471+
await act(async () => {
472+
await setup.mockInput.pressArrow("right");
473+
});
474+
await flush(setup);
475+
frame = setup.captureCharFrame();
476+
if (frame.includes("interaction coverage")) {
477+
break;
478+
}
479+
}
480+
481+
expect(frame).toContain("interaction coverage");
482+
expect(frame).not.toContain("this is a very");
483+
484+
for (let index = 0; index < 64; index += 1) {
485+
await act(async () => {
486+
await setup.mockInput.pressArrow("left");
487+
});
488+
await flush(setup);
489+
frame = setup.captureCharFrame();
490+
if (frame.includes("this is a very")) {
491+
break;
492+
}
493+
}
494+
495+
expect(frame).toContain("this is a very");
496+
} finally {
497+
await act(async () => {
498+
setup.renderer.destroy();
499+
});
500+
}
501+
});
502+
503+
test("shift plus left and right arrows scroll code horizontally faster", async () => {
504+
const setup = await testRender(<AppHost bootstrap={createWrapBootstrap()} />, {
505+
width: 92,
506+
height: 20,
507+
});
508+
509+
try {
510+
await flush(setup);
511+
512+
let frame = setup.captureCharFrame();
513+
expect(frame).toContain("this is a very");
514+
expect(frame).not.toContain("interaction coverage");
515+
516+
for (let index = 0; index < 8; index += 1) {
517+
await act(async () => {
518+
await setup.mockInput.pressArrow("right", { shift: true });
519+
});
520+
await flush(setup);
521+
frame = setup.captureCharFrame();
522+
if (frame.includes("interaction coverage")) {
523+
break;
524+
}
525+
}
526+
527+
expect(frame).toContain("interaction coverage");
528+
expect(frame).not.toContain("this is a very");
529+
530+
for (let index = 0; index < 8; index += 1) {
531+
await act(async () => {
532+
await setup.mockInput.pressArrow("left", { shift: true });
533+
});
534+
await flush(setup);
535+
frame = setup.captureCharFrame();
536+
if (frame.includes("this is a very")) {
537+
break;
538+
}
539+
}
540+
541+
expect(frame).toContain("this is a very");
542+
expect(frame).not.toContain("interaction coverage");
543+
} finally {
544+
await act(async () => {
545+
setup.renderer.destroy();
546+
});
547+
}
548+
});
549+
550+
test("shift plus mouse wheel scrolls code horizontally", async () => {
551+
const setup = await testRender(<AppHost bootstrap={createWrapBootstrap()} />, {
552+
width: 92,
553+
height: 20,
554+
});
555+
556+
try {
557+
await flush(setup);
558+
559+
let frame = setup.captureCharFrame();
560+
expect(frame).toContain("this is a very");
561+
expect(frame).not.toContain("interaction coverage");
562+
563+
for (let index = 0; index < 8; index += 1) {
564+
await act(async () => {
565+
await setup.mockMouse.scroll(60, 10, "down", { modifiers: { shift: true } });
566+
});
567+
await flush(setup);
568+
frame = setup.captureCharFrame();
569+
if (frame.includes("interaction coverage")) {
570+
break;
571+
}
572+
}
573+
574+
expect(frame).toContain("interaction coverage");
575+
expect(frame).not.toContain("this is a very");
576+
577+
for (let index = 0; index < 8; index += 1) {
578+
await act(async () => {
579+
await setup.mockMouse.scroll(60, 10, "up", { modifiers: { shift: true } });
580+
});
581+
await flush(setup);
582+
frame = setup.captureCharFrame();
583+
if (frame.includes("this is a very")) {
584+
break;
585+
}
586+
}
587+
588+
expect(frame).toContain("this is a very");
589+
expect(frame).not.toContain("interaction coverage");
590+
} finally {
591+
await act(async () => {
592+
setup.renderer.destroy();
593+
});
594+
}
595+
});
596+
597+
test("shift plus mouse wheel does not move the vertical review position", async () => {
598+
const setup = await testRender(<AppHost bootstrap={createWrapScrollBootstrap()} />, {
599+
width: 92,
600+
height: 20,
601+
});
602+
603+
try {
604+
await flush(setup);
605+
606+
let frame = setup.captureCharFrame();
607+
const initialTopLine = firstVisibleAddedLineNumber(frame);
608+
expect(initialTopLine).toBeTruthy();
609+
expect(frame).not.toContain("viewport anchoring");
610+
611+
for (let index = 0; index < 8; index += 1) {
612+
await act(async () => {
613+
await setup.mockMouse.scroll(60, 10, "down", { modifiers: { shift: true } });
614+
});
615+
await flush(setup);
616+
frame = setup.captureCharFrame();
617+
if (frame.includes("viewport anchoring")) {
618+
break;
619+
}
620+
}
621+
622+
expect(frame).toContain("viewport anchoring");
623+
expect(firstVisibleAddedLineNumber(frame)).toBe(initialTopLine);
624+
} finally {
625+
await act(async () => {
626+
setup.renderer.destroy();
627+
});
628+
}
629+
});
630+
631+
test("wrap toggles reset the horizontal code offset", async () => {
632+
const setup = await testRender(<AppHost bootstrap={createWrapBootstrap()} />, {
633+
width: 92,
634+
height: 20,
635+
});
636+
637+
try {
638+
await flush(setup);
639+
640+
let frame = setup.captureCharFrame();
641+
expect(frame).toContain("this is a very");
642+
643+
for (let index = 0; index < 8; index += 1) {
644+
await act(async () => {
645+
await setup.mockInput.pressArrow("right", { shift: true });
646+
});
647+
await flush(setup);
648+
frame = setup.captureCharFrame();
649+
if (frame.includes("interaction coverage")) {
650+
break;
651+
}
652+
}
653+
654+
expect(frame).toContain("interaction coverage");
655+
expect(frame).not.toContain("this is a very");
656+
657+
await act(async () => {
658+
await setup.mockInput.typeText("w");
659+
});
660+
await settleWrapToggle(setup);
661+
662+
frame = await waitForFrame(setup, (nextFrame) => nextFrame.includes("overage';"));
663+
expect(frame).toContain("this is a very");
664+
expect(frame).toContain("long wrapped line");
665+
expect(frame).toContain("overage';");
666+
667+
await act(async () => {
668+
await setup.mockInput.typeText("w");
669+
});
670+
await settleWrapToggle(setup);
671+
672+
frame = await waitForFrame(
673+
setup,
674+
(nextFrame) =>
675+
nextFrame.includes("this is a very") && !nextFrame.includes("interaction coverage"),
676+
);
677+
expect(frame).toContain("this is a very");
678+
expect(frame).not.toContain("interaction coverage");
679+
} finally {
680+
await act(async () => {
681+
setup.renderer.destroy();
682+
});
683+
}
684+
});
685+
453686
test("bootstrap preferences initialize the visible view state", async () => {
454687
const setup = await testRender(
455688
<AppHost

src/ui/components/chrome/HelpDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export function HelpDialog({
2727
["d / u", "half page down / up"],
2828
["[ / ]", "previous / next hunk"],
2929
["{ / }", "previous / next comment"],
30+
["← / →", "scroll code (Shift = faster)"],
3031
["Home / End", "jump to top / bottom"],
3132
],
3233
},

0 commit comments

Comments
 (0)