190 lines
24 KiB
TypeScript
190 lines
24 KiB
TypeScript
|
|
import { c as _c } from "react/compiler-runtime";
|
|||
|
|
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 '../ink.js';
|
|||
|
|
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(t0) {
|
|||
|
|
const $ = _c(26);
|
|||
|
|
const {
|
|||
|
|
patch,
|
|||
|
|
dim,
|
|||
|
|
filePath,
|
|||
|
|
firstLine,
|
|||
|
|
fileContent,
|
|||
|
|
width,
|
|||
|
|
skipHighlighting: t1
|
|||
|
|
} = t0;
|
|||
|
|
const skipHighlighting = t1 === undefined ? false : t1;
|
|||
|
|
const [theme] = useTheme();
|
|||
|
|
const settings = useSettings();
|
|||
|
|
const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false;
|
|||
|
|
const safeWidth = Math.max(1, Math.floor(width));
|
|||
|
|
let t2;
|
|||
|
|
if ($[0] !== dim || $[1] !== fileContent || $[2] !== filePath || $[3] !== firstLine || $[4] !== patch || $[5] !== safeWidth || $[6] !== skipHighlighting || $[7] !== syntaxHighlightingDisabled || $[8] !== theme) {
|
|||
|
|
const splitGutter = isFullscreenEnvEnabled();
|
|||
|
|
t2 = skipHighlighting || syntaxHighlightingDisabled ? null : renderColorDiff(patch, firstLine, filePath, fileContent ?? null, theme, safeWidth, dim, splitGutter);
|
|||
|
|
$[0] = dim;
|
|||
|
|
$[1] = fileContent;
|
|||
|
|
$[2] = filePath;
|
|||
|
|
$[3] = firstLine;
|
|||
|
|
$[4] = patch;
|
|||
|
|
$[5] = safeWidth;
|
|||
|
|
$[6] = skipHighlighting;
|
|||
|
|
$[7] = syntaxHighlightingDisabled;
|
|||
|
|
$[8] = theme;
|
|||
|
|
$[9] = t2;
|
|||
|
|
} else {
|
|||
|
|
t2 = $[9];
|
|||
|
|
}
|
|||
|
|
const cached = t2;
|
|||
|
|
if (!cached) {
|
|||
|
|
let t3;
|
|||
|
|
if ($[10] !== dim || $[11] !== patch || $[12] !== width) {
|
|||
|
|
t3 = <Box><StructuredDiffFallback patch={patch} dim={dim} width={width} /></Box>;
|
|||
|
|
$[10] = dim;
|
|||
|
|
$[11] = patch;
|
|||
|
|
$[12] = width;
|
|||
|
|
$[13] = t3;
|
|||
|
|
} else {
|
|||
|
|
t3 = $[13];
|
|||
|
|
}
|
|||
|
|
return t3;
|
|||
|
|
}
|
|||
|
|
const {
|
|||
|
|
lines,
|
|||
|
|
gutterWidth,
|
|||
|
|
gutters,
|
|||
|
|
contents
|
|||
|
|
} = cached;
|
|||
|
|
if (gutterWidth > 0 && gutters && contents) {
|
|||
|
|
let t3;
|
|||
|
|
if ($[14] !== gutterWidth || $[15] !== gutters) {
|
|||
|
|
t3 = <NoSelect fromLeftEdge={true}><RawAnsi lines={gutters} width={gutterWidth} /></NoSelect>;
|
|||
|
|
$[14] = gutterWidth;
|
|||
|
|
$[15] = gutters;
|
|||
|
|
$[16] = t3;
|
|||
|
|
} else {
|
|||
|
|
t3 = $[16];
|
|||
|
|
}
|
|||
|
|
const t4 = safeWidth - gutterWidth;
|
|||
|
|
let t5;
|
|||
|
|
if ($[17] !== contents || $[18] !== t4) {
|
|||
|
|
t5 = <RawAnsi lines={contents} width={t4} />;
|
|||
|
|
$[17] = contents;
|
|||
|
|
$[18] = t4;
|
|||
|
|
$[19] = t5;
|
|||
|
|
} else {
|
|||
|
|
t5 = $[19];
|
|||
|
|
}
|
|||
|
|
let t6;
|
|||
|
|
if ($[20] !== t3 || $[21] !== t5) {
|
|||
|
|
t6 = <Box flexDirection="row">{t3}{t5}</Box>;
|
|||
|
|
$[20] = t3;
|
|||
|
|
$[21] = t5;
|
|||
|
|
$[22] = t6;
|
|||
|
|
} else {
|
|||
|
|
t6 = $[22];
|
|||
|
|
}
|
|||
|
|
return t6;
|
|||
|
|
}
|
|||
|
|
let t3;
|
|||
|
|
if ($[23] !== lines || $[24] !== safeWidth) {
|
|||
|
|
t3 = <Box><RawAnsi lines={lines} width={safeWidth} /></Box>;
|
|||
|
|
$[23] = lines;
|
|||
|
|
$[24] = safeWidth;
|
|||
|
|
$[25] = t3;
|
|||
|
|
} else {
|
|||
|
|
t3 = $[25];
|
|||
|
|
}
|
|||
|
|
return t3;
|
|||
|
|
});
|
|||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJTdHJ1Y3R1cmVkUGF0Y2hIdW5rIiwiUmVhY3QiLCJtZW1vIiwidXNlU2V0dGluZ3MiLCJCb3giLCJOb1NlbGVjdCIsIlJhd0Fuc2kiLCJ1c2VUaGVtZSIsImlzRnVsbHNjcmVlbkVudkVuYWJsZWQiLCJzbGljZUFuc2kiLCJleHBlY3RDb2xvckRpZmYiLCJTdHJ1Y3R1cmVkRGlmZkZhbGxiYWNrIiwiUHJvcHMiLCJwYXRjaCIsImRpbSIsImZpbGVQYXRoIiwiZmlyc3RMaW5lIiwiZmlsZUNvbnRlbnQiLCJ3aWR0aCIsInNraXBIaWdobGlnaHRpbmciLCJDYWNoZWRSZW5kZXIiLCJsaW5lcyIsImd1dHRlcldpZHRoIiwiZ3V0dGVycyIsImNvbnRlbnRzIiwiUkVOREVSX0NBQ0hFIiwiV2Vha01hcCIsIk1hcCIsImNvbXB1dGVHdXR0ZXJXaWR0aCIsIm1heExpbmVOdW1iZXIiLCJNYXRoIiwibWF4Iiwib2xkU3RhcnQiLCJvbGRMaW5lcyIsIm5ld1N0YXJ0IiwibmV3TGluZXMiLCJ0b1N0cmluZyIsImxlbmd0aCIsInJlbmRlckNvbG9yRGlmZiIsInRoZW1lIiwic3BsaXRHdXR0ZXIiLCJDb2xvckRpZmYiLCJyYXdHdXR0ZXJXaWR0aCIsImtleSIsInBlckh1bmsiLCJnZXQiLCJoaXQiLCJyZW5kZXIiLCJtYXAiLCJsIiwiZW50cnkiLCJzZXQiLCJzaXplIiwiY2xlYXIiLCJTdHJ1Y3R1cmVkRGlmZiIsInQwIiwiJCIsIl9jIiwidDEiLCJ1bmRlZmluZWQiLCJzZXR0aW5ncyIsInN5bnRheEhpZ2hsaWdodGluZ0Rpc2FibGVkIiwic2FmZVdpZHRoIiwiZmxvb3IiLCJ0MiIsImNhY2hlZCIsInQzIiwidDQiLCJ0NSIsInQ2Il0sInNvdXJjZXMiOlsiU3RydWN0dXJlZERpZmYudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgU3RydWN0dXJlZFBhdGNoSHVuayB9IGZyb20gJ2RpZmYnXG5pbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IG1lbW8gfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZVNldHRpbmdzIH0gZnJvbSAnLi4vaG9va3MvdXNlU2V0dGluZ3MuanMnXG5pbXBvcnQgeyBCb3gsIE5vU2VsZWN0LCBSYXdBbnNpLCB1c2VUaGVtZSB9IGZyb20gJy4uL2luay5qcydcbmltcG9ydCB7IGlzRnVsbHNjcmVlbkVudkVuYWJsZWQgfSBmcm9tICcuLi91dGlscy9mdWxsc2NyZWVuLmpzJ1xuaW1wb3J0IHNsaWNlQW5zaSBmcm9tICcuLi91dGlscy9zbGljZUFuc2kuanMnXG5pbXBvcnQgeyBleHBlY3RDb2xvckRpZmYgfSBmcm9tICcuL1N0cnVjdHVyZWREaWZmL2NvbG9yRGlmZi5qcydcbmltcG9ydCB7IFN0cnVjdHVyZWREaWZmRmFsbGJhY2sgfSBmcm9tICcuL1N0cnVjdHVyZWREaWZmL0ZhbGxiYWNrLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBwYXRjaDogU3RydWN0dXJlZFBhdGNoSHVua1xuICBkaW06IGJvb2xlYW5cbiAgZmlsZVBhdGg6IHN0cmluZyAvLyBGaWxlIHBhdGggZm9yIGxhbmd1YWdlIGRldGVjdGlvblxuICBmaXJzdExpbmU6IHN0cmluZyB8IG51bGwgLy8gRmlyc3QgbGluZSBvZiBmaWxlIGZvciBzaGViYW5nIGRldGVjdGlvblxuICBmaWxlQ29udGVudD86IHN0cmluZyAvLyBGdWxsIGZpbGUgY29udGVudCBmb3Igc3ludGF4IGNvbnRleHQgKG11bHRpbGluZSBzdHJpbmdzLCBldGMuKVxuICB3aWR0aDogbnVtYmVyXG4gIHNraXBIaWdobGlnaHRpbmc/OiBib29sZWFuIC8vIFNraXAgc3ludGF4IGhpZ2hsaWdodGluZ1xufVxuXG4vLyBSRVBMLnRzeCByZW5kZXJzIDxNZXNzYWdlcz4gYXQgdHdvIGRpc2pvaW50IHRyZWUgcG9zaXRpb25zICh0cmFuc2NyaXB0XG4vLyBlYXJseS1yZXR1cm4gdnMgcHJvbXB0LW1vZGUgbmVzdGVkIGluIEZ1bGxzY3JlZW5MYXlvdXQpLCBzbyBjdHJsK29cbi8vIHVubW91bnRzL3JlbW91bnRzIHRoZSBlbnRpcmUgbWVzc2FnZSB0cmVlIGFuZCBSZWFjdCdzIG1lbW8gY2FjaGUgaXMgbG9zdC5cbi8vIEtlZXAgYm90aCB0aGUgTkFQSSByZXN1bHQgQU5EIHRoZSBwcmUtc3BsaXQgZ3V0dGVyL2NvbnRlbnQgY29sdW1ucyBhdFxuLy8gbW9kdWxlIGxldmVsIHNvIHRoZSBvbmx5IHdvcmsgb24gcmVtb3VudCBpcyBhIFdlYWtNYXAgbG9va3VwIHBsdXMgdHdvXG4vLyA8aW5rLXJhdy1hbnNpPiBsZWF2ZXMg4oCUIG5vdCBhIGZyZXNoIHN5bnRheCBoaWdobGlnaHQsIG5vciBOIHNsaWNlQW5zaVxuLy8gY2FsbHMgKyA2TiBZb2dhIG5vZGVzLlxuLy9cbi8vIFBSICMyMTQzOSAoZnVsbHNjcmVlbiBkZWZhdWx0LW9uKSBtYWRlIGd1dHRlcldpZHRoPjAgdGhlIGRlZmF1bHQgcGF0aCxcbi8vIHJlYWN0aXZhdGluZyB0aGUgcGVyLWxpbmUgPERpZmZMaW5lPiBicmFuY2ggdGhhdCBQUiAjMjAzNzggaGFkIGJ5cGFzc2VkLlxuLy8gQ2FjaGluZyB0aGUgc3BsaXQgaGVyZSByZXN0b3JlcyB0aGUgTygxKS1sZWF2ZXMtcGVyLWRpZmYgaW52YXJpYW50LlxudHlwZSBDYWNoZWRSZW5kZXIgPSB7XG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvLyBUd28gUmF3QW5zaSBjb2x1bW5zIHJlcGxhY2Ugd2hhdCB3YXMgTiBEaWZmTGluZSByb3dzLiBzbGljZUFuc2kgd29ya1xuICAvLyBtb3ZlcyBmcm9tIHBlci1yZW1vdW50IHRvIGNvbGQtY2FjaGUtb25seTsgcGFyc2VUb1NwYW5zIGlzIGVsaW1pbmF0ZWRcbiAgLy8gZW50aXJlbHkgKFJhd0Fuc2kgYnlwYXNzZXMgQW5zaSBwYXJzaW5nKS5cbiAgZ3V0dGVyV2lkdGg6IG51bWJlclxuICBndXR0ZXJzOiBzdHJpbmdbXSB8IG51bGxcbiAgY29udGVudHM6IHN0cmluZ1tdIHwgbnVsbFxufVxuY29uc3QgUkVOREVSX0NBQ0hFID0gbmV3IFdlYWtNYXA8XG4gIFN0cnVjdHVyZWRQYXRjaEh1bmssXG4gIE1hcDxzdHJpbmcsIENhY2hlZFJlbmRlcj5cbj4oKVxuXG4vLyBHdXR0ZXIgd2lkdGggbWF0Y2hlcyB0aGUgUnVzdCBtb2R1bGUncyBsYXlvdXQ6IG1hcmtlciAoMSkgKyBzcGFjZSArXG4vLyByaWdodC1hbGlnbmVkIGxpbmUgbnVtYmVyIChtYXhfZGlnaXRzKSArIHNwYWNlLiBEZXBlbmRzIG9ubHkgb24gcGF0Y2hcbi8vIGlkZW50aXR5ICh0aGUgV2Vha01hcCBrZXkpLCBzbyBpdCdzIGNhY2hlYWJsZSBhbG9uZ3NpZGUgdGhlIE5BUEkgb3V0cHV0LlxuZnVuY3Rpb24gY29tcHV0ZUd1dHR
|