@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.
- package/CHANGELOG.md +138 -0
- package/package.json +1 -1
- package/plugins/config-schema.json +48 -6
- package/plugins/diagnostics_panel.ts +4 -0
- package/plugins/diff_nav.ts +20 -1
- package/plugins/find_references.ts +4 -0
- package/plugins/flash.ts +11 -3
- package/plugins/git_explorer.ts +9 -1
- package/plugins/lib/finder.ts +81 -10
- package/plugins/lib/fresh.d.ts +40 -4
- package/plugins/live_diff.i18n.json +450 -0
- package/plugins/live_diff.ts +946 -0
- package/plugins/live_grep.i18n.json +42 -14
- package/plugins/live_grep.ts +491 -41
- package/plugins/markdown_compose.ts +17 -3
- package/plugins/schemas/theme.schema.json +510 -12
- package/plugins/search_replace.ts +5 -0
- package/plugins/theme_editor.i18n.json +224 -0
- package/plugins/theme_editor.ts +58 -50
- package/plugins/tsconfig.json +1 -0
- package/themes/dark.json +4 -0
- package/themes/dracula.json +2 -0
- package/themes/high-contrast.json +4 -0
- package/themes/light.json +4 -0
- package/themes/nord.json +7 -0
- package/themes/nostalgia.json +4 -0
- package/themes/solarized-dark.json +7 -0
- package/themes/terminal.json +128 -0
|
@@ -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");
|