@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.
- package/CHANGELOG.md +54 -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 +104 -19
- 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 +57 -30
- package/plugins/todo_highlighter.ts +1 -0
- package/plugins/vi_mode.ts +9 -5
- package/plugins/welcome.ts +1 -1
- package/themes/dark.json +102 -0
- package/themes/high-contrast.json +102 -0
- package/themes/light.json +102 -0
- package/themes/nostalgia.json +102 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/// <reference path="./fresh.d.ts" />
|
|
2
|
+
|
|
3
|
+
import type { Location, RGB } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ResultsPanel v2 - VS Code-inspired Provider Pattern
|
|
7
|
+
*
|
|
8
|
+
* Key architecture principles (lessons from VS Code's TreeDataProvider):
|
|
9
|
+
* 1. Don't pass arrays, pass Providers - creates a live data channel
|
|
10
|
+
* 2. Standardize the Item shape - Core handles sync automatically
|
|
11
|
+
* 3. Event-driven updates - Provider emits events, Panel refreshes
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // Create a provider that emits change events
|
|
16
|
+
* class MyProvider implements ResultsProvider<ResultItem> {
|
|
17
|
+
* private items: ResultItem[] = [];
|
|
18
|
+
* private emitter = new EventEmitter<void>();
|
|
19
|
+
* readonly onDidChangeResults = this.emitter.event;
|
|
20
|
+
*
|
|
21
|
+
* updateItems(newItems: ResultItem[]) {
|
|
22
|
+
* this.items = newItems;
|
|
23
|
+
* this.emitter.fire(); // Notify panel to refresh
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* provideResults() {
|
|
27
|
+
* return this.items;
|
|
28
|
+
* }
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* const provider = new MyProvider();
|
|
32
|
+
* const panel = new ResultsPanel(editor, "references", provider, {
|
|
33
|
+
* title: "References",
|
|
34
|
+
* syncWithEditor: true,
|
|
35
|
+
* onSelect: (item) => {
|
|
36
|
+
* if (item.location) {
|
|
37
|
+
* panel.openInSource(item.location.file, item.location.line, item.location.column);
|
|
38
|
+
* }
|
|
39
|
+
* },
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* // Later: update data
|
|
43
|
+
* provider.updateItems(newReferences);
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// ============================================================================
|
|
48
|
+
// Event System (Simplified VS Code-style)
|
|
49
|
+
// ============================================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A function that can be called to unsubscribe from an event
|
|
53
|
+
*/
|
|
54
|
+
export type Disposable = () => void;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* An event that can be subscribed to
|
|
58
|
+
*/
|
|
59
|
+
export type Event<T> = (listener: (e: T) => void) => Disposable;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Simple event emitter for Provider → Panel communication
|
|
63
|
+
*/
|
|
64
|
+
export class EventEmitter<T> {
|
|
65
|
+
private listeners: Array<(e: T) => void> = [];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The event that others can subscribe to
|
|
69
|
+
*/
|
|
70
|
+
readonly event: Event<T> = (listener) => {
|
|
71
|
+
this.listeners.push(listener);
|
|
72
|
+
return () => {
|
|
73
|
+
const index = this.listeners.indexOf(listener);
|
|
74
|
+
if (index >= 0) {
|
|
75
|
+
this.listeners.splice(index, 1);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Fire the event, notifying all listeners
|
|
82
|
+
*/
|
|
83
|
+
fire(data: T): void {
|
|
84
|
+
for (const listener of this.listeners) {
|
|
85
|
+
try {
|
|
86
|
+
listener(data);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// Don't let one listener break others
|
|
89
|
+
console.error("Event listener error:", e);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fire without data (for void events)
|
|
96
|
+
*/
|
|
97
|
+
fireVoid(): void {
|
|
98
|
+
this.fire(undefined as T);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// Core Interfaces
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Standard shape for any item in a results list.
|
|
108
|
+
*
|
|
109
|
+
* By enforcing a standard `location` property, the Core can implement
|
|
110
|
+
* "Sync Cursor" logic once, globally, rather than asking every plugin
|
|
111
|
+
* to write custom sync callbacks.
|
|
112
|
+
*/
|
|
113
|
+
export interface ResultItem {
|
|
114
|
+
/** Unique identifier for this item (used for reveal/selection) */
|
|
115
|
+
id: string;
|
|
116
|
+
|
|
117
|
+
/** Primary text shown for this item */
|
|
118
|
+
label: string;
|
|
119
|
+
|
|
120
|
+
/** Secondary text (e.g., code preview) */
|
|
121
|
+
description?: string;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Location in source file - CRITICAL for Core-managed features:
|
|
125
|
+
* - Bidirectional cursor sync (syncWithEditor)
|
|
126
|
+
* - Navigation (Enter to jump)
|
|
127
|
+
*/
|
|
128
|
+
location?: Location;
|
|
129
|
+
|
|
130
|
+
/** Severity for visual styling (error/warning/info badge) */
|
|
131
|
+
severity?: "error" | "warning" | "info" | "hint";
|
|
132
|
+
|
|
133
|
+
/** Custom data attached to this item */
|
|
134
|
+
metadata?: unknown;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The Provider acts as the bridge between Plugin Logic and UI.
|
|
139
|
+
*
|
|
140
|
+
* This matches VS Code's TreeDataProvider pattern where:
|
|
141
|
+
* - Provider owns the data and business logic
|
|
142
|
+
* - Panel owns the UI rendering and interaction
|
|
143
|
+
*/
|
|
144
|
+
export interface ResultsProvider<T extends ResultItem = ResultItem> {
|
|
145
|
+
/**
|
|
146
|
+
* Data Retrieval: Core calls this when it needs the current items.
|
|
147
|
+
* Can be sync or async.
|
|
148
|
+
*/
|
|
149
|
+
provideResults(): T[] | Promise<T[]>;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Reactivity: Plugin fires this to tell Core "My data changed, please refresh".
|
|
153
|
+
* Matches VS Code's 'onDidChangeTreeData' pattern.
|
|
154
|
+
*
|
|
155
|
+
* If omitted, the panel won't auto-refresh; you must call panel.refresh() manually.
|
|
156
|
+
*/
|
|
157
|
+
onDidChangeResults?: Event<void>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Optional filtering logic for complex custom filtering.
|
|
161
|
+
* If omitted, Core does simple substring matching on label.
|
|
162
|
+
*/
|
|
163
|
+
filter?(item: T, query: string): boolean;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ============================================================================
|
|
167
|
+
// Panel Options
|
|
168
|
+
// ============================================================================
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Options for creating a ResultsPanel
|
|
172
|
+
*/
|
|
173
|
+
export interface ResultsPanelOptions<T extends ResultItem = ResultItem> {
|
|
174
|
+
/** Title shown at top of panel */
|
|
175
|
+
title: string;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Bidirectional Sync: If true, Core automatically highlights items
|
|
179
|
+
* matching the active editor's cursor position, based on the item's
|
|
180
|
+
* `location` property. No custom callback needed.
|
|
181
|
+
*/
|
|
182
|
+
syncWithEditor?: boolean;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Grouping strategy for items.
|
|
186
|
+
* - 'file': Group by file path (default for location-based items)
|
|
187
|
+
* - 'severity': Group by severity level
|
|
188
|
+
* - 'none': Flat list, no grouping
|
|
189
|
+
*/
|
|
190
|
+
groupBy?: "file" | "severity" | "none";
|
|
191
|
+
|
|
192
|
+
/** Split ratio (default 0.7 = source keeps 70%) */
|
|
193
|
+
ratio?: number;
|
|
194
|
+
|
|
195
|
+
/** Called when user presses Enter on an item */
|
|
196
|
+
onSelect?: (item: T, index: number) => void;
|
|
197
|
+
|
|
198
|
+
/** Called when user presses Escape */
|
|
199
|
+
onClose?: () => void;
|
|
200
|
+
|
|
201
|
+
/** Called when cursor moves to a new item (for preview updates) */
|
|
202
|
+
onCursorMove?: (item: T, index: number) => void;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ============================================================================
|
|
206
|
+
// Colors
|
|
207
|
+
// ============================================================================
|
|
208
|
+
|
|
209
|
+
const colors = {
|
|
210
|
+
selected: [80, 80, 120] as RGB,
|
|
211
|
+
location: [150, 255, 150] as RGB,
|
|
212
|
+
help: [150, 150, 150] as RGB,
|
|
213
|
+
title: [200, 200, 255] as RGB,
|
|
214
|
+
error: [255, 100, 100] as RGB,
|
|
215
|
+
warning: [255, 200, 100] as RGB,
|
|
216
|
+
info: [100, 200, 255] as RGB,
|
|
217
|
+
hint: [150, 150, 150] as RGB,
|
|
218
|
+
fileHeader: [180, 180, 255] as RGB,
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ============================================================================
|
|
222
|
+
// Internal State
|
|
223
|
+
// ============================================================================
|
|
224
|
+
|
|
225
|
+
interface PanelState<T extends ResultItem> {
|
|
226
|
+
isOpen: boolean;
|
|
227
|
+
bufferId: number | null;
|
|
228
|
+
splitId: number | null;
|
|
229
|
+
sourceSplitId: number | null;
|
|
230
|
+
cachedContent: string;
|
|
231
|
+
cursorLine: number;
|
|
232
|
+
items: T[];
|
|
233
|
+
// Maps panel line -> item index (for sync)
|
|
234
|
+
lineToItemIndex: Map<number, number>;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// ResultsPanel Class
|
|
239
|
+
// ============================================================================
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* ResultsPanel - manages a results list panel with Provider pattern
|
|
243
|
+
*/
|
|
244
|
+
export class ResultsPanel<T extends ResultItem = ResultItem> {
|
|
245
|
+
private state: PanelState<T> = {
|
|
246
|
+
isOpen: false,
|
|
247
|
+
bufferId: null,
|
|
248
|
+
splitId: null,
|
|
249
|
+
sourceSplitId: null,
|
|
250
|
+
cachedContent: "",
|
|
251
|
+
cursorLine: 1,
|
|
252
|
+
items: [],
|
|
253
|
+
lineToItemIndex: new Map(),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
private readonly modeName: string;
|
|
257
|
+
private readonly panelName: string;
|
|
258
|
+
private readonly namespace: string;
|
|
259
|
+
private readonly handlerPrefix: string;
|
|
260
|
+
|
|
261
|
+
private providerDisposable: Disposable | null = null;
|
|
262
|
+
private cursorSyncDisposable: Disposable | null = null;
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Create a new ResultsPanel with a Provider
|
|
266
|
+
*
|
|
267
|
+
* @param editor - The editor API instance
|
|
268
|
+
* @param id - Unique identifier for this panel (e.g., "references", "diagnostics")
|
|
269
|
+
* @param provider - The data provider
|
|
270
|
+
* @param options - Panel configuration
|
|
271
|
+
*/
|
|
272
|
+
constructor(
|
|
273
|
+
private readonly editor: EditorAPI,
|
|
274
|
+
private readonly id: string,
|
|
275
|
+
private readonly provider: ResultsProvider<T>,
|
|
276
|
+
private readonly options: ResultsPanelOptions<T>
|
|
277
|
+
) {
|
|
278
|
+
this.modeName = `${id}-results`;
|
|
279
|
+
this.panelName = `*${id.charAt(0).toUpperCase() + id.slice(1)}*`;
|
|
280
|
+
this.namespace = id;
|
|
281
|
+
this.handlerPrefix = `_results_panel_${id}`;
|
|
282
|
+
|
|
283
|
+
// Define mode with minimal keybindings (navigation inherited from "normal")
|
|
284
|
+
editor.defineMode(
|
|
285
|
+
this.modeName,
|
|
286
|
+
"normal",
|
|
287
|
+
[
|
|
288
|
+
["Return", `${this.handlerPrefix}_select`],
|
|
289
|
+
["Escape", `${this.handlerPrefix}_close`],
|
|
290
|
+
],
|
|
291
|
+
true
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// Register global handlers
|
|
295
|
+
this.registerHandlers();
|
|
296
|
+
|
|
297
|
+
// Auto-subscribe to provider changes (the "VS Code Way")
|
|
298
|
+
if (this.provider.onDidChangeResults) {
|
|
299
|
+
this.providerDisposable = this.provider.onDidChangeResults(() => {
|
|
300
|
+
if (this.state.isOpen) {
|
|
301
|
+
this.refresh();
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ==========================================================================
|
|
308
|
+
// Public API
|
|
309
|
+
// ==========================================================================
|
|
310
|
+
|
|
311
|
+
get isOpen(): boolean {
|
|
312
|
+
return this.state.isOpen;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
get bufferId(): number | null {
|
|
316
|
+
return this.state.bufferId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
get sourceSplitId(): number | null {
|
|
320
|
+
return this.state.sourceSplitId;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Show the panel, fetching items from the provider
|
|
325
|
+
*/
|
|
326
|
+
async show(): Promise<void> {
|
|
327
|
+
// Save source context if not already open
|
|
328
|
+
if (!this.state.isOpen) {
|
|
329
|
+
this.state.sourceSplitId = this.editor.getActiveSplitId();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Fetch items from provider
|
|
333
|
+
this.state.items = await Promise.resolve(this.provider.provideResults());
|
|
334
|
+
|
|
335
|
+
// Build entries
|
|
336
|
+
const entries = this.buildEntries();
|
|
337
|
+
this.state.cachedContent = entries.map((e) => e.text).join("");
|
|
338
|
+
this.state.cursorLine = this.findFirstItemLine();
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const result = await this.editor.createVirtualBufferInSplit({
|
|
342
|
+
name: this.panelName,
|
|
343
|
+
mode: this.modeName,
|
|
344
|
+
read_only: true,
|
|
345
|
+
entries: entries,
|
|
346
|
+
ratio: this.options.ratio ?? 0.7,
|
|
347
|
+
direction: "horizontal",
|
|
348
|
+
panel_id: this.id,
|
|
349
|
+
show_line_numbers: false,
|
|
350
|
+
show_cursors: true,
|
|
351
|
+
editing_disabled: true,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
if (result.buffer_id !== null) {
|
|
355
|
+
this.state.bufferId = result.buffer_id;
|
|
356
|
+
this.state.splitId = result.split_id ?? null;
|
|
357
|
+
this.state.isOpen = true;
|
|
358
|
+
this.applyHighlighting();
|
|
359
|
+
|
|
360
|
+
// Enable bidirectional cursor sync if requested
|
|
361
|
+
if (this.options.syncWithEditor) {
|
|
362
|
+
this.enableCursorSync();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const count = this.state.items.length;
|
|
366
|
+
this.editor.setStatus(
|
|
367
|
+
`${this.options.title}: ${count} item${count !== 1 ? "s" : ""}`
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
this.editor.setStatus(`Failed to open ${this.panelName}`);
|
|
371
|
+
}
|
|
372
|
+
} catch (error) {
|
|
373
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
374
|
+
this.editor.setStatus(`Failed to open panel: ${msg}`);
|
|
375
|
+
this.editor.debug(`ResultsPanel error: ${msg}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Refresh the panel by re-fetching from the provider
|
|
381
|
+
*/
|
|
382
|
+
async refresh(): Promise<void> {
|
|
383
|
+
if (!this.state.isOpen || this.state.bufferId === null) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
this.state.items = await Promise.resolve(this.provider.provideResults());
|
|
388
|
+
|
|
389
|
+
const entries = this.buildEntries();
|
|
390
|
+
this.state.cachedContent = entries.map((e) => e.text).join("");
|
|
391
|
+
|
|
392
|
+
this.editor.setVirtualBufferContent(this.state.bufferId, entries);
|
|
393
|
+
this.applyHighlighting();
|
|
394
|
+
|
|
395
|
+
const count = this.state.items.length;
|
|
396
|
+
this.editor.setStatus(
|
|
397
|
+
`${this.options.title}: ${count} item${count !== 1 ? "s" : ""}`
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Close the panel and clean up
|
|
403
|
+
*/
|
|
404
|
+
close(): void {
|
|
405
|
+
if (!this.state.isOpen) {
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Capture values before clearing
|
|
410
|
+
const splitId = this.state.splitId;
|
|
411
|
+
const bufferId = this.state.bufferId;
|
|
412
|
+
const sourceSplitId = this.state.sourceSplitId;
|
|
413
|
+
|
|
414
|
+
// Disable cursor sync
|
|
415
|
+
if (this.cursorSyncDisposable) {
|
|
416
|
+
this.cursorSyncDisposable();
|
|
417
|
+
this.cursorSyncDisposable = null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Clear state
|
|
421
|
+
this.state.isOpen = false;
|
|
422
|
+
this.state.bufferId = null;
|
|
423
|
+
this.state.splitId = null;
|
|
424
|
+
this.state.sourceSplitId = null;
|
|
425
|
+
this.state.cachedContent = "";
|
|
426
|
+
this.state.cursorLine = 1;
|
|
427
|
+
this.state.items = [];
|
|
428
|
+
this.state.lineToItemIndex.clear();
|
|
429
|
+
|
|
430
|
+
// Close split and buffer
|
|
431
|
+
if (splitId !== null) {
|
|
432
|
+
this.editor.closeSplit(splitId);
|
|
433
|
+
}
|
|
434
|
+
if (bufferId !== null) {
|
|
435
|
+
this.editor.closeBuffer(bufferId);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Focus source
|
|
439
|
+
if (sourceSplitId !== null) {
|
|
440
|
+
this.editor.focusSplit(sourceSplitId);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Call user callback
|
|
444
|
+
if (this.options.onClose) {
|
|
445
|
+
this.options.onClose();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.editor.setStatus(`${this.panelName} closed`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Reveal an item by ID (scroll to and highlight)
|
|
453
|
+
*/
|
|
454
|
+
reveal(itemId: string, options?: { focus?: boolean; select?: boolean }): void {
|
|
455
|
+
if (!this.state.isOpen || this.state.bufferId === null) return;
|
|
456
|
+
|
|
457
|
+
const index = this.state.items.findIndex((item) => item.id === itemId);
|
|
458
|
+
if (index === -1) return;
|
|
459
|
+
|
|
460
|
+
// Find the panel line for this item
|
|
461
|
+
for (const [line, idx] of this.state.lineToItemIndex) {
|
|
462
|
+
if (idx === index) {
|
|
463
|
+
this.state.cursorLine = line;
|
|
464
|
+
|
|
465
|
+
// Move cursor to this line
|
|
466
|
+
const byteOffset = this.lineToByteOffset(line);
|
|
467
|
+
this.editor.setBufferCursor(this.state.bufferId, byteOffset);
|
|
468
|
+
this.applyHighlighting();
|
|
469
|
+
|
|
470
|
+
if (options?.focus) {
|
|
471
|
+
this.focusPanel();
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Open a file in the source split and jump to location
|
|
480
|
+
*/
|
|
481
|
+
openInSource(file: string, line: number, column: number): void {
|
|
482
|
+
if (this.state.sourceSplitId === null) return;
|
|
483
|
+
|
|
484
|
+
this.editor.focusSplit(this.state.sourceSplitId);
|
|
485
|
+
this.editor.openFile(file, line, column);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Focus the source split
|
|
490
|
+
*/
|
|
491
|
+
focusSource(): void {
|
|
492
|
+
if (this.state.sourceSplitId !== null) {
|
|
493
|
+
this.editor.focusSplit(this.state.sourceSplitId);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Focus the panel split
|
|
499
|
+
*/
|
|
500
|
+
focusPanel(): void {
|
|
501
|
+
if (this.state.splitId !== null) {
|
|
502
|
+
this.editor.focusSplit(this.state.splitId);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get the currently selected item
|
|
508
|
+
*/
|
|
509
|
+
getSelectedItem(): T | null {
|
|
510
|
+
const index = this.state.lineToItemIndex.get(this.state.cursorLine);
|
|
511
|
+
if (index !== undefined && index < this.state.items.length) {
|
|
512
|
+
return this.state.items[index];
|
|
513
|
+
}
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Dispose the panel and all subscriptions
|
|
519
|
+
*/
|
|
520
|
+
dispose(): void {
|
|
521
|
+
this.close();
|
|
522
|
+
if (this.providerDisposable) {
|
|
523
|
+
this.providerDisposable();
|
|
524
|
+
this.providerDisposable = null;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ==========================================================================
|
|
529
|
+
// Private Methods
|
|
530
|
+
// ==========================================================================
|
|
531
|
+
|
|
532
|
+
private registerHandlers(): void {
|
|
533
|
+
const self = this;
|
|
534
|
+
|
|
535
|
+
// Select handler (Enter)
|
|
536
|
+
(globalThis as Record<string, unknown>)[`${this.handlerPrefix}_select`] =
|
|
537
|
+
function (): void {
|
|
538
|
+
if (!self.state.isOpen) return;
|
|
539
|
+
|
|
540
|
+
const item = self.getSelectedItem();
|
|
541
|
+
if (item && self.options.onSelect) {
|
|
542
|
+
const index = self.state.items.indexOf(item);
|
|
543
|
+
self.options.onSelect(item, index);
|
|
544
|
+
} else if (!item) {
|
|
545
|
+
self.editor.setStatus("No item selected");
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// Close handler (Escape)
|
|
550
|
+
(globalThis as Record<string, unknown>)[`${this.handlerPrefix}_close`] =
|
|
551
|
+
function (): void {
|
|
552
|
+
self.close();
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
// Panel cursor movement handler
|
|
556
|
+
(globalThis as Record<string, unknown>)[
|
|
557
|
+
`${this.handlerPrefix}_cursor_moved`
|
|
558
|
+
] = function (data: {
|
|
559
|
+
buffer_id: number;
|
|
560
|
+
cursor_id: number;
|
|
561
|
+
old_position: number;
|
|
562
|
+
new_position: number;
|
|
563
|
+
line: number;
|
|
564
|
+
}): void {
|
|
565
|
+
if (!self.state.isOpen || self.state.bufferId === null) return;
|
|
566
|
+
if (data.buffer_id !== self.state.bufferId) return;
|
|
567
|
+
|
|
568
|
+
self.state.cursorLine = data.line;
|
|
569
|
+
self.applyHighlighting();
|
|
570
|
+
|
|
571
|
+
// Get the item at this line
|
|
572
|
+
const itemIndex = self.state.lineToItemIndex.get(data.line);
|
|
573
|
+
if (itemIndex !== undefined && itemIndex < self.state.items.length) {
|
|
574
|
+
const item = self.state.items[itemIndex];
|
|
575
|
+
self.editor.setStatus(`Item ${itemIndex + 1}/${self.state.items.length}`);
|
|
576
|
+
|
|
577
|
+
if (self.options.onCursorMove) {
|
|
578
|
+
self.options.onCursorMove(item, itemIndex);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
// Register cursor movement handler
|
|
584
|
+
this.editor.on("cursor_moved", `${this.handlerPrefix}_cursor_moved`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Enable bidirectional cursor sync with source files
|
|
589
|
+
*/
|
|
590
|
+
private enableCursorSync(): void {
|
|
591
|
+
const self = this;
|
|
592
|
+
const handlerName = `${this.handlerPrefix}_source_cursor`;
|
|
593
|
+
|
|
594
|
+
// Handler for cursor movement in SOURCE files
|
|
595
|
+
(globalThis as Record<string, unknown>)[handlerName] = function (data: {
|
|
596
|
+
buffer_id: number;
|
|
597
|
+
cursor_id: number;
|
|
598
|
+
old_position: number;
|
|
599
|
+
new_position: number;
|
|
600
|
+
line: number;
|
|
601
|
+
}): void {
|
|
602
|
+
if (!self.state.isOpen || self.state.bufferId === null) return;
|
|
603
|
+
|
|
604
|
+
// Ignore cursor moves in the panel itself
|
|
605
|
+
if (data.buffer_id === self.state.bufferId) return;
|
|
606
|
+
|
|
607
|
+
// Get the file path for this buffer
|
|
608
|
+
const filePath = self.editor.getBufferPath(data.buffer_id);
|
|
609
|
+
if (!filePath) return;
|
|
610
|
+
|
|
611
|
+
// Find an item that matches this file and line
|
|
612
|
+
const matchingIndex = self.state.items.findIndex((item) => {
|
|
613
|
+
if (!item.location) return false;
|
|
614
|
+
return (
|
|
615
|
+
item.location.file === filePath && item.location.line === data.line
|
|
616
|
+
);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
if (matchingIndex >= 0) {
|
|
620
|
+
const item = self.state.items[matchingIndex];
|
|
621
|
+
// Reveal this item in the panel (without stealing focus)
|
|
622
|
+
self.reveal(item.id, { focus: false, select: true });
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// Register the handler
|
|
627
|
+
this.editor.on("cursor_moved", handlerName);
|
|
628
|
+
|
|
629
|
+
// Store disposable to unregister later
|
|
630
|
+
this.cursorSyncDisposable = () => {
|
|
631
|
+
// Note: Fresh doesn't have an "off" method, so we just make the handler a no-op
|
|
632
|
+
(globalThis as Record<string, unknown>)[handlerName] = () => {};
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
private buildEntries(): TextPropertyEntry[] {
|
|
637
|
+
const entries: TextPropertyEntry[] = [];
|
|
638
|
+
this.state.lineToItemIndex.clear();
|
|
639
|
+
|
|
640
|
+
let currentLine = 1;
|
|
641
|
+
|
|
642
|
+
// Title line
|
|
643
|
+
entries.push({
|
|
644
|
+
text: `${this.options.title}\n`,
|
|
645
|
+
properties: { type: "title" },
|
|
646
|
+
});
|
|
647
|
+
currentLine++;
|
|
648
|
+
|
|
649
|
+
if (this.state.items.length === 0) {
|
|
650
|
+
entries.push({
|
|
651
|
+
text: " No results\n",
|
|
652
|
+
properties: { type: "empty" },
|
|
653
|
+
});
|
|
654
|
+
currentLine++;
|
|
655
|
+
} else if (this.options.groupBy === "file") {
|
|
656
|
+
// Group by file
|
|
657
|
+
const byFile = new Map<string, Array<{ item: T; index: number }>>();
|
|
658
|
+
|
|
659
|
+
for (let i = 0; i < this.state.items.length; i++) {
|
|
660
|
+
const item = this.state.items[i];
|
|
661
|
+
const file = item.location?.file ?? "(no file)";
|
|
662
|
+
if (!byFile.has(file)) {
|
|
663
|
+
byFile.set(file, []);
|
|
664
|
+
}
|
|
665
|
+
byFile.get(file)!.push({ item, index: i });
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
for (const [file, itemsInFile] of byFile) {
|
|
669
|
+
// File header
|
|
670
|
+
const fileName = file.split("/").pop() ?? file;
|
|
671
|
+
entries.push({
|
|
672
|
+
text: `\n${fileName}:\n`,
|
|
673
|
+
properties: { type: "file-header", file },
|
|
674
|
+
});
|
|
675
|
+
currentLine += 2;
|
|
676
|
+
|
|
677
|
+
// Items in this file
|
|
678
|
+
for (const { item, index } of itemsInFile) {
|
|
679
|
+
entries.push(this.buildItemEntry(item, index));
|
|
680
|
+
this.state.lineToItemIndex.set(currentLine, index);
|
|
681
|
+
currentLine++;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
// Flat list
|
|
686
|
+
for (let i = 0; i < this.state.items.length; i++) {
|
|
687
|
+
const item = this.state.items[i];
|
|
688
|
+
entries.push(this.buildItemEntry(item, i));
|
|
689
|
+
this.state.lineToItemIndex.set(currentLine, i);
|
|
690
|
+
currentLine++;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Help footer
|
|
695
|
+
entries.push({
|
|
696
|
+
text: "\n",
|
|
697
|
+
properties: { type: "blank" },
|
|
698
|
+
});
|
|
699
|
+
entries.push({
|
|
700
|
+
text: "Enter:select | Esc:close\n",
|
|
701
|
+
properties: { type: "help" },
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
return entries;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private buildItemEntry(item: T, _index: number): TextPropertyEntry {
|
|
708
|
+
const severityIcon =
|
|
709
|
+
item.severity === "error"
|
|
710
|
+
? "[E]"
|
|
711
|
+
: item.severity === "warning"
|
|
712
|
+
? "[W]"
|
|
713
|
+
: item.severity === "info"
|
|
714
|
+
? "[I]"
|
|
715
|
+
: item.severity === "hint"
|
|
716
|
+
? "[H]"
|
|
717
|
+
: "";
|
|
718
|
+
|
|
719
|
+
const prefix = severityIcon ? `${severityIcon} ` : " ";
|
|
720
|
+
const desc = item.description ? ` ${item.description}` : "";
|
|
721
|
+
|
|
722
|
+
let line = `${prefix}${item.label}${desc}`;
|
|
723
|
+
const maxLen = 100;
|
|
724
|
+
if (line.length > maxLen) {
|
|
725
|
+
line = line.slice(0, maxLen - 3) + "...";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
text: `${line}\n`,
|
|
730
|
+
properties: {
|
|
731
|
+
type: "item",
|
|
732
|
+
id: item.id,
|
|
733
|
+
location: item.location,
|
|
734
|
+
severity: item.severity,
|
|
735
|
+
metadata: item.metadata,
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private findFirstItemLine(): number {
|
|
741
|
+
// Find the first line that has an item
|
|
742
|
+
for (const [line] of this.state.lineToItemIndex) {
|
|
743
|
+
return line;
|
|
744
|
+
}
|
|
745
|
+
return 2; // Default to line after title
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private lineToByteOffset(lineNumber: number): number {
|
|
749
|
+
const lines = this.state.cachedContent.split("\n");
|
|
750
|
+
let offset = 0;
|
|
751
|
+
for (let i = 0; i < lineNumber - 1 && i < lines.length; i++) {
|
|
752
|
+
offset += lines[i].length + 1;
|
|
753
|
+
}
|
|
754
|
+
return offset;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
private applyHighlighting(): void {
|
|
758
|
+
if (this.state.bufferId === null) return;
|
|
759
|
+
|
|
760
|
+
const bufferId = this.state.bufferId;
|
|
761
|
+
this.editor.clearNamespace(bufferId, this.namespace);
|
|
762
|
+
|
|
763
|
+
if (!this.state.cachedContent) return;
|
|
764
|
+
|
|
765
|
+
const lines = this.state.cachedContent.split("\n");
|
|
766
|
+
let byteOffset = 0;
|
|
767
|
+
|
|
768
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
769
|
+
const line = lines[lineIdx];
|
|
770
|
+
const lineStart = byteOffset;
|
|
771
|
+
const lineEnd = byteOffset + line.length;
|
|
772
|
+
const lineNumber = lineIdx + 1;
|
|
773
|
+
const isCurrentLine = lineNumber === this.state.cursorLine;
|
|
774
|
+
const isItemLine = this.state.lineToItemIndex.has(lineNumber);
|
|
775
|
+
|
|
776
|
+
// Highlight current line if it's an item line
|
|
777
|
+
if (isCurrentLine && isItemLine && line.trim() !== "") {
|
|
778
|
+
this.editor.addOverlay(
|
|
779
|
+
bufferId,
|
|
780
|
+
this.namespace,
|
|
781
|
+
lineStart,
|
|
782
|
+
lineEnd,
|
|
783
|
+
colors.selected[0],
|
|
784
|
+
colors.selected[1],
|
|
785
|
+
colors.selected[2],
|
|
786
|
+
true,
|
|
787
|
+
true,
|
|
788
|
+
false
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Title line
|
|
793
|
+
if (lineNumber === 1) {
|
|
794
|
+
this.editor.addOverlay(
|
|
795
|
+
bufferId,
|
|
796
|
+
this.namespace,
|
|
797
|
+
lineStart,
|
|
798
|
+
lineEnd,
|
|
799
|
+
colors.title[0],
|
|
800
|
+
colors.title[1],
|
|
801
|
+
colors.title[2],
|
|
802
|
+
true,
|
|
803
|
+
true,
|
|
804
|
+
false
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// File header (ends with : but isn't title)
|
|
809
|
+
if (line.endsWith(":") && lineNumber > 1 && !line.startsWith(" ")) {
|
|
810
|
+
this.editor.addOverlay(
|
|
811
|
+
bufferId,
|
|
812
|
+
this.namespace,
|
|
813
|
+
lineStart,
|
|
814
|
+
lineEnd,
|
|
815
|
+
colors.fileHeader[0],
|
|
816
|
+
colors.fileHeader[1],
|
|
817
|
+
colors.fileHeader[2],
|
|
818
|
+
false,
|
|
819
|
+
true,
|
|
820
|
+
false
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Severity icon highlighting
|
|
825
|
+
const iconMatch = line.match(/^\[([EWIH])\]/);
|
|
826
|
+
if (iconMatch) {
|
|
827
|
+
const iconEnd = lineStart + 3;
|
|
828
|
+
let color: RGB;
|
|
829
|
+
switch (iconMatch[1]) {
|
|
830
|
+
case "E":
|
|
831
|
+
color = colors.error;
|
|
832
|
+
break;
|
|
833
|
+
case "W":
|
|
834
|
+
color = colors.warning;
|
|
835
|
+
break;
|
|
836
|
+
case "I":
|
|
837
|
+
color = colors.info;
|
|
838
|
+
break;
|
|
839
|
+
case "H":
|
|
840
|
+
color = colors.hint;
|
|
841
|
+
break;
|
|
842
|
+
default:
|
|
843
|
+
color = colors.hint;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
this.editor.addOverlay(
|
|
847
|
+
bufferId,
|
|
848
|
+
this.namespace,
|
|
849
|
+
lineStart,
|
|
850
|
+
iconEnd,
|
|
851
|
+
color[0],
|
|
852
|
+
color[1],
|
|
853
|
+
color[2],
|
|
854
|
+
false,
|
|
855
|
+
true,
|
|
856
|
+
false
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Help line (dimmed)
|
|
861
|
+
if (line.startsWith("Enter:") || line.includes("|")) {
|
|
862
|
+
this.editor.addOverlay(
|
|
863
|
+
bufferId,
|
|
864
|
+
this.namespace,
|
|
865
|
+
lineStart,
|
|
866
|
+
lineEnd,
|
|
867
|
+
colors.help[0],
|
|
868
|
+
colors.help[1],
|
|
869
|
+
colors.help[2],
|
|
870
|
+
false,
|
|
871
|
+
true,
|
|
872
|
+
false
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
byteOffset += line.length + 1;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ============================================================================
|
|
882
|
+
// Utility Functions
|
|
883
|
+
// ============================================================================
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Get the relative path for display
|
|
887
|
+
*/
|
|
888
|
+
export function getRelativePath(editor: EditorAPI, filePath: string): string {
|
|
889
|
+
const cwd = editor.getCwd();
|
|
890
|
+
if (filePath.startsWith(cwd)) {
|
|
891
|
+
return filePath.slice(cwd.length + 1);
|
|
892
|
+
}
|
|
893
|
+
return filePath;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Create a simple static provider from an array of items.
|
|
898
|
+
* Useful for one-shot results like "Find References".
|
|
899
|
+
*/
|
|
900
|
+
export function createStaticProvider<T extends ResultItem>(
|
|
901
|
+
initialItems: T[] = []
|
|
902
|
+
): ResultsProvider<T> & { updateItems: (items: T[]) => void } {
|
|
903
|
+
let items = initialItems;
|
|
904
|
+
const emitter = new EventEmitter<void>();
|
|
905
|
+
|
|
906
|
+
return {
|
|
907
|
+
provideResults: () => items,
|
|
908
|
+
onDidChangeResults: emitter.event,
|
|
909
|
+
updateItems: (newItems: T[]) => {
|
|
910
|
+
items = newItems;
|
|
911
|
+
emitter.fireVoid();
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
}
|