@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/lib/widgets.ts
CHANGED
|
@@ -46,7 +46,7 @@ export type WidgetAction = globalThis.WidgetAction;
|
|
|
46
46
|
export type WidgetMutation = globalThis.WidgetMutation;
|
|
47
47
|
export type TreeNode = globalThis.TreeNode;
|
|
48
48
|
export type StyledSegment = globalThis.StyledSegment;
|
|
49
|
-
type TextPropertyEntry = globalThis.TextPropertyEntry;
|
|
49
|
+
export type TextPropertyEntry = globalThis.TextPropertyEntry;
|
|
50
50
|
type InlineOverlay = globalThis.InlineOverlay;
|
|
51
51
|
type OverlayOptions = globalThis.OverlayOptions;
|
|
52
52
|
|
|
@@ -77,8 +77,8 @@ export function hintBar(entries: HintEntry[]): WidgetSpec {
|
|
|
77
77
|
* `TextPropertyEntry[]` (the same shape `setVirtualBufferContent`
|
|
78
78
|
* already accepts) so a plugin can migrate its panel one widget at a
|
|
79
79
|
* time. */
|
|
80
|
-
export function raw(entries: TextPropertyEntry[]): WidgetSpec {
|
|
81
|
-
|
|
80
|
+
export function raw(entries: TextPropertyEntry[], key?: string): WidgetSpec {
|
|
81
|
+
return { kind: "raw", entries, key };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/** Build a `TextPropertyEntry` from a sequence of styled segments.
|
|
@@ -142,13 +142,20 @@ export function toggle(
|
|
|
142
142
|
|
|
143
143
|
/** Action button, rendered as `[ Label ]`. `intent` controls visual
|
|
144
144
|
* emphasis: `"normal"` (default) → no override, `"primary"` → bold,
|
|
145
|
-
* `"danger"` → error theme key.
|
|
145
|
+
* `"danger"` → error theme key.
|
|
146
|
+
*
|
|
147
|
+
* `disabled: true` paints the button with `ui.menu_disabled_fg`,
|
|
148
|
+
* drops it from the Tab cycle, and makes clicks no-ops — for actions
|
|
149
|
+
* that aren't currently available against the surrounding state.
|
|
150
|
+
* The button still occupies its layout cell, so flipping `disabled`
|
|
151
|
+
* doesn't reshuffle the surrounding row. */
|
|
146
152
|
export function button(
|
|
147
153
|
label: string,
|
|
148
154
|
options?: {
|
|
149
155
|
focused?: boolean;
|
|
150
156
|
intent?: ButtonKind;
|
|
151
157
|
key?: string;
|
|
158
|
+
disabled?: boolean;
|
|
152
159
|
},
|
|
153
160
|
): WidgetSpec {
|
|
154
161
|
return {
|
|
@@ -157,6 +164,7 @@ export function button(
|
|
|
157
164
|
focused: options?.focused ?? false,
|
|
158
165
|
intent: options?.intent ?? "normal",
|
|
159
166
|
key: options?.key,
|
|
167
|
+
disabled: options?.disabled ?? false,
|
|
160
168
|
};
|
|
161
169
|
}
|
|
162
170
|
|
|
@@ -339,6 +347,13 @@ export function text(
|
|
|
339
347
|
* with `labeledSection(...)` to get a uniformly full-width
|
|
340
348
|
* fieldset look. */
|
|
341
349
|
fullWidth?: boolean;
|
|
350
|
+
/** Initial completion candidates. Use the
|
|
351
|
+
* `setCompletions(widgetKey, items)` mutation for live
|
|
352
|
+
* updates — the spec field seeds the host's instance state
|
|
353
|
+
* on first render only. Empty = no popup. See
|
|
354
|
+
* `WidgetSpec::Text.completions` (Rust) for the rendering
|
|
355
|
+
* + keyboard semantics. */
|
|
356
|
+
completions?: string[];
|
|
342
357
|
key?: string;
|
|
343
358
|
} = {},
|
|
344
359
|
): WidgetSpec {
|
|
@@ -353,6 +368,7 @@ export function text(
|
|
|
353
368
|
fieldWidth: options.fieldWidth ?? 0,
|
|
354
369
|
maxVisibleChars: options.maxVisibleChars ?? 0,
|
|
355
370
|
fullWidth: options.fullWidth ?? false,
|
|
371
|
+
completions: options.completions ?? [],
|
|
356
372
|
key: options.key,
|
|
357
373
|
};
|
|
358
374
|
}
|
|
@@ -472,6 +488,30 @@ export function labeledSection(options: {
|
|
|
472
488
|
};
|
|
473
489
|
}
|
|
474
490
|
|
|
491
|
+
/** Float `child` over the rest of the layout instead of consuming
|
|
492
|
+
* vertical space. Place inside a `Col` at the position where you
|
|
493
|
+
* want the overlay to anchor — at paint time the child renders
|
|
494
|
+
* at that row but DOES NOT push the rows below it down. Used for
|
|
495
|
+
* dropdown completions, tooltips, hover popups — anything that
|
|
496
|
+
* should appear next to a focused widget without reflowing the
|
|
497
|
+
* panel each time it shows or hides.
|
|
498
|
+
*
|
|
499
|
+
* Hit-testing: overlays paint on top, so clicks inside the
|
|
500
|
+
* overlay's region go to the overlay (not what's underneath).
|
|
501
|
+
* Tab cycle: the child IS walked for tabbable keys — give it a
|
|
502
|
+
* `key` if you want focus to reach it, or leave it keyless to
|
|
503
|
+
* keep it out of the cycle (the typical popup case). */
|
|
504
|
+
export function overlay(
|
|
505
|
+
child: WidgetSpec,
|
|
506
|
+
options?: { key?: string },
|
|
507
|
+
): WidgetSpec {
|
|
508
|
+
return {
|
|
509
|
+
kind: "overlay",
|
|
510
|
+
child,
|
|
511
|
+
key: options?.key,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
475
515
|
// =============================================================================
|
|
476
516
|
// HintEntry parsing — for the legacy `Tab:section Esc:close` format
|
|
477
517
|
// shipped in existing plugin i18n bundles.
|
|
@@ -593,6 +633,18 @@ export class WidgetPanel {
|
|
|
593
633
|
return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
|
|
594
634
|
}
|
|
595
635
|
|
|
636
|
+
/** Update a Text widget's completion popup candidates. Empty
|
|
637
|
+
* `items` closes the popup; non-empty opens it and resets the
|
|
638
|
+
* host-managed selection to index 0. The host repaints the
|
|
639
|
+
* popup on its own; the plugin doesn't need to follow up with
|
|
640
|
+
* an `update(spec)` call. */
|
|
641
|
+
setCompletions(
|
|
642
|
+
widgetKey: string,
|
|
643
|
+
items: Array<string | { value: string; kind?: string }>,
|
|
644
|
+
): boolean {
|
|
645
|
+
return this.mutate({ kind: "setCompletions", widgetKey, items });
|
|
646
|
+
}
|
|
647
|
+
|
|
596
648
|
/** Replace a `List`'s items + parallel `itemKeys`. */
|
|
597
649
|
setItems(
|
|
598
650
|
widgetKey: string,
|
|
@@ -618,6 +670,44 @@ export class WidgetPanel {
|
|
|
618
670
|
setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
|
|
619
671
|
return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
|
|
620
672
|
}
|
|
673
|
+
|
|
674
|
+
/** Append `newNodes` (and parallel `newItemKeys`) to an existing
|
|
675
|
+
* `Tree`'s node list — the streaming-friendly counterpart to
|
|
676
|
+
* `setItems`. Existing selection, scroll, and expansion state are
|
|
677
|
+
* preserved; the renderer simply has more tail to paint on the next
|
|
678
|
+
* cycle. Cheap relative to a full spec re-emit for plugins that
|
|
679
|
+
* stream large result sets (e.g. a project-wide grep). */
|
|
680
|
+
appendTreeNodes(
|
|
681
|
+
widgetKey: string,
|
|
682
|
+
newNodes: TreeNode[],
|
|
683
|
+
newItemKeys: string[] = [],
|
|
684
|
+
): boolean {
|
|
685
|
+
return this.mutate({
|
|
686
|
+
kind: "appendTreeNodes",
|
|
687
|
+
widgetKey,
|
|
688
|
+
newNodes,
|
|
689
|
+
newItemKeys,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Replace the entries of a `Raw` widget identified by `widgetKey`.
|
|
694
|
+
*
|
|
695
|
+
* Use this when a small piece of panel chrome (a label, a header,
|
|
696
|
+
* a status line) needs to change but the rest of the spec is
|
|
697
|
+
* unchanged — calling `set(...)` to push the whole spec just to
|
|
698
|
+
* flip a few characters would force a `js_to_json` walk of every
|
|
699
|
+
* other widget (and every `Tree` node) in the panel, which can
|
|
700
|
+
* block the JS thread for hundreds of ms on large panels. */
|
|
701
|
+
setRawEntries(widgetKey: string, entries: TextPropertyEntry[]): boolean {
|
|
702
|
+
return this.mutate({ kind: "setRawEntries", widgetKey, entries });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** Set the panel's focused widget by key. Passing a key that isn't
|
|
706
|
+
* a current tabbable is harmless — the next render clamps focus to
|
|
707
|
+
* the first tabbable. */
|
|
708
|
+
setFocusKey(widgetKey: string): boolean {
|
|
709
|
+
return this.mutate({ kind: "setFocusKey", widgetKey });
|
|
710
|
+
}
|
|
621
711
|
}
|
|
622
712
|
|
|
623
713
|
// =============================================================================
|
|
@@ -715,6 +805,16 @@ export class FloatingWidgetPanel {
|
|
|
715
805
|
return this.mutate({ kind: "setSelectedIndex", widgetKey, index });
|
|
716
806
|
}
|
|
717
807
|
|
|
808
|
+
/** Update a Text widget's completion popup candidates. Empty
|
|
809
|
+
* `items` closes the popup; non-empty opens it and resets the
|
|
810
|
+
* host-managed selection to index 0. */
|
|
811
|
+
setCompletions(
|
|
812
|
+
widgetKey: string,
|
|
813
|
+
items: Array<string | { value: string; kind?: string }>,
|
|
814
|
+
): boolean {
|
|
815
|
+
return this.mutate({ kind: "setCompletions", widgetKey, items });
|
|
816
|
+
}
|
|
817
|
+
|
|
718
818
|
setItems(
|
|
719
819
|
widgetKey: string,
|
|
720
820
|
items: TextPropertyEntry[],
|
|
@@ -730,6 +830,13 @@ export class FloatingWidgetPanel {
|
|
|
730
830
|
setCheckedKeys(widgetKey: string, checked: boolean, keys: string[]): boolean {
|
|
731
831
|
return this.mutate({ kind: "setCheckedKeys", widgetKey, checked, keys });
|
|
732
832
|
}
|
|
833
|
+
|
|
834
|
+
/** Set the panel's focused widget by key. Passing a key that isn't
|
|
835
|
+
* a current tabbable is harmless — the next render clamps focus to
|
|
836
|
+
* the first tabbable. */
|
|
837
|
+
setFocusKey(widgetKey: string): boolean {
|
|
838
|
+
return this.mutate({ kind: "setFocusKey", widgetKey });
|
|
839
|
+
}
|
|
733
840
|
}
|
|
734
841
|
|
|
735
842
|
// =============================================================================
|
package/plugins/live_diff.ts
CHANGED
|
@@ -73,19 +73,22 @@ const DEBOUNCE_MS = 75;
|
|
|
73
73
|
|
|
74
74
|
// Skip virtual-line rendering when either side is huge — line-by-line
|
|
75
75
|
// LCS would be too slow. Gutter glyphs still render via a degraded path.
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
76
|
+
// In practice the DP only runs over the diff's *middle* (common prefix
|
|
77
|
+
// and suffix are stripped first), so this cap rarely bites for typical
|
|
78
|
+
// "small edit to a large file" cases.
|
|
79
|
+
const MAX_DIFF_LINES = 100_000;
|
|
80
|
+
// Soft cap on the LCS DP table; past this we stop computing virtual
|
|
81
|
+
// lines. Applies to the post-prefix/suffix-stripped middle, not the
|
|
82
|
+
// whole file.
|
|
83
|
+
const MAX_DP_CELLS = 16_000_000;
|
|
79
84
|
|
|
80
85
|
// Similarity (Sørensen–Dice over character LCS) above which a 1:1
|
|
81
86
|
// modified pair is rendered as "modified" (bg-only highlight on the
|
|
82
87
|
// new line, no deletion virtual line). Below this we split the pair
|
|
83
88
|
// into a `removed` (virtual deletion line) + `added` (bg-highlighted)
|
|
84
89
|
// hunk pair so the change reads as a rewrite, not an in-place edit.
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
// by VS Code, IntelliJ and most diff viewers.
|
|
88
|
-
const SIMILARITY_THRESHOLD = 0.5;
|
|
90
|
+
// Tunable at runtime via `editor.getPluginApi("live-diff").setSimilarityThreshold(x)`.
|
|
91
|
+
let similarityThreshold = 0.95;
|
|
89
92
|
// Bail out of char-LCS on huge lines; cost is O(m * n).
|
|
90
93
|
const MAX_LINE_LCS_CHARS = 2000;
|
|
91
94
|
// Bail out of word-LCS when either side has more tokens than this;
|
|
@@ -127,6 +130,12 @@ interface Hunk {
|
|
|
127
130
|
* underline the actually-changed words on the new line. `undefined`
|
|
128
131
|
* for unrefined hunks and for `added`/`removed` hunks. */
|
|
129
132
|
wordRanges?: WordRange[][];
|
|
133
|
+
/** Old-side word ranges, one entry per `oldLines` line. Set on
|
|
134
|
+
* `removed` hunks emitted by `refineHunks` for high-similarity
|
|
135
|
+
* pairs where words were dropped/changed; passed to addVirtualLine
|
|
136
|
+
* as `textOverlays` so the deletion virtual line bolds + underlines
|
|
137
|
+
* the actually-removed words. */
|
|
138
|
+
oldWordRanges?: WordRange[][];
|
|
130
139
|
}
|
|
131
140
|
|
|
132
141
|
interface BufferDiffState {
|
|
@@ -559,29 +568,58 @@ function tokenize(s: string): Token[] {
|
|
|
559
568
|
return tokens;
|
|
560
569
|
}
|
|
561
570
|
|
|
571
|
+
/** Word-level diff result: byte ranges on each side that aren't part
|
|
572
|
+
* of the longest common token subsequence. `newRanges` drives the
|
|
573
|
+
* bold + underline overlay on the new (modified) line; `oldRanges`
|
|
574
|
+
* drives the same overlay on the deletion virtual line so removed
|
|
575
|
+
* words are visually called out, not just present in the gutter. */
|
|
576
|
+
interface WordDiff {
|
|
577
|
+
newRanges: WordRange[];
|
|
578
|
+
oldRanges: WordRange[];
|
|
579
|
+
}
|
|
580
|
+
|
|
562
581
|
/**
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
582
|
+
* Token-level LCS over the two lines. Returns the byte ranges of
|
|
583
|
+
* non-whitespace tokens on each side that aren't part of the LCS.
|
|
584
|
+
* Whitespace-only tokens are never highlighted (whitespace changes
|
|
585
|
+
* mid-word look like noise; whole-line whitespace edits are handled
|
|
586
|
+
* by the line-level diff). Adjacent unmatched non-whitespace tokens
|
|
587
|
+
* are coalesced into a single range so a renamed `foo.bar.baz`
|
|
588
|
+
* becomes one underline, not three.
|
|
570
589
|
*/
|
|
571
|
-
function computeWordDiff(oldS: string, newS: string):
|
|
590
|
+
function computeWordDiff(oldS: string, newS: string): WordDiff {
|
|
572
591
|
const oldTokens = tokenize(oldS);
|
|
573
592
|
const newTokens = tokenize(newS);
|
|
574
593
|
const m = oldTokens.length;
|
|
575
594
|
const n = newTokens.length;
|
|
576
|
-
if (n === 0) return [];
|
|
577
|
-
if (m
|
|
578
|
-
//
|
|
579
|
-
//
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
595
|
+
if (m === 0 && n === 0) return { newRanges: [], oldRanges: [] };
|
|
596
|
+
if (m > MAX_WORD_TOKENS || n > MAX_WORD_TOKENS) {
|
|
597
|
+
// Token DP would dwarf the line-level pass; degrade to "every
|
|
598
|
+
// non-whitespace token changed" on whichever side has tokens.
|
|
599
|
+
return {
|
|
600
|
+
newRanges: collapseRanges(
|
|
601
|
+
newTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
602
|
+
),
|
|
603
|
+
oldRanges: collapseRanges(
|
|
604
|
+
oldTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
605
|
+
),
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
if (m === 0) {
|
|
609
|
+
return {
|
|
610
|
+
newRanges: collapseRanges(
|
|
611
|
+
newTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
612
|
+
),
|
|
613
|
+
oldRanges: [],
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
if (n === 0) {
|
|
617
|
+
return {
|
|
618
|
+
newRanges: [],
|
|
619
|
+
oldRanges: collapseRanges(
|
|
620
|
+
oldTokens.filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
|
|
621
|
+
),
|
|
622
|
+
};
|
|
585
623
|
}
|
|
586
624
|
const stride = n + 1;
|
|
587
625
|
const dp: number[] = new Array((m + 1) * stride).fill(0);
|
|
@@ -597,13 +635,15 @@ function computeWordDiff(oldS: string, newS: string): WordRange[] {
|
|
|
597
635
|
}
|
|
598
636
|
}
|
|
599
637
|
}
|
|
600
|
-
// Backtrack to find which
|
|
601
|
-
const
|
|
638
|
+
// Backtrack to find which old/new tokens participate in the LCS.
|
|
639
|
+
const matchedOld: boolean[] = new Array(m).fill(false);
|
|
640
|
+
const matchedNew: boolean[] = new Array(n).fill(false);
|
|
602
641
|
let i = m;
|
|
603
642
|
let j = n;
|
|
604
643
|
while (i > 0 && j > 0) {
|
|
605
644
|
if (oldTokens[i - 1].text === newTokens[j - 1].text) {
|
|
606
|
-
|
|
645
|
+
matchedOld[i - 1] = true;
|
|
646
|
+
matchedNew[j - 1] = true;
|
|
607
647
|
i--;
|
|
608
648
|
j--;
|
|
609
649
|
} else if (dp[(i - 1) * stride + j] >= dp[i * stride + (j - 1)]) {
|
|
@@ -612,14 +652,23 @@ function computeWordDiff(oldS: string, newS: string): WordRange[] {
|
|
|
612
652
|
j--;
|
|
613
653
|
}
|
|
614
654
|
}
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
655
|
+
const unmatchedOf = (
|
|
656
|
+
tokens: Token[],
|
|
657
|
+
matched: boolean[],
|
|
658
|
+
): WordRange[] => {
|
|
659
|
+
const out: Token[] = [];
|
|
660
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
661
|
+
if (matched[k]) continue;
|
|
662
|
+
const t = tokens[k];
|
|
663
|
+
if (WHITESPACE_CHAR.test(t.text[0] ?? "")) continue;
|
|
664
|
+
out.push(t);
|
|
665
|
+
}
|
|
666
|
+
return collapseRanges(out);
|
|
667
|
+
};
|
|
668
|
+
return {
|
|
669
|
+
newRanges: unmatchedOf(newTokens, matchedNew),
|
|
670
|
+
oldRanges: unmatchedOf(oldTokens, matchedOld),
|
|
671
|
+
};
|
|
623
672
|
}
|
|
624
673
|
|
|
625
674
|
/** Merge adjacent or touching token ranges into a single range so
|
|
@@ -641,9 +690,12 @@ function collapseRanges(tokens: Token[]): WordRange[] {
|
|
|
641
690
|
* Post-process `opsToHunks` output: split low-similarity 1:1
|
|
642
691
|
* `modified` hunks into separate `removed` (virtual deletion line) +
|
|
643
692
|
* `added` (bg-highlighted) hunks. High-similarity pairs stay as
|
|
644
|
-
* `modified`
|
|
645
|
-
*
|
|
646
|
-
*
|
|
693
|
+
* `modified` and gain a `wordRanges` entry that drives the bold +
|
|
694
|
+
* underline word-level overlay on the new line. When a high-similarity
|
|
695
|
+
* pair also has *removed* (or otherwise unmatched) old-side words,
|
|
696
|
+
* we additionally emit a `removed` hunk carrying the old line plus
|
|
697
|
+
* `oldWordRanges` so the deletion virtual line shows the user which
|
|
698
|
+
* words went away — not just that *something* did.
|
|
647
699
|
*
|
|
648
700
|
* Hunks that don't have a 1:1 mapping (e.g. 3 old lines becoming 2
|
|
649
701
|
* new lines) keep their original shape — the pairing is ambiguous,
|
|
@@ -661,14 +713,28 @@ function refineHunks(hunks: Hunk[], newLines: string[]): Hunk[] {
|
|
|
661
713
|
const oldLine = h.oldLines[i];
|
|
662
714
|
const newLine = newLines[h.newStart + i] ?? "";
|
|
663
715
|
const sim = lineSimilarity(oldLine, newLine);
|
|
664
|
-
if (sim >=
|
|
665
|
-
const
|
|
716
|
+
if (sim >= similarityThreshold) {
|
|
717
|
+
const wd = computeWordDiff(oldLine, newLine);
|
|
718
|
+
// Always emit the in-place modified hunk (drives the new-line
|
|
719
|
+
// bg highlight + new-side word-diff). When old-side has
|
|
720
|
+
// unmatched non-WS tokens, also emit a deletion line above
|
|
721
|
+
// it carrying old-side word ranges so the user can see which
|
|
722
|
+
// words were removed/replaced.
|
|
723
|
+
if (wd.oldRanges.length > 0) {
|
|
724
|
+
out.push({
|
|
725
|
+
kind: "removed",
|
|
726
|
+
newStart: h.newStart + i,
|
|
727
|
+
newCount: 0,
|
|
728
|
+
oldLines: [oldLine],
|
|
729
|
+
oldWordRanges: [wd.oldRanges],
|
|
730
|
+
});
|
|
731
|
+
}
|
|
666
732
|
out.push({
|
|
667
733
|
kind: "modified",
|
|
668
734
|
newStart: h.newStart + i,
|
|
669
735
|
newCount: 1,
|
|
670
736
|
oldLines: [],
|
|
671
|
-
wordRanges: [
|
|
737
|
+
wordRanges: [wd.newRanges],
|
|
672
738
|
});
|
|
673
739
|
} else {
|
|
674
740
|
out.push({
|
|
@@ -860,6 +926,18 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
860
926
|
// No "- " prefix in the line text — the indicator goes in the
|
|
861
927
|
// gutter via `gutterGlyph` so it sits next to the deletion
|
|
862
928
|
// line itself, not on the source line that follows it.
|
|
929
|
+
// When `oldWordRanges` is set (from a high-similarity refined
|
|
930
|
+
// pair), bold + underline the actually-removed words so the
|
|
931
|
+
// user can see *what* changed, not just that something did.
|
|
932
|
+
const wordRanges = h.oldWordRanges?.[i];
|
|
933
|
+
const textOverlays = wordRanges
|
|
934
|
+
? wordRanges.map((r) => ({
|
|
935
|
+
start: r.start,
|
|
936
|
+
end: r.end,
|
|
937
|
+
bold: true,
|
|
938
|
+
underline: true,
|
|
939
|
+
}))
|
|
940
|
+
: [];
|
|
863
941
|
editor.addVirtualLine(
|
|
864
942
|
bid,
|
|
865
943
|
anchor,
|
|
@@ -869,6 +947,7 @@ function renderHunks(state: BufferDiffState, newLines: string[]): void {
|
|
|
869
947
|
bg: THEME.removedBg,
|
|
870
948
|
gutterGlyph: SYMBOLS.removed,
|
|
871
949
|
gutterColor: GUTTER_COLORS.removed,
|
|
950
|
+
textOverlays,
|
|
872
951
|
},
|
|
873
952
|
above,
|
|
874
953
|
NS_VLINE,
|
|
@@ -1226,6 +1305,42 @@ editor.registerCommand("%cmd.vs_default_branch", "%cmd.vs_default_branch_desc",
|
|
|
1226
1305
|
editor.registerCommand("%cmd.refresh", "%cmd.refresh_desc", "live_diff_refresh", null);
|
|
1227
1306
|
editor.registerCommand("%cmd.set_default", "%cmd.set_default_desc", "live_diff_set_default", null);
|
|
1228
1307
|
|
|
1308
|
+
// =============================================================================
|
|
1309
|
+
// Plugin API
|
|
1310
|
+
// =============================================================================
|
|
1311
|
+
|
|
1312
|
+
export type LiveDiffApi = {
|
|
1313
|
+
/** Lines whose Sørensen–Dice similarity ratio is at least this value
|
|
1314
|
+
* render as in-place "modified" (bg highlight + word-level diff on the
|
|
1315
|
+
* new line). Below this they split into a removed + added pair so the
|
|
1316
|
+
* change reads as a rewrite. Range 0..1; clamped. */
|
|
1317
|
+
setSimilarityThreshold(value: number): void;
|
|
1318
|
+
getSimilarityThreshold(): number;
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
declare global {
|
|
1322
|
+
interface FreshPluginRegistry {
|
|
1323
|
+
"live-diff": LiveDiffApi;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
editor.exportPluginApi("live-diff", {
|
|
1328
|
+
setSimilarityThreshold(value: number): void {
|
|
1329
|
+
const clamped = Math.max(0, Math.min(1, value));
|
|
1330
|
+
if (clamped === similarityThreshold) return;
|
|
1331
|
+
similarityThreshold = clamped;
|
|
1332
|
+
// Invalidate cached hunks so the next recompute repaints with the
|
|
1333
|
+
// new threshold instead of short-circuiting on the same hunksKey.
|
|
1334
|
+
for (const state of states.values()) {
|
|
1335
|
+
state.lastHunksKey = "";
|
|
1336
|
+
scheduleRecompute(state.bufferId).catch((e) =>
|
|
1337
|
+
editor.error(`live-diff: ${e}`),
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
},
|
|
1341
|
+
getSimilarityThreshold: () => similarityThreshold,
|
|
1342
|
+
} satisfies LiveDiffApi);
|
|
1343
|
+
|
|
1229
1344
|
// =============================================================================
|
|
1230
1345
|
// Initialization
|
|
1231
1346
|
// =============================================================================
|
|
@@ -100,33 +100,90 @@ const mergeState: MergeState = {
|
|
|
100
100
|
resultContent: "",
|
|
101
101
|
};
|
|
102
102
|
|
|
103
|
+
// Caches for the buffer_activated / after_file_open detection path.
|
|
104
|
+
// Without these, every tab switch re-spawns `git rev-parse` + `git ls-files`
|
|
105
|
+
// even though their answers rarely change. The detection path is purely a
|
|
106
|
+
// status-bar hint, so a stale answer for a few seconds is harmless.
|
|
107
|
+
const inGitRepoCache: Map<string, boolean> = new Map();
|
|
108
|
+
const detectionLastCheck: Map<string, number> = new Map();
|
|
109
|
+
const DETECTION_TTL_MS = 30_000;
|
|
110
|
+
const detectionInFlight: Map<string, Promise<void>> = new Map();
|
|
111
|
+
|
|
112
|
+
async function isInGitRepo(fileDir: string): Promise<boolean> {
|
|
113
|
+
const cached = inGitRepoCache.get(fileDir);
|
|
114
|
+
if (cached !== undefined) return cached;
|
|
115
|
+
const gitCheck = await editor.spawnProcess(
|
|
116
|
+
"git",
|
|
117
|
+
["rev-parse", "--is-inside-work-tree"],
|
|
118
|
+
fileDir,
|
|
119
|
+
);
|
|
120
|
+
const ok = gitCheck.exit_code === 0;
|
|
121
|
+
inGitRepoCache.set(fileDir, ok);
|
|
122
|
+
return ok;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function detectMergeConflictFor(
|
|
126
|
+
path: string,
|
|
127
|
+
statusOnDetect: () => string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (mergeState.isActive) return;
|
|
130
|
+
if (!path) return;
|
|
131
|
+
|
|
132
|
+
const last = detectionLastCheck.get(path);
|
|
133
|
+
if (last !== undefined && Date.now() - last < DETECTION_TTL_MS) return;
|
|
134
|
+
|
|
135
|
+
// Coalesce concurrent callers for the same path onto one in-flight check
|
|
136
|
+
// (e.g. buffer_activated + after_file_open firing back-to-back).
|
|
137
|
+
const existing = detectionInFlight.get(path);
|
|
138
|
+
if (existing) return existing;
|
|
139
|
+
|
|
140
|
+
const fileDir = editor.pathDirname(path);
|
|
141
|
+
const promise = (async () => {
|
|
142
|
+
try {
|
|
143
|
+
if (!(await isInGitRepo(fileDir))) return;
|
|
144
|
+
const lsFiles = await editor.spawnProcess(
|
|
145
|
+
"git",
|
|
146
|
+
["ls-files", "-u", path],
|
|
147
|
+
fileDir,
|
|
148
|
+
);
|
|
149
|
+
if (lsFiles.exit_code === 0 && lsFiles.stdout.trim().length > 0) {
|
|
150
|
+
editor.setStatus(statusOnDetect());
|
|
151
|
+
}
|
|
152
|
+
detectionLastCheck.set(path, Date.now());
|
|
153
|
+
} catch (_e) {
|
|
154
|
+
// Not in git repo or other error, ignore
|
|
155
|
+
} finally {
|
|
156
|
+
detectionInFlight.delete(path);
|
|
157
|
+
}
|
|
158
|
+
})();
|
|
159
|
+
detectionInFlight.set(path, promise);
|
|
160
|
+
return promise;
|
|
161
|
+
}
|
|
162
|
+
|
|
103
163
|
// =============================================================================
|
|
104
164
|
// Color Definitions
|
|
105
165
|
// =============================================================================
|
|
106
166
|
|
|
167
|
+
// Theme keys preferred over hard-coded RGB so the look tracks the
|
|
168
|
+
// active theme. `addOverlay` accepts theme key strings directly.
|
|
169
|
+
const OURS_FG_KEY = "editor.diff_add_bg"; // green-ish in most themes
|
|
170
|
+
const THEIRS_FG_KEY = "editor.diff_remove_bg"; // red-ish
|
|
171
|
+
const UNRESOLVED_FG_KEY = "ui.file_status_conflicted_fg";
|
|
172
|
+
|
|
107
173
|
const colors = {
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
diffMod: [50, 50, 100] as [number, number, number], // Blue for modifications
|
|
122
|
-
|
|
123
|
-
// Selection
|
|
124
|
-
selected: [80, 80, 120] as [number, number, number], // Selection highlight
|
|
125
|
-
|
|
126
|
-
// Buttons/actions
|
|
127
|
-
button: [100, 149, 237] as [number, number, number], // Cornflower blue
|
|
128
|
-
resolved: [100, 200, 100] as [number, number, number], // Green for resolved
|
|
129
|
-
unresolved: [200, 100, 100] as [number, number, number], // Red for unresolved
|
|
174
|
+
// RGB tuples retained for any future contributor who reaches for them
|
|
175
|
+
// — the active call sites use the theme keys above. None of these
|
|
176
|
+
// appear in the currently rendered UI.
|
|
177
|
+
oursHeader: [100, 200, 255] as [number, number, number],
|
|
178
|
+
theirsHeader: [255, 180, 100] as [number, number, number],
|
|
179
|
+
resultHeader: [150, 255, 150] as [number, number, number],
|
|
180
|
+
conflictBase: [70, 70, 70] as [number, number, number],
|
|
181
|
+
diffAdd: [50, 100, 50] as [number, number, number],
|
|
182
|
+
diffDel: [100, 50, 50] as [number, number, number],
|
|
183
|
+
diffMod: [50, 50, 100] as [number, number, number],
|
|
184
|
+
selected: [80, 80, 120] as [number, number, number],
|
|
185
|
+
button: [100, 149, 237] as [number, number, number],
|
|
186
|
+
resolved: [100, 200, 100] as [number, number, number],
|
|
130
187
|
};
|
|
131
188
|
|
|
132
189
|
// =============================================================================
|
|
@@ -950,7 +1007,7 @@ function highlightPanel(bufferId: number, side: "ours" | "theirs"): void {
|
|
|
950
1007
|
const lines = content.split("\n");
|
|
951
1008
|
|
|
952
1009
|
let byteOffset = 0;
|
|
953
|
-
const conflictColor = side === "ours" ?
|
|
1010
|
+
const conflictColor = side === "ours" ? OURS_FG_KEY : THEIRS_FG_KEY;
|
|
954
1011
|
|
|
955
1012
|
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
956
1013
|
const line = lines[lineIdx];
|
|
@@ -989,7 +1046,7 @@ function highlightResultPanel(bufferId: number): void {
|
|
|
989
1046
|
// Highlight conflict markers
|
|
990
1047
|
if (line.startsWith("<<<<<<<") || line.startsWith("=======") || line.startsWith(">>>>>>>")) {
|
|
991
1048
|
editor.addOverlay(bufferId, `merge-marker-${lineIdx}`, lineStart, lineEnd, {
|
|
992
|
-
fg:
|
|
1049
|
+
fg: UNRESOLVED_FG_KEY,
|
|
993
1050
|
underline: true,
|
|
994
1051
|
});
|
|
995
1052
|
}
|
|
@@ -1712,50 +1769,18 @@ registerHandler("merge_show_help", merge_show_help);
|
|
|
1712
1769
|
// =============================================================================
|
|
1713
1770
|
|
|
1714
1771
|
editor.on("buffer_activated", async (data) => {
|
|
1715
|
-
// Don't trigger if already in merge mode
|
|
1716
|
-
if (mergeState.isActive) return;
|
|
1717
|
-
|
|
1718
|
-
// Don't trigger for virtual buffers
|
|
1719
1772
|
const info = editor.getBufferInfo(data.buffer_id);
|
|
1720
1773
|
if (!info || !info.path) return;
|
|
1721
|
-
|
|
1722
|
-
// Get the directory of the file for running git commands
|
|
1723
|
-
const fileDir = editor.pathDirname(info.path);
|
|
1724
|
-
|
|
1725
|
-
// Check if we're in a git repo first
|
|
1726
|
-
try {
|
|
1727
|
-
const gitCheck = await editor.spawnProcess("git", ["rev-parse", "--is-inside-work-tree"], fileDir);
|
|
1728
|
-
if (gitCheck.exit_code !== 0) return;
|
|
1729
|
-
|
|
1730
|
-
// Check for unmerged entries
|
|
1731
|
-
const lsFiles = await editor.spawnProcess("git", ["ls-files", "-u", info.path], fileDir);
|
|
1732
|
-
if (lsFiles.exit_code === 0 && lsFiles.stdout.trim().length > 0) {
|
|
1733
|
-
editor.setStatus(editor.t("status.detected"));
|
|
1734
|
-
}
|
|
1735
|
-
} catch (e) {
|
|
1736
|
-
// Not in git repo or other error, ignore
|
|
1737
|
-
}
|
|
1774
|
+
await detectMergeConflictFor(info.path, () => editor.t("status.detected"));
|
|
1738
1775
|
});
|
|
1739
1776
|
editor.on("after_file_open", async (data) => {
|
|
1740
|
-
//
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
try {
|
|
1748
|
-
const gitCheck = await editor.spawnProcess("git", ["rev-parse", "--is-inside-work-tree"], fileDir);
|
|
1749
|
-
if (gitCheck.exit_code !== 0) return;
|
|
1750
|
-
|
|
1751
|
-
// Check for unmerged entries
|
|
1752
|
-
const lsFiles = await editor.spawnProcess("git", ["ls-files", "-u", data.path], fileDir);
|
|
1753
|
-
if (lsFiles.exit_code === 0 && lsFiles.stdout.trim().length > 0) {
|
|
1754
|
-
editor.setStatus(editor.t("status.detected_file", { path: data.path }));
|
|
1755
|
-
}
|
|
1756
|
-
} catch (e) {
|
|
1757
|
-
// Not in git repo or other error, ignore
|
|
1758
|
-
}
|
|
1777
|
+
// File-open is a strong signal that state may have changed externally
|
|
1778
|
+
// (e.g. user ran `git merge` then opened the conflicted file). Bypass the
|
|
1779
|
+
// per-path TTL so the detection still runs.
|
|
1780
|
+
detectionLastCheck.delete(data.path);
|
|
1781
|
+
await detectMergeConflictFor(data.path, () =>
|
|
1782
|
+
editor.t("status.detected_file", { path: data.path }),
|
|
1783
|
+
);
|
|
1759
1784
|
});
|
|
1760
1785
|
|
|
1761
1786
|
// =============================================================================
|