@fresh-editor/fresh-editor 0.3.6 → 0.3.7
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 +91 -0
- package/package.json +1 -1
- package/plugins/audit_mode.i18n.json +84 -0
- package/plugins/audit_mode.ts +139 -3
- package/plugins/config-schema.json +27 -3
- package/plugins/dashboard.ts +18 -18
- package/plugins/flash.ts +22 -4
- package/plugins/git_blame.ts +10 -6
- package/plugins/git_log.ts +534 -124
- package/plugins/git_statusbar.i18n.json +72 -0
- package/plugins/git_statusbar.ts +133 -0
- package/plugins/lib/fresh.d.ts +305 -6
- package/plugins/lib/widgets.ts +111 -4
- package/plugins/live_diff.ts +156 -41
- package/plugins/merge_conflict.ts +89 -64
- package/plugins/orchestrator.ts +1982 -242
- package/plugins/pkg.ts +1 -1
- package/plugins/schemas/theme.schema.json +14 -0
- package/plugins/search_replace.i18n.json +140 -28
- package/plugins/search_replace.ts +674 -117
- package/plugins/tab_actions.i18n.json +212 -0
- package/plugins/tab_actions.ts +76 -0
- package/plugins/theme_editor.i18n.json +28 -0
- package/plugins/tsconfig.json +1 -0
- package/plugins/vi_mode.ts +11 -0
- package/themes/dark.json +1 -0
- package/themes/dracula.json +1 -0
- package/themes/high-contrast.json +1 -0
- package/themes/light.json +1 -0
- package/themes/nord.json +1 -0
- package/themes/nostalgia.json +1 -0
- package/themes/solarized-dark.json +1 -0
- package/themes/terminal.json +1 -0
package/plugins/git_log.ts
CHANGED
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
type GitCommit,
|
|
5
|
-
buildCommitDetailEntries,
|
|
6
5
|
buildCommitLogEntries,
|
|
7
|
-
buildDetailPlaceholderEntries,
|
|
8
|
-
fetchCommitShow,
|
|
9
6
|
fetchGitLog,
|
|
10
7
|
} from "./lib/git_history.ts";
|
|
11
8
|
import { button, flexSpacer, key, list, row, WidgetPanel } from "./lib/index.ts";
|
|
@@ -36,32 +33,42 @@ interface GitLogState {
|
|
|
36
33
|
isOpen: boolean;
|
|
37
34
|
groupId: number | null;
|
|
38
35
|
logBufferId: number | null;
|
|
36
|
+
/**
|
|
37
|
+
* The buffer-group's initial detail panel buffer (a virtual buffer
|
|
38
|
+
* created by `createBufferGroup`). After the first commit is shown,
|
|
39
|
+
* the panel is retargeted at a file-backed streaming buffer via
|
|
40
|
+
* `setBufferGroupPanelBuffer`; this id is kept so we can close the
|
|
41
|
+
* orphaned virtual buffer on group teardown.
|
|
42
|
+
*/
|
|
43
|
+
initialDetailBufferId: number | null;
|
|
44
|
+
/**
|
|
45
|
+
* The buffer id currently displayed in the detail panel (one
|
|
46
|
+
* file-backed buffer per visited commit). Tracked for focus checks
|
|
47
|
+
* and cursor placement.
|
|
48
|
+
*/
|
|
39
49
|
detailBufferId: number | null;
|
|
40
50
|
toolbarBufferId: number | null;
|
|
41
|
-
/** Widget panel rendering the toolbar (Row of Buttons).
|
|
42
|
-
* `show_git_log` once the buffer group exists; cleaned up in
|
|
43
|
-
* `git_log_close`. */
|
|
51
|
+
/** Widget panel rendering the toolbar (Row of Buttons). */
|
|
44
52
|
toolbarPanel: WidgetPanel | null;
|
|
45
|
-
/** Widget panel rendering the log (List of commit rows).
|
|
46
|
-
* `selected_index` + `scroll_offset` as instance state — the
|
|
47
|
-
* plugin's `state.selectedIndex` mirrors what the host reports
|
|
48
|
-
* via `widget_event "select"`. */
|
|
53
|
+
/** Widget panel rendering the log (List of commit rows). */
|
|
49
54
|
logPanel: WidgetPanel | null;
|
|
50
55
|
commits: GitCommit[];
|
|
51
56
|
selectedIndex: number;
|
|
52
|
-
/** Cached `git show` output for the currently-displayed detail commit. */
|
|
53
|
-
detailCache: { hash: string; output: string } | null;
|
|
54
57
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
58
|
+
* Per-commit cache: sha → file-backed buffer id. Each visited
|
|
59
|
+
* commit gets its own buffer pointing at `<dataDir>/git-show/<sha>.diff`,
|
|
60
|
+
* which a background `git show --patch` writes into. Returning to a
|
|
61
|
+
* cached commit is just a `setBufferGroupPanelBuffer` call — no
|
|
62
|
+
* git invocation, scroll position preserved.
|
|
57
63
|
*/
|
|
58
|
-
|
|
64
|
+
commitBuffers: Map<string, number>;
|
|
65
|
+
/** sha → in-flight spawnProcess handle, for kill-on-supersession. */
|
|
66
|
+
inFlightSpawns: Map<string, ProcessHandle<SpawnResult>>;
|
|
59
67
|
/**
|
|
60
68
|
* Debounce token for List `select` events. Rapid selection moves
|
|
61
|
-
* (PageDown, held j/k)
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* has arrived.
|
|
69
|
+
* (PageDown, held j/k) shouldn't churn through buffer swaps + spawns;
|
|
70
|
+
* we bump this id on every event and only do the work after a short
|
|
71
|
+
* delay if no newer event has arrived.
|
|
65
72
|
*/
|
|
66
73
|
pendingSelectId: number;
|
|
67
74
|
}
|
|
@@ -70,14 +77,15 @@ const state: GitLogState = {
|
|
|
70
77
|
isOpen: false,
|
|
71
78
|
groupId: null,
|
|
72
79
|
logBufferId: null,
|
|
80
|
+
initialDetailBufferId: null,
|
|
73
81
|
detailBufferId: null,
|
|
74
82
|
toolbarBufferId: null,
|
|
75
83
|
toolbarPanel: null,
|
|
76
84
|
logPanel: null,
|
|
77
85
|
commits: [],
|
|
78
86
|
selectedIndex: 0,
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
commitBuffers: new Map(),
|
|
88
|
+
inFlightSpawns: new Map(),
|
|
81
89
|
pendingSelectId: 0,
|
|
82
90
|
};
|
|
83
91
|
|
|
@@ -140,14 +148,14 @@ function git_log_select_page_up(): void {
|
|
|
140
148
|
if (isLogPanelActive()) {
|
|
141
149
|
state.logPanel?.command(key("PageUp"));
|
|
142
150
|
} else {
|
|
143
|
-
editor.executeAction("
|
|
151
|
+
editor.executeAction("move_page_up");
|
|
144
152
|
}
|
|
145
153
|
}
|
|
146
154
|
function git_log_select_page_down(): void {
|
|
147
155
|
if (isLogPanelActive()) {
|
|
148
156
|
state.logPanel?.command(key("PageDown"));
|
|
149
157
|
} else {
|
|
150
|
-
editor.executeAction("
|
|
158
|
+
editor.executeAction("move_page_down");
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
161
|
|
|
@@ -318,82 +326,259 @@ function renderLog(): void {
|
|
|
318
326
|
);
|
|
319
327
|
}
|
|
320
328
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
// =============================================================================
|
|
330
|
+
// Streaming detail panel
|
|
331
|
+
//
|
|
332
|
+
// Per-commit cached file-backed buffers. On commit switch we either reuse
|
|
333
|
+
// the existing cached buffer (instant) or spawn `git show --patch` into a
|
|
334
|
+
// per-SHA file and open it via `openFileStreaming`, polling for growth
|
|
335
|
+
// while git runs in the background. The buffer-group panel is re-pointed
|
|
336
|
+
// at the chosen buffer via `setBufferGroupPanelBuffer` — the same single
|
|
337
|
+
// tab keeps the side-by-side log/detail UX.
|
|
338
|
+
// =============================================================================
|
|
329
339
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
340
|
+
/**
|
|
341
|
+
* Path of the per-SHA cache file. Commits are immutable; once we've
|
|
342
|
+
* written one, repeat visits are zero-git.
|
|
343
|
+
*/
|
|
344
|
+
function cachePathForHash(hash: string): string {
|
|
345
|
+
// `<dataDir>/git-show/<sha>.diff` — the .diff extension lets the
|
|
346
|
+
// syntax-highlight grammar kick in for free.
|
|
347
|
+
return `${editor.getDataDir()}/git-show/${hash}.diff`;
|
|
338
348
|
}
|
|
339
349
|
|
|
350
|
+
/** Polling interval while git is still writing. ~5 fps is plenty. */
|
|
351
|
+
const STREAM_POLL_MS = 200;
|
|
352
|
+
|
|
340
353
|
/**
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
* `git show` is debounced.
|
|
354
|
+
* Start a `git show --patch` for `hash`, piping stdout straight into the
|
|
355
|
+
* cache file. Returns the handle so a later commit switch can `.kill()`
|
|
356
|
+
* the still-running spawn.
|
|
345
357
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
358
|
+
* Caller has already verified the cache file doesn't yet exist (or wants
|
|
359
|
+
* to overwrite it).
|
|
348
360
|
*/
|
|
349
|
-
function
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
361
|
+
function spawnGitShow(hash: string, cwd: string): ProcessHandle<SpawnResult> {
|
|
362
|
+
// `--stat --patch` matches what the previous plugin used. The stat
|
|
363
|
+
// header gives users a per-file changed-lines summary at the top
|
|
364
|
+
// of the diff and is also what `git show` produces by default, so
|
|
365
|
+
// its presence is what most readers (and tests) expect.
|
|
366
|
+
//
|
|
367
|
+
// The generated d.ts shows `spawnProcess(cmd, args, cwd?, stdoutTo?)`
|
|
368
|
+
// as flat positional args. The runtime JS wrapper also accepts an
|
|
369
|
+
// `{stdoutTo}` options object in the 4th slot, but using the flat
|
|
370
|
+
// form keeps the call type-checked without a cast.
|
|
371
|
+
return editor.spawnProcess(
|
|
372
|
+
"git",
|
|
373
|
+
["show", "--stat", "--patch", hash],
|
|
374
|
+
cwd,
|
|
375
|
+
cachePathForHash(hash),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Poll `editor.refreshBufferFromDisk` until the spawn handle resolves,
|
|
381
|
+
* then do one final catch-up refresh. Returns immediately if the
|
|
382
|
+
* commit is no longer in flight (e.g. user moved on, kill() fired).
|
|
383
|
+
*/
|
|
384
|
+
async function pollUntilSpawnDone(
|
|
385
|
+
hash: string,
|
|
386
|
+
bufferId: number,
|
|
387
|
+
handle: ProcessHandle<SpawnResult>,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
// Wrap the handle's settlement in a non-rejecting marker promise so
|
|
390
|
+
// a fast subscription loop can `await` it (or race it against a
|
|
391
|
+
// delay) without worrying about whether the spawn errored. The
|
|
392
|
+
// ProcessHandle is a thenable, not a real Promise, so adapt via
|
|
393
|
+
// Promise.resolve().
|
|
394
|
+
let done = false;
|
|
395
|
+
void Promise.resolve(handle).then(
|
|
396
|
+
() => {
|
|
397
|
+
done = true;
|
|
398
|
+
},
|
|
399
|
+
() => {
|
|
400
|
+
done = true;
|
|
401
|
+
},
|
|
402
|
+
);
|
|
358
403
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
return
|
|
404
|
+
while (!done) {
|
|
405
|
+
await editor.delay(STREAM_POLL_MS);
|
|
406
|
+
if (!state.isOpen) return; // group closed mid-stream
|
|
407
|
+
if (state.inFlightSpawns.get(hash) !== handle) return; // superseded
|
|
408
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
409
|
+
}
|
|
410
|
+
// Final catch-up so any bytes written between the last poll and
|
|
411
|
+
// process exit are visible immediately.
|
|
412
|
+
await editor.refreshBufferFromDisk(bufferId);
|
|
413
|
+
// Done — clear the in-flight handle if it's still ours.
|
|
414
|
+
if (state.inFlightSpawns.get(hash) === handle) {
|
|
415
|
+
state.inFlightSpawns.delete(hash);
|
|
362
416
|
}
|
|
417
|
+
// Apply diff coloring once the buffer is complete. Doing this
|
|
418
|
+
// pre-completion would either churn (re-walk on every refresh) or
|
|
419
|
+
// double-overlay newly-extended lines; on completion we walk once.
|
|
420
|
+
await applyDiffHighlights(bufferId);
|
|
421
|
+
}
|
|
363
422
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
423
|
+
// =============================================================================
|
|
424
|
+
// Diff syntax highlighting via per-line bg overlays
|
|
425
|
+
//
|
|
426
|
+
// Sublime-syntax's bundled `Diff` definition only scopes the `diff`
|
|
427
|
+
// keyword, so themes only colour that. Plugins are responsible for the
|
|
428
|
+
// rest — same approach `live_diff` uses for inline diff coloring in
|
|
429
|
+
// regular buffers.
|
|
430
|
+
//
|
|
431
|
+
// One overlay per line of added/removed content is fine for the
|
|
432
|
+
// "normal commit" workload but explodes on giant commits (the
|
|
433
|
+
// rewrite-bun commit is 1M lines = 1M overlays = back to the old
|
|
434
|
+
// 500k-overlay problem this rewire eliminated). Gate on buffer size;
|
|
435
|
+
// gracefully degrade to no highlighting for outliers.
|
|
436
|
+
// =============================================================================
|
|
437
|
+
|
|
438
|
+
const HIGHLIGHT_BG_ADDED = "editor.diff_add_bg";
|
|
439
|
+
const HIGHLIGHT_BG_REMOVED = "editor.diff_remove_bg";
|
|
440
|
+
const HIGHLIGHT_BG_HUNK = "editor.diff_modify_bg";
|
|
441
|
+
const HIGHLIGHT_NAMESPACE = "git-log-diff";
|
|
442
|
+
/** Skip overlay highlighting above this size. ~256 KB covers
|
|
443
|
+
* basically every hand-written commit comfortably; very large
|
|
444
|
+
* generated-file diffs (lockfiles, minified code) just stay
|
|
445
|
+
* uncoloured — the cost would be a few thousand-to-a-million
|
|
446
|
+
* overlays for content the user mostly skims. */
|
|
447
|
+
const HIGHLIGHT_MAX_BYTES = 256 * 1024;
|
|
448
|
+
|
|
449
|
+
async function applyDiffHighlights(bufferId: number): Promise<void> {
|
|
450
|
+
const total = editor.getBufferLength(bufferId);
|
|
451
|
+
if (total === 0 || total > HIGHLIGHT_MAX_BYTES) return;
|
|
452
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
453
|
+
if (!text) return;
|
|
454
|
+
|
|
455
|
+
// Walk lines tracking byte offsets; coalesce consecutive same-kind
|
|
456
|
+
// rows into single ranges so a 30-line added block costs one
|
|
457
|
+
// overlay, not 30.
|
|
458
|
+
let byte = 0;
|
|
459
|
+
let runKind: "+" | "-" | "@" | null = null;
|
|
460
|
+
let runStart = 0;
|
|
461
|
+
let runEnd = 0;
|
|
462
|
+
|
|
463
|
+
const flushRun = () => {
|
|
464
|
+
if (runKind === null) return;
|
|
465
|
+
const bg =
|
|
466
|
+
runKind === "+"
|
|
467
|
+
? HIGHLIGHT_BG_ADDED
|
|
468
|
+
: runKind === "-"
|
|
469
|
+
? HIGHLIGHT_BG_REMOVED
|
|
470
|
+
: HIGHLIGHT_BG_HUNK;
|
|
471
|
+
editor.addOverlay(bufferId, HIGHLIGHT_NAMESPACE, runStart, runEnd, {
|
|
472
|
+
bg,
|
|
473
|
+
extendToLineEnd: true,
|
|
474
|
+
});
|
|
475
|
+
runKind = null;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
for (const line of text.split("\n")) {
|
|
479
|
+
const lineLen = line.length;
|
|
480
|
+
const ch = line.charAt(0);
|
|
481
|
+
let kind: "+" | "-" | "@" | null = null;
|
|
482
|
+
if (ch === "+" && !line.startsWith("+++")) kind = "+";
|
|
483
|
+
else if (ch === "-" && !line.startsWith("---")) kind = "-";
|
|
484
|
+
else if (line.startsWith("@@")) kind = "@";
|
|
485
|
+
|
|
486
|
+
if (kind !== runKind) {
|
|
487
|
+
flushRun();
|
|
488
|
+
if (kind !== null) {
|
|
489
|
+
runStart = byte;
|
|
490
|
+
runKind = kind;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (kind !== null) {
|
|
494
|
+
// Include the trailing newline in the range so the bg colour
|
|
495
|
+
// fills the row even on empty lines that wrap-extend.
|
|
496
|
+
runEnd = byte + lineLen + 1;
|
|
497
|
+
}
|
|
498
|
+
byte += lineLen + 1;
|
|
499
|
+
}
|
|
500
|
+
flushRun();
|
|
368
501
|
}
|
|
369
502
|
|
|
370
503
|
/**
|
|
371
|
-
*
|
|
372
|
-
*
|
|
504
|
+
* Get (or create) the file-backed buffer that displays `commit`.
|
|
505
|
+
* On first call for a hash: ensure cache file exists, kick off
|
|
506
|
+
* `git show` if it doesn't, openFileStreaming, start the poll loop.
|
|
507
|
+
* Returns the buffer id on success or null on failure.
|
|
373
508
|
*/
|
|
374
|
-
async function
|
|
375
|
-
const
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
509
|
+
async function ensureCommitBuffer(commit: GitCommit, cwd: string): Promise<number | null> {
|
|
510
|
+
const hash = commit.hash;
|
|
511
|
+
const existing = state.commitBuffers.get(hash);
|
|
512
|
+
if (existing !== undefined) return existing;
|
|
513
|
+
|
|
514
|
+
const path = cachePathForHash(hash);
|
|
515
|
+
const cacheHit = editor.fileExists(path);
|
|
516
|
+
|
|
517
|
+
if (!cacheHit) {
|
|
518
|
+
// Cache miss: spawn git, polling the file as it grows. The handle
|
|
519
|
+
// is stashed so a fast-scrolling user can supersede us via kill().
|
|
520
|
+
const handle = spawnGitShow(hash, cwd);
|
|
521
|
+
state.inFlightSpawns.set(hash, handle);
|
|
522
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
523
|
+
if (bufferId === null) {
|
|
524
|
+
handle.kill?.();
|
|
525
|
+
state.inFlightSpawns.delete(hash);
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
state.commitBuffers.set(hash, bufferId);
|
|
529
|
+
// Fire-and-forget polling task.
|
|
530
|
+
void pollUntilSpawnDone(hash, bufferId, handle);
|
|
531
|
+
return bufferId;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Cache hit: just open the existing file. No git spawned.
|
|
535
|
+
const bufferId = await editor.openFileStreaming(path);
|
|
536
|
+
if (bufferId === null) return null;
|
|
537
|
+
state.commitBuffers.set(hash, bufferId);
|
|
538
|
+
return bufferId;
|
|
388
539
|
}
|
|
389
540
|
|
|
390
541
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
542
|
+
* Show `commit` in the detail panel. Cancels any superseded in-flight
|
|
543
|
+
* spawn for *other* commits (the user has navigated past them) and
|
|
544
|
+
* retargets the panel at the chosen commit's buffer.
|
|
393
545
|
*/
|
|
546
|
+
async function showCommitInDetail(commit: GitCommit, cwd: string): Promise<void> {
|
|
547
|
+
// Cancel anything still streaming for a commit that isn't this one —
|
|
548
|
+
// the user has moved on; no point keeping git running.
|
|
549
|
+
for (const [hash, handle] of state.inFlightSpawns) {
|
|
550
|
+
if (hash !== commit.hash) {
|
|
551
|
+
handle.kill?.();
|
|
552
|
+
state.inFlightSpawns.delete(hash);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const bufferId = await ensureCommitBuffer(commit, cwd);
|
|
557
|
+
if (bufferId === null) {
|
|
558
|
+
editor.setStatus(
|
|
559
|
+
editor.t("status.failed_open_file", { file: commit.shortHash }),
|
|
560
|
+
);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (state.groupId === null) return;
|
|
564
|
+
await editor.setBufferGroupPanelBuffer(state.groupId, "detail", bufferId);
|
|
565
|
+
state.detailBufferId = bufferId;
|
|
566
|
+
// Each commit buffer needs the same per-buffer presentation as the
|
|
567
|
+
// initial virtual one: visible cursor for diff-line navigation,
|
|
568
|
+
// wrap on (long minified lines unreadable in the 40% panel).
|
|
569
|
+
editor.setBufferShowCursors(bufferId, true);
|
|
570
|
+
editor.setLineWrap(bufferId, null, true);
|
|
571
|
+
// Land at the top of the diff every time we (re-)visit a commit.
|
|
572
|
+
editor.setBufferCursor(bufferId, 0);
|
|
573
|
+
}
|
|
574
|
+
|
|
394
575
|
async function refreshDetail(): Promise<void> {
|
|
395
|
-
|
|
396
|
-
if (
|
|
576
|
+
if (state.groupId === null) return;
|
|
577
|
+
if (state.commits.length === 0) return;
|
|
578
|
+
const idx = Math.max(0, Math.min(state.selectedIndex, state.commits.length - 1));
|
|
579
|
+
const commit = state.commits[idx];
|
|
580
|
+
if (!commit) return;
|
|
581
|
+
await showCommitInDetail(commit, editor.getCwd());
|
|
397
582
|
}
|
|
398
583
|
|
|
399
584
|
// =============================================================================
|
|
@@ -438,7 +623,11 @@ async function show_git_log(): Promise<void> {
|
|
|
438
623
|
);
|
|
439
624
|
state.groupId = group.groupId as number;
|
|
440
625
|
state.logBufferId = (group.panels["log"] as number | undefined) ?? null;
|
|
441
|
-
state.
|
|
626
|
+
state.initialDetailBufferId =
|
|
627
|
+
(group.panels["detail"] as number | undefined) ?? null;
|
|
628
|
+
// detailBufferId starts as the initial virtual buffer; it gets
|
|
629
|
+
// retargeted to a file-backed buffer on first commit selection.
|
|
630
|
+
state.detailBufferId = state.initialDetailBufferId;
|
|
442
631
|
state.toolbarBufferId = (group.panels["toolbar"] as number | undefined) ?? null;
|
|
443
632
|
if (state.toolbarBufferId !== null) {
|
|
444
633
|
state.toolbarPanel = new WidgetPanel(state.toolbarBufferId);
|
|
@@ -447,21 +636,18 @@ async function show_git_log(): Promise<void> {
|
|
|
447
636
|
state.logPanel = new WidgetPanel(state.logBufferId);
|
|
448
637
|
}
|
|
449
638
|
state.selectedIndex = 0;
|
|
450
|
-
state.
|
|
639
|
+
state.commitBuffers = new Map();
|
|
640
|
+
state.inFlightSpawns = new Map();
|
|
451
641
|
state.isOpen = true;
|
|
452
642
|
|
|
453
|
-
// The detail panel
|
|
454
|
-
// clicked / traversed before pressing Enter to open a file.
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
editor.setLineWrap(state.detailBufferId, null, true);
|
|
462
|
-
// Per-panel mode: the group was created with "git-log" which applies
|
|
463
|
-
// to the initially-focused panel (log). The detail panel's mode is
|
|
464
|
-
// set when we focus into it.
|
|
643
|
+
// The detail panel owns a native cursor so diff lines can be
|
|
644
|
+
// clicked / traversed before pressing Enter to open a file. We set
|
|
645
|
+
// the cursor on each retargeted buffer as it gets swapped in, but
|
|
646
|
+
// wrap-default needs setting too — long minified lines in lock-file
|
|
647
|
+
// diffs are unreadable without wrap in the 40% panel.
|
|
648
|
+
if (state.initialDetailBufferId !== null) {
|
|
649
|
+
editor.setBufferShowCursors(state.initialDetailBufferId, true);
|
|
650
|
+
editor.setLineWrap(state.initialDetailBufferId, null, true);
|
|
465
651
|
}
|
|
466
652
|
|
|
467
653
|
renderToolbar();
|
|
@@ -471,9 +657,6 @@ async function show_git_log(): Promise<void> {
|
|
|
471
657
|
// the selected row stays visible).
|
|
472
658
|
await refreshDetail();
|
|
473
659
|
|
|
474
|
-
if (state.groupId !== null) {
|
|
475
|
-
editor.focusBufferGroupPanel(state.groupId, "log");
|
|
476
|
-
}
|
|
477
660
|
editor.on("resize", on_git_log_resize);
|
|
478
661
|
editor.on("buffer_closed", on_git_log_buffer_closed);
|
|
479
662
|
|
|
@@ -490,20 +673,36 @@ function git_log_cleanup(): void {
|
|
|
490
673
|
if (!state.isOpen) return;
|
|
491
674
|
editor.off("resize", on_git_log_resize);
|
|
492
675
|
editor.off("buffer_closed", on_git_log_buffer_closed);
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
676
|
+
// Kill any still-running `git show` spawns — we no longer care.
|
|
677
|
+
for (const [, handle] of state.inFlightSpawns) {
|
|
678
|
+
handle.kill?.();
|
|
679
|
+
}
|
|
680
|
+
state.inFlightSpawns.clear();
|
|
681
|
+
// Close each per-commit buffer we created. The buffer-group's own
|
|
682
|
+
// `close` (called below in `git_log_close`) tears down the panel
|
|
683
|
+
// buffers (toolbar/log/initialDetail) — but retargeted file-backed
|
|
684
|
+
// buffers we allocated via openFileStreaming are *outside* the
|
|
685
|
+
// group's panel_buffers map by the time we got here, so we must
|
|
686
|
+
// close them explicitly to avoid leaks.
|
|
687
|
+
for (const [, bufferId] of state.commitBuffers) {
|
|
688
|
+
editor.closeBuffer(bufferId);
|
|
689
|
+
}
|
|
690
|
+
state.commitBuffers.clear();
|
|
691
|
+
// The buffer-group's `close` will tear down its own panel buffers
|
|
692
|
+
// (toolbar/log/initialDetail) too, which implicitly drops the widget
|
|
693
|
+
// panels rendering into them. We still null out the handles so any
|
|
694
|
+
// stray `renderToolbar()` / `renderLog()` call post-cleanup is a
|
|
695
|
+
// no-op.
|
|
497
696
|
state.toolbarPanel = null;
|
|
498
697
|
state.logPanel = null;
|
|
499
698
|
state.isOpen = false;
|
|
500
699
|
state.groupId = null;
|
|
501
700
|
state.logBufferId = null;
|
|
701
|
+
state.initialDetailBufferId = null;
|
|
502
702
|
state.detailBufferId = null;
|
|
503
703
|
state.toolbarBufferId = null;
|
|
504
704
|
state.commits = [];
|
|
505
705
|
state.selectedIndex = 0;
|
|
506
|
-
state.detailCache = null;
|
|
507
706
|
}
|
|
508
707
|
|
|
509
708
|
function git_log_close(): void {
|
|
@@ -519,12 +718,26 @@ registerHandler("git_log_close", git_log_close);
|
|
|
519
718
|
|
|
520
719
|
function on_git_log_buffer_closed(data: { buffer_id: number }): void {
|
|
521
720
|
if (!state.isOpen) return;
|
|
721
|
+
// Tear down the whole group only when the *group's* buffers close
|
|
722
|
+
// (toolbar / log / the initial virtual detail). A retargeted
|
|
723
|
+
// file-backed commit buffer closing is normal — drop it from our
|
|
724
|
+
// cache but keep the group alive.
|
|
522
725
|
if (
|
|
523
726
|
data.buffer_id === state.logBufferId ||
|
|
524
|
-
data.buffer_id === state.
|
|
727
|
+
data.buffer_id === state.initialDetailBufferId ||
|
|
525
728
|
data.buffer_id === state.toolbarBufferId
|
|
526
729
|
) {
|
|
527
730
|
git_log_cleanup();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
// Removed from cache so a revisit re-spawns / re-opens.
|
|
734
|
+
for (const [hash, bufId] of state.commitBuffers) {
|
|
735
|
+
if (bufId === data.buffer_id) {
|
|
736
|
+
state.commitBuffers.delete(hash);
|
|
737
|
+
state.inFlightSpawns.get(hash)?.kill?.();
|
|
738
|
+
state.inFlightSpawns.delete(hash);
|
|
739
|
+
break;
|
|
740
|
+
}
|
|
528
741
|
}
|
|
529
742
|
}
|
|
530
743
|
registerHandler("on_git_log_buffer_closed", on_git_log_buffer_closed);
|
|
@@ -533,7 +746,13 @@ async function git_log_refresh(): Promise<void> {
|
|
|
533
746
|
if (!state.isOpen) return;
|
|
534
747
|
editor.setStatus(editor.t("status.refreshing"));
|
|
535
748
|
state.commits = await fetchGitLog(editor);
|
|
536
|
-
|
|
749
|
+
// The on-disk cache files are keyed by SHA and commits are
|
|
750
|
+
// immutable, so they remain valid — but our in-memory buffer ids
|
|
751
|
+
// for commits no longer in the visible list are stale; clear them.
|
|
752
|
+
for (const [, handle] of state.inFlightSpawns) handle.kill?.();
|
|
753
|
+
state.inFlightSpawns.clear();
|
|
754
|
+
for (const [, bufferId] of state.commitBuffers) editor.closeBuffer(bufferId);
|
|
755
|
+
state.commitBuffers.clear();
|
|
537
756
|
if (state.selectedIndex >= state.commits.length) {
|
|
538
757
|
state.selectedIndex = Math.max(0, state.commits.length - 1);
|
|
539
758
|
}
|
|
@@ -604,26 +823,222 @@ function git_log_q(): void {
|
|
|
604
823
|
}
|
|
605
824
|
registerHandler("git_log_q", git_log_q);
|
|
606
825
|
|
|
826
|
+
// =============================================================================
|
|
827
|
+
// Folding by file and hunk
|
|
828
|
+
//
|
|
829
|
+
// Publishes structural fold ranges into the buffer's `folding_ranges`
|
|
830
|
+
// via `setFoldingRanges` — the same channel an LSP `foldingRange`
|
|
831
|
+
// response uses. Nothing is pre-collapsed; the user toggles a range
|
|
832
|
+
// with the standard fold keybinding (`za` etc.), which finds the
|
|
833
|
+
// matching range under the cursor.
|
|
834
|
+
//
|
|
835
|
+
// The diff structure gives us two natural fold levels:
|
|
836
|
+
// * per-file: each `diff --git a/X b/Y` section
|
|
837
|
+
// * per-hunk: each `@@ -A,B +C,D @@` block within a file
|
|
838
|
+
// We publish both; the toggle-fold key picks the innermost containing
|
|
839
|
+
// range at the cursor's line.
|
|
840
|
+
//
|
|
841
|
+
// Computed once after `pollUntilSpawnDone` settles — re-running on
|
|
842
|
+
// every refresh would churn the marker list for no benefit (the diff
|
|
843
|
+
// structure is monotonic-append until exit).
|
|
844
|
+
// =============================================================================
|
|
845
|
+
|
|
846
|
+
interface DiffFoldRange {
|
|
847
|
+
startLine: number;
|
|
848
|
+
endLine: number;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** Walk the buffer text and return (file-level, hunk-level) fold
|
|
852
|
+
* ranges. Lines are 0-indexed, both endpoints inclusive — the LSP
|
|
853
|
+
* shape. The "fold header" is the line at `startLine`; everything up
|
|
854
|
+
* through `endLine` collapses under it. */
|
|
855
|
+
function computeDiffFoldRanges(text: string): {
|
|
856
|
+
files: DiffFoldRange[];
|
|
857
|
+
hunks: DiffFoldRange[];
|
|
858
|
+
} {
|
|
859
|
+
const lines = text.split("\n");
|
|
860
|
+
const files: DiffFoldRange[] = [];
|
|
861
|
+
const hunks: DiffFoldRange[] = [];
|
|
862
|
+
|
|
863
|
+
let fileStart: number | null = null;
|
|
864
|
+
let hunkStart: number | null = null;
|
|
865
|
+
|
|
866
|
+
const closeHunk = (endLine: number) => {
|
|
867
|
+
if (hunkStart !== null && endLine > hunkStart) {
|
|
868
|
+
hunks.push({ startLine: hunkStart, endLine });
|
|
869
|
+
}
|
|
870
|
+
hunkStart = null;
|
|
871
|
+
};
|
|
872
|
+
const closeFile = (endLine: number) => {
|
|
873
|
+
closeHunk(endLine);
|
|
874
|
+
if (fileStart !== null && endLine > fileStart) {
|
|
875
|
+
files.push({ startLine: fileStart, endLine });
|
|
876
|
+
}
|
|
877
|
+
fileStart = null;
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
for (let i = 0; i < lines.length; i++) {
|
|
881
|
+
const l = lines[i];
|
|
882
|
+
if (l.startsWith("diff --git ")) {
|
|
883
|
+
closeFile(i - 1);
|
|
884
|
+
fileStart = i;
|
|
885
|
+
} else if (l.startsWith("@@ ")) {
|
|
886
|
+
closeHunk(i - 1);
|
|
887
|
+
hunkStart = i;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
closeFile(lines.length - 1);
|
|
891
|
+
return { files, hunks };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
async function publishDiffFoldRanges(bufferId: number): Promise<void> {
|
|
895
|
+
const total = editor.getBufferLength(bufferId);
|
|
896
|
+
if (total === 0) return;
|
|
897
|
+
const text = await editor.getBufferText(bufferId, 0, total);
|
|
898
|
+
if (!text) return;
|
|
899
|
+
|
|
900
|
+
const { files, hunks } = computeDiffFoldRanges(text);
|
|
901
|
+
|
|
902
|
+
// Merge — the host accepts a single array. "region" kind tags both
|
|
903
|
+
// levels generically; the LSP spec also defines comment/imports
|
|
904
|
+
// kinds which don't apply to diffs.
|
|
905
|
+
const ranges = [...files, ...hunks].map((r) => ({
|
|
906
|
+
startLine: r.startLine,
|
|
907
|
+
endLine: r.endLine,
|
|
908
|
+
kind: "region",
|
|
909
|
+
}));
|
|
910
|
+
editor.setFoldingRanges(bufferId, ranges);
|
|
911
|
+
}
|
|
912
|
+
|
|
607
913
|
// =============================================================================
|
|
608
914
|
// Detail panel — open file at commit
|
|
609
915
|
// =============================================================================
|
|
610
916
|
|
|
917
|
+
/**
|
|
918
|
+
* Walk through the streaming diff buffer to find the file + line
|
|
919
|
+
* context near the cursor. Diff format:
|
|
920
|
+
*
|
|
921
|
+
* diff --git a/<path> b/<path>
|
|
922
|
+
* index ...
|
|
923
|
+
* --- a/<path> (or /dev/null for additions)
|
|
924
|
+
* +++ b/<path> (or /dev/null for deletions)
|
|
925
|
+
* @@ -old,n +new,m @@
|
|
926
|
+
* <context|+|- lines>
|
|
927
|
+
*
|
|
928
|
+
* Strategy:
|
|
929
|
+
* - Read up to the END of the cursor's line, not just up to the
|
|
930
|
+
* cursor's byte offset. This way a cursor sitting on a header line
|
|
931
|
+
* (`diff --git`, `+++ b/...`, `@@ ...`) still gets that line
|
|
932
|
+
* matched, matching the old text-property behaviour.
|
|
933
|
+
* - Walk backwards for the per-file header. Match either:
|
|
934
|
+
* `+++ b/<path>` (preferred — names the new-side path)
|
|
935
|
+
* `diff --git a/<src> b/<dst>` (fallback — covers the case where
|
|
936
|
+
* the cursor is on the `diff --git` line itself, before the
|
|
937
|
+
* `+++` line has appeared in the search range)
|
|
938
|
+
* - Walk backwards for the most recent `@@ -... +<new>,<count> @@`
|
|
939
|
+
* between the header and cursor, then count context/'+' rows
|
|
940
|
+
* forward to the cursor to derive the new-side line number.
|
|
941
|
+
*/
|
|
942
|
+
async function deriveFileAndLineFromDiffCursor(
|
|
943
|
+
bufferId: number,
|
|
944
|
+
): Promise<{ file: string; line: number } | null> {
|
|
945
|
+
const cursor = editor.getCursorPosition();
|
|
946
|
+
if (cursor < 0) return null;
|
|
947
|
+
|
|
948
|
+
const bufLen = editor.getBufferLength(bufferId);
|
|
949
|
+
const readEnd = Math.min(bufLen, cursor + 4096);
|
|
950
|
+
if (readEnd === 0) return null;
|
|
951
|
+
const text = await editor.getBufferText(bufferId, 0, readEnd);
|
|
952
|
+
if (!text) return null;
|
|
953
|
+
const lines = text.split("\n");
|
|
954
|
+
|
|
955
|
+
// Locate the cursor's line index by walking byte offsets. `lines[i]`
|
|
956
|
+
// covers bytes [byte, byte+len]; the `\n` separator lives at
|
|
957
|
+
// byte+len, so the next line starts at byte+len+1.
|
|
958
|
+
let byte = 0;
|
|
959
|
+
let cursorLineIdx = lines.length - 1;
|
|
960
|
+
for (let i = 0; i < lines.length; i++) {
|
|
961
|
+
const lineLen = lines[i].length;
|
|
962
|
+
if (cursor <= byte + lineLen) {
|
|
963
|
+
cursorLineIdx = i;
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
byte += lineLen + 1;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// Walk back from the cursor's line for the per-file header. Match
|
|
970
|
+
// either `+++ b/<path>` or `diff --git a/<src> b/<dst>` so cursor-
|
|
971
|
+
// on-header cases work.
|
|
972
|
+
let file: string | null = null;
|
|
973
|
+
let headerIdx = -1;
|
|
974
|
+
for (let i = cursorLineIdx; i >= 0; i--) {
|
|
975
|
+
const l = lines[i];
|
|
976
|
+
if (l.startsWith("+++ b/")) {
|
|
977
|
+
file = l.slice(6).trim();
|
|
978
|
+
headerIdx = i;
|
|
979
|
+
break;
|
|
980
|
+
}
|
|
981
|
+
if (l.startsWith("+++ /dev/null")) {
|
|
982
|
+
// Deletion — no new-side path. Opening the pre-image is a
|
|
983
|
+
// separate flow.
|
|
984
|
+
return null;
|
|
985
|
+
}
|
|
986
|
+
const m = /^diff --git a\/(.+?) b\/(.+)$/.exec(l);
|
|
987
|
+
if (m) {
|
|
988
|
+
const aSide = m[1];
|
|
989
|
+
const bSide = m[2];
|
|
990
|
+
file = bSide === "/dev/null" ? aSide : bSide;
|
|
991
|
+
headerIdx = i;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (file === null || headerIdx < 0) return null;
|
|
996
|
+
|
|
997
|
+
// Find the most recent `@@ ... +start,count @@` between header and
|
|
998
|
+
// cursor. Default: line 1 (cursor sits on the header itself, or
|
|
999
|
+
// between the header and the first hunk).
|
|
1000
|
+
let line = 1;
|
|
1001
|
+
for (let i = cursorLineIdx; i > headerIdx; i--) {
|
|
1002
|
+
const l = lines[i];
|
|
1003
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(l);
|
|
1004
|
+
if (!m) continue;
|
|
1005
|
+
const newStart = parseInt(m[1], 10);
|
|
1006
|
+
if (!Number.isFinite(newStart)) return null;
|
|
1007
|
+
// Walk forward from the hunk header to the cursor's line,
|
|
1008
|
+
// advancing the new-file line counter for context (' ') and
|
|
1009
|
+
// addition ('+') rows; skip deletion ('-') rows since they don't
|
|
1010
|
+
// exist in the new file.
|
|
1011
|
+
let cur = newStart;
|
|
1012
|
+
for (let j = i + 1; j <= cursorLineIdx; j++) {
|
|
1013
|
+
if (j === cursorLineIdx) {
|
|
1014
|
+
line = cur;
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
const ch = lines[j].charAt(0);
|
|
1018
|
+
if (ch === "+" || ch === " " || ch === "") cur += 1;
|
|
1019
|
+
// '-' / '\' (no-newline marker): don't advance.
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
return { file, line };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
611
1026
|
async function git_log_detail_open_file(): Promise<void> {
|
|
612
1027
|
if (state.detailBufferId === null) return;
|
|
613
1028
|
const commit = selectedCommit();
|
|
614
1029
|
if (!commit) return;
|
|
615
1030
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
if (!file) {
|
|
1031
|
+
// The detail buffer is a plain file-backed view of `git show --patch`,
|
|
1032
|
+
// so we don't have plugin-attached `file`/`line` properties anymore.
|
|
1033
|
+
// Parse the diff backwards from the cursor to find the nearest
|
|
1034
|
+
// `+++ b/<path>` header (a per-file diff section opener) and the
|
|
1035
|
+
// most recent hunk header to derive a line number.
|
|
1036
|
+
const ctx = await deriveFileAndLineFromDiffCursor(state.detailBufferId);
|
|
1037
|
+
if (!ctx) {
|
|
624
1038
|
editor.setStatus(editor.t("status.move_to_diff_with_context"));
|
|
625
1039
|
return;
|
|
626
1040
|
}
|
|
1041
|
+
const { file, line } = ctx;
|
|
627
1042
|
|
|
628
1043
|
editor.setStatus(
|
|
629
1044
|
editor.t("status.file_loading", { file, hash: commit.shortHash })
|
|
@@ -708,11 +1123,6 @@ async function on_log_select(idx: number): Promise<void> {
|
|
|
708
1123
|
if (idx === state.selectedIndex) return;
|
|
709
1124
|
state.selectedIndex = idx;
|
|
710
1125
|
|
|
711
|
-
// Immediate feedback: cached detail or "loading" placeholder.
|
|
712
|
-
// The host already re-rendered the List with the new selection
|
|
713
|
-
// highlight, so we only need to update the right-hand pane.
|
|
714
|
-
const pending = refreshDetailImmediate();
|
|
715
|
-
|
|
716
1126
|
const commit = state.commits[state.selectedIndex];
|
|
717
1127
|
if (commit) {
|
|
718
1128
|
editor.setStatus(
|
|
@@ -723,16 +1133,17 @@ async function on_log_select(idx: number): Promise<void> {
|
|
|
723
1133
|
);
|
|
724
1134
|
}
|
|
725
1135
|
|
|
726
|
-
if (!pending) return;
|
|
727
|
-
|
|
728
1136
|
// Debounce: bump the token, wait a beat, bail if a newer event has
|
|
729
|
-
// arrived.
|
|
730
|
-
//
|
|
1137
|
+
// arrived. Even though re-pointing the panel at a cached buffer is
|
|
1138
|
+
// ~free, kicking off a new `git show --patch` for every intermediate
|
|
1139
|
+
// row in a held-j burst is wasteful. Collapse rapid selection moves.
|
|
731
1140
|
const myId = ++state.pendingSelectId;
|
|
732
1141
|
await editor.delay(SELECT_DEBOUNCE_MS);
|
|
733
1142
|
if (myId !== state.pendingSelectId) return;
|
|
734
1143
|
if (!state.isOpen) return;
|
|
735
|
-
|
|
1144
|
+
const current = state.commits[state.selectedIndex];
|
|
1145
|
+
if (!current) return;
|
|
1146
|
+
await showCommitInDetail(current, editor.getCwd());
|
|
736
1147
|
}
|
|
737
1148
|
|
|
738
1149
|
// =============================================================================
|
|
@@ -757,5 +1168,4 @@ editor.registerCommand(
|
|
|
757
1168
|
"git_log_refresh",
|
|
758
1169
|
null
|
|
759
1170
|
);
|
|
760
|
-
|
|
761
1171
|
editor.debug("Git Log plugin initialized (modern buffer-group layout)");
|