@pi-archimedes/diff 0.2.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 +34 -0
- package/src/ansi.ts +262 -0
- package/src/core/diff.ts +48 -0
- package/src/index.ts +409 -0
- package/src/render.ts +393 -0
- package/src/shiki.ts +83 -0
- package/src/word-diff.ts +108 -0
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-archimedes/diff",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"description": "Shiki-powered diff rendering for pi-archimedes",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"diff": "^8.0.0",
|
|
18
|
+
"shiki": "^4.0.0",
|
|
19
|
+
"@shikijs/cli": "^4.0.2"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
23
|
+
"@earendil-works/pi-tui": ">=0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/diff": "^7.0.2",
|
|
27
|
+
"typescript": "^6.0.0"
|
|
28
|
+
},
|
|
29
|
+
"pi": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./src/index.ts"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/ansi.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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;
|
|
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 _didAutoDerive = false;
|
|
247
|
+
export function resolveDiffColors(theme?: any): DiffColors {
|
|
248
|
+
if (!_didAutoDerive && theme?.getFgAnsi) {
|
|
249
|
+
autoDeriveBgFromTheme(theme);
|
|
250
|
+
_didAutoDerive = true;
|
|
251
|
+
}
|
|
252
|
+
if (!theme?.getFgAnsi) return DEFAULT_DIFF_COLORS;
|
|
253
|
+
try {
|
|
254
|
+
return {
|
|
255
|
+
fgAdd: theme.getFgAnsi("toolDiffAdded") || FG_ADD,
|
|
256
|
+
fgDel: theme.getFgAnsi("toolDiffRemoved") || FG_DEL,
|
|
257
|
+
fgCtx: theme.getFgAnsi("toolDiffContext") || FG_DIM,
|
|
258
|
+
};
|
|
259
|
+
} catch {
|
|
260
|
+
return DEFAULT_DIFF_COLORS;
|
|
261
|
+
}
|
|
262
|
+
}
|
package/src/core/diff.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as Diff from "diff";
|
|
2
|
+
|
|
3
|
+
export interface DiffLine {
|
|
4
|
+
type: "add" | "del" | "ctx" | "sep";
|
|
5
|
+
oldNum: number | null;
|
|
6
|
+
newNum: number | null;
|
|
7
|
+
content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ParsedDiff {
|
|
11
|
+
lines: DiffLine[];
|
|
12
|
+
added: number;
|
|
13
|
+
removed: number;
|
|
14
|
+
chars: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseDiff(oldContent: string, newContent: string, ctx = 3): ParsedDiff {
|
|
18
|
+
const patch = Diff.structuredPatch("", "", oldContent, newContent, "", "", { context: ctx });
|
|
19
|
+
const lines: DiffLine[] = [];
|
|
20
|
+
let added = 0;
|
|
21
|
+
let removed = 0;
|
|
22
|
+
|
|
23
|
+
for (let hi = 0; hi < patch.hunks.length; hi++) {
|
|
24
|
+
if (hi > 0) {
|
|
25
|
+
const prev = patch.hunks[hi - 1];
|
|
26
|
+
const gap = patch.hunks[hi].oldStart - (prev.oldStart + prev.oldLines);
|
|
27
|
+
lines.push({ type: "sep", oldNum: null, newNum: gap > 0 ? gap : null, content: "" });
|
|
28
|
+
}
|
|
29
|
+
const h = patch.hunks[hi];
|
|
30
|
+
let oL = h.oldStart;
|
|
31
|
+
let nL = h.newStart;
|
|
32
|
+
for (const raw of h.lines) {
|
|
33
|
+
if (raw === "\") continue;
|
|
34
|
+
const ch = raw[0];
|
|
35
|
+
const text = raw.slice(1);
|
|
36
|
+
if (ch === "+") {
|
|
37
|
+
lines.push({ type: "add", oldNum: null, newNum: nL++, content: text });
|
|
38
|
+
added++;
|
|
39
|
+
} else if (ch === "-") {
|
|
40
|
+
lines.push({ type: "del", oldNum: oL++, newNum: null, content: text });
|
|
41
|
+
removed++;
|
|
42
|
+
} else {
|
|
43
|
+
lines.push({ type: "ctx", oldNum: oL++, newNum: nL++, content: text });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { lines, added, removed, chars: oldContent.length + newContent.length };
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-archimedes/diff — Shiki-powered terminal diff rendering.
|
|
3
|
+
*
|
|
4
|
+
* Adapted from pi-ui-hephaestus diff renderer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
import { type DiffLine, type ParsedDiff, parseDiff } from "./core/diff.js";
|
|
12
|
+
import * as Ansi from "./ansi.js";
|
|
13
|
+
import { resolveDiffColors, themeCacheKey, DEFAULT_DIFF_COLORS } from "./ansi.js";
|
|
14
|
+
import { setConfigGetter as setShikiConfig } from "./shiki.js";
|
|
15
|
+
import { hlBlock, lang } from "./shiki.js";
|
|
16
|
+
import { renderSplit } from "./render.js";
|
|
17
|
+
import { setConfigGetter as setRenderConfig } from "./render.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Config
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export interface DiffConfig {
|
|
24
|
+
diffTheme: string;
|
|
25
|
+
diffSplitMinWidth: number;
|
|
26
|
+
diffSplitMinCodeWidth: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_DIFF_CONFIG: DiffConfig = {
|
|
30
|
+
diffTheme: "github-dark",
|
|
31
|
+
diffSplitMinWidth: 150,
|
|
32
|
+
diffSplitMinCodeWidth: 60,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let _readConfig: () => DiffConfig = () => DEFAULT_DIFF_CONFIG;
|
|
36
|
+
function getConfig(): DiffConfig { return _readConfig(); }
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Constants
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const MAX_PREVIEW_LINES = 60;
|
|
43
|
+
const MAX_RENDER_LINES = 150;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Settings
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export function getDiffSettingsItems(): SettingItem[] {
|
|
50
|
+
const config = getConfig();
|
|
51
|
+
return [
|
|
52
|
+
{
|
|
53
|
+
id: "diffTheme",
|
|
54
|
+
label: "Diff Theme",
|
|
55
|
+
description: "Shiki theme for diff syntax highlighting",
|
|
56
|
+
currentValue: config.diffTheme,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "diffSplitMinWidth",
|
|
60
|
+
label: "Split Min Width",
|
|
61
|
+
description: "Minimum terminal width for split diff view",
|
|
62
|
+
currentValue: String(config.diffSplitMinWidth),
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "diffSplitMinCodeWidth",
|
|
66
|
+
label: "Split Min Code Width",
|
|
67
|
+
description: "Minimum code column width for split diff view",
|
|
68
|
+
currentValue: String(config.diffSplitMinCodeWidth),
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Extension entry point
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
export function registerDiffTools(
|
|
78
|
+
pi: ExtensionAPI,
|
|
79
|
+
_getTheme: () => Theme,
|
|
80
|
+
readConfig: () => DiffConfig,
|
|
81
|
+
): void {
|
|
82
|
+
_readConfig = readConfig;
|
|
83
|
+
|
|
84
|
+
// Wire config getters into submodules
|
|
85
|
+
setShikiConfig(() => getConfig());
|
|
86
|
+
setRenderConfig(() => getConfig());
|
|
87
|
+
|
|
88
|
+
(async () => {
|
|
89
|
+
let createWriteTool: any, createEditTool: any, TextComponent: any;
|
|
90
|
+
try {
|
|
91
|
+
const sdk = await import("@earendil-works/pi-coding-agent");
|
|
92
|
+
const tui = await import("@earendil-works/pi-tui");
|
|
93
|
+
createWriteTool = sdk.createWriteTool;
|
|
94
|
+
createEditTool = sdk.createEditTool;
|
|
95
|
+
TextComponent = tui.Text;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.error(
|
|
98
|
+
`[diff] failed to load Pi SDK: ${error instanceof Error ? error.message : String(error)}`,
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!createWriteTool || !createEditTool || !TextComponent) return;
|
|
103
|
+
|
|
104
|
+
const cwd = process.cwd();
|
|
105
|
+
const home = process.env.HOME ?? "";
|
|
106
|
+
const sp = (p: string) => Ansi.shortPath(cwd, home, p);
|
|
107
|
+
|
|
108
|
+
// ===================================================================
|
|
109
|
+
// write tool
|
|
110
|
+
// ===================================================================
|
|
111
|
+
|
|
112
|
+
const origWrite = createWriteTool(cwd);
|
|
113
|
+
|
|
114
|
+
pi.registerTool({
|
|
115
|
+
...origWrite,
|
|
116
|
+
name: "write",
|
|
117
|
+
|
|
118
|
+
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
119
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
120
|
+
let old: string | null = null;
|
|
121
|
+
try {
|
|
122
|
+
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
123
|
+
} catch {
|
|
124
|
+
old = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
128
|
+
const content = params.content ?? "";
|
|
129
|
+
|
|
130
|
+
if (old !== null && old !== content) {
|
|
131
|
+
const diff = parseDiff(old, content);
|
|
132
|
+
const lg = lang(fp);
|
|
133
|
+
(result as any).details = {
|
|
134
|
+
_type: "diff",
|
|
135
|
+
summary: Ansi.summarize(diff.added, diff.removed),
|
|
136
|
+
diff,
|
|
137
|
+
language: lg,
|
|
138
|
+
};
|
|
139
|
+
} else if (old === null) {
|
|
140
|
+
const lineCount = content ? content.split("\n").length : 0;
|
|
141
|
+
(result as any).details = { _type: "new", lines: lineCount, content, filePath: fp };
|
|
142
|
+
} else if (old === content) {
|
|
143
|
+
(result as any).details = { _type: "noChange" };
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
renderCall(args: any, theme: any, ctx: any) {
|
|
149
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
150
|
+
const isNew = !fp || !existsSync(fp);
|
|
151
|
+
const label = isNew ? "create" : "write";
|
|
152
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
153
|
+
const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
154
|
+
|
|
155
|
+
if (args?.content && !ctx.argsComplete) {
|
|
156
|
+
const n = String(args.content).split("\n").length;
|
|
157
|
+
text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`);
|
|
158
|
+
return text;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (args?.content && ctx.argsComplete && isNew) {
|
|
162
|
+
const previewKey = `create:${themeCacheKey(theme)}:${fp}:${String(args.content).length}`;
|
|
163
|
+
if (ctx.state._previewKey !== previewKey) {
|
|
164
|
+
ctx.state._previewKey = previewKey;
|
|
165
|
+
ctx.state._previewText = hdr;
|
|
166
|
+
const lg = lang(fp);
|
|
167
|
+
hlBlock(args.content, lg)
|
|
168
|
+
.then((lines: string[]) => {
|
|
169
|
+
if (ctx.state._previewKey !== previewKey) return;
|
|
170
|
+
const maxShow = ctx.expanded ? lines.length : 16;
|
|
171
|
+
const preview = lines.slice(0, maxShow).join("\n");
|
|
172
|
+
const rem = lines.length - maxShow;
|
|
173
|
+
let out = `${hdr}\n\n${preview}`;
|
|
174
|
+
if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
175
|
+
ctx.state._previewText = out;
|
|
176
|
+
ctx.invalidate();
|
|
177
|
+
})
|
|
178
|
+
.catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
text.setText(ctx.state._previewText ?? hdr);
|
|
181
|
+
return text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
text.setText(hdr);
|
|
185
|
+
return text;
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
189
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
190
|
+
if (ctx.isError) {
|
|
191
|
+
const e = result.content
|
|
192
|
+
?.filter((c: any) => c.type === "text")
|
|
193
|
+
.map((c: any) => c.text || "")
|
|
194
|
+
.join("\n") ?? "Error";
|
|
195
|
+
text.setText(`\n${theme.fg("error", e)}`);
|
|
196
|
+
return text;
|
|
197
|
+
}
|
|
198
|
+
const d = result.details;
|
|
199
|
+
if (d?._type === "diff") {
|
|
200
|
+
const w = process.stdout.columns ?? 200;
|
|
201
|
+
const key = `wd:${themeCacheKey(theme)}:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
|
|
202
|
+
if (ctx.state._wdk !== key) {
|
|
203
|
+
ctx.state._wdk = key;
|
|
204
|
+
ctx.state._wdt = ` ${d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
205
|
+
const dc = resolveDiffColors(theme);
|
|
206
|
+
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
|
|
207
|
+
.then((rendered: string) => {
|
|
208
|
+
if (ctx.state._wdk !== key) return;
|
|
209
|
+
ctx.state._wdt = ` ${d.summary}\n${rendered}`;
|
|
210
|
+
ctx.invalidate();
|
|
211
|
+
})
|
|
212
|
+
.catch(() => {
|
|
213
|
+
if (ctx.state._wdk !== key) return;
|
|
214
|
+
ctx.state._wdt = ` ${d.summary}`;
|
|
215
|
+
ctx.invalidate();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
219
|
+
return text;
|
|
220
|
+
}
|
|
221
|
+
if (d?._type === "noChange") {
|
|
222
|
+
text.setText(` ${theme.fg("muted", "✓ no changes")}`);
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
if (d?._type === "new") {
|
|
226
|
+
const { lines: lineCount, content: rawContent, filePath: fp } = d;
|
|
227
|
+
const pk = `nf:${themeCacheKey(theme)}:${fp}:${lineCount}`;
|
|
228
|
+
if (ctx.state._nfk !== pk) {
|
|
229
|
+
ctx.state._nfk = pk;
|
|
230
|
+
ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
231
|
+
const lg = lang(fp);
|
|
232
|
+
if (rawContent) {
|
|
233
|
+
hlBlock(rawContent, lg)
|
|
234
|
+
.then((hlLines: string[]) => {
|
|
235
|
+
if (ctx.state._nfk !== pk) return;
|
|
236
|
+
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
237
|
+
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
238
|
+
const rem = hlLines.length - maxShow;
|
|
239
|
+
let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
|
|
240
|
+
if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
241
|
+
ctx.state._nft = out;
|
|
242
|
+
ctx.invalidate();
|
|
243
|
+
})
|
|
244
|
+
.catch(() => {});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
|
|
248
|
+
return text;
|
|
249
|
+
}
|
|
250
|
+
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
|
|
251
|
+
return text;
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// ===================================================================
|
|
256
|
+
// edit tool
|
|
257
|
+
// ===================================================================
|
|
258
|
+
|
|
259
|
+
const origEdit = createEditTool(cwd);
|
|
260
|
+
|
|
261
|
+
function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
|
|
262
|
+
if (Array.isArray(input?.edits)) {
|
|
263
|
+
return input.edits
|
|
264
|
+
.map((edit: any) => ({
|
|
265
|
+
oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
|
|
266
|
+
newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
|
|
267
|
+
}))
|
|
268
|
+
.filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
|
|
269
|
+
}
|
|
270
|
+
const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
|
|
271
|
+
const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
|
|
272
|
+
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
|
|
276
|
+
const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
|
|
277
|
+
const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
|
|
278
|
+
const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
|
|
279
|
+
return { diffs, totalAdded, totalRemoved, summary: Ansi.summarize(totalAdded, totalRemoved) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
pi.registerTool({
|
|
283
|
+
...origEdit,
|
|
284
|
+
name: "edit",
|
|
285
|
+
|
|
286
|
+
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
287
|
+
const fp = params.path ?? params.file_path ?? "";
|
|
288
|
+
const operations = getEditOperations(params);
|
|
289
|
+
const result = await origEdit.execute(tid, params, sig, upd, ctx);
|
|
290
|
+
|
|
291
|
+
if (operations.length === 0) return result;
|
|
292
|
+
|
|
293
|
+
const { diffs, summary } = summarizeEditOperations(operations);
|
|
294
|
+
if (operations.length === 1) {
|
|
295
|
+
let editLine = 0;
|
|
296
|
+
try {
|
|
297
|
+
if (fp && existsSync(fp)) {
|
|
298
|
+
const f = readFileSync(fp, "utf-8");
|
|
299
|
+
const idx = f.indexOf(operations[0].newText);
|
|
300
|
+
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
301
|
+
}
|
|
302
|
+
} catch { editLine = 0; }
|
|
303
|
+
(result as any).details = { _type: "editInfo", summary, editLine };
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
(result as any).details = {
|
|
308
|
+
_type: "multiEditInfo",
|
|
309
|
+
summary,
|
|
310
|
+
editCount: operations.length,
|
|
311
|
+
diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
|
|
312
|
+
};
|
|
313
|
+
return result;
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
renderCall(args: any, theme: any, ctx: any) {
|
|
317
|
+
const fp = args?.path ?? args?.file_path ?? "";
|
|
318
|
+
const operations = getEditOperations(args);
|
|
319
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
320
|
+
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
321
|
+
|
|
322
|
+
if (!(ctx.argsComplete && operations.length > 0)) {
|
|
323
|
+
text.setText(hdr);
|
|
324
|
+
return text;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const pk = JSON.stringify({ fp, operations, theme: themeCacheKey(theme), w: process.stdout.columns ?? 200 });
|
|
328
|
+
if (ctx.state._pk !== pk) {
|
|
329
|
+
ctx.state._pk = pk;
|
|
330
|
+
ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
|
|
331
|
+
const lg = lang(fp);
|
|
332
|
+
const dc = resolveDiffColors(theme);
|
|
333
|
+
|
|
334
|
+
if (operations.length === 1) {
|
|
335
|
+
const diff = parseDiff(operations[0].oldText, operations[0].newText);
|
|
336
|
+
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
|
|
337
|
+
.then((rendered) => {
|
|
338
|
+
if (ctx.state._pk !== pk) return;
|
|
339
|
+
ctx.state._pt = `${hdr}\n${Ansi.summarize(diff.added, diff.removed)}\n${rendered}`;
|
|
340
|
+
ctx.invalidate();
|
|
341
|
+
})
|
|
342
|
+
.catch(() => {
|
|
343
|
+
if (ctx.state._pk !== pk) return;
|
|
344
|
+
ctx.state._pt = `${hdr} ${Ansi.summarize(diff.added, diff.removed)}`;
|
|
345
|
+
ctx.invalidate();
|
|
346
|
+
});
|
|
347
|
+
} else {
|
|
348
|
+
const { diffs, summary } = summarizeEditOperations(operations);
|
|
349
|
+
const maxShown = Math.min(operations.length, 3);
|
|
350
|
+
const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
|
|
351
|
+
Promise.all(
|
|
352
|
+
diffs.slice(0, maxShown).map((diff, index) =>
|
|
353
|
+
renderSplit(diff, lg, previewLines, dc)
|
|
354
|
+
.then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
|
|
355
|
+
.catch(() => `Edit ${index + 1}/${operations.length} ${Ansi.summarize(diff.added, diff.removed)}`),
|
|
356
|
+
),
|
|
357
|
+
)
|
|
358
|
+
.then((sections) => {
|
|
359
|
+
if (ctx.state._pk !== pk) return;
|
|
360
|
+
const remainder = operations.length - maxShown;
|
|
361
|
+
const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
|
|
362
|
+
ctx.state._pt = `${hdr}\n${operations.length} edits ${summary}\n\n${sections.join("\n\n")}${suffix}`;
|
|
363
|
+
ctx.invalidate();
|
|
364
|
+
})
|
|
365
|
+
.catch(() => {
|
|
366
|
+
if (ctx.state._pk !== pk) return;
|
|
367
|
+
ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
|
|
368
|
+
ctx.invalidate();
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
text.setText(ctx.state._pt ?? hdr);
|
|
374
|
+
return text;
|
|
375
|
+
},
|
|
376
|
+
|
|
377
|
+
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
378
|
+
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
379
|
+
if (ctx.isError) {
|
|
380
|
+
const e = result.content
|
|
381
|
+
?.filter((c: any) => c.type === "text")
|
|
382
|
+
.map((c: any) => c.text || "")
|
|
383
|
+
.join("\n") ?? "Error";
|
|
384
|
+
text.setText(`\n${theme.fg("error", e)}`);
|
|
385
|
+
return text;
|
|
386
|
+
}
|
|
387
|
+
if (result.details?._type === "editInfo") {
|
|
388
|
+
const { summary: s, editLine } = result.details;
|
|
389
|
+
const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
|
|
390
|
+
const content = ` ${s}${loc}`;
|
|
391
|
+
const vis = content.replace(Ansi.ANSI_RE, "").length;
|
|
392
|
+
const pad = Math.max(0, (process.stdout.columns ?? 200) - vis);
|
|
393
|
+
text.setText(`${content}${" ".repeat(pad)}`);
|
|
394
|
+
return text;
|
|
395
|
+
}
|
|
396
|
+
if (result.details?._type === "multiEditInfo") {
|
|
397
|
+
const { summary: s, editCount, diffLineCount } = result.details;
|
|
398
|
+
const content = ` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`;
|
|
399
|
+
const vis = content.replace(Ansi.ANSI_RE, "").length;
|
|
400
|
+
const pad = Math.max(0, (process.stdout.columns ?? 200) - vis);
|
|
401
|
+
text.setText(`${content}${" ".repeat(pad)}`);
|
|
402
|
+
return text;
|
|
403
|
+
}
|
|
404
|
+
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
|
|
405
|
+
return text;
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
})().catch(console.error);
|
|
409
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
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
|
+
const MAX_PREVIEW_LINES = 60;
|
|
16
|
+
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
|
+
}
|
package/src/shiki.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Shiki syntax highlighting — lazy init, LRU cache, language detection. */
|
|
2
|
+
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { codeToANSI } from "@shikijs/cli";
|
|
5
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
6
|
+
|
|
7
|
+
import { normalizeShikiContrast } from "./ansi.js";
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Config access
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
let _getConfig: () => { diffTheme: string } = () => ({ diffTheme: "github-dark" });
|
|
14
|
+
export function setConfigGetter(fn: () => { diffTheme: string }): void {
|
|
15
|
+
_getConfig = fn;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Language detection
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const EXT_LANG: Record<string, BundledLanguage> = {
|
|
23
|
+
ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
|
|
24
|
+
mjs: "javascript", cjs: "javascript", py: "python", rb: "ruby",
|
|
25
|
+
rs: "rust", go: "go", java: "java", c: "c", cpp: "cpp",
|
|
26
|
+
h: "c", hpp: "cpp", cs: "csharp", swift: "swift", kt: "kotlin",
|
|
27
|
+
html: "html", css: "css", scss: "scss", json: "json",
|
|
28
|
+
yaml: "yaml", yml: "yaml", toml: "toml", md: "markdown",
|
|
29
|
+
sql: "sql", sh: "bash", bash: "bash", zsh: "bash",
|
|
30
|
+
lua: "lua", php: "php", dart: "dart", xml: "xml",
|
|
31
|
+
graphql: "graphql", svelte: "svelte", vue: "vue",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function lang(fp: string): BundledLanguage | undefined {
|
|
35
|
+
return EXT_LANG[extname(fp).slice(1).toLowerCase()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Shiki cache + lazy init
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const MAX_HL_CHARS = 80_000;
|
|
43
|
+
const CACHE_LIMIT = 192;
|
|
44
|
+
|
|
45
|
+
let _shikiReady = false;
|
|
46
|
+
async function ensureShiki(): Promise<void> {
|
|
47
|
+
if (_shikiReady) return;
|
|
48
|
+
_shikiReady = true;
|
|
49
|
+
codeToANSI("", "typescript", _getConfig().diffTheme as BundledTheme).catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const _cache = new Map<string, string[]>();
|
|
53
|
+
|
|
54
|
+
function _touch(k: string, v: string[]): string[] {
|
|
55
|
+
_cache.delete(k);
|
|
56
|
+
_cache.set(k, v);
|
|
57
|
+
while (_cache.size > CACHE_LIMIT) {
|
|
58
|
+
const first = _cache.keys().next().value;
|
|
59
|
+
if (first === undefined) break;
|
|
60
|
+
_cache.delete(first);
|
|
61
|
+
}
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function hlBlock(code: string, language: BundledLanguage | undefined): Promise<string[]> {
|
|
66
|
+
if (!code) return [""];
|
|
67
|
+
if (!language || code.length > MAX_HL_CHARS) return code.split("\n");
|
|
68
|
+
|
|
69
|
+
await ensureShiki();
|
|
70
|
+
|
|
71
|
+
const theme = _getConfig().diffTheme;
|
|
72
|
+
const k = `${theme}\0${language}\0${code}`;
|
|
73
|
+
const hit = _cache.get(k);
|
|
74
|
+
if (hit) return _touch(k, hit);
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const ansi = normalizeShikiContrast(await codeToANSI(code, language, theme as BundledTheme));
|
|
78
|
+
const out = (ansi.endsWith("\n") ? ansi.slice(0, -1) : ansi).split("\n");
|
|
79
|
+
return _touch(k, out);
|
|
80
|
+
} catch {
|
|
81
|
+
return code.split("\n");
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/word-diff.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Word diff analysis and background injection. */
|
|
2
|
+
|
|
3
|
+
import * as Diff from "diff";
|
|
4
|
+
import * as Ansi from "./ansi.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Word diff analysis
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Combined word diff analysis — single Diff.diffWords() call returns both
|
|
12
|
+
* similarity score and character ranges for emphasis highlighting.
|
|
13
|
+
*/
|
|
14
|
+
export function wordDiffAnalysis(
|
|
15
|
+
a: string,
|
|
16
|
+
b: string,
|
|
17
|
+
): {
|
|
18
|
+
similarity: number;
|
|
19
|
+
oldRanges: Array<[number, number]>;
|
|
20
|
+
newRanges: Array<[number, number]>;
|
|
21
|
+
} {
|
|
22
|
+
if (!a && !b) return { similarity: 1, oldRanges: [], newRanges: [] };
|
|
23
|
+
const parts = Diff.diffWords(a, b);
|
|
24
|
+
const oldRanges: Array<[number, number]> = [];
|
|
25
|
+
const newRanges: Array<[number, number]> = [];
|
|
26
|
+
let oPos = 0,
|
|
27
|
+
nPos = 0,
|
|
28
|
+
same = 0;
|
|
29
|
+
for (const p of parts) {
|
|
30
|
+
if (p.removed) {
|
|
31
|
+
oldRanges.push([oPos, oPos + p.value.length]);
|
|
32
|
+
oPos += p.value.length;
|
|
33
|
+
} else if (p.added) {
|
|
34
|
+
newRanges.push([nPos, nPos + p.value.length]);
|
|
35
|
+
nPos += p.value.length;
|
|
36
|
+
} else {
|
|
37
|
+
const len = p.value.length;
|
|
38
|
+
same += len;
|
|
39
|
+
oPos += len;
|
|
40
|
+
nPos += len;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const maxLen = Math.max(a.length, b.length);
|
|
44
|
+
return { similarity: maxLen > 0 ? same / maxLen : 1, oldRanges, newRanges };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Background injection
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inject diff background into Shiki ANSI output.
|
|
53
|
+
* `baseBg` on unchanged spans, `hlBg` on changed character ranges.
|
|
54
|
+
* Re-injects bg after any full reset (\x1b[0m).
|
|
55
|
+
*/
|
|
56
|
+
export function injectBg(
|
|
57
|
+
ansiLine: string,
|
|
58
|
+
ranges: Array<[number, number]>,
|
|
59
|
+
baseBg: string,
|
|
60
|
+
hlBg: string,
|
|
61
|
+
): string {
|
|
62
|
+
if (!ranges.length) return baseBg + ansiLine + Ansi.RST;
|
|
63
|
+
|
|
64
|
+
let out = baseBg;
|
|
65
|
+
let vis = 0;
|
|
66
|
+
let inHL = false;
|
|
67
|
+
let ri = 0;
|
|
68
|
+
let i = 0;
|
|
69
|
+
|
|
70
|
+
while (i < ansiLine.length) {
|
|
71
|
+
if (ansiLine[i] === "\x1b") {
|
|
72
|
+
const m = ansiLine.indexOf("m", i);
|
|
73
|
+
if (m !== -1) {
|
|
74
|
+
const seq = ansiLine.slice(i, m + 1);
|
|
75
|
+
out += seq;
|
|
76
|
+
if (seq === "\x1b[0m") out += inHL ? hlBg : baseBg;
|
|
77
|
+
i = m + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
while (ri < ranges.length && vis >= ranges[ri][1]) ri++;
|
|
82
|
+
const want = ri < ranges.length && vis >= ranges[ri][0] && vis < ranges[ri][1];
|
|
83
|
+
if (want !== inHL) {
|
|
84
|
+
inHL = want;
|
|
85
|
+
out += inHL ? hlBg : baseBg;
|
|
86
|
+
}
|
|
87
|
+
out += ansiLine[i];
|
|
88
|
+
vis++;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
return out + Ansi.RST;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Simple word diff (no syntax hl) — fallback when Shiki isn't available. */
|
|
95
|
+
export function plainWordDiff(oldText: string, newText: string): { old: string; new: string } {
|
|
96
|
+
const parts = Diff.diffWords(oldText, newText);
|
|
97
|
+
let o = "",
|
|
98
|
+
n = "";
|
|
99
|
+
for (const p of parts) {
|
|
100
|
+
if (p.removed) o += `${Ansi.BG_DEL_W}${p.value}${Ansi.RST}${Ansi.BG_DEL}`;
|
|
101
|
+
else if (p.added) n += `${Ansi.BG_ADD_W}${p.value}${Ansi.RST}${Ansi.BG_ADD}`;
|
|
102
|
+
else {
|
|
103
|
+
o += p.value;
|
|
104
|
+
n += p.value;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { old: o, new: n };
|
|
108
|
+
}
|