@pi-archimedes/diff 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ansi.ts DELETED
@@ -1,282 +0,0 @@
1
- /** ANSI constants and text manipulation utilities. */
2
-
3
- import { relative } from "node:path";
4
-
5
- // ---------------------------------------------------------------------------
6
- // ANSI escape codes
7
- // ---------------------------------------------------------------------------
8
-
9
- export let RST = "\x1b[0m";
10
- export const BOLD = "\x1b[1m";
11
- export const DIM = "\x1b[2m";
12
-
13
- // Diff foregrounds
14
- export const FG_ADD = "\x1b[38;2;100;180;120m"; // desaturated green
15
- export const FG_DEL = "\x1b[38;2;200;100;100m"; // desaturated red
16
- export const FG_DIM = "\x1b[38;2;80;80;80m";
17
- export const FG_LNUM = "\x1b[38;2;100;100;100m";
18
- export const FG_RULE = "\x1b[38;2;50;50;50m";
19
- export const FG_SAFE_MUTED = "\x1b[38;2;139;148;158m";
20
- export const FG_STRIPE = "\x1b[38;2;40;40;40m"; // gray diagonal stripes
21
-
22
- // Diff backgrounds — muted tones to let syntax fg shine through
23
- export let BG_ADD = "\x1b[48;2;22;38;32m"; // muted teal-green
24
- export let BG_DEL = "\x1b[48;2;45;25;25m"; // muted brown-red
25
- export let BG_ADD_W = "\x1b[48;2;35;75;50m"; // word-level emphasis
26
- export let BG_DEL_W = "\x1b[48;2;80;35;35m";
27
- export let BG_GUTTER_ADD = "\x1b[48;2;18;32;26m";
28
- export let BG_GUTTER_DEL = "\x1b[48;2;38;22;22m";
29
- export const BG_GUTTER_CTX = ""; // use terminal default bg for context gutters
30
- export let BG_EMPTY = "\x1b[48;2;18;18;18m"; // filler rows
31
-
32
- export const BORDER_BAR = "▌";
33
-
34
- export let DIVIDER = `${FG_RULE}│${RST}`;
35
- const ESC_RE = "\u001b";
36
- export const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
37
- const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
38
- const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
39
- const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
40
- export let BG_BASE = BG_DEFAULT; // tool box base bg — updated from theme's toolSuccessBg
41
-
42
- // ---------------------------------------------------------------------------
43
- // ANSI manipulation
44
- // ---------------------------------------------------------------------------
45
-
46
- const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
47
-
48
- /** Strip all ANSI escape codes from a string. */
49
- export const strip = stripSgr;
50
-
51
- /** Replace tabs with 2 spaces. */
52
- export function tabs(s: string): string {
53
- return s.replace(/\t/g, " ");
54
- }
55
-
56
- /** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
57
- export function fit(s: string, w: number): string {
58
- if (w <= 0) return "";
59
- const plain = strip(s);
60
- if (plain.length <= w) return s + " ".repeat(w - plain.length);
61
- const showW = w > 2 ? w - 1 : w;
62
- let vis = 0,
63
- i = 0;
64
- while (i < s.length && vis < showW) {
65
- if (s[i] === "\x1b") {
66
- const e = s.indexOf("m", i);
67
- if (e !== -1) {
68
- i = e + 1;
69
- continue;
70
- }
71
- }
72
- vis++;
73
- i++;
74
- }
75
- return w > 2 ? `${s.slice(0, i)}${RST}${FG_DIM}›${RST}` : `${s.slice(0, i)}${RST}`;
76
- }
77
-
78
- /** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
79
- export function ansiState(s: string): string {
80
- let fg = "",
81
- bg = "";
82
- for (const match of s.matchAll(ANSI_CAPTURE_RE)) {
83
- const p = match[1] ?? "";
84
- const seq = match[0] ?? "";
85
- if (p === "0") {
86
- fg = "";
87
- bg = "";
88
- } else if (p === "39") {
89
- fg = "";
90
- } else if (p.startsWith("38;")) {
91
- fg = seq;
92
- } else if (p.startsWith("48;")) {
93
- bg = seq;
94
- }
95
- }
96
- return bg + fg;
97
- }
98
-
99
- /** Check if a Shiki fg code is too dark to read. */
100
- export function isLowContrastShikiFg(params: string): boolean {
101
- if (params === "30" || params === "90") return true;
102
- if (params === "38;5;0" || params === "38;5;8") return true;
103
- if (!params.startsWith("38;2;")) return false;
104
- const parts = params.split(";").map(Number);
105
- if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
106
- const [, , r, g, b] = parts as [number, number, number, number, number];
107
- const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
108
- return luminance < 72;
109
- }
110
-
111
- /** Normalize Shiki ANSI output to boost low-contrast fg codes. */
112
- export function normalizeShikiContrast(ansi: string): string {
113
- return ansi.replace(ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
114
- isLowContrastShikiFg(params) ? FG_SAFE_MUTED : seq,
115
- );
116
- }
117
-
118
- /** Generate a dense diagonal stripe fill for empty filler cells. */
119
- export function stripes(w: number, _rowOffset: number): string {
120
- return BG_BASE + FG_STRIPE + "╱".repeat(w) + RST;
121
- }
122
-
123
- /** Format a line number, right-padded to width `w`. */
124
- export function lnum(n: number | null, w: number, fg = FG_LNUM): string {
125
- if (n === null) return " ".repeat(w);
126
- const v = String(n);
127
- return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
128
- }
129
-
130
- /** Horizontal rule line. */
131
- export function rule(w: number): string {
132
- return `${BG_BASE}${FG_RULE}${"─".repeat(w)}${RST}`;
133
- }
134
-
135
- /** Shorten a file path relative to cwd or home. */
136
- export function shortPath(cwd: string, home: string, p: string): string {
137
- if (!p) return "";
138
- const r = relative(cwd, p);
139
- if (!r.startsWith("..") && !r.startsWith("/")) return r;
140
- return p.replace(home, "~");
141
- }
142
-
143
- /** Summarize added/removed counts as colored `+N -M` string. */
144
- export function summarize(a: number, d: number): string {
145
- const p: string[] = [];
146
- if (a > 0) p.push(`${FG_ADD}+${a}${RST}`);
147
- if (d > 0) p.push(`${FG_DEL}-${d}${RST}`);
148
- return p.length ? p.join(" ") : `${FG_DIM}no changes${RST}`;
149
- }
150
-
151
- // ---------------------------------------------------------------------------
152
- // Diff color system — auto-derive from theme, hardcoded fallback
153
- // ---------------------------------------------------------------------------
154
-
155
- /** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
156
- function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
157
- const esc = "\u001b";
158
- const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
159
- return m ? { r: +m[1]!, g: +m[2]!, b: +m[3]! } : null;
160
- }
161
-
162
- /** Mix an accent color into a base color at the given intensity (0.0–1.0). */
163
- function mixBg(
164
- base: { r: number; g: number; b: number },
165
- accent: { r: number; g: number; b: number },
166
- intensity: number,
167
- ): string {
168
- const r = Math.round(base.r + (accent.r - base.r) * intensity);
169
- const g = Math.round(base.g + (accent.g - base.g) * intensity);
170
- const b = Math.round(base.b + (accent.b - base.b) * intensity);
171
- return `\x1b[48;2;${r};${g};${b}m`;
172
- }
173
-
174
- /** Auto-derive all diff background colors from the pi theme's fg diff colors. */
175
- export function autoDeriveBgFromTheme(theme: any): void {
176
- if (!theme?.getFgAnsi) return;
177
- try {
178
- const fgAdd = theme.getFgAnsi("toolDiffAdded");
179
- const fgDel = theme.getFgAnsi("toolDiffRemoved");
180
- const addRgb = parseAnsiRgb(fgAdd);
181
- const delRgb = parseAnsiRgb(fgDel);
182
- if (!addRgb || !delRgb) return;
183
-
184
- let addBase = { r: 0, g: 0, b: 0 };
185
- let delBase = addBase;
186
- if (theme.getBgAnsi) {
187
- try {
188
- const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
189
- const successParsed = parseAnsiRgb(successBgAnsi);
190
- if (successParsed) {
191
- addBase = successParsed;
192
- delBase = successParsed;
193
- BG_BASE = successBgAnsi;
194
- }
195
- } catch { /* no toolSuccessBg */ }
196
-
197
- try {
198
- const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
199
- if (errorParsed) delBase = errorParsed;
200
- } catch { /* no toolErrorBg */ }
201
- }
202
-
203
- BG_ADD = mixBg(addBase, addRgb, 0.08);
204
- BG_DEL = mixBg(delBase, delRgb, 0.1);
205
- BG_ADD_W = mixBg(addBase, addRgb, 0.2);
206
- BG_DEL_W = mixBg(delBase, delRgb, 0.22);
207
- BG_GUTTER_ADD = mixBg(addBase, addRgb, 0.05);
208
- BG_GUTTER_DEL = mixBg(delBase, delRgb, 0.06);
209
- BG_EMPTY = BG_BASE;
210
- RST = `\x1b[0m${BG_BASE}`;
211
- DIVIDER = `${FG_RULE}│${RST}`;
212
- } catch {
213
- // Fall back to defaults silently
214
- }
215
- }
216
-
217
- // ---------------------------------------------------------------------------
218
- // Theme-aware diff colors
219
- // ---------------------------------------------------------------------------
220
-
221
- export interface DiffColors {
222
- fgAdd: string;
223
- fgDel: string;
224
- fgCtx: string;
225
- }
226
-
227
- export const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
228
-
229
- export function themeCacheKey(theme?: any): string {
230
- if (!theme?.fg) return "no-theme";
231
- const fgKeys = [
232
- "toolTitle", "accent", "muted", "success", "error",
233
- "toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
234
- ];
235
- const bgKeys = ["toolSuccessBg", "toolErrorBg"];
236
- const parts: string[] = [];
237
- for (const key of fgKeys) {
238
- try { parts.push(theme.fg(key, key)); } catch { parts.push(key); }
239
- }
240
- for (const key of bgKeys) {
241
- try { parts.push(theme.bg ? theme.bg(key, key) : key); } catch { parts.push(key); }
242
- }
243
- return parts.join("|");
244
- }
245
-
246
- let _lastThemeKey: string | undefined;
247
-
248
- /** Reset auto-derived colors — call when theme changes. */
249
- export function resetDiffColors(): void {
250
- _lastThemeKey = undefined;
251
- // Restore hardcoded fallbacks
252
- BG_ADD = "\x1b[48;2;22;38;32m";
253
- BG_DEL = "\x1b[48;2;45;25;25m";
254
- BG_ADD_W = "\x1b[48;2;35;75;50m";
255
- BG_DEL_W = "\x1b[48;2;80;35;35m";
256
- BG_GUTTER_ADD = "\x1b[48;2;18;32;26m";
257
- BG_GUTTER_DEL = "\x1b[48;2;38;22;22m";
258
- BG_EMPTY = "\x1b[48;2;18;18;18m";
259
- BG_BASE = BG_DEFAULT;
260
- RST = "\x1b[0m";
261
- DIVIDER = `${FG_RULE}│${RST}`;
262
- }
263
-
264
- export function resolveDiffColors(theme?: any): DiffColors {
265
- const themeKey = themeCacheKey(theme);
266
-
267
- // Re-derive when theme changes (different key) or first call
268
- if (themeKey !== _lastThemeKey && theme?.getFgAnsi) {
269
- autoDeriveBgFromTheme(theme);
270
- _lastThemeKey = themeKey;
271
- }
272
- if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
273
- try {
274
- return {
275
- fgAdd: theme.getFgAnsi("toolDiffAdded") || FG_ADD,
276
- fgDel: theme.getFgAnsi("toolDiffRemoved") || FG_DEL,
277
- fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
278
- };
279
- } catch {
280
- return DEFAULT_DIFF_COLORS;
281
- }
282
- }
package/src/render.ts DELETED
@@ -1,393 +0,0 @@
1
- /** Split and unified diff rendering. */
2
-
3
- import type { BundledLanguage } from "shiki";
4
- import * as Ansi from "./ansi.js";
5
- import type { DiffColors } from "./ansi.js";
6
- import { DEFAULT_DIFF_COLORS } from "./ansi.js";
7
- import { hlBlock } from "./shiki.js";
8
- import { wordDiffAnalysis, injectBg, plainWordDiff } from "./word-diff.js";
9
- import type { DiffLine, ParsedDiff } from "./core/diff.js";
10
-
11
- // ---------------------------------------------------------------------------
12
- // Constants
13
- // ---------------------------------------------------------------------------
14
-
15
- export const MAX_PREVIEW_LINES = 60;
16
- export const MAX_RENDER_LINES = 150;
17
- const MAX_HL_CHARS = 80_000;
18
- const WORD_DIFF_MIN_SIM = 0.15;
19
- const SPLIT_MAX_WRAP_RATIO = 0.2;
20
- const SPLIT_MAX_WRAP_LINES = 8;
21
- const MAX_WRAP_ROWS_WIDE = 3;
22
- const MAX_WRAP_ROWS_MED = 2;
23
- const MAX_WRAP_ROWS_NARROW = 1;
24
- const MAX_TERM_WIDTH = 210;
25
- const DEFAULT_TERM_WIDTH = 200;
26
-
27
- // ---------------------------------------------------------------------------
28
- // Helpers
29
- // ---------------------------------------------------------------------------
30
-
31
- export function getConfig(): { diffSplitMinWidth: number; diffSplitMinCodeWidth: number } {
32
- return typeof _getConfig === "function" ? _getConfig() : DEFAULT_CONFIG;
33
- }
34
-
35
- const DEFAULT_CONFIG = { diffSplitMinWidth: 150, diffSplitMinCodeWidth: 60 };
36
- let _getConfig: (() => { diffSplitMinWidth: number; diffSplitMinCodeWidth: number }) | undefined;
37
- export function setConfigGetter(fn: () => { diffSplitMinWidth: number; diffSplitMinCodeWidth: number }): void {
38
- _getConfig = fn;
39
- }
40
-
41
- function termW(): number {
42
- const raw =
43
- process.stdout.columns ||
44
- (process.stderr as any).columns ||
45
- Number.parseInt(process.env.COLUMNS ?? "", 10) ||
46
- DEFAULT_TERM_WIDTH;
47
- return Math.max(80, Math.min(raw - 4, MAX_TERM_WIDTH));
48
- }
49
-
50
- function adaptiveWrapRows(tw?: number): number {
51
- const w = tw ?? termW();
52
- if (w >= 180) return MAX_WRAP_ROWS_WIDE;
53
- if (w >= 120) return MAX_WRAP_ROWS_MED;
54
- return MAX_WRAP_ROWS_NARROW;
55
- }
56
-
57
- /** Wrap ANSI-encoded string into rows of `w` visible chars. */
58
- function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(), fillBg = ""): string[] {
59
- if (w <= 0) return [""];
60
- const plain = Ansi.strip(s);
61
- if (plain.length <= w) {
62
- const pad = w - plain.length;
63
- return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? Ansi.RST : "")] : [s];
64
- }
65
-
66
- const rows: string[] = [];
67
- let row = "", vis = 0, i = 0;
68
- let onLastRow = false;
69
- let effW = w;
70
-
71
- while (i < s.length) {
72
- if (!onLastRow && rows.length >= maxRows - 1) {
73
- onLastRow = true;
74
- effW = w > 2 ? w - 1 : w;
75
- }
76
- if (s[i] === "\x1b") {
77
- const end = s.indexOf("m", i);
78
- if (end !== -1) {
79
- row += s.slice(i, end + 1);
80
- i = end + 1;
81
- continue;
82
- }
83
- }
84
- if (vis >= effW) {
85
- if (onLastRow) {
86
- let hasMore = false;
87
- for (let j = i; j < s.length; j++) {
88
- if (s[j] === "\x1b") {
89
- const e2 = s.indexOf("m", j);
90
- if (e2 !== -1) { j = e2; continue; }
91
- }
92
- hasMore = true;
93
- break;
94
- }
95
- if (hasMore && w > 2) row += `${Ansi.RST}${Ansi.FG_DIM}›${Ansi.RST}`;
96
- else row += fillBg + " ".repeat(Math.max(0, w - vis)) + Ansi.RST;
97
- rows.push(row);
98
- return rows;
99
- }
100
- const state = Ansi.ansiState(row);
101
- rows.push(row + Ansi.RST);
102
- row = state + fillBg;
103
- vis = 0;
104
- if (rows.length >= maxRows - 1) {
105
- onLastRow = true;
106
- effW = w > 2 ? w - 1 : w;
107
- }
108
- }
109
- row += s[i];
110
- vis++;
111
- i++;
112
- }
113
- if (row.length > 0 || rows.length === 0) {
114
- rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + Ansi.RST);
115
- }
116
- return rows;
117
- }
118
-
119
- function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
120
- if (!diff.lines.length) return false;
121
- const cfg = getConfig();
122
- if (tw < cfg.diffSplitMinWidth) return false;
123
-
124
- const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
125
- const half = Math.floor((tw - 1) / 2);
126
- const gw = nw + 5;
127
- const cw = Math.max(12, half - gw);
128
- if (cw < cfg.diffSplitMinCodeWidth) return false;
129
-
130
- const vis = diff.lines.slice(0, maxRows);
131
- let contentLines = 0, wrapCandidates = 0;
132
- for (const l of vis) {
133
- if (l.type === "sep") continue;
134
- contentLines++;
135
- if (Ansi.tabs(l.content).length > cw) wrapCandidates++;
136
- }
137
- if (contentLines === 0) return true;
138
- const wrapRatio = wrapCandidates / contentLines;
139
- if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
140
- if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
141
- return true;
142
- }
143
-
144
- // ---------------------------------------------------------------------------
145
- // Unified view
146
- // ---------------------------------------------------------------------------
147
-
148
- export async function renderUnified(
149
- diff: ParsedDiff,
150
- language: BundledLanguage | undefined,
151
- max = MAX_RENDER_LINES,
152
- dc: DiffColors = DEFAULT_DIFF_COLORS,
153
- ): Promise<string> {
154
- if (!diff.lines.length) return "";
155
-
156
- const vis = diff.lines.slice(0, max);
157
- const tw = termW();
158
- const nw = Math.max(2, String(Math.max(...vis.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
159
- const gw = nw + 5;
160
- const cw = Math.max(20, tw - gw);
161
- const canHL = diff.chars <= MAX_HL_CHARS && vis.length <= MAX_RENDER_LINES;
162
-
163
- const oldSrc: string[] = [], newSrc: string[] = [];
164
- for (const l of vis) {
165
- if (l.type === "ctx" || l.type === "del") oldSrc.push(l.content);
166
- if (l.type === "ctx" || l.type === "add") newSrc.push(l.content);
167
- }
168
- const [oldHL, newHL] = canHL
169
- ? await Promise.all([hlBlock(oldSrc.join("\n"), language), hlBlock(newSrc.join("\n"), language)])
170
- : [oldSrc, newSrc];
171
-
172
- let oI = 0, nI = 0, idx = 0;
173
- const out: string[] = [];
174
- out.push(Ansi.rule(tw));
175
-
176
- function emitRow(
177
- num: number | null, sign: string, gutterBg: string, signFg: string, body: string, bodyBg = "",
178
- ): void {
179
- const borderFg = sign === "-" ? dc.fgDel : sign === "+" ? dc.fgAdd : "";
180
- const border = borderFg ? `${borderFg}${Ansi.BORDER_BAR}${Ansi.RST}` : `${Ansi.BG_BASE} `;
181
- const numFg = borderFg || Ansi.FG_LNUM;
182
- const gutter = `${border}${gutterBg}${Ansi.lnum(num, nw, numFg)}${signFg}${sign}${Ansi.RST} ${Ansi.DIVIDER} `;
183
- const contGutter = `${border}${gutterBg}${" ".repeat(nw + 1)}${Ansi.RST} ${Ansi.DIVIDER} `;
184
- const rows = wrapAnsi(Ansi.tabs(body), cw, adaptiveWrapRows(), bodyBg);
185
- out.push(`${gutter}${rows[0]}${Ansi.RST}`);
186
- for (let r = 1; r < rows.length; r++) out.push(`${contGutter}${rows[r]}${Ansi.RST}`);
187
- }
188
-
189
- while (idx < vis.length) {
190
- const l = vis[idx]!;
191
-
192
- if (l.type === "sep") {
193
- const gap = l.newNum;
194
- const label = gap && gap > 0 ? ` ${gap} unmodified lines ` : "···";
195
- const totalW = Math.min(tw, 72);
196
- const pad = Math.max(0, totalW - label.length - 2);
197
- const half1 = Math.floor(pad / 2), half2 = pad - half1;
198
- out.push(`${Ansi.BG_BASE}${Ansi.FG_DIM}${"─".repeat(half1)}${label}${"─".repeat(half2)}${Ansi.RST}`);
199
- idx++;
200
- continue;
201
- }
202
-
203
- if (l.type === "ctx") {
204
- const hl = oldHL[oI] ?? l.content;
205
- emitRow(l.newNum, " ", Ansi.BG_BASE, dc.fgCtx, `${Ansi.BG_BASE}${Ansi.DIM}${hl}`, Ansi.BG_BASE);
206
- oI++; nI++; idx++;
207
- continue;
208
- }
209
-
210
- const dels: Array<{ l: DiffLine; hl: string }> = [];
211
- while (idx < vis.length && vis[idx]!.type === "del") {
212
- dels.push({ l: vis[idx]!, hl: oldHL[oI] ?? vis[idx]!.content });
213
- oI++; idx++;
214
- }
215
- const adds: Array<{ l: DiffLine; hl: string }> = [];
216
- while (idx < vis.length && vis[idx]!.type === "add") {
217
- adds.push({ l: vis[idx]!, hl: newHL[nI] ?? vis[idx]!.content });
218
- nI++; idx++;
219
- }
220
-
221
- const isPaired = dels.length === 1 && adds.length === 1;
222
- const wd = isPaired ? wordDiffAnalysis(dels[0]!.l.content, adds[0]!.l.content) : null;
223
-
224
- if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
225
- const delBody = injectBg(dels[0]!.hl, wd.oldRanges, Ansi.BG_DEL, Ansi.BG_DEL_W);
226
- const addBody = injectBg(adds[0]!.hl, wd.newRanges, Ansi.BG_ADD, Ansi.BG_ADD_W);
227
- emitRow(dels[0]!.l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, delBody, Ansi.BG_DEL);
228
- emitRow(adds[0]!.l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, addBody, Ansi.BG_ADD);
229
- continue;
230
- }
231
- if (isPaired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
232
- const pwd = plainWordDiff(dels[0]!.l.content, adds[0]!.l.content);
233
- emitRow(dels[0]!.l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, `${Ansi.BG_DEL}${pwd.old}`, Ansi.BG_DEL);
234
- emitRow(adds[0]!.l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, `${Ansi.BG_ADD}${pwd.new}`, Ansi.BG_ADD);
235
- continue;
236
- }
237
-
238
- for (const d of dels) {
239
- const body = canHL ? `${Ansi.BG_DEL}${d.hl}` : `${Ansi.BG_DEL}${d.l.content}`;
240
- emitRow(d.l.oldNum, "-", Ansi.BG_GUTTER_DEL, `${dc.fgDel}${Ansi.BOLD}`, body, Ansi.BG_DEL);
241
- }
242
- for (const a of adds) {
243
- const body = canHL ? `${Ansi.BG_ADD}${a.hl}` : `${Ansi.BG_ADD}${a.l.content}`;
244
- emitRow(a.l.newNum, "+", Ansi.BG_GUTTER_ADD, `${dc.fgAdd}${Ansi.BOLD}`, body, Ansi.BG_ADD);
245
- }
246
- }
247
-
248
- out.push(Ansi.rule(tw));
249
- if (diff.lines.length > vis.length) {
250
- out.push(`${Ansi.BG_BASE}${Ansi.FG_DIM} … ${diff.lines.length - vis.length} more lines${Ansi.RST}`);
251
- }
252
- return out.join("\n");
253
- }
254
-
255
- // ---------------------------------------------------------------------------
256
- // Split view
257
- // ---------------------------------------------------------------------------
258
-
259
- export async function renderSplit(
260
- diff: ParsedDiff,
261
- language: BundledLanguage | undefined,
262
- max = MAX_PREVIEW_LINES,
263
- dc: DiffColors = DEFAULT_DIFF_COLORS,
264
- ): Promise<string> {
265
- const tw = termW();
266
- if (!shouldUseSplit(diff, tw, max)) return renderUnified(diff, language, max, dc);
267
- if (!diff.lines.length) return "";
268
-
269
- type Row = { left: DiffLine | null; right: DiffLine | null };
270
- const rows: Row[] = [];
271
- let i = 0;
272
- while (i < diff.lines.length) {
273
- const l = diff.lines[i]!;
274
- if (l.type === "sep" || l.type === "ctx") { rows.push({ left: l, right: l }); i++; continue; }
275
- const dels: DiffLine[] = [], adds: DiffLine[] = [];
276
- while (i < diff.lines.length && diff.lines[i]!.type === "del") { dels.push(diff.lines[i]!); i++; }
277
- while (i < diff.lines.length && diff.lines[i]!.type === "add") { adds.push(diff.lines[i]!); i++; }
278
- const n = Math.max(dels.length, adds.length);
279
- for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
280
- }
281
-
282
- const vis = rows.slice(0, max);
283
- const half = Math.floor((tw - 1) / 2);
284
- const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
285
- const gw = nw + 5;
286
- const cw = Math.max(12, half - gw);
287
- const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
288
-
289
- const leftSrc: string[] = [], rightSrc: string[] = [];
290
- for (const r of vis) {
291
- if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
292
- if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
293
- }
294
- const [leftHL, rightHL] = canHL
295
- ? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
296
- : [leftSrc, rightSrc];
297
-
298
- let lI = 0, rI = 0;
299
- let stripeRow = 0;
300
-
301
- type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
302
-
303
- function half_build(
304
- line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: "left" | "right",
305
- ): HalfResult {
306
- if (!line) {
307
- const gw2 = nw + 2;
308
- const gPat = Ansi.FG_STRIPE + "╱".repeat(gw2) + Ansi.RST;
309
- const g = ` ${gPat}${Ansi.FG_RULE}│${Ansi.RST} `;
310
- return { gutter: g, contGutter: g, bodyRows: [Ansi.stripes(cw, stripeRow)] };
311
- }
312
- if (line.type === "sep") {
313
- const gap = line.newNum;
314
- const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
315
- const g = `${Ansi.BG_BASE} ${Ansi.FG_DIM}${Ansi.fit("", nw + 2)}${Ansi.RST}${Ansi.FG_RULE}│${Ansi.RST} `;
316
- return { gutter: g, contGutter: g, bodyRows: [`${Ansi.BG_BASE}${Ansi.FG_DIM}${Ansi.fit(label, cw)}${Ansi.RST}`] };
317
- }
318
-
319
- const isDel = line.type === "del", isAdd = line.type === "add";
320
- const gBg = isDel ? Ansi.BG_GUTTER_DEL : isAdd ? Ansi.BG_GUTTER_ADD : Ansi.BG_BASE;
321
- const cBg = isDel ? Ansi.BG_DEL : isAdd ? Ansi.BG_ADD : Ansi.BG_BASE;
322
- const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
323
- const sign = isDel ? "-" : isAdd ? "+" : " ";
324
- const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
325
-
326
- const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
327
- const border = borderFg ? `${borderFg}${Ansi.BORDER_BAR}${Ansi.RST}` : ` ${Ansi.BG_BASE}`;
328
- const numFg = borderFg || Ansi.FG_LNUM;
329
-
330
- let body: string;
331
- if (ranges && ranges.length > 0) {
332
- body = injectBg(hl, ranges, cBg, isDel ? Ansi.BG_DEL_W : Ansi.BG_ADD_W);
333
- } else if (isDel || isAdd) {
334
- body = `${cBg}${hl}`;
335
- } else {
336
- body = `${Ansi.BG_BASE}${Ansi.DIM}${hl}`;
337
- }
338
-
339
- const gutter = `${border}${gBg}${Ansi.lnum(num, nw, numFg)}${sFg}${Ansi.BOLD}${sign}${Ansi.RST} ${Ansi.FG_RULE}│${Ansi.RST} `;
340
- const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${Ansi.RST} ${Ansi.FG_RULE}│${Ansi.RST} `;
341
- const bodyRows = wrapAnsi(Ansi.tabs(body), cw, adaptiveWrapRows(), cBg);
342
- return { gutter, contGutter, bodyRows };
343
- }
344
-
345
- const out: string[] = [];
346
- const hdrOld = `${Ansi.BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${Ansi.DIM}old${Ansi.RST}`;
347
- const hdrNew = `${Ansi.BG_BASE}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${Ansi.DIM}new${Ansi.RST}`;
348
- out.push(`${Ansi.BG_BASE}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${Ansi.FG_RULE}┊${Ansi.RST}${hdrNew}`);
349
- out.push(`${Ansi.rule(half)}${Ansi.FG_RULE}┊${Ansi.RST}${Ansi.rule(half)}`);
350
-
351
- for (const r of vis) {
352
- const leftLine = r.left, rightLine = r.right;
353
- const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
354
- const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
355
-
356
- let lResult: HalfResult, rResult: HalfResult;
357
-
358
- if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
359
- const lhl = leftHL[lI++] ?? leftLine.content;
360
- const rhl = rightHL[rI++] ?? rightLine.content;
361
- lResult = half_build(leftLine, lhl, wd.oldRanges, "left");
362
- rResult = half_build(rightLine, rhl, wd.newRanges, "right");
363
- } else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
364
- const pwd = plainWordDiff(leftLine.content, rightLine.content);
365
- lI++; rI++;
366
- lResult = half_build(leftLine, pwd.old, null, "left");
367
- rResult = half_build(rightLine, pwd.new, null, "right");
368
- } else {
369
- const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
370
- const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
371
- lResult = half_build(leftLine, lhl, null, "left");
372
- rResult = half_build(rightLine, rhl, null, "right");
373
- }
374
-
375
- const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
376
- const leftIsEmpty = !r.left;
377
- const rightIsEmpty = !r.right;
378
- for (let row = 0; row < maxRows; row++) {
379
- const lg = row === 0 ? lResult.gutter : lResult.contGutter;
380
- const rg = row === 0 ? rResult.gutter : rResult.contGutter;
381
- const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? Ansi.stripes(cw, stripeRow) : `${Ansi.BG_EMPTY}${" ".repeat(cw)}${Ansi.RST}`);
382
- const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? Ansi.stripes(cw, stripeRow) : `${Ansi.BG_EMPTY}${" ".repeat(cw)}${Ansi.RST}`);
383
- out.push(`${lg}${lb}${Ansi.DIVIDER}${rg}${rb}`);
384
- stripeRow++;
385
- }
386
- }
387
-
388
- out.push(`${Ansi.rule(half)}${Ansi.FG_RULE}┊${Ansi.RST}${Ansi.rule(half)}`);
389
- if (rows.length > vis.length) {
390
- out.push(`${Ansi.BG_BASE}${Ansi.FG_DIM} … ${rows.length - vis.length} more lines${Ansi.RST}`);
391
- }
392
- return out.join("\n");
393
- }