Skip to content

Commit 80065c8

Browse files
perf(review): virtualize Guided Review file cards (#1158)
* perf(review): virtualize guided review file cards * fix(review): preserve guided review card behavior
1 parent 07c89ff commit 80065c8

13 files changed

Lines changed: 1794 additions & 551 deletions
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { afterEach, describe, expect, mock, test } from 'bun:test';
2+
import React, { act, useCallback, useEffect, useImperativeHandle, useRef } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import type { DiffFile } from '../types';
5+
6+
let codeViewMounts = 0;
7+
let codeViewUnmounts = 0;
8+
let scrollTargets: Array<Record<string, unknown>> = [];
9+
10+
mock.module('../workerPool', () => ({
11+
useIsWorkerPoolReadyOrDisabled: () => true,
12+
useWorkerPoolThemeSync: () => {},
13+
}));
14+
15+
mock.module('../hooks/usePierreTheme', () => ({
16+
usePierreTheme: () => ({ type: 'light', css: '' }),
17+
}));
18+
19+
mock.module('@pierre/diffs', () => ({
20+
getSingularPatch: (patch: string) => ({
21+
name: patch.includes('target.ts') ? 'target.ts' : 'file.ts',
22+
type: 'change',
23+
hunks: [],
24+
splitLineCount: 1,
25+
unifiedLineCount: 1,
26+
isPartial: true,
27+
deletionLines: [],
28+
additionLines: [],
29+
}),
30+
processFile: () => null,
31+
}));
32+
33+
mock.module('@pierre/diffs/react', () => ({
34+
CodeView: React.forwardRef(function MockCodeView(
35+
props: {
36+
initialItems?: Array<{ id: string }>;
37+
className?: string;
38+
containerRef?: React.Ref<HTMLDivElement>;
39+
},
40+
ref: React.ForwardedRef<unknown>,
41+
) {
42+
const itemsRef = useRef(new Map((props.initialItems ?? []).map((item) => [item.id, item])));
43+
useEffect(() => {
44+
codeViewMounts += 1;
45+
return () => {
46+
codeViewUnmounts += 1;
47+
};
48+
}, []);
49+
useImperativeHandle(ref, () => ({
50+
addItems: () => {},
51+
getItem: (id: string) => itemsRef.current.get(id),
52+
updateItem: (item: { id: string }) => {
53+
itemsRef.current.set(item.id, item);
54+
return true;
55+
},
56+
updateItemId: () => true,
57+
scrollTo: (target: Record<string, unknown>) => scrollTargets.push(target),
58+
setSelectedLines: () => {},
59+
getSelectedLines: () => null,
60+
clearSelectedLines: () => {},
61+
getInstance: () => ({
62+
getRenderedItems: () => [],
63+
getScrollTop: () => 0,
64+
getScrollHeight: () => 0,
65+
getHeight: () => 0,
66+
getTopForItem: () => 0,
67+
scrollTo: (target: Record<string, unknown>) => scrollTargets.push(target),
68+
}),
69+
}));
70+
return <div ref={props.containerRef} className={props.className} />;
71+
}),
72+
useStableCallback: <T extends (...args: never[]) => unknown>(callback: T): T => {
73+
const callbackRef = useRef(callback);
74+
callbackRef.current = callback;
75+
return useCallback(((...args: Parameters<T>) => callbackRef.current(...args)) as T, []);
76+
},
77+
}));
78+
79+
mock.module('./ToolbarHost', () => ({
80+
ToolbarHost: React.forwardRef(function MockToolbarHost() {
81+
return null;
82+
}),
83+
}));
84+
85+
const { AllFilesCodeView } = await import('./AllFilesCodeView');
86+
87+
const hasDom = typeof document !== 'undefined';
88+
let root: Root | null = null;
89+
let host: HTMLElement | null = null;
90+
91+
const file: DiffFile = {
92+
path: 'target.ts',
93+
patch: 'diff --git a/target.ts b/target.ts\n--- a/target.ts\n+++ b/target.ts\n@@ -1 +1 @@\n-old\n+new',
94+
additions: 1,
95+
deletions: 1,
96+
status: 'modified',
97+
};
98+
99+
function view(overrides: Partial<React.ComponentProps<typeof AllFilesCodeView>> = {}) {
100+
return (
101+
<AllFilesCodeView
102+
files={[file]}
103+
diffStyle="unified"
104+
annotations={[]}
105+
selectedAnnotationId={null}
106+
scrollTargetAnnotation={null}
107+
pendingSelection={null}
108+
onLineSelection={() => {}}
109+
onAddAnnotationForFile={() => {}}
110+
onEditAnnotation={() => {}}
111+
onSelectAnnotation={() => {}}
112+
onDeleteAnnotation={() => {}}
113+
{...overrides}
114+
/>
115+
);
116+
}
117+
118+
async function render(overrides: Partial<React.ComponentProps<typeof AllFilesCodeView>> = {}) {
119+
await act(async () => {
120+
root!.render(view(overrides));
121+
await new Promise((resolve) => setTimeout(resolve, 25));
122+
});
123+
}
124+
125+
afterEach(async () => {
126+
if (root) {
127+
await act(async () => root?.unmount());
128+
}
129+
root = null;
130+
host?.remove();
131+
host = null;
132+
codeViewMounts = 0;
133+
codeViewUnmounts = 0;
134+
scrollTargets = [];
135+
});
136+
137+
describe('AllFilesCodeView guide mount state', () => {
138+
test.skipIf(!hasDom)('does not remount when the live shell collapse value changes', async () => {
139+
host = document.createElement('div');
140+
host.style.height = '400px';
141+
document.body.appendChild(host);
142+
root = createRoot(host);
143+
144+
await render({ mountCollapsed: false });
145+
expect(codeViewMounts).toBe(1);
146+
147+
await render({ mountCollapsed: true });
148+
expect(codeViewMounts).toBe(1);
149+
expect(codeViewUnmounts).toBe(0);
150+
});
151+
152+
test.skipIf(!hasDom)('restores the initial scroll position only once per mount', async () => {
153+
host = document.createElement('div');
154+
host.style.height = '400px';
155+
document.body.appendChild(host);
156+
root = createRoot(host);
157+
158+
await render({ initialScrollPosition: 120 });
159+
await render({ initialScrollPosition: 360 });
160+
161+
expect(scrollTargets.filter((target) => target.type === 'position')).toEqual([
162+
{ type: 'position', position: 120 },
163+
]);
164+
});
165+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, mock, test } from 'bun:test';
2+
import type { AIChatEntry } from '../hooks/useAIChat';
3+
4+
mock.module('../workerPool', () => ({
5+
useIsWorkerPoolReadyOrDisabled: () => true,
6+
useWorkerPoolThemeSync: () => {},
7+
}));
8+
9+
const { projectFileAIMarkers } = await import('./AllFilesCodeView');
10+
11+
function makeMessage(
12+
id: string,
13+
filePath: string,
14+
overrides: Partial<AIChatEntry['question']> = {},
15+
): AIChatEntry {
16+
return {
17+
question: {
18+
id,
19+
prompt: 'Explain why this deliberately long line-scoped question changed.',
20+
filePath,
21+
lineStart: 3,
22+
lineEnd: 5,
23+
side: 'new',
24+
createdAt: 1,
25+
...overrides,
26+
},
27+
response: {
28+
questionId: id,
29+
text: 'Because the behavior changed.',
30+
isStreaming: false,
31+
createdAt: 2,
32+
},
33+
};
34+
}
35+
36+
describe('projectFileAIMarkers', () => {
37+
test('projects only line-scoped messages for the requested file', () => {
38+
const target = makeMessage('target', 'src/target.ts');
39+
const otherFile = makeMessage('other', 'src/other.ts');
40+
const fileScoped = makeMessage('file-scope', 'src/target.ts', {
41+
lineStart: undefined,
42+
lineEnd: undefined,
43+
});
44+
45+
expect(projectFileAIMarkers([target, otherFile, fileScoped], 'src/target.ts')).toEqual([
46+
{
47+
side: 'additions',
48+
lineNumber: 5,
49+
metadata: {
50+
annotationId: 'target',
51+
type: 'comment',
52+
kind: 'ai-marker',
53+
questionId: 'target',
54+
promptPreview: 'Explain why this deliberately long line-...',
55+
hasResponse: true,
56+
isStreaming: false,
57+
},
58+
},
59+
]);
60+
});
61+
});

0 commit comments

Comments
 (0)