- Switch review screen to a virtualized list layout - Queue diff highlighting and default to the JS engine - Add word-diff helpers and tests
Unified · Split
| @@ -36,6 +36,14 @@ Build and run the local iOS preview app: · 35 unmodified lines | ||
| 36 | 36 | bun run ios:preview |
| 37 | 37 | ``` |
| 38 | 38 | |
| 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 | + | |
| 39 | 47 | Inspect the resolved Expo config for a variant: |
| 40 | 48 | |
| 41 | 49 | ```bash |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | ||
| 47 | 47 | "@t3tools/contracts": "workspace:*", |
| 48 | 48 | "@t3tools/shared": "workspace:*", |
| 49 | 49 | "clsx": "^2.1.1", |
| 50 | + "diff": "8.0.3", | |
| 50 | 51 | "effect": "catalog:", |
| 51 | 52 | "expo": "~55.0.14", |
| 52 | 53 | "expo-camera": "~55.0.15", |
| @@ -27,12 +27,16 @@ import { appendReviewCommentToDraft } from "../use-thread-composer-state"; · 26 unmodified lines | ||
| 27 | 27 | import { |
| 28 | 28 | clearReviewCommentTarget, |
| 29 | 29 | formatReviewCommentContext, |
| 30 | − getReviewChangeMarker, | |
| 31 | 30 | getReviewUnifiedLineNumber, |
| 32 | 31 | getSelectedReviewCommentLines, |
| 33 | 32 | useReviewCommentTarget, |
| 34 | 33 | } from "./reviewCommentSelection"; |
| 35 | −import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | |
| 34 | +import { | |
| 35 | + changeTone, | |
| 36 | + DiffTokenText, | |
| 37 | + REVIEW_MONO_FONT_FAMILY, | |
| 38 | + ReviewChangeBar, | |
| 39 | +} from "./reviewDiffRendering"; | |
| 36 | 40 | import { |
| 37 | 41 | highlightReviewSelectedLines, |
| 38 | 42 | type ReviewDiffTheme, |
| @@ -217,19 +221,18 @@ export function ReviewCommentComposerSheet() { · 178 unmodified lines | ||
| 217 | 221 | className={cn("flex-row items-start", changeTone(line.change))} |
| 218 | 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"> | |
| 221 | − {lineNumber ?? ""} | |
| 222 | − </Text> | |
| 224 | + <ReviewChangeBar change={line.change} /> | |
| 223 | 225 | <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 }} | |
| 226 | 228 | > |
| 227 | − {getReviewChangeMarker(line.change)} | |
| 229 | + {lineNumber ?? ""} | |
| 228 | 230 | </Text> |
| 229 | 231 | <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> |
| 230 | 232 | <DiffTokenText |
| 231 | 233 | fallback={line.content} |
| 232 | 234 | tokens={highlightedLinesById[line.id] ?? null} |
| 235 | + change={line.change} | |
| 233 | 236 | /> |
| 234 | 237 | </View> |
| 235 | 238 | </View> |
| @@ -1,14 +1,17 @@ | ||
| 1 | 1 | import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts"; |
| 2 | −import { LegendList } from "@legendapp/list/react-native"; | |
| 3 | 2 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 4 | 3 | import Stack from "expo-router/stack"; |
| 5 | 4 | 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"; | |
| 7 | 6 | import { |
| 8 | 7 | ActivityIndicator, |
| 8 | + FlatList, | |
| 9 | + InteractionManager, | |
| 10 | + type ListRenderItemInfo, | |
| 9 | 11 | Pressable, |
| 10 | 12 | ScrollView, |
| 11 | 13 | Text as NativeText, |
| 14 | + type ViewToken, | |
| 12 | 15 | useColorScheme, |
| 13 | 16 | useWindowDimensions, |
| 14 | 17 | View, |
| @@ -32,16 +35,19 @@ import { · 17 unmodified lines | ||
| 32 | 35 | useReviewCacheForThread, |
| 33 | 36 | } from "./reviewState"; |
| 34 | 37 | import { |
| 38 | + buildReviewListItems, | |
| 35 | 39 | getReadyReviewCheckpoints, |
| 36 | 40 | buildReviewSectionItems, |
| 37 | 41 | getDefaultReviewSectionId, |
| 38 | 42 | getReviewFilePreviewState, |
| 39 | 43 | getReviewSectionIdForCheckpoint, |
| 44 | + type ReviewListItem, | |
| 40 | 45 | type ReviewParsedDiff, |
| 41 | 46 | type ReviewRenderableFile, |
| 42 | 47 | type ReviewRenderableLineRow, |
| 43 | 48 | } from "./reviewModel"; |
| 44 | 49 | import { |
| 50 | + clearReviewHighlightFileCache, | |
| 45 | 51 | getCachedHighlightedReviewFile, |
| 46 | 52 | highlightReviewFile, |
| 47 | 53 | type ReviewDiffTheme, |
| @@ -53,14 +59,17 @@ import { · 5 unmodified lines | ||
| 53 | 59 | clearReviewCommentTarget, |
| 54 | 60 | countReviewCommentContexts, |
| 55 | 61 | formatReviewSelectedRangeLabel, |
| 56 | − getReviewChangeMarker, | |
| 57 | 62 | getReviewUnifiedLineNumber, |
| 58 | − getSelectedReviewCommentLines, | |
| 59 | 63 | setReviewCommentTarget, |
| 60 | 64 | type ReviewCommentTarget, |
| 61 | 65 | useReviewCommentTarget, |
| 62 | 66 | } from "./reviewCommentSelection"; |
| 63 | −import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | |
| 67 | +import { | |
| 68 | + changeTone, | |
| 69 | + DiffTokenText, | |
| 70 | + REVIEW_MONO_FONT_FAMILY, | |
| 71 | + ReviewChangeBar, | |
| 72 | +} from "./reviewDiffRendering"; | |
| 64 | 73 | |
| 65 | 74 | interface PendingCommentSelection { |
| 66 | 75 | readonly sectionTitle: string; |
| @@ -77,20 +86,62 @@ interface ReviewLineActionInput { · 10 unmodified lines | ||
| 77 | 86 | } |
| 78 | 87 | |
| 79 | 88 | 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 | +} | |
| 81 | 114 | |
| 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) { | |
| 84 | 120 | case "new": |
| 85 | − return "Added"; | |
| 121 | + return { | |
| 122 | + rail: "bg-emerald-400", | |
| 123 | + dot: "bg-emerald-400", | |
| 124 | + }; | |
| 86 | 125 | case "deleted": |
| 87 | − return "Deleted"; | |
| 126 | + return { | |
| 127 | + rail: "bg-rose-400", | |
| 128 | + dot: "bg-rose-400", | |
| 129 | + }; | |
| 88 | 130 | case "rename-pure": |
| 89 | − return "Renamed"; | |
| 131 | + return { | |
| 132 | + rail: "bg-amber-400", | |
| 133 | + dot: "bg-amber-400", | |
| 134 | + }; | |
| 90 | 135 | case "rename-changed": |
| 91 | − return "Renamed + edited"; | |
| 136 | + return { | |
| 137 | + rail: "bg-sky-400", | |
| 138 | + dot: "bg-sky-400", | |
| 139 | + }; | |
| 92 | 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 | |
| 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 | + | |
| 111 | 201 | function getDefaultExpandedFileIds( |
| 112 | 202 | files: ReadonlyArray<ReviewRenderableFile>, |
| 113 | 203 | ): ReadonlyArray<string> { |
| 114 | 204 | return files.map((file) => file.id); |
| 115 | 205 | } |
| 116 | 206 | |
| 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 | + | |
| 117 | 226 | const ReviewLineRow = memo(function ReviewLineRow(props: { |
| 118 | 227 | readonly line: ReviewRenderableLineRow; |
| 119 | 228 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| @@ -145,17 +254,19 @@ const ReviewLineRow = memo(function ReviewLineRow(props: { · 25 unmodified lines | ||
| 145 | 254 | onPress={props.onComment} |
| 146 | 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"> | |
| 149 | − {lineNumber ?? ""} | |
| 150 | − </Text> | |
| 257 | + <ReviewChangeBar change={props.line.change} /> | |
| 151 | 258 | <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 }} | |
| 154 | 261 | > |
| 155 | − {getReviewChangeMarker(props.line.change)} | |
| 262 | + {lineNumber ?? ""} | |
| 156 | 263 | </Text> |
| 157 | 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 | 270 | </View> |
| 160 | 271 | </Pressable> |
| 161 | 272 | ); |
| @@ -165,41 +276,55 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 3 unmodified lines | ||
| 165 | 276 | readonly file: ReviewRenderableFile; |
| 166 | 277 | readonly fileId: string; |
| 167 | 278 | readonly expanded: boolean; |
| 279 | + readonly viewportWidth: number; | |
| 168 | 280 | readonly onToggleFile: (fileId: string) => void; |
| 169 | 281 | }) { |
| 282 | + const chrome = getFileHeaderChrome(props.file.changeType); | |
| 283 | + | |
| 170 | 284 | 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"> | |
| 200 | 323 | +{props.file.additions} |
| 201 | 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 | 328 | </View> |
| 204 | 329 | </View> |
| 205 | 330 | </Pressable> |
| @@ -207,117 +332,18 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 1 unmodified line | ||
| 207 | 332 | ); |
| 208 | 333 | }); |
| 209 | 334 | |
| 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 | − | |
| 313 | 335 | const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { |
| 314 | 336 | readonly message: string; |
| 315 | 337 | readonly actionLabel?: string | null; |
| 316 | 338 | readonly fileId: string; |
| 339 | + readonly viewportWidth: number; | |
| 317 | 340 | readonly onLoadDiffFile?: (fileId: string) => void; |
| 318 | 341 | }) { |
| 319 | 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 | 347 | <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text> |
| 322 | 348 | {props.actionLabel && props.onLoadDiffFile ? ( |
| 323 | 349 | <Pressable |
| @@ -331,91 +357,21 @@ const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { · 7 unmodified lines | ||
| 331 | 357 | ); |
| 332 | 358 | }); |
| 333 | 359 | |
| 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; | |
| 341 | 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 | 365 | 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> | |
| 419 | 375 | ); |
| 420 | 376 | }); |
| 421 | 377 | |
| @@ -496,7 +452,18 @@ export function ReviewSheet() { · 74 unmodified lines | ||
| 496 | 452 | const [error, setError] = useState<string | null>(null); |
| 497 | 453 | const [pendingCommentSelection, setPendingCommentSelection] = |
| 498 | 454 | useState<PendingCommentSelection | null>(null); |
| 455 | + const [highlightedFilesById, setHighlightedFilesById] = useState< | |
| 456 | + Record<string, ReviewHighlightedFile | null> | |
| 457 | + >({}); | |
| 458 | + const [visibleFileIds, setVisibleFileIds] = useState<ReadonlyArray<string>>([]); | |
| 499 | 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 | 468 | const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null; |
| 502 | 469 | const readyCheckpoints = useMemo( |
| @@ -544,7 +511,6 @@ export function ReviewSheet() { · 41 unmodified lines | ||
| 544 | 511 | [draftMessage], |
| 545 | 512 | ); |
| 546 | 513 | |
| 547 | − const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | |
| 548 | 514 | const expandedFileIds = useMemo( |
| 549 | 515 | () => |
| 550 | 516 | selectedSection?.id && parsedDiff.kind === "files" |
| @@ -560,6 +526,120 @@ export function ReviewSheet() { · 9 unmodified lines | ||
| 560 | 526 | : [], |
| 561 | 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 | 643 | const loadGitDiffs = useCallback(async () => { |
| 564 | 644 | if (!environmentId || !cwd) { |
| 565 | 645 | return; |
| @@ -634,6 +714,100 @@ export function ReviewSheet() { · 68 unmodified lines | ||
| 634 | 714 | void loadGitDiffs(); |
| 635 | 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 | 811 | useEffect(() => { |
| 638 | 812 | if (reviewSections.length === 0) { |
| 639 | 813 | return; |
| @@ -711,6 +885,71 @@ export function ReviewSheet() { · 71 unmodified lines | ||
| 711 | 885 | }); |
| 712 | 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 | 953 | const refreshSelectedSection = useCallback(async () => { |
| 715 | 954 | if (!selectedSection) { |
| 716 | 955 | return; |
| @@ -841,77 +1080,166 @@ export function ReviewSheet() { · 124 unmodified lines | ||
| 841 | 1080 | |
| 842 | 1081 | return <>{children}</>; |
| 843 | 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( | |
| 846 | − ({ item }: { item: ReviewRenderableFile; index: number }) => { | |
| 1113 | + const renderReviewListItem = useCallback( | |
| 1114 | + ({ item }: ListRenderItemInfo<ReviewListItem>) => { | |
| 847 | 1115 | if (!selectedSection) { |
| 848 | 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 ( | |
| 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 | + } | |
| 879 | 1225 | }, |
| 880 | 1226 | [ |
| 881 | 1227 | activeCommentTarget, |
| 882 | − expandedFileIds, | |
| 883 | 1228 | handlePressLine, |
| 884 | 1229 | handleRevealLargeDiff, |
| 885 | 1230 | handleStartRangeSelection, |
| 886 | 1231 | handleToggleExpandedFile, |
| 1232 | + highlightedFilesById, | |
| 887 | 1233 | pendingCommentSelection, |
| 888 | − revealedLargeFileIds, | |
| 1234 | + reviewFileById, | |
| 1235 | + reviewLineIndexByRowId, | |
| 1236 | + reviewLineRowsByFileId, | |
| 1237 | + reviewListContentWidth, | |
| 889 | 1238 | selectedSection, |
| 890 | 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 | 1243 | return ( |
| 916 | 1244 | <> |
| 917 | 1245 | <Stack.Screen |
| @@ -1045,20 +1373,26 @@ export function ReviewSheet() { · 127 unmodified lines | ||
| 1045 | 1373 | |
| 1046 | 1374 | <View className="flex-1 bg-sheet"> |
| 1047 | 1375 | {selectedSection && parsedDiff.kind === "files" ? ( |
| 1048 | − <LegendList | |
| 1376 | + <FlatList | |
| 1049 | 1377 | style={{ flex: 1 }} |
| 1050 | 1378 | contentInsetAdjustmentBehavior="never" |
| 1051 | 1379 | contentInset={{ top: topContentInset }} |
| 1380 | + contentOffset={{ x: 0, y: -topContentInset }} | |
| 1052 | 1381 | 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} | |
| 1057 | 1385 | keyboardShouldPersistTaps="handled" |
| 1058 | − estimatedItemSize={220} | |
| 1059 | − drawDistance={900} | |
| 1060 | − recycleItems | |
| 1061 | 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 | 1396 | contentContainerStyle={{ |
| 1063 | 1397 | paddingTop: REVIEW_HEADER_SPACING, |
| 1064 | 1398 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { Platform, Text as NativeText } from "react-native"; | |
| 1 | +import { Platform, Text as NativeText, View } from "react-native"; | |
| 2 | 2 | |
| 3 | 3 | import { cn } from "../../../lib/cn"; |
| 4 | 4 | |
| @@ -17,14 +17,47 @@ export function renderVisibleWhitespace(value: string): string { · 12 unmodified lines | ||
| 17 | 17 | } |
| 18 | 18 | |
| 19 | 19 | 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"; | |
| 22 | 22 | return "bg-card"; |
| 23 | 23 | } |
| 24 | 24 | |
| 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 | + | |
| 25 | 57 | export function DiffTokenText(props: { |
| 26 | 58 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 27 | 59 | readonly fallback: string; |
| 60 | + readonly change?: ReviewRenderableLineRow["change"]; | |
| 28 | 61 | readonly className?: string; |
| 29 | 62 | }) { |
| 30 | 63 | if (!props.tokens || props.tokens.length === 0) { |
| @@ -70,6 +103,11 @@ export function DiffTokenText(props: { · 39 unmodified lines | ||
| 70 | 103 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 71 | 104 | fontWeight, |
| 72 | 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 | 113 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} |
| @@ -13,6 +13,7 @@ describe("resolveReviewHighlighterEnginePreference", () => { · 12 unmodified lines | ||
| 13 | 13 | |
| 14 | 14 | it("accepts supported values", () => { |
| 15 | 15 | expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript"); |
| 16 | + expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript"); | |
| 16 | 17 | expect(resolveReviewHighlighterEnginePreference("native")).toBe("native"); |
| 17 | 18 | }); |
| 18 | 19 | }); |
| @@ -5,9 +5,11 @@ export function resolveReviewHighlighterEnginePreference( · 4 unmodified lines | ||
| 5 | 5 | value: string | undefined, |
| 6 | 6 | ): ReviewHighlighterEnginePreference { |
| 7 | 7 | switch (value) { |
| 8 | + case "js": | |
| 8 | 9 | case "javascript": |
| 10 | + return "javascript"; | |
| 9 | 11 | case "native": |
| 10 | − return value; | |
| 12 | + return "native"; | |
| 11 | 13 | default: |
| 12 | 14 | return "javascript"; |
| 13 | 15 | } |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | |
| 10 | 10 | import { |
| 11 | + buildReviewListItems, | |
| 11 | 12 | buildReviewParsedDiff, |
| 12 | 13 | buildReviewSectionItems, |
| 13 | 14 | getDefaultReviewSectionId, |
| @@ -138,6 +139,7 @@ describe("buildReviewParsedDiff", () => { · 124 unmodified lines | ||
| 138 | 139 | oldLineNumber: 1, |
| 139 | 140 | newLineNumber: null, |
| 140 | 141 | content: "const before = 1;", |
| 142 | + comparison: { change: "add", tokenIndex: 0 }, | |
| 141 | 143 | }), |
| 142 | 144 | expect.objectContaining({ |
| 143 | 145 | kind: "line", |
| @@ -145,6 +147,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | ||
| 145 | 147 | oldLineNumber: null, |
| 146 | 148 | newLineNumber: 1, |
| 147 | 149 | content: "const after = 2;", |
| 150 | + comparison: { change: "delete", tokenIndex: 0 }, | |
| 148 | 151 | }), |
| 149 | 152 | expect.objectContaining({ |
| 150 | 153 | kind: "line", |
| @@ -152,6 +155,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | ||
| 152 | 155 | oldLineNumber: null, |
| 153 | 156 | newLineNumber: 2, |
| 154 | 157 | content: "console.log(after);", |
| 158 | + comparison: null, | |
| 155 | 159 | }), |
| 156 | 160 | expect.objectContaining({ |
| 157 | 161 | kind: "line", |
| @@ -159,6 +163,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | ||
| 159 | 163 | oldLineNumber: 2, |
| 160 | 164 | newLineNumber: 3, |
| 161 | 165 | content: "return true;", |
| 166 | + comparison: null, | |
| 162 | 167 | }), |
| 163 | 168 | ]); |
| 164 | 169 | }); |
| @@ -220,6 +225,7 @@ describe("buildReviewParsedDiff", () => { · 55 unmodified lines | ||
| 220 | 225 | content: `const line${index} = ${index};`, |
| 221 | 226 | additionTokenIndex: index, |
| 222 | 227 | deletionTokenIndex: null, |
| 228 | + comparison: null, | |
| 223 | 229 | })), |
| 224 | 230 | }), |
| 225 | 231 | ); |
| @@ -231,4 +237,73 @@ describe("buildReviewParsedDiff", () => { · 5 unmodified lines | ||
| 231 | 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 | }); |
| @@ -30,6 +30,10 @@ export interface ReviewRenderableLineRow { · 29 unmodified lines | ||
| 30 | 30 | readonly content: string; |
| 31 | 31 | readonly additionTokenIndex: number | null; |
| 32 | 32 | readonly deletionTokenIndex: number | null; |
| 33 | + readonly comparison: { | |
| 34 | + readonly change: "add" | "delete"; | |
| 35 | + readonly tokenIndex: number; | |
| 36 | + } | null; | |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | 39 | export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow; |
| @@ -48,6 +52,42 @@ export interface ReviewRenderableFile { · 12 unmodified lines | ||
| 48 | 52 | readonly rows: ReadonlyArray<ReviewRenderableRow>; |
| 49 | 53 | } |
| 50 | 54 | |
| 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 | + | |
| 51 | 91 | export type ReviewFilePreviewState = |
| 52 | 92 | | { |
| 53 | 93 | readonly kind: "render"; |
| @@ -267,6 +307,72 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil · 213 unmodified lines | ||
| 267 | 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 | |
| 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 | + | |
| 270 | 376 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { |
| 271 | 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 | 411 | additionTokenIndex, |
| 306 | 412 | deletionTokenIndex, |
| 413 | + comparison: null, | |
| 307 | 414 | }); |
| 308 | 415 | deletionLineNumber += 1; |
| 309 | 416 | additionLineNumber += 1; |
| @@ -313,6 +420,10 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 3 unmodified lines | ||
| 313 | 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 | 427 | for (let index = 0; index < segment.deletions; index += 1) { |
| 317 | 428 | rows.push({ |
| 318 | 429 | kind: "line", |
| @@ -323,6 +434,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 4 unmodified lines | ||
| 323 | 434 | content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""), |
| 324 | 435 | additionTokenIndex: null, |
| 325 | 436 | deletionTokenIndex, |
| 437 | + comparison: | |
| 438 | + index < pairedLineCount | |
| 439 | + ? { | |
| 440 | + change: "add", | |
| 441 | + tokenIndex: additionTokenIndexStart + index, | |
| 442 | + } | |
| 443 | + : null, | |
| 326 | 444 | }); |
| 327 | 445 | deletionLineNumber += 1; |
| 328 | 446 | deletionTokenIndex += 1; |
| @@ -338,6 +456,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 9 unmodified lines | ||
| 338 | 456 | content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""), |
| 339 | 457 | additionTokenIndex, |
| 340 | 458 | deletionTokenIndex: null, |
| 459 | + comparison: | |
| 460 | + index < pairedLineCount | |
| 461 | + ? { | |
| 462 | + change: "delete", | |
| 463 | + tokenIndex: deletionTokenIndexStart + index, | |
| 464 | + } | |
| 465 | + : null, | |
| 341 | 466 | }); |
| 342 | 467 | additionLineNumber += 1; |
| 343 | 468 | additionTokenIndex += 1; |
| @@ -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 | +}); | |
| @@ -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 | +} | |
| @@ -41,4 +41,73 @@ describe("highlightReviewFile", () => { · 40 unmodified lines | ||
| 41 | 41 | expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true); |
| 42 | 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 | }); |
| @@ -8,13 +8,15 @@ import { · 7 unmodified lines | ||
| 8 | 8 | type ReviewHighlighterEngine, |
| 9 | 9 | } from "./reviewHighlighterEngine"; |
| 10 | 10 | import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; |
| 11 | +import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; | |
| 11 | 12 | |
| 12 | 13 | export type ReviewDiffTheme = "light" | "dark"; |
| 13 | 14 | |
| 14 | 15 | export interface ReviewHighlightedToken { |
| 15 | − readonly content: string; | |
| 16 | + content: string; | |
| 16 | 17 | readonly color: string | null; |
| 17 | 18 | readonly fontStyle: number | null; |
| 19 | + readonly diffHighlight?: boolean; | |
| 18 | 20 | } |
| 19 | 21 | |
| 20 | 22 | export interface ReviewHighlightedFile { |
| @@ -26,13 +28,19 @@ const SHIKI_THEME_NAME_BY_SCHEME = { · 5 unmodified lines | ||
| 26 | 28 | light: "github-light-default", |
| 27 | 29 | dark: "github-dark-default", |
| 28 | 30 | } as const; |
| 31 | +const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = | |
| 32 | + process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript"; | |
| 29 | 33 | const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( |
| 30 | − process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript", | |
| 34 | + REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, | |
| 31 | 35 | ); |
| 32 | 36 | const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( |
| 33 | 37 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, |
| 34 | 38 | false, |
| 35 | 39 | ); |
| 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; | |
| 36 | 44 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); |
| 37 | 45 | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); |
| 38 | 46 | const loadedLanguages = new Set<string>(["text"]); |
| @@ -205,6 +213,17 @@ function joinPatchLines(lines: ReadonlyArray<string>): string { · 166 unmodified lines | ||
| 205 | 213 | return lines.map(stripTrailingNewline).join("\n"); |
| 206 | 214 | } |
| 207 | 215 | |
| 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 | + | |
| 208 | 227 | async function getHighlighter(): Promise<HighlighterCore> { |
| 209 | 228 | if (!highlighterPromise) { |
| 210 | 229 | const configuredHighlighterPromise = Promise.all([ |
| @@ -214,6 +233,7 @@ async function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines | ||
| 214 | 233 | let nativeEngineAvailable = false; |
| 215 | 234 | |
| 216 | 235 | logReviewHighlighterDiagnostic("initializing", { |
| 236 | + configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, | |
| 217 | 237 | preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, |
| 218 | 238 | resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, |
| 219 | 239 | }); |
| @@ -379,6 +399,146 @@ function normalizeHighlightedLines( · 159 unmodified lines | ||
| 379 | 399 | ); |
| 380 | 400 | } |
| 381 | 401 | |
| 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 | + | |
| 382 | 542 | async function highlightLines( |
| 383 | 543 | code: string, |
| 384 | 544 | language: string, |
| @@ -389,14 +549,76 @@ async function highlightLines( · 4 unmodified lines | ||
| 389 | 549 | } |
| 390 | 550 | |
| 391 | 551 | 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; | |
| 394 | 595 | } |
| 395 | 596 | |
| 396 | 597 | function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { |
| 397 | 598 | return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; |
| 398 | 599 | } |
| 399 | 600 | |
| 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 | + | |
| 400 | 622 | export function getCachedHighlightedReviewFile( |
| 401 | 623 | file: ReviewRenderableFile, |
| 402 | 624 | theme: ReviewDiffTheme, |
| @@ -417,32 +639,72 @@ export async function highlightReviewFile( · 14 unmodified lines | ||
| 417 | 639 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 418 | 640 | const resolved = resolvedHighlightCache.get(cacheKey); |
| 419 | 641 | if (resolved) { |
| 642 | + logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { | |
| 643 | + fileId: file.id, | |
| 644 | + filePath: file.path, | |
| 645 | + theme, | |
| 646 | + }); | |
| 420 | 647 | return resolved; |
| 421 | 648 | } |
| 422 | 649 | const cached = highlightCache.get(cacheKey); |
| 423 | 650 | if (cached) { |
| 651 | + logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { | |
| 652 | + fileId: file.id, | |
| 653 | + filePath: file.path, | |
| 654 | + theme, | |
| 655 | + }); | |
| 424 | 656 | return cached; |
| 425 | 657 | } |
| 426 | 658 | } |
| 427 | 659 | |
| 428 | 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 | 670 | 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 }); | |
| 436 | 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 | 697 | return highlighted; |
| 440 | 698 | })(); |
| 441 | 699 | |
| 442 | 700 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 443 | 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 | |
| 448 | 710 | export async function highlightReviewSelectedLines(input: { |
| @@ -483,5 +745,8 @@ export async function highlightReviewSelectedLines(input: { · 34 unmodified lines | ||
| 483 | 745 | additionIndex += 1; |
| 484 | 746 | }); |
| 485 | 747 | |
| 486 | − return tokenMap; | |
| 748 | + return applyWordAltDiffHighlightsToSelectedLines({ | |
| 749 | + lines: input.lines, | |
| 750 | + tokenMap, | |
| 751 | + }); | |
| 487 | 752 | } |
| @@ -66,6 +66,7 @@ · 65 unmodified lines | ||
| 66 | 66 | "@t3tools/contracts": "workspace:*", |
| 67 | 67 | "@t3tools/shared": "workspace:*", |
| 68 | 68 | "clsx": "^2.1.1", |
| 69 | + "diff": "8.0.3", | |
| 69 | 70 | "effect": "catalog:", |
| 70 | 71 | "expo": "~55.0.14", |
| 71 | 72 | "expo-camera": "~55.0.15", |