@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.
@@ -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
- return { kind: "raw", entries };
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
  // =============================================================================
@@ -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
- const MAX_DIFF_LINES = 20_000;
77
- // Soft cap on the LCS DP table; past this we stop computing virtual lines.
78
- const MAX_DP_CELLS = 4_000_000;
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
- // 0.5 matches `difflib.SequenceMatcher.ratio()`-style heuristics used
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
- * Compute the byte ranges of words on the new-side line that are not
564
- * part of the longest common token subsequence with the old-side
565
- * line. Whitespace-only tokens are never highlighted (whitespace
566
- * changes mid-word look like noise; whole-line whitespace edits are
567
- * handled by the line-level diff). Adjacent unmatched non-whitespace
568
- * tokens are coalesced into a single range so a renamed
569
- * `foo.bar.baz` becomes one underline, not three.
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): WordRange[] {
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 === 0 || m > MAX_WORD_TOKENS || n > MAX_WORD_TOKENS) {
578
- // Either nothing to compare against or the line is so long that
579
- // the token DP would dwarf the line-level pass. Mark every non-
580
- // whitespace token as changed so the user still sees *something*.
581
- return collapseRanges(
582
- newTokens
583
- .filter((t) => !WHITESPACE_CHAR.test(t.text[0] ?? "")),
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 newTokens are in the LCS pairing.
601
- const matched: boolean[] = new Array(n).fill(false);
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
- matched[j - 1] = true;
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 unmatched: Token[] = [];
616
- for (let k = 0; k < n; k++) {
617
- if (matched[k]) continue;
618
- const t = newTokens[k];
619
- if (WHITESPACE_CHAR.test(t.text[0] ?? "")) continue;
620
- unmatched.push(t);
621
- }
622
- return collapseRanges(unmatched);
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` but drop their old lines (so no virtual line renders)
645
- * and gain a `wordRanges` entry that drives the bold + underline
646
- * word-level overlay.
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 >= SIMILARITY_THRESHOLD) {
665
- const ranges = computeWordDiff(oldLine, newLine);
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: [ranges],
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
- // Panel headers
109
- oursHeader: [100, 200, 255] as [number, number, number], // Cyan for OURS
110
- theirsHeader: [255, 180, 100] as [number, number, number], // Orange for THEIRS
111
- resultHeader: [150, 255, 150] as [number, number, number], // Green for RESULT
112
-
113
- // Conflict highlighting
114
- conflictOurs: [50, 80, 100] as [number, number, number], // Blue-tinted background
115
- conflictTheirs: [100, 70, 50] as [number, number, number], // Orange-tinted background
116
- conflictBase: [70, 70, 70] as [number, number, number], // Gray for base
117
-
118
- // Intra-line diff colors
119
- diffAdd: [50, 100, 50] as [number, number, number], // Green for additions
120
- diffDel: [100, 50, 50] as [number, number, number], // Red for deletions
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" ? colors.conflictOurs : colors.conflictTheirs;
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: colors.unresolved,
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
- // Don't trigger if already in merge mode
1741
- if (mergeState.isActive) return;
1742
-
1743
- // Get the directory of the file for running git commands
1744
- const fileDir = editor.pathDirname(data.path);
1745
-
1746
- // Check if we're in a git repo first
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
  // =============================================================================