Skip to content

Commit 6cdf774

Browse files
authored
fix(code-review): navigate with rendered file anchors
Generated-By: PostHog Code Task-Id: 71e782ac-16eb-409f-a5d3-2aa4ad9117f8
1 parent fc6dbe9 commit 6cdf774

3 files changed

Lines changed: 116 additions & 30 deletions

File tree

packages/ui/src/features/code-review/components/ReviewShell.tsx

Lines changed: 52 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import { useReviewDraftsStore } from "../reviewDraftsStore";
1515
import { REVIEW_HOST, type ReviewHost } from "../reviewHost";
1616
import { useReviewNavigationStore } from "../reviewNavigationStore";
1717
import type { ReviewListItem, ReviewShellProps } from "../reviewShellParts";
18-
import { isFileViewed } from "../reviewShellParts";
18+
import {
19+
findActiveScrollKey,
20+
findRenderedScrollAnchor,
21+
isFileViewed,
22+
} from "../reviewShellParts";
1923
import { ReviewViewedContext } from "../reviewViewedContext";
2024
import { useReviewViewedStore } from "../reviewViewedStore";
2125
import { PendingReviewBar } from "./PendingReviewBar";
@@ -127,8 +131,10 @@ export function ReviewShell({
127131
const reviewHost = useService<ReviewHost>(REVIEW_HOST);
128132
const taskId = task.id;
129133
const listRef = useRef<VListHandle | null>(null);
134+
const listContainerRef = useRef<HTMLDivElement | null>(null);
130135
const lastActiveRef = useRef<string | null>(null);
131-
const navigationLockedRef = useRef(false);
136+
const pendingNavigationRef = useRef<string | null>(null);
137+
const navigationFrameRef = useRef<number | null>(null);
132138

133139
const workerFactory = useCallback(
134140
() => reviewHost.diffWorkerFactory(),
@@ -203,6 +209,9 @@ export function ReviewShell({
203209

204210
useEffect(() => {
205211
return () => {
212+
if (navigationFrameRef.current !== null) {
213+
cancelAnimationFrame(navigationFrameRef.current);
214+
}
206215
clearTask(taskId);
207216
useReviewDraftsStore.getState().clearDrafts(taskId);
208217
};
@@ -217,14 +226,37 @@ export function ReviewShell({
217226
const viewed =
218227
currentSignature !== undefined &&
219228
isFileViewed(viewedRecord[scrollRequest], currentSignature);
220-
navigationLockedRef.current = true;
229+
if (navigationFrameRef.current !== null) {
230+
cancelAnimationFrame(navigationFrameRef.current);
231+
}
232+
pendingNavigationRef.current = scrollRequest;
221233
if (!viewed) onUncollapseFile?.(scrollRequest);
222-
requestAnimationFrame(() => {
234+
235+
const scrollToAnchor = (remainingAttempts: number) => {
223236
listRef.current?.scrollToIndex(targetIndex, { align: "start" });
224-
lastActiveRef.current = scrollRequest;
225-
setActiveFilePath(taskId, scrollRequest);
226-
clearScrollRequest(taskId);
227-
});
237+
navigationFrameRef.current = requestAnimationFrame(() => {
238+
const root = listContainerRef.current;
239+
const anchor = root
240+
? findRenderedScrollAnchor(root, scrollRequest)
241+
: null;
242+
243+
if (!anchor && remainingAttempts > 0) {
244+
scrollToAnchor(remainingAttempts - 1);
245+
return;
246+
}
247+
248+
anchor?.scrollIntoView({ block: "start", inline: "nearest" });
249+
lastActiveRef.current = scrollRequest;
250+
setActiveFilePath(taskId, scrollRequest);
251+
clearScrollRequest(taskId);
252+
navigationFrameRef.current = requestAnimationFrame(() => {
253+
pendingNavigationRef.current = null;
254+
navigationFrameRef.current = null;
255+
});
256+
});
257+
};
258+
259+
scrollToAnchor(5);
228260
}, [
229261
clearScrollRequest,
230262
currentSignatures,
@@ -236,24 +268,17 @@ export function ReviewShell({
236268
viewedRecord,
237269
]);
238270

239-
const handleScroll = useCallback(
240-
(offset: number) => {
241-
if (navigationLockedRef.current) return;
242-
const handle = listRef.current;
243-
if (!handle) return;
244-
const index = handle.findItemIndex(offset);
245-
const item = items[index];
246-
const scrollKey = item?.scrollKey;
247-
if (!scrollKey || scrollKey === lastActiveRef.current) return;
248-
lastActiveRef.current = scrollKey;
249-
setActiveFilePath(taskId, scrollKey);
250-
},
251-
[items, setActiveFilePath, taskId],
252-
);
253-
254-
const handleUserScrollIntent = useCallback(() => {
255-
navigationLockedRef.current = false;
256-
}, []);
271+
const handleScroll = useCallback(() => {
272+
if (pendingNavigationRef.current !== null) return;
273+
const scrollRoot = listContainerRef.current?.querySelector<HTMLElement>(
274+
".pierre-scroll-root",
275+
);
276+
if (!scrollRoot) return;
277+
const scrollKey = findActiveScrollKey(scrollRoot);
278+
if (!scrollKey || scrollKey === lastActiveRef.current) return;
279+
lastActiveRef.current = scrollKey;
280+
setActiveFilePath(taskId, scrollKey);
281+
}, [setActiveFilePath, taskId]);
257282

258283
const renderItem = useCallback(
259284
(item: ReviewListItem) => (
@@ -314,11 +339,9 @@ export function ReviewShell({
314339
/>
315340
<Flex className="min-h-0 flex-1">
316341
<Flex
342+
ref={listContainerRef}
317343
direction="column"
318344
className="min-w-0 flex-1"
319-
onPointerDownCapture={handleUserScrollIntent}
320-
onWheelCapture={handleUserScrollIntent}
321-
onKeyDownCapture={handleUserScrollIntent}
322345
>
323346
{isLoading ? (
324347
<Flex

packages/ui/src/features/code-review/reviewShellParts.test.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,12 @@ vi.mock("../../primitives/FileIcon", () => ({
1414
FileIcon: () => <span data-testid="file-icon" />,
1515
}));
1616

17-
import { DeferredDiffPlaceholder, DiffFileHeader } from "./reviewShellParts";
17+
import {
18+
DeferredDiffPlaceholder,
19+
DiffFileHeader,
20+
findActiveScrollKey,
21+
findRenderedScrollAnchor,
22+
} from "./reviewShellParts";
1823

1924
type FileDiffMetadata = import("@pierre/diffs/react").FileDiffMetadata;
2025

@@ -107,3 +112,35 @@ describe.each([
107112
expect(text.indexOf("2 comments")).toBeLessThan(text.indexOf(additions));
108113
});
109114
});
115+
116+
function setRect(element: HTMLElement, top: number, bottom: number) {
117+
element.getBoundingClientRect = vi.fn(() => ({ top, bottom }) as DOMRect);
118+
}
119+
120+
describe("review scroll anchors", () => {
121+
it("finds a rendered anchor by its exact file key", () => {
122+
const root = document.createElement("div");
123+
const anchor = document.createElement("div");
124+
anchor.dataset.scrollKey = "src/[id]/file.ts";
125+
root.append(anchor);
126+
127+
expect(findRenderedScrollAnchor(root, "src/[id]/file.ts")).toBe(anchor);
128+
});
129+
130+
it("selects the first rendered file crossing the scroll root top", () => {
131+
const root = document.createElement("div");
132+
const above = document.createElement("div");
133+
const active = document.createElement("div");
134+
const below = document.createElement("div");
135+
above.dataset.scrollKey = "above.ts";
136+
active.dataset.scrollKey = "active.ts";
137+
below.dataset.scrollKey = "below.ts";
138+
root.append(above, active, below);
139+
setRect(root, 100, 500);
140+
setRect(above, 20, 90);
141+
setRect(active, 80, 180);
142+
setRect(below, 180, 280);
143+
144+
expect(findActiveScrollKey(root)).toBe("active.ts");
145+
});
146+
});

packages/ui/src/features/code-review/reviewShellParts.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ export {
3434
} from "@posthog/core/code-review/reviewShellGeometry";
3535

3636
const STICKY_HEADER_CSS = `[data-diffs-header] { position: sticky; top: 0; z-index: 1; background: var(--gray-2); }`;
37+
const SCROLL_ANCHOR_SELECTOR = "[data-scroll-key]";
38+
39+
export function findRenderedScrollAnchor(
40+
root: HTMLElement,
41+
scrollKey: string,
42+
): HTMLElement | null {
43+
for (const anchor of root.querySelectorAll<HTMLElement>(
44+
SCROLL_ANCHOR_SELECTOR,
45+
)) {
46+
if (anchor.dataset.scrollKey === scrollKey) return anchor;
47+
}
48+
return null;
49+
}
50+
51+
export function findActiveScrollKey(root: HTMLElement): string | null {
52+
const rootTop = root.getBoundingClientRect().top;
53+
for (const anchor of root.querySelectorAll<HTMLElement>(
54+
SCROLL_ANCHOR_SELECTOR,
55+
)) {
56+
const scrollKey = anchor.dataset.scrollKey;
57+
if (scrollKey && anchor.getBoundingClientRect().bottom > rootTop + 1) {
58+
return scrollKey;
59+
}
60+
}
61+
return null;
62+
}
3763

3864
export function useDiffOptions() {
3965
const viewMode = useDiffViewerStore((s) => s.viewMode);

0 commit comments

Comments
 (0)