579 lines · 18.4 KB · TypeScript
RawDownload
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 { type MobileThemeId, type MobileThemeVariables } from "../../lib/mobileTheme";import { getMobileTerminalTheme, 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;const NATIVE_HEX_COLOR = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i;const NATIVE_RGBA_COLOR =  /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/; export const NATIVE_REVIEW_DIFF_CONTENT_WIDTH = 2_800; function opaqueNativeHexColor(color: string, background: string): string {  const hex = NATIVE_HEX_COLOR.exec(color);  if (hex) return color;   const rgba = NATIVE_RGBA_COLOR.exec(color);  const backgroundHex = NATIVE_HEX_COLOR.exec(background);  if (!rgba || !backgroundHex) return background;   const alpha = rgba[4] === undefined ? 1 : Math.min(1, Math.max(0, Number(rgba[4])));  const channels = [1, 2, 3].map((index) => {    const foreground = Number(rgba[index]);    const behind = Number.parseInt(backgroundHex[index], 16);    return Math.round(foreground * alpha + behind * (1 - alpha));  });  return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;} 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 prepared: PreparedNativeReviewDiffData;  readonly commentsKey: string;  readonly data: NativeReviewDiffData;} interface PreparedNativeReviewFileRows {  readonly fileId: string;  readonly filePath: string;  readonly lineCount: number;  readonly rows: ReadonlyArray<NativeReviewDiffRow>;  commentedRows: {    readonly commentsKey: string;    readonly rows: ReadonlyArray<NativeReviewDiffRow>;  } | null;} interface PreparedNativeReviewDiffData extends Omit<NativeReviewDiffData, "rows"> {  readonly fileRows: ReadonlyArray<PreparedNativeReviewFileRows>;} const nativeReviewDiffDataCache = new WeakMap<ReviewParsedDiff, CachedNativeReviewDiffData>(); function buildReviewCommentsCacheKey(comments: ReadonlyArray<ReviewInlineComment>): string {  if (comments.length === 0) {    return "none";  }   return JSON.stringify(    comments.map((comment) => [      comment.id,      comment.sectionId,      comment.sectionTitle,      comment.filePath,      comment.startIndex,      comment.endIndex,      comment.rangeLabel,      comment.text,    ]),  );} export function createNativeReviewDiffTheme(  scheme: TerminalAppearanceScheme,  themeId: MobileThemeId,  appTheme: MobileThemeVariables,): NativeReviewDiffTheme {  const terminalTheme = getMobileTerminalTheme(themeId, scheme);  const [, terminalRed] = terminalTheme.palette;  // Swift expects #RRGGBB/#RRGGBBAA while Android expects #RRGGBB/#AARRGGBB.  // Flatten translucent app tokens onto the code surface so both native  // implementations receive the one unambiguous shared format.  const background = opaqueNativeHexColor(appTheme["--color-sheet"], appTheme["--color-screen"]);  const nativeColor = (color: string) => opaqueNativeHexColor(color, background);   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,      text: nativeColor(appTheme["--color-md-code-text"]),      mutedText: nativeColor(appTheme["--color-foreground-muted"]),      headerBackground: background,      border: nativeColor(appTheme["--color-border"]),      hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),      hunkText: nativeColor(appTheme["--color-primary"]),      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,    text: nativeColor(appTheme["--color-md-code-text"]),    mutedText: nativeColor(appTheme["--color-foreground-muted"]),    headerBackground: background,    border: nativeColor(appTheme["--color-border"]),    hunkBackground: nativeColor(appTheme["--color-subtle-strong"]),    hunkText: nativeColor(appTheme["--color-primary"]),    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 prepareFileRows(  file: ReviewRenderableFile,  commentTargetsByRowId: Map<string, NativeReviewDiffCommentTarget>,  rowIdByCommentLineId: Map<string, string>,): PreparedNativeReviewFileRows {  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");  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,    });    lineIndex += 1;  });   rows.push(...noticeRowsForFile(file));  return {    fileId: file.id,    filePath: file.path,    lineCount: lineRows.length,    // Comments must not split the source deletion/addition runs used for word matching.    rows: addNativeWordDiffRanges(rows),    commentedRows: null,  };} function insertFileComments(  file: PreparedNativeReviewFileRows,  comments: ReadonlyArray<ReviewInlineComment>,): ReadonlyArray<NativeReviewDiffRow> {  if (comments.length === 0) {    file.commentedRows = null;    return file.rows;  }  const commentsKey = buildReviewCommentsCacheKey(comments);  if (file.commentedRows?.commentsKey === commentsKey) {    return file.commentedRows.rows;  }   const commentsByEndIndex = new Map<number, ReviewInlineComment[]>();  for (const comment of comments) {    const endIndex = Math.min(comment.endIndex, file.lineCount - 1);    if (endIndex < 0) continue;    const existing = commentsByEndIndex.get(endIndex);    if (existing) {      existing.push(comment);    } else {      commentsByEndIndex.set(endIndex, [comment]);    }  }  const rows: NativeReviewDiffRow[] = [];  let lineIndex = 0;  for (const row of file.rows) {    rows.push(row);    if (row.kind !== "line") continue;    for (const comment of commentsByEndIndex.get(lineIndex) ?? []) {      rows.push({        kind: "comment",        id: comment.id,        fileId: file.fileId,        filePath: file.filePath,        commentText: comment.text,        commentRangeLabel: comment.rangeLabel,        commentSectionTitle: comment.sectionTitle,      });    }    lineIndex += 1;  }  file.commentedRows = { commentsKey, rows };  return rows;} function prepareNativeReviewDiffData(parsedDiff: ReviewParsedDiff): PreparedNativeReviewDiffData {  if (parsedDiff.kind !== "files") {    return {      fileRows: [],      files: [],      commentTargetsByRowId: new Map(),      rowIdByCommentLineId: new Map(),      additions: 0,      deletions: 0,    };  }   const files = parsedDiff.files.map<NativeReviewDiffFile>((file) => ({

Lines 1–500 of 579.