History

Improve mobile review diff rendering

- Switch review screen to a virtualized list layout - Queue diff highlighting and default to the JS engine - Add word-diff helpers and tests

Julius Marminge committed 7 months ago

Commit dcf4d6d1 parent 43d534a14 files · +1573 −323Browse files

Unified · Split

14 files
  1. apps/mobile/README.md +8 −0
  2. apps/mobile/package.json +1 −0
  3. apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx +11 −8
  4. apps/mobile/src/features/threads/review/ReviewSheet.tsx +632 −298
  5. apps/mobile/src/features/threads/review/reviewDiffRendering.tsx +41 −3
  6. apps/mobile/src/features/threads/review/reviewHighlighterEngine.test.ts +1 −0
  7. apps/mobile/src/features/threads/review/reviewHighlighterEngine.ts +3 −1
  8. apps/mobile/src/features/threads/review/reviewModel.test.ts +75 −0
  9. apps/mobile/src/features/threads/review/reviewModel.ts +125 −0
  10. apps/mobile/src/features/threads/review/reviewWordDiffs.test.ts +62 −0
  11. apps/mobile/src/features/threads/review/reviewWordDiffs.ts +266 −0
  12. apps/mobile/src/features/threads/review/shikiReviewHighlighter.test.ts +69 −0
  13. apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts +278 −13
  14. bun.lock +1 −0
apps/mobile/README.md+8−0
@@ -36,6 +36,14 @@ Build and run the local iOS preview app: · 35 unmodified lines
3636 bun run ios:preview
3737 ```
3838
39+Force the review diff highlighter engine:
40+
41+```bash
42+EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=javascript bun run ios:dev
43+```
44+
45+`javascript` is the default and recommended setting for the review diff screen. Set `EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE=native` only when you explicitly want to test the native Shiki engine.
46+
3947 Inspect the resolved Expo config for a variant:
4048
4149 ```bash
apps/mobile/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
4747 "@t3tools/contracts": "workspace:*",
4848 "@t3tools/shared": "workspace:*",
4949 "clsx": "^2.1.1",
50+ "diff": "8.0.3",
5051 "effect": "catalog:",
5152 "expo": "~55.0.14",
5253 "expo-camera": "~55.0.15",
apps/mobile/src/features/threads/review/ReviewCommentComposerSheet.tsx+11−8
@@ -27,12 +27,16 @@ import { appendReviewCommentToDraft } from "../use-thread-composer-state"; · 26 unmodified lines
2727 import {
2828 clearReviewCommentTarget,
2929 formatReviewCommentContext,
30− getReviewChangeMarker,
3130 getReviewUnifiedLineNumber,
3231 getSelectedReviewCommentLines,
3332 useReviewCommentTarget,
3433 } from "./reviewCommentSelection";
35−import { changeTone, DiffTokenText } from "./reviewDiffRendering";
34+import {
35+ changeTone,
36+ DiffTokenText,
37+ REVIEW_MONO_FONT_FAMILY,
38+ ReviewChangeBar,
39+} from "./reviewDiffRendering";
3640 import {
3741 highlightReviewSelectedLines,
3842 type ReviewDiffTheme,
@@ -217,19 +221,18 @@ export function ReviewCommentComposerSheet() { · 178 unmodified lines
217221 className={cn("flex-row items-start", changeTone(line.change))}
218222 style={{ height: REVIEW_COMMENT_LINE_HEIGHT }}
219223 >
220− <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">
221− {lineNumber ?? ""}
222− </Text>
224+ <ReviewChangeBar change={line.change} />
223225 <Text
224− className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"
225− style={{ width: 18 }}
226+ className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted"
227+ style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
226228 >
227− {getReviewChangeMarker(line.change)}
229+ {lineNumber ?? ""}
228230 </Text>
229231 <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">
230232 <DiffTokenText
231233 fallback={line.content}
232234 tokens={highlightedLinesById[line.id] ?? null}
235+ change={line.change}
233236 />
234237 </View>
235238 </View>
apps/mobile/src/features/threads/review/ReviewSheet.tsx+632−298
@@ -1,14 +1,17 @@
11 import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts";
2−import { LegendList } from "@legendapp/list/react-native";
32 import { useLocalSearchParams, useRouter } from "expo-router";
43 import Stack from "expo-router/stack";
54 import { SymbolView } from "expo-symbols";
6−import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from "react";
5+import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
76 import {
87 ActivityIndicator,
8+ FlatList,
9+ InteractionManager,
10+ type ListRenderItemInfo,
911 Pressable,
1012 ScrollView,
1113 Text as NativeText,
14+ type ViewToken,
1215 useColorScheme,
1316 useWindowDimensions,
1417 View,
@@ -32,16 +35,19 @@ import { · 17 unmodified lines
3235 useReviewCacheForThread,
3336 } from "./reviewState";
3437 import {
38+ buildReviewListItems,
3539 getReadyReviewCheckpoints,
3640 buildReviewSectionItems,
3741 getDefaultReviewSectionId,
3842 getReviewFilePreviewState,
3943 getReviewSectionIdForCheckpoint,
44+ type ReviewListItem,
4045 type ReviewParsedDiff,
4146 type ReviewRenderableFile,
4247 type ReviewRenderableLineRow,
4348 } from "./reviewModel";
4449 import {
50+ clearReviewHighlightFileCache,
4551 getCachedHighlightedReviewFile,
4652 highlightReviewFile,
4753 type ReviewDiffTheme,
@@ -53,14 +59,17 @@ import { · 5 unmodified lines
5359 clearReviewCommentTarget,
5460 countReviewCommentContexts,
5561 formatReviewSelectedRangeLabel,
56− getReviewChangeMarker,
5762 getReviewUnifiedLineNumber,
58− getSelectedReviewCommentLines,
5963 setReviewCommentTarget,
6064 type ReviewCommentTarget,
6165 useReviewCommentTarget,
6266 } from "./reviewCommentSelection";
63−import { changeTone, DiffTokenText } from "./reviewDiffRendering";
67+import {
68+ changeTone,
69+ DiffTokenText,
70+ REVIEW_MONO_FONT_FAMILY,
71+ ReviewChangeBar,
72+} from "./reviewDiffRendering";
6473
6574 interface PendingCommentSelection {
6675 readonly sectionTitle: string;
@@ -77,20 +86,62 @@ interface ReviewLineActionInput { · 10 unmodified lines
7786 }
7887
7988 const IOS_NAV_BAR_HEIGHT = 44;
80−const REVIEW_HEADER_SPACING = 32;
89+const REVIEW_HEADER_SPACING = 0;
90+const REVIEW_LINE_GUTTER_WIDTH = 62;
91+const REVIEW_CHARACTER_WIDTH_ESTIMATE = 8.4;
92+const REVIEW_MAX_CONTENT_WIDTH = 4_800;
93+const REVIEW_INITIAL_HIGHLIGHT_FILE_COUNT = 2;
94+const REVIEW_HIGHLIGHT_BACKTRACK_COUNT = 0;
95+const REVIEW_HIGHLIGHT_LOOKAHEAD_COUNT = 1;
96+const loggedMissingReviewTokenKeys = new Set<string>();
97+
98+function isReviewDiffDebugLoggingEnabled(): boolean {
99+ return typeof __DEV__ !== "undefined" ? __DEV__ : false;
100+}
101+
102+function logReviewDiffDiagnostic(message: string, details?: Record<string, unknown>): void {
103+ if (!isReviewDiffDebugLoggingEnabled()) {
104+ return;
105+ }
106+
107+ if (details) {
108+ console.log(`[review-sheet] ${message}`, details);
109+ return;
110+ }
111+
112+ console.log(`[review-sheet] ${message}`);
113+}
81114
82−function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string {
83− switch (type) {
115+function getFileHeaderChrome(changeType: ReviewRenderableFile["changeType"]): {
116+ readonly rail: string;
117+ readonly dot: string;
118+} {
119+ switch (changeType) {
84120 case "new":
85− return "Added";
121+ return {
122+ rail: "bg-emerald-400",
123+ dot: "bg-emerald-400",
124+ };
86125 case "deleted":
87− return "Deleted";
126+ return {
127+ rail: "bg-rose-400",
128+ dot: "bg-rose-400",
129+ };
88130 case "rename-pure":
89− return "Renamed";
131+ return {
132+ rail: "bg-amber-400",
133+ dot: "bg-amber-400",
134+ };
90135 case "rename-changed":
91− return "Renamed + edited";
136+ return {
137+ rail: "bg-sky-400",
138+ dot: "bg-sky-400",
139+ };
92140 default:
93− return "Edited";
141+ return {
142+ rail: "bg-sky-400",
143+ dot: "bg-sky-400",
144+ };
94145 }
95146 }
96147
@@ -108,12 +159,70 @@ function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { · 11 unmodified lines
108159 };
109160 }
110161
162+function computeReviewListContentWidth(
163+ items: ReadonlyArray<ReviewListItem>,
164+ viewportWidth: number,
165+): number {
166+ let maxTextLength = 0;
167+
168+ items.forEach((item) => {
169+ switch (item.kind) {
170+ case "file-header":
171+ maxTextLength = Math.max(
172+ maxTextLength,
173+ item.file.path.length,
174+ item.file.previousPath?.length ?? 0,
175+ );
176+ break;
177+ case "file-suppressed":
178+ maxTextLength = Math.max(maxTextLength, item.message.length, item.actionLabel?.length ?? 0);
179+ break;
180+ case "hunk":
181+ maxTextLength = Math.max(
182+ maxTextLength,
183+ item.row.header.length + (item.row.context ? item.row.context.length + 1 : 0),
184+ );
185+ break;
186+ case "line":
187+ maxTextLength = Math.max(maxTextLength, item.row.content.length);
188+ break;
189+ }
190+ });
191+
192+ return Math.max(
193+ viewportWidth,
194+ Math.min(
195+ REVIEW_MAX_CONTENT_WIDTH,
196+ Math.ceil(REVIEW_LINE_GUTTER_WIDTH + 48 + maxTextLength * REVIEW_CHARACTER_WIDTH_ESTIMATE),
197+ ),
198+ );
199+}
200+
111201 function getDefaultExpandedFileIds(
112202 files: ReadonlyArray<ReviewRenderableFile>,
113203 ): ReadonlyArray<string> {
114204 return files.map((file) => file.id);
115205 }
116206
207+function getHighlightedTokensForLine(
208+ line: ReviewRenderableLineRow,
209+ highlightedFile: ReviewHighlightedFile | null,
210+): ReadonlyArray<ReviewHighlightedToken> | null {
211+ if (!highlightedFile) {
212+ return null;
213+ }
214+
215+ if (line.additionTokenIndex !== null) {
216+ return highlightedFile.additionLines[line.additionTokenIndex] ?? null;
217+ }
218+
219+ if (line.deletionTokenIndex !== null) {
220+ return highlightedFile.deletionLines[line.deletionTokenIndex] ?? null;
221+ }
222+
223+ return null;
224+}
225+
117226 const ReviewLineRow = memo(function ReviewLineRow(props: {
118227 readonly line: ReviewRenderableLineRow;
119228 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
@@ -145,17 +254,19 @@ const ReviewLineRow = memo(function ReviewLineRow(props: { · 25 unmodified lines
145254 onPress={props.onComment}
146255 style={{ minWidth: props.viewportWidth }}
147256 >
148− <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">
149− {lineNumber ?? ""}
150− </Text>
257+ <ReviewChangeBar change={props.line.change} />
151258 <Text
152− className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"
153− style={{ width: 18 }}
259+ className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted"
260+ style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
154261 >
155− {getReviewChangeMarker(props.line.change)}
262+ {lineNumber ?? ""}
156263 </Text>
157264 <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">
158− <DiffTokenText tokens={props.tokens} fallback={props.line.content} />
265+ <DiffTokenText
266+ tokens={props.tokens}
267+ fallback={props.line.content}
268+ change={props.line.change}
269+ />
159270 </View>
160271 </Pressable>
161272 );
@@ -165,41 +276,55 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 3 unmodified lines
165276 readonly file: ReviewRenderableFile;
166277 readonly fileId: string;
167278 readonly expanded: boolean;
279+ readonly viewportWidth: number;
168280 readonly onToggleFile: (fileId: string) => void;
169281 }) {
282+ const chrome = getFileHeaderChrome(props.file.changeType);
283+
170284 return (
171− <View className="border-b border-border bg-card" style={{ zIndex: 1 }}>
172− <Pressable
173− className="flex-row items-start gap-2 px-3 py-3"
174− onPress={() => props.onToggleFile(props.fileId)}
175− >
176− <View className="pt-0.5">
177− <SymbolView
178− name={props.expanded ? "chevron.down" : "chevron.right"}
179− size={14}
180− tintColor="#8a8a8a"
181− type="monochrome"
182− />
183− </View>
184− <View className="min-w-0 flex-1 gap-1">
185− <Text className="font-mono text-[13px] leading-[18px] text-foreground">
186− {props.file.path}
187− </Text>
188− {props.file.previousPath && props.file.previousPath !== props.file.path ? (
189− <Text className="font-mono text-[11px] leading-[16px] text-foreground-muted">
190− {props.file.previousPath}
191− </Text>
192− ) : null}
193− </View>
194− <View className="items-end gap-1 pl-2">
195− <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted">
196− {changeTypeLabel(props.file.changeType)}
197− </Text>
198− <View className="flex-row items-center gap-2">
199− <Text className="text-[12px] font-t3-bold text-emerald-600">
285+ <View
286+ className="border-b border-border/70 bg-card"
287+ style={{
288+ zIndex: 1,
289+ width: props.viewportWidth,
290+ shadowColor: "#000000",
291+ shadowOpacity: 0.08,
292+ shadowRadius: 10,
293+ shadowOffset: { width: 0, height: 6 },
294+ elevation: 4,
295+ }}
296+ >
297+ <Pressable className="flex-row items-center" onPress={() => props.onToggleFile(props.fileId)}>
298+ <View className={cn("w-1 self-stretch", chrome.rail)} />
299+ <View className="flex-1 flex-row items-center px-3 py-1.5">
300+ <View className="size-7 items-center justify-center rounded-full bg-subtle">
301+ <SymbolView
302+ name={props.expanded ? "chevron.down" : "chevron.right"}
303+ size={12}
304+ tintColor="#8a8a8a"
305+ type="monochrome"
306+ />
307+ </View>
308+ <View className="ml-2 min-w-0 flex-1 flex-row items-center gap-2">
309+ <View className={cn("size-2 rounded-full", chrome.dot)} />
310+ <View className="min-w-0 flex-1">
311+ <Text className="font-mono text-[13px] leading-[18px] text-foreground">
312+ {props.file.path}
313+ </Text>
314+ {props.file.previousPath && props.file.previousPath !== props.file.path ? (
315+ <Text className="font-mono text-[10px] leading-[14px] text-foreground-muted">
316+ {props.file.previousPath}
317+ </Text>
318+ ) : null}
319+ </View>
320+ </View>
321+ <View className="ml-3 flex-row items-center justify-end gap-2">
322+ <Text className="font-mono text-[12px] font-t3-bold text-emerald-600 dark:text-emerald-300">
200323 +{props.file.additions}
201324 </Text>
202− <Text className="text-[12px] font-t3-bold text-rose-600">-{props.file.deletions}</Text>
325+ <Text className="font-mono text-[12px] font-t3-bold text-rose-600 dark:text-rose-300">
326+ -{props.file.deletions}
327+ </Text>
203328 </View>
204329 </View>
205330 </Pressable>
@@ -207,117 +332,18 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 1 unmodified line
207332 );
208333 });
209334
210−const ReviewFileBody = memo(function ReviewFileBody(props: {
211− readonly file: ReviewRenderableFile;
212− readonly sectionTitle: string;
213− readonly highlightedFile: ReviewHighlightedFile | null;
214− readonly viewportWidth: number;
215− readonly pendingSelection: PendingCommentSelection | null;
216− readonly selectedTarget: ReviewCommentTarget | null;
217− readonly onPressLine: (input: ReviewLineActionInput) => void;
218− readonly onStartRangeSelection: (input: ReviewLineActionInput) => void;
219−}) {
220− const commentableLines = useMemo(
221− () => props.file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"),
222− [props.file.rows],
223− );
224− const lineIndexById = useMemo(
225− () => new Map(commentableLines.map((line, index) => [line.id, index])),
226− [commentableLines],
227− );
228− const anchorLineId = useMemo(
229− () =>
230− props.pendingSelection &&
231− props.pendingSelection.sectionTitle === props.sectionTitle &&
232− props.pendingSelection.filePath === props.file.path
233− ? (props.pendingSelection.lines[props.pendingSelection.anchorIndex]?.id ?? null)
234− : null,
235− [props.file.path, props.pendingSelection, props.sectionTitle],
236− );
237− const selectedLineIds = useMemo(
238− () =>
239− props.selectedTarget &&
240− props.selectedTarget.sectionTitle === props.sectionTitle &&
241− props.selectedTarget.filePath === props.file.path
242− ? new Set(getSelectedReviewCommentLines(props.selectedTarget).map((line) => line.id))
243− : null,
244− [props.file.path, props.sectionTitle, props.selectedTarget],
245− );
246−
247− return (
248− <ScrollView
249− horizontal
250− bounces={false}
251− showsHorizontalScrollIndicator={false}
252− className="border-b border-border bg-card"
253− >
254− <View style={{ minWidth: props.viewportWidth }}>
255− {props.file.rows.map((row) => {
256− if (row.kind === "hunk") {
257− return (
258− <View
259− key={row.id}
260− className="border-b border-border/60 bg-sky-500/10 px-2 py-2"
261− style={{ minWidth: props.viewportWidth }}
262− >
263− <Text className="font-mono text-[12px] leading-[18px] text-sky-700 dark:text-sky-300">
264− {row.header}
265− {row.context ? ` ${row.context}` : ""}
266− </Text>
267− </View>
268− );
269− }
270−
271− const tokens =
272− row.change === "delete"
273− ? (props.highlightedFile?.deletionLines[row.deletionTokenIndex ?? -1] ?? null)
274− : (props.highlightedFile?.additionLines[row.additionTokenIndex ?? -1] ?? null);
275−
276− return (
277− <ReviewLineRow
278− key={row.id}
279− line={row}
280− tokens={tokens}
281− viewportWidth={props.viewportWidth}
282− selectionState={
283− anchorLineId === row.id
284− ? "anchor"
285− : selectedLineIds?.has(row.id)
286− ? "selected"
287− : null
288− }
289− onComment={() => {
290− props.onPressLine({
291− sectionTitle: props.sectionTitle,
292− filePath: props.file.path,
293− lines: commentableLines,
294− lineIndex: lineIndexById.get(row.id) ?? 0,
295− });
296− }}
297− onStartRangeSelection={() => {
298− props.onStartRangeSelection({
299− sectionTitle: props.sectionTitle,
300− filePath: props.file.path,
301− lines: commentableLines,
302− lineIndex: lineIndexById.get(row.id) ?? 0,
303− });
304− }}
305− />
306− );
307− })}
308− </View>
309− </ScrollView>
310− );
311−});
312−
313335 const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: {
314336 readonly message: string;
315337 readonly actionLabel?: string | null;
316338 readonly fileId: string;
339+ readonly viewportWidth: number;
317340 readonly onLoadDiffFile?: (fileId: string) => void;
318341 }) {
319342 return (
320− <View className="gap-2 border-b border-border bg-card px-4 py-3">
343+ <View
344+ className="gap-2 border-b border-border bg-card px-4 py-3"
345+ style={{ minWidth: props.viewportWidth }}
346+ >
321347 <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text>
322348 {props.actionLabel && props.onLoadDiffFile ? (
323349 <Pressable
@@ -331,91 +357,21 @@ const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { · 7 unmodified lines
331357 );
332358 });
333359
334−const ReviewFileItem = memo(function ReviewFileItem(props: {
335− readonly file: ReviewRenderableFile;
336− readonly expanded: boolean;
337− readonly sectionTitle: string;
338− readonly selectedTheme: ReviewDiffTheme;
339− readonly pendingSelection: PendingCommentSelection | null;
340− readonly selectedTarget: ReviewCommentTarget | null;
360+const ReviewHunkRow = memo(function ReviewHunkRow(props: {
361+ readonly header: string;
362+ readonly context: string | null;
341363 readonly viewportWidth: number;
342− readonly revealedLarge: boolean;
343− readonly onToggleFile: (fileId: string) => void;
344− readonly onLoadDiffFile: (fileId: string) => void;
345− readonly onPressLine: (input: ReviewLineActionInput) => void;
346− readonly onStartRangeSelection: (input: ReviewLineActionInput) => void;
347364 }) {
348− const previewState = useMemo(() => getReviewFilePreviewState(props.file), [props.file]);
349− const shouldRenderBody =
350− previewState.kind === "render" || (previewState.reason === "large" && props.revealedLarge);
351− const highlightCacheKey = `${props.selectedTheme}:${props.file.cacheKey}`;
352− const [highlightedFile, setHighlightedFile] = useState<ReviewHighlightedFile | null>(() =>
353− getCachedHighlightedReviewFile(props.file, props.selectedTheme),
354− );
355−
356− useEffect(() => {
357− setHighlightedFile(getCachedHighlightedReviewFile(props.file, props.selectedTheme));
358− }, [highlightCacheKey, props.file, props.selectedTheme]);
359−
360− useEffect(() => {
361− if (!props.expanded || !shouldRenderBody) {
362− return;
363− }
364−
365− const cached = getCachedHighlightedReviewFile(props.file, props.selectedTheme);
366− if (cached) {
367− setHighlightedFile(cached);
368− return;
369− }
370−
371− let cancelled = false;
372− void highlightReviewFile(props.file, props.selectedTheme)
373− .then((result) => {
374− if (!cancelled) {
375− setHighlightedFile(result);
376− }
377− })
378− .catch(() => {
379− if (cancelled) {
380− return;
381− }
382− });
383−
384− return () => {
385− cancelled = true;
386− };
387− }, [highlightCacheKey, props.expanded, props.file, props.selectedTheme, shouldRenderBody]);
388−
389365 return (
390− <>
391− <ReviewFileCard
392− file={props.file}
393− fileId={props.file.id}
394− expanded={props.expanded}
395− onToggleFile={props.onToggleFile}
396− />
397− {props.expanded ? (
398− shouldRenderBody ? (
399− <ReviewFileBody
400− file={props.file}
401− sectionTitle={props.sectionTitle}
402− highlightedFile={highlightedFile}
403− pendingSelection={props.pendingSelection}
404− selectedTarget={props.selectedTarget}
405− viewportWidth={props.viewportWidth}
406− onPressLine={props.onPressLine}
407− onStartRangeSelection={props.onStartRangeSelection}
408− />
409− ) : (
410− <ReviewFileSuppressedBody
411− message={previewState.message}
412− actionLabel={previewState.actionLabel}
413− fileId={props.file.id}
414− onLoadDiffFile={previewState.actionLabel ? props.onLoadDiffFile : undefined}
415− />
416− )
417− ) : null}
418− </>
366+ <View
367+ className="border-b border-border/60 bg-sky-500/10 px-2 py-2"
368+ style={{ minWidth: props.viewportWidth }}
369+ >
370+ <Text className="font-mono text-[12px] leading-[18px] text-sky-700 dark:text-sky-300">
371+ {props.header}
372+ {props.context ? ` ${props.context}` : ""}
373+ </Text>
374+ </View>
419375 );
420376 });
421377
@@ -496,7 +452,18 @@ export function ReviewSheet() { · 74 unmodified lines
496452 const [error, setError] = useState<string | null>(null);
497453 const [pendingCommentSelection, setPendingCommentSelection] =
498454 useState<PendingCommentSelection | null>(null);
455+ const [highlightedFilesById, setHighlightedFilesById] = useState<
456+ Record<string, ReviewHighlightedFile | null>
457+ >({});
458+ const [visibleFileIds, setVisibleFileIds] = useState<ReadonlyArray<string>>([]);
499459 const activeCommentTarget = useReviewCommentTarget();
460+ const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
461+ const highlightQueueRef = useRef<string[]>([]);
462+ const highlightRequestedFileIdsRef = useRef<Set<string>>(new Set());
463+ const highlightQueueGenerationRef = useRef(0);
464+ const highlightQueueActiveRef = useRef(false);
465+ const highlightableFilesByIdRef = useRef<ReadonlyMap<string, ReviewRenderableFile>>(new Map());
466+ const selectedThemeRef = useRef<ReviewDiffTheme>(selectedTheme);
500467
501468 const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null;
502469 const readyCheckpoints = useMemo(
@@ -544,7 +511,6 @@ export function ReviewSheet() { · 41 unmodified lines
544511 [draftMessage],
545512 );
546513
547− const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
548514 const expandedFileIds = useMemo(
549515 () =>
550516 selectedSection?.id && parsedDiff.kind === "files"
@@ -560,6 +526,120 @@ export function ReviewSheet() { · 9 unmodified lines
560526 : [],
561527 [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id],
562528 );
529+ const reviewListItems = useMemo(
530+ () =>
531+ selectedSection && parsedDiff.kind === "files"
532+ ? buildReviewListItems({
533+ files: parsedDiff.files,
534+ expandedFileIds,
535+ revealedLargeFileIds,
536+ })
537+ : [],
538+ [expandedFileIds, parsedDiff, revealedLargeFileIds, selectedSection],
539+ );
540+ const reviewFileById = useMemo(() => {
541+ if (parsedDiff.kind !== "files") {
542+ return new Map<string, ReviewRenderableFile>();
543+ }
544+
545+ return new Map(parsedDiff.files.map((file) => [file.id, file] as const));
546+ }, [parsedDiff]);
547+ const reviewLineRowsByFileId = useMemo(() => {
548+ if (parsedDiff.kind !== "files") {
549+ return new Map<string, ReadonlyArray<ReviewRenderableLineRow>>();
550+ }
551+
552+ return new Map(
553+ parsedDiff.files.map((file) => [
554+ file.id,
555+ file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"),
556+ ]),
557+ );
558+ }, [parsedDiff]);
559+ const reviewLineIndexByRowId = useMemo(() => {
560+ const map = new Map<string, number>();
561+
562+ reviewLineRowsByFileId.forEach((rows) => {
563+ rows.forEach((row, index) => {
564+ map.set(row.id, index);
565+ });
566+ });
567+
568+ return map;
569+ }, [reviewLineRowsByFileId]);
570+ const highlightableFiles = useMemo(() => {
571+ if (parsedDiff.kind !== "files") {
572+ return [] as ReadonlyArray<ReviewRenderableFile>;
573+ }
574+
575+ const expandedFileIdSet = new Set(expandedFileIds);
576+ const revealedLargeFileIdSet = new Set(revealedLargeFileIds);
577+
578+ return parsedDiff.files.filter((file) => {
579+ if (!expandedFileIdSet.has(file.id)) {
580+ return false;
581+ }
582+
583+ const previewState = getReviewFilePreviewState(file);
584+ return (
585+ previewState.kind === "render" ||
586+ (previewState.reason === "large" && revealedLargeFileIdSet.has(file.id))
587+ );
588+ });
589+ }, [expandedFileIds, parsedDiff, revealedLargeFileIds]);
590+ const highlightableFileIds = useMemo(
591+ () => new Set(highlightableFiles.map((file) => file.id)),
592+ [highlightableFiles],
593+ );
594+ const priorityHighlightFileIds = useMemo(() => {
595+ if (parsedDiff.kind !== "files" || highlightableFiles.length === 0) {
596+ return [] as ReadonlyArray<string>;
597+ }
598+
599+ const fileIdsInPriorityOrder =
600+ visibleFileIds.length > 0
601+ ? visibleFileIds.filter((fileId) => highlightableFileIds.has(fileId))
602+ : parsedDiff.files
603+ .slice(0, REVIEW_INITIAL_HIGHLIGHT_FILE_COUNT)
604+ .map((file) => file.id)
605+ .filter((fileId) => highlightableFileIds.has(fileId));
606+
607+ if (fileIdsInPriorityOrder.length === 0) {
608+ return [] as ReadonlyArray<string>;
609+ }
610+
611+ const firstVisibleIndex = parsedDiff.files.findIndex(
612+ (file) => file.id === fileIdsInPriorityOrder[0],
613+ );
614+ const lastVisibleIndex = parsedDiff.files.findIndex(
615+ (file) => file.id === fileIdsInPriorityOrder[fileIdsInPriorityOrder.length - 1],
616+ );
617+
618+ if (firstVisibleIndex < 0 || lastVisibleIndex < 0) {
619+ return fileIdsInPriorityOrder;
620+ }
621+
622+ const startIndex = Math.max(0, firstVisibleIndex - REVIEW_HIGHLIGHT_BACKTRACK_COUNT);
623+ const endIndex = Math.min(
624+ parsedDiff.files.length - 1,
625+ lastVisibleIndex + REVIEW_HIGHLIGHT_LOOKAHEAD_COUNT,
626+ );
627+
628+ const queuedIds: string[] = [];
629+ for (let index = startIndex; index <= endIndex; index += 1) {
630+ const fileId = parsedDiff.files[index]?.id;
631+ if (fileId && highlightableFileIds.has(fileId)) {
632+ queuedIds.push(fileId);
633+ }
634+ }
635+
636+ return queuedIds;
637+ }, [highlightableFileIds, highlightableFiles.length, parsedDiff, visibleFileIds]);
638+ const viewportWidth = Math.max(width, 280);
639+ const reviewListContentWidth = useMemo(
640+ () => computeReviewListContentWidth(reviewListItems, viewportWidth),
641+ [reviewListItems, viewportWidth],
642+ );
563643 const loadGitDiffs = useCallback(async () => {
564644 if (!environmentId || !cwd) {
565645 return;
@@ -634,6 +714,100 @@ export function ReviewSheet() { · 68 unmodified lines
634714 void loadGitDiffs();
635715 }, [loadGitDiffs]);
636716
717+ useEffect(() => {
718+ selectedThemeRef.current = selectedTheme;
719+ }, [selectedTheme]);
720+
721+ useEffect(() => {
722+ highlightableFilesByIdRef.current = new Map(highlightableFiles.map((file) => [file.id, file]));
723+ }, [highlightableFiles]);
724+
725+ const startHighlightQueueRunner = useCallback(function startHighlightQueueRunner() {
726+ if (highlightQueueActiveRef.current) {
727+ return;
728+ }
729+
730+ const generation = highlightQueueGenerationRef.current;
731+ highlightQueueActiveRef.current = true;
732+
733+ void (async () => {
734+ try {
735+ while (generation === highlightQueueGenerationRef.current) {
736+ const nextFileId = highlightQueueRef.current.shift();
737+ if (!nextFileId) {
738+ break;
739+ }
740+
741+ const file = highlightableFilesByIdRef.current.get(nextFileId);
742+ if (!file) {
743+ continue;
744+ }
745+
746+ const theme = selectedThemeRef.current;
747+ const cached = getCachedHighlightedReviewFile(file, theme);
748+ if (cached) {
749+ logReviewDiffDiagnostic("using cached highlighted file", {
750+ fileId: file.id,
751+ filePath: file.path,
752+ theme,
753+ });
754+ setHighlightedFilesById((current) =>
755+ current[file.id] === cached ? current : { ...current, [file.id]: cached },
756+ );
757+ continue;
758+ }
759+
760+ logReviewDiffDiagnostic("requesting highlighted file", {
761+ fileId: file.id,
762+ filePath: file.path,
763+ theme,
764+ });
765+
766+ try {
767+ await new Promise<void>((resolve) => {
768+ InteractionManager.runAfterInteractions(() => resolve());
769+ });
770+ const result = await highlightReviewFile(file, theme);
771+ if (generation !== highlightQueueGenerationRef.current) {
772+ logReviewDiffDiagnostic("discarding highlighted file after cancellation", {
773+ fileId: file.id,
774+ filePath: file.path,
775+ });
776+ return;
777+ }
778+
779+ logReviewDiffDiagnostic("received highlighted file", {
780+ fileId: file.id,
781+ filePath: file.path,
782+ additionLines: result.additionLines.length,
783+ deletionLines: result.deletionLines.length,
784+ });
785+ setHighlightedFilesById((current) => {
786+ if (current[file.id] === result) {
787+ return current;
788+ }
789+ return { ...current, [file.id]: result };
790+ });
791+ } catch (error) {
792+ logReviewDiffDiagnostic("highlight request failed", {
793+ fileId: file.id,
794+ filePath: file.path,
795+ error: error instanceof Error ? error.message : String(error),
796+ });
797+ }
798+ }
799+ } finally {
800+ highlightQueueActiveRef.current = false;
801+ if (
802+ generation === highlightQueueGenerationRef.current &&
803+ highlightQueueRef.current.length > 0
804+ ) {
805+ startHighlightQueueRunner();
806+ }
807+ }
808+ })();
809+ }, []);
810+
637811 useEffect(() => {
638812 if (reviewSections.length === 0) {
639813 return;
@@ -711,6 +885,71 @@ export function ReviewSheet() { · 71 unmodified lines
711885 });
712886 }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
713887
888+ useEffect(() => {
889+ setHighlightedFilesById({});
890+ setVisibleFileIds([]);
891+ highlightQueueGenerationRef.current += 1;
892+ highlightQueueRef.current = [];
893+ highlightRequestedFileIdsRef.current = new Set();
894+ highlightQueueActiveRef.current = false;
895+ clearReviewHighlightFileCache();
896+ loggedMissingReviewTokenKeys.clear();
897+ logReviewDiffDiagnostic("reset highlighted files", {
898+ selectedSectionId: selectedSection?.id ?? null,
899+ selectedTheme,
900+ });
901+ }, [selectedSection?.id, selectedTheme]);
902+
903+ useEffect(() => {
904+ if (parsedDiff.kind !== "files") {
905+ return;
906+ }
907+
908+ logReviewDiffDiagnostic("parsed diff files", {
909+ selectedSectionId: selectedSection?.id ?? null,
910+ fileCount: parsedDiff.fileCount,
911+ renderableFileCount: parsedDiff.files.length,
912+ });
913+ }, [parsedDiff, selectedSection?.id]);
914+
915+ useEffect(() => {
916+ if (priorityHighlightFileIds.length === 0) {
917+ logReviewDiffDiagnostic("no highlightable files", {
918+ selectedSectionId: selectedSection?.id ?? null,
919+ parsedDiffKind: parsedDiff.kind,
920+ requestedFileCount: 0,
921+ });
922+ return;
923+ }
924+
925+ const queuedFileIds: string[] = [];
926+ priorityHighlightFileIds.forEach((fileId) => {
927+ if (highlightRequestedFileIdsRef.current.has(fileId)) {
928+ return;
929+ }
930+
931+ const file = highlightableFilesByIdRef.current.get(fileId);
932+ if (!file) {
933+ return;
934+ }
935+
936+ highlightRequestedFileIdsRef.current.add(fileId);
937+ highlightQueueRef.current.push(fileId);
938+ queuedFileIds.push(fileId);
939+ });
940+
941+ if (queuedFileIds.length === 0) {
942+ return;
943+ }
944+
945+ logReviewDiffDiagnostic("scheduling file highlights", {
946+ selectedSectionId: selectedSection?.id ?? null,
947+ fileCount: queuedFileIds.length,
948+ fileIds: queuedFileIds,
949+ });
950+ startHighlightQueueRunner();
951+ }, [parsedDiff.kind, priorityHighlightFileIds, selectedSection?.id, startHighlightQueueRunner]);
952+
714953 const refreshSelectedSection = useCallback(async () => {
715954 if (!selectedSection) {
716955 return;
@@ -841,77 +1080,166 @@ export function ReviewSheet() { · 124 unmodified lines
8411080
8421081 return <>{children}</>;
8431082 }, [error, parsedDiffNotice]);
1083+ const stickyHeaderIndices = useMemo(() => {
1084+ const itemOffset = listHeader ? 1 : 0;
1085+
1086+ return reviewListItems.flatMap((item, index) =>
1087+ item.kind === "file-header" ? [index + itemOffset] : [],
1088+ );
1089+ }, [listHeader, reviewListItems]);
1090+
1091+ const viewabilityConfigRef = useRef({
1092+ itemVisiblePercentThreshold: 10,
1093+ });
1094+ const onViewableItemsChangedRef = useRef(
1095+ ({
1096+ viewableItems,
1097+ }: {
1098+ readonly viewableItems: Array<ViewToken<ReviewListItem>>;
1099+ readonly changed: Array<ViewToken<ReviewListItem>>;
1100+ }) => {
1101+ const nextVisibleFileIds = Array.from(
1102+ new Set(
1103+ viewableItems
1104+ .filter((token) => token.isViewable && token.item !== undefined)
1105+ .map((token) => token.item.fileId),
1106+ ),
1107+ );
1108+
1109+ setVisibleFileIds(nextVisibleFileIds);
1110+ },
1111+ );
8441112
845− const renderFileItem = useCallback(
846− ({ item }: { item: ReviewRenderableFile; index: number }) => {
1113+ const renderReviewListItem = useCallback(
1114+ ({ item }: ListRenderItemInfo<ReviewListItem>) => {
8471115 if (!selectedSection) {
8481116 return null;
8491117 }
850− const pendingSelectionForFile =
851− pendingCommentSelection &&
852− pendingCommentSelection.sectionTitle === selectedSection.title &&
853− pendingCommentSelection.filePath === item.path
854− ? pendingCommentSelection
855− : null;
856− const selectedTargetForFile =
857− activeCommentTarget &&
858− activeCommentTarget.sectionTitle === selectedSection.title &&
859− activeCommentTarget.filePath === item.path
860− ? activeCommentTarget
861− : null;
8621118
863− return (
864− <ReviewFileItem
865− file={item}
866− expanded={expandedFileIds.includes(item.id)}
867− sectionTitle={selectedSection.title}
868− selectedTheme={selectedTheme}
869− pendingSelection={pendingSelectionForFile}
870− selectedTarget={selectedTargetForFile}
871− viewportWidth={Math.max(width, 280)}
872− revealedLarge={revealedLargeFileIds.includes(item.id)}
873− onToggleFile={handleToggleExpandedFile}
874− onLoadDiffFile={handleRevealLargeDiff}
875− onPressLine={handlePressLine}
876− onStartRangeSelection={handleStartRangeSelection}
877− />
878− );
1119+ switch (item.kind) {
1120+ case "file-header":
1121+ return (
1122+ <ReviewFileCard
1123+ file={item.file}
1124+ fileId={item.fileId}
1125+ expanded={item.expanded}
1126+ viewportWidth={reviewListContentWidth}
1127+ onToggleFile={handleToggleExpandedFile}
1128+ />
1129+ );
1130+ case "file-suppressed":
1131+ return (
1132+ <ReviewFileSuppressedBody
1133+ message={item.message}
1134+ actionLabel={item.actionLabel}
1135+ fileId={item.fileId}
1136+ viewportWidth={reviewListContentWidth}
1137+ onLoadDiffFile={handleRevealLargeDiff}
1138+ />
1139+ );
1140+ case "hunk":
1141+ return (
1142+ <ReviewHunkRow
1143+ header={item.row.header}
1144+ context={item.row.context}
1145+ viewportWidth={reviewListContentWidth}
1146+ />
1147+ );
1148+ case "line": {
1149+ const file = reviewFileById.get(item.fileId);
1150+ if (!file) {
1151+ return null;
1152+ }
1153+
1154+ const fileLineRows = reviewLineRowsByFileId.get(file.id) ?? [];
1155+ const lineIndex = reviewLineIndexByRowId.get(item.row.id);
1156+ if (lineIndex === undefined) {
1157+ return null;
1158+ }
1159+
1160+ const pendingSelectionForFile =
1161+ pendingCommentSelection &&
1162+ pendingCommentSelection.sectionTitle === selectedSection.title &&
1163+ pendingCommentSelection.filePath === file.path
1164+ ? pendingCommentSelection
1165+ : null;
1166+ const selectedTargetForFile =
1167+ activeCommentTarget &&
1168+ activeCommentTarget.sectionTitle === selectedSection.title &&
1169+ activeCommentTarget.filePath === file.path
1170+ ? activeCommentTarget
1171+ : null;
1172+ const highlightedFile =
1173+ highlightedFilesById[file.id] ??
1174+ getCachedHighlightedReviewFile(file, selectedTheme) ??
1175+ null;
1176+
1177+ if (highlightedFile === null) {
1178+ const missingTokenKey = `${selectedSection.id}:${file.id}`;
1179+ if (!loggedMissingReviewTokenKeys.has(missingTokenKey)) {
1180+ loggedMissingReviewTokenKeys.add(missingTokenKey);
1181+ logReviewDiffDiagnostic("rendering file without tokens", {
1182+ sectionId: selectedSection.id,
1183+ fileId: file.id,
1184+ filePath: file.path,
1185+ highlightedFileKnownInState: highlightedFilesById[file.id] !== undefined,
1186+ });
1187+ }
1188+ }
1189+
1190+ const selectionState =
1191+ pendingSelectionForFile?.anchorIndex === lineIndex
1192+ ? ("anchor" as const)
1193+ : selectedTargetForFile &&
1194+ lineIndex >= selectedTargetForFile.startIndex &&
1195+ lineIndex <= selectedTargetForFile.endIndex
1196+ ? ("selected" as const)
1197+ : null;
1198+
1199+ return (
1200+ <ReviewLineRow
1201+ line={item.row}
1202+ tokens={getHighlightedTokensForLine(item.row, highlightedFile)}
1203+ viewportWidth={reviewListContentWidth}
1204+ selectionState={selectionState}
1205+ onComment={() =>
1206+ handlePressLine({
1207+ sectionTitle: selectedSection.title,
1208+ filePath: file.path,
1209+ lines: fileLineRows,
1210+ lineIndex,
1211+ })
1212+ }
1213+ onStartRangeSelection={() =>
1214+ handleStartRangeSelection({
1215+ sectionTitle: selectedSection.title,
1216+ filePath: file.path,
1217+ lines: fileLineRows,
1218+ lineIndex,
1219+ })
1220+ }
1221+ />
1222+ );
1223+ }
1224+ }
8791225 },
8801226 [
8811227 activeCommentTarget,
882− expandedFileIds,
8831228 handlePressLine,
8841229 handleRevealLargeDiff,
8851230 handleStartRangeSelection,
8861231 handleToggleExpandedFile,
1232+ highlightedFilesById,
8871233 pendingCommentSelection,
888− revealedLargeFileIds,
1234+ reviewFileById,
1235+ reviewLineIndexByRowId,
1236+ reviewLineRowsByFileId,
1237+ reviewListContentWidth,
8891238 selectedSection,
8901239 selectedTheme,
891− width,
8921240 ],
8931241 );
8941242
895− const visibleListExtraData = useMemo(() => {
896− return {
897− selectedSectionId: selectedSection?.id ?? null,
898− selectedTheme,
899− expandedFileIds,
900− revealedLargeFileIds,
901− pendingSelection: pendingCommentSelection,
902− selectedTarget: activeCommentTarget,
903− viewportWidth: Math.max(width, 280),
904− };
905− }, [
906− activeCommentTarget,
907− expandedFileIds,
908− pendingCommentSelection,
909− revealedLargeFileIds,
910− selectedSection,
911− selectedTheme,
912− width,
913− ]);
914−
9151243 return (
9161244 <>
9171245 <Stack.Screen
@@ -1045,20 +1373,26 @@ export function ReviewSheet() { · 127 unmodified lines
10451373
10461374 <View className="flex-1 bg-sheet">
10471375 {selectedSection && parsedDiff.kind === "files" ? (
1048− <LegendList
1376+ <FlatList
10491377 style={{ flex: 1 }}
10501378 contentInsetAdjustmentBehavior="never"
10511379 contentInset={{ top: topContentInset }}
1380+ contentOffset={{ x: 0, y: -topContentInset }}
10521381 scrollIndicatorInsets={{ top: topContentInset }}
1053− data={parsedDiff.files as ReviewRenderableFile[]}
1054− renderItem={renderFileItem}
1055− keyExtractor={(file) => file.id}
1056− extraData={visibleListExtraData}
1382+ data={reviewListItems}
1383+ renderItem={renderReviewListItem}
1384+ keyExtractor={(item) => item.id}
10571385 keyboardShouldPersistTaps="handled"
1058− estimatedItemSize={220}
1059− drawDistance={900}
1060− recycleItems
10611386 ListHeaderComponent={listHeader}
1387+ stickyHeaderIndices={stickyHeaderIndices}
1388+ removeClippedSubviews={false}
1389+ initialNumToRender={24}
1390+ maxToRenderPerBatch={16}
1391+ updateCellsBatchingPeriod={16}
1392+ windowSize={10}
1393+ scrollEventThrottle={16}
1394+ onViewableItemsChanged={onViewableItemsChangedRef.current}
1395+ viewabilityConfig={viewabilityConfigRef.current}
10621396 contentContainerStyle={{
10631397 paddingTop: REVIEW_HEADER_SPACING,
10641398 paddingBottom: Math.max(insets.bottom, 18) + 18,
apps/mobile/src/features/threads/review/reviewDiffRendering.tsx+41−3
@@ -1,4 +1,4 @@
1−import { Platform, Text as NativeText } from "react-native";
1+import { Platform, Text as NativeText, View } from "react-native";
22
33 import { cn } from "../../../lib/cn";
44
@@ -17,14 +17,47 @@ export function renderVisibleWhitespace(value: string): string { · 12 unmodified lines
1717 }
1818
1919 export function changeTone(change: ReviewRenderableLineRow["change"]): string {
20− if (change === "add") return "bg-emerald-500/12";
21− if (change === "delete") return "bg-rose-500/12";
20+ if (change === "add") return "bg-emerald-500/10";
21+ if (change === "delete") return "bg-rose-500/10";
2222 return "bg-card";
2323 }
2424
25+export function changeBarTone(change: ReviewRenderableLineRow["change"]): string {
26+ if (change === "add") return "bg-emerald-400";
27+ if (change === "delete") return "bg-rose-400";
28+ return "bg-border/50";
29+}
30+
31+function diffHighlightColor(change: ReviewRenderableLineRow["change"]): string | undefined {
32+ if (change === "add") return "rgba(16, 185, 129, 0.24)";
33+ if (change === "delete") return "rgba(244, 63, 94, 0.24)";
34+ return undefined;
35+}
36+
37+export function ReviewChangeBar(props: { readonly change: ReviewRenderableLineRow["change"] }) {
38+ if (props.change === "delete") {
39+ return (
40+ <View className="w-[5px] self-stretch overflow-hidden">
41+ <View className="flex-1 justify-between">
42+ {Array.from({ length: 6 }, (_, index) => (
43+ <View key={index} className="h-[2px] w-[5px] bg-rose-400" />
44+ ))}
45+ </View>
46+ </View>
47+ );
48+ }
49+
50+ return (
51+ <View className="w-[5px] self-stretch overflow-hidden">
52+ <View className={cn("h-full w-[5px] flex-1", changeBarTone(props.change))} />
53+ </View>
54+ );
55+}
56+
2557 export function DiffTokenText(props: {
2658 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
2759 readonly fallback: string;
60+ readonly change?: ReviewRenderableLineRow["change"];
2861 readonly className?: string;
2962 }) {
3063 if (!props.tokens || props.tokens.length === 0) {
@@ -70,6 +103,11 @@ export function DiffTokenText(props: { · 39 unmodified lines
70103 fontFamily: REVIEW_MONO_FONT_FAMILY,
71104 fontWeight,
72105 fontStyle,
106+ backgroundColor:
107+ token.diffHighlight && props.change
108+ ? diffHighlightColor(props.change)
109+ : undefined,
110+ borderRadius: token.diffHighlight ? 4 : undefined,
73111 }}
74112 >
75113 {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}
apps/mobile/src/features/threads/review/reviewHighlighterEngine.test.ts+1−0
@@ -13,6 +13,7 @@ describe("resolveReviewHighlighterEnginePreference", () => { · 12 unmodified lines
1313
1414 it("accepts supported values", () => {
1515 expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");
16+ expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript");
1617 expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");
1718 });
1819 });
apps/mobile/src/features/threads/review/reviewHighlighterEngine.ts+3−1
@@ -5,9 +5,11 @@ export function resolveReviewHighlighterEnginePreference( · 4 unmodified lines
55 value: string | undefined,
66 ): ReviewHighlighterEnginePreference {
77 switch (value) {
8+ case "js":
89 case "javascript":
10+ return "javascript";
911 case "native":
10− return value;
12+ return "native";
1113 default:
1214 return "javascript";
1315 }
apps/mobile/src/features/threads/review/reviewModel.test.ts+75−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 } from "@t3tools/contracts";
99
1010 import {
11+ buildReviewListItems,
1112 buildReviewParsedDiff,
1213 buildReviewSectionItems,
1314 getDefaultReviewSectionId,
@@ -138,6 +139,7 @@ describe("buildReviewParsedDiff", () => { · 124 unmodified lines
138139 oldLineNumber: 1,
139140 newLineNumber: null,
140141 content: "const before = 1;",
142+ comparison: { change: "add", tokenIndex: 0 },
141143 }),
142144 expect.objectContaining({
143145 kind: "line",
@@ -145,6 +147,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
145147 oldLineNumber: null,
146148 newLineNumber: 1,
147149 content: "const after = 2;",
150+ comparison: { change: "delete", tokenIndex: 0 },
148151 }),
149152 expect.objectContaining({
150153 kind: "line",
@@ -152,6 +155,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
152155 oldLineNumber: null,
153156 newLineNumber: 2,
154157 content: "console.log(after);",
158+ comparison: null,
155159 }),
156160 expect.objectContaining({
157161 kind: "line",
@@ -159,6 +163,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
159163 oldLineNumber: 2,
160164 newLineNumber: 3,
161165 content: "return true;",
166+ comparison: null,
162167 }),
163168 ]);
164169 });
@@ -220,6 +225,7 @@ describe("buildReviewParsedDiff", () => { · 55 unmodified lines
220225 content: `const line${index} = ${index};`,
221226 additionTokenIndex: index,
222227 deletionTokenIndex: null,
228+ comparison: null,
223229 })),
224230 }),
225231 );
@@ -231,4 +237,73 @@ describe("buildReviewParsedDiff", () => { · 5 unmodified lines
231237 actionLabel: "Load diff",
232238 });
233239 });
240+
241+ it("flattens expanded files into virtualizable review items", () => {
242+ const file = makeRenderableFile({
243+ path: "apps/mobile/src/a.ts",
244+ rows: [
245+ {
246+ kind: "hunk",
247+ id: "hunk-1",
248+ header: "@@ -1,1 +1,2 @@",
249+ context: null,
250+ },
251+ {
252+ kind: "line",
253+ id: "line-1",
254+ change: "add",
255+ oldLineNumber: null,
256+ newLineNumber: 1,
257+ content: "const after = 2;",
258+ additionTokenIndex: 0,
259+ deletionTokenIndex: null,
260+ comparison: null,
261+ },
262+ ],
263+ });
264+
265+ const items = buildReviewListItems({
266+ files: [file],
267+ expandedFileIds: [file.id],
268+ revealedLargeFileIds: [],
269+ });
270+
271+ expect(items).toEqual([
272+ expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
273+ expect.objectContaining({ kind: "hunk", fileId: file.id, row: file.rows[0] }),
274+ expect.objectContaining({ kind: "line", fileId: file.id, row: file.rows[1] }),
275+ ]);
276+ });
277+
278+ it("keeps large diffs collapsed into a placeholder item until revealed", () => {
279+ const file = makeRenderableFile({
280+ path: "apps/mobile/src/big.ts",
281+ rows: Array.from({ length: 401 }, (_, index) => ({
282+ kind: "line" as const,
283+ id: `line-${index}`,
284+ change: "add" as const,
285+ oldLineNumber: null,
286+ newLineNumber: index + 1,
287+ content: `const line${index} = ${index};`,
288+ additionTokenIndex: index,
289+ deletionTokenIndex: null,
290+ comparison: null,
291+ })),
292+ });
293+
294+ const items = buildReviewListItems({
295+ files: [file],
296+ expandedFileIds: [file.id],
297+ revealedLargeFileIds: [],
298+ });
299+
300+ expect(items).toEqual([
301+ expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }),
302+ expect.objectContaining({
303+ kind: "file-suppressed",
304+ fileId: file.id,
305+ actionLabel: "Load diff",
306+ }),
307+ ]);
308+ });
234309 });
apps/mobile/src/features/threads/review/reviewModel.ts+125−0
@@ -30,6 +30,10 @@ export interface ReviewRenderableLineRow { · 29 unmodified lines
3030 readonly content: string;
3131 readonly additionTokenIndex: number | null;
3232 readonly deletionTokenIndex: number | null;
33+ readonly comparison: {
34+ readonly change: "add" | "delete";
35+ readonly tokenIndex: number;
36+ } | null;
3337 }
3438
3539 export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;
@@ -48,6 +52,42 @@ export interface ReviewRenderableFile { · 12 unmodified lines
4852 readonly rows: ReadonlyArray<ReviewRenderableRow>;
4953 }
5054
55+export interface ReviewFileHeaderListItem {
56+ readonly kind: "file-header";
57+ readonly id: string;
58+ readonly fileId: string;
59+ readonly file: ReviewRenderableFile;
60+ readonly expanded: boolean;
61+}
62+
63+export interface ReviewFileSuppressedListItem {
64+ readonly kind: "file-suppressed";
65+ readonly id: string;
66+ readonly fileId: string;
67+ readonly message: string;
68+ readonly actionLabel: string | null;
69+}
70+
71+export interface ReviewHunkListItem {
72+ readonly kind: "hunk";
73+ readonly id: string;
74+ readonly fileId: string;
75+ readonly row: ReviewRenderableHunkRow;
76+}
77+
78+export interface ReviewLineListItem {
79+ readonly kind: "line";
80+ readonly id: string;
81+ readonly fileId: string;
82+ readonly row: ReviewRenderableLineRow;
83+}
84+
85+export type ReviewListItem =
86+ | ReviewFileHeaderListItem
87+ | ReviewFileSuppressedListItem
88+ | ReviewHunkListItem
89+ | ReviewLineListItem;
90+
5191 export type ReviewFilePreviewState =
5292 | {
5393 readonly kind: "render";
@@ -267,6 +307,72 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil · 213 unmodified lines
267307 return { kind: "render" };
268308 }
269309
310+// The flattened review list item model is inspired by pierre/diffs' iterator-first
311+// virtualization architecture, adapted here for React Native and LegendList.
312+// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs
313+// Reference files:
314+// - src/utils/iterateOverDiff.ts
315+// - src/components/VirtualizedFileDiff.ts
316+export function buildReviewListItems(input: {
317+ readonly files: ReadonlyArray<ReviewRenderableFile>;
318+ readonly expandedFileIds: ReadonlyArray<string>;
319+ readonly revealedLargeFileIds: ReadonlyArray<string>;
320+}): ReadonlyArray<ReviewListItem> {
321+ const expandedFileIds = new Set(input.expandedFileIds);
322+ const revealedLargeFileIds = new Set(input.revealedLargeFileIds);
323+ const items: ReviewListItem[] = [];
324+
325+ input.files.forEach((file) => {
326+ const expanded = expandedFileIds.has(file.id);
327+ items.push({
328+ kind: "file-header",
329+ id: `${file.id}:header`,
330+ fileId: file.id,
331+ file,
332+ expanded,
333+ });
334+
335+ if (!expanded) {
336+ return;
337+ }
338+
339+ const previewState = getReviewFilePreviewState(file);
340+ if (previewState.kind === "suppressed") {
341+ if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) {
342+ items.push({
343+ kind: "file-suppressed",
344+ id: `${file.id}:suppressed`,
345+ fileId: file.id,
346+ message: previewState.message,
347+ actionLabel: previewState.actionLabel,
348+ });
349+ return;
350+ }
351+ }
352+
353+ file.rows.forEach((row) => {
354+ if (row.kind === "hunk") {
355+ items.push({
356+ kind: "hunk",
357+ id: row.id,
358+ fileId: file.id,
359+ row,
360+ });
361+ return;
362+ }
363+
364+ items.push({
365+ kind: "line",
366+ id: row.id,
367+ fileId: file.id,
368+ row,
369+ });
370+ });
371+ });
372+
373+ return items;
374+}
375+
270376 function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
271377 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
272378 }
@@ -304,6 +410,7 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 31 unmodified lines
304410 ),
305411 additionTokenIndex,
306412 deletionTokenIndex,
413+ comparison: null,
307414 });
308415 deletionLineNumber += 1;
309416 additionLineNumber += 1;
@@ -313,6 +420,10 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 3 unmodified lines
313420 return;
314421 }
315422
423+ const pairedLineCount = Math.min(segment.deletions, segment.additions);
424+ const deletionTokenIndexStart = deletionTokenIndex;
425+ const additionTokenIndexStart = additionTokenIndex;
426+
316427 for (let index = 0; index < segment.deletions; index += 1) {
317428 rows.push({
318429 kind: "line",
@@ -323,6 +434,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 4 unmodified lines
323434 content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""),
324435 additionTokenIndex: null,
325436 deletionTokenIndex,
437+ comparison:
438+ index < pairedLineCount
439+ ? {
440+ change: "add",
441+ tokenIndex: additionTokenIndexStart + index,
442+ }
443+ : null,
326444 });
327445 deletionLineNumber += 1;
328446 deletionTokenIndex += 1;
@@ -338,6 +456,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 9 unmodified lines
338456 content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""),
339457 additionTokenIndex,
340458 deletionTokenIndex: null,
459+ comparison:
460+ index < pairedLineCount
461+ ? {
462+ change: "delete",
463+ tokenIndex: deletionTokenIndexStart + index,
464+ }
465+ : null,
341466 });
342467 additionLineNumber += 1;
343468 additionTokenIndex += 1;
apps/mobile/src/features/threads/review/reviewWordDiffs.test.ts+62−0
@@ -0,0 +1,62 @@
1+import { describe, expect, it } from "vitest";
2+
3+import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
4+
5+describe("computeWordAltDiffRanges", () => {
6+ it("joins adjacent word replacements across a single shared separator", () => {
7+ const ranges = computeWordAltDiffRanges({
8+ deletionLine: "old old",
9+ additionLine: "new new",
10+ });
11+
12+ expect(ranges.deletion).toEqual([{ start: 0, end: "old old".length }]);
13+ expect(ranges.addition).toEqual([{ start: 0, end: "new new".length }]);
14+ });
15+
16+ it("skips inline word diffs for long lines", () => {
17+ const longDeletion = `const before = "${"a".repeat(1_001)}";`;
18+ const longAddition = `const after = "${"b".repeat(1_001)}";`;
19+
20+ const ranges = computeWordAltDiffRanges({
21+ deletionLine: longDeletion,
22+ additionLine: longAddition,
23+ });
24+
25+ expect(ranges.deletion).toEqual([]);
26+ expect(ranges.addition).toEqual([]);
27+ });
28+});
29+
30+describe("applyDiffRangesToTokens", () => {
31+ it("splits highlighted fragments out of syntax tokens", () => {
32+ const tokens = [
33+ {
34+ content: "const before = 1;",
35+ color: "#fff",
36+ fontStyle: null,
37+ },
38+ ];
39+ const nextTokens = applyDiffRangesToTokens(tokens, [{ start: 6, end: 12 }]);
40+
41+ expect(nextTokens).toEqual([
42+ {
43+ content: "const ",
44+ color: "#fff",
45+ fontStyle: null,
46+ diffHighlight: false,
47+ },
48+ {
49+ content: "before",
50+ color: "#fff",
51+ fontStyle: null,
52+ diffHighlight: true,
53+ },
54+ {
55+ content: " = 1;",
56+ color: "#fff",
57+ fontStyle: null,
58+ diffHighlight: false,
59+ },
60+ ]);
61+ });
62+});
apps/mobile/src/features/threads/review/reviewWordDiffs.ts+266−0
@@ -0,0 +1,266 @@
1+import { diffWordsWithSpace } from "diff";
2+
3+import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
4+
5+interface ReviewDiffOperation {
6+ readonly value: string;
7+ readonly added?: true;
8+ readonly removed?: true;
9+}
10+
11+interface ReviewDiffHighlightRange {
12+ readonly start: number;
13+ readonly end: number;
14+}
15+
16+const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000;
17+
18+function cleanLineEnding(value: string): string {
19+ return value.endsWith("\n") ? value.slice(0, -1) : value;
20+}
21+
22+function pushOrJoinSpan(input: {
23+ readonly operation: ReviewDiffOperation;
24+ readonly spans: Array<[0 | 1, string]>;
25+ readonly enableJoin: boolean;
26+ readonly isNeutral?: boolean;
27+ readonly isLastOperation?: boolean;
28+}): void {
29+ const { operation, spans, enableJoin, isNeutral = false, isLastOperation = false } = input;
30+ const lastSpan = spans.at(-1);
31+
32+ if (!lastSpan || isLastOperation || !enableJoin) {
33+ spans.push([isNeutral ? 0 : 1, operation.value]);
34+ return;
35+ }
36+
37+ const lastSpanIsNeutral = lastSpan[0] === 0;
38+ if (
39+ isNeutral === lastSpanIsNeutral ||
40+ (isNeutral && operation.value.length === 1 && !lastSpanIsNeutral)
41+ ) {
42+ lastSpan[1] += operation.value;
43+ return;
44+ }
45+
46+ spans.push([isNeutral ? 0 : 1, operation.value]);
47+}
48+
49+function spansToRanges(
50+ spans: ReadonlyArray<readonly [0 | 1, string]>,
51+): ReadonlyArray<ReviewDiffHighlightRange> {
52+ const ranges: ReviewDiffHighlightRange[] = [];
53+ let offset = 0;
54+
55+ spans.forEach(([kind, value]) => {
56+ const nextOffset = offset + value.length;
57+ if (kind === 1 && value.length > 0) {
58+ ranges.push({ start: offset, end: nextOffset });
59+ }
60+ offset = nextOffset;
61+ });
62+
63+ return ranges;
64+}
65+
66+function mergeNearbyRanges(
67+ ranges: ReadonlyArray<ReviewDiffHighlightRange>,
68+): ReadonlyArray<ReviewDiffHighlightRange> {
69+ if (ranges.length <= 1) {
70+ return ranges;
71+ }
72+
73+ const merged: ReviewDiffHighlightRange[] = [];
74+
75+ ranges.forEach((range) => {
76+ const previous = merged.at(-1);
77+ if (previous && range.start - previous.end <= 1) {
78+ merged[merged.length - 1] = { start: previous.start, end: range.end };
79+ return;
80+ }
81+ merged.push({ ...range });
82+ });
83+
84+ return merged;
85+}
86+
87+function appendTokenSegment(
88+ target: ReviewHighlightedToken[],
89+ source: ReviewHighlightedToken,
90+ content: string,
91+ diffHighlight: boolean,
92+): void {
93+ if (content.length === 0) {
94+ return;
95+ }
96+
97+ const previous = target.at(-1);
98+ if (
99+ previous &&
100+ previous.color === source.color &&
101+ previous.fontStyle === source.fontStyle &&
102+ previous.diffHighlight === diffHighlight
103+ ) {
104+ previous.content += content;
105+ return;
106+ }
107+
108+ target.push({
109+ content,
110+ color: source.color,
111+ fontStyle: source.fontStyle,
112+ diffHighlight,
113+ });
114+}
115+
116+export function computeWordAltDiffRanges(input: {
117+ readonly deletionLine: string;
118+ readonly additionLine: string;
119+}): {
120+ readonly deletion: ReadonlyArray<ReviewDiffHighlightRange>;
121+ readonly addition: ReadonlyArray<ReviewDiffHighlightRange>;
122+} {
123+ const deletionLine = cleanLineEnding(input.deletionLine);
124+ const additionLine = cleanLineEnding(input.additionLine);
125+
126+ if (deletionLine.length === 0 && additionLine.length === 0) {
127+ return { deletion: [], addition: [] };
128+ }
129+
130+ if (
131+ deletionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH ||
132+ additionLine.length > REVIEW_MAX_WORD_ALT_LINE_LENGTH
133+ ) {
134+ return { deletion: [], addition: [] };
135+ }
136+
137+ const operations = diffWordsWithSpace(
138+ deletionLine,
139+ additionLine,
140+ ) as ReadonlyArray<ReviewDiffOperation>;
141+ const deletionSpans: Array<[0 | 1, string]> = [];
142+ const additionSpans: Array<[0 | 1, string]> = [];
143+ const lastOperation = operations.at(-1);
144+
145+ operations.forEach((operation) => {
146+ const isLastOperation = operation === lastOperation;
147+ if (!operation.added && !operation.removed) {
148+ pushOrJoinSpan({
149+ operation,
150+ spans: deletionSpans,
151+ enableJoin: true,
152+ isNeutral: true,
153+ isLastOperation,
154+ });
155+ pushOrJoinSpan({
156+ operation,
157+ spans: additionSpans,
158+ enableJoin: true,
159+ isNeutral: true,
160+ isLastOperation,
161+ });
162+ return;
163+ }
164+
165+ if (operation.removed) {
166+ pushOrJoinSpan({
167+ operation,
168+ spans: deletionSpans,
169+ enableJoin: true,
170+ isLastOperation,
171+ });
172+ return;
173+ }
174+
175+ pushOrJoinSpan({
176+ operation,
177+ spans: additionSpans,
178+ enableJoin: true,
179+ isLastOperation,
180+ });
181+ });
182+
183+ return {
184+ deletion: mergeNearbyRanges(spansToRanges(deletionSpans)),
185+ addition: mergeNearbyRanges(spansToRanges(additionSpans)),
186+ };
187+}
188+
189+export function applyDiffRangesToTokens(
190+ tokens: ReadonlyArray<ReviewHighlightedToken>,
191+ ranges: ReadonlyArray<ReviewDiffHighlightRange>,
192+): ReadonlyArray<ReviewHighlightedToken> {
193+ if (tokens.length === 0 || ranges.length === 0) {
194+ return tokens;
195+ }
196+
197+ const nextTokens: ReviewHighlightedToken[] = [];
198+ let tokenOffset = 0;
199+ let rangeIndex = 0;
200+
201+ tokens.forEach((token) => {
202+ const tokenStart = tokenOffset;
203+ const tokenEnd = tokenStart + token.content.length;
204+ tokenOffset = tokenEnd;
205+
206+ if (token.content.length === 0) {
207+ nextTokens.push(token);
208+ return;
209+ }
210+
211+ while ((ranges[rangeIndex]?.end ?? 0) <= tokenStart) {
212+ rangeIndex += 1;
213+ }
214+
215+ if ((ranges[rangeIndex]?.start ?? Number.POSITIVE_INFINITY) >= tokenEnd) {
216+ nextTokens.push(token.diffHighlight ? { ...token, diffHighlight: false } : token);
217+ return;
218+ }
219+
220+ let cursor = tokenStart;
221+ let localRangeIndex = rangeIndex;
222+
223+ while ((ranges[localRangeIndex]?.start ?? Number.POSITIVE_INFINITY) < tokenEnd) {
224+ const range = ranges[localRangeIndex];
225+ if (!range) {
226+ break;
227+ }
228+
229+ if (range.start > cursor) {
230+ appendTokenSegment(
231+ nextTokens,
232+ token,
233+ token.content.slice(cursor - tokenStart, range.start - tokenStart),
234+ false,
235+ );
236+ }
237+
238+ const highlightedStart = Math.max(cursor, range.start);
239+ const highlightedEnd = Math.min(tokenEnd, range.end);
240+
241+ if (highlightedEnd > highlightedStart) {
242+ appendTokenSegment(
243+ nextTokens,
244+ token,
245+ token.content.slice(highlightedStart - tokenStart, highlightedEnd - tokenStart),
246+ true,
247+ );
248+ }
249+
250+ cursor = highlightedEnd;
251+ if (range.end <= tokenEnd) {
252+ localRangeIndex += 1;
253+ } else {
254+ break;
255+ }
256+ }
257+
258+ if (cursor < tokenEnd) {
259+ appendTokenSegment(nextTokens, token, token.content.slice(cursor - tokenStart), false);
260+ }
261+
262+ rangeIndex = localRangeIndex;
263+ });
264+
265+ return nextTokens;
266+}
apps/mobile/src/features/threads/review/shikiReviewHighlighter.test.ts+69−0
@@ -41,4 +41,73 @@ describe("highlightReviewFile", () => { · 40 unmodified lines
4141 expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true);
4242 expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true);
4343 });
44+
45+ it("adds word-alt diff emphasis for paired deletion and addition lines", async () => {
46+ const file = makeRenderableFile({
47+ path: "apps/mobile/src/example-inline-diff.test.ts",
48+ additionLines: ["const after = 2;"],
49+ deletionLines: ["const before = 1;"],
50+ rows: [
51+ {
52+ kind: "line",
53+ id: "delete-1",
54+ change: "delete",
55+ oldLineNumber: 1,
56+ newLineNumber: null,
57+ content: "const before = 1;",
58+ additionTokenIndex: null,
59+ deletionTokenIndex: 0,
60+ comparison: { change: "add", tokenIndex: 0 },
61+ },
62+ {
63+ kind: "line",
64+ id: "add-1",
65+ change: "add",
66+ oldLineNumber: null,
67+ newLineNumber: 1,
68+ content: "const after = 2;",
69+ additionTokenIndex: 0,
70+ deletionTokenIndex: null,
71+ comparison: { change: "delete", tokenIndex: 0 },
72+ },
73+ ],
74+ });
75+
76+ const highlighted = await highlightReviewFile(file, "light");
77+
78+ expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true);
79+ expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true);
80+ });
81+
82+ it("falls back to plain tokens for very long lines", async () => {
83+ const longLine = `const value = "${"a".repeat(1_100)}";`;
84+ const file = makeRenderableFile({
85+ path: "apps/mobile/src/example-long-line.ts",
86+ additionLines: [longLine],
87+ rows: [
88+ {
89+ kind: "line",
90+ id: "add-1",
91+ change: "add",
92+ oldLineNumber: null,
93+ newLineNumber: 1,
94+ content: longLine,
95+ additionTokenIndex: 0,
96+ deletionTokenIndex: null,
97+ comparison: null,
98+ },
99+ ],
100+ });
101+
102+ const highlighted = await highlightReviewFile(file, "light");
103+
104+ expect(highlighted.additionLines).toHaveLength(1);
105+ expect(highlighted.additionLines[0]).toEqual([
106+ {
107+ content: longLine,
108+ color: null,
109+ fontStyle: null,
110+ },
111+ ]);
112+ });
44113 });
apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts+278−13
@@ -8,13 +8,15 @@ import { · 7 unmodified lines
88 type ReviewHighlighterEngine,
99 } from "./reviewHighlighterEngine";
1010 import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";
11+import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
1112
1213 export type ReviewDiffTheme = "light" | "dark";
1314
1415 export interface ReviewHighlightedToken {
15− readonly content: string;
16+ content: string;
1617 readonly color: string | null;
1718 readonly fontStyle: number | null;
19+ readonly diffHighlight?: boolean;
1820 }
1921
2022 export interface ReviewHighlightedFile {
@@ -26,13 +28,19 @@ const SHIKI_THEME_NAME_BY_SCHEME = { · 5 unmodified lines
2628 light: "github-light-default",
2729 dark: "github-dark-default",
2830 } as const;
31+const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE =
32+ process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript";
2933 const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
30− process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript",
34+ REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
3135 );
3236 const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag(
3337 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE,
3438 false,
3539 );
40+const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8;
41+const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 40;
42+const REVIEW_HIGHLIGHT_CHUNK_SIZE = 20;
43+const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000;
3644 const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();
3745 const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();
3846 const loadedLanguages = new Set<string>(["text"]);
@@ -205,6 +213,17 @@ function joinPatchLines(lines: ReadonlyArray<string>): string { · 166 unmodified lines
205213 return lines.map(stripTrailingNewline).join("\n");
206214 }
207215
216+function waitForNextFrame(): Promise<void> {
217+ return new Promise((resolve) => {
218+ if (typeof requestAnimationFrame === "function") {
219+ requestAnimationFrame(() => resolve());
220+ return;
221+ }
222+
223+ setTimeout(resolve, 0);
224+ });
225+}
226+
208227 async function getHighlighter(): Promise<HighlighterCore> {
209228 if (!highlighterPromise) {
210229 const configuredHighlighterPromise = Promise.all([
@@ -214,6 +233,7 @@ async function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines
214233 let nativeEngineAvailable = false;
215234
216235 logReviewHighlighterDiagnostic("initializing", {
236+ configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
217237 preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
218238 resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE,
219239 });
@@ -379,6 +399,146 @@ function normalizeHighlightedLines( · 159 unmodified lines
379399 );
380400 }
381401
402+function applyWordAltDiffHighlightsToFile(
403+ file: ReviewRenderableFile,
404+ highlighted: ReviewHighlightedFile,
405+): ReviewHighlightedFile {
406+ const nextAdditionLines = [...highlighted.additionLines];
407+ const nextDeletionLines = [...highlighted.deletionLines];
408+ const processedPairs = new Set<string>();
409+ let changed = false;
410+
411+ file.rows.forEach((row) => {
412+ if (row.kind !== "line" || row.change === "context" || !row.comparison) {
413+ return;
414+ }
415+
416+ const deletionTokenIndex =
417+ row.change === "delete"
418+ ? row.deletionTokenIndex
419+ : row.comparison.change === "delete"
420+ ? row.comparison.tokenIndex
421+ : null;
422+ const additionTokenIndex =
423+ row.change === "add"
424+ ? row.additionTokenIndex
425+ : row.comparison.change === "add"
426+ ? row.comparison.tokenIndex
427+ : null;
428+
429+ if (deletionTokenIndex === null || additionTokenIndex === null) {
430+ return;
431+ }
432+
433+ const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`;
434+ if (processedPairs.has(pairKey)) {
435+ return;
436+ }
437+ processedPairs.add(pairKey);
438+
439+ const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? "");
440+ const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? "");
441+ const ranges = computeWordAltDiffRanges({ deletionLine, additionLine });
442+
443+ if (ranges.deletion.length > 0) {
444+ nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens(
445+ nextDeletionLines[deletionTokenIndex] ?? [],
446+ ranges.deletion,
447+ );
448+ changed = true;
449+ }
450+
451+ if (ranges.addition.length > 0) {
452+ nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens(
453+ nextAdditionLines[additionTokenIndex] ?? [],
454+ ranges.addition,
455+ );
456+ changed = true;
457+ }
458+ });
459+
460+ return changed
461+ ? {
462+ additionLines: nextAdditionLines,
463+ deletionLines: nextDeletionLines,
464+ }
465+ : highlighted;
466+}
467+
468+function applyWordAltDiffHighlightsToSelectedLines(input: {
469+ readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
470+ readonly tokenMap: Record<string, ReadonlyArray<ReviewHighlightedToken>>;
471+}): Record<string, ReadonlyArray<ReviewHighlightedToken>> {
472+ const additionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
473+ const deletionLineByTokenIndex = new Map<number, ReviewRenderableLineRow>();
474+
475+ input.lines.forEach((line) => {
476+ if (line.change === "add" && line.additionTokenIndex !== null) {
477+ additionLineByTokenIndex.set(line.additionTokenIndex, line);
478+ }
479+ if (line.change === "delete" && line.deletionTokenIndex !== null) {
480+ deletionLineByTokenIndex.set(line.deletionTokenIndex, line);
481+ }
482+ });
483+
484+ const nextTokenMap = { ...input.tokenMap };
485+ const processedPairs = new Set<string>();
486+
487+ input.lines.forEach((line) => {
488+ if (line.change === "context" || !line.comparison) {
489+ return;
490+ }
491+
492+ const pairedDeletionLine =
493+ line.change === "delete"
494+ ? line
495+ : line.comparison.change === "delete"
496+ ? deletionLineByTokenIndex.get(line.comparison.tokenIndex)
497+ : undefined;
498+ const pairedAdditionLine =
499+ line.change === "add"
500+ ? line
501+ : line.comparison.change === "add"
502+ ? additionLineByTokenIndex.get(line.comparison.tokenIndex)
503+ : undefined;
504+
505+ if (
506+ !pairedDeletionLine ||
507+ !pairedAdditionLine ||
508+ pairedDeletionLine.deletionTokenIndex === null ||
509+ pairedAdditionLine.additionTokenIndex === null
510+ ) {
511+ return;
512+ }
513+
514+ const pairKey = `${pairedDeletionLine.deletionTokenIndex}:${pairedAdditionLine.additionTokenIndex}`;
515+ if (processedPairs.has(pairKey)) {
516+ return;
517+ }
518+ processedPairs.add(pairKey);
519+
520+ const ranges = computeWordAltDiffRanges({
521+ deletionLine: pairedDeletionLine.content,
522+ additionLine: pairedAdditionLine.content,
523+ });
524+
525+ if (ranges.deletion.length > 0) {
526+ nextTokenMap[pairedDeletionLine.id] = applyDiffRangesToTokens(
527+ nextTokenMap[pairedDeletionLine.id] ?? [],
528+ ranges.deletion,
529+ );
530+ }
531+ if (ranges.addition.length > 0) {
532+ nextTokenMap[pairedAdditionLine.id] = applyDiffRangesToTokens(
533+ nextTokenMap[pairedAdditionLine.id] ?? [],
534+ ranges.addition,
535+ );
536+ }
537+ });
538+
539+ return nextTokenMap;
540+}
541+
382542 async function highlightLines(
383543 code: string,
384544 language: string,
@@ -389,14 +549,76 @@ async function highlightLines( · 4 unmodified lines
389549 }
390550
391551 const highlighter = await getHighlighter();
392− const tokenLines = highlighter.codeToTokensBase(code, { lang: language, theme });
393− return normalizeHighlightedLines(tokenLines);
552+ const sourceLines = code.split("\n");
553+ const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = [];
554+ const shortLineBatch: string[] = [];
555+
556+ const flushShortLineBatch = async (): Promise<void> => {
557+ if (shortLineBatch.length === 0) {
558+ return;
559+ }
560+
561+ const tokenLines = highlighter.codeToTokensBase(shortLineBatch.join("\n"), {
562+ lang: language,
563+ theme,
564+ });
565+ highlightedLines.push(...normalizeHighlightedLines(tokenLines));
566+ shortLineBatch.length = 0;
567+ };
568+
569+ for (let lineIndex = 0; lineIndex < sourceLines.length; lineIndex += 1) {
570+ const line = sourceLines[lineIndex] ?? "";
571+
572+ if (line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) {
573+ await flushShortLineBatch();
574+ highlightedLines.push([{ content: line, color: null, fontStyle: null }]);
575+ } else {
576+ shortLineBatch.push(line);
577+ }
578+
579+ if (shortLineBatch.length >= REVIEW_HIGHLIGHT_CHUNK_SIZE) {
580+ await flushShortLineBatch();
581+ }
582+
583+ if (
584+ sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD &&
585+ lineIndex + 1 < sourceLines.length &&
586+ (shortLineBatch.length === 0 || line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH)
587+ ) {
588+ await waitForNextFrame();
589+ }
590+ }
591+
592+ await flushShortLineBatch();
593+
594+ return highlightedLines;
394595 }
395596
396597 function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {
397598 return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`;
398599 }
399600
601+function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void {
602+ if (resolvedHighlightCache.has(cacheKey)) {
603+ resolvedHighlightCache.delete(cacheKey);
604+ }
605+
606+ resolvedHighlightCache.set(cacheKey, highlighted);
607+
608+ while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) {
609+ const oldestKey = resolvedHighlightCache.keys().next().value;
610+ if (oldestKey === undefined) {
611+ break;
612+ }
613+ resolvedHighlightCache.delete(oldestKey);
614+ }
615+}
616+
617+export function clearReviewHighlightFileCache(): void {
618+ highlightCache.clear();
619+ resolvedHighlightCache.clear();
620+}
621+
400622 export function getCachedHighlightedReviewFile(
401623 file: ReviewRenderableFile,
402624 theme: ReviewDiffTheme,
@@ -417,32 +639,72 @@ export async function highlightReviewFile( · 14 unmodified lines
417639 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
418640 const resolved = resolvedHighlightCache.get(cacheKey);
419641 if (resolved) {
642+ logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", {
643+ fileId: file.id,
644+ filePath: file.path,
645+ theme,
646+ });
420647 return resolved;
421648 }
422649 const cached = highlightCache.get(cacheKey);
423650 if (cached) {
651+ logReviewHighlighterDiagnostic("file highlight cache hit (pending)", {
652+ fileId: file.id,
653+ filePath: file.path,
654+ theme,
655+ });
424656 return cached;
425657 }
426658 }
427659
428660 const promise = (async () => {
661+ const startedAt = Date.now();
662+ logReviewHighlighterDiagnostic("file highlight start", {
663+ fileId: file.id,
664+ filePath: file.path,
665+ theme,
666+ additionLineCount: file.additionLines.length,
667+ deletionLineCount: file.deletionLines.length,
668+ rowCount: file.rows.length,
669+ });
429670 const language = await resolveLanguage(file);
430− const [additionLines, deletionLines] = await Promise.all([
431− highlightLines(joinPatchLines(file.additionLines), language, shikiTheme),
432− highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme),
433− ]);
434−
435− const highlighted = { additionLines, deletionLines };
671+ const additionLines = await highlightLines(
672+ joinPatchLines(file.additionLines),
673+ language,
674+ shikiTheme,
675+ );
676+ await waitForNextFrame();
677+ const deletionLines = await highlightLines(
678+ joinPatchLines(file.deletionLines),
679+ language,
680+ shikiTheme,
681+ );
682+ await waitForNextFrame();
683+
684+ const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines });
436685 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
437− resolvedHighlightCache.set(cacheKey, highlighted);
686+ storeResolvedHighlightedFile(cacheKey, highlighted);
438687 }
688+ logReviewHighlighterDiagnostic("file highlight complete", {
689+ fileId: file.id,
690+ filePath: file.path,
691+ theme,
692+ language,
693+ highlightedAdditionLineCount: highlighted.additionLines.length,
694+ highlightedDeletionLineCount: highlighted.deletionLines.length,
695+ durationMs: Date.now() - startedAt,
696+ });
439697 return highlighted;
440698 })();
441699
442700 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
443701 highlightCache.set(cacheKey, promise);
444702 }
445− return promise;
703+ return promise.finally(() => {
704+ if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
705+ highlightCache.delete(cacheKey);
706+ }
707+ });
446708 }
447709
448710 export async function highlightReviewSelectedLines(input: {
@@ -483,5 +745,8 @@ export async function highlightReviewSelectedLines(input: { · 34 unmodified lines
483745 additionIndex += 1;
484746 });
485747
486− return tokenMap;
748+ return applyWordAltDiffHighlightsToSelectedLines({
749+ lines: input.lines,
750+ tokenMap,
751+ });
487752 }
bun.lock+1−0
@@ -66,6 +66,7 @@ · 65 unmodified lines
6666 "@t3tools/contracts": "workspace:*",
6767 "@t3tools/shared": "workspace:*",
6868 "clsx": "^2.1.1",
69+ "diff": "8.0.3",
6970 "effect": "catalog:",
7071 "expo": "~55.0.14",
7172 "expo-camera": "~55.0.15",