1import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface";
2import type {
3 NativeReviewDiffFile,
4 NativeReviewDiffLanguage,
5} from "../diffs/nativeReviewDiffTypes";
6import * as Arr from "effect/Array";
7import { pipe } from "effect/Function";
8import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
9import { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme";
10import { getMobileTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
11import { computeWordAltDiffRanges } from "./reviewWordDiffs";
12import {
13 getReviewFilePreviewState,
14 type ReviewParsedDiff,
15 type ReviewRenderableFile,
16 type ReviewRenderableLineRow,
17} from "./reviewModel";
18import type { ReviewInlineComment } from "./reviewCommentSelection";
19
20const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
21const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
22const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})([\da-f]{2})?$/i;
23const NATIVE_RGBA_COLOR =
24 /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/;
25
26export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
27
28/** Render headerless selections without guessing file line numbers from selection indices. */
29export function buildNativeReviewSnippetRows(
30 comment: Pick<ReviewInlineComment, "id" | "diff" | "fenceLanguage">,
31): NativeReviewDiffRow[] {
32 if ((comment.fenceLanguage ?? "diff") !== "diff" || !comment.diff.trim()) return [];
33 const lines = comment.diff.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
34 if (lines.some((line) => !/^[ +-]/.test(line) || /^(---|\+\+\+) /.test(line))) return [];
35 return lines.map((line, index) => ({
36 kind: "line",
37 id: `${comment.id}:snippet:${index}`,
38 content: line.slice(1),
39 change: line[0] === "+" ? "add" : line[0] === "-" ? "delete" : "context",
40 oldLineNumber: null,
41 newLineNumber: null,
42 }));
43}
44
45function opaqueNativeHexColor(color: string, background: string): string {
46 const hex = NATIVE_HEX_COLOR.exec(color);
47 if (hex && !hex[4]) return color;
48
49 const rgba = NATIVE_RGBA_COLOR.exec(color);
50 const backgroundHex = NATIVE_HEX_COLOR.exec(background);
51 if ((!hex && !rgba) || !backgroundHex) return background;
52
53 const alpha = hex
54 ? Number.parseInt(hex[4] ?? "ff", 16) / 255
55 : rgba?.[4] === undefined
56 ? 1
57 : Math.min(1, Math.max(0, Number(rgba[4])));
58 const channels = [1, 2, 3].map((index) => {
59 const foreground = hex ? Number.parseInt(hex[index] ?? "0", 16) : Number(rgba?.[index]);
60 const behind = Number.parseInt(backgroundHex[index] ?? "0", 16);
61 return Math.round(foreground * alpha + behind * (1 - alpha));
62 });
63 return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
64}
65
66/** `wordWrap` wraps line rows at the view width instead of panning them horizontally. */
67export function createNativeReviewDiffStyle(
68 codeSurface: ResolvedMobileCodeSurface,
69 wordWrap: boolean,
70) {
71 return {
72 wordWrap,
73 rowHeight: codeSurface.rowHeight,
74 contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
75 changeBarWidth: 4,
76 gutterWidth: codeSurface.gutterWidth,
77 codePadding: codeSurface.codePadding,
78 textVerticalInset: codeSurface.textVerticalInset,
79 fileHeaderHeight: 56,
80 fileHeaderHorizontalMargin: 8,
81 fileHeaderVerticalMargin: 6,
82 fileHeaderCornerRadius: 10,
83 fileHeaderHorizontalPadding: 10,
84 fileHeaderPathRightPadding: 118,
85 fileHeaderCountColumnWidth: 38,
86 fileHeaderCountGap: 5,
87 codeFontSize: codeSurface.fontSize,
88 codeFontWeight: "regular",
89 lineNumberFontSize: codeSurface.lineNumberFontSize,
90 lineNumberFontWeight: "regular",
91 hunkFontSize: 11,
92 hunkFontWeight: "medium",
93 fileHeaderFontSize: 11,
94 fileHeaderFontWeight: "semibold",
95 fileHeaderMetaFontSize: 10,
96 fileHeaderMetaFontWeight: "semibold",
97 fileHeaderSubtextFontSize: 11,
98 fileHeaderSubtextFontWeight: "medium",
99 fileHeaderStatusFontSize: 9,
100 fileHeaderStatusFontWeight: "bold",
101 emptyStateFontSize: 12,
102 emptyStateFontWeight: "medium",
103 } as const;
104}
105
106export interface NativeReviewDiffData {
107 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
108 readonly files: ReadonlyArray<NativeReviewDiffFile>;
109 readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>;
110 readonly rowIdByCommentLineId: ReadonlyMap<string, string>;
111 readonly additions: number;
112 readonly deletions: number;
113}
114
115export interface NativeReviewDiffCommentTarget {
116 readonly filePath: string;
117 readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
118 readonly lineIndex: number;
119}
120
121export interface BuildNativeReviewDiffDataInput {
122 readonly parsedDiff: ReviewParsedDiff;
123 readonly comments?: ReadonlyArray<ReviewInlineComment>;
124}
125
126interface CachedNativeReviewDiffData {
127 readonly prepared: PreparedNativeReviewDiffData;
128 readonly commentsKey: string;
129 readonly data: NativeReviewDiffData;
130}
131
132interface PreparedNativeReviewFileRows {
133 readonly fileId: string;
134 readonly filePath: string;
135 readonly lineCount: number;
136 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
137 readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>;
138 readonly rowIdByCommentLineId: ReadonlyMap<string, string>;
139 commentedRows: {
140 readonly commentsKey: string;
141 readonly rows: ReadonlyArray<NativeReviewDiffRow>;
142 } | null;
143}
144
145interface PreparedNativeReviewDiffData extends Omit<NativeReviewDiffData, "rows"> {
146 readonly fileRows: ReadonlyArray<PreparedNativeReviewFileRows>;
147}
148
149const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
150const nativeReviewFileRowsCache = new WeakMap<ReviewRenderableFile, PreparedNativeReviewFileRows>();
151
152function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
153 if (comments.length === 0) {
154 return "none";
155 }
156
157 return JSON.stringify(
158 comments.map((comment) => [
159 comment.id,
160 comment.sectionId,
161 comment.sectionTitle,
162 comment.filePath,
163 comment.startIndex,
164 comment.endIndex,
165 comment.rangeLabel,
166 comment.text,
167 ]),
168 );
169}
170
171export function createNativeReviewDiffTheme(
172 scheme: TerminalAppearanceScheme,
173 themeId: MobileThemeId,
174 appTheme: MobileThemeVariables,
175): NativeReviewDiffTheme {
176 const terminalTheme = getMobileTerminalTheme(themeId, scheme);
177 const [, terminalRed] = terminalTheme.palette;
178 // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.
179 // Flatten translucent app tokens onto the code surface so both native
180 // implementations receive the one unambiguous shared format.
181 const screen = opaqueNativeHexColor(
182 appTheme["--color-screen"],
183 scheme === "dark" ? "#000000" : "#ffffff",
184 );
185 const background = opaqueNativeHexColor(appTheme["--color-md-code-bg"], screen);
186 const nativeColor = (color: string) => opaqueNativeHexColor(color, background);
187
188 if (scheme === "dark") {
189 return {
190 // Code surfaces share the desktop palette rather than the sheet behind them.
191 background,
192 text: nativeColor(appTheme["--color-md-code-text"]),
193 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
194 headerBackground: background,
195 border: nativeColor(appTheme["--color-border"]),
196 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
197 hunkText: nativeColor(appTheme["--color-foreground"]),
198 addBackground: "#0d2f28",
199 deleteBackground: "#391415",
200 addBar: "#00cab1",
201 deleteBar: terminalRed ?? "#ff2e3f",
202 addText: "#5ECC71",
203 deleteText: "#FF6762",
204 };
205 }
206
207 return {
208 background,
209 text: nativeColor(appTheme["--color-md-code-text"]),
210 mutedText: nativeColor(appTheme["--color-foreground-muted"]),
211 headerBackground: background,
212 border: nativeColor(appTheme["--color-border"]),
213 hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),
214 hunkText: nativeColor(appTheme["--color-foreground"]),
215 addBackground: "#e5f8f5",
216 deleteBackground: "#ffe6e7",
217 addBar: "#00cab1",
218 deleteBar: terminalRed ?? "#ff2e3f",
219 addText: "#199F43",
220 deleteText: "#D52C36",
221 };
222}
223
224function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] {
225 switch (file.changeType) {
226 case "change":
227 return "modified";
228 case "new":
229 case "deleted":
230 case "rename-pure":
231 case "rename-changed":
232 return file.changeType;
233 default:
234 return "modified";
235 }
236}
237
238function getLanguageForPath(
239 filePath: string,
240 languageHint: string | null,
241): NativeReviewDiffLanguage {
242 const hinted = languageHint?.toLowerCase();
243 if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") {
244 return hinted;
245 }
246 if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") {
247 return hinted;
248 }
249
250 const normalizedPath = filePath.toLowerCase();
251 if (normalizedPath.endsWith(".tsx")) return "tsx";
252 if (normalizedPath.endsWith(".ts")) return "typescript";
253 if (normalizedPath.endsWith(".jsx")) return "jsx";
254 if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript";
255 if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json";
256 if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml";
257 if (
258 normalizedPath.endsWith(".sh") ||
259 normalizedPath.includes("/bin/") ||
260 normalizedPath.includes("shell")
261 ) {
262 return "bash";
263 }
264 return "diff";
265}
266
267function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow {
268 return {
269 kind: "notice",
270 id: `${fileId}:notice:${suffix}`,
271 fileId,
272 text,
273 };
274}
275
276function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> {
277 if (file.notice) return [createNoticeRow(file.id, "loading", file.notice)];
278 if (file.rows.length > 0) {
279 return [];
280 }
281
282 const previewState = getReviewFilePreviewState(file);
283 if (previewState.kind === "suppressed" && previewState.reason === "non-text") {
284 return [
285 createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."),
286 ];
287 }
288
289 if (file.changeType === "rename-pure") {
290 return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")];
291 }
292
293 return [];
294}
295
296function trimWordDiffRanges(
297 content: string,
298 ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
299): NonNullable<NativeReviewDiffRow["wordDiffRanges"]> {
300 return pipe(
301 ranges,
302 Arr.flatMap((range) => {
303 let start = Math.max(0, range.start);
304 let end = Math.min(content.length, range.end);
305
306 while (start < end && /\s/.test(content[start] ?? "")) {
307 start += 1;
308 }
309 while (end > start && /\s/.test(content[end - 1] ?? "")) {
310 end -= 1;
311 }
312
313 return end > start ? [{ start, end }] : [];
314 }),
315 );
316}
317
318function nonWhitespaceLength(value: string) {
319 return value.replace(/\s/g, "").length;
320}
321
322function shouldUseWordDiffRanges(
323 content: string,
324 ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
325) {
326 if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) {
327 return false;
328 }
329
330 const meaningfulLength = nonWhitespaceLength(content);
331 if (meaningfulLength === 0) {
332 return false;
333 }
334
335 const highlightedLength = ranges.reduce(
336 (total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)),
337 0,
338 );
339 return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE;
340}
341
342function addNativeWordDiffRanges(
343 rows: ReadonlyArray<NativeReviewDiffRow>,
344): ReadonlyArray<NativeReviewDiffRow> {
345 const nextRows = [...rows];
346 let index = 0;
347
348 while (index < nextRows.length) {
349 const deletedRowIndexes: number[] = [];
350 const addedRowIndexes: number[] = [];
351 const fileId = nextRows[index]?.fileId;
352
353 while (
354 nextRows[index]?.kind === "line" &&
355 nextRows[index]?.change === "delete" &&
356 nextRows[index]?.fileId === fileId
357 ) {
358 deletedRowIndexes.push(index);
359 index += 1;
360 }
361
362 while (
363 nextRows[index]?.kind === "line" &&
364 nextRows[index]?.change === "add" &&
365 nextRows[index]?.fileId === fileId
366 ) {
367 addedRowIndexes.push(index);
368 index += 1;
369 }
370
371 const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length);
372 for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) {
373 const deletedRowIndex = deletedRowIndexes[pairIndex];
374 const addedRowIndex = addedRowIndexes[pairIndex];
375 if (deletedRowIndex === undefined || addedRowIndex === undefined) {
376 continue;
377 }
378 const deletedRow = nextRows[deletedRowIndex];
379 const addedRow = nextRows[addedRowIndex];
380 if (!deletedRow?.content || !addedRow?.content) {
381 continue;
382 }
383
384 const ranges = computeWordAltDiffRanges({
385 deletionLine: deletedRow.content,
386 additionLine: addedRow.content,
387 });
388 const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion);
389 const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition);
390
391 if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) {
392 nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges };
393 }
394 if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) {
395 nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges };
396 }
397 }
398
399 if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) {
400 index += 1;
401 }
402 }
403
404 return nextRows;
405}
406
407function mapLineRow(
408 file: ReviewRenderableFile,
409 row: ReviewRenderableLineRow,
410 rowIndex: number,
411): NativeReviewDiffRow {
412 return {
413 kind: "line",
414 id: `${file.id}:line:${rowIndex}:${row.id}`,
415 fileId: file.id,
416 content: row.content,
417 change: row.change,
418 oldLineNumber: row.oldLineNumber,
419 newLineNumber: row.newLineNumber,
420 };
421}
422
423function prepareFileRows(file: ReviewRenderableFile): PreparedNativeReviewFileRows {
424 const cached = nativeReviewFileRowsCache.get(file);
425 if (cached) return cached;
426 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
427 const rowIdByCommentLineId = new Map<string, string>();
428 const rows: NativeReviewDiffRow[] = [
429 {
430 kind: "file",
431 id: `${file.id}:header`,
432 fileId: file.id,
433 filePath: file.path,
434 previousPath: file.previousPath,
435 changeType: mapChangeType(file),
436 additions: file.additions,
437 deletions: file.deletions,
438 },
439 ];
440
441 const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line");
442 let lineIndex = 0;
443 file.rows.forEach((row, rowIndex) => {
444 if (row.kind === "hunk") {
445 rows.push({
446 kind: "hunk",
447 id: `${file.id}:hunk:${rowIndex}:${row.id}`,
448 fileId: file.id,
449 text: row.context ? `${row.header} ${row.context}` : row.header,
450 });
451 return;
452 }
453
454 const nativeRow = mapLineRow(file, row, rowIndex);
455 rows.push(nativeRow);
456 rowIdByCommentLineId.set(row.id, nativeRow.id);
457 commentTargetsByRowId.set(nativeRow.id, {
458 filePath: file.path,
459 lines: lineRows,
460 lineIndex,
461 });
462 lineIndex += 1;
463 });
464
465 rows.push(...noticeRowsForFile(file));
466 const prepared: PreparedNativeReviewFileRows = {
467 fileId: file.id,
468 filePath: file.path,
469 lineCount: lineRows.length,
470 // Comments must not split the source deletion/addition runs used for word matching.
471 rows: addNativeWordDiffRanges(rows),
472 commentTargetsByRowId,
473 rowIdByCommentLineId,
474 commentedRows: null,
475 };
476 nativeReviewFileRowsCache.set(file, prepared);
477 return prepared;
478}
479
480function insertFileComments(
481 file: PreparedNativeReviewFileRows,
482 comments: ReadonlyArray<ReviewInlineComment>,
483): ReadonlyArray<NativeReviewDiffRow> {
484 if (comments.length === 0) {
485 file.commentedRows = null;
486 return file.rows;
487 }
488 const commentsKey = buildReviewCommentsCacheKey(comments);
489 if (file.commentedRows?.commentsKey === commentsKey) {
490 return file.commentedRows.rows;
491 }
492
493 const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();
494 for (const comment of comments) {
495 const endIndex = Math.min(comment.endIndex, file.lineCount - 1);
496 if (endIndex < 0) continue;
497 const existing = commentsByEndIndex.get(endIndex);
498 if (existing) {
499 existing.push(comment);
500 } else {
501 commentsByEndIndex.set(endIndex, [comment]);
502 }
503 }
504 const rows: NativeReviewDiffRow[] = [];
505 let lineIndex = 0;
506 for (const row of file.rows) {
507 rows.push(row);
508 if (row.kind !== "line") continue;
509 for (const comment of commentsByEndIndex.get(lineIndex) ?? []) {
510 rows.push({
511 kind: "comment",
512 id: comment.id,
513 fileId: file.fileId,
514 filePath: file.filePath,
515 commentText: comment.text,
516 commentRangeLabel: comment.rangeLabel,
517 commentSectionTitle: comment.sectionTitle,
518 });
519 }
520 lineIndex += 1;
521 }
522 file.commentedRows = { commentsKey, rows };
523 return rows;
524}
525
526function prepareNativeReviewDiffData(parsedDiff: ReviewParsedDiff): PreparedNativeReviewDiffData {
527 if (parsedDiff.kind !== "files") {
528 return {
529 fileRows: [],
530 files: [],
531 commentTargetsByRowId: new Map(),
532 rowIdByCommentLineId: new Map(),
533 additions: 0,
534 deletions: 0,
535 };
536 }
537
538 const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({
539 id: file.id,
540 path: file.path,
541 language: getLanguageForPath(file.path, file.languageHint),
542 additions: file.additions,
543 deletions: file.deletions,
544 }));
545 const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
546 const rowIdByCommentLineId = new Map<string, string>();
547 const fileRows = parsedDiff.files.map(prepareFileRows);
548 for (const file of fileRows) {
549 for (const [rowId, target] of file.commentTargetsByRowId) {
550 commentTargetsByRowId.set(rowId, target);
551 }
552 for (const [lineId, rowId] of file.rowIdByCommentLineId) {
553 rowIdByCommentLineId.set(lineId, rowId);
554 }
555 }
556
557 return {
558 fileRows,
559 files,
560 commentTargetsByRowId,
561 rowIdByCommentLineId,
562 additions: parsedDiff.additions,
563 deletions: parsedDiff.deletions,
564 };
565}
566
567function buildCommentedNativeReviewDiffData(
568 prepared: PreparedNativeReviewDiffData,
569 comments: ReadonlyArray<ReviewInlineComment>,
570): NativeReviewDiffData {
571 const commentsByFilePath = new Map<string, ReviewInlineComment[]>();
572 for (const comment of comments) {
573 const existing = commentsByFilePath.get(comment.filePath);
574 if (existing) {
575 existing.push(comment);
576 } else {
577 commentsByFilePath.set(comment.filePath, [comment]);
578 }
579 }
580 return {
581 rows: Arr.flatMap(prepared.fileRows, (file) =>
582 insertFileComments(file, commentsByFilePath.get(file.filePath) ?? []),
583 ),
584 files: prepared.files,
585 commentTargetsByRowId: prepared.commentTargetsByRowId,
586 rowIdByCommentLineId: prepared.rowIdByCommentLineId,
587 additions: prepared.additions,
588 deletions: prepared.deletions,
589 };
590}
591
592export function buildNativeReviewDiffData(
593 input: BuildNativeReviewDiffDataInput,
594): NativeReviewDiffData;
595export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
596export function buildNativeReviewDiffData(
597 input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
598): NativeReviewDiffData {
599 const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
600 const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
601 return buildCommentedNativeReviewDiffData(prepareNativeReviewDiffData(parsedDiff), comments);
602}
603
604/**
605 * Prepares source rows once per parsed diff, including its section-specific IDs.
606 * Comment edits reuse those rows, word ranges, and targets. Each file retains
607 * only its latest comment overlay, and the weak key releases old parsed diffs.
608 */
609export function getCachedNativeReviewDiffData(
610 input: BuildNativeReviewDiffDataInput,
611): NativeReviewDiffData {
612 const comments = input.comments ?? [];
613 const commentsKey = buildReviewCommentsCacheKey(comments);
614 const cached = nativeReviewDiffDataCache.get(input.parsedDiff);
615 if (cached?.commentsKey === commentsKey) {
616 return cached.data;
617 }
618
619 const prepared = cached?.prepared ?? prepareNativeReviewDiffData(input.parsedDiff);
620 const data = buildCommentedNativeReviewDiffData(prepared, comments);
621 nativeReviewDiffDataCache.set(input.parsedDiff, { prepared, commentsKey, data });
622 return data;
623}