@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.
- package/CHANGELOG.md +216 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/plugins/astro-lsp.ts +6 -12
- package/plugins/audit_mode.i18n.json +14 -14
- package/plugins/audit_mode.ts +182 -146
- package/plugins/bash-lsp.ts +15 -22
- package/plugins/clangd-lsp.ts +15 -24
- package/plugins/clojure-lsp.ts +9 -12
- package/plugins/cmake-lsp.ts +9 -12
- package/plugins/code-tour.ts +15 -16
- package/plugins/config-schema.json +79 -6
- package/plugins/csharp_support.ts +25 -30
- package/plugins/css-lsp.ts +15 -22
- package/plugins/dart-lsp.ts +9 -12
- package/plugins/dashboard.ts +1903 -0
- package/plugins/devcontainer.i18n.json +1472 -0
- package/plugins/devcontainer.ts +2793 -0
- package/plugins/diagnostics_panel.ts +10 -17
- package/plugins/elixir-lsp.ts +9 -12
- package/plugins/erlang-lsp.ts +9 -12
- package/plugins/examples/bookmarks.ts +10 -16
- package/plugins/find_references.ts +5 -9
- package/plugins/flash.ts +577 -0
- package/plugins/fsharp-lsp.ts +9 -12
- package/plugins/git_explorer.ts +16 -20
- package/plugins/git_gutter.ts +65 -79
- package/plugins/git_log.i18n.json +14 -42
- package/plugins/git_log.ts +19 -9
- package/plugins/gleam-lsp.ts +9 -12
- package/plugins/go-lsp.ts +15 -22
- package/plugins/graphql-lsp.ts +9 -12
- package/plugins/haskell-lsp.ts +9 -12
- package/plugins/html-lsp.ts +15 -24
- package/plugins/java-lsp.ts +9 -12
- package/plugins/json-lsp.ts +15 -24
- package/plugins/julia-lsp.ts +9 -12
- package/plugins/kotlin-lsp.ts +15 -22
- package/plugins/latex-lsp.ts +9 -12
- package/plugins/lib/fresh.d.ts +603 -0
- package/plugins/lua-lsp.ts +15 -22
- package/plugins/markdown_compose.ts +132 -128
- package/plugins/markdown_source.ts +8 -10
- package/plugins/marksman-lsp.ts +9 -12
- package/plugins/merge_conflict.ts +15 -17
- package/plugins/nim-lsp.ts +9 -12
- package/plugins/nix-lsp.ts +9 -12
- package/plugins/nushell-lsp.ts +9 -12
- package/plugins/ocaml-lsp.ts +9 -12
- package/plugins/odin-lsp.ts +15 -22
- package/plugins/path_complete.ts +5 -6
- package/plugins/perl-lsp.ts +9 -12
- package/plugins/php-lsp.ts +15 -22
- package/plugins/pkg.ts +10 -21
- package/plugins/protobuf-lsp.ts +9 -12
- package/plugins/python-lsp.ts +15 -24
- package/plugins/r-lsp.ts +9 -12
- package/plugins/ruby-lsp.ts +15 -22
- package/plugins/rust-lsp.ts +18 -28
- package/plugins/scala-lsp.ts +9 -12
- package/plugins/schemas/theme.schema.json +126 -0
- package/plugins/search_replace.ts +10 -13
- package/plugins/solidity-lsp.ts +9 -12
- package/plugins/sql-lsp.ts +9 -12
- package/plugins/svelte-lsp.ts +9 -12
- package/plugins/swift-lsp.ts +9 -12
- package/plugins/tailwindcss-lsp.ts +9 -12
- package/plugins/templ-lsp.ts +9 -12
- package/plugins/terraform-lsp.ts +9 -12
- package/plugins/theme_editor.i18n.json +98 -14
- package/plugins/theme_editor.ts +156 -209
- package/plugins/toml-lsp.ts +15 -22
- package/plugins/tsconfig.json +100 -0
- package/plugins/typescript-lsp.ts +15 -24
- package/plugins/typst-lsp.ts +15 -22
- package/plugins/vi_mode.ts +77 -290
- package/plugins/vue-lsp.ts +9 -12
- package/plugins/yaml-lsp.ts +15 -22
- package/plugins/zig-lsp.ts +9 -12
- package/themes/high-contrast.json +2 -2
- package/themes/nord.json +4 -0
- package/themes/solarized-dark.json +4 -0
package/plugins/flash.ts
ADDED
|
@@ -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
|
+
);
|
package/plugins/fsharp-lsp.ts
CHANGED
|
@@ -36,7 +36,8 @@ const INSTALL_COMMANDS = {
|
|
|
36
36
|
|
|
37
37
|
let fsharpLspError: { serverCommand: string; message: string } | null = null;
|
|
38
38
|
|
|
39
|
-
|
|
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
|
-
|
|
60
|
-
editor.on("lsp_server_error", "on_fsharp_lsp_server_error");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
61
|
|
|
62
|
-
|
|
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
|
-
|
|
83
|
-
editor.on("lsp_status_clicked", "on_fsharp_lsp_status_clicked");
|
|
81
|
+
});
|
|
82
|
+
|
|
84
83
|
|
|
85
|
-
|
|
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");
|
package/plugins/git_explorer.ts
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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();
|