2518 lines
74 KiB
TypeScript
2518 lines
74 KiB
TypeScript
import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
import { getLanguageFromPath, highlightCode, type EditToolDetails } from "@earendil-works/pi-coding-agent";
|
|
import { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS, filterSgrSequences, toSgrParams, readSgrColorSequence, isFiniteSgrParam, expandSgrReset } from "./ansi-utils.js";
|
|
import {
|
|
buildCollapsedDiffHintText,
|
|
clampRenderedLineToWidth,
|
|
clampRenderedLinesToWidth,
|
|
} from "./line-width-safety.js";
|
|
import {
|
|
buildDiffSummaryText,
|
|
normalizeDiffRenderWidth,
|
|
resolveDiffPresentationMode,
|
|
type DiffPresentationMode,
|
|
} from "./diff-presentation.js";
|
|
import { pluralize, sanitizeAnsiForThemedOutput } from "./render-utils.js";
|
|
import { splitWriteContentLines } from "./write-display-utils.js";
|
|
import { DEFAULT_TOOL_DISPLAY_CONFIG, type DiffIndicatorMode, type ToolDisplayConfig } from "./types.js";
|
|
|
|
interface DiffTheme {
|
|
fg(color: string, text: string): string;
|
|
bg?(color: string, text: string): string;
|
|
bold?(text: string): string;
|
|
getFgAnsi?(color: string): string;
|
|
getBgAnsi?(color: string): string;
|
|
}
|
|
|
|
type DiffLineKind = "add" | "remove" | "context";
|
|
type DiffEntryKind = "line" | "meta" | "hunk" | "file";
|
|
|
|
interface DiffLineEntry {
|
|
kind: "line";
|
|
lineKind: DiffLineKind;
|
|
oldLineNumber: number | null;
|
|
newLineNumber: number | null;
|
|
fallbackLineNumber: string;
|
|
content: string;
|
|
hashlineAnchorContent?: string;
|
|
raw: string;
|
|
hunkIndex: number;
|
|
}
|
|
|
|
interface DiffMetaEntry {
|
|
kind: Exclude<DiffEntryKind, "line">;
|
|
raw: string;
|
|
hunkIndex: number;
|
|
}
|
|
|
|
type ParsedDiffEntry = DiffLineEntry | DiffMetaEntry;
|
|
|
|
interface ParsedDiff {
|
|
entries: ParsedDiffEntry[];
|
|
stats: DiffStats;
|
|
}
|
|
|
|
interface DiffStats {
|
|
added: number;
|
|
removed: number;
|
|
context: number;
|
|
hunks: number;
|
|
files: number;
|
|
lines: number;
|
|
}
|
|
|
|
interface RenderedRow {
|
|
text: string;
|
|
hunkIndex: number | null;
|
|
}
|
|
|
|
interface SplitDiffRow {
|
|
left?: DiffLineEntry;
|
|
right?: DiffLineEntry;
|
|
meta?: DiffMetaEntry;
|
|
hunkIndex: number | null;
|
|
}
|
|
|
|
interface DiffSpan {
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
interface RgbColor {
|
|
r: number;
|
|
g: number;
|
|
b: number;
|
|
}
|
|
|
|
interface DiffPalette {
|
|
addRowBgAnsi: string;
|
|
removeRowBgAnsi: string;
|
|
addEmphasisBgAnsi: string;
|
|
removeEmphasisBgAnsi: string;
|
|
}
|
|
|
|
interface DiffRenderOptions {
|
|
expanded: boolean;
|
|
filePath?: string;
|
|
previousContent?: string;
|
|
fileExistedBeforeWrite?: boolean;
|
|
headerLabel?: string;
|
|
}
|
|
|
|
type CodeLineHighlighter = (line: string) => string;
|
|
|
|
const CANONICAL_LINE_PATTERN = /^([+\- ])(\s*\d+)\|(.*)$/;
|
|
const HASHLINE_ANCHOR_LINE_PATTERN = /^([+\- ])(\s*\d+)#([A-Za-z0-9]+| {2}):(.*)$/;
|
|
const LEGACY_LINE_PATTERN = /^([+\- ])(\s*\d+)\s(.*)$/;
|
|
const HUNK_HEADER_PATTERN = /^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@(.*)$/;
|
|
const SPLIT_SEPARATOR = " │ ";
|
|
const MIN_LINE_NUMBER_WIDTH = 2;
|
|
const MIN_SPLIT_COLUMN_WIDTH = 24;
|
|
const MAX_INLINE_DIFF_LINE_LENGTH = 700;
|
|
const ADD_ROW_BACKGROUND_MIX_RATIO = 0.12;
|
|
const REMOVE_ROW_BACKGROUND_MIX_RATIO = 0.12;
|
|
const ADD_INLINE_EMPHASIS_MIX_RATIO = 0.26;
|
|
const REMOVE_INLINE_EMPHASIS_MIX_RATIO = 0.26;
|
|
const ADDITION_TINT_TARGET: RgbColor = { r: 84, g: 190, b: 118 };
|
|
const DELETION_TINT_TARGET: RgbColor = { r: 232, g: 95, b: 122 };
|
|
const ANSI_BG_RESET = "\x1b[49m";
|
|
const DIFF_WIDTH_OPS = {
|
|
measure: visibleWidth,
|
|
truncate: (text: string, maxWidth: number): string => truncateToWidth(text, maxWidth, ""),
|
|
};
|
|
|
|
function clampDiffLineToWidth(text: string, width: number): string {
|
|
return stabilizeBackgroundResets(clampRenderedLineToWidth(text, width, DIFF_WIDTH_OPS));
|
|
}
|
|
|
|
function clampDiffLinesToWidth(lines: string[], width: number): string[] {
|
|
return clampRenderedLinesToWidth(lines, width, DIFF_WIDTH_OPS).map((line) => stabilizeBackgroundResets(line));
|
|
}
|
|
|
|
function normalizeCodeWhitespace(text: string): string {
|
|
return text.replace(/\t/g, " ");
|
|
}
|
|
|
|
function emphasis(theme: DiffTheme, text: string): string {
|
|
return typeof theme.bold === "function" ? theme.bold(text) : text;
|
|
}
|
|
|
|
|
|
|
|
function sequenceResetsBackground(params: number[]): boolean {
|
|
let index = 0;
|
|
while (index < params.length) {
|
|
const param = params[index] ?? 0;
|
|
if (param === 0 || param === 49) {
|
|
return true;
|
|
}
|
|
|
|
const colorSequence = readSgrColorSequence(params, index);
|
|
index += colorSequence ? colorSequence.length : 1;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function stripBackgroundResetParams(params: number[]): number[] {
|
|
const filtered: number[] = [];
|
|
|
|
for (let i = 0; i < params.length; i++) {
|
|
const param = params[i] ?? 0;
|
|
|
|
if (param === 0) {
|
|
filtered.push(...expandSgrReset(param)!);
|
|
continue;
|
|
}
|
|
|
|
if (param === 49) {
|
|
continue;
|
|
}
|
|
|
|
const colorSequence = readSgrColorSequence(params, i);
|
|
if (colorSequence) {
|
|
filtered.push(...colorSequence);
|
|
i += colorSequence.length - 1;
|
|
continue;
|
|
}
|
|
|
|
filtered.push(param);
|
|
}
|
|
|
|
return filtered;
|
|
}
|
|
|
|
function stabilizeBackgroundResets(text: string): string {
|
|
if (!text) {
|
|
return text;
|
|
}
|
|
return filterSgrSequences(text, stripBackgroundResetParams);
|
|
}
|
|
|
|
function fitToWidth(text: string, width: number): string {
|
|
const trimmed = truncateToWidth(text, width, "");
|
|
const gap = Math.max(0, width - visibleWidth(trimmed));
|
|
return gap > 0 ? `${trimmed}${" ".repeat(gap)}` : trimmed;
|
|
}
|
|
|
|
function applyBackgroundToVisualRow(
|
|
text: string,
|
|
width: number,
|
|
rowBgAnsi: string,
|
|
restoreBgAnsi: string,
|
|
): string {
|
|
if (width <= 0) {
|
|
return "";
|
|
}
|
|
|
|
const fitted = fitToWidth(text, width);
|
|
const withStableBackground = keepBackgroundAcrossResets(fitted, rowBgAnsi);
|
|
return stabilizeBackgroundResets(`${rowBgAnsi}${withStableBackground}${restoreBgAnsi}`);
|
|
}
|
|
|
|
function applyLineBackgroundToWrappedRows(
|
|
rows: string[],
|
|
width: number,
|
|
rowBgAnsi: string,
|
|
restoreBgAnsi: string,
|
|
): string[] {
|
|
if (rows.length === 0) {
|
|
return [applyBackgroundToVisualRow("", width, rowBgAnsi, restoreBgAnsi)];
|
|
}
|
|
|
|
return rows.map((row) => applyBackgroundToVisualRow(row, width, rowBgAnsi, restoreBgAnsi));
|
|
}
|
|
|
|
function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
|
|
if (width <= 0) {
|
|
return [""];
|
|
}
|
|
|
|
if (!wordWrap) {
|
|
return [fitToWidth(text, width)];
|
|
}
|
|
|
|
const wrapped = wrapTextWithAnsi(text, width);
|
|
if (wrapped.length === 0) {
|
|
return [fitToWidth("", width)];
|
|
}
|
|
|
|
return wrapped.map((line) => fitToWidth(line, width));
|
|
}
|
|
|
|
function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
|
|
if (!rawPath || !rawPath.trim()) {
|
|
return undefined;
|
|
}
|
|
const normalizedPath = rawPath.replace(/^@/, "").trim();
|
|
if (!normalizedPath) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return getLanguageFromPath(normalizedPath);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function createCodeLineHighlighter(language: string | undefined): CodeLineHighlighter {
|
|
if (!language) {
|
|
return (line) => sanitizeAnsiForThemedOutput(line);
|
|
}
|
|
|
|
const cache = new Map<string, string>();
|
|
return (line) => {
|
|
if (!line) {
|
|
return line;
|
|
}
|
|
const cached = cache.get(line);
|
|
if (cached !== undefined) {
|
|
return cached;
|
|
}
|
|
try {
|
|
const highlighted = highlightCode(line, language)[0] ?? line;
|
|
const sanitized = sanitizeAnsiForThemedOutput(highlighted);
|
|
cache.set(line, sanitized);
|
|
return sanitized;
|
|
} catch {
|
|
const sanitizedFallback = sanitizeAnsiForThemedOutput(line);
|
|
cache.set(line, sanitizedFallback);
|
|
return sanitizedFallback;
|
|
}
|
|
};
|
|
}
|
|
|
|
function toParsedDiffLine(
|
|
prefix: string,
|
|
lineNumber: string,
|
|
content: string,
|
|
): {
|
|
lineKind: DiffLineKind;
|
|
lineNumber: string;
|
|
content: string;
|
|
} {
|
|
const normalizedLineNumber = lineNumber.trim();
|
|
if (prefix === "+") {
|
|
return { lineKind: "add", lineNumber: normalizedLineNumber, content };
|
|
}
|
|
if (prefix === "-") {
|
|
return { lineKind: "remove", lineNumber: normalizedLineNumber, content };
|
|
}
|
|
return { lineKind: "context", lineNumber: normalizedLineNumber, content };
|
|
}
|
|
|
|
function parseCanonicalDiffLine(line: string): {
|
|
lineKind: DiffLineKind;
|
|
lineNumber: string;
|
|
content: string;
|
|
hashlineAnchorContent?: string;
|
|
} | null {
|
|
const hashlineAnchorMatch = line.match(HASHLINE_ANCHOR_LINE_PATTERN);
|
|
if (hashlineAnchorMatch) {
|
|
const lineNumber = hashlineAnchorMatch[2] ?? "";
|
|
const hash = hashlineAnchorMatch[3] ?? "";
|
|
const content = hashlineAnchorMatch[4] ?? "";
|
|
const parsed = toParsedDiffLine(
|
|
hashlineAnchorMatch[1] ?? " ",
|
|
lineNumber,
|
|
content,
|
|
);
|
|
return {
|
|
...parsed,
|
|
hashlineAnchorContent: `${lineNumber.trim()}#${hash}:${content}`,
|
|
};
|
|
}
|
|
|
|
const canonicalMatch = line.match(CANONICAL_LINE_PATTERN);
|
|
const legacyMatch = canonicalMatch ? null : line.match(LEGACY_LINE_PATTERN);
|
|
const matched = canonicalMatch ?? legacyMatch;
|
|
if (!matched) {
|
|
return null;
|
|
}
|
|
|
|
return toParsedDiffLine(
|
|
matched[1] ?? " ",
|
|
matched[2] ?? "",
|
|
matched[3] ?? "",
|
|
);
|
|
}
|
|
|
|
function toNumber(value: string | undefined): number | null {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
const parsed = Number.parseInt(value, 10);
|
|
return Number.isNaN(parsed) ? null : parsed;
|
|
}
|
|
|
|
function anchorCanonicalLineCursors(
|
|
kind: DiffLineKind,
|
|
parsedNumber: number | null,
|
|
oldLineCursor: number | null,
|
|
newLineCursor: number | null,
|
|
lineNumberDelta: number,
|
|
): { oldLineCursor: number | null; newLineCursor: number | null } {
|
|
if (parsedNumber === null) {
|
|
return { oldLineCursor, newLineCursor };
|
|
}
|
|
|
|
if (kind === "add") {
|
|
return {
|
|
oldLineCursor,
|
|
newLineCursor: newLineCursor ?? parsedNumber,
|
|
};
|
|
}
|
|
|
|
return {
|
|
oldLineCursor: parsedNumber,
|
|
newLineCursor: parsedNumber + lineNumberDelta,
|
|
};
|
|
}
|
|
|
|
function classifyMetaLine(raw: string): DiffMetaEntry["kind"] {
|
|
if (raw.startsWith("@@")) {
|
|
return "hunk";
|
|
}
|
|
if (
|
|
raw.startsWith("diff --git")
|
|
|| raw.startsWith("index ")
|
|
|| raw.startsWith("--- ")
|
|
|| raw.startsWith("+++ ")
|
|
|| raw.startsWith("rename from ")
|
|
|| raw.startsWith("rename to ")
|
|
|| raw.startsWith("new file mode ")
|
|
|| raw.startsWith("deleted file mode ")
|
|
) {
|
|
return "file";
|
|
}
|
|
return "meta";
|
|
}
|
|
|
|
function pushParsedLineEntry(
|
|
entries: ParsedDiffEntry[],
|
|
lineKind: DiffLineKind,
|
|
oldLineNumber: number | null,
|
|
newLineNumber: number | null,
|
|
fallbackLineNumber: string,
|
|
rawLine: string,
|
|
hunkIndex: number,
|
|
): void {
|
|
entries.push({
|
|
kind: "line",
|
|
lineKind,
|
|
oldLineNumber,
|
|
newLineNumber,
|
|
fallbackLineNumber,
|
|
content: rawLine.slice(1),
|
|
raw: rawLine,
|
|
hunkIndex,
|
|
});
|
|
}
|
|
|
|
function createMetaEntry(raw: string, hunkIndex: number): DiffMetaEntry {
|
|
return {
|
|
kind: classifyMetaLine(raw),
|
|
raw,
|
|
hunkIndex,
|
|
};
|
|
}
|
|
|
|
function ensureImplicitHunk(currentHunk: number): number {
|
|
return currentHunk > 0 ? currentHunk : 1;
|
|
}
|
|
|
|
function parseDiff(diffText: string): ParsedDiff {
|
|
const stats: DiffStats = {
|
|
added: 0,
|
|
removed: 0,
|
|
context: 0,
|
|
hunks: 0,
|
|
files: 0,
|
|
lines: 0,
|
|
};
|
|
const entries: ParsedDiffEntry[] = [];
|
|
|
|
if (!diffText.trim()) {
|
|
return { entries, stats };
|
|
}
|
|
|
|
let hunkIndex = 0;
|
|
let oldLineCursor: number | null = null;
|
|
let newLineCursor: number | null = null;
|
|
let lineNumberDelta = 0;
|
|
|
|
for (const rawLine of diffText.replace(/\r/g, "").split("\n")) {
|
|
stats.lines++;
|
|
|
|
const hunkMatch = rawLine.match(HUNK_HEADER_PATTERN);
|
|
if (hunkMatch) {
|
|
hunkIndex++;
|
|
stats.hunks = Math.max(stats.hunks, hunkIndex);
|
|
oldLineCursor = toNumber(hunkMatch[1]);
|
|
newLineCursor = toNumber(hunkMatch[3]);
|
|
lineNumberDelta = (newLineCursor ?? 0) - (oldLineCursor ?? 0);
|
|
entries.push({ kind: "hunk", raw: rawLine, hunkIndex });
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.startsWith("diff --git ")) {
|
|
stats.files++;
|
|
oldLineCursor = null;
|
|
newLineCursor = null;
|
|
lineNumberDelta = 0;
|
|
entries.push({ kind: "file", raw: rawLine, hunkIndex });
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.startsWith("--- ") || rawLine.startsWith("+++ ")) {
|
|
oldLineCursor = null;
|
|
newLineCursor = null;
|
|
lineNumberDelta = 0;
|
|
}
|
|
|
|
const canonical = parseCanonicalDiffLine(rawLine);
|
|
if (canonical) {
|
|
hunkIndex = ensureImplicitHunk(hunkIndex);
|
|
stats.hunks = Math.max(stats.hunks, hunkIndex);
|
|
|
|
const parsedNumber = toNumber(canonical.lineNumber);
|
|
const anchoredCursors = anchorCanonicalLineCursors(
|
|
canonical.lineKind,
|
|
parsedNumber,
|
|
oldLineCursor,
|
|
newLineCursor,
|
|
lineNumberDelta,
|
|
);
|
|
oldLineCursor = anchoredCursors.oldLineCursor;
|
|
newLineCursor = anchoredCursors.newLineCursor;
|
|
|
|
const oldLineNumber = canonical.lineKind === "add" ? null : oldLineCursor;
|
|
const newLineNumber = canonical.lineKind === "remove" ? null : newLineCursor;
|
|
|
|
if (canonical.lineKind === "add") {
|
|
stats.added++;
|
|
if (newLineCursor !== null) {
|
|
newLineCursor++;
|
|
}
|
|
lineNumberDelta++;
|
|
} else if (canonical.lineKind === "remove") {
|
|
stats.removed++;
|
|
if (oldLineCursor !== null) {
|
|
oldLineCursor++;
|
|
}
|
|
lineNumberDelta--;
|
|
} else {
|
|
stats.context++;
|
|
if (oldLineCursor !== null) {
|
|
oldLineCursor++;
|
|
}
|
|
if (newLineCursor !== null) {
|
|
newLineCursor++;
|
|
}
|
|
}
|
|
|
|
entries.push({
|
|
kind: "line",
|
|
lineKind: canonical.lineKind,
|
|
oldLineNumber,
|
|
newLineNumber,
|
|
fallbackLineNumber: canonical.lineNumber,
|
|
content: canonical.content,
|
|
hashlineAnchorContent: canonical.hashlineAnchorContent,
|
|
raw: rawLine,
|
|
hunkIndex,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.startsWith("-") && !rawLine.startsWith("---")) {
|
|
hunkIndex = ensureImplicitHunk(hunkIndex);
|
|
stats.hunks = Math.max(stats.hunks, hunkIndex);
|
|
stats.removed++;
|
|
const oldLineNumber = oldLineCursor;
|
|
if (oldLineCursor !== null) {
|
|
oldLineCursor++;
|
|
}
|
|
lineNumberDelta--;
|
|
pushParsedLineEntry(
|
|
entries,
|
|
"remove",
|
|
oldLineNumber,
|
|
null,
|
|
oldLineNumber !== null ? `${oldLineNumber}` : "",
|
|
rawLine,
|
|
hunkIndex,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.startsWith("+") && !rawLine.startsWith("+++")) {
|
|
hunkIndex = ensureImplicitHunk(hunkIndex);
|
|
stats.hunks = Math.max(stats.hunks, hunkIndex);
|
|
stats.added++;
|
|
const newLineNumber = newLineCursor;
|
|
if (newLineCursor !== null) {
|
|
newLineCursor++;
|
|
}
|
|
lineNumberDelta++;
|
|
pushParsedLineEntry(
|
|
entries,
|
|
"add",
|
|
null,
|
|
newLineNumber,
|
|
newLineNumber !== null ? `${newLineNumber}` : "",
|
|
rawLine,
|
|
hunkIndex,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (rawLine.startsWith(" ")) {
|
|
hunkIndex = ensureImplicitHunk(hunkIndex);
|
|
stats.hunks = Math.max(stats.hunks, hunkIndex);
|
|
stats.context++;
|
|
const oldLineNumber = oldLineCursor;
|
|
const newLineNumber = newLineCursor;
|
|
if (oldLineCursor !== null) {
|
|
oldLineCursor++;
|
|
}
|
|
if (newLineCursor !== null) {
|
|
newLineCursor++;
|
|
}
|
|
pushParsedLineEntry(
|
|
entries,
|
|
"context",
|
|
oldLineNumber,
|
|
newLineNumber,
|
|
oldLineNumber !== null ? `${oldLineNumber}` : newLineNumber !== null ? `${newLineNumber}` : "",
|
|
rawLine,
|
|
hunkIndex,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
entries.push(createMetaEntry(rawLine, hunkIndex));
|
|
}
|
|
|
|
if (stats.hunks === 0 && (stats.added > 0 || stats.removed > 0 || stats.context > 0)) {
|
|
stats.hunks = 1;
|
|
}
|
|
if (stats.files === 0) {
|
|
const patchStyleFileHeaders = entries.filter(
|
|
(entry) => entry.kind === "file" && entry.raw.startsWith("+++ "),
|
|
).length;
|
|
if (patchStyleFileHeaders > 0) {
|
|
stats.files = patchStyleFileHeaders;
|
|
} else if (stats.hunks > 0) {
|
|
stats.files = 1;
|
|
}
|
|
}
|
|
|
|
return { entries, stats };
|
|
}
|
|
|
|
function getHashlineAnchorLabel(entry: DiffLineEntry): string | undefined {
|
|
if (!entry.hashlineAnchorContent) {
|
|
return undefined;
|
|
}
|
|
const separatorIndex = entry.hashlineAnchorContent.indexOf(":");
|
|
return separatorIndex >= 0
|
|
? entry.hashlineAnchorContent.slice(0, separatorIndex)
|
|
: entry.hashlineAnchorContent;
|
|
}
|
|
|
|
function getLineNumberWidth(entries: ParsedDiffEntry[], showHashlineAnchors = false): number {
|
|
let maxWidth = MIN_LINE_NUMBER_WIDTH;
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind !== "line") {
|
|
continue;
|
|
}
|
|
|
|
if (showHashlineAnchors) {
|
|
const anchorLabel = getHashlineAnchorLabel(entry);
|
|
if (anchorLabel) {
|
|
maxWidth = Math.max(maxWidth, visibleWidth(anchorLabel));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const candidates = [
|
|
entry.oldLineNumber,
|
|
entry.newLineNumber,
|
|
toNumber(entry.fallbackLineNumber),
|
|
].filter((value): value is number => value !== null);
|
|
|
|
for (const candidate of candidates) {
|
|
const digits = `${candidate}`.length;
|
|
if (digits > maxWidth) {
|
|
maxWidth = digits;
|
|
}
|
|
}
|
|
}
|
|
|
|
return maxWidth;
|
|
}
|
|
|
|
function formatLineNumber(value: number | null, fallback: string, width: number): string {
|
|
if (value !== null) {
|
|
return `${value}`.padStart(width, " ");
|
|
}
|
|
if (fallback.trim()) {
|
|
return fallback.trim().slice(-width).padStart(width, " ");
|
|
}
|
|
return " ".repeat(width);
|
|
}
|
|
|
|
function formatLineNumberLabel(
|
|
entry: DiffLineEntry,
|
|
value: number | null,
|
|
fallback: string,
|
|
width: number,
|
|
showHashlineAnchors: boolean,
|
|
): string {
|
|
const anchorLabel = showHashlineAnchors ? getHashlineAnchorLabel(entry) : undefined;
|
|
if (anchorLabel) {
|
|
return fitToWidth(anchorLabel, width);
|
|
}
|
|
return formatLineNumber(value, fallback, width);
|
|
}
|
|
|
|
function formatMetaEntryRows(entry: DiffMetaEntry, width: number, theme: DiffTheme, wordWrap: boolean): RenderedRow[] {
|
|
const normalized = sanitizeAnsiForThemedOutput(normalizeCodeWhitespace(entry.raw));
|
|
const lines = wordWrap
|
|
? wrapToWidth(normalized, width, true)
|
|
: [truncateToWidth(normalized, width)];
|
|
|
|
const mapColor = (line: string): string => {
|
|
if (entry.kind === "hunk") {
|
|
return stabilizeBackgroundResets(theme.fg("accent", line));
|
|
}
|
|
if (entry.kind === "file") {
|
|
return stabilizeBackgroundResets(theme.fg("muted", line));
|
|
}
|
|
return stabilizeBackgroundResets(theme.fg("toolDiffContext", line));
|
|
};
|
|
|
|
return lines.map((line) => ({
|
|
text: mapColor(line),
|
|
hunkIndex: entry.kind === "file" ? null : entry.hunkIndex || null,
|
|
}));
|
|
}
|
|
|
|
function collectConsecutiveLineEntries(
|
|
entries: ParsedDiffEntry[],
|
|
startIndex: number,
|
|
lineKind: DiffLineKind,
|
|
): { collected: DiffLineEntry[]; nextIndex: number } {
|
|
const collected: DiffLineEntry[] = [];
|
|
let index = startIndex;
|
|
while (index < entries.length) {
|
|
const candidate = entries[index];
|
|
if (!candidate || candidate.kind !== "line" || candidate.lineKind !== lineKind) {
|
|
break;
|
|
}
|
|
collected.push(candidate);
|
|
index++;
|
|
}
|
|
return { collected, nextIndex: index };
|
|
}
|
|
|
|
function buildSplitRows(entries: ParsedDiffEntry[]): SplitDiffRow[] {
|
|
const rows: SplitDiffRow[] = [];
|
|
let index = 0;
|
|
|
|
while (index < entries.length) {
|
|
const entry = entries[index];
|
|
if (!entry) {
|
|
break;
|
|
}
|
|
|
|
if (entry.kind !== "line") {
|
|
rows.push({ meta: entry, hunkIndex: entry.hunkIndex || null });
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
if (entry.lineKind === "remove") {
|
|
const removedResult = collectConsecutiveLineEntries(entries, index, "remove");
|
|
const removed = removedResult.collected;
|
|
const addedResult = collectConsecutiveLineEntries(entries, removedResult.nextIndex, "add");
|
|
const added = addedResult.collected;
|
|
index = addedResult.nextIndex;
|
|
|
|
const pairCount = Math.max(removed.length, added.length);
|
|
for (let pairIndex = 0; pairIndex < pairCount; pairIndex++) {
|
|
const left = removed[pairIndex];
|
|
const right = added[pairIndex];
|
|
rows.push({
|
|
left,
|
|
right,
|
|
hunkIndex: left?.hunkIndex ?? right?.hunkIndex ?? null,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (entry.lineKind === "add") {
|
|
rows.push({ right: entry, hunkIndex: entry.hunkIndex || null });
|
|
index++;
|
|
continue;
|
|
}
|
|
|
|
rows.push({ left: entry, right: entry, hunkIndex: entry.hunkIndex || null });
|
|
index++;
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
function getCellLineNumber(line: DiffLineEntry, side: "left" | "right"): number | null {
|
|
if (side === "left") {
|
|
return line.oldLineNumber ?? (line.lineKind === "context" ? line.newLineNumber : null);
|
|
}
|
|
return line.newLineNumber ?? (line.lineKind === "context" ? line.oldLineNumber : null);
|
|
}
|
|
|
|
function tokenizeInlineDiff(input: string): Array<{ value: string; start: number; end: number }> {
|
|
if (!input) {
|
|
return [];
|
|
}
|
|
|
|
const tokens: Array<{ value: string; start: number; end: number }> = [];
|
|
const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = pattern.exec(input)) !== null) {
|
|
const value = match[0] ?? "";
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
tokens.push({
|
|
value,
|
|
start: match.index,
|
|
end: match.index + value.length,
|
|
});
|
|
}
|
|
|
|
if (tokens.length === 0 && input.length > 0) {
|
|
tokens.push({ value: input, start: 0, end: input.length });
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
function mergeSpans(spans: DiffSpan[]): DiffSpan[] {
|
|
if (spans.length <= 1) {
|
|
return spans;
|
|
}
|
|
|
|
const sorted = [...spans].sort((a, b) => a.start - b.start);
|
|
const merged: DiffSpan[] = [sorted[0]];
|
|
|
|
for (let index = 1; index < sorted.length; index++) {
|
|
const current = sorted[index];
|
|
const previous = merged[merged.length - 1];
|
|
if (!current || !previous) {
|
|
continue;
|
|
}
|
|
|
|
if (current.start <= previous.end) {
|
|
previous.end = Math.max(previous.end, current.end);
|
|
continue;
|
|
}
|
|
|
|
merged.push({ ...current });
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
function tokensToDiffSpans(
|
|
text: string,
|
|
tokens: Array<{ value: string; start: number; end: number }>,
|
|
changedIndexes: Set<number>,
|
|
): DiffSpan[] {
|
|
if (tokens.length === 0 || changedIndexes.size === 0) {
|
|
return [];
|
|
}
|
|
|
|
const spans: DiffSpan[] = [];
|
|
let start: number | null = null;
|
|
let end = -1;
|
|
|
|
for (let index = 0; index < tokens.length; index++) {
|
|
if (!changedIndexes.has(index)) {
|
|
if (start !== null && end > start) {
|
|
spans.push({ start, end });
|
|
start = null;
|
|
end = -1;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const token = tokens[index];
|
|
if (!token) {
|
|
continue;
|
|
}
|
|
|
|
if (start === null) {
|
|
start = token.start;
|
|
end = token.end;
|
|
} else {
|
|
end = token.end;
|
|
}
|
|
}
|
|
|
|
if (start !== null && end > start) {
|
|
spans.push({ start, end });
|
|
}
|
|
|
|
const trimmed: DiffSpan[] = [];
|
|
for (const span of spans) {
|
|
let spanStart = span.start;
|
|
let spanEnd = span.end;
|
|
|
|
while (spanStart < spanEnd && /\s/.test(text[spanStart] ?? "")) {
|
|
spanStart++;
|
|
}
|
|
while (spanEnd > spanStart && /\s/.test(text[spanEnd - 1] ?? "")) {
|
|
spanEnd--;
|
|
}
|
|
if (spanEnd > spanStart) {
|
|
trimmed.push({ start: spanStart, end: spanEnd });
|
|
}
|
|
}
|
|
|
|
return mergeSpans(trimmed);
|
|
}
|
|
|
|
function computeInlineDiffSpans(leftLine: string, rightLine: string): { left: DiffSpan[]; right: DiffSpan[] } {
|
|
if (leftLine === rightLine) {
|
|
return { left: [], right: [] };
|
|
}
|
|
if (leftLine.length > MAX_INLINE_DIFF_LINE_LENGTH || rightLine.length > MAX_INLINE_DIFF_LINE_LENGTH) {
|
|
return { left: [], right: [] };
|
|
}
|
|
|
|
const leftTokens = tokenizeInlineDiff(leftLine);
|
|
const rightTokens = tokenizeInlineDiff(rightLine);
|
|
const leftCount = leftTokens.length;
|
|
const rightCount = rightTokens.length;
|
|
|
|
if (leftCount === 0 || rightCount === 0) {
|
|
return {
|
|
left: leftLine.trim().length > 0 ? [{ start: 0, end: leftLine.length }] : [],
|
|
right: rightLine.trim().length > 0 ? [{ start: 0, end: rightLine.length }] : [],
|
|
};
|
|
}
|
|
|
|
const table: number[][] = Array.from({ length: leftCount + 1 }, () => Array<number>(rightCount + 1).fill(0));
|
|
|
|
for (let leftIndex = 1; leftIndex <= leftCount; leftIndex++) {
|
|
const leftToken = leftTokens[leftIndex - 1];
|
|
for (let rightIndex = 1; rightIndex <= rightCount; rightIndex++) {
|
|
const rightToken = rightTokens[rightIndex - 1];
|
|
if (leftToken?.value === rightToken?.value) {
|
|
table[leftIndex][rightIndex] = (table[leftIndex - 1]?.[rightIndex - 1] ?? 0) + 1;
|
|
} else {
|
|
const top = table[leftIndex - 1]?.[rightIndex] ?? 0;
|
|
const side = table[leftIndex]?.[rightIndex - 1] ?? 0;
|
|
table[leftIndex][rightIndex] = Math.max(top, side);
|
|
}
|
|
}
|
|
}
|
|
|
|
const changedLeft = new Set<number>();
|
|
const changedRight = new Set<number>();
|
|
let leftCursor = leftCount;
|
|
let rightCursor = rightCount;
|
|
|
|
while (leftCursor > 0 && rightCursor > 0) {
|
|
const leftToken = leftTokens[leftCursor - 1];
|
|
const rightToken = rightTokens[rightCursor - 1];
|
|
if (leftToken?.value === rightToken?.value) {
|
|
leftCursor--;
|
|
rightCursor--;
|
|
continue;
|
|
}
|
|
|
|
const top = table[leftCursor - 1]?.[rightCursor] ?? 0;
|
|
const side = table[leftCursor]?.[rightCursor - 1] ?? 0;
|
|
if (top >= side) {
|
|
changedLeft.add(leftCursor - 1);
|
|
leftCursor--;
|
|
} else {
|
|
changedRight.add(rightCursor - 1);
|
|
rightCursor--;
|
|
}
|
|
}
|
|
|
|
while (leftCursor > 0) {
|
|
changedLeft.add(leftCursor - 1);
|
|
leftCursor--;
|
|
}
|
|
while (rightCursor > 0) {
|
|
changedRight.add(rightCursor - 1);
|
|
rightCursor--;
|
|
}
|
|
|
|
return {
|
|
left: tokensToDiffSpans(leftLine, leftTokens, changedLeft),
|
|
right: tokensToDiffSpans(rightLine, rightTokens, changedRight),
|
|
};
|
|
}
|
|
|
|
function getCompactLineRenderContent(entry: DiffLineEntry, showHashlineAnchors: boolean): string {
|
|
return showHashlineAnchors && entry.hashlineAnchorContent
|
|
? entry.hashlineAnchorContent
|
|
: entry.content;
|
|
}
|
|
|
|
function buildInlineHighlightMap(rows: SplitDiffRow[]): WeakMap<DiffLineEntry, DiffSpan[]> {
|
|
const highlights = new WeakMap<DiffLineEntry, DiffSpan[]>();
|
|
|
|
for (const row of rows) {
|
|
if (!row.left || !row.right) {
|
|
continue;
|
|
}
|
|
if (row.left.lineKind !== "remove" || row.right.lineKind !== "add") {
|
|
continue;
|
|
}
|
|
|
|
const leftText = normalizeCodeWhitespace(row.left.content);
|
|
const rightText = normalizeCodeWhitespace(row.right.content);
|
|
const inline = computeInlineDiffSpans(leftText, rightText);
|
|
if (inline.left.length > 0) {
|
|
highlights.set(row.left, inline.left);
|
|
}
|
|
if (inline.right.length > 0) {
|
|
highlights.set(row.right, inline.right);
|
|
}
|
|
}
|
|
|
|
return highlights;
|
|
}
|
|
|
|
function ansi256ToRgb(code: number): RgbColor {
|
|
if (code < 0) {
|
|
return { r: 0, g: 0, b: 0 };
|
|
}
|
|
if (code <= 15) {
|
|
const base16: RgbColor[] = [
|
|
{ r: 0, g: 0, b: 0 },
|
|
{ r: 128, g: 0, b: 0 },
|
|
{ r: 0, g: 128, b: 0 },
|
|
{ r: 128, g: 128, b: 0 },
|
|
{ r: 0, g: 0, b: 128 },
|
|
{ r: 128, g: 0, b: 128 },
|
|
{ r: 0, g: 128, b: 128 },
|
|
{ r: 192, g: 192, b: 192 },
|
|
{ r: 128, g: 128, b: 128 },
|
|
{ r: 255, g: 0, b: 0 },
|
|
{ r: 0, g: 255, b: 0 },
|
|
{ r: 255, g: 255, b: 0 },
|
|
{ r: 0, g: 0, b: 255 },
|
|
{ r: 255, g: 0, b: 255 },
|
|
{ r: 0, g: 255, b: 255 },
|
|
{ r: 255, g: 255, b: 255 },
|
|
];
|
|
return base16[code] ?? { r: 255, g: 255, b: 255 };
|
|
}
|
|
if (code >= 232) {
|
|
const value = Math.max(0, Math.min(255, 8 + (code - 232) * 10));
|
|
return { r: value, g: value, b: value };
|
|
}
|
|
|
|
const cube = code - 16;
|
|
const levels = [0, 95, 135, 175, 215, 255];
|
|
const blue = cube % 6;
|
|
const green = Math.floor(cube / 6) % 6;
|
|
const red = Math.floor(cube / 36) % 6;
|
|
return {
|
|
r: levels[red] ?? 0,
|
|
g: levels[green] ?? 0,
|
|
b: levels[blue] ?? 0,
|
|
};
|
|
}
|
|
|
|
function parseAnsiColorCode(ansi: string | undefined): RgbColor | null {
|
|
if (!ansi) {
|
|
return null;
|
|
}
|
|
const rgbMatch = /\x1b\[(?:3|4)8;2;(\d{1,3});(\d{1,3});(\d{1,3})m/.exec(ansi);
|
|
if (rgbMatch) {
|
|
const r = Number.parseInt(rgbMatch[1] ?? "0", 10);
|
|
const g = Number.parseInt(rgbMatch[2] ?? "0", 10);
|
|
const b = Number.parseInt(rgbMatch[3] ?? "0", 10);
|
|
if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
|
|
return {
|
|
r: Math.max(0, Math.min(255, r)),
|
|
g: Math.max(0, Math.min(255, g)),
|
|
b: Math.max(0, Math.min(255, b)),
|
|
};
|
|
}
|
|
}
|
|
|
|
const bitMatch = /\x1b\[(?:3|4)8;5;(\d{1,3})m/.exec(ansi);
|
|
if (bitMatch) {
|
|
const code = Number.parseInt(bitMatch[1] ?? "0", 10);
|
|
if (Number.isFinite(code)) {
|
|
return ansi256ToRgb(code);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function rgbToBgAnsi(color: RgbColor): string {
|
|
const r = Math.max(0, Math.min(255, Math.round(color.r)));
|
|
const g = Math.max(0, Math.min(255, Math.round(color.g)));
|
|
const b = Math.max(0, Math.min(255, Math.round(color.b)));
|
|
return `\x1b[48;2;${r};${g};${b}m`;
|
|
}
|
|
|
|
function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
|
|
const clamped = Math.max(0, Math.min(1, ratio));
|
|
return {
|
|
r: base.r * (1 - clamped) + tint.r * clamped,
|
|
g: base.g * (1 - clamped) + tint.g * clamped,
|
|
b: base.b * (1 - clamped) + tint.b * clamped,
|
|
};
|
|
}
|
|
|
|
function extractThemeBackgroundAnsi(text: string): string | undefined {
|
|
if (!text || !text.includes("\x1b[")) {
|
|
return undefined;
|
|
}
|
|
|
|
ANSI_SGR_PATTERN.lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
|
|
const parsed = toSgrParams(match[1] ?? "");
|
|
for (let index = 0; index < parsed.length; index += 1) {
|
|
const param = parsed[index] ?? 0;
|
|
if ((param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
|
|
return `\x1b[${param}m`;
|
|
}
|
|
|
|
const colorSequence = readSgrColorSequence(parsed, index);
|
|
if (colorSequence?.[0] === 48) {
|
|
return `\x1b[${colorSequence.join(";")}m`;
|
|
}
|
|
if (colorSequence) {
|
|
index += colorSequence.length - 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function readThemeAnsi(theme: DiffTheme, kind: "fg" | "bg", slot: string): string | undefined {
|
|
try {
|
|
if (kind === "fg" && typeof theme.getFgAnsi === "function") {
|
|
return theme.getFgAnsi(slot);
|
|
}
|
|
if (kind === "bg") {
|
|
if (typeof theme.getBgAnsi === "function") {
|
|
return theme.getBgAnsi(slot);
|
|
}
|
|
if (typeof theme.bg === "function") {
|
|
return extractThemeBackgroundAnsi(theme.bg(slot, " "));
|
|
}
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveContainerBackgroundAnsi(theme: DiffTheme): string | undefined {
|
|
return readThemeAnsi(theme, "bg", "toolSuccessBg")
|
|
?? readThemeAnsi(theme, "bg", "toolPendingBg")
|
|
?? readThemeAnsi(theme, "bg", "toolErrorBg")
|
|
?? readThemeAnsi(theme, "bg", "userMessageBg");
|
|
}
|
|
|
|
function resolveDiffPalette(theme: DiffTheme): DiffPalette {
|
|
const baseBg = parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg"))
|
|
?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolPendingBg"))
|
|
?? parseAnsiColorCode(readThemeAnsi(theme, "bg", "userMessageBg"))
|
|
?? { r: 32, g: 35, b: 42 };
|
|
const addFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffAdded")) ?? { r: 88, g: 173, b: 88 };
|
|
const removeFg = parseAnsiColorCode(readThemeAnsi(theme, "fg", "toolDiffRemoved")) ?? { r: 196, g: 98, b: 98 };
|
|
const addTint = mixRgb(addFg, ADDITION_TINT_TARGET, 0.35);
|
|
const removeTint = mixRgb(removeFg, DELETION_TINT_TARGET, 0.65);
|
|
|
|
const addRowBg = mixRgb(baseBg, addTint, ADD_ROW_BACKGROUND_MIX_RATIO);
|
|
const removeRowBg = mixRgb(baseBg, removeTint, REMOVE_ROW_BACKGROUND_MIX_RATIO);
|
|
const addEmphasisBg = mixRgb(baseBg, addTint, ADD_INLINE_EMPHASIS_MIX_RATIO);
|
|
const removeEmphasisBg = mixRgb(baseBg, removeTint, REMOVE_INLINE_EMPHASIS_MIX_RATIO);
|
|
|
|
return {
|
|
addRowBgAnsi: rgbToBgAnsi(addRowBg),
|
|
removeRowBgAnsi: rgbToBgAnsi(removeRowBg),
|
|
addEmphasisBgAnsi: rgbToBgAnsi(addEmphasisBg),
|
|
removeEmphasisBgAnsi: rgbToBgAnsi(removeEmphasisBg),
|
|
};
|
|
}
|
|
|
|
function getLineBackground(kind: DiffLineKind, palette: DiffPalette, emphasis: boolean): string | undefined {
|
|
if (kind === "add") {
|
|
return emphasis ? palette.addEmphasisBgAnsi : palette.addRowBgAnsi;
|
|
}
|
|
if (kind === "remove") {
|
|
return emphasis ? palette.removeEmphasisBgAnsi : palette.removeRowBgAnsi;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function applyBackgroundToVisibleRange(
|
|
ansiText: string,
|
|
start: number,
|
|
end: number,
|
|
backgroundAnsi: string,
|
|
restoreBackgroundAnsi: string,
|
|
): string {
|
|
if (!ansiText || start >= end || end <= 0) {
|
|
return ansiText;
|
|
}
|
|
|
|
const rangeStart = Math.max(0, start);
|
|
const rangeEnd = Math.max(rangeStart, end);
|
|
let output = "";
|
|
let visibleIndex = 0;
|
|
let index = 0;
|
|
let inRange = false;
|
|
|
|
while (index < ansiText.length) {
|
|
if (ansiText[index] === "\x1b") {
|
|
const sequenceEnd = ansiText.indexOf("m", index);
|
|
if (sequenceEnd !== -1) {
|
|
output += ansiText.slice(index, sequenceEnd + 1);
|
|
index = sequenceEnd + 1;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (visibleIndex === rangeStart && !inRange) {
|
|
output += backgroundAnsi;
|
|
inRange = true;
|
|
}
|
|
if (visibleIndex === rangeEnd && inRange) {
|
|
output += restoreBackgroundAnsi;
|
|
inRange = false;
|
|
}
|
|
|
|
output += ansiText[index] ?? "";
|
|
visibleIndex++;
|
|
index++;
|
|
}
|
|
|
|
if (inRange) {
|
|
output += restoreBackgroundAnsi;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function applyInlineSpanHighlight(
|
|
plainText: string,
|
|
renderedText: string,
|
|
spans: DiffSpan[],
|
|
emphasisBgAnsi: string | undefined,
|
|
rowBgAnsi: string | undefined,
|
|
fallbackBgAnsi: string | undefined,
|
|
): string {
|
|
if (!renderedText || !plainText || spans.length === 0 || !emphasisBgAnsi) {
|
|
return renderedText;
|
|
}
|
|
|
|
const sorted = mergeSpans(
|
|
spans
|
|
.map((span) => ({
|
|
start: Math.max(0, Math.min(plainText.length, span.start)),
|
|
end: Math.max(0, Math.min(plainText.length, span.end)),
|
|
}))
|
|
.filter((span) => span.end > span.start),
|
|
);
|
|
if (sorted.length === 0) {
|
|
return renderedText;
|
|
}
|
|
|
|
const restoreBackgroundAnsi = rowBgAnsi ?? fallbackBgAnsi ?? ANSI_BG_RESET;
|
|
let highlighted = renderedText;
|
|
for (let index = sorted.length - 1; index >= 0; index--) {
|
|
const span = sorted[index];
|
|
if (!span) {
|
|
continue;
|
|
}
|
|
highlighted = applyBackgroundToVisibleRange(
|
|
highlighted,
|
|
span.start,
|
|
span.end,
|
|
emphasisBgAnsi,
|
|
restoreBackgroundAnsi,
|
|
);
|
|
}
|
|
|
|
return highlighted;
|
|
}
|
|
|
|
function resolveDiffIndicatorMode(config: Partial<Pick<ToolDisplayConfig, "diffIndicatorMode">>): DiffIndicatorMode {
|
|
return config.diffIndicatorMode ?? DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
|
|
}
|
|
|
|
function resolveIndicatorGlyph(kind: DiffLineKind, indicatorMode: DiffIndicatorMode, continuation: boolean): string {
|
|
if (kind === "context") {
|
|
return " ";
|
|
}
|
|
|
|
switch (indicatorMode) {
|
|
case "bars":
|
|
return "▌";
|
|
case "classic":
|
|
if (continuation) {
|
|
return " ";
|
|
}
|
|
return kind === "add" ? "+" : "-";
|
|
case "none":
|
|
default:
|
|
return " ";
|
|
}
|
|
}
|
|
|
|
function colorizeSegment(
|
|
theme: DiffTheme,
|
|
color: "dim" | "toolDiffAdded" | "toolDiffRemoved",
|
|
text: string,
|
|
rowBg: string | undefined,
|
|
): string {
|
|
let themedText: string;
|
|
try {
|
|
themedText = theme.fg(color, text);
|
|
} catch {
|
|
themedText = text;
|
|
}
|
|
|
|
if (!rowBg) {
|
|
return themedText;
|
|
}
|
|
|
|
const stableText = keepBackgroundAcrossResets(themedText, rowBg);
|
|
return `${rowBg}${stableText}${rowBg}`;
|
|
}
|
|
|
|
function keepBackgroundAcrossResets(text: string, rowBg: string): string {
|
|
if (!text) {
|
|
return text;
|
|
}
|
|
|
|
return text.replace(ANSI_SGR_PATTERN, (sequence, rawParams: string) => {
|
|
const params = toSgrParams(rawParams);
|
|
if (params.length === 0 || !sequenceResetsBackground(params)) {
|
|
return sequence;
|
|
}
|
|
return `${sequence}${rowBg}`;
|
|
});
|
|
}
|
|
|
|
function renderChangeMarker(
|
|
kind: DiffLineKind,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
indicatorMode: DiffIndicatorMode,
|
|
continuation = false,
|
|
): string {
|
|
const glyph = resolveIndicatorGlyph(kind, indicatorMode, continuation);
|
|
if (glyph === " ") {
|
|
return rowBg ? `${rowBg} ${rowBg}` : " ";
|
|
}
|
|
if (kind === "add") {
|
|
return colorizeSegment(theme, "toolDiffAdded", glyph, rowBg);
|
|
}
|
|
if (kind === "remove") {
|
|
return colorizeSegment(theme, "toolDiffRemoved", glyph, rowBg);
|
|
}
|
|
return colorizeSegment(theme, "dim", glyph, rowBg);
|
|
}
|
|
|
|
function usesHashlineGutter(showHashlineAnchors: boolean): boolean {
|
|
return showHashlineAnchors;
|
|
}
|
|
|
|
function getHashlineGutterMarkerWidth(_indicatorMode: DiffIndicatorMode): number {
|
|
return 0;
|
|
}
|
|
|
|
function getLineDividerPlainWidth(indicatorMode: DiffIndicatorMode, hashlineGutter = false): number {
|
|
if (hashlineGutter) {
|
|
return 2;
|
|
}
|
|
return indicatorMode === "classic" ? 1 : 2;
|
|
}
|
|
|
|
function renderCodeDivider(
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter = false,
|
|
): string {
|
|
return colorizeSegment(theme, "dim", hashlineGutter || indicatorMode !== "classic" ? "│ " : "│", rowBg);
|
|
}
|
|
|
|
function getLineNumberColor(kind: DiffLineKind): "dim" | "toolDiffAdded" | "toolDiffRemoved" {
|
|
if (kind === "add") {
|
|
return "toolDiffAdded";
|
|
}
|
|
if (kind === "remove") {
|
|
return "toolDiffRemoved";
|
|
}
|
|
return "dim";
|
|
}
|
|
|
|
function renderLineNumberSegment(
|
|
kind: DiffLineKind,
|
|
lineNumber: string,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
): string {
|
|
return colorizeSegment(theme, getLineNumberColor(kind), lineNumber, rowBg);
|
|
}
|
|
|
|
function getLinePrefixPlainWidth(lineNumberWidth: number, indicatorMode: DiffIndicatorMode, hashlineGutter = false): number {
|
|
if (hashlineGutter) {
|
|
return getHashlineGutterMarkerWidth(indicatorMode) + lineNumberWidth;
|
|
}
|
|
return indicatorMode === "bars"
|
|
? visibleWidth(`▌ ${" ".repeat(lineNumberWidth)} `)
|
|
: visibleWidth(`${" ".repeat(lineNumberWidth)} `);
|
|
}
|
|
|
|
function getLineContentIndicatorPrefixPlainWidth(indicatorMode: DiffIndicatorMode): number {
|
|
return indicatorMode === "classic" ? 2 : 0;
|
|
}
|
|
|
|
function renderClassicContentPrefix(
|
|
kind: DiffLineKind,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
continuation = false,
|
|
): string {
|
|
if (kind === "context" || continuation) {
|
|
return rowBg ? `${rowBg} ${rowBg}` : " ";
|
|
}
|
|
|
|
const glyph = kind === "add" ? "+" : "-";
|
|
const glyphColor = kind === "add" ? "toolDiffAdded" : "toolDiffRemoved";
|
|
const spacer = rowBg ? `${rowBg} ` : " ";
|
|
return `${colorizeSegment(theme, glyphColor, glyph, rowBg)}${spacer}`;
|
|
}
|
|
|
|
function renderLinePrefix(
|
|
kind: DiffLineKind,
|
|
lineNumber: string,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
indicatorMode: DiffIndicatorMode,
|
|
continuation = false,
|
|
hashlineGutter = false,
|
|
): string {
|
|
const number = renderLineNumberSegment(kind, lineNumber, theme, rowBg);
|
|
if (hashlineGutter) {
|
|
return number;
|
|
}
|
|
const spacer = rowBg ? `${rowBg} ` : " ";
|
|
if (indicatorMode !== "bars") {
|
|
return `${number}${spacer}`;
|
|
}
|
|
const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
|
|
return `${marker}${spacer}${number}${spacer}`;
|
|
}
|
|
|
|
function renderLineContinuationPrefix(
|
|
kind: DiffLineKind,
|
|
lineNumberWidth: number,
|
|
rowBg: string | undefined,
|
|
theme: DiffTheme,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter = false,
|
|
): string {
|
|
const blankLineNumber = " ".repeat(lineNumberWidth);
|
|
return renderLinePrefix(kind, blankLineNumber, theme, rowBg, indicatorMode, true, hashlineGutter);
|
|
}
|
|
|
|
function renderLineContentIndicatorPrefix(
|
|
kind: DiffLineKind,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
indicatorMode: DiffIndicatorMode,
|
|
continuation = false,
|
|
): string {
|
|
return indicatorMode === "classic"
|
|
? renderClassicContentPrefix(kind, theme, rowBg, continuation)
|
|
: "";
|
|
}
|
|
|
|
function renderCompactLinePrefix(
|
|
kind: DiffLineKind,
|
|
theme: DiffTheme,
|
|
rowBg: string | undefined,
|
|
indicatorMode: DiffIndicatorMode,
|
|
continuation = false,
|
|
): string {
|
|
const marker = renderChangeMarker(kind, theme, rowBg, indicatorMode, continuation);
|
|
const spacer = rowBg ? `${rowBg} ` : " ";
|
|
return `${marker}${spacer}`;
|
|
}
|
|
|
|
interface LineCellRenderParams {
|
|
kind: DiffLineKind;
|
|
code: string;
|
|
width: number;
|
|
rowBg: string | undefined;
|
|
restoreBgAnsi: string | undefined;
|
|
theme: DiffTheme;
|
|
wordWrap: boolean;
|
|
indicatorMode: DiffIndicatorMode;
|
|
}
|
|
|
|
function renderWrappedRowsWithOptionalBackground(
|
|
wrappedCodeLines: string[],
|
|
buildRow: (index: number, wrappedCodeLine: string) => string,
|
|
width: number,
|
|
rowBg: string | undefined,
|
|
restoreBgAnsi: string | undefined,
|
|
): string[] {
|
|
if (!rowBg) {
|
|
return wrappedCodeLines.map((wrappedCodeLine, index) =>
|
|
stabilizeBackgroundResets(buildRow(index, wrappedCodeLine)),
|
|
);
|
|
}
|
|
const safeRestoreBgAnsi = restoreBgAnsi ?? rowBg ?? ANSI_BG_RESET;
|
|
const visualRows = wrappedCodeLines.map((wrappedCodeLine, index) => buildRow(index, wrappedCodeLine));
|
|
return applyLineBackgroundToWrappedRows(visualRows, width, rowBg, safeRestoreBgAnsi);
|
|
}
|
|
|
|
function computeLineCellCodeWidth(
|
|
width: number,
|
|
lineNumberWidth: number,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter: boolean,
|
|
): number {
|
|
const prefixPlainWidth = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
|
|
const dividerPlainWidth = getLineDividerPlainWidth(indicatorMode, hashlineGutter);
|
|
const contentIndicatorWidth = hashlineGutter ? 0 : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
|
|
return Math.max(0, width - prefixPlainWidth - dividerPlainWidth - contentIndicatorWidth);
|
|
}
|
|
|
|
function buildLineCellParams(
|
|
kind: DiffLineKind,
|
|
code: string,
|
|
width: number,
|
|
rowBg: string | undefined,
|
|
restoreBgAnsi: string | undefined,
|
|
theme: DiffTheme,
|
|
wordWrap: boolean,
|
|
indicatorMode: DiffIndicatorMode,
|
|
): LineCellRenderParams {
|
|
return { kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode };
|
|
}
|
|
|
|
function renderCompactLineCell({ kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode }: LineCellRenderParams): string[] {
|
|
if (width <= 0) {
|
|
return [""];
|
|
}
|
|
|
|
const prefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode);
|
|
const continuationPrefix = renderCompactLinePrefix(kind, theme, undefined, indicatorMode, true);
|
|
const prefixPlainWidth = 2;
|
|
const codeWidth = Math.max(0, width - prefixPlainWidth);
|
|
const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
|
|
return renderWrappedRowsWithOptionalBackground(
|
|
wrappedCodeLines,
|
|
(index, line) => `${index === 0 ? prefix : continuationPrefix}${line}`,
|
|
width,
|
|
rowBg,
|
|
restoreBgAnsi,
|
|
);
|
|
}
|
|
|
|
function renderLineCell(
|
|
{ kind, code, width, rowBg, restoreBgAnsi, theme, wordWrap, indicatorMode }: LineCellRenderParams,
|
|
lineNumber: string,
|
|
hashlineGutter = false,
|
|
): string[] {
|
|
if (width <= 0) {
|
|
return [""];
|
|
}
|
|
|
|
const codeWidth = computeLineCellCodeWidth(width, lineNumber.length, indicatorMode, hashlineGutter);
|
|
const prefix = renderLinePrefix(kind, lineNumber, theme, undefined, indicatorMode, false, hashlineGutter);
|
|
const continuationPrefix = renderLineContinuationPrefix(kind, lineNumber.length, undefined, theme, indicatorMode, hashlineGutter);
|
|
const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
|
|
const firstContentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode);
|
|
const continuationContentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix(kind, theme, undefined, indicatorMode, true);
|
|
const wrappedCodeLines = wrapToWidth(code, codeWidth, wordWrap);
|
|
return renderWrappedRowsWithOptionalBackground(
|
|
wrappedCodeLines,
|
|
(index, line) => {
|
|
const linePrefix = index === 0 ? prefix : continuationPrefix;
|
|
const contentPrefix = index === 0 ? firstContentPrefix : continuationContentPrefix;
|
|
return `${linePrefix}${divider}${contentPrefix}${line}`;
|
|
},
|
|
width,
|
|
rowBg,
|
|
restoreBgAnsi,
|
|
);
|
|
}
|
|
|
|
function highlightDiffLine(
|
|
codeText: string,
|
|
entry: DiffLineEntry,
|
|
inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
|
|
palette: DiffPalette,
|
|
highlightLine: CodeLineHighlighter,
|
|
containerBgAnsi: string | undefined,
|
|
): { highlighted: string; rowBg: string | undefined } {
|
|
const syntaxHighlighted = highlightLine(codeText);
|
|
const rowBg = getLineBackground(entry.lineKind, palette, false);
|
|
const emphasisBg = getLineBackground(entry.lineKind, palette, true);
|
|
const inlineSpans = inlineHighlights.get(entry) ?? [];
|
|
const highlighted = applyInlineSpanHighlight(codeText, syntaxHighlighted, inlineSpans, emphasisBg, rowBg, containerBgAnsi);
|
|
return { highlighted, rowBg };
|
|
}
|
|
|
|
function pushDiffLineRows(rows: RenderedRow[], lines: string[], entry: DiffLineEntry): void {
|
|
rows.push(
|
|
...lines.map((text) => ({
|
|
text,
|
|
hunkIndex: entry.hunkIndex || null,
|
|
})),
|
|
);
|
|
}
|
|
|
|
interface DiffRenderContext {
|
|
width: number;
|
|
theme: DiffTheme;
|
|
inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
|
|
palette: DiffPalette;
|
|
highlightLine: CodeLineHighlighter;
|
|
containerBgAnsi: string | undefined;
|
|
wordWrap: boolean;
|
|
indicatorMode: DiffIndicatorMode;
|
|
showHashlineAnchors: boolean;
|
|
}
|
|
|
|
function processDiffEntries(
|
|
entries: ParsedDiffEntry[],
|
|
ctx: DiffRenderContext,
|
|
processLine: (entry: DiffLineEntry) => string[],
|
|
): RenderedRow[] {
|
|
const { width, theme, wordWrap } = ctx;
|
|
const rows: RenderedRow[] = [];
|
|
for (const entry of entries) {
|
|
if (entry.kind !== "line") {
|
|
rows.push(...formatMetaEntryRows(entry, width, theme, wordWrap));
|
|
continue;
|
|
}
|
|
pushDiffLineRows(rows, processLine(entry), entry);
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function renderUnified(
|
|
entries: ParsedDiffEntry[],
|
|
ctx: DiffRenderContext,
|
|
lineNumberWidth: number,
|
|
): RenderedRow[] {
|
|
return processDiffEntries(entries, ctx, (entry) => {
|
|
const lineNumber = entry.lineKind === "add"
|
|
? formatLineNumberLabel(entry, entry.newLineNumber, entry.fallbackLineNumber, lineNumberWidth, ctx.showHashlineAnchors)
|
|
: formatLineNumberLabel(entry, entry.oldLineNumber, entry.fallbackLineNumber, lineNumberWidth, ctx.showHashlineAnchors);
|
|
const codeText = normalizeCodeWhitespace(entry.content);
|
|
const { highlighted, rowBg } = highlightDiffLine(codeText, entry, ctx.inlineHighlights, ctx.palette, ctx.highlightLine, ctx.containerBgAnsi);
|
|
return renderLineCell(
|
|
buildLineCellParams(entry.lineKind, highlighted, ctx.width, rowBg, ctx.containerBgAnsi, ctx.theme, ctx.wordWrap, ctx.indicatorMode),
|
|
lineNumber,
|
|
usesHashlineGutter(ctx.showHashlineAnchors),
|
|
);
|
|
});
|
|
}
|
|
|
|
function toUnifiedFallbackRows(
|
|
rows: SplitDiffRow[],
|
|
ctx: DiffRenderContext,
|
|
lineNumberWidth: number,
|
|
): RenderedRow[] {
|
|
const flattened: ParsedDiffEntry[] = [];
|
|
for (const row of rows) {
|
|
if (row.meta) {
|
|
flattened.push(row.meta);
|
|
continue;
|
|
}
|
|
if (row.left) {
|
|
flattened.push(row.left);
|
|
}
|
|
if (row.right && row.right !== row.left) {
|
|
flattened.push(row.right);
|
|
}
|
|
}
|
|
return renderUnified(flattened, ctx, lineNumberWidth);
|
|
}
|
|
|
|
function renderCompact(
|
|
entries: ParsedDiffEntry[],
|
|
ctx: DiffRenderContext,
|
|
): RenderedRow[] {
|
|
return processDiffEntries(entries, ctx, (entry) => {
|
|
const codeText = normalizeCodeWhitespace(getCompactLineRenderContent(entry, ctx.showHashlineAnchors));
|
|
const { highlighted, rowBg } = highlightDiffLine(codeText, entry, ctx.inlineHighlights, ctx.palette, ctx.highlightLine, ctx.containerBgAnsi);
|
|
return renderCompactLineCell(buildLineCellParams(entry.lineKind, highlighted, ctx.width, rowBg, ctx.containerBgAnsi, ctx.theme, ctx.wordWrap, ctx.indicatorMode));
|
|
});
|
|
}
|
|
|
|
function renderSplitBlankCell(
|
|
columnWidth: number,
|
|
lineNumberWidth: number,
|
|
theme: DiffTheme,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter = false,
|
|
): string {
|
|
const codeWidth = computeLineCellCodeWidth(columnWidth, lineNumberWidth, indicatorMode, hashlineGutter);
|
|
const prefix = renderLinePrefix("context", " ".repeat(lineNumberWidth), theme, undefined, indicatorMode, true, hashlineGutter);
|
|
const divider = renderCodeDivider(theme, undefined, indicatorMode, hashlineGutter);
|
|
const contentPrefix = hashlineGutter ? "" : renderLineContentIndicatorPrefix("context", theme, undefined, indicatorMode, true);
|
|
return stabilizeBackgroundResets(`${prefix}${divider}${contentPrefix}${" ".repeat(codeWidth)}`);
|
|
}
|
|
|
|
function renderSplitCell(
|
|
line: DiffLineEntry | undefined,
|
|
side: "left" | "right",
|
|
columnWidth: number,
|
|
lineNumberWidth: number,
|
|
theme: DiffTheme,
|
|
inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>,
|
|
palette: DiffPalette,
|
|
highlightLine: CodeLineHighlighter,
|
|
containerBgAnsi: string | undefined,
|
|
wordWrap: boolean,
|
|
indicatorMode: DiffIndicatorMode,
|
|
showHashlineAnchors: boolean,
|
|
): string[] {
|
|
const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
|
|
if (!line) {
|
|
return [renderSplitBlankCell(columnWidth, lineNumberWidth, theme, indicatorMode, hashlineGutter)];
|
|
}
|
|
|
|
const lineNumber = formatLineNumberLabel(line, getCellLineNumber(line, side), line.fallbackLineNumber, lineNumberWidth, showHashlineAnchors);
|
|
const codeText = normalizeCodeWhitespace(line.content);
|
|
const { highlighted, rowBg } = highlightDiffLine(codeText, line, inlineHighlights, palette, highlightLine, containerBgAnsi);
|
|
return renderLineCell(
|
|
buildLineCellParams(line.lineKind, highlighted, columnWidth, rowBg, containerBgAnsi, theme, wordWrap, indicatorMode),
|
|
lineNumber,
|
|
hashlineGutter,
|
|
);
|
|
}
|
|
|
|
function renderSplitDivider(
|
|
theme: DiffTheme,
|
|
containerBgAnsi: string | undefined,
|
|
separatorText: string = SPLIT_SEPARATOR,
|
|
): string {
|
|
const dimAnsi = readThemeAnsi(theme, "fg", "dim");
|
|
if (!containerBgAnsi) {
|
|
return stabilizeBackgroundResets(theme.fg("dim", separatorText));
|
|
}
|
|
if (!dimAnsi) {
|
|
return stabilizeBackgroundResets(`${containerBgAnsi}${theme.fg("dim", separatorText)}${containerBgAnsi}`);
|
|
}
|
|
return stabilizeBackgroundResets(`${containerBgAnsi}${dimAnsi}${separatorText}\x1b[39m${containerBgAnsi}`);
|
|
}
|
|
|
|
function renderSplitTopBorderCell(
|
|
columnWidth: number,
|
|
lineNumberWidth: number,
|
|
theme: DiffTheme,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter = false,
|
|
): string {
|
|
const safeColumnWidth = Math.max(1, columnWidth);
|
|
const chars = "─".repeat(safeColumnWidth).split("");
|
|
const dividerIndex = getLinePrefixPlainWidth(lineNumberWidth, indicatorMode, hashlineGutter);
|
|
if (dividerIndex >= 0 && dividerIndex < chars.length) {
|
|
chars[dividerIndex] = "┬";
|
|
}
|
|
return stabilizeBackgroundResets(theme.fg("dim", chars.join("")));
|
|
}
|
|
|
|
function renderSplitHeaderCell(
|
|
label: string,
|
|
columnWidth: number,
|
|
lineNumberWidth: number,
|
|
theme: DiffTheme,
|
|
indicatorMode: DiffIndicatorMode,
|
|
hashlineGutter = false,
|
|
): string {
|
|
const markerPad = hashlineGutter
|
|
? " ".repeat(getHashlineGutterMarkerWidth(indicatorMode))
|
|
: indicatorMode === "bars" ? " " : "";
|
|
const lineNumberLabel = fitToWidth(label, lineNumberWidth);
|
|
const lineNumberSpacer = hashlineGutter ? "" : " ";
|
|
const divider = hashlineGutter || indicatorMode !== "classic" ? "│ " : "│";
|
|
const prefix = `${theme.fg("dim", markerPad)}${theme.fg("muted", lineNumberLabel)}${theme.fg("dim", lineNumberSpacer)}${theme.fg("dim", divider)}`;
|
|
const prefixWidth = visibleWidth(`${markerPad}${lineNumberLabel}${lineNumberSpacer}${divider}`);
|
|
const contentIndicatorWidth = hashlineGutter ? 0 : getLineContentIndicatorPrefixPlainWidth(indicatorMode);
|
|
const codeWidth = Math.max(0, columnWidth - prefixWidth - contentIndicatorWidth);
|
|
const contentPad = !hashlineGutter && indicatorMode === "classic" ? " " : "";
|
|
return stabilizeBackgroundResets(`${prefix}${contentPad}${" ".repeat(codeWidth)}`);
|
|
}
|
|
|
|
function canRenderSplitLayout(width: number): boolean {
|
|
const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
|
|
const minimumSplitWidth = MIN_SPLIT_COLUMN_WIDTH * 2 + separatorWidth;
|
|
return width >= minimumSplitWidth;
|
|
}
|
|
|
|
function renderSplit(
|
|
rows: SplitDiffRow[],
|
|
ctx: DiffRenderContext,
|
|
lineNumberWidth: number,
|
|
): RenderedRow[] {
|
|
const { width, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors } = ctx;
|
|
if (!canRenderSplitLayout(width)) {
|
|
return toUnifiedFallbackRows(rows, ctx, lineNumberWidth);
|
|
}
|
|
|
|
const separatorWidth = visibleWidth(SPLIT_SEPARATOR);
|
|
const leftWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, Math.floor((width - separatorWidth) / 2));
|
|
const rightWidth = Math.max(MIN_SPLIT_COLUMN_WIDTH, width - separatorWidth - leftWidth);
|
|
const splitLineNumberWidth = Math.max(3, lineNumberWidth);
|
|
const hashlineGutter = usesHashlineGutter(showHashlineAnchors);
|
|
const separator = renderSplitDivider(theme, containerBgAnsi);
|
|
const topSeparator = renderSplitDivider(theme, containerBgAnsi, "─┬─");
|
|
const output: RenderedRow[] = [];
|
|
output.push({
|
|
text: `${renderSplitTopBorderCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${topSeparator}${renderSplitTopBorderCell(rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
|
|
hunkIndex: null,
|
|
});
|
|
output.push({
|
|
text: `${renderSplitHeaderCell("old", leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}${separator}${renderSplitHeaderCell("new", rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter)}`,
|
|
hunkIndex: null,
|
|
});
|
|
|
|
for (const row of rows) {
|
|
if (row.meta) {
|
|
output.push(...formatMetaEntryRows(row.meta, width, theme, wordWrap));
|
|
continue;
|
|
}
|
|
|
|
const leftCells = renderSplitCell(
|
|
row.left,
|
|
"left",
|
|
leftWidth,
|
|
splitLineNumberWidth,
|
|
theme,
|
|
inlineHighlights,
|
|
palette,
|
|
highlightLine,
|
|
containerBgAnsi,
|
|
wordWrap,
|
|
indicatorMode,
|
|
showHashlineAnchors,
|
|
);
|
|
const rightCells = renderSplitCell(
|
|
row.right,
|
|
"right",
|
|
rightWidth,
|
|
splitLineNumberWidth,
|
|
theme,
|
|
inlineHighlights,
|
|
palette,
|
|
highlightLine,
|
|
containerBgAnsi,
|
|
wordWrap,
|
|
indicatorMode,
|
|
showHashlineAnchors,
|
|
);
|
|
|
|
const rowCount = Math.max(leftCells.length, rightCells.length);
|
|
for (let index = 0; index < rowCount; index++) {
|
|
const leftCell = leftCells[index] ?? renderSplitBlankCell(leftWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter);
|
|
const rightCell = rightCells[index] ?? renderSplitBlankCell(rightWidth, splitLineNumberWidth, theme, indicatorMode, hashlineGutter);
|
|
output.push({ text: `${leftCell}${separator}${rightCell}`, hunkIndex: row.hunkIndex });
|
|
}
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function renderDiffStatBar(stats: DiffStats, width: number, theme: DiffTheme): string | null {
|
|
const totalChanges = stats.added + stats.removed;
|
|
if (totalChanges === 0 || width < 20) {
|
|
return null;
|
|
}
|
|
|
|
const barSlots = Math.max(8, Math.min(24, Math.floor(width / 12)));
|
|
let addedSlots = Math.max(0, Math.min(barSlots, Math.round((stats.added / totalChanges) * barSlots)));
|
|
if (stats.added > 0 && addedSlots === 0) {
|
|
addedSlots = 1;
|
|
}
|
|
if (stats.removed > 0 && addedSlots >= barSlots) {
|
|
addedSlots = barSlots - 1;
|
|
}
|
|
const removedSlots = Math.max(0, barSlots - addedSlots);
|
|
|
|
const addedBar = addedSlots > 0 ? theme.fg("toolDiffAdded", "━".repeat(addedSlots)) : "";
|
|
const removedBar = removedSlots > 0 ? theme.fg("toolDiffRemoved", "━".repeat(removedSlots)) : "";
|
|
return stabilizeBackgroundResets(`${theme.fg("dim", "[")}${addedBar}${removedBar}${theme.fg("dim", "]")}`);
|
|
}
|
|
|
|
function buildDiffSummaryBasePieces(stats: DiffStats, theme: DiffTheme): string[] {
|
|
return [
|
|
theme.fg("toolOutput", `↳ ${emphasis(theme, "diff")}`),
|
|
theme.fg("toolDiffAdded", `+${stats.added}`),
|
|
theme.fg("toolDiffRemoved", `-${stats.removed}`),
|
|
];
|
|
}
|
|
|
|
function renderHeaderRows(stats: DiffStats, mode: Exclude<DiffPresentationMode, "summary">, width: number, theme: DiffTheme): RenderedRow[] {
|
|
if (mode === "compact") {
|
|
const summary = buildDiffSummaryBasePieces(stats, theme).join(" ");
|
|
return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
|
|
}
|
|
|
|
const summaryPieces = mode === "split"
|
|
? [
|
|
...buildDiffSummaryBasePieces(stats, theme),
|
|
theme.fg("muted", mode),
|
|
]
|
|
: [
|
|
...buildDiffSummaryBasePieces(stats, theme),
|
|
theme.fg("muted", `${stats.hunks} ${pluralize(stats.hunks, "hunk")}`),
|
|
theme.fg("muted", `${stats.files} ${pluralize(stats.files, "file")}`),
|
|
theme.fg("muted", mode),
|
|
];
|
|
|
|
const summary = summaryPieces.join(mode === "split" ? " " : theme.fg("muted", " • "));
|
|
const meter = renderDiffStatBar(stats, width, theme);
|
|
if (!meter) {
|
|
return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
|
|
}
|
|
|
|
const meterSeparator = " ";
|
|
const meterWidth = visibleWidth(meterSeparator) + visibleWidth(meter);
|
|
if (meterWidth >= width) {
|
|
return [{ text: stabilizeBackgroundResets(truncateToWidth(summary, width)), hunkIndex: null }];
|
|
}
|
|
|
|
const summaryWidth = Math.max(0, width - meterWidth);
|
|
const fittedSummary = truncateToWidth(summary, summaryWidth);
|
|
return [{ text: stabilizeBackgroundResets(`${fittedSummary}${meterSeparator}${meter}`), hunkIndex: null }];
|
|
}
|
|
|
|
function renderDiffFrameLine(width: number, theme: DiffTheme): string {
|
|
const frameWidth = Math.max(0, width);
|
|
if (frameWidth === 0) {
|
|
return "";
|
|
}
|
|
return stabilizeBackgroundResets(theme.fg("dim", "─".repeat(frameWidth)));
|
|
}
|
|
|
|
function renderDiffSpacerLine(width: number): string {
|
|
const safeWidth = Math.max(0, width);
|
|
return safeWidth > 0 ? " ".repeat(safeWidth) : "";
|
|
}
|
|
|
|
function applyLineLimit(
|
|
rows: RenderedRow[],
|
|
width: number,
|
|
expanded: boolean,
|
|
maxCollapsedLines: number,
|
|
maxExpandedLines: number,
|
|
totalHunks: number,
|
|
theme: DiffTheme,
|
|
): string[] {
|
|
const expandedLimit = Number.isFinite(maxExpandedLines)
|
|
? maxExpandedLines
|
|
: DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
|
|
const collapsedLimit = Number.isFinite(maxCollapsedLines)
|
|
? maxCollapsedLines
|
|
: DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines;
|
|
const limit = expanded ? Math.max(0, expandedLimit) : Math.max(1, collapsedLimit);
|
|
if (limit === 0 || rows.length <= limit) {
|
|
return rows.map((row) => clampDiffLineToWidth(row.text, width));
|
|
}
|
|
|
|
const shown = rows.slice(0, limit);
|
|
const remaining = rows.length - shown.length;
|
|
const visibleHunks = new Set(
|
|
shown
|
|
.map((row) => row.hunkIndex)
|
|
.filter((hunkIndex): hunkIndex is number => typeof hunkIndex === "number" && hunkIndex > 0),
|
|
);
|
|
const hiddenHunks = Math.max(0, totalHunks - visibleHunks.size);
|
|
const hintText = buildCollapsedDiffHintText(
|
|
{
|
|
remainingLines: remaining,
|
|
hiddenHunks,
|
|
},
|
|
width,
|
|
DIFF_WIDTH_OPS,
|
|
);
|
|
|
|
return [
|
|
...shown.map((row) => clampDiffLineToWidth(row.text, width)),
|
|
renderDiffSpacerLine(width),
|
|
clampDiffLineToWidth(theme.fg(expanded ? "warning" : "muted", hintText), width),
|
|
];
|
|
}
|
|
|
|
function collectDiffStats(entries: ParsedDiffEntry[], fallbackHunks = 0, fallbackFiles = 0): DiffStats {
|
|
const stats: DiffStats = {
|
|
added: 0,
|
|
removed: 0,
|
|
context: 0,
|
|
hunks: fallbackHunks,
|
|
files: fallbackFiles,
|
|
lines: entries.length,
|
|
};
|
|
|
|
const hunkIndexes = new Set<number>();
|
|
let explicitFileCount = 0;
|
|
|
|
for (const entry of entries) {
|
|
if (entry.kind === "line") {
|
|
if (entry.lineKind === "add") {
|
|
stats.added++;
|
|
} else if (entry.lineKind === "remove") {
|
|
stats.removed++;
|
|
} else {
|
|
stats.context++;
|
|
}
|
|
if (entry.hunkIndex > 0) {
|
|
hunkIndexes.add(entry.hunkIndex);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (entry.kind === "hunk" && entry.hunkIndex > 0) {
|
|
hunkIndexes.add(entry.hunkIndex);
|
|
}
|
|
if (entry.kind === "file") {
|
|
explicitFileCount++;
|
|
}
|
|
}
|
|
|
|
if (hunkIndexes.size > 0) {
|
|
stats.hunks = Math.max(stats.hunks, hunkIndexes.size);
|
|
}
|
|
if (explicitFileCount > 0) {
|
|
stats.files = Math.max(stats.files, explicitFileCount);
|
|
} else if (entries.length > 0) {
|
|
stats.files = Math.max(stats.files, 1);
|
|
}
|
|
if (stats.hunks === 0 && entries.some((entry) => entry.kind === "line")) {
|
|
stats.hunks = 1;
|
|
}
|
|
|
|
return stats;
|
|
}
|
|
|
|
function renderSingleDiffRow(text: string, color: string, width: number, theme: DiffTheme): string[] {
|
|
if (width <= 0) {
|
|
return [""];
|
|
}
|
|
return [clampDiffLineToWidth(stabilizeBackgroundResets(theme.fg(color, text)), width)];
|
|
}
|
|
|
|
function safeGetDiff(details: unknown): string {
|
|
if (!details || typeof details !== "object") {
|
|
return "";
|
|
}
|
|
const typed = details as Partial<EditToolDetails>;
|
|
return typeof typed.diff === "string" ? typed.diff : "";
|
|
}
|
|
|
|
function createDiffRenderCache() {
|
|
let cachedWidth: number | undefined;
|
|
let cachedExpanded: boolean | undefined;
|
|
let cachedMode: DiffPresentationMode | undefined;
|
|
let cachedLines: string[] | undefined;
|
|
|
|
return {
|
|
get(width: number, expanded: boolean, mode: DiffPresentationMode): string[] | undefined {
|
|
if (cachedLines && cachedWidth === width && cachedExpanded === expanded && cachedMode === mode) {
|
|
return cachedLines;
|
|
}
|
|
return undefined;
|
|
},
|
|
set(width: number, expanded: boolean, mode: DiffPresentationMode, lines: string[]): string[] {
|
|
cachedWidth = width;
|
|
cachedExpanded = expanded;
|
|
cachedMode = mode;
|
|
cachedLines = lines;
|
|
return lines;
|
|
},
|
|
invalidate(): void {
|
|
cachedWidth = undefined;
|
|
cachedExpanded = undefined;
|
|
cachedMode = undefined;
|
|
cachedLines = undefined;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function renderEditDiffResult(
|
|
details: unknown,
|
|
options: DiffRenderOptions,
|
|
config: ToolDisplayConfig,
|
|
theme: DiffTheme,
|
|
fallbackText: string,
|
|
): Component {
|
|
const diffText = safeGetDiff(details);
|
|
if (!diffText.trim()) {
|
|
if (!fallbackText.trim()) {
|
|
return new Text(theme.fg("muted", "↳ edit completed (no diff payload)"), 0, 0);
|
|
}
|
|
return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
|
|
}
|
|
|
|
let parsed: ParsedDiff;
|
|
try {
|
|
parsed = parseDiff(diffText);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return new Text(theme.fg("warning", `↳ unable to render diff: ${message}`), 0, 0);
|
|
}
|
|
|
|
if (parsed.entries.length === 0) {
|
|
return new Text(theme.fg("muted", "↳ no diff data"), 0, 0);
|
|
}
|
|
|
|
const splitRows = buildSplitRows(parsed.entries);
|
|
const showHashlineAnchors = options.expanded === true
|
|
&& parsed.entries.some((entry) => entry.kind === "line" && !!entry.hashlineAnchorContent);
|
|
const lineNumberWidth = getLineNumberWidth(parsed.entries, showHashlineAnchors);
|
|
const palette = resolveDiffPalette(theme);
|
|
const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
|
|
const language = resolveLanguageFromPath(options.filePath);
|
|
const highlightLine = createCodeLineHighlighter(language);
|
|
const wordWrap = config.diffWordWrap;
|
|
const indicatorMode = resolveDiffIndicatorMode(config);
|
|
|
|
const cache = createDiffRenderCache();
|
|
|
|
return {
|
|
render(width: number): string[] {
|
|
const safeWidth = normalizeDiffRenderWidth(width);
|
|
const mode = resolveDiffPresentationMode(config, safeWidth, canRenderSplitLayout(safeWidth));
|
|
const cached = cache.get(safeWidth, options.expanded, mode);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
if (mode === "summary") {
|
|
return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(renderSingleDiffRow(buildDiffSummaryText(parsed.stats, safeWidth), "toolOutput", safeWidth, theme), safeWidth));
|
|
}
|
|
|
|
const headerRows = renderHeaderRows(parsed.stats, mode, safeWidth, theme);
|
|
const inlineHighlights = buildInlineHighlightMap(splitRows);
|
|
const bodyRows = mode === "split"
|
|
? renderSplit(
|
|
splitRows,
|
|
{ width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
|
|
lineNumberWidth,
|
|
)
|
|
: mode === "compact"
|
|
? renderCompact(
|
|
parsed.entries,
|
|
{ width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
|
|
)
|
|
: renderUnified(
|
|
parsed.entries,
|
|
{ width: safeWidth, theme, inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors },
|
|
lineNumberWidth,
|
|
);
|
|
const bodyWithLimit = applyLineLimit(
|
|
bodyRows,
|
|
safeWidth,
|
|
options.expanded,
|
|
config.diffCollapsedLines,
|
|
config.expandedPreviewMaxLines,
|
|
parsed.stats.hunks,
|
|
theme,
|
|
);
|
|
const frame = renderDiffFrameLine(safeWidth, theme);
|
|
const renderedLines = mode === "unified"
|
|
? [...headerRows.map((row) => row.text), frame, ...bodyWithLimit, frame]
|
|
: [...headerRows.map((row) => row.text), ...bodyWithLimit];
|
|
|
|
const clampedLines = clampDiffLinesToWidth(renderedLines, safeWidth);
|
|
return cache.set(safeWidth, options.expanded, mode, clampedLines);
|
|
},
|
|
invalidate: cache.invalidate,
|
|
};
|
|
}
|
|
|
|
function renderWriteHeader(
|
|
wasOverwrite: boolean,
|
|
width: number,
|
|
theme: DiffTheme,
|
|
headerLabel?: string,
|
|
): string {
|
|
const actionLabel = headerLabel?.trim() || (wasOverwrite ? "overwritten" : "created");
|
|
return stabilizeBackgroundResets(
|
|
truncateToWidth(theme.fg("toolOutput", `↳ ${emphasis(theme, actionLabel)}`), width),
|
|
);
|
|
}
|
|
|
|
type WriteDiffOperationKind = "context" | "remove" | "add";
|
|
|
|
interface WriteDiffOperation {
|
|
kind: WriteDiffOperationKind;
|
|
content: string;
|
|
}
|
|
|
|
function buildWriteDiffOperations(oldLines: string[], newLines: string[]): WriteDiffOperation[] {
|
|
const oldLength = oldLines.length;
|
|
const newLength = newLines.length;
|
|
const table: number[][] = Array.from({ length: oldLength + 1 }, () => Array<number>(newLength + 1).fill(0));
|
|
|
|
for (let oldIndex = 1; oldIndex <= oldLength; oldIndex++) {
|
|
for (let newIndex = 1; newIndex <= newLength; newIndex++) {
|
|
if ((oldLines[oldIndex - 1] ?? "") === (newLines[newIndex - 1] ?? "")) {
|
|
table[oldIndex]![newIndex] = (table[oldIndex - 1]?.[newIndex - 1] ?? 0) + 1;
|
|
continue;
|
|
}
|
|
const top = table[oldIndex - 1]?.[newIndex] ?? 0;
|
|
const left = table[oldIndex]?.[newIndex - 1] ?? 0;
|
|
table[oldIndex]![newIndex] = Math.max(top, left);
|
|
}
|
|
}
|
|
|
|
const operations: WriteDiffOperation[] = [];
|
|
let oldCursor = oldLength;
|
|
let newCursor = newLength;
|
|
|
|
while (oldCursor > 0 || newCursor > 0) {
|
|
const oldLine = oldCursor > 0 ? (oldLines[oldCursor - 1] ?? "") : undefined;
|
|
const newLine = newCursor > 0 ? (newLines[newCursor - 1] ?? "") : undefined;
|
|
|
|
if (oldCursor > 0 && newCursor > 0 && oldLine === newLine) {
|
|
operations.push({ kind: "context", content: oldLine ?? "" });
|
|
oldCursor--;
|
|
newCursor--;
|
|
continue;
|
|
}
|
|
|
|
const top = oldCursor > 0 ? (table[oldCursor - 1]?.[newCursor] ?? 0) : -1;
|
|
const left = newCursor > 0 ? (table[oldCursor]?.[newCursor - 1] ?? 0) : -1;
|
|
|
|
if (newCursor > 0 && left >= top) {
|
|
operations.push({ kind: "add", content: newLine ?? "" });
|
|
newCursor--;
|
|
continue;
|
|
}
|
|
|
|
if (oldCursor > 0) {
|
|
operations.push({ kind: "remove", content: oldLine ?? "" });
|
|
oldCursor--;
|
|
}
|
|
}
|
|
|
|
operations.reverse();
|
|
return operations;
|
|
}
|
|
|
|
function buildWriteEntries(lines: string[]): ParsedDiffEntry[] {
|
|
return lines.map((line, index) => ({
|
|
kind: "line",
|
|
lineKind: "add",
|
|
oldLineNumber: null,
|
|
newLineNumber: index + 1,
|
|
fallbackLineNumber: `${index + 1}`,
|
|
content: line,
|
|
raw: `+${line}`,
|
|
hunkIndex: 1,
|
|
}));
|
|
}
|
|
|
|
function buildWriteOverwriteEntries(oldLines: string[], newLines: string[]): ParsedDiffEntry[] {
|
|
const operations = buildWriteDiffOperations(oldLines, newLines);
|
|
const entries: ParsedDiffEntry[] = [];
|
|
let oldLineNumber = 1;
|
|
let newLineNumber = 1;
|
|
|
|
for (const operation of operations) {
|
|
if (operation.kind === "context") {
|
|
entries.push({
|
|
kind: "line",
|
|
lineKind: "context",
|
|
oldLineNumber,
|
|
newLineNumber,
|
|
fallbackLineNumber: `${newLineNumber}`,
|
|
content: operation.content,
|
|
raw: ` ${operation.content}`,
|
|
hunkIndex: 1,
|
|
});
|
|
oldLineNumber++;
|
|
newLineNumber++;
|
|
continue;
|
|
}
|
|
|
|
if (operation.kind === "remove") {
|
|
entries.push({
|
|
kind: "line",
|
|
lineKind: "remove",
|
|
oldLineNumber,
|
|
newLineNumber: null,
|
|
fallbackLineNumber: `${oldLineNumber}`,
|
|
content: operation.content,
|
|
raw: `-${operation.content}`,
|
|
hunkIndex: 1,
|
|
});
|
|
oldLineNumber++;
|
|
continue;
|
|
}
|
|
|
|
entries.push({
|
|
kind: "line",
|
|
lineKind: "add",
|
|
oldLineNumber: null,
|
|
newLineNumber,
|
|
fallbackLineNumber: `${newLineNumber}`,
|
|
content: operation.content,
|
|
raw: `+${operation.content}`,
|
|
hunkIndex: 1,
|
|
});
|
|
newLineNumber++;
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
interface WriteDiffData {
|
|
entries: ParsedDiffEntry[];
|
|
splitRows: SplitDiffRow[];
|
|
inlineHighlights: WeakMap<DiffLineEntry, DiffSpan[]>;
|
|
lineNumberWidth: number;
|
|
stats: DiffStats;
|
|
hunkCount: number;
|
|
}
|
|
|
|
interface WriteOverwriteGuard {
|
|
previousLineCount: number;
|
|
nextLineCount: number;
|
|
}
|
|
|
|
const MAX_WRITE_OVERWRITE_DIFF_LINES = 4000;
|
|
const MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS = 1_000_000;
|
|
|
|
function buildApproximateWriteStats(
|
|
lineCount: number,
|
|
previousLineCount: number,
|
|
hasComparablePrevious: boolean,
|
|
): DiffStats {
|
|
const removed = hasComparablePrevious ? previousLineCount : 0;
|
|
const added = lineCount;
|
|
const hasContent = lineCount > 0 || removed > 0;
|
|
return {
|
|
added,
|
|
removed,
|
|
context: 0,
|
|
hunks: hasContent ? 1 : 0,
|
|
files: 1,
|
|
lines: added + removed,
|
|
};
|
|
}
|
|
|
|
function buildWriteDiffData(entries: ParsedDiffEntry[]): WriteDiffData {
|
|
const splitRows = buildSplitRows(entries);
|
|
const inlineHighlights = buildInlineHighlightMap(splitRows);
|
|
const lineNumberWidth = getLineNumberWidth(entries);
|
|
const hunkCount = entries.length > 0 ? 1 : 0;
|
|
const stats = collectDiffStats(entries, hunkCount, 1);
|
|
return {
|
|
entries,
|
|
splitRows,
|
|
inlineHighlights,
|
|
lineNumberWidth,
|
|
stats,
|
|
hunkCount,
|
|
};
|
|
}
|
|
|
|
function resolveWriteOverwriteGuard(
|
|
previousLines: string[],
|
|
nextLines: string[],
|
|
): WriteOverwriteGuard | undefined {
|
|
const previousLineCount = previousLines.length;
|
|
const nextLineCount = nextLines.length;
|
|
if (previousLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES || nextLineCount > MAX_WRITE_OVERWRITE_DIFF_LINES) {
|
|
return { previousLineCount, nextLineCount };
|
|
}
|
|
if (previousLineCount === 0 || nextLineCount === 0) {
|
|
return undefined;
|
|
}
|
|
return previousLineCount * nextLineCount > MAX_WRITE_OVERWRITE_DIFF_MATRIX_CELLS
|
|
? { previousLineCount, nextLineCount }
|
|
: undefined;
|
|
}
|
|
|
|
function buildWriteOverwriteGuardText(guard: WriteOverwriteGuard, width: number): string {
|
|
const safeWidth = normalizeDiffRenderWidth(width);
|
|
if (safeWidth === 0) {
|
|
return "";
|
|
}
|
|
|
|
const candidates = [
|
|
`↳ overwrite diff omitted (${guard.previousLineCount} → ${guard.nextLineCount} lines)`,
|
|
`↳ overwrite diff omitted (${guard.previousLineCount}→${guard.nextLineCount})`,
|
|
"↳ overwrite diff omitted",
|
|
"diff omitted",
|
|
"…",
|
|
];
|
|
for (const candidate of candidates) {
|
|
if (visibleWidth(candidate) <= safeWidth) {
|
|
return candidate;
|
|
}
|
|
}
|
|
return truncateToWidth(candidates[candidates.length - 1] ?? "", safeWidth, "");
|
|
}
|
|
|
|
function renderWriteOverwriteGuardRows(
|
|
guard: WriteOverwriteGuard,
|
|
width: number,
|
|
theme: DiffTheme,
|
|
): string[] {
|
|
return renderSingleDiffRow(buildWriteOverwriteGuardText(guard, width), "warning", width, theme);
|
|
}
|
|
|
|
export function renderWriteDiffResult(
|
|
content: string | undefined,
|
|
options: DiffRenderOptions,
|
|
config: ToolDisplayConfig,
|
|
theme: DiffTheme,
|
|
fallbackText: string,
|
|
): Component {
|
|
if (typeof content !== "string") {
|
|
if (!fallbackText.trim()) {
|
|
return new Text(theme.fg("muted", "↳ write completed"), 0, 0);
|
|
}
|
|
return new Text(theme.fg("toolOutput", fallbackText), 0, 0);
|
|
}
|
|
|
|
const filePath = options.filePath?.trim() || "(unknown path)";
|
|
const lines = splitWriteContentLines(content);
|
|
const previousLines = typeof options.previousContent === "string"
|
|
? splitWriteContentLines(options.previousContent)
|
|
: [];
|
|
const hasComparablePrevious = options.fileExistedBeforeWrite === true && typeof options.previousContent === "string";
|
|
const approximateStats = buildApproximateWriteStats(
|
|
lines.length,
|
|
previousLines.length,
|
|
hasComparablePrevious,
|
|
);
|
|
const overwriteGuard = hasComparablePrevious
|
|
? resolveWriteOverwriteGuard(previousLines, lines)
|
|
: undefined;
|
|
const palette = resolveDiffPalette(theme);
|
|
const containerBgAnsi = resolveContainerBackgroundAnsi(theme);
|
|
const language = resolveLanguageFromPath(filePath);
|
|
const highlightLine = createCodeLineHighlighter(language);
|
|
const wordWrap = config.diffWordWrap;
|
|
const indicatorMode = resolveDiffIndicatorMode(config);
|
|
|
|
let detailedData: WriteDiffData | undefined;
|
|
const cache = createDiffRenderCache();
|
|
|
|
function getDetailedData(): WriteDiffData {
|
|
if (detailedData) {
|
|
return detailedData;
|
|
}
|
|
const entries = hasComparablePrevious
|
|
? buildWriteOverwriteEntries(previousLines, lines)
|
|
: buildWriteEntries(lines);
|
|
detailedData = buildWriteDiffData(entries);
|
|
return detailedData;
|
|
}
|
|
|
|
return {
|
|
render(width: number): string[] {
|
|
const safeWidth = normalizeDiffRenderWidth(width);
|
|
const resolvedMode = resolveDiffPresentationMode(config, safeWidth, canRenderSplitLayout(safeWidth));
|
|
const mode: DiffPresentationMode = hasComparablePrevious
|
|
? resolvedMode
|
|
: resolvedMode === "split"
|
|
? "unified"
|
|
: resolvedMode;
|
|
const cached = cache.get(safeWidth, options.expanded, mode);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const header = renderWriteHeader(
|
|
options.fileExistedBeforeWrite === true,
|
|
safeWidth,
|
|
theme,
|
|
options.headerLabel,
|
|
);
|
|
if (overwriteGuard) {
|
|
return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(
|
|
[header, ...renderWriteOverwriteGuardRows(overwriteGuard, safeWidth, theme)],
|
|
safeWidth,
|
|
));
|
|
}
|
|
|
|
if (mode === "summary") {
|
|
const summaryRows = approximateStats.lines === 0
|
|
? [header]
|
|
: [header, ...renderSingleDiffRow(buildDiffSummaryText(approximateStats, safeWidth), "toolOutput", safeWidth, theme)];
|
|
return cache.set(safeWidth, options.expanded, mode, clampDiffLinesToWidth(summaryRows, safeWidth));
|
|
}
|
|
|
|
const data = getDetailedData();
|
|
const renderCtx: DiffRenderContext = {
|
|
width: safeWidth, theme, inlineHighlights: data.inlineHighlights, palette, highlightLine, containerBgAnsi, wordWrap, indicatorMode, showHashlineAnchors: false,
|
|
};
|
|
const bodyRows: RenderedRow[] = data.entries.length === 0
|
|
? [{ text: theme.fg("muted", "(empty file)"), hunkIndex: null }]
|
|
: mode === "split"
|
|
? renderSplit(
|
|
data.splitRows,
|
|
renderCtx,
|
|
data.lineNumberWidth,
|
|
)
|
|
: mode === "compact"
|
|
? renderCompact(
|
|
data.entries,
|
|
renderCtx,
|
|
)
|
|
: renderUnified(
|
|
data.entries,
|
|
renderCtx,
|
|
data.lineNumberWidth,
|
|
);
|
|
|
|
const bodyWithLimit = applyLineLimit(
|
|
bodyRows,
|
|
safeWidth,
|
|
options.expanded,
|
|
config.diffCollapsedLines,
|
|
config.expandedPreviewMaxLines,
|
|
data.hunkCount,
|
|
theme,
|
|
);
|
|
const frame = renderDiffFrameLine(safeWidth, theme);
|
|
const renderedLines = mode === "unified"
|
|
? [header, frame, ...bodyWithLimit, frame]
|
|
: [header, ...bodyWithLimit];
|
|
const finalLines = clampDiffLinesToWidth(renderedLines, safeWidth);
|
|
return cache.set(safeWidth, options.expanded, mode, finalLines);
|
|
},
|
|
invalidate: cache.invalidate,
|
|
};
|
|
}
|