Skip to content

Commit 6d431a5

Browse files
authored
Sync active hunks to mouse scrolling and prefetch diff highlighting (#172)
1 parent 05416a3 commit 6d431a5

11 files changed

Lines changed: 784 additions & 144 deletions

src/ui/App.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ export function App({
718718
scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS);
719719
}}
720720
onSelectFile={jumpToFile}
721+
onViewportCenteredHunkChange={review.selectHunk}
721722
/>
722723
</box>
723724

src/ui/AppHost.interactions.test.tsx

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import { describe, expect, mock, test } from "bun:test";
55
import { testRender } from "@opentui/react/test-utils";
66
import { act } from "react";
77
import type { HunkHostClient } from "../mcp/client";
8-
import type { HunkSessionRegistration, SessionServerMessage } from "../mcp/types";
8+
import type {
9+
HunkSessionRegistration,
10+
HunkSessionSnapshot,
11+
SessionServerMessage,
12+
} from "../mcp/types";
913
import type { AppBootstrap, LayoutMode } from "../core/types";
1014
import { createTestGitAppBootstrap } from "../../test/helpers/app-bootstrap";
1115
import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers";
@@ -41,6 +45,7 @@ function createMockHostClient() {
4145
type Bridge = Parameters<HunkHostClient["setBridge"]>[0];
4246

4347
let bridge: Bridge = null;
48+
let latestSnapshot: HunkSessionSnapshot | null = null;
4449
const registration: HunkSessionRegistration = {
4550
sessionId: "session-1",
4651
pid: process.pid,
@@ -59,9 +64,12 @@ function createMockHostClient() {
5964
setBridge: (nextBridge: Bridge) => {
6065
bridge = nextBridge;
6166
},
62-
updateSnapshot: () => {},
67+
updateSnapshot: (snapshot: HunkSessionSnapshot) => {
68+
latestSnapshot = snapshot;
69+
},
6370
} as unknown as HunkHostClient,
6471
getBridge: () => bridge,
72+
getLatestSnapshot: () => latestSnapshot,
6573
navigateToHunk: async (
6674
input: Extract<SessionServerMessage, { command: "navigate_to_hunk" }>["input"],
6775
) => {
@@ -226,6 +234,40 @@ function createTwoFileHunkBootstrap(): AppBootstrap {
226234
});
227235
}
228236

237+
function createMouseScrollSelectionBootstrap(): AppBootstrap {
238+
const firstBeforeLines = createNumberedAssignmentLines(1, 12);
239+
const secondBeforeLines = Array.from(
240+
{ length: 90 },
241+
(_, index) => `export const line${String(index + 13).padStart(2, "0")} = ${index + 13};`,
242+
);
243+
const secondAfterLines = [...secondBeforeLines];
244+
245+
secondAfterLines[0] = "export const line13 = 1300;";
246+
secondAfterLines[59] = "export const line72 = 7200;";
247+
secondAfterLines[60] = "export const line73 = 7300;";
248+
secondAfterLines[61] = "export const line74 = 7400;";
249+
250+
return createTestGitAppBootstrap({
251+
changesetId: "changeset:mouse-scroll-selection",
252+
files: [
253+
createTestDiffFile(
254+
"first",
255+
"first.ts",
256+
lines(...firstBeforeLines),
257+
lines("export const line01 = 101;", ...createNumberedAssignmentLines(2, 11)),
258+
true,
259+
),
260+
createTestDiffFile(
261+
"second",
262+
"second.ts",
263+
lines(...secondBeforeLines),
264+
lines(...secondAfterLines),
265+
true,
266+
),
267+
],
268+
});
269+
}
270+
229271
function createCollapsedTopBootstrap(): AppBootstrap {
230272
const beforeLines = Array.from(
231273
{ length: 400 },
@@ -293,6 +335,29 @@ async function waitForFrame(
293335
return frame;
294336
}
295337

338+
async function waitForSnapshot(
339+
setup: Awaited<ReturnType<typeof testRender>>,
340+
getSnapshot: () => HunkSessionSnapshot | null,
341+
predicate: (snapshot: HunkSessionSnapshot) => boolean,
342+
attempts = 8,
343+
) {
344+
let snapshot = getSnapshot();
345+
346+
for (let attempt = 0; attempt < attempts; attempt += 1) {
347+
if (snapshot && predicate(snapshot)) {
348+
return snapshot;
349+
}
350+
351+
await act(async () => {
352+
await Bun.sleep(30);
353+
await setup.renderOnce();
354+
});
355+
snapshot = getSnapshot();
356+
}
357+
358+
return snapshot;
359+
}
360+
296361
function firstVisibleAddedLine(frame: string) {
297362
return frame.match(/line\d{2} = 1\d{2}/)?.[0] ?? null;
298363
}
@@ -1669,6 +1734,55 @@ describe("App interactions", () => {
16691734
}
16701735
});
16711736

1737+
test("mouse wheel scrolling updates the active file and hunk to the viewport center", async () => {
1738+
const { getLatestSnapshot, hostClient } = createMockHostClient();
1739+
const setup = await testRender(
1740+
<AppHost bootstrap={createMouseScrollSelectionBootstrap()} hostClient={hostClient} />,
1741+
{
1742+
width: 220,
1743+
height: 12,
1744+
},
1745+
);
1746+
1747+
try {
1748+
await flush(setup);
1749+
1750+
expect(getLatestSnapshot()).toMatchObject({
1751+
selectedFilePath: "first.ts",
1752+
selectedHunkIndex: 0,
1753+
});
1754+
1755+
let snapshot = getLatestSnapshot();
1756+
for (let index = 0; index < 24; index += 1) {
1757+
await act(async () => {
1758+
await setup.mockMouse.scroll(120, 7, "down");
1759+
});
1760+
await flush(setup);
1761+
1762+
snapshot = await waitForSnapshot(
1763+
setup,
1764+
getLatestSnapshot,
1765+
(currentSnapshot) =>
1766+
currentSnapshot.selectedFilePath === "second.ts" &&
1767+
currentSnapshot.selectedHunkIndex === 1,
1768+
4,
1769+
);
1770+
if (snapshot?.selectedFilePath === "second.ts" && snapshot.selectedHunkIndex === 1) {
1771+
break;
1772+
}
1773+
}
1774+
1775+
expect(snapshot).toMatchObject({
1776+
selectedFilePath: "second.ts",
1777+
selectedHunkIndex: 1,
1778+
});
1779+
} finally {
1780+
await act(async () => {
1781+
setup.renderer.destroy();
1782+
});
1783+
}
1784+
});
1785+
16721786
test("clicking a sidebar file makes that file own the top of the review pane", async () => {
16731787
const setup = await testRender(<AppHost bootstrap={createTwoFileHunkBootstrap()} />, {
16741788
width: 220,

0 commit comments

Comments
 (0)