import type { StructuredPatchHunk } from 'diff'; import * as React from 'react'; import { memo } from 'react'; import { useSettings } from '../hooks/useSettings.js'; import { Box, NoSelect, RawAnsi, useTheme } from '@anthropic/ink'; import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; import sliceAnsi from '../utils/sliceAnsi.js'; import { expectColorDiff } from './StructuredDiff/colorDiff.js'; import { StructuredDiffFallback } from './StructuredDiff/Fallback.js'; type Props = { patch: StructuredPatchHunk; dim: boolean; filePath: string; // File path for language detection firstLine: string | null; // First line of file for shebang detection fileContent?: string; // Full file content for syntax context (multiline strings, etc.) width: number; skipHighlighting?: boolean; // Skip syntax highlighting }; // REPL.tsx renders at two disjoint tree positions (transcript // early-return vs prompt-mode nested in FullscreenLayout), so ctrl+o // unmounts/remounts the entire message tree and React's memo cache is lost. // Keep both the NAPI result AND the pre-split gutter/content columns at // module level so the only work on remount is a WeakMap lookup plus two // leaves — not a fresh syntax highlight, nor N sliceAnsi // calls + 6N Yoga nodes. // // PR #21439 (fullscreen default-on) made gutterWidth>0 the default path, // reactivating the per-line branch that PR #20378 had bypassed. // Caching the split here restores the O(1)-leaves-per-diff invariant. type CachedRender = { lines: string[]; // Two RawAnsi columns replace what was N DiffLine rows. sliceAnsi work // moves from per-remount to cold-cache-only; parseToSpans is eliminated // entirely (RawAnsi bypasses Ansi parsing). gutterWidth: number; gutters: string[] | null; contents: string[] | null; }; const RENDER_CACHE = new WeakMap>(); // Gutter width matches the Rust module's layout: marker (1) + space + // right-aligned line number (max_digits) + space. Depends only on patch // identity (the WeakMap key), so it's cacheable alongside the NAPI output. function computeGutterWidth(patch: StructuredPatchHunk): number { const maxLineNumber = Math.max(patch.oldStart + patch.oldLines - 1, patch.newStart + patch.newLines - 1, 1); return maxLineNumber.toString().length + 3; // marker + 2 padding spaces } function renderColorDiff( patch: StructuredPatchHunk, firstLine: string | null, filePath: string, fileContent: string | null, theme: string, width: number, dim: boolean, splitGutter: boolean, ): CachedRender | null { const ColorDiff = expectColorDiff(); if (!ColorDiff) return null; // Defensive: if the gutter would eat the whole render width (narrow // terminal), skip the split. Rust already wraps to `width` so the // single-column output stays correct; we just lose noSelect. Without // this, sliceAnsi(line, gutterWidth) would return empty content and // RawAnsi(width<=0) is untested. const rawGutterWidth = splitGutter ? computeGutterWidth(patch) : 0; const gutterWidth = rawGutterWidth > 0 && rawGutterWidth < width ? rawGutterWidth : 0; const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}`; let perHunk = RENDER_CACHE.get(patch); const hit = perHunk?.get(key); if (hit) return hit; const lines = new ColorDiff(patch, firstLine, filePath, fileContent).render(theme, width, dim); if (lines === null) return null; // Pre-split the gutter column once (cold-cache). sliceAnsi preserves // styles across the cut; the Rust module already pads the gutter to // gutterWidth so the narrow RawAnsi column's width matches its cells. let gutters: string[] | null = null; let contents: string[] | null = null; if (gutterWidth > 0) { gutters = lines.map(l => sliceAnsi(l, 0, gutterWidth)); contents = lines.map(l => sliceAnsi(l, gutterWidth)); } const entry: CachedRender = { lines, gutterWidth, gutters, contents }; if (!perHunk) { perHunk = new Map(); RENDER_CACHE.set(patch, perHunk); } // Cap the inner map: width is part of the key, so terminal resize while a // diff is visible accumulates a full render copy per distinct width. Four // variants (two widths × dim on/off) covers the steady state; beyond that // the user is actively resizing and old widths are stale. if (perHunk.size >= 4) perHunk.clear(); perHunk.set(key, entry); return entry; } export const StructuredDiff = memo(function StructuredDiff({ patch, dim, filePath, firstLine, fileContent, width, skipHighlighting = false, }: Props): React.ReactNode { const [theme] = useTheme(); const settings = useSettings(); const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; // Ensure width is at least 1 to prevent crashes in the Rust NAPI module // which expects u32 (can't handle negative numbers) const safeWidth = Math.max(1, Math.floor(width)); // Only split out a noSelect gutter in fullscreen mode — terminal native // selection is used otherwise and noSelect is meaningless. Both branches // are now O(1) Yoga leaves per diff on remount (2 vs 1), so this gate // only saves cold-cache sliceAnsi work when fullscreen is off. const splitGutter = isFullscreenEnvEnabled(); const cached = skipHighlighting || syntaxHighlightingDisabled ? null : renderColorDiff(patch, firstLine, filePath, fileContent ?? null, theme, safeWidth, dim, splitGutter); if (!cached) { return ( ); } const { lines, gutterWidth, gutters, contents } = cached; // Two-column layout: gutter (noSelect) + content. NoSelect marks the // Box's computed bounds non-selectable; RawAnsi's measure func sets // rawHeight=lines.length, so one tall leaf gets the same noSelect // coverage N per-row Boxes would — without the per-row Yoga cost. if (gutterWidth > 0 && gutters && contents) { return ( ); } return ( ); });