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
36bun run ios:preview36bun run ios:preview
37```37```
38 38
39Force the review diff highlighter engine:
40
41```bash
42EXPO_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
39Inspect the resolved Expo config for a variant:47Inspect the resolved Expo config for a variant:
40 48
41```bash49```bash
apps/mobile/package.json+1−0
@@ -47,6 +47,7 @@ · 46 unmodified lines
47 "@t3tools/contracts": "workspace:*",47 "@t3tools/contracts": "workspace:*",
48 "@t3tools/shared": "workspace:*",48 "@t3tools/shared": "workspace:*",
49 "clsx": "^2.1.1",49 "clsx": "^2.1.1",
50 "diff": "8.0.3",
50 "effect": "catalog:",51 "effect": "catalog:",
51 "expo": "~55.0.14",52 "expo": "~55.0.14",
52 "expo-camera": "~55.0.15",53 "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
27import {27import {
28 clearReviewCommentTarget,28 clearReviewCommentTarget,
29 formatReviewCommentContext,29 formatReviewCommentContext,
30 getReviewChangeMarker,
31 getReviewUnifiedLineNumber,30 getReviewUnifiedLineNumber,
32 getSelectedReviewCommentLines,31 getSelectedReviewCommentLines,
33 useReviewCommentTarget,32 useReviewCommentTarget,
34} from "./reviewCommentSelection";33} from "./reviewCommentSelection";
35import { changeTone, DiffTokenText } from "./reviewDiffRendering";34import {
35 changeTone,
36 DiffTokenText,
37 REVIEW_MONO_FONT_FAMILY,
38 ReviewChangeBar,
39} from "./reviewDiffRendering";
36import {40import {
37 highlightReviewSelectedLines,41 highlightReviewSelectedLines,
38 type ReviewDiffTheme,42 type ReviewDiffTheme,
@@ -217,19 +221,18 @@ export function ReviewCommentComposerSheet() { · 178 unmodified lines
217 className={cn("flex-row items-start", changeTone(line.change))}221 className={cn("flex-row items-start", changeTone(line.change))}
218 style={{ height: REVIEW_COMMENT_LINE_HEIGHT }}222 style={{ height: REVIEW_COMMENT_LINE_HEIGHT }}
219 >223 >
220 <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">224 <ReviewChangeBar change={line.change} />
221 {lineNumber ?? ""}
222 </Text>
223 <Text225 <Text
224 className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"226 className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted"
225 style={{ width: 18 }}227 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
226 >228 >
227 {getReviewChangeMarker(line.change)}229 {lineNumber ?? ""}
228 </Text>230 </Text>
229 <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">231 <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">
230 <DiffTokenText232 <DiffTokenText
231 fallback={line.content}233 fallback={line.content}
232 tokens={highlightedLinesById[line.id] ?? null}234 tokens={highlightedLinesById[line.id] ?? null}
235 change={line.change}
233 />236 />
234 </View>237 </View>
235 </View>238 </View>
apps/mobile/src/features/threads/review/ReviewSheet.tsx+632−298
@@ -1,14 +1,17 @@
1import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts";1import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts";
2import { LegendList } from "@legendapp/list/react-native";
3import { useLocalSearchParams, useRouter } from "expo-router";2import { useLocalSearchParams, useRouter } from "expo-router";
4import Stack from "expo-router/stack";3import Stack from "expo-router/stack";
5import { SymbolView } from "expo-symbols";4import { SymbolView } from "expo-symbols";
6import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from "react";5import { memo, type ReactElement, useCallback, useEffect, useMemo, useRef, useState } from "react";
7import {6import {
8 ActivityIndicator,7 ActivityIndicator,
8 FlatList,
9 InteractionManager,
10 type ListRenderItemInfo,
9 Pressable,11 Pressable,
10 ScrollView,12 ScrollView,
11 Text as NativeText,13 Text as NativeText,
14 type ViewToken,
12 useColorScheme,15 useColorScheme,
13 useWindowDimensions,16 useWindowDimensions,
14 View,17 View,
@@ -32,16 +35,19 @@ import { · 17 unmodified lines
32 useReviewCacheForThread,35 useReviewCacheForThread,
33} from "./reviewState";36} from "./reviewState";
34import {37import {
38 buildReviewListItems,
35 getReadyReviewCheckpoints,39 getReadyReviewCheckpoints,
36 buildReviewSectionItems,40 buildReviewSectionItems,
37 getDefaultReviewSectionId,41 getDefaultReviewSectionId,
38 getReviewFilePreviewState,42 getReviewFilePreviewState,
39 getReviewSectionIdForCheckpoint,43 getReviewSectionIdForCheckpoint,
44 type ReviewListItem,
40 type ReviewParsedDiff,45 type ReviewParsedDiff,
41 type ReviewRenderableFile,46 type ReviewRenderableFile,
42 type ReviewRenderableLineRow,47 type ReviewRenderableLineRow,
43} from "./reviewModel";48} from "./reviewModel";
44import {49import {
50 clearReviewHighlightFileCache,
45 getCachedHighlightedReviewFile,51 getCachedHighlightedReviewFile,
46 highlightReviewFile,52 highlightReviewFile,
47 type ReviewDiffTheme,53 type ReviewDiffTheme,
@@ -53,14 +59,17 @@ import { · 5 unmodified lines
53 clearReviewCommentTarget,59 clearReviewCommentTarget,
54 countReviewCommentContexts,60 countReviewCommentContexts,
55 formatReviewSelectedRangeLabel,61 formatReviewSelectedRangeLabel,
56 getReviewChangeMarker,
57 getReviewUnifiedLineNumber,62 getReviewUnifiedLineNumber,
58 getSelectedReviewCommentLines,
59 setReviewCommentTarget,63 setReviewCommentTarget,
60 type ReviewCommentTarget,64 type ReviewCommentTarget,
61 useReviewCommentTarget,65 useReviewCommentTarget,
62} from "./reviewCommentSelection";66} from "./reviewCommentSelection";
63import { changeTone, DiffTokenText } from "./reviewDiffRendering";67import {
68 changeTone,
69 DiffTokenText,
70 REVIEW_MONO_FONT_FAMILY,
71 ReviewChangeBar,
72} from "./reviewDiffRendering";
64 73
65interface PendingCommentSelection {74interface PendingCommentSelection {
66 readonly sectionTitle: string;75 readonly sectionTitle: string;
@@ -77,20 +86,62 @@ interface ReviewLineActionInput { · 10 unmodified lines
77}86}
78 87
79const IOS_NAV_BAR_HEIGHT = 44;88const IOS_NAV_BAR_HEIGHT = 44;
80const REVIEW_HEADER_SPACING = 32;89const REVIEW_HEADER_SPACING = 0;
90const REVIEW_LINE_GUTTER_WIDTH = 62;
91const REVIEW_CHARACTER_WIDTH_ESTIMATE = 8.4;
92const REVIEW_MAX_CONTENT_WIDTH = 4_800;
93const REVIEW_INITIAL_HIGHLIGHT_FILE_COUNT = 2;
94const REVIEW_HIGHLIGHT_BACKTRACK_COUNT = 0;
95const REVIEW_HIGHLIGHT_LOOKAHEAD_COUNT = 1;
96const loggedMissingReviewTokenKeys = new Set<string>();
97
98function isReviewDiffDebugLoggingEnabled(): boolean {
99 return typeof __DEV__ !== "undefined" ? __DEV__ : false;
100}
101
102function 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}
81 114
82function changeTypeLabel(type: ReviewRenderableFile["changeType"]): string {115function getFileHeaderChrome(changeType: ReviewRenderableFile["changeType"]): {
83 switch (type) {116 readonly rail: string;
117 readonly dot: string;
118} {
119 switch (changeType) {
84 case "new":120 case "new":
85 return "Added";121 return {
122 rail: "bg-emerald-400",
123 dot: "bg-emerald-400",
124 };
86 case "deleted":125 case "deleted":
87 return "Deleted";126 return {
127 rail: "bg-rose-400",
128 dot: "bg-rose-400",
129 };
88 case "rename-pure":130 case "rename-pure":
89 return "Renamed";131 return {
132 rail: "bg-amber-400",
133 dot: "bg-amber-400",
134 };
90 case "rename-changed":135 case "rename-changed":
91 return "Renamed + edited";136 return {
137 rail: "bg-sky-400",
138 dot: "bg-sky-400",
139 };
92 default:140 default:
93 return "Edited";141 return {
142 rail: "bg-sky-400",
143 dot: "bg-sky-400",
144 };
94 }145 }
95}146}
96 147
@@ -108,12 +159,70 @@ function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { · 11 unmodified lines
108 };159 };
109}160}
110 161
162function 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
111function getDefaultExpandedFileIds(201function getDefaultExpandedFileIds(
112 files: ReadonlyArray<ReviewRenderableFile>,202 files: ReadonlyArray<ReviewRenderableFile>,
113): ReadonlyArray<string> {203): ReadonlyArray<string> {
114 return files.map((file) => file.id);204 return files.map((file) => file.id);
115}205}
116 206
207function 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
117const ReviewLineRow = memo(function ReviewLineRow(props: {226const ReviewLineRow = memo(function ReviewLineRow(props: {
118 readonly line: ReviewRenderableLineRow;227 readonly line: ReviewRenderableLineRow;
119 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;228 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
@@ -145,17 +254,19 @@ const ReviewLineRow = memo(function ReviewLineRow(props: { · 25 unmodified lines
145 onPress={props.onComment}254 onPress={props.onComment}
146 style={{ minWidth: props.viewportWidth }}255 style={{ minWidth: props.viewportWidth }}
147 >256 >
148 <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted">257 <ReviewChangeBar change={props.line.change} />
149 {lineNumber ?? ""}
150 </Text>
151 <Text258 <Text
152 className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted"259 className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted"
153 style={{ width: 18 }}260 style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }}
154 >261 >
155 {getReviewChangeMarker(props.line.change)}262 {lineNumber ?? ""}
156 </Text>263 </Text>
157 <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1">264 <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 />
159 </View>270 </View>
160 </Pressable>271 </Pressable>
161 );272 );
@@ -165,41 +276,55 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 3 unmodified lines
165 readonly file: ReviewRenderableFile;276 readonly file: ReviewRenderableFile;
166 readonly fileId: string;277 readonly fileId: string;
167 readonly expanded: boolean;278 readonly expanded: boolean;
279 readonly viewportWidth: number;
168 readonly onToggleFile: (fileId: string) => void;280 readonly onToggleFile: (fileId: string) => void;
169}) {281}) {
282 const chrome = getFileHeaderChrome(props.file.changeType);
283
170 return (284 return (
171 <View className="border-b border-border bg-card" style={{ zIndex: 1 }}>285 <View
172 <Pressable286 className="border-b border-border/70 bg-card"
173 className="flex-row items-start gap-2 px-3 py-3"287 style={{
174 onPress={() => props.onToggleFile(props.fileId)}288 zIndex: 1,
175 >289 width: props.viewportWidth,
176 <View className="pt-0.5">290 shadowColor: "#000000",
177 <SymbolView291 shadowOpacity: 0.08,
178 name={props.expanded ? "chevron.down" : "chevron.right"}292 shadowRadius: 10,
179 size={14}293 shadowOffset: { width: 0, height: 6 },
180 tintColor="#8a8a8a"294 elevation: 4,
181 type="monochrome"295 }}
182 />296 >
183 </View>297 <Pressable className="flex-row items-center" onPress={() => props.onToggleFile(props.fileId)}>
184 <View className="min-w-0 flex-1 gap-1">298 <View className={cn("w-1 self-stretch", chrome.rail)} />
185 <Text className="font-mono text-[13px] leading-[18px] text-foreground">299 <View className="flex-1 flex-row items-center px-3 py-1.5">
186 {props.file.path}300 <View className="size-7 items-center justify-center rounded-full bg-subtle">
187 </Text>301 <SymbolView
188 {props.file.previousPath && props.file.previousPath !== props.file.path ? (302 name={props.expanded ? "chevron.down" : "chevron.right"}
189 <Text className="font-mono text-[11px] leading-[16px] text-foreground-muted">303 size={12}
190 {props.file.previousPath}304 tintColor="#8a8a8a"
191 </Text>305 type="monochrome"
192 ) : null}306 />
193 </View>307 </View>
194 <View className="items-end gap-1 pl-2">308 <View className="ml-2 min-w-0 flex-1 flex-row items-center gap-2">
195 <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted">309 <View className={cn("size-2 rounded-full", chrome.dot)} />
196 {changeTypeLabel(props.file.changeType)}310 <View className="min-w-0 flex-1">
197 </Text>311 <Text className="font-mono text-[13px] leading-[18px] text-foreground">
198 <View className="flex-row items-center gap-2">312 {props.file.path}
199 <Text className="text-[12px] font-t3-bold text-emerald-600">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">
200 +{props.file.additions}323 +{props.file.additions}
201 </Text>324 </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>
203 </View>328 </View>
204 </View>329 </View>
205 </Pressable>330 </Pressable>
@@ -207,117 +332,18 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 1 unmodified line
207 );332 );
208});333});
209 334
210const 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
313const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: {335const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: {
314 readonly message: string;336 readonly message: string;
315 readonly actionLabel?: string | null;337 readonly actionLabel?: string | null;
316 readonly fileId: string;338 readonly fileId: string;
339 readonly viewportWidth: number;
317 readonly onLoadDiffFile?: (fileId: string) => void;340 readonly onLoadDiffFile?: (fileId: string) => void;
318}) {341}) {
319 return (342 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 >
321 <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text>347 <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text>
322 {props.actionLabel && props.onLoadDiffFile ? (348 {props.actionLabel && props.onLoadDiffFile ? (
323 <Pressable349 <Pressable
@@ -331,91 +357,21 @@ const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { · 7 unmodified lines
331 );357 );
332});358});
333 359
334const ReviewFileItem = memo(function ReviewFileItem(props: {360const ReviewHunkRow = memo(function ReviewHunkRow(props: {
335 readonly file: ReviewRenderableFile;361 readonly header: string;
336 readonly expanded: boolean;362 readonly context: string | null;
337 readonly sectionTitle: string;
338 readonly selectedTheme: ReviewDiffTheme;
339 readonly pendingSelection: PendingCommentSelection | null;
340 readonly selectedTarget: ReviewCommentTarget | null;
341 readonly viewportWidth: number;363 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;
347}) {364}) {
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
389 return (365 return (
390 <>366 <View
391 <ReviewFileCard367 className="border-b border-border/60 bg-sky-500/10 px-2 py-2"
392 file={props.file}368 style={{ minWidth: props.viewportWidth }}
393 fileId={props.file.id}369 >
394 expanded={props.expanded}370 <Text className="font-mono text-[12px] leading-[18px] text-sky-700 dark:text-sky-300">
395 onToggleFile={props.onToggleFile}371 {props.header}
396 />372 {props.context ? ` ${props.context}` : ""}
397 {props.expanded ? (373 </Text>
398 shouldRenderBody ? (374 </View>
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 </>
419 );375 );
420});376});
421 377
@@ -496,7 +452,18 @@ export function ReviewSheet() { · 74 unmodified lines
496 const [error, setError] = useState<string | null>(null);452 const [error, setError] = useState<string | null>(null);
497 const [pendingCommentSelection, setPendingCommentSelection] =453 const [pendingCommentSelection, setPendingCommentSelection] =
498 useState<PendingCommentSelection | null>(null);454 useState<PendingCommentSelection | null>(null);
455 const [highlightedFilesById, setHighlightedFilesById] = useState<
456 Record<string, ReviewHighlightedFile | null>
457 >({});
458 const [visibleFileIds, setVisibleFileIds] = useState<ReadonlyArray<string>>([]);
499 const activeCommentTarget = useReviewCommentTarget();459 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);
500 467
501 const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null;468 const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null;
502 const readyCheckpoints = useMemo(469 const readyCheckpoints = useMemo(
@@ -544,7 +511,6 @@ export function ReviewSheet() { · 41 unmodified lines
544 [draftMessage],511 [draftMessage],
545 );512 );
546 513
547 const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme;
548 const expandedFileIds = useMemo(514 const expandedFileIds = useMemo(
549 () =>515 () =>
550 selectedSection?.id && parsedDiff.kind === "files"516 selectedSection?.id && parsedDiff.kind === "files"
@@ -560,6 +526,120 @@ export function ReviewSheet() { · 9 unmodified lines
560 : [],526 : [],
561 [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id],527 [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id],
562 );528 );
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 );
563 const loadGitDiffs = useCallback(async () => {643 const loadGitDiffs = useCallback(async () => {
564 if (!environmentId || !cwd) {644 if (!environmentId || !cwd) {
565 return;645 return;
@@ -634,6 +714,100 @@ export function ReviewSheet() { · 68 unmodified lines
634 void loadGitDiffs();714 void loadGitDiffs();
635 }, [loadGitDiffs]);715 }, [loadGitDiffs]);
636 716
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
637 useEffect(() => {811 useEffect(() => {
638 if (reviewSections.length === 0) {812 if (reviewSections.length === 0) {
639 return;813 return;
@@ -711,6 +885,71 @@ export function ReviewSheet() { · 71 unmodified lines
711 });885 });
712 }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);886 }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]);
713 887
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
714 const refreshSelectedSection = useCallback(async () => {953 const refreshSelectedSection = useCallback(async () => {
715 if (!selectedSection) {954 if (!selectedSection) {
716 return;955 return;
@@ -841,77 +1080,166 @@ export function ReviewSheet() { · 124 unmodified lines
841 1080
842 return <>{children}</>;1081 return <>{children}</>;
843 }, [error, parsedDiffNotice]);1082 }, [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 );
844 1112
845 const renderFileItem = useCallback(1113 const renderReviewListItem = useCallback(
846 ({ item }: { item: ReviewRenderableFile; index: number }) => {1114 ({ item }: ListRenderItemInfo<ReviewListItem>) => {
847 if (!selectedSection) {1115 if (!selectedSection) {
848 return null;1116 return null;
849 }1117 }
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;
862 1118
863 return (1119 switch (item.kind) {
864 <ReviewFileItem1120 case "file-header":
865 file={item}1121 return (
866 expanded={expandedFileIds.includes(item.id)}1122 <ReviewFileCard
867 sectionTitle={selectedSection.title}1123 file={item.file}
868 selectedTheme={selectedTheme}1124 fileId={item.fileId}
869 pendingSelection={pendingSelectionForFile}1125 expanded={item.expanded}
870 selectedTarget={selectedTargetForFile}1126 viewportWidth={reviewListContentWidth}
871 viewportWidth={Math.max(width, 280)}1127 onToggleFile={handleToggleExpandedFile}
872 revealedLarge={revealedLargeFileIds.includes(item.id)}1128 />
873 onToggleFile={handleToggleExpandedFile}1129 );
874 onLoadDiffFile={handleRevealLargeDiff}1130 case "file-suppressed":
875 onPressLine={handlePressLine}1131 return (
876 onStartRangeSelection={handleStartRangeSelection}1132 <ReviewFileSuppressedBody
877 />1133 message={item.message}
878 );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 }
879 },1225 },
880 [1226 [
881 activeCommentTarget,1227 activeCommentTarget,
882 expandedFileIds,
883 handlePressLine,1228 handlePressLine,
884 handleRevealLargeDiff,1229 handleRevealLargeDiff,
885 handleStartRangeSelection,1230 handleStartRangeSelection,
886 handleToggleExpandedFile,1231 handleToggleExpandedFile,
1232 highlightedFilesById,
887 pendingCommentSelection,1233 pendingCommentSelection,
888 revealedLargeFileIds,1234 reviewFileById,
1235 reviewLineIndexByRowId,
1236 reviewLineRowsByFileId,
1237 reviewListContentWidth,
889 selectedSection,1238 selectedSection,
890 selectedTheme,1239 selectedTheme,
891 width,
892 ],1240 ],
893 );1241 );
894 1242
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
915 return (1243 return (
916 <>1244 <>
917 <Stack.Screen1245 <Stack.Screen
@@ -1045,20 +1373,26 @@ export function ReviewSheet() { · 127 unmodified lines
1045 1373
1046 <View className="flex-1 bg-sheet">1374 <View className="flex-1 bg-sheet">
1047 {selectedSection && parsedDiff.kind === "files" ? (1375 {selectedSection && parsedDiff.kind === "files" ? (
1048 <LegendList1376 <FlatList
1049 style={{ flex: 1 }}1377 style={{ flex: 1 }}
1050 contentInsetAdjustmentBehavior="never"1378 contentInsetAdjustmentBehavior="never"
1051 contentInset={{ top: topContentInset }}1379 contentInset={{ top: topContentInset }}
1380 contentOffset={{ x: 0, y: -topContentInset }}
1052 scrollIndicatorInsets={{ top: topContentInset }}1381 scrollIndicatorInsets={{ top: topContentInset }}
1053 data={parsedDiff.files as ReviewRenderableFile[]}1382 data={reviewListItems}
1054 renderItem={renderFileItem}1383 renderItem={renderReviewListItem}
1055 keyExtractor={(file) => file.id}1384 keyExtractor={(item) => item.id}
1056 extraData={visibleListExtraData}
1057 keyboardShouldPersistTaps="handled"1385 keyboardShouldPersistTaps="handled"
1058 estimatedItemSize={220}
1059 drawDistance={900}
1060 recycleItems
1061 ListHeaderComponent={listHeader}1386 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}
1062 contentContainerStyle={{1396 contentContainerStyle={{
1063 paddingTop: REVIEW_HEADER_SPACING,1397 paddingTop: REVIEW_HEADER_SPACING,
1064 paddingBottom: Math.max(insets.bottom, 18) + 18,1398 paddingBottom: Math.max(insets.bottom, 18) + 18,
apps/mobile/src/features/threads/review/reviewDiffRendering.tsx+41−3
@@ -1,4 +1,4 @@
1import { Platform, Text as NativeText } from "react-native";1import { Platform, Text as NativeText, View } from "react-native";
2 2
3import { cn } from "../../../lib/cn";3import { cn } from "../../../lib/cn";
4 4
@@ -17,14 +17,47 @@ export function renderVisibleWhitespace(value: string): string { · 12 unmodified lines
17}17}
18 18
19export function changeTone(change: ReviewRenderableLineRow["change"]): string {19export function changeTone(change: ReviewRenderableLineRow["change"]): string {
20 if (change === "add") return "bg-emerald-500/12";20 if (change === "add") return "bg-emerald-500/10";
21 if (change === "delete") return "bg-rose-500/12";21 if (change === "delete") return "bg-rose-500/10";
22 return "bg-card";22 return "bg-card";
23}23}
24 24
25export 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
31function 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
37export 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
25export function DiffTokenText(props: {57export function DiffTokenText(props: {
26 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;58 readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null;
27 readonly fallback: string;59 readonly fallback: string;
60 readonly change?: ReviewRenderableLineRow["change"];
28 readonly className?: string;61 readonly className?: string;
29}) {62}) {
30 if (!props.tokens || props.tokens.length === 0) {63 if (!props.tokens || props.tokens.length === 0) {
@@ -70,6 +103,11 @@ export function DiffTokenText(props: { · 39 unmodified lines
70 fontFamily: REVIEW_MONO_FONT_FAMILY,103 fontFamily: REVIEW_MONO_FONT_FAMILY,
71 fontWeight,104 fontWeight,
72 fontStyle,105 fontStyle,
106 backgroundColor:
107 token.diffHighlight && props.change
108 ? diffHighlightColor(props.change)
109 : undefined,
110 borderRadius: token.diffHighlight ? 4 : undefined,
73 }}111 }}
74 >112 >
75 {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "}113 {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
13 13
14 it("accepts supported values", () => {14 it("accepts supported values", () => {
15 expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");15 expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript");
16 expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript");
16 expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");17 expect(resolveReviewHighlighterEnginePreference("native")).toBe("native");
17 });18 });
18});19});
apps/mobile/src/features/threads/review/reviewHighlighterEngine.ts+3−1
@@ -5,9 +5,11 @@ export function resolveReviewHighlighterEnginePreference( · 4 unmodified lines
5 value: string | undefined,5 value: string | undefined,
6): ReviewHighlighterEnginePreference {6): ReviewHighlighterEnginePreference {
7 switch (value) {7 switch (value) {
8 case "js":
8 case "javascript":9 case "javascript":
10 return "javascript";
9 case "native":11 case "native":
10 return value;12 return "native";
11 default:13 default:
12 return "javascript";14 return "javascript";
13 }15 }
apps/mobile/src/features/threads/review/reviewModel.test.ts+75−0
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9 9
10import {10import {
11 buildReviewListItems,
11 buildReviewParsedDiff,12 buildReviewParsedDiff,
12 buildReviewSectionItems,13 buildReviewSectionItems,
13 getDefaultReviewSectionId,14 getDefaultReviewSectionId,
@@ -138,6 +139,7 @@ describe("buildReviewParsedDiff", () => { · 124 unmodified lines
138 oldLineNumber: 1,139 oldLineNumber: 1,
139 newLineNumber: null,140 newLineNumber: null,
140 content: "const before = 1;",141 content: "const before = 1;",
142 comparison: { change: "add", tokenIndex: 0 },
141 }),143 }),
142 expect.objectContaining({144 expect.objectContaining({
143 kind: "line",145 kind: "line",
@@ -145,6 +147,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
145 oldLineNumber: null,147 oldLineNumber: null,
146 newLineNumber: 1,148 newLineNumber: 1,
147 content: "const after = 2;",149 content: "const after = 2;",
150 comparison: { change: "delete", tokenIndex: 0 },
148 }),151 }),
149 expect.objectContaining({152 expect.objectContaining({
150 kind: "line",153 kind: "line",
@@ -152,6 +155,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
152 oldLineNumber: null,155 oldLineNumber: null,
153 newLineNumber: 2,156 newLineNumber: 2,
154 content: "console.log(after);",157 content: "console.log(after);",
158 comparison: null,
155 }),159 }),
156 expect.objectContaining({160 expect.objectContaining({
157 kind: "line",161 kind: "line",
@@ -159,6 +163,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line
159 oldLineNumber: 2,163 oldLineNumber: 2,
160 newLineNumber: 3,164 newLineNumber: 3,
161 content: "return true;",165 content: "return true;",
166 comparison: null,
162 }),167 }),
163 ]);168 ]);
164 });169 });
@@ -220,6 +225,7 @@ describe("buildReviewParsedDiff", () => { · 55 unmodified lines
220 content: `const line${index} = ${index};`,225 content: `const line${index} = ${index};`,
221 additionTokenIndex: index,226 additionTokenIndex: index,
222 deletionTokenIndex: null,227 deletionTokenIndex: null,
228 comparison: null,
223 })),229 })),
224 }),230 }),
225 );231 );
@@ -231,4 +237,73 @@ describe("buildReviewParsedDiff", () => { · 5 unmodified lines
231 actionLabel: "Load diff",237 actionLabel: "Load diff",
232 });238 });
233 });239 });
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 });
234});309});
apps/mobile/src/features/threads/review/reviewModel.ts+125−0
@@ -30,6 +30,10 @@ export interface ReviewRenderableLineRow { · 29 unmodified lines
30 readonly content: string;30 readonly content: string;
31 readonly additionTokenIndex: number | null;31 readonly additionTokenIndex: number | null;
32 readonly deletionTokenIndex: number | null;32 readonly deletionTokenIndex: number | null;
33 readonly comparison: {
34 readonly change: "add" | "delete";
35 readonly tokenIndex: number;
36 } | null;
33}37}
34 38
35export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;39export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow;
@@ -48,6 +52,42 @@ export interface ReviewRenderableFile { · 12 unmodified lines
48 readonly rows: ReadonlyArray<ReviewRenderableRow>;52 readonly rows: ReadonlyArray<ReviewRenderableRow>;
49}53}
50 54
55export 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
63export 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
71export interface ReviewHunkListItem {
72 readonly kind: "hunk";
73 readonly id: string;
74 readonly fileId: string;
75 readonly row: ReviewRenderableHunkRow;
76}
77
78export interface ReviewLineListItem {
79 readonly kind: "line";
80 readonly id: string;
81 readonly fileId: string;
82 readonly row: ReviewRenderableLineRow;
83}
84
85export type ReviewListItem =
86 | ReviewFileHeaderListItem
87 | ReviewFileSuppressedListItem
88 | ReviewHunkListItem
89 | ReviewLineListItem;
90
51export type ReviewFilePreviewState =91export type ReviewFilePreviewState =
52 | {92 | {
53 readonly kind: "render";93 readonly kind: "render";
@@ -267,6 +307,72 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil · 213 unmodified lines
267 return { kind: "render" };307 return { kind: "render" };
268}308}
269 309
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
316export 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
270function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {376function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string {
271 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;377 return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`;
272}378}
@@ -304,6 +410,7 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 31 unmodified lines
304 ),410 ),
305 additionTokenIndex,411 additionTokenIndex,
306 deletionTokenIndex,412 deletionTokenIndex,
413 comparison: null,
307 });414 });
308 deletionLineNumber += 1;415 deletionLineNumber += 1;
309 additionLineNumber += 1;416 additionLineNumber += 1;
@@ -313,6 +420,10 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 3 unmodified lines
313 return;420 return;
314 }421 }
315 422
423 const pairedLineCount = Math.min(segment.deletions, segment.additions);
424 const deletionTokenIndexStart = deletionTokenIndex;
425 const additionTokenIndexStart = additionTokenIndex;
426
316 for (let index = 0; index < segment.deletions; index += 1) {427 for (let index = 0; index < segment.deletions; index += 1) {
317 rows.push({428 rows.push({
318 kind: "line",429 kind: "line",
@@ -323,6 +434,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 4 unmodified lines
323 content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""),434 content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""),
324 additionTokenIndex: null,435 additionTokenIndex: null,
325 deletionTokenIndex,436 deletionTokenIndex,
437 comparison:
438 index < pairedLineCount
439 ? {
440 change: "add",
441 tokenIndex: additionTokenIndexStart + index,
442 }
443 : null,
326 });444 });
327 deletionLineNumber += 1;445 deletionLineNumber += 1;
328 deletionTokenIndex += 1;446 deletionTokenIndex += 1;
@@ -338,6 +456,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 9 unmodified lines
338 content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""),456 content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""),
339 additionTokenIndex,457 additionTokenIndex,
340 deletionTokenIndex: null,458 deletionTokenIndex: null,
459 comparison:
460 index < pairedLineCount
461 ? {
462 change: "delete",
463 tokenIndex: deletionTokenIndexStart + index,
464 }
465 : null,
341 });466 });
342 additionLineNumber += 1;467 additionLineNumber += 1;
343 additionTokenIndex += 1;468 additionTokenIndex += 1;
apps/mobile/src/features/threads/review/reviewWordDiffs.test.ts+62−0
@@ -0,0 +1,62 @@
1import { describe, expect, it } from "vitest";
2
3import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
4
5describe("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
30describe("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 @@
1import { diffWordsWithSpace } from "diff";
2
3import type { ReviewHighlightedToken } from "./shikiReviewHighlighter";
4
5interface ReviewDiffOperation {
6 readonly value: string;
7 readonly added?: true;
8 readonly removed?: true;
9}
10
11interface ReviewDiffHighlightRange {
12 readonly start: number;
13 readonly end: number;
14}
15
16const REVIEW_MAX_WORD_ALT_LINE_LENGTH = 1_000;
17
18function cleanLineEnding(value: string): string {
19 return value.endsWith("\n") ? value.slice(0, -1) : value;
20}
21
22function 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
49function 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
66function 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
87function 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
116export 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
189export 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
41 expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true);41 expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true);
42 expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true);42 expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true);
43 });43 });
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 });
44});113});
apps/mobile/src/features/threads/review/shikiReviewHighlighter.ts+278−13
@@ -8,13 +8,15 @@ import { · 7 unmodified lines
8 type ReviewHighlighterEngine,8 type ReviewHighlighterEngine,
9} from "./reviewHighlighterEngine";9} from "./reviewHighlighterEngine";
10import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";10import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel";
11import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs";
11 12
12export type ReviewDiffTheme = "light" | "dark";13export type ReviewDiffTheme = "light" | "dark";
13 14
14export interface ReviewHighlightedToken {15export interface ReviewHighlightedToken {
15 readonly content: string;16 content: string;
16 readonly color: string | null;17 readonly color: string | null;
17 readonly fontStyle: number | null;18 readonly fontStyle: number | null;
19 readonly diffHighlight?: boolean;
18}20}
19 21
20export interface ReviewHighlightedFile {22export interface ReviewHighlightedFile {
@@ -26,13 +28,19 @@ const SHIKI_THEME_NAME_BY_SCHEME = { · 5 unmodified lines
26 light: "github-light-default",28 light: "github-light-default",
27 dark: "github-dark-default",29 dark: "github-dark-default",
28} as const;30} as const;
31const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE =
32 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript";
29const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(33const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference(
30 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript",34 REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
31);35);
32const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag(36const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag(
33 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE,37 process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE,
34 false,38 false,
35);39);
40const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8;
41const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 40;
42const REVIEW_HIGHLIGHT_CHUNK_SIZE = 20;
43const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000;
36const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();44const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>();
37const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();45const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>();
38const loadedLanguages = new Set<string>(["text"]);46const loadedLanguages = new Set<string>(["text"]);
@@ -205,6 +213,17 @@ function joinPatchLines(lines: ReadonlyArray<string>): string { · 166 unmodified lines
205 return lines.map(stripTrailingNewline).join("\n");213 return lines.map(stripTrailingNewline).join("\n");
206}214}
207 215
216function 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
208async function getHighlighter(): Promise<HighlighterCore> {227async function getHighlighter(): Promise<HighlighterCore> {
209 if (!highlighterPromise) {228 if (!highlighterPromise) {
210 const configuredHighlighterPromise = Promise.all([229 const configuredHighlighterPromise = Promise.all([
@@ -214,6 +233,7 @@ async function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines
214 let nativeEngineAvailable = false;233 let nativeEngineAvailable = false;
215 234
216 logReviewHighlighterDiagnostic("initializing", {235 logReviewHighlighterDiagnostic("initializing", {
236 configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE,
217 preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,237 preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE,
218 resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE,238 resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE,
219 });239 });
@@ -379,6 +399,146 @@ function normalizeHighlightedLines( · 159 unmodified lines
379 );399 );
380}400}
381 401
402function 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
468function 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
382async function highlightLines(542async function highlightLines(
383 code: string,543 code: string,
384 language: string,544 language: string,
@@ -389,14 +549,76 @@ async function highlightLines( · 4 unmodified lines
389 }549 }
390 550
391 const highlighter = await getHighlighter();551 const highlighter = await getHighlighter();
392 const tokenLines = highlighter.codeToTokensBase(code, { lang: language, theme });552 const sourceLines = code.split("\n");
393 return normalizeHighlightedLines(tokenLines);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;
394}595}
395 596
396function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {597function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string {
397 return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`;598 return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`;
398}599}
399 600
601function 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
617export function clearReviewHighlightFileCache(): void {
618 highlightCache.clear();
619 resolvedHighlightCache.clear();
620}
621
400export function getCachedHighlightedReviewFile(622export function getCachedHighlightedReviewFile(
401 file: ReviewRenderableFile,623 file: ReviewRenderableFile,
402 theme: ReviewDiffTheme,624 theme: ReviewDiffTheme,
@@ -417,32 +639,72 @@ export async function highlightReviewFile( · 14 unmodified lines
417 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {639 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
418 const resolved = resolvedHighlightCache.get(cacheKey);640 const resolved = resolvedHighlightCache.get(cacheKey);
419 if (resolved) {641 if (resolved) {
642 logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", {
643 fileId: file.id,
644 filePath: file.path,
645 theme,
646 });
420 return resolved;647 return resolved;
421 }648 }
422 const cached = highlightCache.get(cacheKey);649 const cached = highlightCache.get(cacheKey);
423 if (cached) {650 if (cached) {
651 logReviewHighlighterDiagnostic("file highlight cache hit (pending)", {
652 fileId: file.id,
653 filePath: file.path,
654 theme,
655 });
424 return cached;656 return cached;
425 }657 }
426 }658 }
427 659
428 const promise = (async () => {660 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 });
429 const language = await resolveLanguage(file);670 const language = await resolveLanguage(file);
430 const [additionLines, deletionLines] = await Promise.all([671 const additionLines = await highlightLines(
431 highlightLines(joinPatchLines(file.additionLines), language, shikiTheme),672 joinPatchLines(file.additionLines),
432 highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme),673 language,
433 ]);674 shikiTheme,
434 675 );
435 const highlighted = { additionLines, deletionLines };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 });
436 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {685 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
437 resolvedHighlightCache.set(cacheKey, highlighted);686 storeResolvedHighlightedFile(cacheKey, highlighted);
438 }687 }
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 });
439 return highlighted;697 return highlighted;
440 })();698 })();
441 699
442 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {700 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
443 highlightCache.set(cacheKey, promise);701 highlightCache.set(cacheKey, promise);
444 }702 }
445 return promise;703 return promise.finally(() => {
704 if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) {
705 highlightCache.delete(cacheKey);
706 }
707 });
446}708}
447 709
448export async function highlightReviewSelectedLines(input: {710export async function highlightReviewSelectedLines(input: {
@@ -483,5 +745,8 @@ export async function highlightReviewSelectedLines(input: { · 34 unmodified lines
483 additionIndex += 1;745 additionIndex += 1;
484 });746 });
485 747
486 return tokenMap;748 return applyWordAltDiffHighlightsToSelectedLines({
749 lines: input.lines,
750 tokenMap,
751 });
487}752}
bun.lock+1−0
@@ -66,6 +66,7 @@ · 65 unmodified lines
66 "@t3tools/contracts": "workspace:*",66 "@t3tools/contracts": "workspace:*",
67 "@t3tools/shared": "workspace:*",67 "@t3tools/shared": "workspace:*",
68 "clsx": "^2.1.1",68 "clsx": "^2.1.1",
69 "diff": "8.0.3",
69 "effect": "catalog:",70 "effect": "catalog:",
70 "expo": "~55.0.14",71 "expo": "~55.0.14",
71 "expo-camera": "~55.0.15",72 "expo-camera": "~55.0.15",