@fresh-editor/fresh-editor 0.1.75 → 0.1.76
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 +26 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/plugins/audit_mode.ts +9 -4
- package/plugins/buffer_modified.ts +1 -1
- package/plugins/calculator.ts +1 -1
- package/plugins/check-types.sh +41 -0
- package/plugins/clangd_support.ts +1 -1
- package/plugins/color_highlighter.ts +4 -1
- package/plugins/config-schema.json +44 -2
- package/plugins/diagnostics_panel.i18n.json +52 -52
- package/plugins/diagnostics_panel.ts +168 -540
- package/plugins/find_references.ts +82 -324
- package/plugins/git_blame.i18n.json +260 -247
- package/plugins/git_blame.ts +4 -9
- package/plugins/git_find_file.ts +42 -270
- package/plugins/git_grep.ts +50 -167
- package/plugins/git_gutter.ts +1 -1
- package/plugins/git_log.ts +4 -11
- package/plugins/lib/finder.ts +1499 -0
- package/plugins/lib/fresh.d.ts +93 -17
- package/plugins/lib/index.ts +14 -0
- package/plugins/lib/navigation-controller.ts +1 -1
- package/plugins/lib/panel-manager.ts +7 -13
- package/plugins/lib/results-panel.ts +914 -0
- package/plugins/lib/search-utils.ts +343 -0
- package/plugins/lib/virtual-buffer-factory.ts +3 -2
- package/plugins/live_grep.ts +56 -379
- package/plugins/markdown_compose.ts +1 -17
- package/plugins/merge_conflict.ts +16 -14
- package/plugins/path_complete.ts +1 -1
- package/plugins/search_replace.i18n.json +13 -13
- package/plugins/search_replace.ts +11 -9
- package/plugins/theme_editor.ts +15 -9
- package/plugins/todo_highlighter.ts +1 -0
- package/plugins/vi_mode.ts +9 -5
- package/plugins/welcome.ts +1 -1
|
@@ -0,0 +1,1499 @@
|
|
|
1
|
+
/// <reference path="./fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified Finder Abstraction for Fresh Editor Plugins
|
|
5
|
+
*
|
|
6
|
+
* Provides a single API for "find something and navigate to it" workflows.
|
|
7
|
+
* Inspired by VSCode's QuickPick API and Neovim's Telescope.nvim.
|
|
8
|
+
*
|
|
9
|
+
* Key features:
|
|
10
|
+
* - Prompt mode for interactive search (Live Grep, Git Grep, Git Find File)
|
|
11
|
+
* - Panel mode for displaying results (Find References)
|
|
12
|
+
* - Live panel mode for reactive data (Diagnostics)
|
|
13
|
+
* - Built-in fuzzy filtering
|
|
14
|
+
* - Automatic preview panel management
|
|
15
|
+
* - Automatic debouncing and process cancellation
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const finder = new Finder(editor, {
|
|
20
|
+
* id: "live-grep",
|
|
21
|
+
* format: (match) => ({
|
|
22
|
+
* label: `${match.file}:${match.line}`,
|
|
23
|
+
* description: match.content.trim(),
|
|
24
|
+
* location: { file: match.file, line: match.line, column: match.column },
|
|
25
|
+
* }),
|
|
26
|
+
* preview: true,
|
|
27
|
+
* });
|
|
28
|
+
*
|
|
29
|
+
* finder.prompt({
|
|
30
|
+
* title: "Search:",
|
|
31
|
+
* source: { mode: "search", search: runRipgrep, debounceMs: 150 },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { Location, RGB } from "./types.ts";
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Core Types
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How a result should be displayed
|
|
44
|
+
*/
|
|
45
|
+
export interface DisplayEntry {
|
|
46
|
+
/** Primary text (e.g., "src/main.rs:42") */
|
|
47
|
+
label: string;
|
|
48
|
+
/** Secondary text (e.g., code snippet) */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Location for preview and navigation */
|
|
51
|
+
location?: Location;
|
|
52
|
+
/** Severity for visual styling */
|
|
53
|
+
severity?: "error" | "warning" | "info" | "hint";
|
|
54
|
+
/** Custom metadata */
|
|
55
|
+
metadata?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Data source for search mode (external command per query)
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchSource<T> {
|
|
62
|
+
mode: "search";
|
|
63
|
+
/** Function that returns a ProcessHandle or Promise of results */
|
|
64
|
+
search: (query: string) => ProcessHandle | Promise<T[]>;
|
|
65
|
+
/** Debounce delay in ms (default: 150) */
|
|
66
|
+
debounceMs?: number;
|
|
67
|
+
/** Minimum query length to trigger search (default: 2) */
|
|
68
|
+
minQueryLength?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Data source for filter mode (load once, filter client-side)
|
|
73
|
+
*/
|
|
74
|
+
export interface FilterSource<T> {
|
|
75
|
+
mode: "filter";
|
|
76
|
+
/** Function to load all items */
|
|
77
|
+
load: () => Promise<T[]>;
|
|
78
|
+
/** Optional custom filter function (default: fuzzy match on formatted label) */
|
|
79
|
+
filter?: (items: T[], query: string) => T[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Preview configuration
|
|
84
|
+
*/
|
|
85
|
+
export interface PreviewConfig {
|
|
86
|
+
enabled: boolean;
|
|
87
|
+
/** Lines of context before and after (default: 5) */
|
|
88
|
+
contextLines?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Main Finder configuration
|
|
93
|
+
*/
|
|
94
|
+
export interface FinderConfig<T> {
|
|
95
|
+
/** Unique identifier (used for prompt_type, panel IDs) */
|
|
96
|
+
id: string;
|
|
97
|
+
|
|
98
|
+
/** Transform raw result to display format */
|
|
99
|
+
format: (item: T, index: number) => DisplayEntry;
|
|
100
|
+
|
|
101
|
+
/** Preview configuration (default: auto-enabled if format returns location) */
|
|
102
|
+
preview?: boolean | PreviewConfig;
|
|
103
|
+
|
|
104
|
+
/** Maximum results to display (default: 100) */
|
|
105
|
+
maxResults?: number;
|
|
106
|
+
|
|
107
|
+
/** Custom selection handler (default: open file at location) */
|
|
108
|
+
onSelect?: (item: T, entry: DisplayEntry) => void;
|
|
109
|
+
|
|
110
|
+
/** Panel-specific: group results by file */
|
|
111
|
+
groupBy?: "file" | "severity" | "none";
|
|
112
|
+
|
|
113
|
+
/** Panel-specific: sync cursor with editor */
|
|
114
|
+
syncWithEditor?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Options for prompt-based display
|
|
119
|
+
*/
|
|
120
|
+
export interface PromptOptions<T> {
|
|
121
|
+
title: string;
|
|
122
|
+
source: SearchSource<T> | FilterSource<T>;
|
|
123
|
+
/** Initial query value */
|
|
124
|
+
initialQuery?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Options for panel-based display (static data)
|
|
129
|
+
*/
|
|
130
|
+
export interface PanelOptions<T> {
|
|
131
|
+
title: string;
|
|
132
|
+
items: T[];
|
|
133
|
+
/** Split ratio (default: 0.3) */
|
|
134
|
+
ratio?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Provider interface for live panel data
|
|
139
|
+
*/
|
|
140
|
+
export interface FinderProvider<T> {
|
|
141
|
+
/** Get current items */
|
|
142
|
+
getItems(): T[];
|
|
143
|
+
/** Subscribe to changes, returns unsubscribe function */
|
|
144
|
+
subscribe(callback: () => void): () => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Options for live panel display (provider-based)
|
|
149
|
+
*/
|
|
150
|
+
export interface LivePanelOptions<T> {
|
|
151
|
+
title: string;
|
|
152
|
+
provider: FinderProvider<T>;
|
|
153
|
+
/** Split ratio (default: 0.3) */
|
|
154
|
+
ratio?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Colors
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
const colors = {
|
|
162
|
+
selected: [80, 80, 120] as RGB,
|
|
163
|
+
location: [150, 255, 150] as RGB,
|
|
164
|
+
help: [150, 150, 150] as RGB,
|
|
165
|
+
title: [200, 200, 255] as RGB,
|
|
166
|
+
error: [255, 100, 100] as RGB,
|
|
167
|
+
warning: [255, 200, 100] as RGB,
|
|
168
|
+
info: [100, 200, 255] as RGB,
|
|
169
|
+
hint: [150, 150, 150] as RGB,
|
|
170
|
+
fileHeader: [180, 180, 255] as RGB,
|
|
171
|
+
match: [255, 255, 150] as RGB,
|
|
172
|
+
context: [180, 180, 180] as RGB,
|
|
173
|
+
header: [200, 200, 255] as RGB,
|
|
174
|
+
separator: [100, 100, 100] as RGB,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Fuzzy Filter
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Score a fuzzy match (higher is better, -1 means no match)
|
|
183
|
+
*/
|
|
184
|
+
function fuzzyScore(str: string, pattern: string): number {
|
|
185
|
+
if (pattern === "") return 0;
|
|
186
|
+
|
|
187
|
+
str = str.toLowerCase();
|
|
188
|
+
pattern = pattern.toLowerCase();
|
|
189
|
+
|
|
190
|
+
let score = 0;
|
|
191
|
+
let strIdx = 0;
|
|
192
|
+
let patIdx = 0;
|
|
193
|
+
let consecutiveMatches = 0;
|
|
194
|
+
let lastMatchIdx = -1;
|
|
195
|
+
|
|
196
|
+
while (strIdx < str.length && patIdx < pattern.length) {
|
|
197
|
+
if (str[strIdx] === pattern[patIdx]) {
|
|
198
|
+
// Bonus for consecutive matches
|
|
199
|
+
if (lastMatchIdx === strIdx - 1) {
|
|
200
|
+
consecutiveMatches++;
|
|
201
|
+
score += consecutiveMatches * 10;
|
|
202
|
+
} else {
|
|
203
|
+
consecutiveMatches = 1;
|
|
204
|
+
score += 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Bonus for matching at start of path segments
|
|
208
|
+
if (
|
|
209
|
+
strIdx === 0 ||
|
|
210
|
+
str[strIdx - 1] === "/" ||
|
|
211
|
+
str[strIdx - 1] === "_" ||
|
|
212
|
+
str[strIdx - 1] === "-"
|
|
213
|
+
) {
|
|
214
|
+
score += 15;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Bonus for matching filename (after last /)
|
|
218
|
+
const lastSlash = str.lastIndexOf("/");
|
|
219
|
+
if (strIdx > lastSlash) {
|
|
220
|
+
score += 5;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lastMatchIdx = strIdx;
|
|
224
|
+
patIdx++;
|
|
225
|
+
}
|
|
226
|
+
strIdx++;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Penalty for longer paths
|
|
230
|
+
score -= str.length * 0.1;
|
|
231
|
+
|
|
232
|
+
return patIdx >= pattern.length ? score : -1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Default fuzzy filter implementation
|
|
237
|
+
*/
|
|
238
|
+
export function defaultFuzzyFilter<T>(
|
|
239
|
+
items: T[],
|
|
240
|
+
query: string,
|
|
241
|
+
format: (item: T, index: number) => DisplayEntry,
|
|
242
|
+
maxResults: number = 100
|
|
243
|
+
): T[] {
|
|
244
|
+
if (query === "" || query.trim() === "") {
|
|
245
|
+
return items.slice(0, maxResults);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const scored: Array<{ item: T; score: number }> = [];
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < items.length; i++) {
|
|
251
|
+
const entry = format(items[i], i);
|
|
252
|
+
const score = fuzzyScore(entry.label, query);
|
|
253
|
+
if (score > 0) {
|
|
254
|
+
scored.push({ item: items[i], score });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Stop early if we have enough high-quality matches
|
|
258
|
+
if (scored.length >= 500) {
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Sort by score descending
|
|
264
|
+
scored.sort((a, b) => b.score - a.score);
|
|
265
|
+
|
|
266
|
+
return scored.slice(0, maxResults).map((s) => s.item);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ============================================================================
|
|
270
|
+
// Parse Utilities
|
|
271
|
+
// ============================================================================
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Parse a grep-style output line (file:line:column:content)
|
|
275
|
+
*/
|
|
276
|
+
export function parseGrepLine(line: string): {
|
|
277
|
+
file: string;
|
|
278
|
+
line: number;
|
|
279
|
+
column: number;
|
|
280
|
+
content: string;
|
|
281
|
+
} | null {
|
|
282
|
+
const match = line.match(/^([^:]+):(\d+):(\d+):(.*)$/);
|
|
283
|
+
if (match) {
|
|
284
|
+
return {
|
|
285
|
+
file: match[1],
|
|
286
|
+
line: parseInt(match[2], 10),
|
|
287
|
+
column: parseInt(match[3], 10),
|
|
288
|
+
content: match[4],
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Parse ripgrep/grep output into results array
|
|
296
|
+
*/
|
|
297
|
+
export function parseGrepOutput(
|
|
298
|
+
stdout: string,
|
|
299
|
+
maxResults: number = 100
|
|
300
|
+
): Array<{ file: string; line: number; column: number; content: string }> {
|
|
301
|
+
const results: Array<{
|
|
302
|
+
file: string;
|
|
303
|
+
line: number;
|
|
304
|
+
column: number;
|
|
305
|
+
content: string;
|
|
306
|
+
}> = [];
|
|
307
|
+
|
|
308
|
+
for (const line of stdout.split("\n")) {
|
|
309
|
+
if (!line.trim()) continue;
|
|
310
|
+
const match = parseGrepLine(line);
|
|
311
|
+
if (match) {
|
|
312
|
+
results.push(match);
|
|
313
|
+
if (results.length >= maxResults) {
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return results;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================================================
|
|
323
|
+
// Internal State Types
|
|
324
|
+
// ============================================================================
|
|
325
|
+
|
|
326
|
+
interface PromptState<T> {
|
|
327
|
+
results: T[];
|
|
328
|
+
entries: DisplayEntry[];
|
|
329
|
+
lastQuery: string;
|
|
330
|
+
searchVersion: number;
|
|
331
|
+
currentSearch: ProcessHandle | null;
|
|
332
|
+
pendingKill: Promise<boolean> | null;
|
|
333
|
+
originalSplitId: number | null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface PreviewState {
|
|
337
|
+
bufferId: number | null;
|
|
338
|
+
splitId: number | null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface PanelState<T> {
|
|
342
|
+
bufferId: number | null;
|
|
343
|
+
splitId: number | null;
|
|
344
|
+
sourceSplitId: number | null;
|
|
345
|
+
items: T[];
|
|
346
|
+
entries: DisplayEntry[];
|
|
347
|
+
cursorLine: number;
|
|
348
|
+
cachedContent: string;
|
|
349
|
+
lineToItemIndex: Map<number, number>;
|
|
350
|
+
unsubscribe: (() => void) | null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ============================================================================
|
|
354
|
+
// Finder Class
|
|
355
|
+
// ============================================================================
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Unified Finder for "find something and navigate to it" workflows
|
|
359
|
+
*/
|
|
360
|
+
export class Finder<T> {
|
|
361
|
+
private config: FinderConfig<T>;
|
|
362
|
+
private editor: EditorAPI;
|
|
363
|
+
|
|
364
|
+
// Prompt mode state
|
|
365
|
+
private promptState: PromptState<T> = {
|
|
366
|
+
results: [],
|
|
367
|
+
entries: [],
|
|
368
|
+
lastQuery: "",
|
|
369
|
+
searchVersion: 0,
|
|
370
|
+
currentSearch: null,
|
|
371
|
+
pendingKill: null,
|
|
372
|
+
originalSplitId: null,
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// Preview state (shared between prompt and panel modes)
|
|
376
|
+
private previewState: PreviewState = {
|
|
377
|
+
bufferId: null,
|
|
378
|
+
splitId: null,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
// Panel mode state
|
|
382
|
+
private panelState: PanelState<T> = {
|
|
383
|
+
bufferId: null,
|
|
384
|
+
splitId: null,
|
|
385
|
+
sourceSplitId: null,
|
|
386
|
+
items: [],
|
|
387
|
+
entries: [],
|
|
388
|
+
cursorLine: 1,
|
|
389
|
+
cachedContent: "",
|
|
390
|
+
lineToItemIndex: new Map(),
|
|
391
|
+
unsubscribe: null,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
// Mode flags
|
|
395
|
+
private isPromptMode = false;
|
|
396
|
+
private isPanelMode = false;
|
|
397
|
+
|
|
398
|
+
// Handler names (for cleanup)
|
|
399
|
+
private handlerPrefix: string;
|
|
400
|
+
private modeName: string;
|
|
401
|
+
private previewModeName: string;
|
|
402
|
+
|
|
403
|
+
// Current source (for prompt mode)
|
|
404
|
+
private currentSource: SearchSource<T> | FilterSource<T> | null = null;
|
|
405
|
+
private allItems: T[] = []; // For filter mode
|
|
406
|
+
|
|
407
|
+
constructor(editor: EditorAPI, config: FinderConfig<T>) {
|
|
408
|
+
this.editor = editor;
|
|
409
|
+
this.config = {
|
|
410
|
+
maxResults: 100,
|
|
411
|
+
groupBy: "none",
|
|
412
|
+
syncWithEditor: false,
|
|
413
|
+
...config,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
this.handlerPrefix = `_finder_${config.id}`;
|
|
417
|
+
this.modeName = `${config.id}-results`;
|
|
418
|
+
this.previewModeName = `${config.id}-preview`;
|
|
419
|
+
|
|
420
|
+
// Register handlers
|
|
421
|
+
this.registerPromptHandlers();
|
|
422
|
+
this.registerPanelHandlers();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ==========================================================================
|
|
426
|
+
// Public API
|
|
427
|
+
// ==========================================================================
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Check if the finder is currently open
|
|
431
|
+
*/
|
|
432
|
+
get isOpen(): boolean {
|
|
433
|
+
return this.isPromptMode || this.isPanelMode;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Start interactive prompt mode
|
|
438
|
+
*/
|
|
439
|
+
prompt(options: PromptOptions<T>): void {
|
|
440
|
+
this.isPromptMode = true;
|
|
441
|
+
this.isPanelMode = false;
|
|
442
|
+
this.currentSource = options.source;
|
|
443
|
+
|
|
444
|
+
// Reset state
|
|
445
|
+
this.promptState = {
|
|
446
|
+
results: [],
|
|
447
|
+
entries: [],
|
|
448
|
+
lastQuery: "",
|
|
449
|
+
searchVersion: 0,
|
|
450
|
+
currentSearch: null,
|
|
451
|
+
pendingKill: null,
|
|
452
|
+
originalSplitId: this.editor.getActiveSplitId(),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// For filter mode, load items upfront
|
|
456
|
+
if (options.source.mode === "filter") {
|
|
457
|
+
this.loadFilterItems(options.source);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Start the prompt
|
|
461
|
+
if (options.initialQuery) {
|
|
462
|
+
this.editor.startPromptWithInitial(
|
|
463
|
+
options.title,
|
|
464
|
+
this.config.id,
|
|
465
|
+
options.initialQuery
|
|
466
|
+
);
|
|
467
|
+
} else {
|
|
468
|
+
this.editor.startPrompt(options.title, this.config.id);
|
|
469
|
+
}
|
|
470
|
+
this.editor.setStatus("Type to search...");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Show static results in panel
|
|
475
|
+
*/
|
|
476
|
+
async panel(options: PanelOptions<T>): Promise<void> {
|
|
477
|
+
this.isPromptMode = false;
|
|
478
|
+
this.isPanelMode = true;
|
|
479
|
+
|
|
480
|
+
// Save source context
|
|
481
|
+
this.panelState.sourceSplitId = this.editor.getActiveSplitId();
|
|
482
|
+
this.panelState.items = options.items;
|
|
483
|
+
this.panelState.entries = options.items.map((item, i) =>
|
|
484
|
+
this.config.format(item, i)
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
await this.showPanel(options.title, options.ratio ?? 0.3);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Show live-updating results in panel
|
|
492
|
+
*/
|
|
493
|
+
async livePanel(options: LivePanelOptions<T>): Promise<void> {
|
|
494
|
+
this.isPromptMode = false;
|
|
495
|
+
this.isPanelMode = true;
|
|
496
|
+
|
|
497
|
+
// Save source context
|
|
498
|
+
this.panelState.sourceSplitId = this.editor.getActiveSplitId();
|
|
499
|
+
|
|
500
|
+
// Initial load
|
|
501
|
+
this.panelState.items = options.provider.getItems();
|
|
502
|
+
this.panelState.entries = this.panelState.items.map((item, i) =>
|
|
503
|
+
this.config.format(item, i)
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
// Subscribe to updates
|
|
507
|
+
this.panelState.unsubscribe = options.provider.subscribe(() => {
|
|
508
|
+
if (this.isPanelMode && this.panelState.bufferId !== null) {
|
|
509
|
+
this.panelState.items = options.provider.getItems();
|
|
510
|
+
this.panelState.entries = this.panelState.items.map((item, i) =>
|
|
511
|
+
this.config.format(item, i)
|
|
512
|
+
);
|
|
513
|
+
this.refreshPanel(options.title);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
await this.showPanel(options.title, options.ratio ?? 0.3);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Close the finder (prompt or panel)
|
|
522
|
+
*/
|
|
523
|
+
close(): void {
|
|
524
|
+
if (this.isPromptMode) {
|
|
525
|
+
this.closePrompt();
|
|
526
|
+
}
|
|
527
|
+
if (this.isPanelMode) {
|
|
528
|
+
this.closePanel();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Update panel title (for live panels)
|
|
534
|
+
*/
|
|
535
|
+
updateTitle(title: string): void {
|
|
536
|
+
if (this.isPanelMode && this.panelState.bufferId !== null) {
|
|
537
|
+
this.refreshPanel(title);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ==========================================================================
|
|
542
|
+
// Prompt Mode Implementation
|
|
543
|
+
// ==========================================================================
|
|
544
|
+
|
|
545
|
+
private registerPromptHandlers(): void {
|
|
546
|
+
const self = this;
|
|
547
|
+
const id = this.config.id;
|
|
548
|
+
|
|
549
|
+
// Handle prompt input changes
|
|
550
|
+
(globalThis as Record<string, unknown>)[`${this.handlerPrefix}_changed`] =
|
|
551
|
+
function (args: { prompt_type: string; input: string }): boolean {
|
|
552
|
+
if (args.prompt_type !== id) {
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
self.onPromptChanged(args.input);
|
|
556
|
+
return true;
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
// Handle selection changes
|
|
560
|
+
(globalThis as Record<string, unknown>)[
|
|
561
|
+
`${this.handlerPrefix}_selection`
|
|
562
|
+
] = function (args: {
|
|
563
|
+
prompt_type: string;
|
|
564
|
+
selected_index: number;
|
|
565
|
+
}): boolean {
|
|
566
|
+
if (args.prompt_type !== id) {
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
self.onPromptSelectionChanged(args.selected_index);
|
|
570
|
+
return true;
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
// Handle prompt confirmation
|
|
574
|
+
(globalThis as Record<string, unknown>)[`${this.handlerPrefix}_confirmed`] =
|
|
575
|
+
function (args: {
|
|
576
|
+
prompt_type: string;
|
|
577
|
+
selected_index: number | null;
|
|
578
|
+
input: string;
|
|
579
|
+
}): boolean {
|
|
580
|
+
if (args.prompt_type !== id) {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
self.onPromptConfirmed(args.selected_index);
|
|
584
|
+
return true;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
// Handle prompt cancellation
|
|
588
|
+
(globalThis as Record<string, unknown>)[`${this.handlerPrefix}_cancelled`] =
|
|
589
|
+
function (args: { prompt_type: string }): boolean {
|
|
590
|
+
if (args.prompt_type !== id) {
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
self.onPromptCancelled();
|
|
594
|
+
return true;
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// Register event handlers
|
|
598
|
+
this.editor.on("prompt_changed", `${this.handlerPrefix}_changed`);
|
|
599
|
+
this.editor.on("prompt_selection_changed", `${this.handlerPrefix}_selection`);
|
|
600
|
+
this.editor.on("prompt_confirmed", `${this.handlerPrefix}_confirmed`);
|
|
601
|
+
this.editor.on("prompt_cancelled", `${this.handlerPrefix}_cancelled`);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private async loadFilterItems(source: FilterSource<T>): Promise<void> {
|
|
605
|
+
try {
|
|
606
|
+
this.allItems = await source.load();
|
|
607
|
+
// Show initial suggestions
|
|
608
|
+
const filtered = this.filterItems("", source);
|
|
609
|
+
this.updatePromptResults(filtered);
|
|
610
|
+
this.editor.setStatus(`${this.allItems.length} items available`);
|
|
611
|
+
} catch (e) {
|
|
612
|
+
this.editor.debug(`[Finder] Failed to load items: ${e}`);
|
|
613
|
+
this.editor.setStatus(`Failed to load items: ${e}`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private filterItems(query: string, source: FilterSource<T>): T[] {
|
|
618
|
+
if (source.filter) {
|
|
619
|
+
return source.filter(this.allItems, query);
|
|
620
|
+
}
|
|
621
|
+
return defaultFuzzyFilter(
|
|
622
|
+
this.allItems,
|
|
623
|
+
query,
|
|
624
|
+
this.config.format,
|
|
625
|
+
this.config.maxResults
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async onPromptChanged(input: string): Promise<void> {
|
|
630
|
+
if (!this.currentSource) return;
|
|
631
|
+
|
|
632
|
+
if (this.currentSource.mode === "filter") {
|
|
633
|
+
// Filter mode: filter client-side
|
|
634
|
+
const filtered = this.filterItems(input, this.currentSource);
|
|
635
|
+
this.updatePromptResults(filtered);
|
|
636
|
+
|
|
637
|
+
if (filtered.length > 0) {
|
|
638
|
+
this.editor.setStatus(`Found ${filtered.length} matches`);
|
|
639
|
+
} else {
|
|
640
|
+
this.editor.setStatus("No matches");
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
// Search mode: run external search
|
|
644
|
+
await this.runSearch(input, this.currentSource);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async runSearch(
|
|
649
|
+
query: string,
|
|
650
|
+
source: SearchSource<T>
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
const debounceMs = source.debounceMs ?? 150;
|
|
653
|
+
const minQueryLength = source.minQueryLength ?? 2;
|
|
654
|
+
const thisVersion = ++this.promptState.searchVersion;
|
|
655
|
+
|
|
656
|
+
// Kill any existing search
|
|
657
|
+
if (this.promptState.currentSearch) {
|
|
658
|
+
this.promptState.pendingKill = this.promptState.currentSearch.kill();
|
|
659
|
+
this.promptState.currentSearch = null;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// Check minimum query length
|
|
663
|
+
if (!query || query.trim().length < minQueryLength) {
|
|
664
|
+
if (this.promptState.pendingKill) {
|
|
665
|
+
await this.promptState.pendingKill;
|
|
666
|
+
this.promptState.pendingKill = null;
|
|
667
|
+
}
|
|
668
|
+
this.editor.setPromptSuggestions([]);
|
|
669
|
+
this.promptState.results = [];
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Debounce
|
|
674
|
+
await this.editor.delay(debounceMs);
|
|
675
|
+
|
|
676
|
+
// Wait for pending kill
|
|
677
|
+
if (this.promptState.pendingKill) {
|
|
678
|
+
await this.promptState.pendingKill;
|
|
679
|
+
this.promptState.pendingKill = null;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Check if superseded
|
|
683
|
+
if (this.promptState.searchVersion !== thisVersion) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Skip duplicate queries
|
|
688
|
+
if (query === this.promptState.lastQuery) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
this.promptState.lastQuery = query;
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
const searchResult = source.search(query);
|
|
695
|
+
|
|
696
|
+
// Check if it's a ProcessHandle or a Promise
|
|
697
|
+
if ("kill" in searchResult) {
|
|
698
|
+
// ProcessHandle
|
|
699
|
+
this.promptState.currentSearch = searchResult;
|
|
700
|
+
const result = await searchResult;
|
|
701
|
+
|
|
702
|
+
// Check if cancelled
|
|
703
|
+
if (this.promptState.searchVersion !== thisVersion) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
this.promptState.currentSearch = null;
|
|
707
|
+
|
|
708
|
+
if (result.exit_code === 0) {
|
|
709
|
+
// Parse as grep output by default
|
|
710
|
+
const parsed = parseGrepOutput(
|
|
711
|
+
result.stdout,
|
|
712
|
+
this.config.maxResults
|
|
713
|
+
) as unknown as T[];
|
|
714
|
+
this.updatePromptResults(parsed);
|
|
715
|
+
|
|
716
|
+
if (parsed.length > 0) {
|
|
717
|
+
this.editor.setStatus(`Found ${parsed.length} matches`);
|
|
718
|
+
// Show preview of first result
|
|
719
|
+
if (this.shouldShowPreview()) {
|
|
720
|
+
await this.updatePreview(this.promptState.entries[0]);
|
|
721
|
+
}
|
|
722
|
+
} else {
|
|
723
|
+
this.editor.setStatus("No matches");
|
|
724
|
+
}
|
|
725
|
+
} else if (result.exit_code === 1) {
|
|
726
|
+
// No matches
|
|
727
|
+
this.updatePromptResults([]);
|
|
728
|
+
this.editor.setStatus("No matches");
|
|
729
|
+
} else if (result.exit_code !== -1) {
|
|
730
|
+
// Error (ignore -1 which means killed)
|
|
731
|
+
this.editor.setStatus(`Search error: ${result.stderr}`);
|
|
732
|
+
}
|
|
733
|
+
} else {
|
|
734
|
+
// Promise<T[]>
|
|
735
|
+
const results = await searchResult;
|
|
736
|
+
|
|
737
|
+
// Check if cancelled
|
|
738
|
+
if (this.promptState.searchVersion !== thisVersion) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
this.updatePromptResults(results);
|
|
743
|
+
|
|
744
|
+
if (results.length > 0) {
|
|
745
|
+
this.editor.setStatus(`Found ${results.length} matches`);
|
|
746
|
+
if (this.shouldShowPreview()) {
|
|
747
|
+
await this.updatePreview(this.promptState.entries[0]);
|
|
748
|
+
}
|
|
749
|
+
} else {
|
|
750
|
+
this.editor.setStatus("No matches");
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} catch (e) {
|
|
754
|
+
const errorMsg = String(e);
|
|
755
|
+
if (!errorMsg.includes("killed") && !errorMsg.includes("not found")) {
|
|
756
|
+
this.editor.setStatus(`Search error: ${e}`);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
private updatePromptResults(results: T[]): void {
|
|
762
|
+
this.promptState.results = results;
|
|
763
|
+
this.promptState.entries = results.map((item, i) =>
|
|
764
|
+
this.config.format(item, i)
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const suggestions: PromptSuggestion[] = this.promptState.entries.map(
|
|
768
|
+
(entry, i) => ({
|
|
769
|
+
text: entry.label,
|
|
770
|
+
description: entry.description,
|
|
771
|
+
value: `${i}`,
|
|
772
|
+
disabled: false,
|
|
773
|
+
})
|
|
774
|
+
);
|
|
775
|
+
|
|
776
|
+
this.editor.setPromptSuggestions(suggestions);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private async onPromptSelectionChanged(selectedIndex: number): Promise<void> {
|
|
780
|
+
const entry = this.promptState.entries[selectedIndex];
|
|
781
|
+
if (entry && this.shouldShowPreview()) {
|
|
782
|
+
await this.updatePreview(entry);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private onPromptConfirmed(selectedIndex: number | null): void {
|
|
787
|
+
// Kill any running search
|
|
788
|
+
if (this.promptState.currentSearch) {
|
|
789
|
+
this.promptState.currentSearch.kill();
|
|
790
|
+
this.promptState.currentSearch = null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Close preview
|
|
794
|
+
this.closePreview();
|
|
795
|
+
|
|
796
|
+
// Handle selection
|
|
797
|
+
if (
|
|
798
|
+
selectedIndex !== null &&
|
|
799
|
+
this.promptState.results[selectedIndex] !== undefined
|
|
800
|
+
) {
|
|
801
|
+
const item = this.promptState.results[selectedIndex];
|
|
802
|
+
const entry = this.promptState.entries[selectedIndex];
|
|
803
|
+
|
|
804
|
+
if (this.config.onSelect) {
|
|
805
|
+
this.config.onSelect(item, entry);
|
|
806
|
+
} else if (entry.location) {
|
|
807
|
+
// Default: open file at location
|
|
808
|
+
this.editor.openFile(
|
|
809
|
+
entry.location.file,
|
|
810
|
+
entry.location.line,
|
|
811
|
+
entry.location.column
|
|
812
|
+
);
|
|
813
|
+
this.editor.setStatus(
|
|
814
|
+
`Opened ${entry.location.file}:${entry.location.line}`
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
this.editor.setStatus("No selection");
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Clear state
|
|
822
|
+
this.isPromptMode = false;
|
|
823
|
+
this.promptState.results = [];
|
|
824
|
+
this.promptState.entries = [];
|
|
825
|
+
this.promptState.originalSplitId = null;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private onPromptCancelled(): void {
|
|
829
|
+
// Kill any running search
|
|
830
|
+
if (this.promptState.currentSearch) {
|
|
831
|
+
this.promptState.currentSearch.kill();
|
|
832
|
+
this.promptState.currentSearch = null;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Close preview
|
|
836
|
+
this.closePreview();
|
|
837
|
+
|
|
838
|
+
// Clear state
|
|
839
|
+
this.isPromptMode = false;
|
|
840
|
+
this.promptState.results = [];
|
|
841
|
+
this.promptState.entries = [];
|
|
842
|
+
this.promptState.originalSplitId = null;
|
|
843
|
+
this.editor.setStatus("Cancelled");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private closePrompt(): void {
|
|
847
|
+
this.onPromptCancelled();
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ==========================================================================
|
|
851
|
+
// Preview Implementation
|
|
852
|
+
// ==========================================================================
|
|
853
|
+
|
|
854
|
+
private shouldShowPreview(): boolean {
|
|
855
|
+
if (this.config.preview === false) {
|
|
856
|
+
return false;
|
|
857
|
+
}
|
|
858
|
+
if (this.config.preview === true) {
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
if (typeof this.config.preview === "object") {
|
|
862
|
+
return this.config.preview.enabled;
|
|
863
|
+
}
|
|
864
|
+
// Auto-detect: enable if any entry has a location
|
|
865
|
+
return this.promptState.entries.some((e) => e.location);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private getContextLines(): number {
|
|
869
|
+
if (typeof this.config.preview === "object") {
|
|
870
|
+
return this.config.preview.contextLines ?? 5;
|
|
871
|
+
}
|
|
872
|
+
return 5;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async updatePreview(entry: DisplayEntry): Promise<void> {
|
|
876
|
+
if (!entry.location) return;
|
|
877
|
+
|
|
878
|
+
try {
|
|
879
|
+
const content = await this.editor.readFile(entry.location.file);
|
|
880
|
+
const lines = content.split("\n");
|
|
881
|
+
|
|
882
|
+
const contextLines = this.getContextLines();
|
|
883
|
+
const startLine = Math.max(0, entry.location.line - 1 - contextLines);
|
|
884
|
+
const endLine = Math.min(lines.length, entry.location.line + contextLines);
|
|
885
|
+
|
|
886
|
+
const entries: TextPropertyEntry[] = [];
|
|
887
|
+
|
|
888
|
+
// Header
|
|
889
|
+
entries.push({
|
|
890
|
+
text: ` ${entry.location.file}:${entry.location.line}:${entry.location.column ?? 1}\n`,
|
|
891
|
+
properties: { type: "header" },
|
|
892
|
+
});
|
|
893
|
+
entries.push({
|
|
894
|
+
text: "─".repeat(60) + "\n",
|
|
895
|
+
properties: { type: "separator" },
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
// Content lines with line numbers
|
|
899
|
+
for (let i = startLine; i < endLine; i++) {
|
|
900
|
+
const lineNum = i + 1;
|
|
901
|
+
const lineContent = lines[i] || "";
|
|
902
|
+
const isMatchLine = lineNum === entry.location.line;
|
|
903
|
+
const prefix = isMatchLine ? "> " : " ";
|
|
904
|
+
const lineNumStr = String(lineNum).padStart(4, " ");
|
|
905
|
+
|
|
906
|
+
entries.push({
|
|
907
|
+
text: `${prefix}${lineNumStr} │ ${lineContent}\n`,
|
|
908
|
+
properties: {
|
|
909
|
+
type: isMatchLine ? "match" : "context",
|
|
910
|
+
line: lineNum,
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (this.previewState.bufferId === null) {
|
|
916
|
+
// Define preview mode
|
|
917
|
+
this.editor.defineMode(
|
|
918
|
+
this.previewModeName,
|
|
919
|
+
"special",
|
|
920
|
+
[["q", "close_buffer"]],
|
|
921
|
+
true
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
// Create preview split
|
|
925
|
+
const result = await this.editor.createVirtualBufferInSplit({
|
|
926
|
+
name: "*Preview*",
|
|
927
|
+
mode: this.previewModeName,
|
|
928
|
+
read_only: true,
|
|
929
|
+
entries,
|
|
930
|
+
ratio: 0.5,
|
|
931
|
+
direction: "vertical",
|
|
932
|
+
panel_id: `${this.config.id}-preview`,
|
|
933
|
+
show_line_numbers: false,
|
|
934
|
+
editing_disabled: true,
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
this.previewState.bufferId = result.buffer_id;
|
|
938
|
+
this.previewState.splitId = result.split_id ?? null;
|
|
939
|
+
|
|
940
|
+
// Return focus to original split
|
|
941
|
+
if (this.promptState.originalSplitId !== null) {
|
|
942
|
+
this.editor.focusSplit(this.promptState.originalSplitId);
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
// Update existing preview
|
|
946
|
+
this.editor.setVirtualBufferContent(this.previewState.bufferId, entries);
|
|
947
|
+
}
|
|
948
|
+
} catch (e) {
|
|
949
|
+
this.editor.debug(`[Finder] Failed to update preview: ${e}`);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
private closePreview(): void {
|
|
954
|
+
if (this.previewState.bufferId !== null) {
|
|
955
|
+
this.editor.closeBuffer(this.previewState.bufferId);
|
|
956
|
+
this.previewState.bufferId = null;
|
|
957
|
+
}
|
|
958
|
+
if (this.previewState.splitId !== null) {
|
|
959
|
+
this.editor.closeSplit(this.previewState.splitId);
|
|
960
|
+
this.previewState.splitId = null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ==========================================================================
|
|
965
|
+
// Panel Mode Implementation
|
|
966
|
+
// ==========================================================================
|
|
967
|
+
|
|
968
|
+
private registerPanelHandlers(): void {
|
|
969
|
+
const self = this;
|
|
970
|
+
|
|
971
|
+
// Define panel mode
|
|
972
|
+
this.editor.defineMode(
|
|
973
|
+
this.modeName,
|
|
974
|
+
"normal",
|
|
975
|
+
[
|
|
976
|
+
["Return", `${this.handlerPrefix}_panel_select`],
|
|
977
|
+
["Escape", `${this.handlerPrefix}_panel_close`],
|
|
978
|
+
],
|
|
979
|
+
true
|
|
980
|
+
);
|
|
981
|
+
|
|
982
|
+
// Select handler
|
|
983
|
+
(globalThis as Record<string, unknown>)[
|
|
984
|
+
`${this.handlerPrefix}_panel_select`
|
|
985
|
+
] = function (): void {
|
|
986
|
+
self.onPanelSelect();
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// Close handler
|
|
990
|
+
(globalThis as Record<string, unknown>)[
|
|
991
|
+
`${this.handlerPrefix}_panel_close`
|
|
992
|
+
] = function (): void {
|
|
993
|
+
self.closePanel();
|
|
994
|
+
};
|
|
995
|
+
|
|
996
|
+
// Cursor movement handler
|
|
997
|
+
(globalThis as Record<string, unknown>)[
|
|
998
|
+
`${this.handlerPrefix}_panel_cursor`
|
|
999
|
+
] = function (data: {
|
|
1000
|
+
buffer_id: number;
|
|
1001
|
+
cursor_id: number;
|
|
1002
|
+
old_position: number;
|
|
1003
|
+
new_position: number;
|
|
1004
|
+
line: number;
|
|
1005
|
+
}): void {
|
|
1006
|
+
if (!self.isPanelMode || self.panelState.bufferId === null) return;
|
|
1007
|
+
if (data.buffer_id !== self.panelState.bufferId) return;
|
|
1008
|
+
|
|
1009
|
+
self.panelState.cursorLine = data.line;
|
|
1010
|
+
self.applyPanelHighlighting();
|
|
1011
|
+
|
|
1012
|
+
const itemIndex = self.panelState.lineToItemIndex.get(data.line);
|
|
1013
|
+
if (itemIndex !== undefined && itemIndex < self.panelState.items.length) {
|
|
1014
|
+
self.editor.setStatus(
|
|
1015
|
+
`Item ${itemIndex + 1}/${self.panelState.items.length}`
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
// Register cursor movement handler
|
|
1021
|
+
this.editor.on("cursor_moved", `${this.handlerPrefix}_panel_cursor`);
|
|
1022
|
+
|
|
1023
|
+
// Sync with editor handler (if enabled)
|
|
1024
|
+
if (this.config.syncWithEditor) {
|
|
1025
|
+
(globalThis as Record<string, unknown>)[
|
|
1026
|
+
`${this.handlerPrefix}_editor_cursor`
|
|
1027
|
+
] = function (data: {
|
|
1028
|
+
buffer_id: number;
|
|
1029
|
+
cursor_id: number;
|
|
1030
|
+
old_position: number;
|
|
1031
|
+
new_position: number;
|
|
1032
|
+
line: number;
|
|
1033
|
+
}): void {
|
|
1034
|
+
if (!self.isPanelMode || self.panelState.bufferId === null) return;
|
|
1035
|
+
if (data.buffer_id === self.panelState.bufferId) return;
|
|
1036
|
+
|
|
1037
|
+
const filePath = self.editor.getBufferPath(data.buffer_id);
|
|
1038
|
+
if (!filePath) return;
|
|
1039
|
+
|
|
1040
|
+
// Find matching item
|
|
1041
|
+
const matchingIndex = self.panelState.entries.findIndex((entry) => {
|
|
1042
|
+
if (!entry.location) return false;
|
|
1043
|
+
return (
|
|
1044
|
+
entry.location.file === filePath &&
|
|
1045
|
+
entry.location.line === data.line
|
|
1046
|
+
);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
if (matchingIndex >= 0) {
|
|
1050
|
+
self.revealItem(matchingIndex);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
this.editor.on("cursor_moved", `${this.handlerPrefix}_editor_cursor`);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private async showPanel(title: string, ratio: number): Promise<void> {
|
|
1059
|
+
const entries = this.buildPanelEntries(title);
|
|
1060
|
+
this.panelState.cachedContent = entries.map((e) => e.text).join("");
|
|
1061
|
+
this.panelState.cursorLine = this.findFirstItemLine();
|
|
1062
|
+
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await this.editor.createVirtualBufferInSplit({
|
|
1065
|
+
name: `*${this.config.id.charAt(0).toUpperCase() + this.config.id.slice(1)}*`,
|
|
1066
|
+
mode: this.modeName,
|
|
1067
|
+
read_only: true,
|
|
1068
|
+
entries,
|
|
1069
|
+
ratio,
|
|
1070
|
+
direction: "horizontal",
|
|
1071
|
+
panel_id: this.config.id,
|
|
1072
|
+
show_line_numbers: false,
|
|
1073
|
+
show_cursors: true,
|
|
1074
|
+
editing_disabled: true,
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
if (result.buffer_id !== null) {
|
|
1078
|
+
this.panelState.bufferId = result.buffer_id;
|
|
1079
|
+
this.panelState.splitId = result.split_id ?? null;
|
|
1080
|
+
this.applyPanelHighlighting();
|
|
1081
|
+
|
|
1082
|
+
const count = this.panelState.items.length;
|
|
1083
|
+
this.editor.setStatus(`${title}: ${count} item${count !== 1 ? "s" : ""}`);
|
|
1084
|
+
} else {
|
|
1085
|
+
this.editor.setStatus("Failed to open panel");
|
|
1086
|
+
}
|
|
1087
|
+
} catch (e) {
|
|
1088
|
+
this.editor.setStatus(`Failed to open panel: ${e}`);
|
|
1089
|
+
this.editor.debug(`[Finder] Panel error: ${e}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private refreshPanel(title: string): void {
|
|
1094
|
+
if (this.panelState.bufferId === null) return;
|
|
1095
|
+
|
|
1096
|
+
const entries = this.buildPanelEntries(title);
|
|
1097
|
+
this.panelState.cachedContent = entries.map((e) => e.text).join("");
|
|
1098
|
+
|
|
1099
|
+
this.editor.setVirtualBufferContent(this.panelState.bufferId, entries);
|
|
1100
|
+
this.applyPanelHighlighting();
|
|
1101
|
+
|
|
1102
|
+
const count = this.panelState.items.length;
|
|
1103
|
+
this.editor.setStatus(`${title}: ${count} item${count !== 1 ? "s" : ""}`);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
private buildPanelEntries(title: string): TextPropertyEntry[] {
|
|
1107
|
+
const entries: TextPropertyEntry[] = [];
|
|
1108
|
+
this.panelState.lineToItemIndex.clear();
|
|
1109
|
+
|
|
1110
|
+
let currentLine = 1;
|
|
1111
|
+
|
|
1112
|
+
// Title line
|
|
1113
|
+
entries.push({
|
|
1114
|
+
text: `${title}\n`,
|
|
1115
|
+
properties: { type: "title" },
|
|
1116
|
+
});
|
|
1117
|
+
currentLine++;
|
|
1118
|
+
|
|
1119
|
+
if (this.panelState.entries.length === 0) {
|
|
1120
|
+
entries.push({
|
|
1121
|
+
text: " No results\n",
|
|
1122
|
+
properties: { type: "empty" },
|
|
1123
|
+
});
|
|
1124
|
+
currentLine++;
|
|
1125
|
+
} else if (this.config.groupBy === "file") {
|
|
1126
|
+
// Group by file
|
|
1127
|
+
const byFile = new Map<
|
|
1128
|
+
string,
|
|
1129
|
+
Array<{ entry: DisplayEntry; index: number }>
|
|
1130
|
+
>();
|
|
1131
|
+
|
|
1132
|
+
for (let i = 0; i < this.panelState.entries.length; i++) {
|
|
1133
|
+
const entry = this.panelState.entries[i];
|
|
1134
|
+
const file = entry.location?.file ?? "(no file)";
|
|
1135
|
+
if (!byFile.has(file)) {
|
|
1136
|
+
byFile.set(file, []);
|
|
1137
|
+
}
|
|
1138
|
+
byFile.get(file)!.push({ entry, index: i });
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
for (const [file, itemsInFile] of byFile) {
|
|
1142
|
+
// File header
|
|
1143
|
+
const fileName = file.split("/").pop() ?? file;
|
|
1144
|
+
entries.push({
|
|
1145
|
+
text: `\n${fileName}:\n`,
|
|
1146
|
+
properties: { type: "file-header", file },
|
|
1147
|
+
});
|
|
1148
|
+
currentLine += 2;
|
|
1149
|
+
|
|
1150
|
+
// Items in this file
|
|
1151
|
+
for (const { entry, index } of itemsInFile) {
|
|
1152
|
+
entries.push(this.buildItemEntry(entry));
|
|
1153
|
+
this.panelState.lineToItemIndex.set(currentLine, index);
|
|
1154
|
+
currentLine++;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
} else {
|
|
1158
|
+
// Flat list
|
|
1159
|
+
for (let i = 0; i < this.panelState.entries.length; i++) {
|
|
1160
|
+
const entry = this.panelState.entries[i];
|
|
1161
|
+
entries.push(this.buildItemEntry(entry));
|
|
1162
|
+
this.panelState.lineToItemIndex.set(currentLine, i);
|
|
1163
|
+
currentLine++;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Help footer
|
|
1168
|
+
entries.push({
|
|
1169
|
+
text: "\n",
|
|
1170
|
+
properties: { type: "blank" },
|
|
1171
|
+
});
|
|
1172
|
+
entries.push({
|
|
1173
|
+
text: "Enter:select | Esc:close\n",
|
|
1174
|
+
properties: { type: "help" },
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
return entries;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private buildItemEntry(entry: DisplayEntry): TextPropertyEntry {
|
|
1181
|
+
const severityIcon =
|
|
1182
|
+
entry.severity === "error"
|
|
1183
|
+
? "[E]"
|
|
1184
|
+
: entry.severity === "warning"
|
|
1185
|
+
? "[W]"
|
|
1186
|
+
: entry.severity === "info"
|
|
1187
|
+
? "[I]"
|
|
1188
|
+
: entry.severity === "hint"
|
|
1189
|
+
? "[H]"
|
|
1190
|
+
: "";
|
|
1191
|
+
|
|
1192
|
+
const prefix = severityIcon ? `${severityIcon} ` : " ";
|
|
1193
|
+
const desc = entry.description ? ` ${entry.description}` : "";
|
|
1194
|
+
|
|
1195
|
+
let line = `${prefix}${entry.label}${desc}`;
|
|
1196
|
+
const maxLen = 100;
|
|
1197
|
+
if (line.length > maxLen) {
|
|
1198
|
+
line = line.slice(0, maxLen - 3) + "...";
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
return {
|
|
1202
|
+
text: `${line}\n`,
|
|
1203
|
+
properties: {
|
|
1204
|
+
type: "item",
|
|
1205
|
+
location: entry.location,
|
|
1206
|
+
severity: entry.severity,
|
|
1207
|
+
metadata: entry.metadata,
|
|
1208
|
+
},
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
private findFirstItemLine(): number {
|
|
1213
|
+
for (const [line] of this.panelState.lineToItemIndex) {
|
|
1214
|
+
return line;
|
|
1215
|
+
}
|
|
1216
|
+
return 2;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private onPanelSelect(): void {
|
|
1220
|
+
const itemIndex = this.panelState.lineToItemIndex.get(
|
|
1221
|
+
this.panelState.cursorLine
|
|
1222
|
+
);
|
|
1223
|
+
if (itemIndex === undefined) {
|
|
1224
|
+
this.editor.setStatus("No item selected");
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const item = this.panelState.items[itemIndex];
|
|
1229
|
+
const entry = this.panelState.entries[itemIndex];
|
|
1230
|
+
|
|
1231
|
+
if (this.config.onSelect) {
|
|
1232
|
+
this.config.onSelect(item, entry);
|
|
1233
|
+
} else if (entry.location) {
|
|
1234
|
+
// Default: open file at location
|
|
1235
|
+
if (this.panelState.sourceSplitId !== null) {
|
|
1236
|
+
this.editor.focusSplit(this.panelState.sourceSplitId);
|
|
1237
|
+
}
|
|
1238
|
+
this.editor.openFile(
|
|
1239
|
+
entry.location.file,
|
|
1240
|
+
entry.location.line,
|
|
1241
|
+
entry.location.column
|
|
1242
|
+
);
|
|
1243
|
+
this.editor.setStatus(
|
|
1244
|
+
`Jumped to ${entry.location.file}:${entry.location.line}`
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
private closePanel(): void {
|
|
1250
|
+
// Unsubscribe from provider
|
|
1251
|
+
if (this.panelState.unsubscribe) {
|
|
1252
|
+
this.panelState.unsubscribe();
|
|
1253
|
+
this.panelState.unsubscribe = null;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Close split and buffer
|
|
1257
|
+
const splitId = this.panelState.splitId;
|
|
1258
|
+
const bufferId = this.panelState.bufferId;
|
|
1259
|
+
const sourceSplitId = this.panelState.sourceSplitId;
|
|
1260
|
+
|
|
1261
|
+
// Clear state
|
|
1262
|
+
this.isPanelMode = false;
|
|
1263
|
+
this.panelState.bufferId = null;
|
|
1264
|
+
this.panelState.splitId = null;
|
|
1265
|
+
this.panelState.sourceSplitId = null;
|
|
1266
|
+
this.panelState.items = [];
|
|
1267
|
+
this.panelState.entries = [];
|
|
1268
|
+
this.panelState.cachedContent = "";
|
|
1269
|
+
this.panelState.cursorLine = 1;
|
|
1270
|
+
this.panelState.lineToItemIndex.clear();
|
|
1271
|
+
|
|
1272
|
+
// Close UI
|
|
1273
|
+
if (splitId !== null) {
|
|
1274
|
+
this.editor.closeSplit(splitId);
|
|
1275
|
+
}
|
|
1276
|
+
if (bufferId !== null) {
|
|
1277
|
+
this.editor.closeBuffer(bufferId);
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Focus source
|
|
1281
|
+
if (sourceSplitId !== null) {
|
|
1282
|
+
this.editor.focusSplit(sourceSplitId);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
this.editor.setStatus("Closed");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private revealItem(index: number): void {
|
|
1289
|
+
if (this.panelState.bufferId === null) return;
|
|
1290
|
+
|
|
1291
|
+
// Find the panel line for this item
|
|
1292
|
+
for (const [line, idx] of this.panelState.lineToItemIndex) {
|
|
1293
|
+
if (idx === index) {
|
|
1294
|
+
this.panelState.cursorLine = line;
|
|
1295
|
+
|
|
1296
|
+
// Move cursor to this line
|
|
1297
|
+
const byteOffset = this.lineToByteOffset(line);
|
|
1298
|
+
this.editor.setBufferCursor(this.panelState.bufferId, byteOffset);
|
|
1299
|
+
this.applyPanelHighlighting();
|
|
1300
|
+
break;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
private lineToByteOffset(lineNumber: number): number {
|
|
1306
|
+
const lines = this.panelState.cachedContent.split("\n");
|
|
1307
|
+
let offset = 0;
|
|
1308
|
+
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
|
|
1309
|
+
offset += lines[i].length + 1;
|
|
1310
|
+
}
|
|
1311
|
+
return offset;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
private applyPanelHighlighting(): void {
|
|
1315
|
+
if (this.panelState.bufferId === null) return;
|
|
1316
|
+
|
|
1317
|
+
const bufferId = this.panelState.bufferId;
|
|
1318
|
+
const namespace = this.config.id;
|
|
1319
|
+
this.editor.clearNamespace(bufferId, namespace);
|
|
1320
|
+
|
|
1321
|
+
if (!this.panelState.cachedContent) return;
|
|
1322
|
+
|
|
1323
|
+
const lines = this.panelState.cachedContent.split("\n");
|
|
1324
|
+
let byteOffset = 0;
|
|
1325
|
+
|
|
1326
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
1327
|
+
const line = lines[lineIdx];
|
|
1328
|
+
const lineStart = byteOffset;
|
|
1329
|
+
const lineEnd = byteOffset + line.length;
|
|
1330
|
+
const lineNumber = lineIdx + 1;
|
|
1331
|
+
const isCurrentLine = lineNumber === this.panelState.cursorLine;
|
|
1332
|
+
const isItemLine = this.panelState.lineToItemIndex.has(lineNumber);
|
|
1333
|
+
|
|
1334
|
+
// Highlight current line if it's an item line
|
|
1335
|
+
if (isCurrentLine && isItemLine && line.trim() !== "") {
|
|
1336
|
+
this.editor.addOverlay(
|
|
1337
|
+
bufferId,
|
|
1338
|
+
namespace,
|
|
1339
|
+
lineStart,
|
|
1340
|
+
lineEnd,
|
|
1341
|
+
colors.selected[0],
|
|
1342
|
+
colors.selected[1],
|
|
1343
|
+
colors.selected[2],
|
|
1344
|
+
-1,
|
|
1345
|
+
-1,
|
|
1346
|
+
-1,
|
|
1347
|
+
false,
|
|
1348
|
+
true,
|
|
1349
|
+
false,
|
|
1350
|
+
true
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// Title line
|
|
1355
|
+
if (lineNumber === 1) {
|
|
1356
|
+
this.editor.addOverlay(
|
|
1357
|
+
bufferId,
|
|
1358
|
+
namespace,
|
|
1359
|
+
lineStart,
|
|
1360
|
+
lineEnd,
|
|
1361
|
+
colors.title[0],
|
|
1362
|
+
colors.title[1],
|
|
1363
|
+
colors.title[2],
|
|
1364
|
+
-1,
|
|
1365
|
+
-1,
|
|
1366
|
+
-1,
|
|
1367
|
+
false,
|
|
1368
|
+
true,
|
|
1369
|
+
false,
|
|
1370
|
+
false
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// File header (ends with : but isn't title)
|
|
1375
|
+
if (line.endsWith(":") && lineNumber > 1 && !line.startsWith(" ")) {
|
|
1376
|
+
this.editor.addOverlay(
|
|
1377
|
+
bufferId,
|
|
1378
|
+
namespace,
|
|
1379
|
+
lineStart,
|
|
1380
|
+
lineEnd,
|
|
1381
|
+
colors.fileHeader[0],
|
|
1382
|
+
colors.fileHeader[1],
|
|
1383
|
+
colors.fileHeader[2],
|
|
1384
|
+
-1,
|
|
1385
|
+
-1,
|
|
1386
|
+
-1,
|
|
1387
|
+
false,
|
|
1388
|
+
true,
|
|
1389
|
+
false,
|
|
1390
|
+
false
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Severity icon highlighting
|
|
1395
|
+
const iconMatch = line.match(/^\[([EWIH])\]/);
|
|
1396
|
+
if (iconMatch) {
|
|
1397
|
+
const iconEnd = lineStart + 3;
|
|
1398
|
+
let color: RGB;
|
|
1399
|
+
switch (iconMatch[1]) {
|
|
1400
|
+
case "E":
|
|
1401
|
+
color = colors.error;
|
|
1402
|
+
break;
|
|
1403
|
+
case "W":
|
|
1404
|
+
color = colors.warning;
|
|
1405
|
+
break;
|
|
1406
|
+
case "I":
|
|
1407
|
+
color = colors.info;
|
|
1408
|
+
break;
|
|
1409
|
+
case "H":
|
|
1410
|
+
color = colors.hint;
|
|
1411
|
+
break;
|
|
1412
|
+
default:
|
|
1413
|
+
color = colors.hint;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
this.editor.addOverlay(
|
|
1417
|
+
bufferId,
|
|
1418
|
+
namespace,
|
|
1419
|
+
lineStart,
|
|
1420
|
+
iconEnd,
|
|
1421
|
+
color[0],
|
|
1422
|
+
color[1],
|
|
1423
|
+
color[2],
|
|
1424
|
+
-1,
|
|
1425
|
+
-1,
|
|
1426
|
+
-1,
|
|
1427
|
+
false,
|
|
1428
|
+
true,
|
|
1429
|
+
false,
|
|
1430
|
+
false
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// Help line (dimmed)
|
|
1435
|
+
if (line.startsWith("Enter:") || line.includes("|")) {
|
|
1436
|
+
this.editor.addOverlay(
|
|
1437
|
+
bufferId,
|
|
1438
|
+
namespace,
|
|
1439
|
+
lineStart,
|
|
1440
|
+
lineEnd,
|
|
1441
|
+
colors.help[0],
|
|
1442
|
+
colors.help[1],
|
|
1443
|
+
colors.help[2],
|
|
1444
|
+
-1,
|
|
1445
|
+
-1,
|
|
1446
|
+
-1,
|
|
1447
|
+
false,
|
|
1448
|
+
false,
|
|
1449
|
+
false,
|
|
1450
|
+
false
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
byteOffset += line.length + 1;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
// ============================================================================
|
|
1460
|
+
// Helper Functions
|
|
1461
|
+
// ============================================================================
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* Get relative path for display
|
|
1465
|
+
*/
|
|
1466
|
+
export function getRelativePath(editor: EditorAPI, filePath: string): string {
|
|
1467
|
+
const cwd = editor.getCwd();
|
|
1468
|
+
if (filePath.startsWith(cwd)) {
|
|
1469
|
+
return filePath.slice(cwd.length + 1);
|
|
1470
|
+
}
|
|
1471
|
+
return filePath;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Create a simple live provider from a getter function
|
|
1476
|
+
*/
|
|
1477
|
+
export function createLiveProvider<T>(
|
|
1478
|
+
getItems: () => T[]
|
|
1479
|
+
): FinderProvider<T> & { notify: () => void } {
|
|
1480
|
+
const listeners: Array<() => void> = [];
|
|
1481
|
+
|
|
1482
|
+
return {
|
|
1483
|
+
getItems,
|
|
1484
|
+
subscribe(callback: () => void) {
|
|
1485
|
+
listeners.push(callback);
|
|
1486
|
+
return () => {
|
|
1487
|
+
const index = listeners.indexOf(callback);
|
|
1488
|
+
if (index >= 0) {
|
|
1489
|
+
listeners.splice(index, 1);
|
|
1490
|
+
}
|
|
1491
|
+
};
|
|
1492
|
+
},
|
|
1493
|
+
notify() {
|
|
1494
|
+
for (const listener of listeners) {
|
|
1495
|
+
listener();
|
|
1496
|
+
}
|
|
1497
|
+
},
|
|
1498
|
+
};
|
|
1499
|
+
}
|