@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/src/index.ts
CHANGED
|
@@ -4,17 +4,21 @@
|
|
|
4
4
|
* Adapted from pi-ui-hephaestus diff renderer.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
8
7
|
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
10
9
|
|
|
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
10
|
import { setConfigGetter as setShikiConfig } from "./shiki.js";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
11
|
+
import { setConfigGetter as setRenderConfig } from "./render/index.js";
|
|
12
|
+
import { DiffComponent } from "./diff-component.js";
|
|
13
|
+
import type { DiffBg, DiffColors } from "./ansi/index.js";
|
|
14
|
+
import { registerWriteTool, registerEditTool } from "./tools/index.js";
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Re-exports
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
export { DiffComponent } from "./diff-component.js";
|
|
21
|
+
export type { DiffBg, DiffColors } from "./ansi/index.js";
|
|
18
22
|
|
|
19
23
|
// ---------------------------------------------------------------------------
|
|
20
24
|
// Config
|
|
@@ -96,301 +100,11 @@ export function registerDiffTools(
|
|
|
96
100
|
|
|
97
101
|
const cwd = process.cwd();
|
|
98
102
|
const home = process.env.HOME ?? "";
|
|
99
|
-
const sp = (p: string) => Ansi.shortPath(cwd, home, p);
|
|
100
|
-
|
|
101
|
-
// ===================================================================
|
|
102
|
-
// write tool
|
|
103
|
-
// ===================================================================
|
|
104
|
-
|
|
105
|
-
const origWrite = createWriteTool(cwd);
|
|
106
|
-
|
|
107
|
-
pi.registerTool({
|
|
108
|
-
...origWrite,
|
|
109
|
-
name: "write",
|
|
110
|
-
|
|
111
|
-
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
112
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
113
|
-
let old: string | null = null;
|
|
114
|
-
try {
|
|
115
|
-
if (fp && existsSync(fp)) old = readFileSync(fp, "utf-8");
|
|
116
|
-
} catch {
|
|
117
|
-
old = null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const result = await origWrite.execute(tid, params, sig, upd, ctx);
|
|
121
|
-
const content = params.content ?? "";
|
|
122
|
-
|
|
123
|
-
if (old !== null && old !== content) {
|
|
124
|
-
const diff = parseDiff(old, content);
|
|
125
|
-
const lg = lang(fp);
|
|
126
|
-
(result as any).details = {
|
|
127
|
-
_type: "diff",
|
|
128
|
-
summary: Ansi.summarize(diff.added, diff.removed),
|
|
129
|
-
diff,
|
|
130
|
-
language: lg,
|
|
131
|
-
};
|
|
132
|
-
} else if (old === null) {
|
|
133
|
-
const lineCount = content ? content.split("\n").length : 0;
|
|
134
|
-
(result as any).details = { _type: "new", lines: lineCount, content, filePath: fp };
|
|
135
|
-
} else if (old === content) {
|
|
136
|
-
(result as any).details = { _type: "noChange" };
|
|
137
|
-
}
|
|
138
|
-
return result;
|
|
139
|
-
},
|
|
140
|
-
|
|
141
|
-
renderCall(args: any, theme: any, ctx: any) {
|
|
142
|
-
const fp = args?.path ?? args?.file_path ?? "";
|
|
143
|
-
const isNew = !fp || !existsSync(fp);
|
|
144
|
-
const label = isNew ? "create" : "write";
|
|
145
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
146
|
-
const hdr = `${theme.fg("toolTitle", theme.bold(label))} ${theme.fg("accent", sp(fp))}`;
|
|
147
|
-
|
|
148
|
-
if (args?.content && !ctx.argsComplete) {
|
|
149
|
-
const n = String(args.content).split("\n").length;
|
|
150
|
-
text.setText(`${hdr} ${theme.fg("muted", `(${n} lines…)`)}`);
|
|
151
|
-
return text;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
if (args?.content && ctx.argsComplete && isNew) {
|
|
155
|
-
const previewKey = `create:${themeCacheKey(theme)}:${fp}:${String(args.content).length}`;
|
|
156
|
-
if (ctx.state._previewKey !== previewKey) {
|
|
157
|
-
ctx.state._previewKey = previewKey;
|
|
158
|
-
ctx.state._previewText = hdr;
|
|
159
|
-
const lg = lang(fp);
|
|
160
|
-
hlBlock(args.content, lg)
|
|
161
|
-
.then((lines: string[]) => {
|
|
162
|
-
if (ctx.state._previewKey !== previewKey) return;
|
|
163
|
-
const maxShow = ctx.expanded ? lines.length : 16;
|
|
164
|
-
const preview = lines.slice(0, maxShow).join("\n");
|
|
165
|
-
const rem = lines.length - maxShow;
|
|
166
|
-
let out = `${hdr}\n\n${preview}`;
|
|
167
|
-
if (rem > 0) out += `\n${theme.fg("muted", `… (${rem} more lines, ${lines.length} total)`)}`;
|
|
168
|
-
ctx.state._previewText = out;
|
|
169
|
-
ctx.invalidate();
|
|
170
|
-
})
|
|
171
|
-
.catch(() => {});
|
|
172
|
-
}
|
|
173
|
-
text.setText(ctx.state._previewText ?? hdr);
|
|
174
|
-
return text;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
text.setText(hdr);
|
|
178
|
-
return text;
|
|
179
|
-
},
|
|
180
|
-
|
|
181
|
-
renderResult(result: any, _opt: any, theme: any, ctx: any) {
|
|
182
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
183
|
-
if (ctx.isError) {
|
|
184
|
-
const e = result.content
|
|
185
|
-
?.filter((c: any) => c.type === "text")
|
|
186
|
-
.map((c: any) => c.text || "")
|
|
187
|
-
.join("\n") ?? "Error";
|
|
188
|
-
text.setText(`\n${theme.fg("error", e)}`);
|
|
189
|
-
return text;
|
|
190
|
-
}
|
|
191
|
-
const d = result.details;
|
|
192
|
-
if (d?._type === "diff") {
|
|
193
|
-
const w = process.stdout.columns ?? 200;
|
|
194
|
-
const key = `wd:${themeCacheKey(theme)}:${w}:${d.summary}:${d.diff?.lines?.length ?? 0}:${d.language ?? ""}`;
|
|
195
|
-
if (ctx.state._wdk !== key) {
|
|
196
|
-
ctx.state._wdk = key;
|
|
197
|
-
ctx.state._wdt = `${" " + d.summary}\n${theme.fg("muted", " rendering diff…")}`;
|
|
198
|
-
const dc = resolveDiffColors(theme);
|
|
199
|
-
renderSplit(d.diff, d.language, MAX_RENDER_LINES, dc)
|
|
200
|
-
.then((rendered: string) => {
|
|
201
|
-
if (ctx.state._wdk !== key) return;
|
|
202
|
-
ctx.state._wdt = `${" " + d.summary}\n${rendered}`;
|
|
203
|
-
ctx.invalidate();
|
|
204
|
-
})
|
|
205
|
-
.catch(() => {
|
|
206
|
-
if (ctx.state._wdk !== key) return;
|
|
207
|
-
ctx.state._wdt = ` ${d.summary}`;
|
|
208
|
-
ctx.invalidate();
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
text.setText(ctx.state._wdt ?? ` ${d.summary}`);
|
|
212
|
-
return text;
|
|
213
|
-
}
|
|
214
|
-
if (d?._type === "noChange") {
|
|
215
|
-
text.setText(` ${theme.fg("muted", "✓ no changes")}`);
|
|
216
|
-
return text;
|
|
217
|
-
}
|
|
218
|
-
if (d?._type === "new") {
|
|
219
|
-
const { lines: lineCount, content: rawContent, filePath: fp } = d;
|
|
220
|
-
const pk = `nf:${themeCacheKey(theme)}:${fp}:${lineCount}`;
|
|
221
|
-
if (ctx.state._nfk !== pk) {
|
|
222
|
-
ctx.state._nfk = pk;
|
|
223
|
-
ctx.state._nft = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`;
|
|
224
|
-
const lg = lang(fp);
|
|
225
|
-
if (rawContent) {
|
|
226
|
-
hlBlock(rawContent, lg)
|
|
227
|
-
.then((hlLines: string[]) => {
|
|
228
|
-
if (ctx.state._nfk !== pk) return;
|
|
229
|
-
const maxShow = ctx.expanded ? hlLines.length : 12;
|
|
230
|
-
const preview = hlLines.slice(0, maxShow).join("\n");
|
|
231
|
-
const rem = hlLines.length - maxShow;
|
|
232
|
-
let out = ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}\n${preview}`;
|
|
233
|
-
if (rem > 0) out += `\n${theme.fg("muted", ` … ${rem} more lines`)}`;
|
|
234
|
-
ctx.state._nft = out;
|
|
235
|
-
ctx.invalidate();
|
|
236
|
-
})
|
|
237
|
-
.catch(() => {});
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
text.setText(ctx.state._nft ?? ` ${theme.fg("success", `✓ new file (${lineCount} lines)`)}`);
|
|
241
|
-
return text;
|
|
242
|
-
}
|
|
243
|
-
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "written").slice(0, 120))}`);
|
|
244
|
-
return text;
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
// ===================================================================
|
|
249
|
-
// edit tool
|
|
250
|
-
// ===================================================================
|
|
251
|
-
|
|
252
|
-
const origEdit = createEditTool(cwd);
|
|
253
|
-
|
|
254
|
-
function getEditOperations(input: any): Array<{ oldText: string; newText: string }> {
|
|
255
|
-
if (Array.isArray(input?.edits)) {
|
|
256
|
-
return input.edits
|
|
257
|
-
.map((edit: any) => ({
|
|
258
|
-
oldText: typeof edit?.oldText === "string" ? edit.oldText : typeof edit?.old_text === "string" ? edit.old_text : "",
|
|
259
|
-
newText: typeof edit?.newText === "string" ? edit.newText : typeof edit?.new_text === "string" ? edit.new_text : "",
|
|
260
|
-
}))
|
|
261
|
-
.filter((edit: { oldText: string; newText: string }) => edit.oldText && edit.oldText !== edit.newText);
|
|
262
|
-
}
|
|
263
|
-
const oldText = typeof input?.oldText === "string" ? input.oldText : typeof input?.old_text === "string" ? input.old_text : "";
|
|
264
|
-
const newText = typeof input?.newText === "string" ? input.newText : typeof input?.new_text === "string" ? input.new_text : "";
|
|
265
|
-
return oldText && oldText !== newText ? [{ oldText, newText }] : [];
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function summarizeEditOperations(operations: Array<{ oldText: string; newText: string }>) {
|
|
269
|
-
const diffs = operations.map((edit) => parseDiff(edit.oldText, edit.newText));
|
|
270
|
-
const totalAdded = diffs.reduce((sum, diff) => sum + diff.added, 0);
|
|
271
|
-
const totalRemoved = diffs.reduce((sum, diff) => sum + diff.removed, 0);
|
|
272
|
-
return { diffs, totalAdded, totalRemoved, summary: Ansi.summarize(totalAdded, totalRemoved) };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
pi.registerTool({
|
|
276
|
-
...origEdit,
|
|
277
|
-
name: "edit",
|
|
278
|
-
|
|
279
|
-
async execute(tid: string, params: any, sig: any, upd: any, ctx: any) {
|
|
280
|
-
const fp = params.path ?? params.file_path ?? "";
|
|
281
|
-
const operations = getEditOperations(params);
|
|
282
|
-
const result = await origEdit.execute(tid, params, sig, upd, ctx);
|
|
283
|
-
|
|
284
|
-
if (operations.length === 0) return result;
|
|
285
|
-
|
|
286
|
-
const { diffs, summary } = summarizeEditOperations(operations);
|
|
287
|
-
if (operations.length === 1) {
|
|
288
|
-
let editLine = 0;
|
|
289
|
-
try {
|
|
290
|
-
if (fp && existsSync(fp)) {
|
|
291
|
-
const f = readFileSync(fp, "utf-8");
|
|
292
|
-
const idx = f.indexOf(operations[0]!.newText);
|
|
293
|
-
if (idx >= 0) editLine = f.slice(0, idx).split("\n").length;
|
|
294
|
-
}
|
|
295
|
-
} catch { editLine = 0; }
|
|
296
|
-
(result as any).details = { _type: "editInfo", summary, editLine };
|
|
297
|
-
return result;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
(result as any).details = {
|
|
301
|
-
_type: "multiEditInfo",
|
|
302
|
-
summary,
|
|
303
|
-
editCount: operations.length,
|
|
304
|
-
diffLineCount: diffs.reduce((sum, diff) => sum + diff.lines.length, 0),
|
|
305
|
-
};
|
|
306
|
-
return result;
|
|
307
|
-
},
|
|
308
|
-
|
|
309
|
-
renderCall(args: any, theme: any, ctx: any) {
|
|
310
|
-
const fp = args?.path ?? args?.file_path ?? "";
|
|
311
|
-
const operations = getEditOperations(args);
|
|
312
|
-
const text = ctx.lastComponent ?? new TextComponent("", 0, 0);
|
|
313
|
-
const hdr = `${theme.fg("toolTitle", theme.bold("edit"))} ${theme.fg("accent", sp(fp))}`;
|
|
314
|
-
|
|
315
|
-
if (!(ctx.argsComplete && operations.length > 0)) {
|
|
316
|
-
text.setText(hdr);
|
|
317
|
-
return text;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const pk = JSON.stringify({ fp, operations, theme: themeCacheKey(theme), w: process.stdout.columns ?? 200 });
|
|
321
|
-
if (ctx.state._pk !== pk) {
|
|
322
|
-
ctx.state._pk = pk;
|
|
323
|
-
ctx.state._pt = `${hdr} ${theme.fg("muted", "(rendering…)")}`;
|
|
324
|
-
const lg = lang(fp);
|
|
325
|
-
const dc = resolveDiffColors(theme);
|
|
326
|
-
|
|
327
|
-
if (operations.length === 1) {
|
|
328
|
-
const diff = parseDiff(operations[0]!.oldText, operations[0]!.newText);
|
|
329
|
-
renderSplit(diff, lg, MAX_PREVIEW_LINES, dc)
|
|
330
|
-
.then((rendered) => {
|
|
331
|
-
if (ctx.state._pk !== pk) return;
|
|
332
|
-
ctx.state._pt = `${hdr}\n${Ansi.summarize(diff.added, diff.removed)}\n${rendered}`;
|
|
333
|
-
ctx.invalidate();
|
|
334
|
-
})
|
|
335
|
-
.catch(() => {
|
|
336
|
-
if (ctx.state._pk !== pk) return;
|
|
337
|
-
ctx.state._pt = `${hdr} ${Ansi.summarize(diff.added, diff.removed)}`;
|
|
338
|
-
ctx.invalidate();
|
|
339
|
-
});
|
|
340
|
-
} else {
|
|
341
|
-
const { diffs, summary } = summarizeEditOperations(operations);
|
|
342
|
-
const maxShown = Math.min(operations.length, 3);
|
|
343
|
-
const previewLines = Math.max(8, Math.floor(MAX_PREVIEW_LINES / maxShown));
|
|
344
|
-
Promise.all(
|
|
345
|
-
diffs.slice(0, maxShown).map((diff, index) =>
|
|
346
|
-
renderSplit(diff, lg, previewLines, dc)
|
|
347
|
-
.then((rendered) => `Edit ${index + 1}/${operations.length}\n${rendered}`)
|
|
348
|
-
.catch(() => `Edit ${index + 1}/${operations.length} ${Ansi.summarize(diff.added, diff.removed)}`),
|
|
349
|
-
),
|
|
350
|
-
)
|
|
351
|
-
.then((sections) => {
|
|
352
|
-
if (ctx.state._pk !== pk) return;
|
|
353
|
-
const remainder = operations.length - maxShown;
|
|
354
|
-
const suffix = remainder > 0 ? `\n${theme.fg("muted", `… ${remainder} more edit blocks`)}` : "";
|
|
355
|
-
ctx.state._pt = `${hdr}\n${`${operations.length} edits ${summary}`}\n\n${sections.join("\n\n")}${suffix}`;
|
|
356
|
-
ctx.invalidate();
|
|
357
|
-
})
|
|
358
|
-
.catch(() => {
|
|
359
|
-
if (ctx.state._pk !== pk) return;
|
|
360
|
-
ctx.state._pt = `${hdr} ${operations.length} edits ${summary}`;
|
|
361
|
-
ctx.invalidate();
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
103
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
},
|
|
104
|
+
// Register write tool override
|
|
105
|
+
registerWriteTool(pi, cwd, home, createWriteTool, TextComponent);
|
|
369
106
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (ctx.isError) {
|
|
373
|
-
const e = result.content
|
|
374
|
-
?.filter((c: any) => c.type === "text")
|
|
375
|
-
.map((c: any) => c.text || "")
|
|
376
|
-
.join("\n") ?? "Error";
|
|
377
|
-
text.setText(`\n${theme.fg("error", e)}`);
|
|
378
|
-
return text;
|
|
379
|
-
}
|
|
380
|
-
if (result.details?._type === "editInfo") {
|
|
381
|
-
const { summary: s, editLine } = result.details;
|
|
382
|
-
const loc = editLine > 0 ? ` ${theme.fg("muted", `at line ${editLine}`)}` : "";
|
|
383
|
-
text.setText(` ${s}${loc}`);
|
|
384
|
-
return text;
|
|
385
|
-
}
|
|
386
|
-
if (result.details?._type === "multiEditInfo") {
|
|
387
|
-
const { summary: s, editCount, diffLineCount } = result.details;
|
|
388
|
-
text.setText(` ${editCount} edits ${s}${typeof diffLineCount === "number" ? ` ${theme.fg("muted", `(${diffLineCount} diff lines)`)}` : ""}`);
|
|
389
|
-
return text;
|
|
390
|
-
}
|
|
391
|
-
text.setText(` ${theme.fg("dim", String(result?.content?.[0]?.text ?? "edited").slice(0, 120))}`);
|
|
392
|
-
return text;
|
|
393
|
-
},
|
|
394
|
-
});
|
|
107
|
+
// Register edit tool override
|
|
108
|
+
registerEditTool(pi, cwd, home, createEditTool);
|
|
395
109
|
})().catch(console.error);
|
|
396
110
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Barrel — re-export all render modules. */
|
|
2
|
+
|
|
3
|
+
// Shared constants, config, and helpers
|
|
4
|
+
export {
|
|
5
|
+
MAX_PREVIEW_LINES,
|
|
6
|
+
MAX_RENDER_LINES,
|
|
7
|
+
MAX_HL_CHARS,
|
|
8
|
+
WORD_DIFF_MIN_SIM,
|
|
9
|
+
DEFAULT_TERM_WIDTH,
|
|
10
|
+
getConfig,
|
|
11
|
+
setConfigGetter,
|
|
12
|
+
adaptiveWrapRows,
|
|
13
|
+
wrapAnsi,
|
|
14
|
+
shouldUseSplit,
|
|
15
|
+
} from "./shared.js";
|
|
16
|
+
|
|
17
|
+
// Unified view
|
|
18
|
+
export { renderUnified, renderUnifiedLines } from "./unified.js";
|
|
19
|
+
|
|
20
|
+
// Split view
|
|
21
|
+
export { renderSplit, renderSplitLines } from "./split.js";
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/** Shared constants, config, and helpers for diff rendering. */
|
|
2
|
+
|
|
3
|
+
import * as Ansi from "../ansi/index.js";
|
|
4
|
+
import type { ParsedDiff } from "../core/diff.js";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Constants
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export const MAX_PREVIEW_LINES = 60;
|
|
11
|
+
export const MAX_RENDER_LINES = 150;
|
|
12
|
+
export const MAX_HL_CHARS = 80_000;
|
|
13
|
+
export const WORD_DIFF_MIN_SIM = 0.15;
|
|
14
|
+
const SPLIT_MAX_WRAP_RATIO = 0.2;
|
|
15
|
+
const SPLIT_MAX_WRAP_LINES = 8;
|
|
16
|
+
const MAX_WRAP_ROWS_WIDE = 3;
|
|
17
|
+
const MAX_WRAP_ROWS_MED = 2;
|
|
18
|
+
const MAX_WRAP_ROWS_NARROW = 1;
|
|
19
|
+
export const DEFAULT_TERM_WIDTH = 200;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Config
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export function getConfig(): { diffSplitMinWidth: number; diffSplitMinCodeWidth: number } {
|
|
26
|
+
return typeof _getConfig === "function" ? _getConfig() : DEFAULT_CONFIG;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CONFIG = { diffSplitMinWidth: 150, diffSplitMinCodeWidth: 60 };
|
|
30
|
+
let _getConfig: (() => { diffSplitMinWidth: number; diffSplitMinCodeWidth: number }) | undefined;
|
|
31
|
+
export function setConfigGetter(fn: () => { diffSplitMinWidth: number; diffSplitMinCodeWidth: number }): void {
|
|
32
|
+
_getConfig = fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function adaptiveWrapRows(w: number): number {
|
|
40
|
+
if (w >= 180) return MAX_WRAP_ROWS_WIDE;
|
|
41
|
+
if (w >= 120) return MAX_WRAP_ROWS_MED;
|
|
42
|
+
return MAX_WRAP_ROWS_NARROW;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wrap ANSI-encoded string into rows of `w` visible chars. */
|
|
46
|
+
export function wrapAnsi(s: string, w: number, maxRows = adaptiveWrapRows(w), fillBg = "", rst = Ansi.RST): string[] {
|
|
47
|
+
if (w <= 0) return [""];
|
|
48
|
+
const plain = Ansi.strip(s);
|
|
49
|
+
if (plain.length <= w) {
|
|
50
|
+
const pad = w - plain.length;
|
|
51
|
+
return pad > 0 ? [s + fillBg + " ".repeat(pad) + (fillBg ? rst : "")] : [s];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rows: string[] = [];
|
|
55
|
+
let row = "", vis = 0, i = 0;
|
|
56
|
+
let onLastRow = false;
|
|
57
|
+
let effW = w;
|
|
58
|
+
|
|
59
|
+
while (i < s.length) {
|
|
60
|
+
if (!onLastRow && rows.length >= maxRows - 1) {
|
|
61
|
+
onLastRow = true;
|
|
62
|
+
effW = w > 2 ? w - 1 : w;
|
|
63
|
+
}
|
|
64
|
+
if (s[i] === "\x1b") {
|
|
65
|
+
const end = s.indexOf("m", i);
|
|
66
|
+
if (end !== -1) {
|
|
67
|
+
row += s.slice(i, end + 1);
|
|
68
|
+
i = end + 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (vis >= effW) {
|
|
73
|
+
if (onLastRow) {
|
|
74
|
+
let hasMore = false;
|
|
75
|
+
for (let j = i; j < s.length; j++) {
|
|
76
|
+
if (s[j] === "\x1b") {
|
|
77
|
+
const e2 = s.indexOf("m", j);
|
|
78
|
+
if (e2 !== -1) { j = e2; continue; }
|
|
79
|
+
}
|
|
80
|
+
hasMore = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
if (hasMore && w > 2) row += `${rst}${Ansi.FG_DIM}›${rst}`;
|
|
84
|
+
else row += fillBg + " ".repeat(Math.max(0, w - vis)) + rst;
|
|
85
|
+
rows.push(row);
|
|
86
|
+
return rows;
|
|
87
|
+
}
|
|
88
|
+
const state = Ansi.ansiState(row);
|
|
89
|
+
rows.push(row + rst);
|
|
90
|
+
row = state + fillBg;
|
|
91
|
+
vis = 0;
|
|
92
|
+
if (rows.length >= maxRows - 1) {
|
|
93
|
+
onLastRow = true;
|
|
94
|
+
effW = w > 2 ? w - 1 : w;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
row += s[i];
|
|
98
|
+
vis++;
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
if (row.length > 0 || rows.length === 0) {
|
|
102
|
+
rows.push(row + fillBg + " ".repeat(Math.max(0, w - vis)) + rst);
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function shouldUseSplit(diff: ParsedDiff, tw: number, maxRows = MAX_PREVIEW_LINES): boolean {
|
|
108
|
+
if (!diff.lines.length) return false;
|
|
109
|
+
const cfg = getConfig();
|
|
110
|
+
if (tw < cfg.diffSplitMinWidth) return false;
|
|
111
|
+
|
|
112
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
113
|
+
const half = Math.floor((tw - 1) / 2);
|
|
114
|
+
const gw = nw + 5;
|
|
115
|
+
const cw = Math.max(12, half - gw);
|
|
116
|
+
if (cw < cfg.diffSplitMinCodeWidth) return false;
|
|
117
|
+
|
|
118
|
+
const vis = diff.lines.slice(0, maxRows);
|
|
119
|
+
let contentLines = 0, wrapCandidates = 0;
|
|
120
|
+
for (const l of vis) {
|
|
121
|
+
if (l.type === "sep") continue;
|
|
122
|
+
contentLines++;
|
|
123
|
+
if (Ansi.tabs(l.content).length > cw) wrapCandidates++;
|
|
124
|
+
}
|
|
125
|
+
if (contentLines === 0) return true;
|
|
126
|
+
const wrapRatio = wrapCandidates / contentLines;
|
|
127
|
+
if (wrapCandidates >= SPLIT_MAX_WRAP_LINES) return false;
|
|
128
|
+
if (wrapRatio >= SPLIT_MAX_WRAP_RATIO) return false;
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/** Split diff view rendering. */
|
|
2
|
+
|
|
3
|
+
import type { BundledLanguage } from "shiki";
|
|
4
|
+
import * as Ansi from "../ansi/index.js";
|
|
5
|
+
import type { DiffBg, DiffColors } from "../ansi/index.js";
|
|
6
|
+
import { DEFAULT_DIFF_COLORS, DEFAULT_DIFF_BG } from "../ansi/index.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
|
+
import {
|
|
11
|
+
MAX_HL_CHARS,
|
|
12
|
+
MAX_PREVIEW_LINES,
|
|
13
|
+
MAX_RENDER_LINES,
|
|
14
|
+
WORD_DIFF_MIN_SIM,
|
|
15
|
+
DEFAULT_TERM_WIDTH,
|
|
16
|
+
adaptiveWrapRows,
|
|
17
|
+
wrapAnsi,
|
|
18
|
+
shouldUseSplit,
|
|
19
|
+
} from "./shared.js";
|
|
20
|
+
import { renderUnifiedLines } from "./unified.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Split view
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Backward-compatible wrapper — reads width from stdout, returns joined string.
|
|
28
|
+
* Prefer `renderSplitLines` for Component-based rendering.
|
|
29
|
+
*/
|
|
30
|
+
export async function renderSplit(
|
|
31
|
+
diff: ParsedDiff,
|
|
32
|
+
language: BundledLanguage | undefined,
|
|
33
|
+
max = MAX_PREVIEW_LINES,
|
|
34
|
+
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
35
|
+
): Promise<string> {
|
|
36
|
+
const width = process.stdout.columns ?? DEFAULT_TERM_WIDTH;
|
|
37
|
+
// Build DiffBg from current (possibly theme-derived) global aliases.
|
|
38
|
+
const dbg: DiffBg = {
|
|
39
|
+
bgAdd: Ansi.BG_ADD,
|
|
40
|
+
bgDel: Ansi.BG_DEL,
|
|
41
|
+
bgAddW: Ansi.BG_ADD_W,
|
|
42
|
+
bgDelW: Ansi.BG_DEL_W,
|
|
43
|
+
bgGutterAdd: Ansi.BG_GUTTER_ADD,
|
|
44
|
+
bgGutterDel: Ansi.BG_GUTTER_DEL,
|
|
45
|
+
bgEmpty: Ansi.BG_EMPTY,
|
|
46
|
+
bgBase: Ansi.BG_BASE,
|
|
47
|
+
rst: Ansi.BG_BASE === "\x1b[49m" ? "\x1b[0m" : `\x1b[0m${Ansi.BG_BASE}`,
|
|
48
|
+
divider: Ansi.DIVIDER,
|
|
49
|
+
};
|
|
50
|
+
const lines = await renderSplitLines(diff, language, width, max, dc, dbg);
|
|
51
|
+
return lines.join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function renderSplitLines(
|
|
55
|
+
diff: ParsedDiff,
|
|
56
|
+
language: BundledLanguage | undefined,
|
|
57
|
+
width: number,
|
|
58
|
+
max = MAX_PREVIEW_LINES,
|
|
59
|
+
dc: DiffColors = DEFAULT_DIFF_COLORS,
|
|
60
|
+
dbg: DiffBg = DEFAULT_DIFF_BG,
|
|
61
|
+
): Promise<string[]> {
|
|
62
|
+
const rst = dbg.rst;
|
|
63
|
+
const bgBase = dbg.bgBase;
|
|
64
|
+
|
|
65
|
+
if (!shouldUseSplit(diff, width, max)) return renderUnifiedLines(diff, language, width, max, dc, dbg);
|
|
66
|
+
if (!diff.lines.length) return [];
|
|
67
|
+
|
|
68
|
+
type Row = { left: DiffLine | null; right: DiffLine | null };
|
|
69
|
+
const rows: Row[] = [];
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < diff.lines.length) {
|
|
72
|
+
const l = diff.lines[i]!;
|
|
73
|
+
if (l.type === "sep" || l.type === "ctx") { rows.push({ left: l, right: l }); i++; continue; }
|
|
74
|
+
const dels: DiffLine[] = [], adds: DiffLine[] = [];
|
|
75
|
+
while (i < diff.lines.length && diff.lines[i]!.type === "del") { dels.push(diff.lines[i]!); i++; }
|
|
76
|
+
while (i < diff.lines.length && diff.lines[i]!.type === "add") { adds.push(diff.lines[i]!); i++; }
|
|
77
|
+
const n = Math.max(dels.length, adds.length);
|
|
78
|
+
for (let j = 0; j < n; j++) rows.push({ left: dels[j] ?? null, right: adds[j] ?? null });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const vis = rows.slice(0, max);
|
|
82
|
+
const half = Math.floor((width - 1) / 2);
|
|
83
|
+
const nw = Math.max(2, String(Math.max(...diff.lines.map((l) => l.oldNum ?? l.newNum ?? 0), 0)).length);
|
|
84
|
+
const gw = nw + 5;
|
|
85
|
+
const cw = Math.max(12, half - gw);
|
|
86
|
+
const canHL = diff.chars <= MAX_HL_CHARS && vis.length * 2 <= MAX_RENDER_LINES * 2;
|
|
87
|
+
|
|
88
|
+
const leftSrc: string[] = [], rightSrc: string[] = [];
|
|
89
|
+
for (const r of vis) {
|
|
90
|
+
if (r.left && r.left.type !== "sep") leftSrc.push(r.left.content);
|
|
91
|
+
if (r.right && r.right.type !== "sep") rightSrc.push(r.right.content);
|
|
92
|
+
}
|
|
93
|
+
const [leftHL, rightHL] = canHL
|
|
94
|
+
? await Promise.all([hlBlock(leftSrc.join("\n"), language), hlBlock(rightSrc.join("\n"), language)])
|
|
95
|
+
: [leftSrc, rightSrc];
|
|
96
|
+
|
|
97
|
+
let lI = 0, rI = 0;
|
|
98
|
+
let stripeRow = 0;
|
|
99
|
+
|
|
100
|
+
type HalfResult = { gutter: string; contGutter: string; bodyRows: string[] };
|
|
101
|
+
|
|
102
|
+
function half_build(
|
|
103
|
+
line: DiffLine | null, hl: string, ranges: Array<[number, number]> | null, side: "left" | "right",
|
|
104
|
+
): HalfResult {
|
|
105
|
+
if (!line) {
|
|
106
|
+
const gw2 = nw + 2;
|
|
107
|
+
const gPat = Ansi.FG_STRIPE + "╱".repeat(gw2) + rst;
|
|
108
|
+
const g = ` ${gPat}${Ansi.FG_RULE}│${rst} `;
|
|
109
|
+
return { gutter: g, contGutter: g, bodyRows: [Ansi.stripes(dbg, cw, stripeRow)] };
|
|
110
|
+
}
|
|
111
|
+
if (line.type === "sep") {
|
|
112
|
+
const gap = line.newNum;
|
|
113
|
+
const label = gap && gap > 0 ? `··· ${gap} lines ···` : "···";
|
|
114
|
+
const g = `${bgBase} ${Ansi.FG_DIM}${Ansi.fit("", nw + 2)}${rst}${Ansi.FG_RULE}│${rst} `;
|
|
115
|
+
return { gutter: g, contGutter: g, bodyRows: [`${bgBase}${Ansi.FG_DIM}${Ansi.fit(label, cw)}${rst}`] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isDel = line.type === "del", isAdd = line.type === "add";
|
|
119
|
+
const gBg = isDel ? dbg.bgGutterDel : isAdd ? dbg.bgGutterAdd : bgBase;
|
|
120
|
+
const cBg = isDel ? dbg.bgDel : isAdd ? dbg.bgAdd : bgBase;
|
|
121
|
+
const sFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : dc.fgCtx;
|
|
122
|
+
const sign = isDel ? "-" : isAdd ? "+" : " ";
|
|
123
|
+
const num = isDel ? line.oldNum : isAdd ? line.newNum : side === "left" ? line.oldNum : line.newNum;
|
|
124
|
+
|
|
125
|
+
const borderFg = isDel ? dc.fgDel : isAdd ? dc.fgAdd : "";
|
|
126
|
+
const border = borderFg ? `${borderFg}${Ansi.BORDER_BAR}${rst}` : ` ${bgBase}`;
|
|
127
|
+
const numFg = borderFg || Ansi.FG_LNUM;
|
|
128
|
+
|
|
129
|
+
let body: string;
|
|
130
|
+
if (ranges && ranges.length > 0) {
|
|
131
|
+
body = injectBg(hl, ranges, cBg, isDel ? dbg.bgDelW : dbg.bgAddW);
|
|
132
|
+
} else if (isDel || isAdd) {
|
|
133
|
+
body = `${cBg}${hl}`;
|
|
134
|
+
} else {
|
|
135
|
+
body = `${bgBase}${Ansi.DIM}${hl}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const gutter = `${border}${gBg}${Ansi.lnum(num, nw, numFg)}${sFg}${Ansi.BOLD}${sign}${rst} ${Ansi.FG_RULE}│${rst} `;
|
|
139
|
+
const contGutter = `${border}${gBg}${" ".repeat(nw + 1)}${rst} ${Ansi.FG_RULE}│${rst} `;
|
|
140
|
+
const bodyRows = wrapAnsi(Ansi.tabs(body), cw, adaptiveWrapRows(cw), cBg, rst);
|
|
141
|
+
return { gutter, contGutter, bodyRows };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const out: string[] = [];
|
|
145
|
+
const hdrOld = `${bgBase}${" ".repeat(Math.max(0, nw - 2))}${dc.fgDel}${Ansi.DIM}old${rst}`;
|
|
146
|
+
const hdrNew = `${bgBase}${" ".repeat(Math.max(0, nw - 2))}${dc.fgAdd}${Ansi.DIM}new${rst}`;
|
|
147
|
+
out.push(`${bgBase}${hdrOld}${" ".repeat(Math.max(0, half - nw - 1))}${Ansi.FG_RULE}┊${rst}${hdrNew}`);
|
|
148
|
+
out.push(`${Ansi.rule(dbg, half)}${Ansi.FG_RULE}┊${rst}${Ansi.rule(dbg, half)}`);
|
|
149
|
+
|
|
150
|
+
for (const r of vis) {
|
|
151
|
+
const leftLine = r.left, rightLine = r.right;
|
|
152
|
+
const paired = leftLine && rightLine && leftLine.type === "del" && rightLine.type === "add";
|
|
153
|
+
const wd = paired ? wordDiffAnalysis(leftLine.content, rightLine.content) : null;
|
|
154
|
+
|
|
155
|
+
let lResult: HalfResult, rResult: HalfResult;
|
|
156
|
+
|
|
157
|
+
if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && canHL) {
|
|
158
|
+
const lhl = leftHL[lI++] ?? leftLine.content;
|
|
159
|
+
const rhl = rightHL[rI++] ?? rightLine.content;
|
|
160
|
+
lResult = half_build(leftLine, lhl, wd.oldRanges, "left");
|
|
161
|
+
rResult = half_build(rightLine, rhl, wd.newRanges, "right");
|
|
162
|
+
} else if (paired && wd && wd.similarity >= WORD_DIFF_MIN_SIM && !canHL) {
|
|
163
|
+
const pwd = plainWordDiff(leftLine.content, rightLine.content);
|
|
164
|
+
lI++; rI++;
|
|
165
|
+
lResult = half_build(leftLine, pwd.old, null, "left");
|
|
166
|
+
rResult = half_build(rightLine, pwd.new, null, "right");
|
|
167
|
+
} else {
|
|
168
|
+
const lhl = leftLine && leftLine.type !== "sep" ? (leftHL[lI++] ?? leftLine?.content ?? "") : "";
|
|
169
|
+
const rhl = rightLine && rightLine.type !== "sep" ? (rightHL[rI++] ?? rightLine?.content ?? "") : "";
|
|
170
|
+
lResult = half_build(leftLine, lhl, null, "left");
|
|
171
|
+
rResult = half_build(rightLine, rhl, null, "right");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const maxRows = Math.max(lResult.bodyRows.length, rResult.bodyRows.length);
|
|
175
|
+
const leftIsEmpty = !r.left;
|
|
176
|
+
const rightIsEmpty = !r.right;
|
|
177
|
+
for (let row = 0; row < maxRows; row++) {
|
|
178
|
+
const lg = row === 0 ? lResult.gutter : lResult.contGutter;
|
|
179
|
+
const rg = row === 0 ? rResult.gutter : rResult.contGutter;
|
|
180
|
+
const lb = lResult.bodyRows[row] ?? (leftIsEmpty ? Ansi.stripes(dbg, cw, stripeRow) : `${dbg.bgEmpty}${" ".repeat(cw)}${rst}`);
|
|
181
|
+
const rb = rResult.bodyRows[row] ?? (rightIsEmpty ? Ansi.stripes(dbg, cw, stripeRow) : `${dbg.bgEmpty}${" ".repeat(cw)}${rst}`);
|
|
182
|
+
out.push(`${lg}${lb}${dbg.divider}${rg}${rb}`);
|
|
183
|
+
stripeRow++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
out.push(`${Ansi.rule(dbg, half)}${Ansi.FG_RULE}┊${rst}${Ansi.rule(dbg, half)}`);
|
|
188
|
+
if (rows.length > vis.length) {
|
|
189
|
+
out.push(`${bgBase}${Ansi.FG_DIM} … ${rows.length - vis.length} more lines${rst}`);
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|