@fresh-editor/fresh-editor 0.3.1 → 0.3.4

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.
@@ -0,0 +1,946 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+ /**
5
+ * Live Diff Plugin
6
+ *
7
+ * Renders a unified-diff view directly inside the live editable buffer:
8
+ * - `-`/`+`/`~` indicators in the gutter for changed lines
9
+ * - virtual lines containing the OLD content rendered above edited lines
10
+ * - background highlight on added/modified new-side lines
11
+ *
12
+ * Target use case: a coding agent (or any background process) is modifying
13
+ * the file on disk while the user watches. `after_insert` / `after_delete`
14
+ * fire when Fresh reloads the buffer from disk, so the diff updates live.
15
+ *
16
+ * The diff reference (left side) is selectable per buffer via the
17
+ * command palette:
18
+ * - Live Diff: vs HEAD — git HEAD revision (default)
19
+ * - Live Diff: vs Disk — file content currently on disk
20
+ * - Live Diff: vs Branch... — user-supplied git ref
21
+ * - Live Diff: vs Default Branch — origin/HEAD or main/master
22
+ * - Live Diff: Toggle — disable/enable for the active buffer
23
+ * - Live Diff: Refresh — re-fetch reference and recompute
24
+ * - Live Diff: Set Default Mode... — pick the default for new buffers
25
+ */
26
+
27
+ // =============================================================================
28
+ // Constants
29
+ // =============================================================================
30
+
31
+ const NS_GUTTER = "live-diff";
32
+ const NS_VLINE = "live-diff-vlines";
33
+ const NS_OVERLAY = "live-diff-overlay";
34
+
35
+ // Lower priority than git_gutter (10) so live-diff loses if both are active
36
+ // on the same line — but in practice users will run one or the other.
37
+ const PRIORITY = 9;
38
+
39
+ // Theme keys for backgrounds and virtual-line foregrounds. These are
40
+ // resolved at render time by the editor, so the diff colors track
41
+ // the active theme automatically. All bundled themes provide
42
+ // `editor.diff_*_bg` (defaulted via serde) and `ui.file_status_*_fg`
43
+ // (falls through to `diagnostic.{info,warning,error}_fg` when the
44
+ // theme doesn't override).
45
+ const THEME = {
46
+ addedBg: "editor.diff_add_bg",
47
+ addedFg: "ui.file_status_added_fg",
48
+ modifiedBg: "editor.diff_modify_bg",
49
+ modifiedFg: "ui.file_status_modified_fg",
50
+ removedBg: "editor.diff_remove_bg",
51
+ removedFg: "ui.file_status_deleted_fg",
52
+ };
53
+
54
+ // `setLineIndicator` only accepts RGB triples (not theme keys), so the
55
+ // gutter glyphs use a fixed palette. Keep them muted so they read on
56
+ // both light and dark themes; the visual signal is the glyph shape.
57
+ const GUTTER_COLORS = {
58
+ added: [80, 200, 120] as [number, number, number],
59
+ modified: [220, 160, 90] as [number, number, number],
60
+ removed: [220, 90, 90] as [number, number, number],
61
+ };
62
+ const SYMBOLS = {
63
+ added: "+",
64
+ modified: "~",
65
+ removed: "-",
66
+ };
67
+
68
+ // Coalesce edit bursts (agent paste, undo, editor reload) into one
69
+ // recompute. Token-bumped delay loop, mirrors git_log.ts's CURSOR_DEBOUNCE_MS.
70
+ const DEBOUNCE_MS = 75;
71
+
72
+ // Skip virtual-line rendering when either side is huge — line-by-line
73
+ // LCS would be too slow. Gutter glyphs still render via a degraded path.
74
+ const MAX_DIFF_LINES = 20_000;
75
+ // Soft cap on the LCS DP table; past this we stop computing virtual lines.
76
+ const MAX_DP_CELLS = 4_000_000;
77
+
78
+ // =============================================================================
79
+ // Types
80
+ // =============================================================================
81
+
82
+ type DiffMode =
83
+ | { kind: "head" }
84
+ | { kind: "disk" }
85
+ | { kind: "branch"; ref: string };
86
+
87
+ type HunkKind = "added" | "removed" | "modified";
88
+
89
+ interface Hunk {
90
+ kind: HunkKind;
91
+ /** First changed new-side line (0-indexed). */
92
+ newStart: number;
93
+ /** Number of new-side lines (0 for pure deletion). */
94
+ newCount: number;
95
+ /** Old-side text, line by line, no trailing newline. */
96
+ oldLines: string[];
97
+ }
98
+
99
+ interface BufferDiffState {
100
+ bufferId: number;
101
+ filePath: string;
102
+ mode: DiffMode;
103
+ /** Reference text. `null` while loading or when no reference is available. */
104
+ oldText: string | null;
105
+ /** Pre-split cached lines from `oldText` to skip resplit on every keystroke. */
106
+ oldLines: string[];
107
+ /** Most recent hunks, published to view state for diff_nav.ts. */
108
+ hunks: Hunk[];
109
+ /** True while a recompute is in flight. */
110
+ updating: boolean;
111
+ /** Token bumped on every scheduleRecompute; mismatched tokens are stale. */
112
+ pendingToken: number;
113
+ /**
114
+ * Per-buffer enable override. `null` means "follow the global toggle";
115
+ * `true` forces live-diff on for this buffer regardless of the global
116
+ * setting; `false` forces it off. Set by `Live Diff: Toggle (Buffer)`.
117
+ */
118
+ override: boolean | null;
119
+ /**
120
+ * Last buffer text we ran the diff against. `lines_changed` fires for
121
+ * viewport scrolls too — comparing the text catches those cheaply and
122
+ * skips the expensive clear-and-repaint that caused flicker on cursor
123
+ * movement.
124
+ */
125
+ lastBufferText: string | null;
126
+ /**
127
+ * Stringified hunks from the previous successful render. When a
128
+ * recompute produces an identical structure we skip the redraw to
129
+ * avoid a clear-then-set flash even when the buffer itself did
130
+ * change (e.g., the user typed inside an already-modified line).
131
+ */
132
+ lastHunksKey: string;
133
+ }
134
+
135
+ const states: Map<number, BufferDiffState> = new Map();
136
+
137
+ // =============================================================================
138
+ // Persistence helpers
139
+ // =============================================================================
140
+
141
+ function getDefaultMode(): DiffMode {
142
+ const stored = editor.getGlobalState("live_diff.default_mode") as DiffMode | null;
143
+ if (stored && (stored.kind === "head" || stored.kind === "disk" || stored.kind === "branch")) {
144
+ return stored;
145
+ }
146
+ return { kind: "head" };
147
+ }
148
+
149
+ function setDefaultMode(mode: DiffMode): void {
150
+ editor.setGlobalState("live_diff.default_mode", mode);
151
+ }
152
+
153
+ function getStoredMode(bufferId: number): DiffMode | null {
154
+ const stored = editor.getViewState(bufferId, "live_diff.mode") as DiffMode | null;
155
+ if (stored && (stored.kind === "head" || stored.kind === "disk" || stored.kind === "branch")) {
156
+ return stored;
157
+ }
158
+ return null;
159
+ }
160
+
161
+ function storeMode(bufferId: number, mode: DiffMode): void {
162
+ editor.setViewState(bufferId, "live_diff.mode", mode);
163
+ }
164
+
165
+ // Plugin is opt-in: `live_diff.global_enabled` defaults to false. Users
166
+ // flip it via "Live Diff: Toggle (Global)" or override per buffer with
167
+ // "Live Diff: Toggle (Buffer)".
168
+ function isGlobalEnabled(): boolean {
169
+ return editor.getGlobalState("live_diff.global_enabled") === true;
170
+ }
171
+
172
+ function setGlobalEnabled(enabled: boolean): void {
173
+ editor.setGlobalState("live_diff.global_enabled", enabled);
174
+ }
175
+
176
+ function getStoredOverride(bufferId: number): boolean | null {
177
+ const stored = editor.getViewState(bufferId, "live_diff.override");
178
+ if (stored === true || stored === false) return stored;
179
+ return null;
180
+ }
181
+
182
+ function storeOverride(bufferId: number, override: boolean | null): void {
183
+ editor.setViewState(bufferId, "live_diff.override", override);
184
+ }
185
+
186
+ function isEnabledForBuffer(state: BufferDiffState): boolean {
187
+ if (state.override !== null) return state.override;
188
+ return isGlobalEnabled();
189
+ }
190
+
191
+ // =============================================================================
192
+ // Reference loading
193
+ // =============================================================================
194
+
195
+ function fileDir(filePath: string): string {
196
+ const lastSlash = filePath.lastIndexOf("/");
197
+ return lastSlash > 0 ? filePath.substring(0, lastSlash) : ".";
198
+ }
199
+
200
+ async function repoRelativePath(filePath: string): Promise<string | null> {
201
+ const cwd = fileDir(filePath);
202
+ const result = await editor.spawnProcess(
203
+ "git", ["ls-files", "--full-name", "--", filePath], cwd,
204
+ );
205
+ if (result.exit_code !== 0) return null;
206
+ const path = result.stdout.split("\n")[0]?.trim();
207
+ return path && path.length > 0 ? path : null;
208
+ }
209
+
210
+ async function loadHeadRef(filePath: string): Promise<string | null> {
211
+ const repoPath = await repoRelativePath(filePath);
212
+ if (!repoPath) return null;
213
+ const cwd = fileDir(filePath);
214
+ const result = await editor.spawnProcess(
215
+ "git", ["show", `HEAD:${repoPath}`], cwd,
216
+ );
217
+ return result.exit_code === 0 ? result.stdout : null;
218
+ }
219
+
220
+ async function loadBranchRef(filePath: string, ref: string): Promise<string | null> {
221
+ const repoPath = await repoRelativePath(filePath);
222
+ if (!repoPath) return null;
223
+ const cwd = fileDir(filePath);
224
+ const result = await editor.spawnProcess(
225
+ "git", ["show", `${ref}:${repoPath}`], cwd,
226
+ );
227
+ return result.exit_code === 0 ? result.stdout : null;
228
+ }
229
+
230
+ function loadDiskRef(filePath: string): string | null {
231
+ return editor.readFile(filePath);
232
+ }
233
+
234
+ async function resolveDefaultBranch(filePath: string): Promise<string> {
235
+ const cwd = fileDir(filePath);
236
+ const head = await editor.spawnProcess(
237
+ "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd,
238
+ );
239
+ if (head.exit_code === 0) {
240
+ const trimmed = head.stdout.trim();
241
+ if (trimmed.startsWith("origin/")) return trimmed.substring("origin/".length);
242
+ if (trimmed.length > 0) return trimmed;
243
+ }
244
+ const main = await editor.spawnProcess(
245
+ "git", ["rev-parse", "--verify", "main"], cwd,
246
+ );
247
+ if (main.exit_code === 0) return "main";
248
+ return "master";
249
+ }
250
+
251
+ async function loadReference(state: BufferDiffState): Promise<string | null> {
252
+ switch (state.mode.kind) {
253
+ case "head":
254
+ return await loadHeadRef(state.filePath);
255
+ case "disk":
256
+ return loadDiskRef(state.filePath);
257
+ case "branch":
258
+ return await loadBranchRef(state.filePath, state.mode.ref);
259
+ }
260
+ }
261
+
262
+ // =============================================================================
263
+ // Line diff (LCS, with prefix/suffix stripping for speed)
264
+ // =============================================================================
265
+
266
+ interface DiffOp {
267
+ /** "=" equal, "-" delete (old only), "+" insert (new only). */
268
+ op: "=" | "-" | "+";
269
+ /** 0-indexed line in the old file (for "=" and "-"). */
270
+ oldLine: number;
271
+ /** 0-indexed line in the new file (for "=" and "+"). */
272
+ newLine: number;
273
+ }
274
+
275
+ function splitLines(text: string): string[] {
276
+ // Preserve empty trailing line semantics: "foo\n" -> ["foo"], "" -> [].
277
+ if (text.length === 0) return [];
278
+ const lines = text.split("\n");
279
+ if (lines.length > 0 && lines[lines.length - 1] === "") {
280
+ lines.pop();
281
+ }
282
+ return lines;
283
+ }
284
+
285
+ /**
286
+ * Line-level LCS diff. Returns ops in old/new order. Bails (returns null)
287
+ * when the DP table would exceed MAX_DP_CELLS — caller falls back to a
288
+ * coarser representation.
289
+ */
290
+ function lineDiff(oldLines: string[], newLines: string[]): DiffOp[] | null {
291
+ let prefix = 0;
292
+ const minLen = Math.min(oldLines.length, newLines.length);
293
+ while (prefix < minLen && oldLines[prefix] === newLines[prefix]) prefix++;
294
+
295
+ let oldEnd = oldLines.length;
296
+ let newEnd = newLines.length;
297
+ while (oldEnd > prefix && newEnd > prefix && oldLines[oldEnd - 1] === newLines[newEnd - 1]) {
298
+ oldEnd--;
299
+ newEnd--;
300
+ }
301
+
302
+ const ops: DiffOp[] = [];
303
+ for (let i = 0; i < prefix; i++) {
304
+ ops.push({ op: "=", oldLine: i, newLine: i });
305
+ }
306
+
307
+ const m = oldEnd - prefix;
308
+ const n = newEnd - prefix;
309
+
310
+ if (m === 0 && n === 0) {
311
+ // Pure prefix; tail equal-block follows below.
312
+ } else if (m === 0) {
313
+ for (let j = 0; j < n; j++) {
314
+ ops.push({ op: "+", oldLine: prefix, newLine: prefix + j });
315
+ }
316
+ } else if (n === 0) {
317
+ for (let i = 0; i < m; i++) {
318
+ ops.push({ op: "-", oldLine: prefix + i, newLine: prefix });
319
+ }
320
+ } else {
321
+ if ((m + 1) * (n + 1) > MAX_DP_CELLS) return null;
322
+
323
+ // dp[(i)*(n+1) + j] = LCS length of oldMid[0..i] vs newMid[0..j].
324
+ // Plain Array — QuickJS doesn't expose typed arrays in this runtime.
325
+ const stride = n + 1;
326
+ const dp: number[] = new Array((m + 1) * stride).fill(0);
327
+ for (let i = 1; i <= m; i++) {
328
+ const oi = oldLines[prefix + i - 1];
329
+ for (let j = 1; j <= n; j++) {
330
+ if (oi === newLines[prefix + j - 1]) {
331
+ dp[i * stride + j] = dp[(i - 1) * stride + (j - 1)] + 1;
332
+ } else {
333
+ const a = dp[(i - 1) * stride + j];
334
+ const b = dp[i * stride + (j - 1)];
335
+ dp[i * stride + j] = a >= b ? a : b;
336
+ }
337
+ }
338
+ }
339
+
340
+ // Backtrack — push ops in reverse, then reverse at the end of this block.
341
+ const middle: DiffOp[] = [];
342
+ let i = m;
343
+ let j = n;
344
+ while (i > 0 && j > 0) {
345
+ if (oldLines[prefix + i - 1] === newLines[prefix + j - 1]) {
346
+ middle.push({ op: "=", oldLine: prefix + i - 1, newLine: prefix + j - 1 });
347
+ i--;
348
+ j--;
349
+ } else if (dp[(i - 1) * stride + j] >= dp[i * stride + (j - 1)]) {
350
+ middle.push({ op: "-", oldLine: prefix + i - 1, newLine: prefix + j });
351
+ i--;
352
+ } else {
353
+ middle.push({ op: "+", oldLine: prefix + i, newLine: prefix + j - 1 });
354
+ j--;
355
+ }
356
+ }
357
+ while (i > 0) {
358
+ middle.push({ op: "-", oldLine: prefix + i - 1, newLine: prefix });
359
+ i--;
360
+ }
361
+ while (j > 0) {
362
+ middle.push({ op: "+", oldLine: prefix + i, newLine: prefix + j - 1 });
363
+ j--;
364
+ }
365
+ middle.reverse();
366
+ for (const m of middle) ops.push(m);
367
+ }
368
+
369
+ for (let i = 0; i < oldLines.length - oldEnd; i++) {
370
+ ops.push({ op: "=", oldLine: oldEnd + i, newLine: newEnd + i });
371
+ }
372
+
373
+ return ops;
374
+ }
375
+
376
+ /**
377
+ * Group a diff-op stream into hunks. Adjacent `-` and `+` runs collapse into
378
+ * a single `modified` hunk so the old line renders directly above the new one.
379
+ */
380
+ function opsToHunks(ops: DiffOp[]): Hunk[] {
381
+ const hunks: Hunk[] = [];
382
+ let i = 0;
383
+ while (i < ops.length) {
384
+ if (ops[i].op === "=") {
385
+ i++;
386
+ continue;
387
+ }
388
+ let dels = 0;
389
+ let ins = 0;
390
+ const oldLines: string[] = [];
391
+ let firstNew = ops[i].newLine;
392
+ while (i < ops.length && ops[i].op !== "=") {
393
+ if (ops[i].op === "-") {
394
+ dels++;
395
+ } else {
396
+ ins++;
397
+ }
398
+ i++;
399
+ }
400
+ // Walk back over the run we just consumed to capture old-side text and
401
+ // the first new-side line, since op order may interleave.
402
+ const start = i - (dels + ins);
403
+ firstNew = ops[start].newLine;
404
+ for (let k = start; k < i; k++) {
405
+ const o = ops[k];
406
+ if (o.op === "+") firstNew = Math.min(firstNew, o.newLine);
407
+ }
408
+ // We don't carry old-side text on DiffOp (memory), so look it up later.
409
+ // Stash indices for now; the caller resolves text from `oldLines[]`.
410
+ const kind: HunkKind = dels > 0 && ins > 0 ? "modified" : ins > 0 ? "added" : "removed";
411
+ hunks.push({
412
+ kind,
413
+ newStart: firstNew,
414
+ newCount: ins,
415
+ // oldLines populated by the caller from the source array; placeholder:
416
+ oldLines: [],
417
+ });
418
+ // Save indices so we can fill oldLines outside.
419
+ (hunks[hunks.length - 1] as Hunk & { _oldStart?: number; _oldEnd?: number })._oldStart = ops[start].oldLine;
420
+ (hunks[hunks.length - 1] as Hunk & { _oldStart?: number; _oldEnd?: number })._oldEnd = ops[start].oldLine + dels;
421
+ }
422
+ return hunks;
423
+ }
424
+
425
+ function fillOldLines(hunks: Hunk[], oldLines: string[]): void {
426
+ for (const h of hunks) {
427
+ const meta = h as Hunk & { _oldStart?: number; _oldEnd?: number };
428
+ const s = meta._oldStart ?? 0;
429
+ const e = meta._oldEnd ?? 0;
430
+ h.oldLines = oldLines.slice(s, e);
431
+ delete meta._oldStart;
432
+ delete meta._oldEnd;
433
+ }
434
+ }
435
+
436
+ // =============================================================================
437
+ // Rendering
438
+ // =============================================================================
439
+
440
+ function clearDecorations(bufferId: number): void {
441
+ editor.clearLineIndicators(bufferId, NS_GUTTER);
442
+ editor.clearVirtualTextNamespace(bufferId, NS_VLINE);
443
+ editor.clearNamespace(bufferId, NS_OVERLAY);
444
+ }
445
+
446
+ /**
447
+ * Compute byte offsets of every line start in the buffer (one entry per
448
+ * line, plus one past-the-end entry) so renderHunks can map line indices
449
+ * to byte ranges synchronously, without awaiting `getLineStartPosition`
450
+ * per line.
451
+ *
452
+ * `getLineStartPosition` is async and yields back to the editor event
453
+ * loop on every call. With one await per overlay we add, the editor
454
+ * renders frames mid-render and the user sees green stripes fill in one
455
+ * line at a time. Computing locally from the buffer text keeps the
456
+ * whole render in a single JS turn → instant repaint.
457
+ *
458
+ * Uses `editor.utf8ByteLength` once per *whole* line (the
459
+ * `fresh.d.ts`-documented helper for converting JS UTF-16 string
460
+ * lengths to UTF-8 byte counts). Calling it per character would be
461
+ * incorrect because `text[i]` splits a surrogate pair into invalid
462
+ * half-code-units; passing whole lines is safe — `splitLines` always
463
+ * returns valid Unicode strings.
464
+ */
465
+ function computeLineByteStarts(lines: string[]): number[] {
466
+ const starts: number[] = new Array(lines.length + 1);
467
+ let pos = 0;
468
+ starts[0] = 0;
469
+ for (let i = 0; i < lines.length; i++) {
470
+ pos += editor.utf8ByteLength(lines[i]) + 1; // +1 for the trailing newline
471
+ starts[i + 1] = pos;
472
+ }
473
+ return starts;
474
+ }
475
+
476
+ function renderHunks(state: BufferDiffState, newLines: string[]): void {
477
+ const bid = state.bufferId;
478
+ clearDecorations(bid);
479
+
480
+ const lineStarts = computeLineByteStarts(newLines);
481
+ const totalBytes = lineStarts[lineStarts.length - 1] || 0;
482
+ // For line N, lineStarts[N] = byte of first char on line N. lineEnd
483
+ // before the trailing newline = lineStarts[N+1] - 1 (when a newline
484
+ // follows) or totalBytes when N is the last line. Empty/last-line
485
+ // edge cases default to lineStarts[N].
486
+ const lineEndExclusive = (line: number): number => {
487
+ if (line + 1 < lineStarts.length) return lineStarts[line + 1] - 1;
488
+ return totalBytes;
489
+ };
490
+ const lineCount = lineStarts.length;
491
+
492
+ // Group new-side lines per kind for batched setLineIndicators.
493
+ const addedLines: number[] = [];
494
+ const modifiedLines: number[] = [];
495
+ const removedAnchors: number[] = [];
496
+
497
+ for (const h of state.hunks) {
498
+ if (h.kind === "removed") {
499
+ // Anchor on the line that took the deletion's place. If newStart
500
+ // is past EOF, step back to the last real line.
501
+ let anchor = h.newStart;
502
+ if (anchor >= lineCount) anchor = Math.max(0, lineCount - 1);
503
+ removedAnchors.push(anchor);
504
+ } else if (h.kind === "added") {
505
+ for (let i = 0; i < h.newCount; i++) addedLines.push(h.newStart + i);
506
+ } else {
507
+ for (let i = 0; i < h.newCount; i++) modifiedLines.push(h.newStart + i);
508
+ }
509
+ }
510
+
511
+ if (addedLines.length > 0) {
512
+ editor.setLineIndicators(
513
+ bid, addedLines, NS_GUTTER, SYMBOLS.added,
514
+ GUTTER_COLORS.added[0], GUTTER_COLORS.added[1], GUTTER_COLORS.added[2], PRIORITY,
515
+ );
516
+ }
517
+ if (modifiedLines.length > 0) {
518
+ editor.setLineIndicators(
519
+ bid, modifiedLines, NS_GUTTER, SYMBOLS.modified,
520
+ GUTTER_COLORS.modified[0], GUTTER_COLORS.modified[1], GUTTER_COLORS.modified[2], PRIORITY,
521
+ );
522
+ }
523
+ if (removedAnchors.length > 0) {
524
+ editor.setLineIndicators(
525
+ bid, removedAnchors, NS_GUTTER, SYMBOLS.removed,
526
+ GUTTER_COLORS.removed[0], GUTTER_COLORS.removed[1], GUTTER_COLORS.removed[2], PRIORITY,
527
+ );
528
+ }
529
+
530
+ // Background highlights and virtual lines, all sync now.
531
+ for (const h of state.hunks) {
532
+ if (h.kind === "added" || h.kind === "modified") {
533
+ const bg = h.kind === "added" ? THEME.addedBg : THEME.modifiedBg;
534
+ for (let i = 0; i < h.newCount; i++) {
535
+ const line = h.newStart + i;
536
+ if (line >= lineCount) break;
537
+ const start = lineStarts[line];
538
+ let end = lineEndExclusive(line);
539
+ // Empty source lines have lineEndExclusive == lineStart. A
540
+ // zero-width overlay never enters the renderer's byte sweep
541
+ // (the chars iter has no chars to advance over), so the
542
+ // extend_to_line_end fill never fires for empty lines and the
543
+ // user sees a "skipped" row in the middle of an added block.
544
+ // Bump the end by one so the range covers the trailing
545
+ // newline byte; the sweep advances at the next non-empty line
546
+ // and catches our overlay.
547
+ if (end <= start) end = start + 1;
548
+ editor.addOverlay(bid, NS_OVERLAY, start, end, {
549
+ bg,
550
+ underline: false,
551
+ bold: false,
552
+ italic: false,
553
+ strikethrough: false,
554
+ extendToLineEnd: true,
555
+ });
556
+ }
557
+ }
558
+
559
+ if (h.oldLines.length === 0) continue;
560
+
561
+ // Anchor: line that follows the deletion on the new side. If past
562
+ // EOF, anchor on the last real line and place "below".
563
+ let anchorLine = h.newStart;
564
+ let above = true;
565
+ if (anchorLine >= lineCount) {
566
+ anchorLine = Math.max(0, lineCount - 1);
567
+ above = false;
568
+ }
569
+ const anchor = lineStarts[anchorLine];
570
+
571
+ for (let i = 0; i < h.oldLines.length; i++) {
572
+ // No "- " prefix — the red bg/fg is the visual signal, and the user
573
+ // prefers any "-" indicator to live in the gutter rather than
574
+ // inside the buffer content.
575
+ editor.addVirtualLine(
576
+ bid,
577
+ anchor,
578
+ h.oldLines[i],
579
+ {
580
+ fg: THEME.removedFg,
581
+ bg: THEME.removedBg,
582
+ },
583
+ above,
584
+ NS_VLINE,
585
+ i,
586
+ );
587
+ }
588
+ }
589
+ }
590
+
591
+ // =============================================================================
592
+ // Recompute pipeline
593
+ // =============================================================================
594
+
595
+ async function recompute(bufferId: number): Promise<void> {
596
+ const state = states.get(bufferId);
597
+ if (!state) return;
598
+ if (!isEnabledForBuffer(state)) return;
599
+ if (state.updating) return;
600
+
601
+ state.updating = true;
602
+ try {
603
+ if (state.oldText === null) {
604
+ const ref = await loadReference(state);
605
+ if (ref === null) {
606
+ // Reference fetch failed (file untracked, no repo, etc.).
607
+ clearDecorations(bufferId);
608
+ state.hunks = [];
609
+ editor.setViewState(bufferId, "live_diff_hunks", null);
610
+ return;
611
+ }
612
+ state.oldText = ref;
613
+ state.oldLines = splitLines(ref);
614
+ }
615
+
616
+ const length = editor.getBufferLength(bufferId);
617
+ const newText = await editor.getBufferText(bufferId, 0, length);
618
+
619
+ // Skip 1: same buffer text as last recompute. `lines_changed` fires
620
+ // on viewport scrolls (cursor up/down past the visible area), and
621
+ // re-clearing then re-painting the same decorations causes a
622
+ // visible flash on the highlighted lines. The string comparison is
623
+ // microseconds for typical source files; we only fall through when
624
+ // the buffer actually changed.
625
+ if (state.lastBufferText === newText) {
626
+ return;
627
+ }
628
+ state.lastBufferText = newText;
629
+
630
+ const newLines = splitLines(newText);
631
+
632
+ if (state.oldLines.length > MAX_DIFF_LINES || newLines.length > MAX_DIFF_LINES) {
633
+ // Files too large for line-level diff. Don't render anything; surface
634
+ // a status so the user knows why the gutter is empty.
635
+ clearDecorations(bufferId);
636
+ state.hunks = [];
637
+ state.lastHunksKey = "";
638
+ editor.setViewState(bufferId, "live_diff_hunks", null);
639
+ editor.setStatus(editor.t("status.too_large"));
640
+ return;
641
+ }
642
+
643
+ const ops = lineDiff(state.oldLines, newLines);
644
+ if (ops === null) {
645
+ clearDecorations(bufferId);
646
+ state.hunks = [];
647
+ state.lastHunksKey = "";
648
+ editor.setViewState(bufferId, "live_diff_hunks", null);
649
+ editor.setStatus(editor.t("status.too_large"));
650
+ return;
651
+ }
652
+
653
+ const hunks = opsToHunks(ops);
654
+ fillOldLines(hunks, state.oldLines);
655
+
656
+ // Skip 2: same hunks as last render. The user can edit inside an
657
+ // already-flagged region without changing line counts (e.g., typing
658
+ // mid-word on a modified line). Without this guard we still
659
+ // clear+repaint each keystroke, producing visible flicker.
660
+ const hunksKey = JSON.stringify(hunks);
661
+ if (hunksKey === state.lastHunksKey) {
662
+ state.hunks = hunks;
663
+ return;
664
+ }
665
+ state.hunks = hunks;
666
+ state.lastHunksKey = hunksKey;
667
+
668
+ renderHunks(state, newLines);
669
+
670
+ editor.setViewState(bufferId, "live_diff_hunks", hunks);
671
+ } finally {
672
+ state.updating = false;
673
+ }
674
+ }
675
+
676
+ async function scheduleRecompute(bufferId: number): Promise<void> {
677
+ const state = states.get(bufferId);
678
+ if (!state) return;
679
+ const myToken = ++state.pendingToken;
680
+ await editor.delay(DEBOUNCE_MS);
681
+ if (myToken !== state.pendingToken) return;
682
+ await recompute(bufferId);
683
+ }
684
+
685
+ // =============================================================================
686
+ // State helpers
687
+ // =============================================================================
688
+
689
+ function ensureState(bufferId: number): BufferDiffState | null {
690
+ const existing = states.get(bufferId);
691
+ if (existing) return existing;
692
+
693
+ const info = editor.getBufferInfo(bufferId);
694
+ if (!info) return null;
695
+ if (info.is_virtual) return null;
696
+ if (!info.path || info.path.length === 0) return null;
697
+
698
+ const mode = getStoredMode(bufferId) ?? getDefaultMode();
699
+ const state: BufferDiffState = {
700
+ bufferId,
701
+ filePath: info.path,
702
+ mode,
703
+ oldText: null,
704
+ oldLines: [],
705
+ hunks: [],
706
+ updating: false,
707
+ pendingToken: 0,
708
+ override: getStoredOverride(bufferId),
709
+ lastBufferText: null,
710
+ lastHunksKey: "",
711
+ };
712
+ states.set(bufferId, state);
713
+ return state;
714
+ }
715
+
716
+ function dropReference(state: BufferDiffState): void {
717
+ state.oldText = null;
718
+ state.oldLines = [];
719
+ // Force the next recompute to repaint even if the buffer itself
720
+ // hasn't changed (mode swap rebuilds against a new reference).
721
+ state.lastBufferText = null;
722
+ state.lastHunksKey = "";
723
+ }
724
+
725
+ async function setMode(bufferId: number, mode: DiffMode): Promise<void> {
726
+ const state = ensureState(bufferId);
727
+ if (!state) return;
728
+ state.mode = mode;
729
+ // Choosing a comparison reference is a clear "I want to see the diff"
730
+ // signal — force-on for this buffer so the command works even when the
731
+ // global toggle is off.
732
+ state.override = true;
733
+ storeOverride(bufferId, true);
734
+ storeMode(bufferId, mode);
735
+ dropReference(state);
736
+ await recompute(bufferId);
737
+ }
738
+
739
+ // =============================================================================
740
+ // Commands
741
+ // =============================================================================
742
+
743
+ /**
744
+ * Reflect the current effective enabled state for a buffer in the
745
+ * editor: paint or clear decorations and (re)compute as needed.
746
+ * Called from both toggle commands.
747
+ */
748
+ function syncBufferToEnabledState(state: BufferDiffState): void {
749
+ if (isEnabledForBuffer(state)) {
750
+ recompute(state.bufferId).catch((e) => editor.error(`live-diff: ${e}`));
751
+ } else {
752
+ clearDecorations(state.bufferId);
753
+ state.hunks = [];
754
+ state.lastBufferText = null;
755
+ state.lastHunksKey = "";
756
+ editor.setViewState(state.bufferId, "live_diff_hunks", null);
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Toggle the per-buffer override for the active buffer. Sets the
762
+ * override to the opposite of the buffer's current effective state, so
763
+ * one invocation always flips what the user sees on screen.
764
+ */
765
+ function live_diff_toggle_buffer(): void {
766
+ const bid = editor.getActiveBufferId();
767
+ const state = ensureState(bid);
768
+ if (!state) {
769
+ editor.setStatus(editor.t("status.no_file"));
770
+ return;
771
+ }
772
+ const newEnabled = !isEnabledForBuffer(state);
773
+ state.override = newEnabled;
774
+ storeOverride(bid, newEnabled);
775
+ syncBufferToEnabledState(state);
776
+ editor.setStatus(editor.t(newEnabled ? "status.buffer_enabled" : "status.buffer_disabled"));
777
+ }
778
+ registerHandler("live_diff_toggle_buffer", live_diff_toggle_buffer);
779
+
780
+ /**
781
+ * Toggle the global enable flag. Refreshes every tracked buffer that
782
+ * doesn't have its own override set so the change is visible immediately.
783
+ */
784
+ function live_diff_toggle_global(): void {
785
+ const newEnabled = !isGlobalEnabled();
786
+ setGlobalEnabled(newEnabled);
787
+ for (const state of states.values()) {
788
+ if (state.override === null) {
789
+ syncBufferToEnabledState(state);
790
+ }
791
+ }
792
+ editor.setStatus(editor.t(newEnabled ? "status.global_enabled" : "status.global_disabled"));
793
+ }
794
+ registerHandler("live_diff_toggle_global", live_diff_toggle_global);
795
+
796
+ async function live_diff_vs_head(): Promise<void> {
797
+ await setMode(editor.getActiveBufferId(), { kind: "head" });
798
+ editor.setStatus(editor.t("status.mode_head"));
799
+ }
800
+ registerHandler("live_diff_vs_head", live_diff_vs_head);
801
+
802
+ async function live_diff_vs_disk(): Promise<void> {
803
+ await setMode(editor.getActiveBufferId(), { kind: "disk" });
804
+ editor.setStatus(editor.t("status.mode_disk"));
805
+ }
806
+ registerHandler("live_diff_vs_disk", live_diff_vs_disk);
807
+
808
+ async function live_diff_vs_branch(): Promise<void> {
809
+ const last = (editor.getGlobalState("live_diff.last_branch") as string | null) ?? "main";
810
+ const ref = await editor.prompt(editor.t("prompt.branch"), last);
811
+ if (!ref || ref.trim().length === 0) return;
812
+ const trimmed = ref.trim();
813
+ editor.setGlobalState("live_diff.last_branch", trimmed);
814
+ await setMode(editor.getActiveBufferId(), { kind: "branch", ref: trimmed });
815
+ editor.setStatus(editor.t("status.mode_branch", { ref: trimmed }));
816
+ }
817
+ registerHandler("live_diff_vs_branch", live_diff_vs_branch);
818
+
819
+ async function live_diff_vs_default_branch(): Promise<void> {
820
+ const bid = editor.getActiveBufferId();
821
+ const path = editor.getBufferPath(bid);
822
+ if (!path) {
823
+ editor.setStatus(editor.t("status.no_file"));
824
+ return;
825
+ }
826
+ const ref = await resolveDefaultBranch(path);
827
+ await setMode(bid, { kind: "branch", ref });
828
+ editor.setStatus(editor.t("status.mode_branch", { ref }));
829
+ }
830
+ registerHandler("live_diff_vs_default_branch", live_diff_vs_default_branch);
831
+
832
+ async function live_diff_refresh(): Promise<void> {
833
+ const bid = editor.getActiveBufferId();
834
+ const state = ensureState(bid);
835
+ if (!state) {
836
+ editor.setStatus(editor.t("status.no_file"));
837
+ return;
838
+ }
839
+ dropReference(state);
840
+ await recompute(bid);
841
+ editor.setStatus(editor.t("status.refreshed"));
842
+ }
843
+ registerHandler("live_diff_refresh", live_diff_refresh);
844
+
845
+ async function live_diff_set_default(): Promise<void> {
846
+ const choice = await editor.prompt(editor.t("prompt.default_mode"), "head");
847
+ if (!choice) return;
848
+ const c = choice.trim().toLowerCase();
849
+ if (c === "head") setDefaultMode({ kind: "head" });
850
+ else if (c === "disk") setDefaultMode({ kind: "disk" });
851
+ else if (c.startsWith("branch:")) setDefaultMode({ kind: "branch", ref: c.substring("branch:".length) });
852
+ else {
853
+ editor.setStatus(editor.t("status.bad_default"));
854
+ return;
855
+ }
856
+ editor.setStatus(editor.t("status.default_set"));
857
+ }
858
+ registerHandler("live_diff_set_default", live_diff_set_default);
859
+
860
+ // =============================================================================
861
+ // Event wiring
862
+ // =============================================================================
863
+
864
+ editor.on("after_file_open", (args) => {
865
+ const state = ensureState(args.buffer_id);
866
+ if (!state) return true;
867
+ recompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
868
+ return true;
869
+ });
870
+
871
+ editor.on("buffer_activated", (args) => {
872
+ const state = ensureState(args.buffer_id);
873
+ if (!state) return true;
874
+ // Indicators stick around across activations; only repaint if we never
875
+ // ran a first pass (e.g. plugin loaded after the buffer opened).
876
+ if (state.hunks.length === 0 && state.oldText === null) {
877
+ recompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
878
+ }
879
+ return true;
880
+ });
881
+
882
+ editor.on("after_insert", (args) => {
883
+ if (!states.has(args.buffer_id)) return true;
884
+ scheduleRecompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
885
+ return true;
886
+ });
887
+
888
+ editor.on("after_delete", (args) => {
889
+ if (!states.has(args.buffer_id)) return true;
890
+ scheduleRecompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
891
+ return true;
892
+ });
893
+
894
+ // `lines_changed` fires on every visible-line redraw, including the ones
895
+ // driven by Fresh's external-file-watch reload (which doesn't go through
896
+ // after_insert/after_delete). This is the hook that makes the live-diff
897
+ // view update when a coding agent rewrites the file on disk.
898
+ editor.on("lines_changed", (args) => {
899
+ if (!states.has(args.buffer_id)) return true;
900
+ scheduleRecompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
901
+ return true;
902
+ });
903
+
904
+ editor.on("after_file_save", (args) => {
905
+ const state = states.get(args.buffer_id);
906
+ if (!state) return true;
907
+ // Save changes the file path (save-as) and invalidates the disk-mode reference.
908
+ state.filePath = args.path;
909
+ if (state.mode.kind === "disk") {
910
+ dropReference(state);
911
+ }
912
+ recompute(args.buffer_id).catch((e) => editor.error(`live-diff: ${e}`));
913
+ return true;
914
+ });
915
+
916
+ editor.on("buffer_closed", (args) => {
917
+ states.delete(args.buffer_id);
918
+ return true;
919
+ });
920
+
921
+ // =============================================================================
922
+ // Command registration
923
+ // =============================================================================
924
+
925
+ editor.registerCommand("%cmd.toggle_global", "%cmd.toggle_global_desc", "live_diff_toggle_global", null);
926
+ editor.registerCommand("%cmd.toggle_buffer", "%cmd.toggle_buffer_desc", "live_diff_toggle_buffer", null);
927
+ editor.registerCommand("%cmd.vs_head", "%cmd.vs_head_desc", "live_diff_vs_head", null);
928
+ editor.registerCommand("%cmd.vs_disk", "%cmd.vs_disk_desc", "live_diff_vs_disk", null);
929
+ editor.registerCommand("%cmd.vs_branch", "%cmd.vs_branch_desc", "live_diff_vs_branch", null);
930
+ editor.registerCommand("%cmd.vs_default_branch", "%cmd.vs_default_branch_desc", "live_diff_vs_default_branch", null);
931
+ editor.registerCommand("%cmd.refresh", "%cmd.refresh_desc", "live_diff_refresh", null);
932
+ editor.registerCommand("%cmd.set_default", "%cmd.set_default_desc", "live_diff_set_default", null);
933
+
934
+ // =============================================================================
935
+ // Initialization
936
+ // =============================================================================
937
+
938
+ const initBid = editor.getActiveBufferId();
939
+ if (initBid !== 0) {
940
+ const state = ensureState(initBid);
941
+ if (state) {
942
+ recompute(initBid).catch((e) => editor.error(`live-diff: ${e}`));
943
+ }
944
+ }
945
+
946
+ editor.debug("Live Diff plugin loaded");