@pi-archimedes/diff 0.5.0 → 0.7.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/package.json +1 -1
- package/src/ansi/codes.ts +157 -0
- package/src/ansi/colors.ts +80 -0
- package/src/ansi/index.ts +21 -0
- package/src/ansi/manip.ts +113 -0
- package/src/diff-component.ts +139 -0
- package/src/index.ts +15 -301
- package/src/render/index.ts +21 -0
- package/src/render/shared.ts +130 -0
- package/src/render/split.ts +192 -0
- package/src/render/unified.ts +159 -0
- package/src/shiki.ts +1 -1
- package/src/tools/edit.ts +170 -0
- package/src/tools/index.ts +4 -0
- package/src/tools/write.ts +148 -0
- package/src/word-diff.ts +1 -1
- package/src/ansi.ts +0 -282
- package/src/render.ts +0 -393
package/package.json
CHANGED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/** ANSI escape codes, constants, and interfaces. */
|
|
2
|
+
|
|
3
|
+
import { deriveBgFromTheme } from "./colors.js";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// ANSI escape codes
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export const RST = "\x1b[0m";
|
|
10
|
+
export const BOLD = "\x1b[1m";
|
|
11
|
+
export const DIM = "\x1b[2m";
|
|
12
|
+
|
|
13
|
+
// Diff foregrounds — hardcoded fallbacks
|
|
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
|
+
export const BORDER_BAR = "▌";
|
|
23
|
+
const BG_DEFAULT = "\x1b[49m"; // reset to terminal default background
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// DiffBg — instance-scoped background colors derived from a theme
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** All background colors needed for diff rendering, derived from a theme. */
|
|
30
|
+
export interface DiffBg {
|
|
31
|
+
bgAdd: string;
|
|
32
|
+
bgDel: string;
|
|
33
|
+
bgAddW: string; // word-level emphasis
|
|
34
|
+
bgDelW: string;
|
|
35
|
+
bgGutterAdd: string;
|
|
36
|
+
bgGutterDel: string;
|
|
37
|
+
bgEmpty: string;
|
|
38
|
+
bgBase: string; // tool box base bg
|
|
39
|
+
rst: string; // reset + base bg
|
|
40
|
+
divider: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Hardcoded fallback backgrounds. */
|
|
44
|
+
export const DEFAULT_DIFF_BG: DiffBg = {
|
|
45
|
+
bgAdd: "\x1b[48;2;22;38;32m",
|
|
46
|
+
bgDel: "\x1b[48;2;45;25;25m",
|
|
47
|
+
bgAddW: "\x1b[48;2;35;75;50m",
|
|
48
|
+
bgDelW: "\x1b[48;2;80;35;35m",
|
|
49
|
+
bgGutterAdd: "\x1b[48;2;18;32;26m",
|
|
50
|
+
bgGutterDel: "\x1b[48;2;38;22;22m",
|
|
51
|
+
bgEmpty: "\x1b[48;2;18;18;18m",
|
|
52
|
+
bgBase: BG_DEFAULT,
|
|
53
|
+
rst: "\x1b[0m",
|
|
54
|
+
divider: `${FG_RULE}│\x1b[0m`,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Legacy aliases — used by word-diff.ts during transition. Will be removed.
|
|
58
|
+
// These MUST be mutable and live in the same module as the functions that mutate them.
|
|
59
|
+
export let BG_ADD = DEFAULT_DIFF_BG.bgAdd;
|
|
60
|
+
export let BG_DEL = DEFAULT_DIFF_BG.bgDel;
|
|
61
|
+
export let BG_ADD_W = DEFAULT_DIFF_BG.bgAddW;
|
|
62
|
+
export let BG_DEL_W = DEFAULT_DIFF_BG.bgDelW;
|
|
63
|
+
export let BG_GUTTER_ADD = DEFAULT_DIFF_BG.bgGutterAdd;
|
|
64
|
+
export let BG_GUTTER_DEL = DEFAULT_DIFF_BG.bgGutterDel;
|
|
65
|
+
export let BG_EMPTY = DEFAULT_DIFF_BG.bgEmpty;
|
|
66
|
+
export let BG_BASE = DEFAULT_DIFF_BG.bgBase;
|
|
67
|
+
export let DIVIDER = DEFAULT_DIFF_BG.divider;
|
|
68
|
+
|
|
69
|
+
// Theme cache key state — lives here since it's used by resolveDiffColors.
|
|
70
|
+
let _lastThemeKey: string | undefined;
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Regex patterns
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const ESC_RE = "\u001b";
|
|
77
|
+
export const ANSI_RE = new RegExp(`${ESC_RE}\\[[0-9;]*m`, "g");
|
|
78
|
+
export const ANSI_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([^m]*)m`, "g");
|
|
79
|
+
export const ANSI_PARAM_CAPTURE_RE = new RegExp(`${ESC_RE}\\[([0-9;]*)m`, "g");
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// DiffColors — foreground colors for diff signs
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export interface DiffColors {
|
|
86
|
+
fgAdd: string;
|
|
87
|
+
fgDel: string;
|
|
88
|
+
fgCtx: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const DEFAULT_DIFF_COLORS: DiffColors = { fgAdd: FG_ADD, fgDel: FG_DEL, fgCtx: FG_DIM };
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Theme-aware color resolution (mutates legacy aliases)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** Reset auto-derived colors — call when theme changes. */
|
|
98
|
+
export function resetDiffColors(): void {
|
|
99
|
+
_lastThemeKey = undefined;
|
|
100
|
+
BG_ADD = DEFAULT_DIFF_BG.bgAdd;
|
|
101
|
+
BG_DEL = DEFAULT_DIFF_BG.bgDel;
|
|
102
|
+
BG_ADD_W = DEFAULT_DIFF_BG.bgAddW;
|
|
103
|
+
BG_DEL_W = DEFAULT_DIFF_BG.bgDelW;
|
|
104
|
+
BG_GUTTER_ADD = DEFAULT_DIFF_BG.bgGutterAdd;
|
|
105
|
+
BG_GUTTER_DEL = DEFAULT_DIFF_BG.bgGutterDel;
|
|
106
|
+
BG_EMPTY = DEFAULT_DIFF_BG.bgEmpty;
|
|
107
|
+
BG_BASE = DEFAULT_DIFF_BG.bgBase;
|
|
108
|
+
DIVIDER = DEFAULT_DIFF_BG.divider;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function themeCacheKey(theme?: any): string {
|
|
112
|
+
if (!theme?.fg) return "no-theme";
|
|
113
|
+
const fgKeys = [
|
|
114
|
+
"toolTitle", "accent", "muted", "success", "error",
|
|
115
|
+
"toolDiffAdded", "toolDiffRemoved", "toolDiffContext",
|
|
116
|
+
];
|
|
117
|
+
const bgKeys = ["toolSuccessBg", "toolErrorBg"];
|
|
118
|
+
const parts: string[] = [];
|
|
119
|
+
for (const key of fgKeys) {
|
|
120
|
+
try { parts.push(theme.fg(key, key)); } catch { parts.push(key); }
|
|
121
|
+
}
|
|
122
|
+
for (const key of bgKeys) {
|
|
123
|
+
try { parts.push(theme.bg ? theme.bg(key, key) : key); } catch { parts.push(key); }
|
|
124
|
+
}
|
|
125
|
+
return parts.join("|");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { deriveBgFromTheme };
|
|
129
|
+
|
|
130
|
+
export function resolveDiffColors(theme?: any): DiffColors {
|
|
131
|
+
const themeKey = themeCacheKey(theme);
|
|
132
|
+
|
|
133
|
+
// Re-derive when theme changes (different key) or first call
|
|
134
|
+
if (themeKey !== _lastThemeKey && theme?.getFgAnsi) {
|
|
135
|
+
const dbg = deriveBgFromTheme(theme);
|
|
136
|
+
BG_ADD = dbg.bgAdd;
|
|
137
|
+
BG_DEL = dbg.bgDel;
|
|
138
|
+
BG_ADD_W = dbg.bgAddW;
|
|
139
|
+
BG_DEL_W = dbg.bgDelW;
|
|
140
|
+
BG_GUTTER_ADD = dbg.bgGutterAdd;
|
|
141
|
+
BG_GUTTER_DEL = dbg.bgGutterDel;
|
|
142
|
+
BG_EMPTY = dbg.bgEmpty;
|
|
143
|
+
BG_BASE = dbg.bgBase;
|
|
144
|
+
DIVIDER = dbg.divider;
|
|
145
|
+
_lastThemeKey = themeKey;
|
|
146
|
+
}
|
|
147
|
+
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
148
|
+
try {
|
|
149
|
+
return {
|
|
150
|
+
fgAdd: theme.getFgAnsi("toolDiffAdded") || FG_ADD,
|
|
151
|
+
fgDel: theme.getFgAnsi("toolDiffRemoved") || FG_DEL,
|
|
152
|
+
fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return DEFAULT_DIFF_COLORS;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** Diff background color derivation from theme. */
|
|
2
|
+
|
|
3
|
+
import type { DiffBg } from "./codes.js";
|
|
4
|
+
import { DEFAULT_DIFF_BG, FG_RULE } from "./codes.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Color helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Parse 24-bit ANSI color code → RGB. Works for both fg and bg escapes. */
|
|
11
|
+
function parseAnsiRgb(ansi: string): { r: number; g: number; b: number } | null {
|
|
12
|
+
const esc = "\x1b";
|
|
13
|
+
const m = ansi.match(new RegExp(`${esc}\\[(?:38|48);2;(\\d+);(\\d+);(\\d+)m`));
|
|
14
|
+
return m ? { r: +m[1]!, g: +m[2]!, b: +m[3]! } : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Mix an accent color into a base color at the given intensity (0.0–1.0). */
|
|
18
|
+
function mixBg(
|
|
19
|
+
base: { r: number; g: number; b: number },
|
|
20
|
+
accent: { r: number; g: number; b: number },
|
|
21
|
+
intensity: number,
|
|
22
|
+
): string {
|
|
23
|
+
const r = Math.round(base.r + (accent.r - base.r) * intensity);
|
|
24
|
+
const g = Math.round(base.g + (accent.g - base.g) * intensity);
|
|
25
|
+
const b = Math.round(base.b + (accent.b - base.b) * intensity);
|
|
26
|
+
return `\x1b[48;2;${r};${g};${b}m`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Theme-aware diff colors
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Auto-derive diff background colors from the pi theme's fg diff colors. */
|
|
34
|
+
export function deriveBgFromTheme(theme: any): DiffBg {
|
|
35
|
+
if (!theme?.getFgAnsi) return { ...DEFAULT_DIFF_BG };
|
|
36
|
+
try {
|
|
37
|
+
const fgAdd = theme.getFgAnsi("toolDiffAdded");
|
|
38
|
+
const fgDel = theme.getFgAnsi("toolDiffRemoved");
|
|
39
|
+
const addRgb = parseAnsiRgb(fgAdd);
|
|
40
|
+
const delRgb = parseAnsiRgb(fgDel);
|
|
41
|
+
if (!addRgb || !delRgb) return { ...DEFAULT_DIFF_BG };
|
|
42
|
+
|
|
43
|
+
let addBase = { r: 0, g: 0, b: 0 };
|
|
44
|
+
let delBase = addBase;
|
|
45
|
+
let bgBase = "\x1b[49m"; // BG_DEFAULT
|
|
46
|
+
|
|
47
|
+
if (theme.getBgAnsi) {
|
|
48
|
+
try {
|
|
49
|
+
const successBgAnsi = theme.getBgAnsi("toolSuccessBg");
|
|
50
|
+
const successParsed = parseAnsiRgb(successBgAnsi);
|
|
51
|
+
if (successParsed) {
|
|
52
|
+
addBase = successParsed;
|
|
53
|
+
delBase = successParsed;
|
|
54
|
+
bgBase = successBgAnsi;
|
|
55
|
+
}
|
|
56
|
+
} catch { /* no toolSuccessBg */ }
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const errorParsed = parseAnsiRgb(theme.getBgAnsi("toolErrorBg"));
|
|
60
|
+
if (errorParsed) delBase = errorParsed;
|
|
61
|
+
} catch { /* no toolErrorBg */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rst = bgBase === "\x1b[49m" ? "\x1b[0m" : `\x1b[0m${bgBase}`;
|
|
65
|
+
return {
|
|
66
|
+
bgAdd: mixBg(addBase, addRgb, 0.08),
|
|
67
|
+
bgDel: mixBg(delBase, delRgb, 0.1),
|
|
68
|
+
bgAddW: mixBg(addBase, addRgb, 0.2),
|
|
69
|
+
bgDelW: mixBg(delBase, delRgb, 0.22),
|
|
70
|
+
bgGutterAdd: mixBg(addBase, addRgb, 0.05),
|
|
71
|
+
bgGutterDel: mixBg(delBase, delRgb, 0.06),
|
|
72
|
+
bgEmpty: bgBase,
|
|
73
|
+
bgBase,
|
|
74
|
+
rst,
|
|
75
|
+
divider: `${FG_RULE}│${rst}`,
|
|
76
|
+
};
|
|
77
|
+
} catch {
|
|
78
|
+
return { ...DEFAULT_DIFF_BG };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** ANSI utilities — barrel re-export. */
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
RST, BOLD, DIM,
|
|
5
|
+
FG_ADD, FG_DEL, FG_DIM, FG_LNUM, FG_RULE, FG_SAFE_MUTED, FG_STRIPE,
|
|
6
|
+
BORDER_BAR,
|
|
7
|
+
DEFAULT_DIFF_BG,
|
|
8
|
+
BG_ADD, BG_DEL, BG_ADD_W, BG_DEL_W, BG_GUTTER_ADD, BG_GUTTER_DEL, BG_EMPTY, BG_BASE, DIVIDER,
|
|
9
|
+
ANSI_RE, ANSI_CAPTURE_RE, ANSI_PARAM_CAPTURE_RE,
|
|
10
|
+
DEFAULT_DIFF_COLORS,
|
|
11
|
+
resetDiffColors, themeCacheKey, resolveDiffColors,
|
|
12
|
+
deriveBgFromTheme,
|
|
13
|
+
type DiffBg,
|
|
14
|
+
type DiffColors,
|
|
15
|
+
} from "./codes.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
strip, tabs, fit, ansiState,
|
|
19
|
+
isLowContrastShikiFg, normalizeShikiContrast,
|
|
20
|
+
stripes, lnum, rule, shortPath, summarize,
|
|
21
|
+
} from "./manip.js";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** ANSI string manipulation utilities. */
|
|
2
|
+
|
|
3
|
+
import { relative } from "node:path";
|
|
4
|
+
import * as C from "./codes.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// ANSI manipulation
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
11
|
+
|
|
12
|
+
/** Strip all ANSI escape codes from a string. */
|
|
13
|
+
export const strip = stripSgr;
|
|
14
|
+
|
|
15
|
+
/** Replace tabs with 2 spaces. */
|
|
16
|
+
export function tabs(s: string): string {
|
|
17
|
+
return s.replace(/\t/g, " ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Pad/truncate `s` to exactly `w` visible chars. ANSI-aware. */
|
|
21
|
+
export function fit(s: string, w: number): string {
|
|
22
|
+
if (w <= 0) return "";
|
|
23
|
+
const plain = strip(s);
|
|
24
|
+
if (plain.length <= w) return s + " ".repeat(w - plain.length);
|
|
25
|
+
const showW = w > 2 ? w - 1 : w;
|
|
26
|
+
let vis = 0,
|
|
27
|
+
i = 0;
|
|
28
|
+
while (i < s.length && vis < showW) {
|
|
29
|
+
if (s[i] === "\x1b") {
|
|
30
|
+
const e = s.indexOf("m", i);
|
|
31
|
+
if (e !== -1) {
|
|
32
|
+
i = e + 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
vis++;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return w > 2 ? `${s.slice(0, i)}${C.RST}${C.FG_DIM}›${C.RST}` : `${s.slice(0, i)}${C.RST}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Extract last active fg + bg ANSI codes from a string. Used for wrapping continuations. */
|
|
43
|
+
export function ansiState(s: string): string {
|
|
44
|
+
let fg = "",
|
|
45
|
+
bg = "";
|
|
46
|
+
for (const match of s.matchAll(C.ANSI_CAPTURE_RE)) {
|
|
47
|
+
const p = match[1] ?? "";
|
|
48
|
+
const seq = match[0] ?? "";
|
|
49
|
+
if (p === "0") {
|
|
50
|
+
fg = "";
|
|
51
|
+
bg = "";
|
|
52
|
+
} else if (p === "39") {
|
|
53
|
+
fg = "";
|
|
54
|
+
} else if (p.startsWith("38;")) {
|
|
55
|
+
fg = seq;
|
|
56
|
+
} else if (p.startsWith("48;")) {
|
|
57
|
+
bg = seq;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return bg + fg;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Check if a Shiki fg code is too dark to read. */
|
|
64
|
+
export function isLowContrastShikiFg(params: string): boolean {
|
|
65
|
+
if (params === "30" || params === "90") return true;
|
|
66
|
+
if (params === "38;5;0" || params === "38;5;8") return true;
|
|
67
|
+
if (!params.startsWith("38;2;")) return false;
|
|
68
|
+
const parts = params.split(";").map(Number);
|
|
69
|
+
if (parts.length !== 5 || parts.some((n) => !Number.isFinite(n))) return false;
|
|
70
|
+
const [, , r, g, b] = parts as [number, number, number, number, number];
|
|
71
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
72
|
+
return luminance < 72;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Normalize Shiki ANSI output to boost low-contrast fg codes. */
|
|
76
|
+
export function normalizeShikiContrast(ansi: string): string {
|
|
77
|
+
return ansi.replace(C.ANSI_PARAM_CAPTURE_RE, (seq, params: string) =>
|
|
78
|
+
isLowContrastShikiFg(params) ? C.FG_SAFE_MUTED : seq,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Generate a dense diagonal stripe fill for empty filler cells. */
|
|
83
|
+
export function stripes(dbg: C.DiffBg, w: number, _rowOffset: number): string {
|
|
84
|
+
return dbg.bgBase + C.FG_STRIPE + "╱".repeat(w) + dbg.rst;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Format a line number, right-padded to width `w`. */
|
|
88
|
+
export function lnum(n: number | null, w: number, fg = C.FG_LNUM): string {
|
|
89
|
+
if (n === null) return " ".repeat(w);
|
|
90
|
+
const v = String(n);
|
|
91
|
+
return `${fg}${" ".repeat(Math.max(0, w - v.length))}${v}${C.RST}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Horizontal rule line. */
|
|
95
|
+
export function rule(dbg: C.DiffBg, w: number): string {
|
|
96
|
+
return `${dbg.bgBase}${C.FG_RULE}${"─".repeat(w)}${dbg.rst}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Shorten a file path relative to cwd or home. */
|
|
100
|
+
export function shortPath(cwd: string, home: string, p: string): string {
|
|
101
|
+
if (!p) return "";
|
|
102
|
+
const r = relative(cwd, p);
|
|
103
|
+
if (!r.startsWith("..") && !r.startsWith("/")) return r;
|
|
104
|
+
return p.replace(home, "~");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Summarize added/removed counts as colored `+N -M` string. */
|
|
108
|
+
export function summarize(a: number, d: number): string {
|
|
109
|
+
const p: string[] = [];
|
|
110
|
+
if (a > 0) p.push(`${C.FG_ADD}+${a}${C.RST}`);
|
|
111
|
+
if (d > 0) p.push(`${C.FG_DEL}-${d}${C.RST}`);
|
|
112
|
+
return p.length ? p.join(" ") : `${C.FG_DIM}no changes${C.RST}`;
|
|
113
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/** DiffComponent — proper pi-tui Component for diff rendering. */
|
|
2
|
+
|
|
3
|
+
import type { BundledLanguage } from "shiki";
|
|
4
|
+
import { Box, type Component } from "@earendil-works/pi-tui";
|
|
5
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import * as Ansi from "./ansi/index.js";
|
|
7
|
+
import type { DiffBg, DiffColors } from "./ansi/index.js";
|
|
8
|
+
import { DEFAULT_DIFF_COLORS, DEFAULT_DIFF_BG, deriveBgFromTheme } from "./ansi/index.js";
|
|
9
|
+
import { renderSplitLines, MAX_RENDER_LINES } from "./render/index.js";
|
|
10
|
+
import type { ParsedDiff } from "./core/diff.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// DiffComponent
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A pi-tui Component that renders a diff with Shiki syntax highlighting.
|
|
18
|
+
*
|
|
19
|
+
* Rendering is lazy: `render(width)` returns an array of lines computed from
|
|
20
|
+
* the diff data. Shiki highlighting is cached (LRU), so repeated renders at
|
|
21
|
+
* different widths are cheap after the first call.
|
|
22
|
+
*
|
|
23
|
+
* The component wraps its output in a `Box` with the theme-derived base
|
|
24
|
+
* background, so it is visually self-contained regardless of the tool shell.
|
|
25
|
+
*
|
|
26
|
+
* The first render may show a placeholder if Shiki hasn't warmed up yet.
|
|
27
|
+
* Subsequent renders (triggered by `invalidate()` + TUI re-render cycle)
|
|
28
|
+
* show the full diff.
|
|
29
|
+
*
|
|
30
|
+
* Usage in a tool renderResult:
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* renderResult(result, options, theme, context) {
|
|
34
|
+
* const d = result.details;
|
|
35
|
+
* if (d?._type === "diff") {
|
|
36
|
+
* const comp = context.lastComponent ?? new DiffComponent(d.diff, d.language, theme);
|
|
37
|
+
* return comp;
|
|
38
|
+
* }
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export class DiffComponent implements Component {
|
|
43
|
+
private diff: ParsedDiff;
|
|
44
|
+
private language: BundledLanguage | undefined;
|
|
45
|
+
private dc: DiffColors;
|
|
46
|
+
private dbg: DiffBg;
|
|
47
|
+
private maxLines: number;
|
|
48
|
+
|
|
49
|
+
/** Box wrapper with base background — makes the diff self-contained. */
|
|
50
|
+
private shell: Box;
|
|
51
|
+
|
|
52
|
+
/** Pending render promise — reused across width changes. */
|
|
53
|
+
private _renderPromise: Promise<string[]> | null = null;
|
|
54
|
+
/** Resolved raw diff lines (without box wrapping). */
|
|
55
|
+
private _rawLines: string[] | null = null;
|
|
56
|
+
/** Width at which output was last computed. */
|
|
57
|
+
private _cachedWidth: number | undefined;
|
|
58
|
+
/** Final cached output (box-wrapped). */
|
|
59
|
+
private _cachedLines: string[] | undefined;
|
|
60
|
+
|
|
61
|
+
constructor(
|
|
62
|
+
diff: ParsedDiff,
|
|
63
|
+
language: BundledLanguage | undefined,
|
|
64
|
+
theme: Theme,
|
|
65
|
+
maxLines: number = MAX_RENDER_LINES,
|
|
66
|
+
) {
|
|
67
|
+
this.diff = diff;
|
|
68
|
+
this.language = language;
|
|
69
|
+
this.dc = Ansi.resolveDiffColors(theme);
|
|
70
|
+
this.dbg = deriveBgFromTheme(theme);
|
|
71
|
+
this.maxLines = maxLines;
|
|
72
|
+
|
|
73
|
+
// Box provides base background so the diff is self-contained.
|
|
74
|
+
this.shell = new Box(0, 0, (s: string) => this.dbg.bgBase + s + this.dbg.rst);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
render(width: number): string[] {
|
|
78
|
+
// Use cached output if width unchanged
|
|
79
|
+
if (this._cachedLines && this._cachedWidth === width) {
|
|
80
|
+
return this._cachedLines;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// If we have resolved raw lines, wrap them in the box
|
|
84
|
+
if (this._rawLines) {
|
|
85
|
+
this._updateShell(this._rawLines);
|
|
86
|
+
const lines = this.shell.render(width);
|
|
87
|
+
this._cachedWidth = width;
|
|
88
|
+
this._cachedLines = lines;
|
|
89
|
+
return lines;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// If we have a pending promise, we haven't resolved yet — show placeholder
|
|
93
|
+
if (this._renderPromise) {
|
|
94
|
+
this._updateShell([Ansi.FG_DIM + " rendering diff…" + Ansi.RST]);
|
|
95
|
+
return this.shell.render(width);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Start async render for this width
|
|
99
|
+
const promise = renderSplitLines(
|
|
100
|
+
this.diff,
|
|
101
|
+
this.language,
|
|
102
|
+
width,
|
|
103
|
+
this.maxLines,
|
|
104
|
+
this.dc,
|
|
105
|
+
this.dbg,
|
|
106
|
+
);
|
|
107
|
+
this._renderPromise = promise;
|
|
108
|
+
|
|
109
|
+
// Store resolved lines
|
|
110
|
+
promise.then((lines) => {
|
|
111
|
+
this._rawLines = lines;
|
|
112
|
+
this._renderPromise = null;
|
|
113
|
+
this.invalidate();
|
|
114
|
+
}).catch(() => {
|
|
115
|
+
this._rawLines = [Ansi.FG_DIM + " diff render failed" + Ansi.RST];
|
|
116
|
+
this._renderPromise = null;
|
|
117
|
+
this.invalidate();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Show placeholder while waiting
|
|
121
|
+
this._updateShell([Ansi.FG_DIM + " rendering diff…" + Ansi.RST]);
|
|
122
|
+
return this.shell.render(width);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private _updateShell(lines: string[]): void {
|
|
126
|
+
this.shell.clear();
|
|
127
|
+
// Join lines into a single text block; each line is a row.
|
|
128
|
+
const textComponent = new (class implements Component {
|
|
129
|
+
render(_w: number): string[] { return lines; }
|
|
130
|
+
invalidate(): void {}
|
|
131
|
+
})();
|
|
132
|
+
this.shell.addChild(textComponent);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
invalidate(): void {
|
|
136
|
+
this._cachedWidth = undefined;
|
|
137
|
+
this._cachedLines = undefined;
|
|
138
|
+
}
|
|
139
|
+
}
|