@pellux/goodvibes-tui 0.18.20 → 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 +154 -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 +22 -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/session.ts +0 -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/feed-context-factory.ts +236 -0
- package/src/input/handler-feed.ts +44 -6
- package/src/input/handler-shortcuts.ts +138 -125
- package/src/input/handler.ts +119 -119
- package/src/input/keybindings.ts +30 -0
- 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 +55 -82
- package/src/panels/automation-control-panel.ts +120 -161
- package/src/panels/base-panel.ts +108 -3
- package/src/panels/communication-panel.ts +69 -107
- package/src/panels/context-visualizer-panel.ts +2 -0
- package/src/panels/control-plane-panel.ts +117 -172
- 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 +103 -138
- package/src/panels/incident-review-panel.ts +59 -109
- package/src/panels/knowledge-panel.ts +75 -107
- package/src/panels/local-auth-panel.ts +77 -93
- package/src/panels/marketplace-panel.ts +51 -69
- package/src/panels/mcp-panel.ts +110 -155
- package/src/panels/memory-panel.ts +90 -158
- package/src/panels/ops-control-panel.ts +51 -85
- package/src/panels/orchestration-panel.ts +70 -51
- package/src/panels/panel-list-panel.ts +5 -4
- package/src/panels/panel-manager.ts +25 -2
- package/src/panels/plan-dashboard-panel.ts +2 -0
- package/src/panels/plugins-panel.ts +37 -60
- 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 +91 -141
- package/src/panels/schedule-panel.ts +7 -6
- package/src/panels/scrollable-list-panel.ts +64 -16
- package/src/panels/security-panel.ts +118 -152
- package/src/panels/services-panel.ts +63 -105
- package/src/panels/session-browser-panel.ts +19 -18
- package/src/panels/settings-sync-panel.ts +79 -123
- package/src/panels/skills-panel.ts +114 -230
- package/src/panels/subscription-panel.ts +64 -86
- package/src/panels/system-messages-panel.ts +147 -141
- package/src/panels/tasks-panel.ts +130 -179
- package/src/panels/token-budget-panel.ts +2 -0
- package/src/panels/watchers-panel.ts +89 -137
- 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 +23 -1
- package/src/renderer/diff.ts +8 -0
- package/src/renderer/help-overlay.ts +48 -28
- package/src/renderer/markdown.ts +3 -145
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Line } from '../types/grid.ts';
|
|
2
2
|
import { createEmptyLine } from '../types/grid.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { ScrollableListPanel } from './scrollable-list-panel.ts';
|
|
4
4
|
import type { UiControlPlaneSnapshot, UiReadModel } from '../runtime/ui-read-models.ts';
|
|
5
5
|
import { truncateDisplay } from '../utils/terminal-width.ts';
|
|
6
6
|
import {
|
|
@@ -10,8 +10,7 @@ import {
|
|
|
10
10
|
buildPanelLine,
|
|
11
11
|
buildPanelWorkspace,
|
|
12
12
|
DEFAULT_PANEL_PALETTE,
|
|
13
|
-
|
|
14
|
-
type PanelWorkspaceSection,
|
|
13
|
+
type PanelPalette,
|
|
15
14
|
} from './polish.ts';
|
|
16
15
|
|
|
17
16
|
const C = {
|
|
@@ -37,13 +36,14 @@ function connectionColor(state: string): string {
|
|
|
37
36
|
return C.dim;
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
type ControlPlaneClient = UiControlPlaneSnapshot['clients'][number];
|
|
40
|
+
|
|
41
|
+
export class ControlPlanePanel extends ScrollableListPanel<ControlPlaneClient> {
|
|
41
42
|
private readonly unsub: (() => void) | null;
|
|
42
|
-
private selectedIndex = 0;
|
|
43
|
-
private scrollOffset = 0;
|
|
44
43
|
|
|
45
44
|
public constructor(private readonly readModel?: UiReadModel<UiControlPlaneSnapshot>) {
|
|
46
45
|
super('control-plane', 'Control Plane', 'C', 'monitoring');
|
|
46
|
+
this.showSelectionGutter = true; // I5: non-color selection affordance
|
|
47
47
|
this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -51,29 +51,38 @@ export class ControlPlanePanel extends BasePanel {
|
|
|
51
51
|
this.unsub?.();
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (clients.length === 0) return false;
|
|
57
|
-
if (key === 'up' || key === 'k') {
|
|
58
|
-
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
59
|
-
this.markDirty();
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
if (key === 'down' || key === 'j') {
|
|
63
|
-
this.selectedIndex = Math.min(clients.length - 1, this.selectedIndex + 1);
|
|
64
|
-
this.markDirty();
|
|
65
|
-
return true;
|
|
66
|
-
}
|
|
67
|
-
return false;
|
|
54
|
+
protected override getPalette(): PanelPalette {
|
|
55
|
+
return C;
|
|
68
56
|
}
|
|
69
57
|
|
|
70
|
-
|
|
58
|
+
protected getItems(): readonly ControlPlaneClient[] {
|
|
71
59
|
if (!this.readModel) return [];
|
|
72
|
-
return
|
|
60
|
+
return this.readModel.getSnapshot().clients;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
protected renderItem(client: ControlPlaneClient, _index: number, selected: boolean, width: number): Line {
|
|
64
|
+
const bg = selected ? C.selectBg : undefined;
|
|
65
|
+
return buildPanelLine(width, [
|
|
66
|
+
[' ', C.label, bg],
|
|
67
|
+
[client.kind.padEnd(10), C.info, bg],
|
|
68
|
+
[` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
|
|
69
|
+
[` ${client.transport.padEnd(12)}`, C.dim, bg],
|
|
70
|
+
[` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
|
|
71
|
+
]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected override getEmptyStateMessage(): string {
|
|
75
|
+
return ' No control-plane activity recorded.';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
|
|
79
|
+
return [
|
|
80
|
+
{ command: '/cockpit', summary: 'watch operator posture from the terminal' },
|
|
81
|
+
{ command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
|
|
82
|
+
];
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
public render(width: number, height: number): Line[] {
|
|
76
|
-
this.needsRender = false;
|
|
77
86
|
const intro = 'Shared daemon control plane state, live clients, approval pressure, and recent omnichannel session posture.';
|
|
78
87
|
|
|
79
88
|
if (!this.readModel) {
|
|
@@ -99,168 +108,104 @@ export class ControlPlanePanel extends BasePanel {
|
|
|
99
108
|
const approvals = snapshot.approvals;
|
|
100
109
|
const sessions = snapshot.sessions;
|
|
101
110
|
const recentEvents = snapshot.recentEvents;
|
|
102
|
-
const clients = this.
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
|
|
120
|
-
],
|
|
121
|
-
};
|
|
111
|
+
const clients = this.getItems();
|
|
112
|
+
|
|
113
|
+
const headerLines: Line[] = [
|
|
114
|
+
buildKeyValueLine(width, [
|
|
115
|
+
{ label: 'state', value: snapshot.connectionState, valueColor: connectionColor(snapshot.connectionState) },
|
|
116
|
+
{ label: 'clients', value: String(snapshot.activeClientIds.length), valueColor: snapshot.activeClientIds.length > 0 ? C.ok : C.dim },
|
|
117
|
+
{ label: 'requests', value: String(snapshot.requestCount), valueColor: snapshot.requestCount > 0 ? C.info : C.dim },
|
|
118
|
+
{ label: 'errors', value: String(snapshot.errorCount), valueColor: snapshot.errorCount > 0 ? C.error : C.dim },
|
|
119
|
+
], C),
|
|
120
|
+
buildKeyValueLine(width, [
|
|
121
|
+
{ label: 'host', value: `${snapshot.host}:${snapshot.port}`, valueColor: C.value },
|
|
122
|
+
{ label: 'approvals', value: String(approvals.filter((entry) => entry.status === 'pending').length), valueColor: approvals.some((entry) => entry.status === 'pending') ? C.warn : C.dim },
|
|
123
|
+
{ label: 'sessions', value: String(sessions.length), valueColor: sessions.length > 0 ? C.info : C.dim },
|
|
124
|
+
{ label: 'events', value: String(recentEvents.length), valueColor: recentEvents.length > 0 ? C.info : C.dim },
|
|
125
|
+
], C),
|
|
126
|
+
buildGuidanceLine(width, '/cockpit', 'use the web operator surface or daemon APIs for direct interventions while this panel tracks overall posture', C),
|
|
127
|
+
];
|
|
122
128
|
|
|
123
129
|
if (clients.length === 0 && approvals.length === 0 && sessions.length === 0) {
|
|
124
|
-
|
|
130
|
+
return this.renderList(width, height, {
|
|
125
131
|
title: 'Control Plane',
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
summarySection,
|
|
129
|
-
{
|
|
130
|
-
lines: buildEmptyState(
|
|
131
|
-
width,
|
|
132
|
-
' No control-plane activity recorded.',
|
|
133
|
-
'Start the daemon, connect a surface, or trigger an approval to populate this operator panel.',
|
|
134
|
-
[
|
|
135
|
-
{ command: '/cockpit', summary: 'watch operator posture from the terminal' },
|
|
136
|
-
{ command: '/schedule list', summary: 'run automation that creates surface and daemon traffic' },
|
|
137
|
-
],
|
|
138
|
-
C,
|
|
139
|
-
),
|
|
140
|
-
},
|
|
141
|
-
],
|
|
142
|
-
palette: C,
|
|
132
|
+
header: headerLines,
|
|
133
|
+
emptyMessage: ' No control-plane activity recorded.',
|
|
143
134
|
});
|
|
144
|
-
while (workspace.length < height) workspace.push(createEmptyLine(width));
|
|
145
|
-
return workspace;
|
|
146
135
|
}
|
|
147
136
|
|
|
148
|
-
this.
|
|
137
|
+
this.clampSelection();
|
|
149
138
|
const selected = clients[this.selectedIndex];
|
|
150
139
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
title: 'Selected Client',
|
|
183
|
-
lines: [buildPanelLine(width, [[' No connected client selected.', C.dim]])],
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
const approvalsSection: PanelWorkspaceSection = {
|
|
187
|
-
title: 'Approvals',
|
|
188
|
-
lines: approvals.length > 0
|
|
189
|
-
? approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
|
|
190
|
-
[' ', C.label],
|
|
191
|
-
[approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
|
|
192
|
-
[` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
|
|
193
|
-
[` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
|
|
194
|
-
]))
|
|
195
|
-
: [buildPanelLine(width, [[' No recent approvals.', C.dim]])],
|
|
196
|
-
};
|
|
140
|
+
const footerLines: Line[] = [];
|
|
141
|
+
if (selected) {
|
|
142
|
+
footerLines.push(
|
|
143
|
+
buildPanelLine(width, [
|
|
144
|
+
[' Client: ', C.label],
|
|
145
|
+
[selected.label, C.value],
|
|
146
|
+
[' Kind: ', C.label],
|
|
147
|
+
[selected.kind, C.info],
|
|
148
|
+
]),
|
|
149
|
+
buildPanelLine(width, [
|
|
150
|
+
[' Transport: ', C.label],
|
|
151
|
+
[selected.transport, C.value],
|
|
152
|
+
[' Connected: ', C.label],
|
|
153
|
+
[selected.connected ? 'yes' : 'no', selected.connected ? C.ok : C.warn],
|
|
154
|
+
]),
|
|
155
|
+
buildPanelLine(width, [
|
|
156
|
+
[' Route: ', C.label],
|
|
157
|
+
[selected.routeId ?? 'n/a', C.dim],
|
|
158
|
+
[' Session: ', C.label],
|
|
159
|
+
[selected.sessionId ?? 'n/a', C.dim],
|
|
160
|
+
]),
|
|
161
|
+
buildPanelLine(width, [
|
|
162
|
+
[' Last seen: ', C.label],
|
|
163
|
+
[formatTime(selected.lastSeenAt), C.dim],
|
|
164
|
+
[' Remote: ', C.label],
|
|
165
|
+
[truncateDisplay(selected.remoteAddress ?? 'n/a', Math.max(0, width - 36)), C.dim],
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
} else {
|
|
169
|
+
footerLines.push(buildPanelLine(width, [[' No connected client selected.', C.dim]]));
|
|
170
|
+
}
|
|
197
171
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
};
|
|
172
|
+
if (approvals.length > 0) {
|
|
173
|
+
footerLines.push(
|
|
174
|
+
...approvals.slice(0, 6).map((approval) => buildPanelLine(width, [
|
|
175
|
+
[' ', C.label],
|
|
176
|
+
[approval.status.padEnd(10), approval.status === 'pending' ? C.warn : approval.status === 'approved' ? C.ok : approval.status === 'denied' ? C.error : C.dim],
|
|
177
|
+
[` ${truncateDisplay(approval.request.tool, 16).padEnd(16)}`, C.value],
|
|
178
|
+
[` ${truncateDisplay(approval.sessionId ?? approval.id, Math.max(0, width - 30))}`, C.dim],
|
|
179
|
+
])),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
209
182
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
183
|
+
if (sessions.length > 0) {
|
|
184
|
+
footerLines.push(
|
|
185
|
+
...sessions.slice(0, 6).map((session) => buildPanelLine(width, [
|
|
186
|
+
[' ', C.label],
|
|
187
|
+
[session.status.padEnd(10), session.status === 'active' ? C.ok : C.dim],
|
|
188
|
+
[` ${truncateDisplay(session.title, 20).padEnd(20)}`, C.value],
|
|
189
|
+
[` ${truncateDisplay(session.activeAgentId ?? session.id, Math.max(0, width - 34))}`, C.dim],
|
|
190
|
+
])),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
220
193
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
[' ', C.label, bg],
|
|
232
|
-
[client.kind.padEnd(10), C.info, bg],
|
|
233
|
-
[` ${truncateDisplay(client.label, 20).padEnd(20)}`, C.value, bg],
|
|
234
|
-
[` ${client.transport.padEnd(12)}`, C.dim, bg],
|
|
235
|
-
[` ${truncateDisplay(formatTime(client.lastSeenAt), Math.max(0, width - 46))}`, C.dim, bg],
|
|
236
|
-
]);
|
|
237
|
-
}),
|
|
238
|
-
selectedIndex: this.selectedIndex,
|
|
239
|
-
scrollOffset: this.scrollOffset,
|
|
240
|
-
guardRows: 1,
|
|
241
|
-
minRows: 4,
|
|
242
|
-
appendWindowSummary: { dimColor: C.dim },
|
|
243
|
-
},
|
|
244
|
-
afterSections: [detailSection, approvalsSection, sessionsSection, eventsSection],
|
|
245
|
-
});
|
|
246
|
-
this.scrollOffset = resolvedClients.scrollOffset;
|
|
194
|
+
if (recentEvents.length > 0) {
|
|
195
|
+
footerLines.push(
|
|
196
|
+
...recentEvents.slice(0, 6).map((event) => buildPanelLine(width, [
|
|
197
|
+
[' ', C.label],
|
|
198
|
+
[truncateDisplay(event.event, 16).padEnd(16), C.info],
|
|
199
|
+
[` ${truncateDisplay(typeof event.payload === 'string' ? event.payload : JSON.stringify(event.payload) ?? '', Math.max(0, width - 19))}`, C.dim],
|
|
200
|
+
])),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
footerLines.push(buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]]));
|
|
247
204
|
|
|
248
|
-
|
|
249
|
-
summarySection,
|
|
250
|
-
resolvedClients.section,
|
|
251
|
-
detailSection,
|
|
252
|
-
approvalsSection,
|
|
253
|
-
sessionsSection,
|
|
254
|
-
eventsSection,
|
|
255
|
-
];
|
|
256
|
-
const lines = buildPanelWorkspace(width, height, {
|
|
205
|
+
return this.renderList(width, height, {
|
|
257
206
|
title: 'Control Plane',
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
footerLines: [buildPanelLine(width, [[' Up/Down move through connected clients', C.dim]])],
|
|
261
|
-
palette: C,
|
|
207
|
+
header: headerLines,
|
|
208
|
+
footer: footerLines,
|
|
262
209
|
});
|
|
263
|
-
while (lines.length < height) lines.push(createEmptyLine(width));
|
|
264
|
-
return lines.slice(0, height);
|
|
265
210
|
}
|
|
266
211
|
}
|
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
|
|