@fresh-editor/fresh-editor 0.2.25 → 0.3.1

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.
Files changed (82) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/plugins/astro-lsp.ts +6 -12
  5. package/plugins/audit_mode.i18n.json +14 -14
  6. package/plugins/audit_mode.ts +182 -146
  7. package/plugins/bash-lsp.ts +15 -22
  8. package/plugins/clangd-lsp.ts +15 -24
  9. package/plugins/clojure-lsp.ts +9 -12
  10. package/plugins/cmake-lsp.ts +9 -12
  11. package/plugins/code-tour.ts +15 -16
  12. package/plugins/config-schema.json +79 -6
  13. package/plugins/csharp_support.ts +25 -30
  14. package/plugins/css-lsp.ts +15 -22
  15. package/plugins/dart-lsp.ts +9 -12
  16. package/plugins/dashboard.ts +1903 -0
  17. package/plugins/devcontainer.i18n.json +1472 -0
  18. package/plugins/devcontainer.ts +2793 -0
  19. package/plugins/diagnostics_panel.ts +10 -17
  20. package/plugins/elixir-lsp.ts +9 -12
  21. package/plugins/erlang-lsp.ts +9 -12
  22. package/plugins/examples/bookmarks.ts +10 -16
  23. package/plugins/find_references.ts +5 -9
  24. package/plugins/flash.ts +577 -0
  25. package/plugins/fsharp-lsp.ts +9 -12
  26. package/plugins/git_explorer.ts +16 -20
  27. package/plugins/git_gutter.ts +65 -79
  28. package/plugins/git_log.i18n.json +14 -42
  29. package/plugins/git_log.ts +19 -9
  30. package/plugins/gleam-lsp.ts +9 -12
  31. package/plugins/go-lsp.ts +15 -22
  32. package/plugins/graphql-lsp.ts +9 -12
  33. package/plugins/haskell-lsp.ts +9 -12
  34. package/plugins/html-lsp.ts +15 -24
  35. package/plugins/java-lsp.ts +9 -12
  36. package/plugins/json-lsp.ts +15 -24
  37. package/plugins/julia-lsp.ts +9 -12
  38. package/plugins/kotlin-lsp.ts +15 -22
  39. package/plugins/latex-lsp.ts +9 -12
  40. package/plugins/lib/fresh.d.ts +603 -0
  41. package/plugins/lua-lsp.ts +15 -22
  42. package/plugins/markdown_compose.ts +132 -128
  43. package/plugins/markdown_source.ts +8 -10
  44. package/plugins/marksman-lsp.ts +9 -12
  45. package/plugins/merge_conflict.ts +15 -17
  46. package/plugins/nim-lsp.ts +9 -12
  47. package/plugins/nix-lsp.ts +9 -12
  48. package/plugins/nushell-lsp.ts +9 -12
  49. package/plugins/ocaml-lsp.ts +9 -12
  50. package/plugins/odin-lsp.ts +15 -22
  51. package/plugins/path_complete.ts +5 -6
  52. package/plugins/perl-lsp.ts +9 -12
  53. package/plugins/php-lsp.ts +15 -22
  54. package/plugins/pkg.ts +10 -21
  55. package/plugins/protobuf-lsp.ts +9 -12
  56. package/plugins/python-lsp.ts +15 -24
  57. package/plugins/r-lsp.ts +9 -12
  58. package/plugins/ruby-lsp.ts +15 -22
  59. package/plugins/rust-lsp.ts +18 -28
  60. package/plugins/scala-lsp.ts +9 -12
  61. package/plugins/schemas/theme.schema.json +126 -0
  62. package/plugins/search_replace.ts +10 -13
  63. package/plugins/solidity-lsp.ts +9 -12
  64. package/plugins/sql-lsp.ts +9 -12
  65. package/plugins/svelte-lsp.ts +9 -12
  66. package/plugins/swift-lsp.ts +9 -12
  67. package/plugins/tailwindcss-lsp.ts +9 -12
  68. package/plugins/templ-lsp.ts +9 -12
  69. package/plugins/terraform-lsp.ts +9 -12
  70. package/plugins/theme_editor.i18n.json +98 -14
  71. package/plugins/theme_editor.ts +156 -209
  72. package/plugins/toml-lsp.ts +15 -22
  73. package/plugins/tsconfig.json +100 -0
  74. package/plugins/typescript-lsp.ts +15 -24
  75. package/plugins/typst-lsp.ts +15 -22
  76. package/plugins/vi_mode.ts +77 -290
  77. package/plugins/vue-lsp.ts +9 -12
  78. package/plugins/yaml-lsp.ts +15 -22
  79. package/plugins/zig-lsp.ts +9 -12
  80. package/themes/high-contrast.json +2 -2
  81. package/themes/nord.json +4 -0
  82. package/themes/solarized-dark.json +4 -0
@@ -0,0 +1,577 @@
1
+ /// <reference path="./lib/fresh.d.ts" />
2
+ const editor = getEditor();
3
+
4
+ /**
5
+ * Flash Jump
6
+ *
7
+ * Label-based jump navigation, ported in spirit from flash.nvim.
8
+ *
9
+ * 1. User invokes the `Flash: Jump` command.
10
+ * 2. Each character typed extends a literal-substring pattern. Every
11
+ * visible match across **every split** gets a single-letter label.
12
+ * 3. Pressing a label moves the cursor to that match. If the match
13
+ * lives in a different split, focus is transferred to that split
14
+ * first. Backspace shrinks the pattern; Enter jumps to the
15
+ * closest match in the active split; Escape (or any non-character
16
+ * key) cancels and restores the prior cursor and mode.
17
+ *
18
+ * Labels are picked so that no label letter equals the next character
19
+ * after any visible match — this is the flash.nvim "skip" rule and
20
+ * guarantees that pressing a label is never ambiguous with continuing
21
+ * to type the pattern.
22
+ */
23
+
24
+ const NS_MATCH = "flash";
25
+ const NS_LABEL = "flash-label";
26
+ const VTEXT_PREFIX = "flash-";
27
+
28
+ // Pool ordered by keyboard comfort, like flash.nvim:
29
+ // 1. home row (asdfghjkl)
30
+ // 2. upper row (qwertyuiop) — minus 'e' (vowel; common search char)
31
+ // 3. lower row (zxcvbnm)
32
+ // Distance-sort then assigns the first labels to nearest matches, so
33
+ // the closest jump targets get the most comfortable keys. All
34
+ // lowercase: case-sensitive matching keeps the label letter from also
35
+ // being a valid pattern continuation, which matters for the skip rule.
36
+ const LABEL_POOL = "asdfghjklqwertyuiopzxcvbnm";
37
+
38
+ interface Match {
39
+ /** Byte offset where the match starts in its buffer. */
40
+ start: number;
41
+ /** Byte offset just past the end of the match. */
42
+ end: number;
43
+ /** Char index of the first match char in the split's viewport text. */
44
+ charIdx: number;
45
+ /** Char index just past the end of the match in the split's viewport text. */
46
+ charEnd: number;
47
+ /** The buffer this match lives in. */
48
+ bufferId: number;
49
+ /** The split currently displaying that buffer. */
50
+ splitId: number;
51
+ /** Assigned label letter, or undefined when out of label pool. */
52
+ label?: string;
53
+ }
54
+
55
+ interface SplitView {
56
+ splitId: number;
57
+ bufferId: number;
58
+ snap: ViewportSnapshot;
59
+ }
60
+
61
+ interface FlashState {
62
+ active: boolean;
63
+ pattern: string;
64
+ matches: Match[];
65
+ /** Buffers we've drawn decorations on — track for cleanup. */
66
+ touchedBuffers: Set<number>;
67
+ /** Map from matchKey (`bufferId:start:end`) to last-frame's label.
68
+ * Used by `assignLabels` to reuse the same letter for matches
69
+ * that survive a pattern change — flash.nvim's "stability" rule.
70
+ * Cleared on each fresh activation. */
71
+ prevLabelByKey: Map<string, string>;
72
+ /** Active-split's primary cursor at activation, used as distance origin. */
73
+ startCursor: number;
74
+ startBufferId: number;
75
+ startSplitId: number;
76
+ priorMode: string | null;
77
+ }
78
+
79
+ const state: FlashState = {
80
+ active: false,
81
+ pattern: "",
82
+ matches: [],
83
+ touchedBuffers: new Set<number>(),
84
+ prevLabelByKey: new Map<string, string>(),
85
+ startCursor: 0,
86
+ startBufferId: 0,
87
+ startSplitId: 0,
88
+ priorMode: null,
89
+ };
90
+
91
+ function matchKey(m: Match): string {
92
+ return m.bufferId + ":" + m.start + ":" + m.end;
93
+ }
94
+
95
+ // =============================================================================
96
+ // Byte-offset bookkeeping
97
+ // =============================================================================
98
+
99
+ // JS strings are UTF-16; the editor talks in UTF-8 byte offsets. Build a
100
+ // once-per-frame lookup so substring matches translate to buffer byte
101
+ // offsets in O(1). byteAt[i] is the byte offset of char i; byteAt has
102
+ // length = text.length + 1 so byteAt[text.length] is the total byte length.
103
+ function buildByteIndex(text: string): number[] {
104
+ const out = new Array<number>(text.length + 1);
105
+ out[0] = 0;
106
+ for (let i = 0; i < text.length; i++) {
107
+ const c = text.charCodeAt(i);
108
+ let b: number;
109
+ if (c < 0x80) b = 1;
110
+ else if (c < 0x800) b = 2;
111
+ else if (c >= 0xd800 && c <= 0xdbff) {
112
+ // High surrogate of a 4-byte codepoint; the paired low surrogate
113
+ // contributes 0 below.
114
+ b = 4;
115
+ } else if (c >= 0xdc00 && c <= 0xdfff) {
116
+ b = 0;
117
+ } else {
118
+ b = 3;
119
+ }
120
+ out[i + 1] = out[i] + b;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // =============================================================================
126
+ // Viewport read (one snapshot per split)
127
+ // =============================================================================
128
+
129
+ interface ViewportSnapshot {
130
+ text: string;
131
+ topByte: number;
132
+ byteAt: number[];
133
+ }
134
+
135
+ async function readSplitViewport(
136
+ bufferId: number,
137
+ topByte: number,
138
+ width: number,
139
+ height: number,
140
+ ): Promise<ViewportSnapshot | null> {
141
+ const bufLen = editor.getBufferLength(bufferId);
142
+ // Over-read by a generous margin (height × (width+4)), capped at
143
+ // buffer length. Over-read is harmless: matches outside the actual
144
+ // viewport just render off-screen and clearNamespace wipes them.
145
+ const estEnd = Math.min(bufLen, topByte + (height + 2) * (width + 4));
146
+ if (estEnd <= topByte) return null;
147
+ const text = await editor.getBufferText(bufferId, topByte, estEnd);
148
+ return { text, topByte, byteAt: buildByteIndex(text) };
149
+ }
150
+
151
+ async function readAllSplits(): Promise<SplitView[]> {
152
+ const splits = editor.listSplits();
153
+ const out: SplitView[] = [];
154
+ for (const s of splits) {
155
+ const snap = await readSplitViewport(
156
+ s.bufferId,
157
+ s.viewport.topByte,
158
+ s.viewport.width,
159
+ s.viewport.height,
160
+ );
161
+ if (snap) {
162
+ out.push({ splitId: s.splitId, bufferId: s.bufferId, snap });
163
+ }
164
+ }
165
+ return out;
166
+ }
167
+
168
+ // =============================================================================
169
+ // Matching (across every split)
170
+ // =============================================================================
171
+
172
+ function findMatchesInSplit(view: SplitView, pattern: string): Match[] {
173
+ if (!pattern) return [];
174
+ const out: Match[] = [];
175
+ let from = 0;
176
+ while (true) {
177
+ const i = view.snap.text.indexOf(pattern, from);
178
+ if (i < 0) break;
179
+ out.push({
180
+ start: view.snap.topByte + view.snap.byteAt[i],
181
+ end: view.snap.topByte + view.snap.byteAt[i + pattern.length],
182
+ charIdx: i,
183
+ charEnd: i + pattern.length,
184
+ bufferId: view.bufferId,
185
+ splitId: view.splitId,
186
+ });
187
+ // Allow overlapping advances by one char so e.g. pattern "aa" in
188
+ // "aaa" produces two matches; flash.nvim does the same.
189
+ from = i + 1;
190
+ }
191
+ return out;
192
+ }
193
+
194
+ function findMatches(views: SplitView[], pattern: string): Match[] {
195
+ const all: Match[] = [];
196
+ for (const v of views) {
197
+ for (const m of findMatchesInSplit(v, pattern)) {
198
+ all.push(m);
199
+ }
200
+ }
201
+ return all;
202
+ }
203
+
204
+ // Empty-pattern mode: label every visible word start.
205
+ //
206
+ // A "word start" is any alphanumeric / `_` char preceded by a non-word
207
+ // character (or sitting at the start of the viewport snapshot). Each
208
+ // becomes a 1-char synthetic match anchored at the word's first letter
209
+ // — pressing the assigned label teleports the cursor to that word.
210
+ // This is the "no-filter, jump anywhere visible" mode that flash.nvim
211
+ // ships with `min_pattern_length = 0`.
212
+ function isWordChar(ch: number): boolean {
213
+ return (
214
+ (ch >= 0x30 && ch <= 0x39) || // 0-9
215
+ (ch >= 0x41 && ch <= 0x5a) || // A-Z
216
+ (ch >= 0x61 && ch <= 0x7a) || // a-z
217
+ ch === 0x5f // _
218
+ );
219
+ }
220
+
221
+ function findWordStartMatchesInSplit(view: SplitView): Match[] {
222
+ const out: Match[] = [];
223
+ const text = view.snap.text;
224
+ let prevWord = false;
225
+ for (let i = 0; i < text.length; i++) {
226
+ const cur = isWordChar(text.charCodeAt(i));
227
+ if (cur && !prevWord) {
228
+ out.push({
229
+ start: view.snap.topByte + view.snap.byteAt[i],
230
+ end: view.snap.topByte + view.snap.byteAt[i + 1],
231
+ charIdx: i,
232
+ charEnd: i + 1,
233
+ bufferId: view.bufferId,
234
+ splitId: view.splitId,
235
+ });
236
+ }
237
+ prevWord = cur;
238
+ }
239
+ return out;
240
+ }
241
+
242
+ function findWordStartMatches(views: SplitView[]): Match[] {
243
+ const all: Match[] = [];
244
+ for (const v of views) {
245
+ for (const m of findWordStartMatchesInSplit(v)) {
246
+ all.push(m);
247
+ }
248
+ }
249
+ return all;
250
+ }
251
+
252
+ // =============================================================================
253
+ // Labeler — port of flash.nvim labeler.lua
254
+ // =============================================================================
255
+
256
+ // Build the set of label letters to skip:
257
+ //
258
+ // - In **search mode** (non-empty pattern): every char that appears
259
+ // immediately AFTER a visible match could be a valid pattern
260
+ // continuation. Pressing it must extend the pattern unambiguously,
261
+ // so it can't also be a label. Skip those letters.
262
+ //
263
+ // - In **word-start mode** (empty pattern): every char that is the
264
+ // FIRST letter of a visible word is reserved for "start a search
265
+ // with this letter". Pressing it must enter search mode, not jump.
266
+ // Skip those.
267
+ //
268
+ // Returns the set of letters to remove from the label pool.
269
+ function buildSkipSet(
270
+ matches: Match[],
271
+ views: SplitView[],
272
+ emptyPattern: boolean,
273
+ ): Set<string> {
274
+ const byBufferToText = new Map<number, string>();
275
+ for (const v of views) byBufferToText.set(v.bufferId, v.snap.text);
276
+ const skip = new Set<string>();
277
+ for (const m of matches) {
278
+ const text = byBufferToText.get(m.bufferId);
279
+ if (!text) continue;
280
+ const idx = emptyPattern ? m.charIdx : m.charEnd;
281
+ if (idx < text.length) {
282
+ const ch = text.charAt(idx);
283
+ // Pool is lowercase only. Skip the char and its lower-case form
284
+ // — the conservative "case-sensitive labels never collide with
285
+ // case-insensitive continuation" rule.
286
+ skip.add(ch);
287
+ skip.add(ch.toLowerCase());
288
+ }
289
+ }
290
+ return skip;
291
+ }
292
+
293
+ // Sort matches with active-split-first ordering. Within the active
294
+ // split, sort by byte distance from the start cursor (mimics
295
+ // flash.nvim's `distance = true`). Other splits go after, ordered by
296
+ // byte position. Ties are broken by start byte for determinism.
297
+ function sortMatches(
298
+ matches: Match[],
299
+ activeSplitId: number,
300
+ startCursor: number,
301
+ ): Match[] {
302
+ return [...matches].sort((a, b) => {
303
+ const aActive = a.splitId === activeSplitId ? 0 : 1;
304
+ const bActive = b.splitId === activeSplitId ? 0 : 1;
305
+ if (aActive !== bActive) return aActive - bActive;
306
+ if (aActive === 0) {
307
+ const da = Math.abs(a.start - startCursor);
308
+ const db = Math.abs(b.start - startCursor);
309
+ if (da !== db) return da - db;
310
+ } else {
311
+ if (a.splitId !== b.splitId) return a.splitId - b.splitId;
312
+ }
313
+ return a.start - b.start;
314
+ });
315
+ }
316
+
317
+ // Two-pass label assignment, mirroring flash.nvim's labeler:
318
+ //
319
+ // Pass 1 (stability): for each match that already had a label in
320
+ // the previous frame AND whose label is still in the pool, reuse it.
321
+ // This keeps a label visually anchored to the same target while the
322
+ // user types more characters to filter — typing extra chars only
323
+ // removes other labels, never re-shuffles the surviving ones.
324
+ //
325
+ // Pass 2 (proximity): walk the remaining matches in distance order
326
+ // from the cursor (active split first) and consume the rest of the
327
+ // pool. Closest unlabelled match → most comfortable remaining key.
328
+ function assignLabels(
329
+ matches: Match[],
330
+ views: SplitView[],
331
+ startCursor: number,
332
+ startSplitId: number,
333
+ emptyPattern: boolean,
334
+ prevLabelByKey: Map<string, string>,
335
+ ): Match[] {
336
+ if (matches.length === 0) return matches;
337
+ const skip = buildSkipSet(matches, views, emptyPattern);
338
+ const remaining = new Set<string>();
339
+ for (const c of LABEL_POOL) if (!skip.has(c)) remaining.add(c);
340
+
341
+ const sorted = sortMatches(matches, startSplitId, startCursor);
342
+
343
+ // Pass 1: stability — reuse labels for matches that survived.
344
+ for (const m of sorted) {
345
+ const prev = prevLabelByKey.get(matchKey(m));
346
+ if (prev && remaining.has(prev)) {
347
+ m.label = prev;
348
+ remaining.delete(prev);
349
+ }
350
+ }
351
+
352
+ // Pass 2: proximity — assign remaining pool letters to unlabelled
353
+ // matches in distance order. Iterate the pool in its native
354
+ // (comfort-ranked) order so home-row letters go to nearest matches.
355
+ const orderedRemaining: string[] = [];
356
+ for (const c of LABEL_POOL) if (remaining.has(c)) orderedRemaining.push(c);
357
+ let next = 0;
358
+ for (const m of sorted) {
359
+ if (m.label) continue;
360
+ if (next >= orderedRemaining.length) break;
361
+ m.label = orderedRemaining[next++];
362
+ }
363
+ return sorted;
364
+ }
365
+
366
+ // =============================================================================
367
+ // Render
368
+ // =============================================================================
369
+
370
+ function clearTouched(): void {
371
+ for (const buf of state.touchedBuffers) {
372
+ editor.clearNamespace(buf, NS_MATCH);
373
+ editor.clearNamespace(buf, NS_LABEL);
374
+ editor.clearConcealNamespace(buf, NS_LABEL);
375
+ // Legacy: pre-conceal versions of flash painted labels with
376
+ // virtual text. Sweep the namespace so a stale frame from an
377
+ // older plugin install doesn't leak across an upgrade.
378
+ editor.removeVirtualTextsByPrefix(buf, VTEXT_PREFIX);
379
+ }
380
+ state.touchedBuffers.clear();
381
+ }
382
+
383
+ // Compute the byte length of the char at `viewportText[charIdx]`
384
+ // using the snapshot's byteAt table. Returns 0 if `charIdx` is
385
+ // past the end of the snapshot — caller falls back to alternate
386
+ // label placement.
387
+ function nextCharByteLen(views: SplitView[], m: Match): number {
388
+ const v = views.find((sv) => sv.bufferId === m.bufferId);
389
+ if (!v) return 0;
390
+ const text = v.snap.text;
391
+ if (m.charEnd >= text.length) return 0;
392
+ // Skip newlines: overlaying a label on `\n` would corrupt line
393
+ // layout (the renderer can't substitute a non-newline glyph for
394
+ // a newline byte). flash.nvim does the same fallback.
395
+ if (text.charCodeAt(m.charEnd) === 0x0a /* \n */) return 0;
396
+ return v.snap.byteAt[m.charEnd + 1] - v.snap.byteAt[m.charEnd];
397
+ }
398
+
399
+ function redraw(matches: Match[], views: SplitView[]): void {
400
+ // Clear last frame's decorations on every buffer we touched, then
401
+ // repaint. Flash never accumulates state across iterations.
402
+ clearTouched();
403
+ for (const m of matches) {
404
+ state.touchedBuffers.add(m.bufferId);
405
+ editor.addOverlay(m.bufferId, NS_MATCH, m.start, m.end, {
406
+ bg: "search.match_bg",
407
+ fg: "search.match_fg",
408
+ bold: true,
409
+ });
410
+ if (!m.label) continue;
411
+
412
+ // Label rendering — overlay-style, no layout shift.
413
+ //
414
+ // flash.nvim's "overlay" placement paints the label letter ON
415
+ // TOP of the character right after the match, replacing it
416
+ // visually but leaving the buffer untouched. Two pieces:
417
+ //
418
+ // 1. addConceal(...) substitutes the next-char glyph with the
419
+ // label letter — same cell width, no text pushed sideways.
420
+ // 2. addOverlay(...) paints the magenta search.label_* style
421
+ // on the same cell so the substituted letter pops.
422
+ //
423
+ // When there's no usable next-char (end-of-viewport or the next
424
+ // char is a newline), fall back to inline virtual text. That
425
+ // edge case is rare and pushes only the line-end whitespace.
426
+ const nextLen = nextCharByteLen(views, m);
427
+ if (nextLen > 0) {
428
+ const labelStart = m.end;
429
+ const labelEnd = m.end + nextLen;
430
+ editor.addConceal(m.bufferId, NS_LABEL, labelStart, labelEnd, m.label);
431
+ editor.addOverlay(m.bufferId, NS_LABEL, labelStart, labelEnd, {
432
+ fg: "search.label_fg",
433
+ bg: "search.label_bg",
434
+ bold: true,
435
+ });
436
+ } else {
437
+ editor.addVirtualTextStyled(
438
+ m.bufferId,
439
+ VTEXT_PREFIX + String(m.bufferId) + ":" + String(m.start),
440
+ m.end,
441
+ m.label,
442
+ {
443
+ fg: "search.label_fg",
444
+ bg: "search.label_bg",
445
+ bold: true,
446
+ },
447
+ true, // before = true
448
+ );
449
+ }
450
+ }
451
+ }
452
+
453
+ // =============================================================================
454
+ // Jump
455
+ // =============================================================================
456
+
457
+ function jumpTo(m: Match): void {
458
+ if (m.splitId !== state.startSplitId) {
459
+ editor.focusSplit(m.splitId);
460
+ }
461
+ editor.setBufferCursor(m.bufferId, m.start);
462
+ }
463
+
464
+ // =============================================================================
465
+ // Main loop
466
+ // =============================================================================
467
+
468
+ async function flashJump(): Promise<void> {
469
+ if (state.active) return;
470
+
471
+ const startBufferId = editor.getActiveBufferId();
472
+ if (!startBufferId) return;
473
+ const startCursor = editor.getCursorPosition();
474
+ if (startCursor === null) return;
475
+ const startSplitId = editor.getActiveSplitId();
476
+
477
+ state.active = true;
478
+ state.startBufferId = startBufferId;
479
+ state.startSplitId = startSplitId;
480
+ state.startCursor = startCursor;
481
+ state.pattern = "";
482
+ state.matches = [];
483
+ state.touchedBuffers = new Set<number>();
484
+ state.prevLabelByKey = new Map<string, string>();
485
+ state.priorMode = editor.getEditorMode();
486
+
487
+ editor.setEditorMode("flash");
488
+ // Begin lossless key capture — keys typed between two `getNextKey()`
489
+ // iterations are buffered and replayed in order. Released in the
490
+ // `finally` below.
491
+ editor.beginKeyCapture();
492
+ // Short status string — long enough to be informative, short
493
+ // enough to survive status-bar truncation. Includes the current
494
+ // pattern so tests (and careful users) can confirm the plugin has
495
+ // accepted each typed key.
496
+ const setStatusForPattern = (): void => {
497
+ editor.setStatus("Flash[" + state.pattern + "]");
498
+ };
499
+ setStatusForPattern();
500
+
501
+ try {
502
+ while (true) {
503
+ const views = await readAllSplits();
504
+ // Empty pattern → label every visible word start ("jump
505
+ // anywhere" mode). Non-empty pattern → label every literal
506
+ // substring match.
507
+ const emptyPattern = state.pattern.length === 0;
508
+ const rawMatches = emptyPattern
509
+ ? findWordStartMatches(views)
510
+ : findMatches(views, state.pattern);
511
+ state.matches = assignLabels(
512
+ rawMatches,
513
+ views,
514
+ state.startCursor,
515
+ state.startSplitId,
516
+ emptyPattern,
517
+ state.prevLabelByKey,
518
+ );
519
+ // Snapshot the labels for next iteration's stability pass.
520
+ state.prevLabelByKey.clear();
521
+ for (const m of state.matches) {
522
+ if (m.label) state.prevLabelByKey.set(matchKey(m), m.label);
523
+ }
524
+ redraw(state.matches, views);
525
+
526
+ const ev = await editor.getNextKey();
527
+
528
+ if (ev.key === "escape") break;
529
+
530
+ if (ev.key === "enter") {
531
+ // Jump to the first (closest, active-split-preferred) match.
532
+ const target = state.matches[0];
533
+ if (target) jumpTo(target);
534
+ break;
535
+ }
536
+
537
+ if (ev.key === "backspace") {
538
+ if (state.pattern.length > 0) {
539
+ state.pattern = state.pattern.slice(0, -1);
540
+ }
541
+ setStatusForPattern();
542
+ continue;
543
+ }
544
+
545
+ // Plain single-character key (no modifiers). Could be a label
546
+ // press or a pattern extension.
547
+ if (ev.key.length === 1 && !ev.ctrl && !ev.alt && !ev.meta) {
548
+ const hit = state.matches.find((m) => m.label === ev.key);
549
+ if (hit) {
550
+ jumpTo(hit);
551
+ break;
552
+ }
553
+ state.pattern += ev.key;
554
+ setStatusForPattern();
555
+ continue;
556
+ }
557
+
558
+ // Anything else (arrow keys, function keys, modified keys) ends
559
+ // the session without jumping — keeps the cursor at startCursor.
560
+ break;
561
+ }
562
+ } finally {
563
+ editor.endKeyCapture();
564
+ clearTouched();
565
+ editor.setEditorMode(state.priorMode);
566
+ editor.setStatus("");
567
+ state.active = false;
568
+ }
569
+ }
570
+
571
+ registerHandler("flash_jump", flashJump);
572
+ editor.registerCommand(
573
+ "Flash: Jump",
574
+ "Jump to any visible match across every split",
575
+ "flash_jump",
576
+ null,
577
+ );
@@ -36,7 +36,8 @@ const INSTALL_COMMANDS = {
36
36
 
37
37
  let fsharpLspError: { serverCommand: string; message: string } | null = null;
38
38
 
39
- function on_fsharp_lsp_server_error(data: LspServerErrorData): void {
39
+
40
+ editor.on("lsp_server_error", (data) => {
40
41
  if (data.language !== "fsharp") {
41
42
  return;
42
43
  }
@@ -55,11 +56,10 @@ function on_fsharp_lsp_server_error(data: LspServerErrorData): void {
55
56
  } else {
56
57
  editor.setStatus(`F# LSP error: ${data.message}`);
57
58
  }
58
- }
59
- registerHandler("on_fsharp_lsp_server_error", on_fsharp_lsp_server_error);
60
- editor.on("lsp_server_error", "on_fsharp_lsp_server_error");
59
+ });
60
+
61
61
 
62
- function on_fsharp_lsp_status_clicked(data: LspStatusClickedData): void {
62
+ editor.on("lsp_status_clicked", (data) => {
63
63
  if (data.language !== "fsharp" || !fsharpLspError) {
64
64
  return;
65
65
  }
@@ -78,11 +78,10 @@ function on_fsharp_lsp_status_clicked(data: LspStatusClickedData): void {
78
78
  { id: "dismiss", label: "Dismiss (ESC)" },
79
79
  ],
80
80
  });
81
- }
82
- registerHandler("on_fsharp_lsp_status_clicked", on_fsharp_lsp_status_clicked);
83
- editor.on("lsp_status_clicked", "on_fsharp_lsp_status_clicked");
81
+ });
82
+
84
83
 
85
- function on_fsharp_lsp_action_result(data: ActionPopupResultData): void {
84
+ editor.on("action_popup_result", (data) => {
86
85
  if (data.popup_id !== "fsharp-lsp-help") {
87
86
  return;
88
87
  }
@@ -118,8 +117,6 @@ function on_fsharp_lsp_action_result(data: ActionPopupResultData): void {
118
117
  default:
119
118
  editor.debug(`fsharp-lsp: Unknown action: ${data.action_id}`);
120
119
  }
121
- }
122
- registerHandler("on_fsharp_lsp_action_result", on_fsharp_lsp_action_result);
123
- editor.on("action_popup_result", "on_fsharp_lsp_action_result");
120
+ });
124
121
 
125
122
  editor.debug("fsharp-lsp: Plugin loaded");
@@ -141,29 +141,25 @@ async function refreshGitExplorerDecorations() {
141
141
  }
142
142
  }
143
143
 
144
- function onGitExplorerAfterFileOpen() {
145
- refreshGitExplorerDecorations();
146
- }
147
- registerHandler("onGitExplorerAfterFileOpen", onGitExplorerAfterFileOpen);
148
144
 
149
- function onGitExplorerAfterFileSave() {
150
- refreshGitExplorerDecorations();
151
- }
152
- registerHandler("onGitExplorerAfterFileSave", onGitExplorerAfterFileSave);
153
145
 
154
- function onGitExplorerEditorInitialized() {
155
- refreshGitExplorerDecorations();
156
- }
157
- registerHandler("onGitExplorerEditorInitialized", onGitExplorerEditorInitialized);
158
146
 
159
- function onGitExplorerFocusGained() {
160
- refreshGitExplorerDecorations();
161
- }
162
- registerHandler("onGitExplorerFocusGained", onGitExplorerFocusGained);
163
147
 
164
- editor.on("after_file_open", "onGitExplorerAfterFileOpen");
165
- editor.on("after_file_save", "onGitExplorerAfterFileSave");
166
- editor.on("editor_initialized", "onGitExplorerEditorInitialized");
167
- editor.on("focus_gained", "onGitExplorerFocusGained");
148
+
149
+
150
+
151
+
152
+ editor.on("after_file_open", () => {
153
+ refreshGitExplorerDecorations();
154
+ });
155
+ editor.on("after_file_save", () => {
156
+ refreshGitExplorerDecorations();
157
+ });
158
+ editor.on("editor_initialized", () => {
159
+ refreshGitExplorerDecorations();
160
+ });
161
+ editor.on("focus_gained", () => {
162
+ refreshGitExplorerDecorations();
163
+ });
168
164
 
169
165
  refreshGitExplorerDecorations();