@pellux/goodvibes-tui 0.18.23 → 0.19.0
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 +34 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -3
- package/src/core/conversation-rendering.ts +8 -6
- package/src/core/orchestrator.ts +1 -1
- package/src/input/commands/diff-runtime.ts +6 -5
- package/src/input/commands/guidance-runtime.ts +1 -1
- package/src/input/commands/health-runtime.ts +2 -2
- package/src/input/commands/local-setup-review.ts +1 -1
- package/src/input/commands/session-content.ts +1 -1
- package/src/input/commands/shell-core.ts +3 -2
- package/src/input/commands/skills-runtime.ts +2 -2
- package/src/input/commands/subscription-runtime.ts +4 -4
- package/src/input/handler.ts +8 -10
- package/src/input/panel-integration-actions.ts +2 -1
- package/src/input/settings-modal-types.ts +60 -0
- package/src/input/settings-modal.ts +83 -65
- package/src/panels/agent-inspector-panel.ts +10 -9
- package/src/panels/agent-logs-panel.ts +26 -6
- package/src/panels/approval-panel.ts +1 -0
- package/src/panels/automation-control-panel.ts +1 -0
- package/src/panels/base-panel.ts +108 -3
- package/src/panels/communication-panel.ts +1 -0
- package/src/panels/context-visualizer-panel.ts +2 -0
- package/src/panels/control-plane-panel.ts +1 -0
- package/src/panels/diff-panel.ts +2 -0
- package/src/panels/file-explorer-panel.ts +51 -31
- package/src/panels/file-preview-panel.ts +57 -35
- package/src/panels/git-panel.ts +12 -13
- package/src/panels/hooks-panel.ts +3 -1
- package/src/panels/incident-review-panel.ts +4 -2
- package/src/panels/knowledge-panel.ts +75 -107
- package/src/panels/local-auth-panel.ts +1 -0
- package/src/panels/marketplace-panel.ts +51 -69
- package/src/panels/mcp-panel.ts +3 -1
- package/src/panels/memory-panel.ts +90 -158
- package/src/panels/ops-control-panel.ts +1 -0
- package/src/panels/orchestration-panel.ts +70 -51
- package/src/panels/panel-list-panel.ts +5 -4
- package/src/panels/panel-manager.ts +3 -0
- package/src/panels/plan-dashboard-panel.ts +2 -0
- package/src/panels/plugins-panel.ts +1 -0
- package/src/panels/polish.ts +51 -2
- package/src/panels/provider-accounts-panel.ts +1 -0
- package/src/panels/provider-health-panel.ts +6 -8
- package/src/panels/routes-panel.ts +3 -1
- package/src/panels/schedule-panel.ts +7 -6
- package/src/panels/scrollable-list-panel.ts +19 -2
- package/src/panels/security-panel.ts +17 -15
- package/src/panels/services-panel.ts +6 -4
- package/src/panels/session-browser-panel.ts +19 -18
- package/src/panels/settings-sync-panel.ts +3 -1
- package/src/panels/skills-panel.ts +114 -230
- package/src/panels/subscription-panel.ts +1 -0
- package/src/panels/system-messages-panel.ts +147 -141
- package/src/panels/tasks-panel.ts +1 -0
- package/src/panels/token-budget-panel.ts +2 -0
- package/src/panels/watchers-panel.ts +1 -0
- package/src/panels/worktree-panel.ts +1 -0
- package/src/panels/wrfc-panel.ts +2 -0
- package/src/renderer/agent-detail-modal.ts +2 -2
- package/src/renderer/ansi-sanitize.ts +76 -0
- package/src/renderer/buffer.ts +12 -1
- package/src/renderer/help-overlay.ts +14 -3
- package/src/renderer/settings-modal-helpers.ts +27 -0
- package/src/renderer/settings-modal.ts +18 -1
- package/src/renderer/status-glyphs.ts +21 -0
- package/src/renderer/status-token.ts +4 -8
- package/src/renderer/tool-call.ts +4 -3
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap-hook-bridge.ts +1 -1
- package/src/runtime/bootstrap.ts +7 -8
- package/src/runtime/diagnostics/panels/policy.ts +2 -1
- package/src/shell/ui-openers.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -97,7 +97,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
97
97
|
private cursorIndex = 0;
|
|
98
98
|
|
|
99
99
|
// Refresh timer (active only while panel is active)
|
|
100
|
-
private
|
|
100
|
+
private refreshTimerId: ReturnType<typeof setInterval> | null = null;
|
|
101
101
|
|
|
102
102
|
// Row cache — cleared on markDirty(), computed once per render cycle
|
|
103
103
|
private _cachedRows: DisplayRow[] | null = null;
|
|
@@ -131,7 +131,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
131
131
|
this.cursorIndex = 0;
|
|
132
132
|
this.timeline = [];
|
|
133
133
|
this.markDirty();
|
|
134
|
-
this._refreshTimeline().catch(() => {});
|
|
134
|
+
this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh failed', { err }); });
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
// -------------------------------------------------------------------------
|
|
@@ -149,6 +149,7 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
149
149
|
|
|
150
150
|
override onDestroy(): void {
|
|
151
151
|
this._stopRefresh();
|
|
152
|
+
super.onDestroy();
|
|
152
153
|
}
|
|
153
154
|
|
|
154
155
|
// -------------------------------------------------------------------------
|
|
@@ -461,16 +462,16 @@ export class AgentInspectorPanel extends BasePanel {
|
|
|
461
462
|
}
|
|
462
463
|
|
|
463
464
|
private _startRefresh(): void {
|
|
464
|
-
if (this.
|
|
465
|
-
this.
|
|
466
|
-
this._refreshTimeline().catch(() => {});
|
|
467
|
-
}, REFRESH_MS);
|
|
465
|
+
if (this.refreshTimerId) return;
|
|
466
|
+
this.refreshTimerId = this.registerTimer(setInterval(() => {
|
|
467
|
+
this._refreshTimeline().catch((err) => { logger.debug('agent inspector timeline refresh tick failed', { err }); });
|
|
468
|
+
}, REFRESH_MS));
|
|
468
469
|
}
|
|
469
470
|
|
|
470
471
|
private _stopRefresh(): void {
|
|
471
|
-
if (this.
|
|
472
|
-
|
|
473
|
-
this.
|
|
472
|
+
if (this.refreshTimerId) {
|
|
473
|
+
this.clearTimer(this.refreshTimerId);
|
|
474
|
+
this.refreshTimerId = null;
|
|
474
475
|
}
|
|
475
476
|
}
|
|
476
477
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { promises as fsPromises, watch, type FSWatcher } from 'fs';
|
|
2
2
|
import type { Line } from '../types/grid.ts';
|
|
3
3
|
import { createEmptyLine, createStyledCell } from '../types/grid.ts';
|
|
4
4
|
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
@@ -61,6 +61,7 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
61
61
|
|
|
62
62
|
constructor(agentEvents: UiEventFeed<AgentEvent>, private readonly deps: AgentLogsPanelDeps) {
|
|
63
63
|
super('agent-logs', 'Agents', 'A', 'agent');
|
|
64
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
64
65
|
this.agentEvents = agentEvents;
|
|
65
66
|
this._refreshAgents();
|
|
66
67
|
this._startPolling();
|
|
@@ -256,14 +257,22 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
256
257
|
}
|
|
257
258
|
|
|
258
259
|
private _pollCurrentAgent(): void {
|
|
260
|
+
void this._pollCurrentAgentAsync();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private async _pollCurrentAgentAsync(): Promise<void> {
|
|
259
264
|
const agent = this._selectedAgent();
|
|
260
265
|
if (!agent) return;
|
|
261
266
|
|
|
262
267
|
const sessionFile = this._sessionFilePath(agent.id);
|
|
263
|
-
|
|
268
|
+
try {
|
|
269
|
+
await fsPromises.access(sessionFile);
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
264
273
|
|
|
265
274
|
try {
|
|
266
|
-
const content =
|
|
275
|
+
const content = await fsPromises.readFile(sessionFile, 'utf-8');
|
|
267
276
|
if (content.length === this.lastFileSize) return;
|
|
268
277
|
this.lastFileSize = content.length;
|
|
269
278
|
|
|
@@ -284,7 +293,9 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
284
293
|
private _watchAgent(agentId: string): void {
|
|
285
294
|
this._stopWatcher();
|
|
286
295
|
const sessionFile = this._sessionFilePath(agentId);
|
|
287
|
-
|
|
296
|
+
// Start watching immediately; the watcher setup itself is synchronous,
|
|
297
|
+
// the file-existence check is skipped to avoid blocking — if the file
|
|
298
|
+
// does not yet exist watch() will throw and we catch it below.
|
|
288
299
|
try {
|
|
289
300
|
this.fsWatcher = watch(sessionFile, () => {
|
|
290
301
|
if (!this.paused) {
|
|
@@ -392,24 +403,33 @@ export class AgentLogsPanel extends ScrollableListPanel<LogEntry> {
|
|
|
392
403
|
}
|
|
393
404
|
|
|
394
405
|
private _reloadAgent(agent: AgentRecord): void {
|
|
406
|
+
void this._reloadAgentAsync(agent);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async _reloadAgentAsync(agent: AgentRecord): Promise<void> {
|
|
395
410
|
const sessionFile = this._sessionFilePath(agent.id);
|
|
396
|
-
|
|
411
|
+
try {
|
|
412
|
+
await fsPromises.access(sessionFile);
|
|
413
|
+
} catch {
|
|
397
414
|
this.allEntries = [];
|
|
398
415
|
this.filteredEntries = [];
|
|
399
416
|
this.lastFileSize = 0;
|
|
417
|
+
this.markDirty();
|
|
400
418
|
return;
|
|
401
419
|
}
|
|
402
420
|
try {
|
|
403
|
-
const content =
|
|
421
|
+
const content = await fsPromises.readFile(sessionFile, 'utf-8');
|
|
404
422
|
this.lastFileSize = content.length;
|
|
405
423
|
this.allEntries = parseAgentJsonl(content);
|
|
406
424
|
this._applyFilter();
|
|
407
425
|
if (this.autoFollow) {
|
|
408
426
|
this.selectedIndex = Math.max(0, this.filteredEntries.length - 1);
|
|
409
427
|
}
|
|
428
|
+
this.markDirty();
|
|
410
429
|
} catch {
|
|
411
430
|
this.allEntries = [];
|
|
412
431
|
this.filteredEntries = [];
|
|
432
|
+
this.markDirty();
|
|
413
433
|
}
|
|
414
434
|
}
|
|
415
435
|
|
|
@@ -33,6 +33,7 @@ export class ApprovalPanel extends ScrollableListPanel<ApprovalRow> {
|
|
|
33
33
|
|
|
34
34
|
public constructor(policyRuntimeState: Pick<PolicyRuntimeState, 'getSnapshot'>) {
|
|
35
35
|
super('approval', 'Approval', 'A', 'monitoring');
|
|
36
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
36
37
|
this.policyRuntimeState = policyRuntimeState;
|
|
37
38
|
}
|
|
38
39
|
|
|
@@ -45,6 +45,7 @@ export class AutomationControlPanel extends ScrollableListPanel<AutomationRun> {
|
|
|
45
45
|
|
|
46
46
|
public constructor(readModel?: UiReadModel<UiAutomationSnapshot>) {
|
|
47
47
|
super('automation', 'Automation', 'M', 'monitoring');
|
|
48
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
48
49
|
this.readModel = readModel;
|
|
49
50
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
50
51
|
}
|
package/src/panels/base-panel.ts
CHANGED
|
@@ -11,14 +11,54 @@ export abstract class BasePanel implements Panel {
|
|
|
11
11
|
public isPinned = false;
|
|
12
12
|
protected readonly componentHealthMonitor?: ComponentHealthMonitor;
|
|
13
13
|
|
|
14
|
+
// -------------------------------------------------------------------------
|
|
15
|
+
// Timer registry
|
|
16
|
+
// -------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** All timers registered via registerTimer(). Cleared automatically on onDestroy(). */
|
|
19
|
+
private readonly _timers: Set<ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>> = new Set();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Register a timer id (from setInterval or setTimeout) so it is
|
|
23
|
+
* automatically cleared when the panel is destroyed. Returns the id
|
|
24
|
+
* unchanged so the call can be chained inline:
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* this.registerTimer(setInterval(() => this.refresh(), 5_000));
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
protected registerTimer<T extends ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>>(id: T): T {
|
|
31
|
+
this._timers.add(id);
|
|
32
|
+
return id;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clear a specific timer and remove it from the registry.
|
|
37
|
+
* Safe to call with an id that was never registered or already cleared.
|
|
38
|
+
*/
|
|
39
|
+
protected clearTimer(id: ReturnType<typeof setTimeout> | ReturnType<typeof setInterval>): void {
|
|
40
|
+
clearInterval(id as ReturnType<typeof setInterval>);
|
|
41
|
+
this._timers.delete(id);
|
|
42
|
+
}
|
|
43
|
+
|
|
14
44
|
// -------------------------------------------------------------------------
|
|
15
45
|
// I2: Error surface slot
|
|
16
46
|
// -------------------------------------------------------------------------
|
|
17
47
|
|
|
18
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Last error message to surface in the panel footer.
|
|
50
|
+
* Auto-cleared on the next keystroke by `ScrollableListPanel.handleInput()` (and any
|
|
51
|
+
* subclass that calls `super.handleInput()` or manually calls `this.clearError()` at
|
|
52
|
+
* the start of its handler). BasePanel itself does NOT auto-clear — only subclasses
|
|
53
|
+
* that opt into the contract do.
|
|
54
|
+
*/
|
|
19
55
|
protected lastError: string | null = null;
|
|
20
56
|
|
|
21
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Set a transient error message. Triggers a re-render.
|
|
59
|
+
* The error will be auto-cleared on the next keystroke if the panel extends
|
|
60
|
+
* `ScrollableListPanel` (which calls `clearError()` at the top of `handleInput()`).
|
|
61
|
+
*/
|
|
22
62
|
protected setError(msg: string): void {
|
|
23
63
|
this.lastError = msg;
|
|
24
64
|
this.needsRender = true;
|
|
@@ -66,6 +106,32 @@ export abstract class BasePanel implements Panel {
|
|
|
66
106
|
this.needsRender = true;
|
|
67
107
|
}
|
|
68
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Run an async operation with the panel's loading spinner visible.
|
|
111
|
+
* The spinner is always cleared on completion, whether the operation succeeds or throws
|
|
112
|
+
* (uses try/finally). Rethrows any error so callers can handle it or forward to setError.
|
|
113
|
+
*
|
|
114
|
+
* @param label Optional label shown next to the spinner.
|
|
115
|
+
* @param fn The async work to run.
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```ts
|
|
119
|
+
* try {
|
|
120
|
+
* await this.withLoading('Loading diff…', () => this.fetchDiff());
|
|
121
|
+
* } catch (err) {
|
|
122
|
+
* this.setError(summarizeError(err));
|
|
123
|
+
* }
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
protected async withLoading<T>(label: string | undefined, fn: () => Promise<T>): Promise<T> {
|
|
127
|
+
this.startLoading(label);
|
|
128
|
+
try {
|
|
129
|
+
return await fn();
|
|
130
|
+
} finally {
|
|
131
|
+
this.stopLoading();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
69
135
|
/**
|
|
70
136
|
* Build a spinner Line for the loading state.
|
|
71
137
|
* Returns null when loadingState is not 'loading'.
|
|
@@ -107,7 +173,17 @@ export abstract class BasePanel implements Panel {
|
|
|
107
173
|
|
|
108
174
|
onActivate(): void { this.needsRender = true; }
|
|
109
175
|
onDeactivate(): void {}
|
|
110
|
-
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Called when the panel is permanently removed. Subclasses should call
|
|
179
|
+
* `super.onDestroy()` to ensure all registered timers are cleared.
|
|
180
|
+
*/
|
|
181
|
+
onDestroy(): void {
|
|
182
|
+
for (const id of this._timers) {
|
|
183
|
+
clearInterval(id as ReturnType<typeof setInterval>);
|
|
184
|
+
}
|
|
185
|
+
this._timers.clear();
|
|
186
|
+
}
|
|
111
187
|
|
|
112
188
|
abstract render(width: number, height: number): Line[];
|
|
113
189
|
|
|
@@ -146,4 +222,33 @@ export abstract class BasePanel implements Panel {
|
|
|
146
222
|
protected reportRenderDuration(durationMs: number, now: number = Date.now()): void {
|
|
147
223
|
this.componentHealthMonitor?.recordRender(this.id, durationMs, now);
|
|
148
224
|
}
|
|
225
|
+
|
|
226
|
+
/** Cache of the most recent lines produced by trackedRender. */
|
|
227
|
+
private _lastTrackedLines: Line[] = [];
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Wrap a render body with canRenderNow throttle check, wall-clock timing,
|
|
231
|
+
* and automatic reportRenderDuration.
|
|
232
|
+
*
|
|
233
|
+
* When throttled, returns the previously cached lines (stale but correctly
|
|
234
|
+
* sized) rather than empty lines, avoiding a flicker on every skipped frame.
|
|
235
|
+
*
|
|
236
|
+
* Usage:
|
|
237
|
+
* ```ts
|
|
238
|
+
* render(width: number, height: number): Line[] {
|
|
239
|
+
* return this.trackedRender(() => {
|
|
240
|
+
* // expensive render logic
|
|
241
|
+
* return lines;
|
|
242
|
+
* });
|
|
243
|
+
* }
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
protected trackedRender(fn: () => Line[]): Line[] {
|
|
247
|
+
if (!this.canRenderNow()) return this._lastTrackedLines;
|
|
248
|
+
const start = Date.now();
|
|
249
|
+
const lines = fn();
|
|
250
|
+
this.reportRenderDuration(Date.now() - start);
|
|
251
|
+
this._lastTrackedLines = lines;
|
|
252
|
+
return lines;
|
|
253
|
+
}
|
|
149
254
|
}
|
|
@@ -32,6 +32,7 @@ export class CommunicationPanel extends ScrollableListPanel<CommunicationRecord>
|
|
|
32
32
|
|
|
33
33
|
public constructor(readModel?: UiReadModel<UiCommunicationSnapshot>) {
|
|
34
34
|
super('communication', 'Communication', 'Y', 'monitoring');
|
|
35
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
35
36
|
this.readModel = readModel;
|
|
36
37
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
37
38
|
}
|
|
@@ -72,6 +72,7 @@ export class ContextVisualizerPanel extends BasePanel {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
render(width: number, height: number): Line[] {
|
|
75
|
+
return this.trackedRender(() => {
|
|
75
76
|
if (height <= 0 || width <= 0) return [];
|
|
76
77
|
|
|
77
78
|
const input = this.snapshot.input;
|
|
@@ -131,6 +132,7 @@ export class ContextVisualizerPanel extends BasePanel {
|
|
|
131
132
|
],
|
|
132
133
|
palette: DEFAULT_PANEL_PALETTE,
|
|
133
134
|
});
|
|
135
|
+
});
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
private _renderBar(width: number, barWidth: number, input: number, limit: number): Line {
|
|
@@ -43,6 +43,7 @@ export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
|
|
|
43
43
|
|
|
44
44
|
public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
|
|
45
45
|
super('control-plane', 'Control Plane', 'C', 'monitoring');
|
|
46
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
46
47
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
47
48
|
}
|
|
48
49
|
|
package/src/panels/diff-panel.ts
CHANGED
|
@@ -345,6 +345,7 @@ export class DiffPanel extends BasePanel {
|
|
|
345
345
|
// -------------------------------------------------------------------------
|
|
346
346
|
|
|
347
347
|
render(width: number, height: number): Line[] {
|
|
348
|
+
return this.trackedRender(() => {
|
|
348
349
|
if (height <= 0 || width <= 0) return [];
|
|
349
350
|
|
|
350
351
|
if (this.entries.length === 0) {
|
|
@@ -440,6 +441,7 @@ export class DiffPanel extends BasePanel {
|
|
|
440
441
|
sections,
|
|
441
442
|
footerLines: [this.renderStatusBar(width, entry)],
|
|
442
443
|
});
|
|
444
|
+
});
|
|
443
445
|
}
|
|
444
446
|
|
|
445
447
|
// ── Tab bar ──────────────────────────────────────────────────────────────
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// FileExplorerPanel — collapsible project tree view
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { promises as fsPromises } from 'node:fs';
|
|
6
6
|
import { join, relative, basename } from 'node:path';
|
|
7
7
|
import type { Line } from '../types/grid.ts';
|
|
8
8
|
import { createEmptyLine } from '../types/grid.ts';
|
|
@@ -120,6 +120,7 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
120
120
|
private rootPath: string;
|
|
121
121
|
private readonly workingDirectory: string;
|
|
122
122
|
private cacheValid: boolean = false;
|
|
123
|
+
private readyPromise: Promise<void> | null = null;
|
|
123
124
|
|
|
124
125
|
// --- navigation ---
|
|
125
126
|
private cursor: number = 0;
|
|
@@ -139,7 +140,9 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
139
140
|
|
|
140
141
|
override onActivate(): void {
|
|
141
142
|
super.onActivate();
|
|
142
|
-
if (!this.cacheValid)
|
|
143
|
+
if (!this.cacheValid) {
|
|
144
|
+
void this._buildTreeAsync();
|
|
145
|
+
}
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
override onDestroy(): void {
|
|
@@ -153,8 +156,7 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
153
156
|
/** Force a full tree refresh from disk. */
|
|
154
157
|
refresh(): void {
|
|
155
158
|
this.cacheValid = false;
|
|
156
|
-
this.
|
|
157
|
-
this.markDirty();
|
|
159
|
+
void this._buildTreeAsync();
|
|
158
160
|
}
|
|
159
161
|
|
|
160
162
|
/** Currently focused node (or null). */
|
|
@@ -197,7 +199,6 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
197
199
|
// ── Render ─────────────────────────────────────────────────────────────────
|
|
198
200
|
|
|
199
201
|
render(width: number, height: number): Line[] {
|
|
200
|
-
if (!this.cacheValid) this._buildTree();
|
|
201
202
|
this.needsRender = false;
|
|
202
203
|
const searchLine = this.searchMode
|
|
203
204
|
? `/ ${this.searchQuery}_`
|
|
@@ -324,14 +325,29 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
324
325
|
|
|
325
326
|
// ── Private: tree building ─────────────────────────────────────────────────
|
|
326
327
|
|
|
327
|
-
private
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
private _buildTreeAsync(): Promise<void> {
|
|
329
|
+
const p = (async () => {
|
|
330
|
+
try {
|
|
331
|
+
await this.withLoading('Scanning directory\u2026', async () => {
|
|
332
|
+
this.root = await this._scanDirAsync(this.rootPath, 0);
|
|
333
|
+
this._rebuildFlat();
|
|
334
|
+
this.cacheValid = true;
|
|
335
|
+
});
|
|
336
|
+
} catch (err) {
|
|
337
|
+
this.setError(err instanceof Error ? err.message : String(err));
|
|
338
|
+
}
|
|
339
|
+
this.markDirty();
|
|
340
|
+
})();
|
|
341
|
+
this.readyPromise = p;
|
|
342
|
+
return p;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Resolves when the current tree build has settled. */
|
|
346
|
+
public awaitReady(): Promise<void> {
|
|
347
|
+
return this.readyPromise ?? Promise.resolve();
|
|
332
348
|
}
|
|
333
349
|
|
|
334
|
-
private
|
|
350
|
+
private async _scanDirAsync(dirPath: string, depth: number): Promise<TreeNode> {
|
|
335
351
|
const name = basename(dirPath);
|
|
336
352
|
const node: TreeNode = {
|
|
337
353
|
path: dirPath,
|
|
@@ -339,7 +355,7 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
339
355
|
isDir: true,
|
|
340
356
|
depth,
|
|
341
357
|
size: 0,
|
|
342
|
-
expanded: depth === 0,
|
|
358
|
+
expanded: depth === 0,
|
|
343
359
|
children: [],
|
|
344
360
|
loaded: false,
|
|
345
361
|
};
|
|
@@ -348,7 +364,7 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
348
364
|
|
|
349
365
|
let entries: string[];
|
|
350
366
|
try {
|
|
351
|
-
entries =
|
|
367
|
+
entries = await fsPromises.readdir(dirPath);
|
|
352
368
|
} catch {
|
|
353
369
|
return node;
|
|
354
370
|
}
|
|
@@ -356,31 +372,35 @@ export class FileExplorerPanel extends BasePanel {
|
|
|
356
372
|
node.loaded = true;
|
|
357
373
|
|
|
358
374
|
// Sort: dirs first, then files, alphabetically within each group
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
})
|
|
375
|
+
const filtered = entries.filter(e => !shouldSkip(e));
|
|
376
|
+
const statResults = await Promise.all(
|
|
377
|
+
filtered.map(async (e) => {
|
|
378
|
+
try {
|
|
379
|
+
const s = await fsPromises.stat(join(dirPath, e));
|
|
380
|
+
return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
|
|
381
|
+
} catch {
|
|
382
|
+
return { name: e, isDir: false, size: 0, stat: null };
|
|
383
|
+
}
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const sorted = statResults.sort((a, b) => {
|
|
388
|
+
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
389
|
+
return a.name.localeCompare(b.name);
|
|
390
|
+
});
|
|
369
391
|
|
|
370
392
|
for (const entry of sorted) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (stat.isDirectory()) {
|
|
376
|
-
node.children.push(this._scanDir(fullPath, depth + 1));
|
|
393
|
+
if (entry.stat === null) continue;
|
|
394
|
+
const fullPath = join(dirPath, entry.name);
|
|
395
|
+
if (entry.isDir) {
|
|
396
|
+
node.children.push(await this._scanDirAsync(fullPath, depth + 1));
|
|
377
397
|
} else {
|
|
378
398
|
node.children.push({
|
|
379
399
|
path: fullPath,
|
|
380
|
-
name: entry,
|
|
400
|
+
name: entry.name,
|
|
381
401
|
isDir: false,
|
|
382
402
|
depth: depth + 1,
|
|
383
|
-
size:
|
|
403
|
+
size: entry.size,
|
|
384
404
|
expanded: false,
|
|
385
405
|
children: [],
|
|
386
406
|
loaded: true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { Stats } from 'node:fs';
|
|
2
|
+
import { promises as fsPromises, readFileSync, statSync } from 'node:fs';
|
|
2
3
|
import * as path from 'node:path';
|
|
3
4
|
import type { Line, Cell } from '../types/grid.ts';
|
|
4
5
|
import { createStyledCell, createEmptyLine } from '../types/grid.ts';
|
|
@@ -68,7 +69,7 @@ export class FilePreviewPanel extends BasePanel {
|
|
|
68
69
|
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* Load a file into the preview. Reads
|
|
72
|
+
* Load a file into the preview. Reads asynchronously.
|
|
72
73
|
* Files larger than 100 KB show a warning instead of content.
|
|
73
74
|
*/
|
|
74
75
|
openFile(filePath: string): void {
|
|
@@ -79,51 +80,72 @@ export class FilePreviewPanel extends BasePanel {
|
|
|
79
80
|
|
|
80
81
|
this.filePath = filePath;
|
|
81
82
|
this.oversized = false;
|
|
82
|
-
this.
|
|
83
|
-
this.fenceTag = '';
|
|
83
|
+
this.fenceTag = extToFenceTag(filePath);
|
|
84
84
|
|
|
85
85
|
// Restore scroll position for this file, or start at top
|
|
86
86
|
this.scrollOffset = this.scrollMemory.get(filePath) ?? 0;
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
// Synchronously pre-populate fileLines for small files so that callers
|
|
89
|
+
// (e.g. syncSymbolOutlineFromPreview) can read getSource() immediately.
|
|
89
90
|
try {
|
|
90
|
-
stat =
|
|
91
|
+
const stat = statSync(filePath);
|
|
92
|
+
if (stat.size <= MAX_FILE_SIZE) {
|
|
93
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
94
|
+
this.fileLines = content.split('\n');
|
|
95
|
+
} else {
|
|
96
|
+
this.fileLines = [];
|
|
97
|
+
this.oversized = true;
|
|
98
|
+
}
|
|
91
99
|
} catch {
|
|
92
100
|
this.fileLines = [`(cannot open: ${filePath})`];
|
|
93
|
-
this.markDirty();
|
|
94
|
-
return;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
this.markDirty();
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
103
|
+
void this._loadFileAsync(filePath);
|
|
104
|
+
}
|
|
102
105
|
|
|
103
|
-
|
|
106
|
+
private async _loadFileAsync(filePath: string): Promise<void> {
|
|
104
107
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
108
|
+
await this.withLoading('Loading…', async () => {
|
|
109
|
+
let stat: Stats;
|
|
110
|
+
try {
|
|
111
|
+
stat = await fsPromises.stat(filePath);
|
|
112
|
+
} catch {
|
|
113
|
+
this.fileLines = [`(cannot open: ${filePath})`];
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
118
|
+
this.oversized = true;
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let content: string;
|
|
123
|
+
try {
|
|
124
|
+
content = await fsPromises.readFile(filePath, 'utf-8');
|
|
125
|
+
} catch {
|
|
126
|
+
this.fileLines = [`(read error: ${filePath})`];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
this.fileLines = content.split('\n');
|
|
131
|
+
// Strip trailing empty line from final newline
|
|
132
|
+
if (this.fileLines.length > 0 && this.fileLines[this.fileLines.length - 1] === '') {
|
|
133
|
+
this.fileLines.pop();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.fenceTag = extToFenceTag(filePath);
|
|
137
|
+
|
|
138
|
+
// Kick off async tree-sitter parse so subsequent renders get highlighting
|
|
139
|
+
if (this.fenceTag) {
|
|
140
|
+
this.syntaxHighlighter.highlight(content, this.fenceTag);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Clamp scroll in case the new file is shorter
|
|
144
|
+
this.clampScroll(0);
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
this.setError(err instanceof Error ? err.message : String(err));
|
|
123
148
|
}
|
|
124
|
-
|
|
125
|
-
// Clamp scroll in case the new file is shorter
|
|
126
|
-
this.clampScroll(0);
|
|
127
149
|
this.markDirty();
|
|
128
150
|
}
|
|
129
151
|
|