import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface";import type { NativeReviewDiffFile, NativeReviewDiffLanguage,} from "../diffs/nativeReviewDiffTypes";import * as Arr from "effect/Array";import { pipe } from "effect/Function";import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";import { MOBILE_CODE_SURFACE } from "../../lib/typography";import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";import { computeWordAltDiffRanges } from "./reviewWordDiffs";import { getReviewFilePreviewState, type ReviewParsedDiff, type ReviewRenderableFile, type ReviewRenderableLineRow,} from "./reviewModel";import type { ReviewInlineComment } from "./reviewCommentSelection"; const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45; export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle( resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),); export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) { return { rowHeight: codeSurface.rowHeight, contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH, changeBarWidth: 4, gutterWidth: codeSurface.gutterWidth, codePadding: codeSurface.codePadding, textVerticalInset: codeSurface.textVerticalInset, fileHeaderHeight: 56, fileHeaderHorizontalMargin: 8, fileHeaderVerticalMargin: 6, fileHeaderCornerRadius: 10, fileHeaderHorizontalPadding: 10, fileHeaderPathRightPadding: 118, fileHeaderCountColumnWidth: 38, fileHeaderCountGap: 5, codeFontSize: codeSurface.fontSize, codeFontWeight: "regular", lineNumberFontSize: codeSurface.lineNumberFontSize, lineNumberFontWeight: "regular", hunkFontSize: 11, hunkFontWeight: "medium", fileHeaderFontSize: 11, fileHeaderFontWeight: "semibold", fileHeaderMetaFontSize: 10, fileHeaderMetaFontWeight: "semibold", fileHeaderSubtextFontSize: 11, fileHeaderSubtextFontWeight: "medium", fileHeaderStatusFontSize: 9, fileHeaderStatusFontWeight: "bold", emptyStateFontSize: 12, emptyStateFontWeight: "medium", } as const;} export interface NativeReviewDiffData { readonly rows: ReadonlyArray<NativeReviewDiffRow>; readonly files: ReadonlyArray<NativeReviewDiffFile>; readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>; readonly rowIdByCommentLineId: ReadonlyMap<string, string>; readonly additions: number; readonly deletions: number;} export interface NativeReviewDiffCommentTarget { readonly filePath: string; readonly lines: ReadonlyArray<ReviewRenderableLineRow>; readonly lineIndex: number;} export interface BuildNativeReviewDiffDataInput { readonly parsedDiff: ReviewParsedDiff; readonly comments?: ReadonlyArray<ReviewInlineComment>;} interface CachedNativeReviewDiffData { readonly commentsKey: string; readonly data: NativeReviewDiffData;} const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>(); function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string { if (comments.length === 0) { return "none"; } return comments .map((comment) => [ comment.id, comment.sectionId, comment.filePath, comment.startIndex, comment.endIndex, comment.rangeLabel, comment.text, ].join("\u001f"), ) .join("\u001e");} export function createNativeReviewDiffTheme( scheme: TerminalAppearanceScheme,): NativeReviewDiffTheme { const terminalTheme = getPierreTerminalTheme(scheme); const [, terminalRed, , , terminalBlue] = terminalTheme.palette; if (scheme === "dark") { return { // Match the app surface (--color-sheet) so code views blend with the rest of // the app instead of using a distinct code-editor background. background: "#0e0e0e", text: terminalTheme.foreground, mutedText: terminalTheme.mutedForeground, headerBackground: "#0e0e0e", border: terminalTheme.border, hunkBackground: "#071f28", hunkText: terminalBlue ?? "#009fff", addBackground: "#0d2f28", deleteBackground: "#391415", addBar: "#00cab1", deleteBar: terminalRed ?? "#ff2e3f", addText: "#5ECC71", deleteText: "#FF6762", }; } return { // Match the app surface (--color-sheet) so code views blend with the rest of the // app instead of using a distinct code-editor background. background: "#f2f2f7", text: "#070707", mutedText: terminalTheme.mutedForeground, headerBackground: "#f2f2f7", border: terminalTheme.border, hunkBackground: "#e0f2ff", hunkText: terminalBlue ?? "#009fff", addBackground: "#e5f8f5", deleteBackground: "#ffe6e7", addBar: "#00cab1", deleteBar: terminalRed ?? "#ff2e3f", addText: "#199F43", deleteText: "#D52C36", };} function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] { switch (file.changeType) { case "change": return "modified"; case "new": case "deleted": case "rename-pure": case "rename-changed": return file.changeType; default: return "modified"; }} function getLanguageForPath( filePath: string, languageHint: string | null,): NativeReviewDiffLanguage { const hinted = languageHint?.toLowerCase(); if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") { return hinted; } if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") { return hinted; } const normalizedPath = filePath.toLowerCase(); if (normalizedPath.endsWith(".tsx")) return "tsx"; if (normalizedPath.endsWith(".ts")) return "typescript"; if (normalizedPath.endsWith(".jsx")) return "jsx"; if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript"; if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json"; if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml"; if ( normalizedPath.endsWith(".sh") || normalizedPath.includes("/bin/") || normalizedPath.includes("shell") ) { return "bash"; } return "diff";} function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow { return { kind: "notice", id: `${fileId}:notice:${suffix}`, fileId, text, };} function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> { if (file.rows.length > 0) { return []; } const previewState = getReviewFilePreviewState(file); if (previewState.kind === "suppressed" && previewState.reason === "non-text") { return [ createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."), ]; } if (file.changeType === "rename-pure") { return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")]; } return [];} function trimWordDiffRanges( content: string, ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,): NonNullable<NativeReviewDiffRow["wordDiffRanges"]> { return pipe( ranges, Arr.flatMap((range) => { let start = Math.max(0, range.start); let end = Math.min(content.length, range.end); while (start < end && /\s/.test(content[start] ?? "")) { start += 1; } while (end > start && /\s/.test(content[end - 1] ?? "")) { end -= 1; } return end > start ? [{ start, end }] : []; }), );} function nonWhitespaceLength(value: string) { return value.replace(/\s/g, "").length;} function shouldUseWordDiffRanges( content: string, ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,) { if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) { return false; } const meaningfulLength = nonWhitespaceLength(content); if (meaningfulLength === 0) { return false; } const highlightedLength = ranges.reduce( (total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)), 0, ); return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE;} function addNativeWordDiffRanges( rows: ReadonlyArray<NativeReviewDiffRow>,): ReadonlyArray<NativeReviewDiffRow> { const nextRows = [...rows]; let index = 0; while (index < nextRows.length) { const deletedRowIndexes: number[] = []; const addedRowIndexes: number[] = []; const fileId = nextRows[index]?.fileId; while ( nextRows[index]?.kind === "line" && nextRows[index]?.change === "delete" && nextRows[index]?.fileId === fileId ) { deletedRowIndexes.push(index); index += 1; } while ( nextRows[index]?.kind === "line" && nextRows[index]?.change === "add" && nextRows[index]?.fileId === fileId ) { addedRowIndexes.push(index); index += 1; } const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length); for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) { const deletedRowIndex = deletedRowIndexes[pairIndex]; const addedRowIndex = addedRowIndexes[pairIndex]; const deletedRow = nextRows[deletedRowIndex]; const addedRow = nextRows[addedRowIndex]; if (!deletedRow?.content || !addedRow?.content) { continue; } const ranges = computeWordAltDiffRanges({ deletionLine: deletedRow.content, additionLine: addedRow.content, }); const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion); const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition); if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) { nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges }; } if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) { nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges }; } } if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) { index += 1; } } return nextRows;} function mapLineRow( file: ReviewRenderableFile, row: ReviewRenderableLineRow, rowIndex: number,): NativeReviewDiffRow { return { kind: "line", id: `${file.id}:line:${rowIndex}:${row.id}`, fileId: file.id, content: row.content, change: row.change, oldLineNumber: row.oldLineNumber, newLineNumber: row.newLineNumber, };} function mapFileRows( file: ReviewRenderableFile, comments: ReadonlyArray<ReviewInlineComment>, commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>, rowIdByCommentLineId: Map<string, string>,): ReadonlyArray<NativeReviewDiffRow> { const rows: NativeReviewDiffRow[] = [ { kind: "file", id: `${file.id}:header`, fileId: file.id, filePath: file.path, previousPath: file.previousPath, changeType: mapChangeType(file), additions: file.additions, deletions: file.deletions, }, ]; const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line"); const commentsByEndIndex = new Map<number, ReviewInlineComment[]>(); comments.forEach((comment) => { if (comment.filePath !== file.path) { return; } const endIndex = Math.min(comment.endIndex, lineRows.length - 1); if (endIndex < 0) { return; } const existing = commentsByEndIndex.get(endIndex); if (existing) { existing.push(comment); return; } commentsByEndIndex.set(endIndex, [comment]); }); let lineIndex = 0; file.rows.forEach((row, rowIndex) => { if (row.kind === "hunk") { rows.push({ kind: "hunk", id: `${file.id}:hunk:${rowIndex}:${row.id}`, fileId: file.id, text: row.context ? `${row.header} ${row.context}` : row.header, }); return; } const nativeRow = mapLineRow(file, row, rowIndex); rows.push(nativeRow); rowIdByCommentLineId.set(row.id, nativeRow.id); commentTargetsByRowId.set(nativeRow.id, { filePath: file.path, lines: lineRows, lineIndex, }); const commentsForLine = commentsByEndIndex.get(lineIndex) ?? []; for (const comment of commentsForLine) { rows.push({ kind: "comment", id: comment.id, fileId: file.id, filePath: file.path, commentText: comment.text, commentRangeLabel: comment.rangeLabel, commentSectionTitle: comment.sectionTitle, }); } lineIndex += 1; }); rows.push(...noticeRowsForFile(file)); return rows;} export function buildNativeReviewDiffData( input: BuildNativeReviewDiffDataInput,): NativeReviewDiffData;export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;export function buildNativeReviewDiffData( input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,): NativeReviewDiffData { const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input; const comments = "parsedDiff" in input ? (input.comments ?? []) : []; if (parsedDiff.kind !== "files") { return { rows: [], files: [], commentTargetsByRowId: new Map(), rowIdByCommentLineId: new Map(), additions: 0, deletions: 0, }; } const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({ id: file.id, path: file.path, language: getLanguageForPath(file.path, file.languageHint), additions: file.additions, deletions: file.deletions, })); const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>(); const rowIdByCommentLineId = new Map<string, string>(); const rows = addNativeWordDiffRanges( Arr.flatMap(parsedDiff.files, (file) => mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId), ), ); return { rows, files, commentTargetsByRowId, rowIdByCommentLineId, additions: parsedDiff.additions, deletions: parsedDiff.deletions, };} /** * Reuses the expensive flattened native row model across React development * render probes and unrelated draft updates. Only the latest comment version * is retained for each parsed diff so editing a comment cannot grow the cache. */export function getCachedNativeReviewDiffData( input: BuildNativeReviewDiffDataInput,): NativeReviewDiffData { const comments = input.comments ?? []; const commentsKey = buildReviewCommentsCacheKey(comments); const cached = nativeReviewDiffDataCache.get(input.parsedDiff); if (cached?.commentsKey === commentsKey) { return cached.data; } const data = buildNativeReviewDiffData({ parsedDiff: input.parsedDiff, comments, }); nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data }); return data;}
import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface";
import type {
NativeReviewDiffFile,
NativeReviewDiffLanguage,
} from "../diffs/nativeReviewDiffTypes";
import * as Arr from "effect/Array";
import { pipe } from "effect/Function";
import type { ResolvedMobileCodeSurface } from "../../lib/appearancePreferences";
import { resolveMobileCodeSurface } from "../../lib/appearancePreferences";
import { MOBILE_CODE_SURFACE } from "../../lib/typography";
import { getPierreTerminalTheme, type TerminalAppearanceScheme } from "../terminal/terminalTheme";
import { computeWordAltDiffRanges } from "./reviewWordDiffs";
import {
getReviewFilePreviewState,
type ReviewParsedDiff,
type ReviewRenderableFile,
type ReviewRenderableLineRow,
} from "./reviewModel";
import type { ReviewInlineComment } from "./reviewCommentSelection";
const NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT = 4;
const NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE = 0.45;
export const NATIVE_REVIEW_DIFF_ROW_HEIGHT = MOBILE_CODE_SURFACE.rowHeight;
export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800;
export const NATIVE_REVIEW_DIFF_STYLE = createNativeReviewDiffStyle(
resolveMobileCodeSurface(MOBILE_CODE_SURFACE.fontSize),
);
export function createNativeReviewDiffStyle(codeSurface: ResolvedMobileCodeSurface) {
return {
rowHeight: codeSurface.rowHeight,
contentWidth: NATIVE_REVIEW_DIFF_CONTENT_WIDTH,
changeBarWidth: 4,
gutterWidth: codeSurface.gutterWidth,
codePadding: codeSurface.codePadding,
textVerticalInset: codeSurface.textVerticalInset,
fileHeaderHeight: 56,
fileHeaderHorizontalMargin: 8,
fileHeaderVerticalMargin: 6,
fileHeaderCornerRadius: 10,
fileHeaderHorizontalPadding: 10,
fileHeaderPathRightPadding: 118,
fileHeaderCountColumnWidth: 38,
fileHeaderCountGap: 5,
codeFontSize: codeSurface.fontSize,
codeFontWeight: "regular",
lineNumberFontSize: codeSurface.lineNumberFontSize,
lineNumberFontWeight: "regular",
hunkFontSize: 11,
hunkFontWeight: "medium",
fileHeaderFontSize: 11,
fileHeaderFontWeight: "semibold",
fileHeaderMetaFontSize: 10,
fileHeaderMetaFontWeight: "semibold",
fileHeaderSubtextFontSize: 11,
fileHeaderSubtextFontWeight: "medium",
fileHeaderStatusFontSize: 9,
fileHeaderStatusFontWeight: "bold",
emptyStateFontSize: 12,
emptyStateFontWeight: "medium",
} as const;
}
export interface NativeReviewDiffData {
readonly rows: ReadonlyArray<NativeReviewDiffRow>;
readonly files: ReadonlyArray<NativeReviewDiffFile>;
readonly commentTargetsByRowId: ReadonlyMap<string, NativeReviewDiffCommentTarget>;
readonly rowIdByCommentLineId: ReadonlyMap<string, string>;
readonly additions: number;
readonly deletions: number;
}
export interface NativeReviewDiffCommentTarget {
readonly filePath: string;
readonly lines: ReadonlyArray<ReviewRenderableLineRow>;
readonly lineIndex: number;
}
export interface BuildNativeReviewDiffDataInput {
readonly parsedDiff: ReviewParsedDiff;
readonly comments?: ReadonlyArray<ReviewInlineComment>;
}
interface CachedNativeReviewDiffData {
readonly commentsKey: string;
readonly data: NativeReviewDiffData;
}
const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>();
function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {
if (comments.length === 0) {
return "none";
}
return comments
.map((comment) =>
[
comment.id,
comment.sectionId,
comment.filePath,
comment.startIndex,
comment.endIndex,
comment.rangeLabel,
comment.text,
].join("\u001f"),
)
.join("\u001e");
}
export function createNativeReviewDiffTheme(
scheme: TerminalAppearanceScheme,
): NativeReviewDiffTheme {
const terminalTheme = getPierreTerminalTheme(scheme);
const [, terminalRed, , , terminalBlue] = terminalTheme.palette;
if (scheme === "dark") {
return {
// Match the app surface (--color-sheet) so code views blend with the rest of
// the app instead of using a distinct code-editor background.
background: "#0e0e0e",
text: terminalTheme.foreground,
mutedText: terminalTheme.mutedForeground,
headerBackground: "#0e0e0e",
border: terminalTheme.border,
hunkBackground: "#071f28",
hunkText: terminalBlue ?? "#009fff",
addBackground: "#0d2f28",
deleteBackground: "#391415",
addBar: "#00cab1",
deleteBar: terminalRed ?? "#ff2e3f",
addText: "#5ECC71",
deleteText: "#FF6762",
};
}
return {
// Match the app surface (--color-sheet) so code views blend with the rest of the
// app instead of using a distinct code-editor background.
background: "#f2f2f7",
text: "#070707",
mutedText: terminalTheme.mutedForeground,
headerBackground: "#f2f2f7",
border: terminalTheme.border,
hunkBackground: "#e0f2ff",
hunkText: terminalBlue ?? "#009fff",
addBackground: "#e5f8f5",
deleteBackground: "#ffe6e7",
addBar: "#00cab1",
deleteBar: terminalRed ?? "#ff2e3f",
addText: "#199F43",
deleteText: "#D52C36",
};
}
function mapChangeType(file: ReviewRenderableFile): NativeReviewDiffRow["changeType"] {
switch (file.changeType) {
case "change":
return "modified";
case "new":
case "deleted":
case "rename-pure":
case "rename-changed":
return file.changeType;
default:
return "modified";
}
}
function getLanguageForPath(
filePath: string,
languageHint: string | null,
): NativeReviewDiffLanguage {
const hinted = languageHint?.toLowerCase();
if (hinted === "typescript" || hinted === "tsx" || hinted === "javascript" || hinted === "jsx") {
return hinted;
}
if (hinted === "json" || hinted === "yaml" || hinted === "bash" || hinted === "diff") {
return hinted;
}
const normalizedPath = filePath.toLowerCase();
if (normalizedPath.endsWith(".tsx")) return "tsx";
if (normalizedPath.endsWith(".ts")) return "typescript";
if (normalizedPath.endsWith(".jsx")) return "jsx";
if (normalizedPath.endsWith(".js") || normalizedPath.endsWith(".cjs")) return "javascript";
if (normalizedPath.endsWith(".json") || normalizedPath.endsWith(".jsonc")) return "json";
if (normalizedPath.endsWith(".yml") || normalizedPath.endsWith(".yaml")) return "yaml";
if (
normalizedPath.endsWith(".sh") ||
normalizedPath.includes("/bin/") ||
normalizedPath.includes("shell")
) {
return "bash";
}
return "diff";
}
function createNoticeRow(fileId: string, suffix: string, text: string): NativeReviewDiffRow {
return {
kind: "notice",
id: `${fileId}:notice:${suffix}`,
fileId,
text,
};
}
function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray<NativeReviewDiffRow> {
if (file.rows.length > 0) {
return [];
}
const previewState = getReviewFilePreviewState(file);
if (previewState.kind === "suppressed" && previewState.reason === "non-text") {
return [
createNoticeRow(file.id, "non-text", "Unsupported format. Diff contents are not available."),
];
}
if (file.changeType === "rename-pure") {
return [createNoticeRow(file.id, "rename", "This file was renamed without modifications.")];
}
return [];
}
function trimWordDiffRanges(
content: string,
ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
): NonNullable<NativeReviewDiffRow["wordDiffRanges"]> {
return pipe(
ranges,
Arr.flatMap((range) => {
let start = Math.max(0, range.start);
let end = Math.min(content.length, range.end);
while (start < end && /\s/.test(content[start] ?? "")) {
start += 1;
}
while (end > start && /\s/.test(content[end - 1] ?? "")) {
end -= 1;
}
return end > start ? [{ start, end }] : [];
}),
);
}
function nonWhitespaceLength(value: string) {
return value.replace(/\s/g, "").length;
}
function shouldUseWordDiffRanges(
content: string,
ranges: NonNullable<NativeReviewDiffRow["wordDiffRanges"]>,
) {
if (ranges.length === 0 || ranges.length > NATIVE_REVIEW_MAX_WORD_DIFF_RANGE_COUNT) {
return false;
}
const meaningfulLength = nonWhitespaceLength(content);
if (meaningfulLength === 0) {
return false;
}
const highlightedLength = ranges.reduce(
(total, range) => total + nonWhitespaceLength(content.slice(range.start, range.end)),
0,
);
return highlightedLength / meaningfulLength <= NATIVE_REVIEW_MAX_WORD_DIFF_COVERAGE;
}
function addNativeWordDiffRanges(
rows: ReadonlyArray<NativeReviewDiffRow>,
): ReadonlyArray<NativeReviewDiffRow> {
const nextRows = [...rows];
let index = 0;
while (index < nextRows.length) {
const deletedRowIndexes: number[] = [];
const addedRowIndexes: number[] = [];
const fileId = nextRows[index]?.fileId;
while (
nextRows[index]?.kind === "line" &&
nextRows[index]?.change === "delete" &&
nextRows[index]?.fileId === fileId
) {
deletedRowIndexes.push(index);
index += 1;
}
while (
nextRows[index]?.kind === "line" &&
nextRows[index]?.change === "add" &&
nextRows[index]?.fileId === fileId
) {
addedRowIndexes.push(index);
index += 1;
}
const pairedCount = Math.min(deletedRowIndexes.length, addedRowIndexes.length);
for (let pairIndex = 0; pairIndex < pairedCount; pairIndex += 1) {
const deletedRowIndex = deletedRowIndexes[pairIndex];
const addedRowIndex = addedRowIndexes[pairIndex];
const deletedRow = nextRows[deletedRowIndex];
const addedRow = nextRows[addedRowIndex];
if (!deletedRow?.content || !addedRow?.content) {
continue;
}
const ranges = computeWordAltDiffRanges({
deletionLine: deletedRow.content,
additionLine: addedRow.content,
});
const deletionRanges = trimWordDiffRanges(deletedRow.content, ranges.deletion);
const additionRanges = trimWordDiffRanges(addedRow.content, ranges.addition);
if (shouldUseWordDiffRanges(deletedRow.content, deletionRanges)) {
nextRows[deletedRowIndex] = { ...deletedRow, wordDiffRanges: deletionRanges };
}
if (shouldUseWordDiffRanges(addedRow.content, additionRanges)) {
nextRows[addedRowIndex] = { ...addedRow, wordDiffRanges: additionRanges };
}
}
if (deletedRowIndexes.length === 0 && addedRowIndexes.length === 0) {
index += 1;
}
}
return nextRows;
}
function mapLineRow(
file: ReviewRenderableFile,
row: ReviewRenderableLineRow,
rowIndex: number,
): NativeReviewDiffRow {
return {
kind: "line",
id: `${file.id}:line:${rowIndex}:${row.id}`,
fileId: file.id,
content: row.content,
change: row.change,
oldLineNumber: row.oldLineNumber,
newLineNumber: row.newLineNumber,
};
}
function mapFileRows(
file: ReviewRenderableFile,
comments: ReadonlyArray<ReviewInlineComment>,
commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>,
rowIdByCommentLineId: Map<string, string>,
): ReadonlyArray<NativeReviewDiffRow> {
const rows: NativeReviewDiffRow[] = [
{
kind: "file",
id: `${file.id}:header`,
fileId: file.id,
filePath: file.path,
previousPath: file.previousPath,
changeType: mapChangeType(file),
additions: file.additions,
deletions: file.deletions,
},
];
const lineRows = file.rows.filter((row): row is ReviewRenderableLineRow => row.kind === "line");
const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();
comments.forEach((comment) => {
if (comment.filePath !== file.path) {
return;
}
const endIndex = Math.min(comment.endIndex, lineRows.length - 1);
if (endIndex < 0) {
return;
}
const existing = commentsByEndIndex.get(endIndex);
if (existing) {
existing.push(comment);
return;
}
commentsByEndIndex.set(endIndex, [comment]);
});
let lineIndex = 0;
file.rows.forEach((row, rowIndex) => {
if (row.kind === "hunk") {
rows.push({
kind: "hunk",
id: `${file.id}:hunk:${rowIndex}:${row.id}`,
fileId: file.id,
text: row.context ? `${row.header} ${row.context}` : row.header,
});
return;
}
const nativeRow = mapLineRow(file, row, rowIndex);
rows.push(nativeRow);
rowIdByCommentLineId.set(row.id, nativeRow.id);
commentTargetsByRowId.set(nativeRow.id, {
filePath: file.path,
lines: lineRows,
lineIndex,
});
const commentsForLine = commentsByEndIndex.get(lineIndex) ?? [];
for (const comment of commentsForLine) {
rows.push({
kind: "comment",
id: comment.id,
fileId: file.id,
filePath: file.path,
commentText: comment.text,
commentRangeLabel: comment.rangeLabel,
commentSectionTitle: comment.sectionTitle,
});
}
lineIndex += 1;
});
rows.push(...noticeRowsForFile(file));
return rows;
}
export function buildNativeReviewDiffData(
input: BuildNativeReviewDiffDataInput,
): NativeReviewDiffData;
export function buildNativeReviewDiffData(parsedDiff: ReviewParsedDiff): NativeReviewDiffData;
export function buildNativeReviewDiffData(
input: ReviewParsedDiff | BuildNativeReviewDiffDataInput,
): NativeReviewDiffData {
const parsedDiff = "parsedDiff" in input ? input.parsedDiff : input;
const comments = "parsedDiff" in input ? (input.comments ?? []) : [];
if (parsedDiff.kind !== "files") {
return {
rows: [],
files: [],
commentTargetsByRowId: new Map(),
rowIdByCommentLineId: new Map(),
additions: 0,
deletions: 0,
};
}
const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({
id: file.id,
path: file.path,
language: getLanguageForPath(file.path, file.languageHint),
additions: file.additions,
deletions: file.deletions,
}));
const commentTargetsByRowId = new Map<string, NativeReviewDiffCommentTarget>();
const rowIdByCommentLineId = new Map<string, string>();
const rows = addNativeWordDiffRanges(
Arr.flatMap(parsedDiff.files, (file) =>
mapFileRows(file, comments, commentTargetsByRowId, rowIdByCommentLineId),
),
);
return {
rows,
files,
commentTargetsByRowId,
rowIdByCommentLineId,
additions: parsedDiff.additions,
deletions: parsedDiff.deletions,
};
}
/**
* Reuses the expensive flattened native row model across React development
* render probes and unrelated draft updates. Only the latest comment version
* is retained for each parsed diff so editing a comment cannot grow the cache.
*/
export function getCachedNativeReviewDiffData(
input: BuildNativeReviewDiffDataInput,
): NativeReviewDiffData {
const comments = input.comments ?? [];
const commentsKey = buildReviewCommentsCacheKey(comments);
const cached = nativeReviewDiffDataCache.get(input.parsedDiff);
if (cached?.commentsKey === commentsKey) {
return cached.data;
}
const data = buildNativeReviewDiffData({
parsedDiff: input.parsedDiff,
comments,
});
nativeReviewDiffDataCache.set(input.parsedDiff, { commentsKey, data });
return data;
}