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< StructuredPatchHunk, Map >() // 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 ( ) })