- 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 | bun run ios:preview | 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 | Inspect the resolved Expo config for a variant: | 47 | Inspect the resolved Expo config for a variant: |
| 40 | 48 | ||
| 41 | ```bash | 49 | ```bash |
| @@ -47,6 +47,7 @@ · 46 unmodified lines | |||
| 47 | "@t3tools/contracts": "workspace:*", | 47 | "@t3tools/contracts": "workspace:*", |
| 48 | "@t3tools/shared": "workspace:*", | 48 | "@t3tools/shared": "workspace:*", |
| 49 | "clsx": "^2.1.1", | 49 | "clsx": "^2.1.1", |
| 50 | "diff": "8.0.3", | ||
| 50 | "effect": "catalog:", | 51 | "effect": "catalog:", |
| 51 | "expo": "~55.0.14", | 52 | "expo": "~55.0.14", |
| 52 | "expo-camera": "~55.0.15", | 53 | "expo-camera": "~55.0.15", |
| @@ -27,12 +27,16 @@ import { appendReviewCommentToDraft } from "../use-thread-composer-state"; · 26 unmodified lines | |||
| 27 | import { | 27 | import { |
| 28 | clearReviewCommentTarget, | 28 | clearReviewCommentTarget, |
| 29 | formatReviewCommentContext, | 29 | formatReviewCommentContext, |
| 30 | getReviewChangeMarker, | ||
| 31 | getReviewUnifiedLineNumber, | 30 | getReviewUnifiedLineNumber, |
| 32 | getSelectedReviewCommentLines, | 31 | getSelectedReviewCommentLines, |
| 33 | useReviewCommentTarget, | 32 | useReviewCommentTarget, |
| 34 | } from "./reviewCommentSelection"; | 33 | } from "./reviewCommentSelection"; |
| 35 | import { changeTone, DiffTokenText } from "./reviewDiffRendering"; | 34 | import { |
| 35 | changeTone, | ||
| 36 | DiffTokenText, | ||
| 37 | REVIEW_MONO_FONT_FAMILY, | ||
| 38 | ReviewChangeBar, | ||
| 39 | } from "./reviewDiffRendering"; | ||
| 36 | import { | 40 | import { |
| 37 | highlightReviewSelectedLines, | 41 | highlightReviewSelectedLines, |
| 38 | type ReviewDiffTheme, | 42 | type ReviewDiffTheme, |
| @@ -217,19 +221,18 @@ export function ReviewCommentComposerSheet() { · 178 unmodified lines | |||
| 217 | className={cn("flex-row items-start", changeTone(line.change))} | 221 | className={cn("flex-row items-start", changeTone(line.change))} |
| 218 | style={{ height: REVIEW_COMMENT_LINE_HEIGHT }} | 222 | style={{ height: REVIEW_COMMENT_LINE_HEIGHT }} |
| 219 | > | 223 | > |
| 220 | <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted"> | 224 | <ReviewChangeBar change={line.change} /> |
| 221 | {lineNumber ?? ""} | ||
| 222 | </Text> | ||
| 223 | <Text | 225 | <Text |
| 224 | className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted" | 226 | className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted" |
| 225 | style={{ width: 18 }} | 227 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} |
| 226 | > | 228 | > |
| 227 | {getReviewChangeMarker(line.change)} | 229 | {lineNumber ?? ""} |
| 228 | </Text> | 230 | </Text> |
| 229 | <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> | 231 | <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> |
| 230 | <DiffTokenText | 232 | <DiffTokenText |
| 231 | fallback={line.content} | 233 | fallback={line.content} |
| 232 | tokens={highlightedLinesById[line.id] ?? null} | 234 | tokens={highlightedLinesById[line.id] ?? null} |
| 235 | change={line.change} | ||
| 233 | /> | 236 | /> |
| 234 | </View> | 237 | </View> |
| 235 | </View> | 238 | </View> |
| @@ -1,14 +1,17 @@ | |||
| 1 | import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts"; | 1 | import { ThreadId, type OrchestrationCheckpointSummary } from "@t3tools/contracts"; |
| 2 | import { LegendList } from "@legendapp/list/react-native"; | ||
| 3 | import { useLocalSearchParams, useRouter } from "expo-router"; | 2 | import { useLocalSearchParams, useRouter } from "expo-router"; |
| 4 | import Stack from "expo-router/stack"; | 3 | import Stack from "expo-router/stack"; |
| 5 | import { SymbolView } from "expo-symbols"; | 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 | import { | 6 | import { |
| 8 | ActivityIndicator, | 7 | ActivityIndicator, |
| 8 | FlatList, | ||
| 9 | InteractionManager, | ||
| 10 | type ListRenderItemInfo, | ||
| 9 | Pressable, | 11 | Pressable, |
| 10 | ScrollView, | 12 | ScrollView, |
| 11 | Text as NativeText, | 13 | Text as NativeText, |
| 14 | type ViewToken, | ||
| 12 | useColorScheme, | 15 | useColorScheme, |
| 13 | useWindowDimensions, | 16 | useWindowDimensions, |
| 14 | View, | 17 | View, |
| @@ -32,16 +35,19 @@ import { · 17 unmodified lines | |||
| 32 | useReviewCacheForThread, | 35 | useReviewCacheForThread, |
| 33 | } from "./reviewState"; | 36 | } from "./reviewState"; |
| 34 | import { | 37 | import { |
| 38 | buildReviewListItems, | ||
| 35 | getReadyReviewCheckpoints, | 39 | getReadyReviewCheckpoints, |
| 36 | buildReviewSectionItems, | 40 | buildReviewSectionItems, |
| 37 | getDefaultReviewSectionId, | 41 | getDefaultReviewSectionId, |
| 38 | getReviewFilePreviewState, | 42 | getReviewFilePreviewState, |
| 39 | getReviewSectionIdForCheckpoint, | 43 | getReviewSectionIdForCheckpoint, |
| 44 | type ReviewListItem, | ||
| 40 | type ReviewParsedDiff, | 45 | type ReviewParsedDiff, |
| 41 | type ReviewRenderableFile, | 46 | type ReviewRenderableFile, |
| 42 | type ReviewRenderableLineRow, | 47 | type ReviewRenderableLineRow, |
| 43 | } from "./reviewModel"; | 48 | } from "./reviewModel"; |
| 44 | import { | 49 | import { |
| 50 | clearReviewHighlightFileCache, | ||
| 45 | getCachedHighlightedReviewFile, | 51 | getCachedHighlightedReviewFile, |
| 46 | highlightReviewFile, | 52 | highlightReviewFile, |
| 47 | type ReviewDiffTheme, | 53 | type ReviewDiffTheme, |
| @@ -53,14 +59,17 @@ import { · 5 unmodified lines | |||
| 53 | clearReviewCommentTarget, | 59 | clearReviewCommentTarget, |
| 54 | countReviewCommentContexts, | 60 | countReviewCommentContexts, |
| 55 | formatReviewSelectedRangeLabel, | 61 | formatReviewSelectedRangeLabel, |
| 56 | getReviewChangeMarker, | ||
| 57 | getReviewUnifiedLineNumber, | 62 | getReviewUnifiedLineNumber, |
| 58 | getSelectedReviewCommentLines, | ||
| 59 | setReviewCommentTarget, | 63 | setReviewCommentTarget, |
| 60 | type ReviewCommentTarget, | 64 | type ReviewCommentTarget, |
| 61 | useReviewCommentTarget, | 65 | useReviewCommentTarget, |
| 62 | } from "./reviewCommentSelection"; | 66 | } from "./reviewCommentSelection"; |
| 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 | interface PendingCommentSelection { | 74 | interface PendingCommentSelection { |
| 66 | readonly sectionTitle: string; | 75 | readonly sectionTitle: string; |
| @@ -77,20 +86,62 @@ interface ReviewLineActionInput { · 10 unmodified lines | |||
| 77 | } | 86 | } |
| 78 | 87 | ||
| 79 | const IOS_NAV_BAR_HEIGHT = 44; | 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 { | 115 | function getFileHeaderChrome(changeType: ReviewRenderableFile["changeType"]): { |
| 83 | switch (type) { | 116 | readonly rail: string; |
| 117 | readonly dot: string; | ||
| 118 | } { | ||
| 119 | switch (changeType) { | ||
| 84 | case "new": | 120 | case "new": |
| 85 | return "Added"; | 121 | return { |
| 122 | rail: "bg-emerald-400", | ||
| 123 | dot: "bg-emerald-400", | ||
| 124 | }; | ||
| 86 | case "deleted": | 125 | case "deleted": |
| 87 | return "Deleted"; | 126 | return { |
| 127 | rail: "bg-rose-400", | ||
| 128 | dot: "bg-rose-400", | ||
| 129 | }; | ||
| 88 | case "rename-pure": | 130 | case "rename-pure": |
| 89 | return "Renamed"; | 131 | return { |
| 132 | rail: "bg-amber-400", | ||
| 133 | dot: "bg-amber-400", | ||
| 134 | }; | ||
| 90 | case "rename-changed": | 135 | case "rename-changed": |
| 91 | return "Renamed + edited"; | 136 | return { |
| 137 | rail: "bg-sky-400", | ||
| 138 | dot: "bg-sky-400", | ||
| 139 | }; | ||
| 92 | default: | 140 | default: |
| 93 | return "Edited"; | 141 | return { |
| 142 | rail: "bg-sky-400", | ||
| 143 | dot: "bg-sky-400", | ||
| 144 | }; | ||
| 94 | } | 145 | } |
| 95 | } | 146 | } |
| 96 | 147 | ||
| @@ -108,12 +159,70 @@ function formatHeaderDiffSummary(parsedDiff: ReviewParsedDiff): { · 11 unmodified lines | |||
| 108 | }; | 159 | }; |
| 109 | } | 160 | } |
| 110 | 161 | ||
| 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 | function getDefaultExpandedFileIds( | 201 | function getDefaultExpandedFileIds( |
| 112 | files: ReadonlyArray<ReviewRenderableFile>, | 202 | files: ReadonlyArray<ReviewRenderableFile>, |
| 113 | ): ReadonlyArray<string> { | 203 | ): ReadonlyArray<string> { |
| 114 | return files.map((file) => file.id); | 204 | return files.map((file) => file.id); |
| 115 | } | 205 | } |
| 116 | 206 | ||
| 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 | const ReviewLineRow = memo(function ReviewLineRow(props: { | 226 | const ReviewLineRow = memo(function ReviewLineRow(props: { |
| 118 | readonly line: ReviewRenderableLineRow; | 227 | readonly line: ReviewRenderableLineRow; |
| 119 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | 228 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| @@ -145,17 +254,19 @@ const ReviewLineRow = memo(function ReviewLineRow(props: { · 25 unmodified lines | |||
| 145 | onPress={props.onComment} | 254 | onPress={props.onComment} |
| 146 | style={{ minWidth: props.viewportWidth }} | 255 | style={{ minWidth: props.viewportWidth }} |
| 147 | > | 256 | > |
| 148 | <Text className="w-9 px-1 py-1 text-right text-[11px] font-t3-medium text-foreground-muted"> | 257 | <ReviewChangeBar change={props.line.change} /> |
| 149 | {lineNumber ?? ""} | ||
| 150 | </Text> | ||
| 151 | <Text | 258 | <Text |
| 152 | className="px-0.5 py-1 text-center font-mono text-[12px] text-foreground-muted" | 259 | className="w-9 py-1 pr-1 text-right text-[11px] font-t3-medium text-foreground-muted" |
| 153 | style={{ width: 18 }} | 260 | style={{ fontFamily: REVIEW_MONO_FONT_FAMILY }} |
| 154 | > | 261 | > |
| 155 | {getReviewChangeMarker(props.line.change)} | 262 | {lineNumber ?? ""} |
| 156 | </Text> | 263 | </Text> |
| 157 | <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> | 264 | <View className="min-w-0 flex-1 flex-shrink-0 px-1 py-1"> |
| 158 | <DiffTokenText tokens={props.tokens} fallback={props.line.content} /> | 265 | <DiffTokenText |
| 266 | tokens={props.tokens} | ||
| 267 | fallback={props.line.content} | ||
| 268 | change={props.line.change} | ||
| 269 | /> | ||
| 159 | </View> | 270 | </View> |
| 160 | </Pressable> | 271 | </Pressable> |
| 161 | ); | 272 | ); |
| @@ -165,41 +276,55 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 3 unmodified lines | |||
| 165 | readonly file: ReviewRenderableFile; | 276 | readonly file: ReviewRenderableFile; |
| 166 | readonly fileId: string; | 277 | readonly fileId: string; |
| 167 | readonly expanded: boolean; | 278 | readonly expanded: boolean; |
| 279 | readonly viewportWidth: number; | ||
| 168 | readonly onToggleFile: (fileId: string) => void; | 280 | readonly onToggleFile: (fileId: string) => void; |
| 169 | }) { | 281 | }) { |
| 282 | const chrome = getFileHeaderChrome(props.file.changeType); | ||
| 283 | |||
| 170 | return ( | 284 | return ( |
| 171 | <View className="border-b border-border bg-card" style={{ zIndex: 1 }}> | 285 | <View |
| 172 | <Pressable | 286 | className="border-b border-border/70 bg-card" |
| 173 | className="flex-row items-start gap-2 px-3 py-3" | 287 | style={{ |
| 174 | onPress={() => props.onToggleFile(props.fileId)} | 288 | zIndex: 1, |
| 175 | > | 289 | width: props.viewportWidth, |
| 176 | <View className="pt-0.5"> | 290 | shadowColor: "#000000", |
| 177 | <SymbolView | 291 | shadowOpacity: 0.08, |
| 178 | name={props.expanded ? "chevron.down" : "chevron.right"} | 292 | shadowRadius: 10, |
| 179 | size={14} | 293 | shadowOffset: { width: 0, height: 6 }, |
| 180 | tintColor="#8a8a8a" | 294 | elevation: 4, |
| 181 | type="monochrome" | 295 | }} |
| 182 | /> | 296 | > |
| 183 | </View> | 297 | <Pressable className="flex-row items-center" onPress={() => props.onToggleFile(props.fileId)}> |
| 184 | <View className="min-w-0 flex-1 gap-1"> | 298 | <View className={cn("w-1 self-stretch", chrome.rail)} /> |
| 185 | <Text className="font-mono text-[13px] leading-[18px] text-foreground"> | 299 | <View className="flex-1 flex-row items-center px-3 py-1.5"> |
| 186 | {props.file.path} | 300 | <View className="size-7 items-center justify-center rounded-full bg-subtle"> |
| 187 | </Text> | 301 | <SymbolView |
| 188 | {props.file.previousPath && props.file.previousPath !== props.file.path ? ( | 302 | name={props.expanded ? "chevron.down" : "chevron.right"} |
| 189 | <Text className="font-mono text-[11px] leading-[16px] text-foreground-muted"> | 303 | size={12} |
| 190 | {props.file.previousPath} | 304 | tintColor="#8a8a8a" |
| 191 | </Text> | 305 | type="monochrome" |
| 192 | ) : null} | 306 | /> |
| 193 | </View> | 307 | </View> |
| 194 | <View className="items-end gap-1 pl-2"> | 308 | <View className="ml-2 min-w-0 flex-1 flex-row items-center gap-2"> |
| 195 | <Text className="text-[11px] font-t3-bold uppercase text-foreground-muted"> | 309 | <View className={cn("size-2 rounded-full", chrome.dot)} /> |
| 196 | {changeTypeLabel(props.file.changeType)} | 310 | <View className="min-w-0 flex-1"> |
| 197 | </Text> | 311 | <Text className="font-mono text-[13px] leading-[18px] text-foreground"> |
| 198 | <View className="flex-row items-center gap-2"> | 312 | {props.file.path} |
| 199 | <Text className="text-[12px] font-t3-bold text-emerald-600"> | 313 | </Text> |
| 314 | {props.file.previousPath && props.file.previousPath !== props.file.path ? ( | ||
| 315 | <Text className="font-mono text-[10px] leading-[14px] text-foreground-muted"> | ||
| 316 | {props.file.previousPath} | ||
| 317 | </Text> | ||
| 318 | ) : null} | ||
| 319 | </View> | ||
| 320 | </View> | ||
| 321 | <View className="ml-3 flex-row items-center justify-end gap-2"> | ||
| 322 | <Text className="font-mono text-[12px] font-t3-bold text-emerald-600 dark:text-emerald-300"> | ||
| 200 | +{props.file.additions} | 323 | +{props.file.additions} |
| 201 | </Text> | 324 | </Text> |
| 202 | <Text className="text-[12px] font-t3-bold text-rose-600">-{props.file.deletions}</Text> | 325 | <Text className="font-mono text-[12px] font-t3-bold text-rose-600 dark:text-rose-300"> |
| 326 | -{props.file.deletions} | ||
| 327 | </Text> | ||
| 203 | </View> | 328 | </View> |
| 204 | </View> | 329 | </View> |
| 205 | </Pressable> | 330 | </Pressable> |
| @@ -207,117 +332,18 @@ const ReviewFileCard = memo(function ReviewFileCard(props: { · 1 unmodified line | |||
| 207 | ); | 332 | ); |
| 208 | }); | 333 | }); |
| 209 | 334 | ||
| 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 | const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { | 335 | const ReviewFileSuppressedBody = memo(function ReviewFileSuppressedBody(props: { |
| 314 | readonly message: string; | 336 | readonly message: string; |
| 315 | readonly actionLabel?: string | null; | 337 | readonly actionLabel?: string | null; |
| 316 | readonly fileId: string; | 338 | readonly fileId: string; |
| 339 | readonly viewportWidth: number; | ||
| 317 | readonly onLoadDiffFile?: (fileId: string) => void; | 340 | readonly onLoadDiffFile?: (fileId: string) => void; |
| 318 | }) { | 341 | }) { |
| 319 | return ( | 342 | return ( |
| 320 | <View className="gap-2 border-b border-border bg-card px-4 py-3"> | 343 | <View |
| 344 | className="gap-2 border-b border-border bg-card px-4 py-3" | ||
| 345 | style={{ minWidth: props.viewportWidth }} | ||
| 346 | > | ||
| 321 | <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text> | 347 | <Text className="text-[12px] leading-[18px] text-foreground-muted">{props.message}</Text> |
| 322 | {props.actionLabel && props.onLoadDiffFile ? ( | 348 | {props.actionLabel && props.onLoadDiffFile ? ( |
| 323 | <Pressable | 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: { | 360 | const ReviewHunkRow = memo(function ReviewHunkRow(props: { |
| 335 | readonly file: ReviewRenderableFile; | 361 | readonly header: string; |
| 336 | readonly expanded: boolean; | 362 | readonly context: string | null; |
| 337 | readonly sectionTitle: string; | ||
| 338 | readonly selectedTheme: ReviewDiffTheme; | ||
| 339 | readonly pendingSelection: PendingCommentSelection | null; | ||
| 340 | readonly selectedTarget: ReviewCommentTarget | null; | ||
| 341 | readonly viewportWidth: number; | 363 | readonly viewportWidth: number; |
| 342 | readonly revealedLarge: boolean; | ||
| 343 | readonly onToggleFile: (fileId: string) => void; | ||
| 344 | readonly onLoadDiffFile: (fileId: string) => void; | ||
| 345 | readonly onPressLine: (input: ReviewLineActionInput) => void; | ||
| 346 | readonly onStartRangeSelection: (input: ReviewLineActionInput) => void; | ||
| 347 | }) { | 364 | }) { |
| 348 | const previewState = useMemo(() => getReviewFilePreviewState(props.file), [props.file]); | ||
| 349 | const shouldRenderBody = | ||
| 350 | previewState.kind === "render" || (previewState.reason === "large" && props.revealedLarge); | ||
| 351 | const highlightCacheKey = `${props.selectedTheme}:${props.file.cacheKey}`; | ||
| 352 | const [highlightedFile, setHighlightedFile] = useState<ReviewHighlightedFile | null>(() => | ||
| 353 | getCachedHighlightedReviewFile(props.file, props.selectedTheme), | ||
| 354 | ); | ||
| 355 | |||
| 356 | useEffect(() => { | ||
| 357 | setHighlightedFile(getCachedHighlightedReviewFile(props.file, props.selectedTheme)); | ||
| 358 | }, [highlightCacheKey, props.file, props.selectedTheme]); | ||
| 359 | |||
| 360 | useEffect(() => { | ||
| 361 | if (!props.expanded || !shouldRenderBody) { | ||
| 362 | return; | ||
| 363 | } | ||
| 364 | |||
| 365 | const cached = getCachedHighlightedReviewFile(props.file, props.selectedTheme); | ||
| 366 | if (cached) { | ||
| 367 | setHighlightedFile(cached); | ||
| 368 | return; | ||
| 369 | } | ||
| 370 | |||
| 371 | let cancelled = false; | ||
| 372 | void highlightReviewFile(props.file, props.selectedTheme) | ||
| 373 | .then((result) => { | ||
| 374 | if (!cancelled) { | ||
| 375 | setHighlightedFile(result); | ||
| 376 | } | ||
| 377 | }) | ||
| 378 | .catch(() => { | ||
| 379 | if (cancelled) { | ||
| 380 | return; | ||
| 381 | } | ||
| 382 | }); | ||
| 383 | |||
| 384 | return () => { | ||
| 385 | cancelled = true; | ||
| 386 | }; | ||
| 387 | }, [highlightCacheKey, props.expanded, props.file, props.selectedTheme, shouldRenderBody]); | ||
| 388 | |||
| 389 | return ( | 365 | return ( |
| 390 | <> | 366 | <View |
| 391 | <ReviewFileCard | 367 | className="border-b border-border/60 bg-sky-500/10 px-2 py-2" |
| 392 | file={props.file} | 368 | style={{ minWidth: props.viewportWidth }} |
| 393 | fileId={props.file.id} | 369 | > |
| 394 | expanded={props.expanded} | 370 | <Text className="font-mono text-[12px] leading-[18px] text-sky-700 dark:text-sky-300"> |
| 395 | onToggleFile={props.onToggleFile} | 371 | {props.header} |
| 396 | /> | 372 | {props.context ? ` ${props.context}` : ""} |
| 397 | {props.expanded ? ( | 373 | </Text> |
| 398 | shouldRenderBody ? ( | 374 | </View> |
| 399 | <ReviewFileBody | ||
| 400 | file={props.file} | ||
| 401 | sectionTitle={props.sectionTitle} | ||
| 402 | highlightedFile={highlightedFile} | ||
| 403 | pendingSelection={props.pendingSelection} | ||
| 404 | selectedTarget={props.selectedTarget} | ||
| 405 | viewportWidth={props.viewportWidth} | ||
| 406 | onPressLine={props.onPressLine} | ||
| 407 | onStartRangeSelection={props.onStartRangeSelection} | ||
| 408 | /> | ||
| 409 | ) : ( | ||
| 410 | <ReviewFileSuppressedBody | ||
| 411 | message={previewState.message} | ||
| 412 | actionLabel={previewState.actionLabel} | ||
| 413 | fileId={props.file.id} | ||
| 414 | onLoadDiffFile={previewState.actionLabel ? props.onLoadDiffFile : undefined} | ||
| 415 | /> | ||
| 416 | ) | ||
| 417 | ) : null} | ||
| 418 | </> | ||
| 419 | ); | 375 | ); |
| 420 | }); | 376 | }); |
| 421 | 377 | ||
| @@ -496,7 +452,18 @@ export function ReviewSheet() { · 74 unmodified lines | |||
| 496 | const [error, setError] = useState<string | null>(null); | 452 | const [error, setError] = useState<string | null>(null); |
| 497 | const [pendingCommentSelection, setPendingCommentSelection] = | 453 | const [pendingCommentSelection, setPendingCommentSelection] = |
| 498 | useState<PendingCommentSelection | null>(null); | 454 | useState<PendingCommentSelection | null>(null); |
| 455 | const [highlightedFilesById, setHighlightedFilesById] = useState< | ||
| 456 | Record<string, ReviewHighlightedFile | null> | ||
| 457 | >({}); | ||
| 458 | const [visibleFileIds, setVisibleFileIds] = useState<ReadonlyArray<string>>([]); | ||
| 499 | const activeCommentTarget = useReviewCommentTarget(); | 459 | const activeCommentTarget = useReviewCommentTarget(); |
| 460 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | ||
| 461 | const highlightQueueRef = useRef<string[]>([]); | ||
| 462 | const highlightRequestedFileIdsRef = useRef<Set<string>>(new Set()); | ||
| 463 | const highlightQueueGenerationRef = useRef(0); | ||
| 464 | const highlightQueueActiveRef = useRef(false); | ||
| 465 | const highlightableFilesByIdRef = useRef<ReadonlyMap<string, ReviewRenderableFile>>(new Map()); | ||
| 466 | const selectedThemeRef = useRef<ReviewDiffTheme>(selectedTheme); | ||
| 500 | 467 | ||
| 501 | const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null; | 468 | const cwd = selectedThread?.worktreePath ?? selectedThreadProject?.workspaceRoot ?? null; |
| 502 | const readyCheckpoints = useMemo( | 469 | const readyCheckpoints = useMemo( |
| @@ -544,7 +511,6 @@ export function ReviewSheet() { · 41 unmodified lines | |||
| 544 | [draftMessage], | 511 | [draftMessage], |
| 545 | ); | 512 | ); |
| 546 | 513 | ||
| 547 | const selectedTheme = (colorScheme === "dark" ? "dark" : "light") satisfies ReviewDiffTheme; | ||
| 548 | const expandedFileIds = useMemo( | 514 | const expandedFileIds = useMemo( |
| 549 | () => | 515 | () => |
| 550 | selectedSection?.id && parsedDiff.kind === "files" | 516 | selectedSection?.id && parsedDiff.kind === "files" |
| @@ -560,6 +526,120 @@ export function ReviewSheet() { · 9 unmodified lines | |||
| 560 | : [], | 526 | : [], |
| 561 | [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id], | 527 | [reviewCache.revealedLargeFileIdsBySection, selectedSection?.id], |
| 562 | ); | 528 | ); |
| 529 | const reviewListItems = useMemo( | ||
| 530 | () => | ||
| 531 | selectedSection && parsedDiff.kind === "files" | ||
| 532 | ? buildReviewListItems({ | ||
| 533 | files: parsedDiff.files, | ||
| 534 | expandedFileIds, | ||
| 535 | revealedLargeFileIds, | ||
| 536 | }) | ||
| 537 | : [], | ||
| 538 | [expandedFileIds, parsedDiff, revealedLargeFileIds, selectedSection], | ||
| 539 | ); | ||
| 540 | const reviewFileById = useMemo(() => { | ||
| 541 | if (parsedDiff.kind !== "files") { | ||
| 542 | return new Map<string, ReviewRenderableFile>(); | ||
| 543 | } | ||
| 544 | |||
| 545 | return new Map(parsedDiff.files.map((file) => [file.id, file] as const)); | ||
| 546 | }, [parsedDiff]); | ||
| 547 | const reviewLineRowsByFileId = useMemo(() => { | ||
| 548 | if (parsedDiff.kind !== "files") { | ||
| 549 | return new Map<string, ReadonlyArray<ReviewRenderableLineRow>>(); | ||
| 550 | } | ||
| 551 | |||
| 552 | return new Map( | ||
| 553 | parsedDiff.files.map((file) => [ | ||
| 554 | file.id, | ||
| 555 | file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"), | ||
| 556 | ]), | ||
| 557 | ); | ||
| 558 | }, [parsedDiff]); | ||
| 559 | const reviewLineIndexByRowId = useMemo(() => { | ||
| 560 | const map = new Map<string, number>(); | ||
| 561 | |||
| 562 | reviewLineRowsByFileId.forEach((rows) => { | ||
| 563 | rows.forEach((row, index) => { | ||
| 564 | map.set(row.id, index); | ||
| 565 | }); | ||
| 566 | }); | ||
| 567 | |||
| 568 | return map; | ||
| 569 | }, [reviewLineRowsByFileId]); | ||
| 570 | const highlightableFiles = useMemo(() => { | ||
| 571 | if (parsedDiff.kind !== "files") { | ||
| 572 | return [] as ReadonlyArray<ReviewRenderableFile>; | ||
| 573 | } | ||
| 574 | |||
| 575 | const expandedFileIdSet = new Set(expandedFileIds); | ||
| 576 | const revealedLargeFileIdSet = new Set(revealedLargeFileIds); | ||
| 577 | |||
| 578 | return parsedDiff.files.filter((file) => { | ||
| 579 | if (!expandedFileIdSet.has(file.id)) { | ||
| 580 | return false; | ||
| 581 | } | ||
| 582 | |||
| 583 | const previewState = getReviewFilePreviewState(file); | ||
| 584 | return ( | ||
| 585 | previewState.kind === "render" || | ||
| 586 | (previewState.reason === "large" && revealedLargeFileIdSet.has(file.id)) | ||
| 587 | ); | ||
| 588 | }); | ||
| 589 | }, [expandedFileIds, parsedDiff, revealedLargeFileIds]); | ||
| 590 | const highlightableFileIds = useMemo( | ||
| 591 | () => new Set(highlightableFiles.map((file) => file.id)), | ||
| 592 | [highlightableFiles], | ||
| 593 | ); | ||
| 594 | const priorityHighlightFileIds = useMemo(() => { | ||
| 595 | if (parsedDiff.kind !== "files" || highlightableFiles.length === 0) { | ||
| 596 | return [] as ReadonlyArray<string>; | ||
| 597 | } | ||
| 598 | |||
| 599 | const fileIdsInPriorityOrder = | ||
| 600 | visibleFileIds.length > 0 | ||
| 601 | ? visibleFileIds.filter((fileId) => highlightableFileIds.has(fileId)) | ||
| 602 | : parsedDiff.files | ||
| 603 | .slice(0, REVIEW_INITIAL_HIGHLIGHT_FILE_COUNT) | ||
| 604 | .map((file) => file.id) | ||
| 605 | .filter((fileId) => highlightableFileIds.has(fileId)); | ||
| 606 | |||
| 607 | if (fileIdsInPriorityOrder.length === 0) { | ||
| 608 | return [] as ReadonlyArray<string>; | ||
| 609 | } | ||
| 610 | |||
| 611 | const firstVisibleIndex = parsedDiff.files.findIndex( | ||
| 612 | (file) => file.id === fileIdsInPriorityOrder[0], | ||
| 613 | ); | ||
| 614 | const lastVisibleIndex = parsedDiff.files.findIndex( | ||
| 615 | (file) => file.id === fileIdsInPriorityOrder[fileIdsInPriorityOrder.length - 1], | ||
| 616 | ); | ||
| 617 | |||
| 618 | if (firstVisibleIndex < 0 || lastVisibleIndex < 0) { | ||
| 619 | return fileIdsInPriorityOrder; | ||
| 620 | } | ||
| 621 | |||
| 622 | const startIndex = Math.max(0, firstVisibleIndex - REVIEW_HIGHLIGHT_BACKTRACK_COUNT); | ||
| 623 | const endIndex = Math.min( | ||
| 624 | parsedDiff.files.length - 1, | ||
| 625 | lastVisibleIndex + REVIEW_HIGHLIGHT_LOOKAHEAD_COUNT, | ||
| 626 | ); | ||
| 627 | |||
| 628 | const queuedIds: string[] = []; | ||
| 629 | for (let index = startIndex; index <= endIndex; index += 1) { | ||
| 630 | const fileId = parsedDiff.files[index]?.id; | ||
| 631 | if (fileId && highlightableFileIds.has(fileId)) { | ||
| 632 | queuedIds.push(fileId); | ||
| 633 | } | ||
| 634 | } | ||
| 635 | |||
| 636 | return queuedIds; | ||
| 637 | }, [highlightableFileIds, highlightableFiles.length, parsedDiff, visibleFileIds]); | ||
| 638 | const viewportWidth = Math.max(width, 280); | ||
| 639 | const reviewListContentWidth = useMemo( | ||
| 640 | () => computeReviewListContentWidth(reviewListItems, viewportWidth), | ||
| 641 | [reviewListItems, viewportWidth], | ||
| 642 | ); | ||
| 563 | const loadGitDiffs = useCallback(async () => { | 643 | const loadGitDiffs = useCallback(async () => { |
| 564 | if (!environmentId || !cwd) { | 644 | if (!environmentId || !cwd) { |
| 565 | return; | 645 | return; |
| @@ -634,6 +714,100 @@ export function ReviewSheet() { · 68 unmodified lines | |||
| 634 | void loadGitDiffs(); | 714 | void loadGitDiffs(); |
| 635 | }, [loadGitDiffs]); | 715 | }, [loadGitDiffs]); |
| 636 | 716 | ||
| 717 | useEffect(() => { | ||
| 718 | selectedThemeRef.current = selectedTheme; | ||
| 719 | }, [selectedTheme]); | ||
| 720 | |||
| 721 | useEffect(() => { | ||
| 722 | highlightableFilesByIdRef.current = new Map(highlightableFiles.map((file) => [file.id, file])); | ||
| 723 | }, [highlightableFiles]); | ||
| 724 | |||
| 725 | const startHighlightQueueRunner = useCallback(function startHighlightQueueRunner() { | ||
| 726 | if (highlightQueueActiveRef.current) { | ||
| 727 | return; | ||
| 728 | } | ||
| 729 | |||
| 730 | const generation = highlightQueueGenerationRef.current; | ||
| 731 | highlightQueueActiveRef.current = true; | ||
| 732 | |||
| 733 | void (async () => { | ||
| 734 | try { | ||
| 735 | while (generation === highlightQueueGenerationRef.current) { | ||
| 736 | const nextFileId = highlightQueueRef.current.shift(); | ||
| 737 | if (!nextFileId) { | ||
| 738 | break; | ||
| 739 | } | ||
| 740 | |||
| 741 | const file = highlightableFilesByIdRef.current.get(nextFileId); | ||
| 742 | if (!file) { | ||
| 743 | continue; | ||
| 744 | } | ||
| 745 | |||
| 746 | const theme = selectedThemeRef.current; | ||
| 747 | const cached = getCachedHighlightedReviewFile(file, theme); | ||
| 748 | if (cached) { | ||
| 749 | logReviewDiffDiagnostic("using cached highlighted file", { | ||
| 750 | fileId: file.id, | ||
| 751 | filePath: file.path, | ||
| 752 | theme, | ||
| 753 | }); | ||
| 754 | setHighlightedFilesById((current) => | ||
| 755 | current[file.id] === cached ? current : { ...current, [file.id]: cached }, | ||
| 756 | ); | ||
| 757 | continue; | ||
| 758 | } | ||
| 759 | |||
| 760 | logReviewDiffDiagnostic("requesting highlighted file", { | ||
| 761 | fileId: file.id, | ||
| 762 | filePath: file.path, | ||
| 763 | theme, | ||
| 764 | }); | ||
| 765 | |||
| 766 | try { | ||
| 767 | await new Promise<void>((resolve) => { | ||
| 768 | InteractionManager.runAfterInteractions(() => resolve()); | ||
| 769 | }); | ||
| 770 | const result = await highlightReviewFile(file, theme); | ||
| 771 | if (generation !== highlightQueueGenerationRef.current) { | ||
| 772 | logReviewDiffDiagnostic("discarding highlighted file after cancellation", { | ||
| 773 | fileId: file.id, | ||
| 774 | filePath: file.path, | ||
| 775 | }); | ||
| 776 | return; | ||
| 777 | } | ||
| 778 | |||
| 779 | logReviewDiffDiagnostic("received highlighted file", { | ||
| 780 | fileId: file.id, | ||
| 781 | filePath: file.path, | ||
| 782 | additionLines: result.additionLines.length, | ||
| 783 | deletionLines: result.deletionLines.length, | ||
| 784 | }); | ||
| 785 | setHighlightedFilesById((current) => { | ||
| 786 | if (current[file.id] === result) { | ||
| 787 | return current; | ||
| 788 | } | ||
| 789 | return { ...current, [file.id]: result }; | ||
| 790 | }); | ||
| 791 | } catch (error) { | ||
| 792 | logReviewDiffDiagnostic("highlight request failed", { | ||
| 793 | fileId: file.id, | ||
| 794 | filePath: file.path, | ||
| 795 | error: error instanceof Error ? error.message : String(error), | ||
| 796 | }); | ||
| 797 | } | ||
| 798 | } | ||
| 799 | } finally { | ||
| 800 | highlightQueueActiveRef.current = false; | ||
| 801 | if ( | ||
| 802 | generation === highlightQueueGenerationRef.current && | ||
| 803 | highlightQueueRef.current.length > 0 | ||
| 804 | ) { | ||
| 805 | startHighlightQueueRunner(); | ||
| 806 | } | ||
| 807 | } | ||
| 808 | })(); | ||
| 809 | }, []); | ||
| 810 | |||
| 637 | useEffect(() => { | 811 | useEffect(() => { |
| 638 | if (reviewSections.length === 0) { | 812 | if (reviewSections.length === 0) { |
| 639 | return; | 813 | return; |
| @@ -711,6 +885,71 @@ export function ReviewSheet() { · 71 unmodified lines | |||
| 711 | }); | 885 | }); |
| 712 | }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); | 886 | }, [parsedDiff, reviewCache.threadKey, selectedSection?.id]); |
| 713 | 887 | ||
| 888 | useEffect(() => { | ||
| 889 | setHighlightedFilesById({}); | ||
| 890 | setVisibleFileIds([]); | ||
| 891 | highlightQueueGenerationRef.current += 1; | ||
| 892 | highlightQueueRef.current = []; | ||
| 893 | highlightRequestedFileIdsRef.current = new Set(); | ||
| 894 | highlightQueueActiveRef.current = false; | ||
| 895 | clearReviewHighlightFileCache(); | ||
| 896 | loggedMissingReviewTokenKeys.clear(); | ||
| 897 | logReviewDiffDiagnostic("reset highlighted files", { | ||
| 898 | selectedSectionId: selectedSection?.id ?? null, | ||
| 899 | selectedTheme, | ||
| 900 | }); | ||
| 901 | }, [selectedSection?.id, selectedTheme]); | ||
| 902 | |||
| 903 | useEffect(() => { | ||
| 904 | if (parsedDiff.kind !== "files") { | ||
| 905 | return; | ||
| 906 | } | ||
| 907 | |||
| 908 | logReviewDiffDiagnostic("parsed diff files", { | ||
| 909 | selectedSectionId: selectedSection?.id ?? null, | ||
| 910 | fileCount: parsedDiff.fileCount, | ||
| 911 | renderableFileCount: parsedDiff.files.length, | ||
| 912 | }); | ||
| 913 | }, [parsedDiff, selectedSection?.id]); | ||
| 914 | |||
| 915 | useEffect(() => { | ||
| 916 | if (priorityHighlightFileIds.length === 0) { | ||
| 917 | logReviewDiffDiagnostic("no highlightable files", { | ||
| 918 | selectedSectionId: selectedSection?.id ?? null, | ||
| 919 | parsedDiffKind: parsedDiff.kind, | ||
| 920 | requestedFileCount: 0, | ||
| 921 | }); | ||
| 922 | return; | ||
| 923 | } | ||
| 924 | |||
| 925 | const queuedFileIds: string[] = []; | ||
| 926 | priorityHighlightFileIds.forEach((fileId) => { | ||
| 927 | if (highlightRequestedFileIdsRef.current.has(fileId)) { | ||
| 928 | return; | ||
| 929 | } | ||
| 930 | |||
| 931 | const file = highlightableFilesByIdRef.current.get(fileId); | ||
| 932 | if (!file) { | ||
| 933 | return; | ||
| 934 | } | ||
| 935 | |||
| 936 | highlightRequestedFileIdsRef.current.add(fileId); | ||
| 937 | highlightQueueRef.current.push(fileId); | ||
| 938 | queuedFileIds.push(fileId); | ||
| 939 | }); | ||
| 940 | |||
| 941 | if (queuedFileIds.length === 0) { | ||
| 942 | return; | ||
| 943 | } | ||
| 944 | |||
| 945 | logReviewDiffDiagnostic("scheduling file highlights", { | ||
| 946 | selectedSectionId: selectedSection?.id ?? null, | ||
| 947 | fileCount: queuedFileIds.length, | ||
| 948 | fileIds: queuedFileIds, | ||
| 949 | }); | ||
| 950 | startHighlightQueueRunner(); | ||
| 951 | }, [parsedDiff.kind, priorityHighlightFileIds, selectedSection?.id, startHighlightQueueRunner]); | ||
| 952 | |||
| 714 | const refreshSelectedSection = useCallback(async () => { | 953 | const refreshSelectedSection = useCallback(async () => { |
| 715 | if (!selectedSection) { | 954 | if (!selectedSection) { |
| 716 | return; | 955 | return; |
| @@ -841,77 +1080,166 @@ export function ReviewSheet() { · 124 unmodified lines | |||
| 841 | 1080 | ||
| 842 | return <>{children}</>; | 1081 | return <>{children}</>; |
| 843 | }, [error, parsedDiffNotice]); | 1082 | }, [error, parsedDiffNotice]); |
| 1083 | const stickyHeaderIndices = useMemo(() => { | ||
| 1084 | const itemOffset = listHeader ? 1 : 0; | ||
| 1085 | |||
| 1086 | return reviewListItems.flatMap((item, index) => | ||
| 1087 | item.kind === "file-header" ? [index + itemOffset] : [], | ||
| 1088 | ); | ||
| 1089 | }, [listHeader, reviewListItems]); | ||
| 1090 | |||
| 1091 | const viewabilityConfigRef = useRef({ | ||
| 1092 | itemVisiblePercentThreshold: 10, | ||
| 1093 | }); | ||
| 1094 | const onViewableItemsChangedRef = useRef( | ||
| 1095 | ({ | ||
| 1096 | viewableItems, | ||
| 1097 | }: { | ||
| 1098 | readonly viewableItems: Array<ViewToken<ReviewListItem>>; | ||
| 1099 | readonly changed: Array<ViewToken<ReviewListItem>>; | ||
| 1100 | }) => { | ||
| 1101 | const nextVisibleFileIds = Array.from( | ||
| 1102 | new Set( | ||
| 1103 | viewableItems | ||
| 1104 | .filter((token) => token.isViewable && token.item !== undefined) | ||
| 1105 | .map((token) => token.item.fileId), | ||
| 1106 | ), | ||
| 1107 | ); | ||
| 1108 | |||
| 1109 | setVisibleFileIds(nextVisibleFileIds); | ||
| 1110 | }, | ||
| 1111 | ); | ||
| 844 | 1112 | ||
| 845 | const renderFileItem = useCallback( | 1113 | const renderReviewListItem = useCallback( |
| 846 | ({ item }: { item: ReviewRenderableFile; index: number }) => { | 1114 | ({ item }: ListRenderItemInfo<ReviewListItem>) => { |
| 847 | if (!selectedSection) { | 1115 | if (!selectedSection) { |
| 848 | return null; | 1116 | return null; |
| 849 | } | 1117 | } |
| 850 | const pendingSelectionForFile = | ||
| 851 | pendingCommentSelection && | ||
| 852 | pendingCommentSelection.sectionTitle === selectedSection.title && | ||
| 853 | pendingCommentSelection.filePath === item.path | ||
| 854 | ? pendingCommentSelection | ||
| 855 | : null; | ||
| 856 | const selectedTargetForFile = | ||
| 857 | activeCommentTarget && | ||
| 858 | activeCommentTarget.sectionTitle === selectedSection.title && | ||
| 859 | activeCommentTarget.filePath === item.path | ||
| 860 | ? activeCommentTarget | ||
| 861 | : null; | ||
| 862 | 1118 | ||
| 863 | return ( | 1119 | switch (item.kind) { |
| 864 | <ReviewFileItem | 1120 | case "file-header": |
| 865 | file={item} | 1121 | return ( |
| 866 | expanded={expandedFileIds.includes(item.id)} | 1122 | <ReviewFileCard |
| 867 | sectionTitle={selectedSection.title} | 1123 | file={item.file} |
| 868 | selectedTheme={selectedTheme} | 1124 | fileId={item.fileId} |
| 869 | pendingSelection={pendingSelectionForFile} | 1125 | expanded={item.expanded} |
| 870 | selectedTarget={selectedTargetForFile} | 1126 | viewportWidth={reviewListContentWidth} |
| 871 | viewportWidth={Math.max(width, 280)} | 1127 | onToggleFile={handleToggleExpandedFile} |
| 872 | revealedLarge={revealedLargeFileIds.includes(item.id)} | 1128 | /> |
| 873 | onToggleFile={handleToggleExpandedFile} | 1129 | ); |
| 874 | onLoadDiffFile={handleRevealLargeDiff} | 1130 | case "file-suppressed": |
| 875 | onPressLine={handlePressLine} | 1131 | return ( |
| 876 | onStartRangeSelection={handleStartRangeSelection} | 1132 | <ReviewFileSuppressedBody |
| 877 | /> | 1133 | message={item.message} |
| 878 | ); | 1134 | actionLabel={item.actionLabel} |
| 1135 | fileId={item.fileId} | ||
| 1136 | viewportWidth={reviewListContentWidth} | ||
| 1137 | onLoadDiffFile={handleRevealLargeDiff} | ||
| 1138 | /> | ||
| 1139 | ); | ||
| 1140 | case "hunk": | ||
| 1141 | return ( | ||
| 1142 | <ReviewHunkRow | ||
| 1143 | header={item.row.header} | ||
| 1144 | context={item.row.context} | ||
| 1145 | viewportWidth={reviewListContentWidth} | ||
| 1146 | /> | ||
| 1147 | ); | ||
| 1148 | case "line": { | ||
| 1149 | const file = reviewFileById.get(item.fileId); | ||
| 1150 | if (!file) { | ||
| 1151 | return null; | ||
| 1152 | } | ||
| 1153 | |||
| 1154 | const fileLineRows = reviewLineRowsByFileId.get(file.id) ?? []; | ||
| 1155 | const lineIndex = reviewLineIndexByRowId.get(item.row.id); | ||
| 1156 | if (lineIndex === undefined) { | ||
| 1157 | return null; | ||
| 1158 | } | ||
| 1159 | |||
| 1160 | const pendingSelectionForFile = | ||
| 1161 | pendingCommentSelection && | ||
| 1162 | pendingCommentSelection.sectionTitle === selectedSection.title && | ||
| 1163 | pendingCommentSelection.filePath === file.path | ||
| 1164 | ? pendingCommentSelection | ||
| 1165 | : null; | ||
| 1166 | const selectedTargetForFile = | ||
| 1167 | activeCommentTarget && | ||
| 1168 | activeCommentTarget.sectionTitle === selectedSection.title && | ||
| 1169 | activeCommentTarget.filePath === file.path | ||
| 1170 | ? activeCommentTarget | ||
| 1171 | : null; | ||
| 1172 | const highlightedFile = | ||
| 1173 | highlightedFilesById[file.id] ?? | ||
| 1174 | getCachedHighlightedReviewFile(file, selectedTheme) ?? | ||
| 1175 | null; | ||
| 1176 | |||
| 1177 | if (highlightedFile === null) { | ||
| 1178 | const missingTokenKey = `${selectedSection.id}:${file.id}`; | ||
| 1179 | if (!loggedMissingReviewTokenKeys.has(missingTokenKey)) { | ||
| 1180 | loggedMissingReviewTokenKeys.add(missingTokenKey); | ||
| 1181 | logReviewDiffDiagnostic("rendering file without tokens", { | ||
| 1182 | sectionId: selectedSection.id, | ||
| 1183 | fileId: file.id, | ||
| 1184 | filePath: file.path, | ||
| 1185 | highlightedFileKnownInState: highlightedFilesById[file.id] !== undefined, | ||
| 1186 | }); | ||
| 1187 | } | ||
| 1188 | } | ||
| 1189 | |||
| 1190 | const selectionState = | ||
| 1191 | pendingSelectionForFile?.anchorIndex === lineIndex | ||
| 1192 | ? ("anchor" as const) | ||
| 1193 | : selectedTargetForFile && | ||
| 1194 | lineIndex >= selectedTargetForFile.startIndex && | ||
| 1195 | lineIndex <= selectedTargetForFile.endIndex | ||
| 1196 | ? ("selected" as const) | ||
| 1197 | : null; | ||
| 1198 | |||
| 1199 | return ( | ||
| 1200 | <ReviewLineRow | ||
| 1201 | line={item.row} | ||
| 1202 | tokens={getHighlightedTokensForLine(item.row, highlightedFile)} | ||
| 1203 | viewportWidth={reviewListContentWidth} | ||
| 1204 | selectionState={selectionState} | ||
| 1205 | onComment={() => | ||
| 1206 | handlePressLine({ | ||
| 1207 | sectionTitle: selectedSection.title, | ||
| 1208 | filePath: file.path, | ||
| 1209 | lines: fileLineRows, | ||
| 1210 | lineIndex, | ||
| 1211 | }) | ||
| 1212 | } | ||
| 1213 | onStartRangeSelection={() => | ||
| 1214 | handleStartRangeSelection({ | ||
| 1215 | sectionTitle: selectedSection.title, | ||
| 1216 | filePath: file.path, | ||
| 1217 | lines: fileLineRows, | ||
| 1218 | lineIndex, | ||
| 1219 | }) | ||
| 1220 | } | ||
| 1221 | /> | ||
| 1222 | ); | ||
| 1223 | } | ||
| 1224 | } | ||
| 879 | }, | 1225 | }, |
| 880 | [ | 1226 | [ |
| 881 | activeCommentTarget, | 1227 | activeCommentTarget, |
| 882 | expandedFileIds, | ||
| 883 | handlePressLine, | 1228 | handlePressLine, |
| 884 | handleRevealLargeDiff, | 1229 | handleRevealLargeDiff, |
| 885 | handleStartRangeSelection, | 1230 | handleStartRangeSelection, |
| 886 | handleToggleExpandedFile, | 1231 | handleToggleExpandedFile, |
| 1232 | highlightedFilesById, | ||
| 887 | pendingCommentSelection, | 1233 | pendingCommentSelection, |
| 888 | revealedLargeFileIds, | 1234 | reviewFileById, |
| 1235 | reviewLineIndexByRowId, | ||
| 1236 | reviewLineRowsByFileId, | ||
| 1237 | reviewListContentWidth, | ||
| 889 | selectedSection, | 1238 | selectedSection, |
| 890 | selectedTheme, | 1239 | selectedTheme, |
| 891 | width, | ||
| 892 | ], | 1240 | ], |
| 893 | ); | 1241 | ); |
| 894 | 1242 | ||
| 895 | const visibleListExtraData = useMemo(() => { | ||
| 896 | return { | ||
| 897 | selectedSectionId: selectedSection?.id ?? null, | ||
| 898 | selectedTheme, | ||
| 899 | expandedFileIds, | ||
| 900 | revealedLargeFileIds, | ||
| 901 | pendingSelection: pendingCommentSelection, | ||
| 902 | selectedTarget: activeCommentTarget, | ||
| 903 | viewportWidth: Math.max(width, 280), | ||
| 904 | }; | ||
| 905 | }, [ | ||
| 906 | activeCommentTarget, | ||
| 907 | expandedFileIds, | ||
| 908 | pendingCommentSelection, | ||
| 909 | revealedLargeFileIds, | ||
| 910 | selectedSection, | ||
| 911 | selectedTheme, | ||
| 912 | width, | ||
| 913 | ]); | ||
| 914 | |||
| 915 | return ( | 1243 | return ( |
| 916 | <> | 1244 | <> |
| 917 | <Stack.Screen | 1245 | <Stack.Screen |
| @@ -1045,20 +1373,26 @@ export function ReviewSheet() { · 127 unmodified lines | |||
| 1045 | 1373 | ||
| 1046 | <View className="flex-1 bg-sheet"> | 1374 | <View className="flex-1 bg-sheet"> |
| 1047 | {selectedSection && parsedDiff.kind === "files" ? ( | 1375 | {selectedSection && parsedDiff.kind === "files" ? ( |
| 1048 | <LegendList | 1376 | <FlatList |
| 1049 | style={{ flex: 1 }} | 1377 | style={{ flex: 1 }} |
| 1050 | contentInsetAdjustmentBehavior="never" | 1378 | contentInsetAdjustmentBehavior="never" |
| 1051 | contentInset={{ top: topContentInset }} | 1379 | contentInset={{ top: topContentInset }} |
| 1380 | contentOffset={{ x: 0, y: -topContentInset }} | ||
| 1052 | scrollIndicatorInsets={{ top: topContentInset }} | 1381 | scrollIndicatorInsets={{ top: topContentInset }} |
| 1053 | data={parsedDiff.files as ReviewRenderableFile[]} | 1382 | data={reviewListItems} |
| 1054 | renderItem={renderFileItem} | 1383 | renderItem={renderReviewListItem} |
| 1055 | keyExtractor={(file) => file.id} | 1384 | keyExtractor={(item) => item.id} |
| 1056 | extraData={visibleListExtraData} | ||
| 1057 | keyboardShouldPersistTaps="handled" | 1385 | keyboardShouldPersistTaps="handled" |
| 1058 | estimatedItemSize={220} | ||
| 1059 | drawDistance={900} | ||
| 1060 | recycleItems | ||
| 1061 | ListHeaderComponent={listHeader} | 1386 | ListHeaderComponent={listHeader} |
| 1387 | stickyHeaderIndices={stickyHeaderIndices} | ||
| 1388 | removeClippedSubviews={false} | ||
| 1389 | initialNumToRender={24} | ||
| 1390 | maxToRenderPerBatch={16} | ||
| 1391 | updateCellsBatchingPeriod={16} | ||
| 1392 | windowSize={10} | ||
| 1393 | scrollEventThrottle={16} | ||
| 1394 | onViewableItemsChanged={onViewableItemsChangedRef.current} | ||
| 1395 | viewabilityConfig={viewabilityConfigRef.current} | ||
| 1062 | contentContainerStyle={{ | 1396 | contentContainerStyle={{ |
| 1063 | paddingTop: REVIEW_HEADER_SPACING, | 1397 | paddingTop: REVIEW_HEADER_SPACING, |
| 1064 | paddingBottom: Math.max(insets.bottom, 18) + 18, | 1398 | paddingBottom: Math.max(insets.bottom, 18) + 18, |
| @@ -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 | import { cn } from "../../../lib/cn"; | 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 | export function changeTone(change: ReviewRenderableLineRow["change"]): string { | 19 | export function changeTone(change: ReviewRenderableLineRow["change"]): string { |
| 20 | if (change === "add") return "bg-emerald-500/12"; | 20 | if (change === "add") return "bg-emerald-500/10"; |
| 21 | if (change === "delete") return "bg-rose-500/12"; | 21 | if (change === "delete") return "bg-rose-500/10"; |
| 22 | return "bg-card"; | 22 | return "bg-card"; |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 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 | export function DiffTokenText(props: { | 57 | export function DiffTokenText(props: { |
| 26 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; | 58 | readonly tokens: ReadonlyArray<ReviewHighlightedToken> | null; |
| 27 | readonly fallback: string; | 59 | readonly fallback: string; |
| 60 | readonly change?: ReviewRenderableLineRow["change"]; | ||
| 28 | readonly className?: string; | 61 | readonly className?: string; |
| 29 | }) { | 62 | }) { |
| 30 | if (!props.tokens || props.tokens.length === 0) { | 63 | if (!props.tokens || props.tokens.length === 0) { |
| @@ -70,6 +103,11 @@ export function DiffTokenText(props: { · 39 unmodified lines | |||
| 70 | fontFamily: REVIEW_MONO_FONT_FAMILY, | 103 | fontFamily: REVIEW_MONO_FONT_FAMILY, |
| 71 | fontWeight, | 104 | fontWeight, |
| 72 | fontStyle, | 105 | fontStyle, |
| 106 | backgroundColor: | ||
| 107 | token.diffHighlight && props.change | ||
| 108 | ? diffHighlightColor(props.change) | ||
| 109 | : undefined, | ||
| 110 | borderRadius: token.diffHighlight ? 4 : undefined, | ||
| 73 | }} | 111 | }} |
| 74 | > | 112 | > |
| 75 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} | 113 | {token.content.length > 0 ? renderVisibleWhitespace(token.content) : " "} |
| @@ -13,6 +13,7 @@ describe("resolveReviewHighlighterEnginePreference", () => { · 12 unmodified lines | |||
| 13 | 13 | ||
| 14 | it("accepts supported values", () => { | 14 | it("accepts supported values", () => { |
| 15 | expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript"); | 15 | expect(resolveReviewHighlighterEnginePreference("javascript")).toBe("javascript"); |
| 16 | expect(resolveReviewHighlighterEnginePreference("js")).toBe("javascript"); | ||
| 16 | expect(resolveReviewHighlighterEnginePreference("native")).toBe("native"); | 17 | expect(resolveReviewHighlighterEnginePreference("native")).toBe("native"); |
| 17 | }); | 18 | }); |
| 18 | }); | 19 | }); |
| @@ -5,9 +5,11 @@ export function resolveReviewHighlighterEnginePreference( · 4 unmodified lines | |||
| 5 | value: string | undefined, | 5 | value: string | undefined, |
| 6 | ): ReviewHighlighterEnginePreference { | 6 | ): ReviewHighlighterEnginePreference { |
| 7 | switch (value) { | 7 | switch (value) { |
| 8 | case "js": | ||
| 8 | case "javascript": | 9 | case "javascript": |
| 10 | return "javascript"; | ||
| 9 | case "native": | 11 | case "native": |
| 10 | return value; | 12 | return "native"; |
| 11 | default: | 13 | default: |
| 12 | return "javascript"; | 14 | return "javascript"; |
| 13 | } | 15 | } |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | |||
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | buildReviewListItems, | ||
| 11 | buildReviewParsedDiff, | 12 | buildReviewParsedDiff, |
| 12 | buildReviewSectionItems, | 13 | buildReviewSectionItems, |
| 13 | getDefaultReviewSectionId, | 14 | getDefaultReviewSectionId, |
| @@ -138,6 +139,7 @@ describe("buildReviewParsedDiff", () => { · 124 unmodified lines | |||
| 138 | oldLineNumber: 1, | 139 | oldLineNumber: 1, |
| 139 | newLineNumber: null, | 140 | newLineNumber: null, |
| 140 | content: "const before = 1;", | 141 | content: "const before = 1;", |
| 142 | comparison: { change: "add", tokenIndex: 0 }, | ||
| 141 | }), | 143 | }), |
| 142 | expect.objectContaining({ | 144 | expect.objectContaining({ |
| 143 | kind: "line", | 145 | kind: "line", |
| @@ -145,6 +147,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | |||
| 145 | oldLineNumber: null, | 147 | oldLineNumber: null, |
| 146 | newLineNumber: 1, | 148 | newLineNumber: 1, |
| 147 | content: "const after = 2;", | 149 | content: "const after = 2;", |
| 150 | comparison: { change: "delete", tokenIndex: 0 }, | ||
| 148 | }), | 151 | }), |
| 149 | expect.objectContaining({ | 152 | expect.objectContaining({ |
| 150 | kind: "line", | 153 | kind: "line", |
| @@ -152,6 +155,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | |||
| 152 | oldLineNumber: null, | 155 | oldLineNumber: null, |
| 153 | newLineNumber: 2, | 156 | newLineNumber: 2, |
| 154 | content: "console.log(after);", | 157 | content: "console.log(after);", |
| 158 | comparison: null, | ||
| 155 | }), | 159 | }), |
| 156 | expect.objectContaining({ | 160 | expect.objectContaining({ |
| 157 | kind: "line", | 161 | kind: "line", |
| @@ -159,6 +163,7 @@ describe("buildReviewParsedDiff", () => { · 1 unmodified line | |||
| 159 | oldLineNumber: 2, | 163 | oldLineNumber: 2, |
| 160 | newLineNumber: 3, | 164 | newLineNumber: 3, |
| 161 | content: "return true;", | 165 | content: "return true;", |
| 166 | comparison: null, | ||
| 162 | }), | 167 | }), |
| 163 | ]); | 168 | ]); |
| 164 | }); | 169 | }); |
| @@ -220,6 +225,7 @@ describe("buildReviewParsedDiff", () => { · 55 unmodified lines | |||
| 220 | content: `const line${index} = ${index};`, | 225 | content: `const line${index} = ${index};`, |
| 221 | additionTokenIndex: index, | 226 | additionTokenIndex: index, |
| 222 | deletionTokenIndex: null, | 227 | deletionTokenIndex: null, |
| 228 | comparison: null, | ||
| 223 | })), | 229 | })), |
| 224 | }), | 230 | }), |
| 225 | ); | 231 | ); |
| @@ -231,4 +237,73 @@ describe("buildReviewParsedDiff", () => { · 5 unmodified lines | |||
| 231 | actionLabel: "Load diff", | 237 | actionLabel: "Load diff", |
| 232 | }); | 238 | }); |
| 233 | }); | 239 | }); |
| 240 | |||
| 241 | it("flattens expanded files into virtualizable review items", () => { | ||
| 242 | const file = makeRenderableFile({ | ||
| 243 | path: "apps/mobile/src/a.ts", | ||
| 244 | rows: [ | ||
| 245 | { | ||
| 246 | kind: "hunk", | ||
| 247 | id: "hunk-1", | ||
| 248 | header: "@@ -1,1 +1,2 @@", | ||
| 249 | context: null, | ||
| 250 | }, | ||
| 251 | { | ||
| 252 | kind: "line", | ||
| 253 | id: "line-1", | ||
| 254 | change: "add", | ||
| 255 | oldLineNumber: null, | ||
| 256 | newLineNumber: 1, | ||
| 257 | content: "const after = 2;", | ||
| 258 | additionTokenIndex: 0, | ||
| 259 | deletionTokenIndex: null, | ||
| 260 | comparison: null, | ||
| 261 | }, | ||
| 262 | ], | ||
| 263 | }); | ||
| 264 | |||
| 265 | const items = buildReviewListItems({ | ||
| 266 | files: [file], | ||
| 267 | expandedFileIds: [file.id], | ||
| 268 | revealedLargeFileIds: [], | ||
| 269 | }); | ||
| 270 | |||
| 271 | expect(items).toEqual([ | ||
| 272 | expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), | ||
| 273 | expect.objectContaining({ kind: "hunk", fileId: file.id, row: file.rows[0] }), | ||
| 274 | expect.objectContaining({ kind: "line", fileId: file.id, row: file.rows[1] }), | ||
| 275 | ]); | ||
| 276 | }); | ||
| 277 | |||
| 278 | it("keeps large diffs collapsed into a placeholder item until revealed", () => { | ||
| 279 | const file = makeRenderableFile({ | ||
| 280 | path: "apps/mobile/src/big.ts", | ||
| 281 | rows: Array.from({ length: 401 }, (_, index) => ({ | ||
| 282 | kind: "line" as const, | ||
| 283 | id: `line-${index}`, | ||
| 284 | change: "add" as const, | ||
| 285 | oldLineNumber: null, | ||
| 286 | newLineNumber: index + 1, | ||
| 287 | content: `const line${index} = ${index};`, | ||
| 288 | additionTokenIndex: index, | ||
| 289 | deletionTokenIndex: null, | ||
| 290 | comparison: null, | ||
| 291 | })), | ||
| 292 | }); | ||
| 293 | |||
| 294 | const items = buildReviewListItems({ | ||
| 295 | files: [file], | ||
| 296 | expandedFileIds: [file.id], | ||
| 297 | revealedLargeFileIds: [], | ||
| 298 | }); | ||
| 299 | |||
| 300 | expect(items).toEqual([ | ||
| 301 | expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), | ||
| 302 | expect.objectContaining({ | ||
| 303 | kind: "file-suppressed", | ||
| 304 | fileId: file.id, | ||
| 305 | actionLabel: "Load diff", | ||
| 306 | }), | ||
| 307 | ]); | ||
| 308 | }); | ||
| 234 | }); | 309 | }); |
| @@ -30,6 +30,10 @@ export interface ReviewRenderableLineRow { · 29 unmodified lines | |||
| 30 | readonly content: string; | 30 | readonly content: string; |
| 31 | readonly additionTokenIndex: number | null; | 31 | readonly additionTokenIndex: number | null; |
| 32 | readonly deletionTokenIndex: number | null; | 32 | readonly deletionTokenIndex: number | null; |
| 33 | readonly comparison: { | ||
| 34 | readonly change: "add" | "delete"; | ||
| 35 | readonly tokenIndex: number; | ||
| 36 | } | null; | ||
| 33 | } | 37 | } |
| 34 | 38 | ||
| 35 | export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow; | 39 | export type ReviewRenderableRow = ReviewRenderableHunkRow | ReviewRenderableLineRow; |
| @@ -48,6 +52,42 @@ export interface ReviewRenderableFile { · 12 unmodified lines | |||
| 48 | readonly rows: ReadonlyArray<ReviewRenderableRow>; | 52 | readonly rows: ReadonlyArray<ReviewRenderableRow>; |
| 49 | } | 53 | } |
| 50 | 54 | ||
| 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 | export type ReviewFilePreviewState = | 91 | export type ReviewFilePreviewState = |
| 52 | | { | 92 | | { |
| 53 | readonly kind: "render"; | 93 | readonly kind: "render"; |
| @@ -267,6 +307,72 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil · 213 unmodified lines | |||
| 267 | return { kind: "render" }; | 307 | return { kind: "render" }; |
| 268 | } | 308 | } |
| 269 | 309 | ||
| 310 | // The flattened review list item model is inspired by pierre/diffs' iterator-first | ||
| 311 | // virtualization architecture, adapted here for React Native and LegendList. | ||
| 312 | // Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs | ||
| 313 | // Reference files: | ||
| 314 | // - src/utils/iterateOverDiff.ts | ||
| 315 | // - src/components/VirtualizedFileDiff.ts | ||
| 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 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { | 376 | function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { |
| 271 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; | 377 | return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; |
| 272 | } | 378 | } |
| @@ -304,6 +410,7 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 31 unmodified lines | |||
| 304 | ), | 410 | ), |
| 305 | additionTokenIndex, | 411 | additionTokenIndex, |
| 306 | deletionTokenIndex, | 412 | deletionTokenIndex, |
| 413 | comparison: null, | ||
| 307 | }); | 414 | }); |
| 308 | deletionLineNumber += 1; | 415 | deletionLineNumber += 1; |
| 309 | additionLineNumber += 1; | 416 | additionLineNumber += 1; |
| @@ -313,6 +420,10 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 3 unmodified lines | |||
| 313 | return; | 420 | return; |
| 314 | } | 421 | } |
| 315 | 422 | ||
| 423 | const pairedLineCount = Math.min(segment.deletions, segment.additions); | ||
| 424 | const deletionTokenIndexStart = deletionTokenIndex; | ||
| 425 | const additionTokenIndexStart = additionTokenIndex; | ||
| 426 | |||
| 316 | for (let index = 0; index < segment.deletions; index += 1) { | 427 | for (let index = 0; index < segment.deletions; index += 1) { |
| 317 | rows.push({ | 428 | rows.push({ |
| 318 | kind: "line", | 429 | kind: "line", |
| @@ -323,6 +434,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 4 unmodified lines | |||
| 323 | content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""), | 434 | content: stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""), |
| 324 | additionTokenIndex: null, | 435 | additionTokenIndex: null, |
| 325 | deletionTokenIndex, | 436 | deletionTokenIndex, |
| 437 | comparison: | ||
| 438 | index < pairedLineCount | ||
| 439 | ? { | ||
| 440 | change: "add", | ||
| 441 | tokenIndex: additionTokenIndexStart + index, | ||
| 442 | } | ||
| 443 | : null, | ||
| 326 | }); | 444 | }); |
| 327 | deletionLineNumber += 1; | 445 | deletionLineNumber += 1; |
| 328 | deletionTokenIndex += 1; | 446 | deletionTokenIndex += 1; |
| @@ -338,6 +456,13 @@ function buildRenderableRows(file: FileDiffMetadata): ReadonlyArray<ReviewRender · 9 unmodified lines | |||
| 338 | content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""), | 456 | content: stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""), |
| 339 | additionTokenIndex, | 457 | additionTokenIndex, |
| 340 | deletionTokenIndex: null, | 458 | deletionTokenIndex: null, |
| 459 | comparison: | ||
| 460 | index < pairedLineCount | ||
| 461 | ? { | ||
| 462 | change: "delete", | ||
| 463 | tokenIndex: deletionTokenIndexStart + index, | ||
| 464 | } | ||
| 465 | : null, | ||
| 341 | }); | 466 | }); |
| 342 | additionLineNumber += 1; | 467 | additionLineNumber += 1; |
| 343 | additionTokenIndex += 1; | 468 | additionTokenIndex += 1; |
| @@ -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 | expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true); | 41 | expect(highlighted.additionLines[2]?.some((token) => token.content === "const")).toBe(true); |
| 42 | expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true); | 42 | expect(highlighted.additionLines[3]?.some((token) => token.content === "expect")).toBe(true); |
| 43 | }); | 43 | }); |
| 44 | |||
| 45 | it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { | ||
| 46 | const file = makeRenderableFile({ | ||
| 47 | path: "apps/mobile/src/example-inline-diff.test.ts", | ||
| 48 | additionLines: ["const after = 2;"], | ||
| 49 | deletionLines: ["const before = 1;"], | ||
| 50 | rows: [ | ||
| 51 | { | ||
| 52 | kind: "line", | ||
| 53 | id: "delete-1", | ||
| 54 | change: "delete", | ||
| 55 | oldLineNumber: 1, | ||
| 56 | newLineNumber: null, | ||
| 57 | content: "const before = 1;", | ||
| 58 | additionTokenIndex: null, | ||
| 59 | deletionTokenIndex: 0, | ||
| 60 | comparison: { change: "add", tokenIndex: 0 }, | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | kind: "line", | ||
| 64 | id: "add-1", | ||
| 65 | change: "add", | ||
| 66 | oldLineNumber: null, | ||
| 67 | newLineNumber: 1, | ||
| 68 | content: "const after = 2;", | ||
| 69 | additionTokenIndex: 0, | ||
| 70 | deletionTokenIndex: null, | ||
| 71 | comparison: { change: "delete", tokenIndex: 0 }, | ||
| 72 | }, | ||
| 73 | ], | ||
| 74 | }); | ||
| 75 | |||
| 76 | const highlighted = await highlightReviewFile(file, "light"); | ||
| 77 | |||
| 78 | expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); | ||
| 79 | expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("falls back to plain tokens for very long lines", async () => { | ||
| 83 | const longLine = `const value = "${"a".repeat(1_100)}";`; | ||
| 84 | const file = makeRenderableFile({ | ||
| 85 | path: "apps/mobile/src/example-long-line.ts", | ||
| 86 | additionLines: [longLine], | ||
| 87 | rows: [ | ||
| 88 | { | ||
| 89 | kind: "line", | ||
| 90 | id: "add-1", | ||
| 91 | change: "add", | ||
| 92 | oldLineNumber: null, | ||
| 93 | newLineNumber: 1, | ||
| 94 | content: longLine, | ||
| 95 | additionTokenIndex: 0, | ||
| 96 | deletionTokenIndex: null, | ||
| 97 | comparison: null, | ||
| 98 | }, | ||
| 99 | ], | ||
| 100 | }); | ||
| 101 | |||
| 102 | const highlighted = await highlightReviewFile(file, "light"); | ||
| 103 | |||
| 104 | expect(highlighted.additionLines).toHaveLength(1); | ||
| 105 | expect(highlighted.additionLines[0]).toEqual([ | ||
| 106 | { | ||
| 107 | content: longLine, | ||
| 108 | color: null, | ||
| 109 | fontStyle: null, | ||
| 110 | }, | ||
| 111 | ]); | ||
| 112 | }); | ||
| 44 | }); | 113 | }); |
| @@ -8,13 +8,15 @@ import { · 7 unmodified lines | |||
| 8 | type ReviewHighlighterEngine, | 8 | type ReviewHighlighterEngine, |
| 9 | } from "./reviewHighlighterEngine"; | 9 | } from "./reviewHighlighterEngine"; |
| 10 | import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; | 10 | import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; |
| 11 | import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; | ||
| 11 | 12 | ||
| 12 | export type ReviewDiffTheme = "light" | "dark"; | 13 | export type ReviewDiffTheme = "light" | "dark"; |
| 13 | 14 | ||
| 14 | export interface ReviewHighlightedToken { | 15 | export interface ReviewHighlightedToken { |
| 15 | readonly content: string; | 16 | content: string; |
| 16 | readonly color: string | null; | 17 | readonly color: string | null; |
| 17 | readonly fontStyle: number | null; | 18 | readonly fontStyle: number | null; |
| 19 | readonly diffHighlight?: boolean; | ||
| 18 | } | 20 | } |
| 19 | 21 | ||
| 20 | export interface ReviewHighlightedFile { | 22 | export interface ReviewHighlightedFile { |
| @@ -26,13 +28,19 @@ const SHIKI_THEME_NAME_BY_SCHEME = { · 5 unmodified lines | |||
| 26 | light: "github-light-default", | 28 | light: "github-light-default", |
| 27 | dark: "github-dark-default", | 29 | dark: "github-dark-default", |
| 28 | } as const; | 30 | } as const; |
| 31 | const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = | ||
| 32 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_ENGINE ?? "javascript"; | ||
| 29 | const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( | 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 | const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( | 36 | const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( |
| 33 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, | 37 | process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, |
| 34 | false, | 38 | false, |
| 35 | ); | 39 | ); |
| 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 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); | 44 | const highlightCache = new Map<string, Promise<ReviewHighlightedFile>>(); |
| 37 | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); | 45 | const resolvedHighlightCache = new Map<string, ReviewHighlightedFile>(); |
| 38 | const loadedLanguages = new Set<string>(["text"]); | 46 | const loadedLanguages = new Set<string>(["text"]); |
| @@ -205,6 +213,17 @@ function joinPatchLines(lines: ReadonlyArray<string>): string { · 166 unmodified lines | |||
| 205 | return lines.map(stripTrailingNewline).join("\n"); | 213 | return lines.map(stripTrailingNewline).join("\n"); |
| 206 | } | 214 | } |
| 207 | 215 | ||
| 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 | async function getHighlighter(): Promise<HighlighterCore> { | 227 | async function getHighlighter(): Promise<HighlighterCore> { |
| 209 | if (!highlighterPromise) { | 228 | if (!highlighterPromise) { |
| 210 | const configuredHighlighterPromise = Promise.all([ | 229 | const configuredHighlighterPromise = Promise.all([ |
| @@ -214,6 +233,7 @@ async function getHighlighter(): Promise<HighlighterCore> { · 3 unmodified lines | |||
| 214 | let nativeEngineAvailable = false; | 233 | let nativeEngineAvailable = false; |
| 215 | 234 | ||
| 216 | logReviewHighlighterDiagnostic("initializing", { | 235 | logReviewHighlighterDiagnostic("initializing", { |
| 236 | configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, | ||
| 217 | preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, | 237 | preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, |
| 218 | resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, | 238 | resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, |
| 219 | }); | 239 | }); |
| @@ -379,6 +399,146 @@ function normalizeHighlightedLines( · 159 unmodified lines | |||
| 379 | ); | 399 | ); |
| 380 | } | 400 | } |
| 381 | 401 | ||
| 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 | async function highlightLines( | 542 | async function highlightLines( |
| 383 | code: string, | 543 | code: string, |
| 384 | language: string, | 544 | language: string, |
| @@ -389,14 +549,76 @@ async function highlightLines( · 4 unmodified lines | |||
| 389 | } | 549 | } |
| 390 | 550 | ||
| 391 | const highlighter = await getHighlighter(); | 551 | const highlighter = await getHighlighter(); |
| 392 | const tokenLines = highlighter.codeToTokensBase(code, { lang: language, theme }); | 552 | const sourceLines = code.split("\n"); |
| 393 | return normalizeHighlightedLines(tokenLines); | 553 | const highlightedLines: Array<ReadonlyArray<ReviewHighlightedToken>> = []; |
| 554 | const shortLineBatch: string[] = []; | ||
| 555 | |||
| 556 | const flushShortLineBatch = async (): Promise<void> => { | ||
| 557 | if (shortLineBatch.length === 0) { | ||
| 558 | return; | ||
| 559 | } | ||
| 560 | |||
| 561 | const tokenLines = highlighter.codeToTokensBase(shortLineBatch.join("\n"), { | ||
| 562 | lang: language, | ||
| 563 | theme, | ||
| 564 | }); | ||
| 565 | highlightedLines.push(...normalizeHighlightedLines(tokenLines)); | ||
| 566 | shortLineBatch.length = 0; | ||
| 567 | }; | ||
| 568 | |||
| 569 | for (let lineIndex = 0; lineIndex < sourceLines.length; lineIndex += 1) { | ||
| 570 | const line = sourceLines[lineIndex] ?? ""; | ||
| 571 | |||
| 572 | if (line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { | ||
| 573 | await flushShortLineBatch(); | ||
| 574 | highlightedLines.push([{ content: line, color: null, fontStyle: null }]); | ||
| 575 | } else { | ||
| 576 | shortLineBatch.push(line); | ||
| 577 | } | ||
| 578 | |||
| 579 | if (shortLineBatch.length >= REVIEW_HIGHLIGHT_CHUNK_SIZE) { | ||
| 580 | await flushShortLineBatch(); | ||
| 581 | } | ||
| 582 | |||
| 583 | if ( | ||
| 584 | sourceLines.length > REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD && | ||
| 585 | lineIndex + 1 < sourceLines.length && | ||
| 586 | (shortLineBatch.length === 0 || line.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) | ||
| 587 | ) { | ||
| 588 | await waitForNextFrame(); | ||
| 589 | } | ||
| 590 | } | ||
| 591 | |||
| 592 | await flushShortLineBatch(); | ||
| 593 | |||
| 594 | return highlightedLines; | ||
| 394 | } | 595 | } |
| 395 | 596 | ||
| 396 | function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { | 597 | function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { |
| 397 | return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; | 598 | return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; |
| 398 | } | 599 | } |
| 399 | 600 | ||
| 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 | export function getCachedHighlightedReviewFile( | 622 | export function getCachedHighlightedReviewFile( |
| 401 | file: ReviewRenderableFile, | 623 | file: ReviewRenderableFile, |
| 402 | theme: ReviewDiffTheme, | 624 | theme: ReviewDiffTheme, |
| @@ -417,32 +639,72 @@ export async function highlightReviewFile( · 14 unmodified lines | |||
| 417 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | 639 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 418 | const resolved = resolvedHighlightCache.get(cacheKey); | 640 | const resolved = resolvedHighlightCache.get(cacheKey); |
| 419 | if (resolved) { | 641 | if (resolved) { |
| 642 | logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { | ||
| 643 | fileId: file.id, | ||
| 644 | filePath: file.path, | ||
| 645 | theme, | ||
| 646 | }); | ||
| 420 | return resolved; | 647 | return resolved; |
| 421 | } | 648 | } |
| 422 | const cached = highlightCache.get(cacheKey); | 649 | const cached = highlightCache.get(cacheKey); |
| 423 | if (cached) { | 650 | if (cached) { |
| 651 | logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { | ||
| 652 | fileId: file.id, | ||
| 653 | filePath: file.path, | ||
| 654 | theme, | ||
| 655 | }); | ||
| 424 | return cached; | 656 | return cached; |
| 425 | } | 657 | } |
| 426 | } | 658 | } |
| 427 | 659 | ||
| 428 | const promise = (async () => { | 660 | const promise = (async () => { |
| 661 | const startedAt = Date.now(); | ||
| 662 | logReviewHighlighterDiagnostic("file highlight start", { | ||
| 663 | fileId: file.id, | ||
| 664 | filePath: file.path, | ||
| 665 | theme, | ||
| 666 | additionLineCount: file.additionLines.length, | ||
| 667 | deletionLineCount: file.deletionLines.length, | ||
| 668 | rowCount: file.rows.length, | ||
| 669 | }); | ||
| 429 | const language = await resolveLanguage(file); | 670 | const language = await resolveLanguage(file); |
| 430 | const [additionLines, deletionLines] = await Promise.all([ | 671 | const additionLines = await highlightLines( |
| 431 | highlightLines(joinPatchLines(file.additionLines), language, shikiTheme), | 672 | joinPatchLines(file.additionLines), |
| 432 | highlightLines(joinPatchLines(file.deletionLines), language, shikiTheme), | 673 | language, |
| 433 | ]); | 674 | shikiTheme, |
| 434 | 675 | ); | |
| 435 | const highlighted = { additionLines, deletionLines }; | 676 | await waitForNextFrame(); |
| 677 | const deletionLines = await highlightLines( | ||
| 678 | joinPatchLines(file.deletionLines), | ||
| 679 | language, | ||
| 680 | shikiTheme, | ||
| 681 | ); | ||
| 682 | await waitForNextFrame(); | ||
| 683 | |||
| 684 | const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); | ||
| 436 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | 685 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 437 | resolvedHighlightCache.set(cacheKey, highlighted); | 686 | storeResolvedHighlightedFile(cacheKey, highlighted); |
| 438 | } | 687 | } |
| 688 | logReviewHighlighterDiagnostic("file highlight complete", { | ||
| 689 | fileId: file.id, | ||
| 690 | filePath: file.path, | ||
| 691 | theme, | ||
| 692 | language, | ||
| 693 | highlightedAdditionLineCount: highlighted.additionLines.length, | ||
| 694 | highlightedDeletionLineCount: highlighted.deletionLines.length, | ||
| 695 | durationMs: Date.now() - startedAt, | ||
| 696 | }); | ||
| 439 | return highlighted; | 697 | return highlighted; |
| 440 | })(); | 698 | })(); |
| 441 | 699 | ||
| 442 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | 700 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { |
| 443 | highlightCache.set(cacheKey, promise); | 701 | highlightCache.set(cacheKey, promise); |
| 444 | } | 702 | } |
| 445 | return promise; | 703 | return promise.finally(() => { |
| 704 | if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { | ||
| 705 | highlightCache.delete(cacheKey); | ||
| 706 | } | ||
| 707 | }); | ||
| 446 | } | 708 | } |
| 447 | 709 | ||
| 448 | export async function highlightReviewSelectedLines(input: { | 710 | export async function highlightReviewSelectedLines(input: { |
| @@ -483,5 +745,8 @@ export async function highlightReviewSelectedLines(input: { · 34 unmodified lines | |||
| 483 | additionIndex += 1; | 745 | additionIndex += 1; |
| 484 | }); | 746 | }); |
| 485 | 747 | ||
| 486 | return tokenMap; | 748 | return applyWordAltDiffHighlightsToSelectedLines({ |
| 749 | lines: input.lines, | ||
| 750 | tokenMap, | ||
| 751 | }); | ||
| 487 | } | 752 | } |
| @@ -66,6 +66,7 @@ · 65 unmodified lines | |||
| 66 | "@t3tools/contracts": "workspace:*", | 66 | "@t3tools/contracts": "workspace:*", |
| 67 | "@t3tools/shared": "workspace:*", | 67 | "@t3tools/shared": "workspace:*", |
| 68 | "clsx": "^2.1.1", | 68 | "clsx": "^2.1.1", |
| 69 | "diff": "8.0.3", | ||
| 69 | "effect": "catalog:", | 70 | "effect": "catalog:", |
| 70 | "expo": "~55.0.14", | 71 | "expo": "~55.0.14", |
| 71 | "expo-camera": "~55.0.15", | 72 | "expo-camera": "~55.0.15", |