Files
claude-code/src/components/StructuredDiff.tsx
2026-05-01 21:39:30 +08:00

165 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <Messages> 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
// <ink-raw-ansi> 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 <DiffLine> 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<string, CachedRender>>();
// 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 (
<Box>
<StructuredDiffFallback patch={patch} dim={dim} width={width} />
</Box>
);
}
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 (
<Box flexDirection="row">
<NoSelect fromLeftEdge>
<RawAnsi lines={gutters} width={gutterWidth} />
</NoSelect>
<RawAnsi lines={contents} width={safeWidth - gutterWidth} />
</Box>
);
}
return (
<Box>
<RawAnsi lines={lines} width={safeWidth} />
</Box>
);
});