@fresh-editor/fresh-editor 0.1.74 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +6 -0
  3. package/package.json +1 -1
  4. package/plugins/audit_mode.ts +9 -4
  5. package/plugins/buffer_modified.ts +1 -1
  6. package/plugins/calculator.ts +1 -1
  7. package/plugins/check-types.sh +41 -0
  8. package/plugins/clangd_support.ts +1 -1
  9. package/plugins/color_highlighter.ts +4 -1
  10. package/plugins/config-schema.json +44 -2
  11. package/plugins/diagnostics_panel.i18n.json +52 -52
  12. package/plugins/diagnostics_panel.ts +168 -540
  13. package/plugins/find_references.ts +82 -324
  14. package/plugins/git_blame.i18n.json +260 -247
  15. package/plugins/git_blame.ts +4 -9
  16. package/plugins/git_find_file.ts +42 -270
  17. package/plugins/git_grep.ts +50 -167
  18. package/plugins/git_gutter.ts +1 -1
  19. package/plugins/git_log.ts +4 -11
  20. package/plugins/lib/finder.ts +1499 -0
  21. package/plugins/lib/fresh.d.ts +104 -19
  22. package/plugins/lib/index.ts +14 -0
  23. package/plugins/lib/navigation-controller.ts +1 -1
  24. package/plugins/lib/panel-manager.ts +7 -13
  25. package/plugins/lib/results-panel.ts +914 -0
  26. package/plugins/lib/search-utils.ts +343 -0
  27. package/plugins/lib/virtual-buffer-factory.ts +3 -2
  28. package/plugins/live_grep.ts +56 -379
  29. package/plugins/markdown_compose.ts +1 -17
  30. package/plugins/merge_conflict.ts +16 -14
  31. package/plugins/path_complete.ts +1 -1
  32. package/plugins/search_replace.i18n.json +13 -13
  33. package/plugins/search_replace.ts +11 -9
  34. package/plugins/theme_editor.ts +57 -30
  35. package/plugins/todo_highlighter.ts +1 -0
  36. package/plugins/vi_mode.ts +9 -5
  37. package/plugins/welcome.ts +1 -1
  38. package/themes/dark.json +102 -0
  39. package/themes/high-contrast.json +102 -0
  40. package/themes/light.json +102 -0
  41. package/themes/nostalgia.json +102 -0
@@ -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
+ }