@pellux/goodvibes-agent 0.1.1 → 0.1.3
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 +14 -0
- package/README.md +12 -1
- package/docs/README.md +2 -0
- package/docs/getting-started.md +19 -1
- package/docs/release-and-publishing.md +3 -1
- package/package.json +10 -1
- package/src/agent/persona-registry.ts +379 -0
- package/src/agent/skill-registry.ts +360 -0
- package/src/audio/spoken-turn-model-routing.ts +2 -1
- package/src/cli/agent-knowledge-command.ts +525 -0
- package/src/cli/help.ts +35 -0
- package/src/cli/management-commands.ts +3 -1
- package/src/cli/management.ts +33 -9
- package/src/cli/parser.ts +7 -0
- package/src/cli/types.ts +3 -0
- package/src/config/surface.ts +1 -0
- package/src/input/agent-workspace.ts +33 -3
- package/src/input/command-registry.ts +4 -1
- package/src/input/commands/agent-skills-runtime.ts +216 -0
- package/src/input/commands/delegation-runtime.ts +129 -0
- package/src/input/commands/knowledge.ts +18 -18
- package/src/input/commands/personas-runtime.ts +219 -0
- package/src/input/commands/shell-core.ts +9 -6
- package/src/input/commands/skills-runtime.ts +7 -2
- package/src/input/commands.ts +6 -0
- package/src/input/panel-integration-actions.ts +0 -52
- package/src/input/submission-router.ts +1 -1
- package/src/main.ts +2 -1
- package/src/panels/builtin/agent.ts +0 -14
- package/src/panels/builtin/session.ts +4 -3
- package/src/panels/index.ts +0 -5
- package/src/panels/orchestration-panel.ts +4 -5
- package/src/panels/qr-panel.ts +3 -2
- package/src/panels/tasks-panel.ts +4 -4
- package/src/renderer/agent-workspace.ts +2 -0
- package/src/runtime/bootstrap-command-context.ts +3 -0
- package/src/runtime/bootstrap-command-parts.ts +6 -2
- package/src/runtime/bootstrap-core.ts +8 -4
- package/src/runtime/bootstrap-shell.ts +5 -2
- package/src/runtime/bootstrap.ts +10 -2
- package/src/runtime/cloudflare-control-plane.ts +2 -1
- package/src/version.ts +1 -1
- package/src/daemon/cli.ts +0 -55
- package/src/daemon/safe-serve.ts +0 -61
- package/src/panels/diff-panel.ts +0 -520
- package/src/panels/file-explorer-panel.ts +0 -584
- package/src/panels/file-preview-panel.ts +0 -434
- package/src/panels/git-panel.ts +0 -638
- package/src/panels/sandbox-panel.ts +0 -283
- package/src/panels/symbol-outline-panel.ts +0 -486
- package/src/panels/worktree-panel.ts +0 -182
- package/src/panels/wrfc-panel.ts +0 -609
|
@@ -1,584 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// FileExplorerPanel — collapsible project tree view
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
import { promises as fsPromises } from 'node:fs';
|
|
6
|
-
import { join, relative, basename } from 'node:path';
|
|
7
|
-
import type { Line } from '../types/grid.ts';
|
|
8
|
-
import { createEmptyLine } from '../types/grid.ts';
|
|
9
|
-
import { BasePanel } from './base-panel.ts';
|
|
10
|
-
import {
|
|
11
|
-
buildEmptyState,
|
|
12
|
-
buildPanelLine,
|
|
13
|
-
buildSearchInputLine,
|
|
14
|
-
buildSelectablePanelLine,
|
|
15
|
-
buildPanelWorkspace,
|
|
16
|
-
resolveScrollablePanelSection,
|
|
17
|
-
DEFAULT_PANEL_PALETTE,
|
|
18
|
-
} from './polish.ts';
|
|
19
|
-
import { getDisplayWidth } from '../utils/terminal-width.ts';
|
|
20
|
-
import {
|
|
21
|
-
getPanelSearchFocusTransition,
|
|
22
|
-
isPanelSearchBackspace,
|
|
23
|
-
isPanelSearchCancel,
|
|
24
|
-
isPanelSearchCommit,
|
|
25
|
-
isPanelSearchPrintable,
|
|
26
|
-
} from './search-focus.ts';
|
|
27
|
-
|
|
28
|
-
// ---------------------------------------------------------------------------
|
|
29
|
-
// Constants
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
|
|
32
|
-
const MAX_DEPTH = 5;
|
|
33
|
-
|
|
34
|
-
/** Directories / files to skip (gitignore-style). */
|
|
35
|
-
const SKIP_NAMES = new Set([
|
|
36
|
-
'node_modules', '.git', '.svn', '.hg',
|
|
37
|
-
'dist', 'build', 'out', '.next', '.nuxt', '.output',
|
|
38
|
-
'__pycache__', '.pytest_cache', '.mypy_cache',
|
|
39
|
-
'coverage', '.nyc_output',
|
|
40
|
-
'.DS_Store', 'Thumbs.db',
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
const SKIP_PATTERNS: RegExp[] = [
|
|
44
|
-
/^\..*\.sw[px]$/, // vim swap files
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// File-type icons (single-char safe for TUI columns)
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
const EXT_ICONS: Record<string, string> = {
|
|
51
|
-
ts: 'T', tsx: 'T', js: 'J', jsx: 'J',
|
|
52
|
-
json: 'J', jsonc: 'J',
|
|
53
|
-
md: 'M', mdx: 'M',
|
|
54
|
-
css: 'S', scss: 'S', sass: 'S',
|
|
55
|
-
html: 'H', htm: 'H',
|
|
56
|
-
py: 'P', rb: 'R', go: 'G', rs: 'R',
|
|
57
|
-
sh: '$', bash: '$', zsh: '$',
|
|
58
|
-
yaml: 'Y', yml: 'Y', toml: 'C',
|
|
59
|
-
lock: 'L', log: 'L',
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
function fileIcon(name: string): string {
|
|
63
|
-
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
64
|
-
return EXT_ICONS[ext] ?? 'f';
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
// Tree node
|
|
69
|
-
// ---------------------------------------------------------------------------
|
|
70
|
-
|
|
71
|
-
interface TreeNode {
|
|
72
|
-
path: string; // absolute path
|
|
73
|
-
name: string; // display name
|
|
74
|
-
isDir: boolean;
|
|
75
|
-
depth: number;
|
|
76
|
-
size: number; // bytes (0 for dirs)
|
|
77
|
-
expanded: boolean;
|
|
78
|
-
children: TreeNode[];
|
|
79
|
-
/** Whether children have been loaded. */
|
|
80
|
-
loaded: boolean;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Colour palette
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
const CLR_DIR = '#00ffff'; // cyan — directories
|
|
87
|
-
const CLR_FILE = '#e0e0e0'; // near-white — files
|
|
88
|
-
const CLR_SIZE = '244'; // dim grey — sizes
|
|
89
|
-
const CLR_CURSOR = '#1a2a3a'; // cursor background
|
|
90
|
-
const CLR_CURSOR_FG = '#ffffff';
|
|
91
|
-
const CLR_SEARCH_BG = '#2a1a3a';
|
|
92
|
-
const CLR_SEARCH_FG = '#ff79c6';
|
|
93
|
-
const CLR_TOGGLE = '244'; // ▶/▼ toggle arrows
|
|
94
|
-
const CLR_ICON = '244'; // file type icon
|
|
95
|
-
|
|
96
|
-
// ---------------------------------------------------------------------------
|
|
97
|
-
// Helpers
|
|
98
|
-
// ---------------------------------------------------------------------------
|
|
99
|
-
|
|
100
|
-
function shouldSkip(name: string): boolean {
|
|
101
|
-
if (SKIP_NAMES.has(name)) return true;
|
|
102
|
-
for (const re of SKIP_PATTERNS) if (re.test(name)) return true;
|
|
103
|
-
return false;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function formatSize(bytes: number): string {
|
|
107
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
108
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
|
|
109
|
-
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ---------------------------------------------------------------------------
|
|
113
|
-
// FileExplorerPanel
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
|
|
116
|
-
export class FileExplorerPanel extends BasePanel {
|
|
117
|
-
// --- tree state ---
|
|
118
|
-
private root: TreeNode | null = null;
|
|
119
|
-
private flat: TreeNode[] = []; // visible flattened list
|
|
120
|
-
private rootPath: string;
|
|
121
|
-
private readonly workingDirectory: string;
|
|
122
|
-
private cacheValid: boolean = false;
|
|
123
|
-
private readyPromise: Promise<void> | null = null;
|
|
124
|
-
|
|
125
|
-
// --- navigation ---
|
|
126
|
-
private cursor: number = 0;
|
|
127
|
-
private scrollTop: number = 0;
|
|
128
|
-
|
|
129
|
-
// --- search ---
|
|
130
|
-
private searchMode: boolean = false;
|
|
131
|
-
private searchQuery: string = '';
|
|
132
|
-
|
|
133
|
-
constructor(rootPath: string | undefined, workingDirectory: string) {
|
|
134
|
-
super('explorer', 'Explorer', 'E', 'development');
|
|
135
|
-
this.workingDirectory = workingDirectory;
|
|
136
|
-
this.rootPath = rootPath ?? workingDirectory;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
140
|
-
|
|
141
|
-
override onActivate(): void {
|
|
142
|
-
super.onActivate();
|
|
143
|
-
if (!this.cacheValid) {
|
|
144
|
-
void this._buildTreeAsync();
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
override onDestroy(): void {
|
|
149
|
-
this.root = null;
|
|
150
|
-
this.flat = [];
|
|
151
|
-
this.cacheValid = false;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
/** Force a full tree refresh from disk. */
|
|
157
|
-
refresh(): void {
|
|
158
|
-
this.cacheValid = false;
|
|
159
|
-
void this._buildTreeAsync();
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** Currently focused node (or null). */
|
|
163
|
-
getFocusedNode(): TreeNode | null {
|
|
164
|
-
return this.flat[this.cursor] ?? null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
getFocusedFilePath(): string | null {
|
|
168
|
-
const node = this.getFocusedNode();
|
|
169
|
-
return node && !node.isDir ? node.path : null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── Input ──────────────────────────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
handleInput(key: string): boolean {
|
|
175
|
-
if (this.searchMode) return this._handleSearchInput(key);
|
|
176
|
-
|
|
177
|
-
const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.cursor, itemCount: this.flat.length });
|
|
178
|
-
if (transition === 'focus-search') {
|
|
179
|
-
this._enterSearch();
|
|
180
|
-
return true;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
switch (key) {
|
|
184
|
-
case 'up': case 'k': this._moveCursor(-1); return true;
|
|
185
|
-
case 'down': case 'j': this._moveCursor(1); return true;
|
|
186
|
-
case 'pageup': this._moveCursorPage(-1); return true;
|
|
187
|
-
case 'pagedown': this._moveCursorPage(1); return true;
|
|
188
|
-
case 'home': case 'g': this._setCursor(0); return true;
|
|
189
|
-
case 'end': case 'G': this._setCursor(this.flat.length - 1); return true;
|
|
190
|
-
case 'return': case 'enter': this._activateNode(); return true;
|
|
191
|
-
case 'right': this._expandNode(); return true;
|
|
192
|
-
case 'left': this._collapseNode(); return true;
|
|
193
|
-
case '/': this._enterSearch(); return true;
|
|
194
|
-
case 'r': this.refresh(); return true;
|
|
195
|
-
default: return false;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
handleScroll(deltaRows: number): boolean {
|
|
200
|
-
const rows = Math.trunc(deltaRows);
|
|
201
|
-
if (this.flat.length === 0 || rows === 0) return false;
|
|
202
|
-
const previous = this.cursor;
|
|
203
|
-
this._setCursor(this.cursor + rows);
|
|
204
|
-
return this.cursor !== previous;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ── Render ─────────────────────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
render(width: number, height: number): Line[] {
|
|
210
|
-
this.needsRender = false;
|
|
211
|
-
const searchLine = this.searchMode
|
|
212
|
-
? `/ ${this.searchQuery}_`
|
|
213
|
-
: this.searchQuery
|
|
214
|
-
? `Filter: ${this.searchQuery} (/ or up at top to edit)`
|
|
215
|
-
: `Root: ${relative(this.workingDirectory, this.rootPath) || '.'} (/ or up at top to search)`;
|
|
216
|
-
|
|
217
|
-
if (this.flat.length === 0) {
|
|
218
|
-
return buildPanelWorkspace(width, height, {
|
|
219
|
-
title: ' Explorer',
|
|
220
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
221
|
-
sections: [
|
|
222
|
-
{
|
|
223
|
-
lines: buildEmptyState(
|
|
224
|
-
width,
|
|
225
|
-
' No files found',
|
|
226
|
-
this.searchQuery
|
|
227
|
-
? 'No files or directories match the current search.'
|
|
228
|
-
: 'This root did not produce any visible files after the explorer filters were applied.',
|
|
229
|
-
[],
|
|
230
|
-
DEFAULT_PANEL_PALETTE,
|
|
231
|
-
),
|
|
232
|
-
},
|
|
233
|
-
],
|
|
234
|
-
footerLines: [
|
|
235
|
-
buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
|
|
236
|
-
active: this.searchMode,
|
|
237
|
-
valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
|
|
238
|
-
}),
|
|
239
|
-
buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
|
|
240
|
-
],
|
|
241
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
const summarySection = {
|
|
246
|
-
title: 'Summary',
|
|
247
|
-
lines: [
|
|
248
|
-
buildPanelLine(width, [
|
|
249
|
-
[' Visible ', DEFAULT_PANEL_PALETTE.label],
|
|
250
|
-
[String(this.flat.length), DEFAULT_PANEL_PALETTE.value],
|
|
251
|
-
[' Search ', DEFAULT_PANEL_PALETTE.label],
|
|
252
|
-
[this.searchQuery || 'none', this.searchQuery ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
|
|
253
|
-
]),
|
|
254
|
-
],
|
|
255
|
-
} as const;
|
|
256
|
-
const selected = this.flat[this.cursor];
|
|
257
|
-
const selectedSection = {
|
|
258
|
-
title: 'Selected',
|
|
259
|
-
lines: selected
|
|
260
|
-
? [
|
|
261
|
-
buildPanelLine(width, [
|
|
262
|
-
[' Name ', DEFAULT_PANEL_PALETTE.label],
|
|
263
|
-
[selected.name, DEFAULT_PANEL_PALETTE.value],
|
|
264
|
-
[' Type ', DEFAULT_PANEL_PALETTE.label],
|
|
265
|
-
[selected.isDir ? 'directory' : 'file', selected.isDir ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.value],
|
|
266
|
-
]),
|
|
267
|
-
buildPanelLine(width, [
|
|
268
|
-
[' Path ', DEFAULT_PANEL_PALETTE.label],
|
|
269
|
-
[selected.path, DEFAULT_PANEL_PALETTE.dim],
|
|
270
|
-
]),
|
|
271
|
-
]
|
|
272
|
-
: [],
|
|
273
|
-
} as const;
|
|
274
|
-
const treeSection = resolveScrollablePanelSection(width, height, {
|
|
275
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
276
|
-
footerLines: [
|
|
277
|
-
buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
|
|
278
|
-
active: this.searchMode,
|
|
279
|
-
valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
|
|
280
|
-
}),
|
|
281
|
-
buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
|
|
282
|
-
],
|
|
283
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
284
|
-
beforeSections: [summarySection],
|
|
285
|
-
section: {
|
|
286
|
-
title: 'Tree',
|
|
287
|
-
scrollableLines: this.flat.map((node, absoluteIdx) => {
|
|
288
|
-
const isCursor = absoluteIdx === this.cursor;
|
|
289
|
-
const baseBg = isCursor ? CLR_CURSOR : '';
|
|
290
|
-
const baseFg = isCursor ? CLR_CURSOR_FG : (node.isDir ? CLR_DIR : CLR_FILE);
|
|
291
|
-
const indent = ' '.repeat(node.depth);
|
|
292
|
-
const segments = [
|
|
293
|
-
{ text: indent, fg: baseFg },
|
|
294
|
-
node.isDir
|
|
295
|
-
? { text: node.expanded ? '▾ ' : '▸ ', fg: CLR_TOGGLE, bold: isCursor }
|
|
296
|
-
: { text: `${fileIcon(node.name)} `, fg: CLR_ICON, dim: !isCursor },
|
|
297
|
-
{ text: node.name, fg: baseFg, bold: node.isDir || isCursor },
|
|
298
|
-
];
|
|
299
|
-
if (!node.isDir && node.size > 0) {
|
|
300
|
-
const sizeStr = ` ${formatSize(node.size)}`;
|
|
301
|
-
const contentWidth = getDisplayWidth(indent) + 2 + getDisplayWidth(node.name);
|
|
302
|
-
const gap = Math.max(1, width - contentWidth - getDisplayWidth(sizeStr));
|
|
303
|
-
segments.push({ text: ' '.repeat(gap), fg: baseFg });
|
|
304
|
-
segments.push({ text: sizeStr, fg: CLR_SIZE, dim: true });
|
|
305
|
-
}
|
|
306
|
-
return buildSelectablePanelLine(width, segments, { selected: isCursor, selectedBg: baseBg, fillFg: baseFg });
|
|
307
|
-
}),
|
|
308
|
-
selectedIndex: this.cursor,
|
|
309
|
-
scrollOffset: this.scrollTop,
|
|
310
|
-
minRows: 8,
|
|
311
|
-
},
|
|
312
|
-
afterSections: [selectedSection],
|
|
313
|
-
});
|
|
314
|
-
this.scrollTop = treeSection.scrollOffset;
|
|
315
|
-
return buildPanelWorkspace(width, height, {
|
|
316
|
-
title: ' Explorer',
|
|
317
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
318
|
-
sections: [
|
|
319
|
-
summarySection,
|
|
320
|
-
treeSection.section,
|
|
321
|
-
selectedSection,
|
|
322
|
-
],
|
|
323
|
-
footerLines: [
|
|
324
|
-
buildSearchInputLine(width, '', searchLine, DEFAULT_PANEL_PALETTE, {
|
|
325
|
-
active: this.searchMode,
|
|
326
|
-
valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim,
|
|
327
|
-
}),
|
|
328
|
-
buildPanelLine(width, [[' Up/Down', DEFAULT_PANEL_PALETTE.info], [' navigate', DEFAULT_PANEL_PALETTE.dim], [' Enter/Right', DEFAULT_PANEL_PALETTE.info], [' expand', DEFAULT_PANEL_PALETTE.dim], [' Left', DEFAULT_PANEL_PALETTE.info], [' collapse', DEFAULT_PANEL_PALETTE.dim], [' /', DEFAULT_PANEL_PALETTE.info], [' search', DEFAULT_PANEL_PALETTE.dim], [' r', DEFAULT_PANEL_PALETTE.info], [' refresh', DEFAULT_PANEL_PALETTE.dim]]),
|
|
329
|
-
],
|
|
330
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
331
|
-
});
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// ── Private: tree building ─────────────────────────────────────────────────
|
|
335
|
-
|
|
336
|
-
private _buildTreeAsync(): Promise<void> {
|
|
337
|
-
const p = (async () => {
|
|
338
|
-
try {
|
|
339
|
-
await this.withLoading('Scanning directory\u2026', async () => {
|
|
340
|
-
this.root = await this._scanDirAsync(this.rootPath, 0);
|
|
341
|
-
this._rebuildFlat();
|
|
342
|
-
this.cacheValid = true;
|
|
343
|
-
});
|
|
344
|
-
} catch (err) {
|
|
345
|
-
this.setError(err instanceof Error ? err.message : String(err));
|
|
346
|
-
}
|
|
347
|
-
this.markDirty();
|
|
348
|
-
})();
|
|
349
|
-
this.readyPromise = p;
|
|
350
|
-
return p;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** Resolves when the current tree build has settled. */
|
|
354
|
-
public awaitReady(): Promise<void> {
|
|
355
|
-
return this.readyPromise ?? Promise.resolve();
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
private async _scanDirAsync(dirPath: string, depth: number): Promise<TreeNode> {
|
|
359
|
-
const name = basename(dirPath);
|
|
360
|
-
const node: TreeNode = {
|
|
361
|
-
path: dirPath,
|
|
362
|
-
name,
|
|
363
|
-
isDir: true,
|
|
364
|
-
depth,
|
|
365
|
-
size: 0,
|
|
366
|
-
expanded: depth === 0,
|
|
367
|
-
children: [],
|
|
368
|
-
loaded: false,
|
|
369
|
-
};
|
|
370
|
-
|
|
371
|
-
if (depth >= MAX_DEPTH) return node;
|
|
372
|
-
|
|
373
|
-
let entries: string[];
|
|
374
|
-
try {
|
|
375
|
-
entries = await fsPromises.readdir(dirPath);
|
|
376
|
-
} catch {
|
|
377
|
-
return node;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
node.loaded = true;
|
|
381
|
-
|
|
382
|
-
// Sort: dirs first, then files, alphabetically within each group
|
|
383
|
-
const filtered = entries.filter(e => !shouldSkip(e));
|
|
384
|
-
const statResults = await Promise.all(
|
|
385
|
-
filtered.map(async (e) => {
|
|
386
|
-
try {
|
|
387
|
-
const s = await fsPromises.stat(join(dirPath, e));
|
|
388
|
-
return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
|
|
389
|
-
} catch {
|
|
390
|
-
return { name: e, isDir: false, size: 0, stat: null };
|
|
391
|
-
}
|
|
392
|
-
}),
|
|
393
|
-
);
|
|
394
|
-
|
|
395
|
-
const sorted = statResults.sort((a, b) => {
|
|
396
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
397
|
-
return a.name.localeCompare(b.name);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
for (const entry of sorted) {
|
|
401
|
-
if (entry.stat === null) continue;
|
|
402
|
-
const fullPath = join(dirPath, entry.name);
|
|
403
|
-
if (entry.isDir) {
|
|
404
|
-
node.children.push(await this._scanDirAsync(fullPath, depth + 1));
|
|
405
|
-
} else {
|
|
406
|
-
node.children.push({
|
|
407
|
-
path: fullPath,
|
|
408
|
-
name: entry.name,
|
|
409
|
-
isDir: false,
|
|
410
|
-
depth: depth + 1,
|
|
411
|
-
size: entry.size,
|
|
412
|
-
expanded: false,
|
|
413
|
-
children: [],
|
|
414
|
-
loaded: true,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return node;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Flatten the tree into a visible list based on expansion state
|
|
424
|
-
* and the current search query.
|
|
425
|
-
*/
|
|
426
|
-
private _rebuildFlat(): void {
|
|
427
|
-
const q = this.searchQuery.trim().toLowerCase();
|
|
428
|
-
|
|
429
|
-
if (q) {
|
|
430
|
-
// In search mode: show all matching nodes (any depth), ignoring expand state
|
|
431
|
-
const results: TreeNode[] = [];
|
|
432
|
-
this._collectMatching(this.root, q, results);
|
|
433
|
-
this.flat = results;
|
|
434
|
-
} else {
|
|
435
|
-
const rows: TreeNode[] = [];
|
|
436
|
-
if (this.root) this._flatten(this.root, rows, /* skipSelf */ true);
|
|
437
|
-
this.flat = rows;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// Clamp cursor
|
|
441
|
-
if (this.cursor >= this.flat.length) {
|
|
442
|
-
this.cursor = Math.max(0, this.flat.length - 1);
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
private _flatten(node: TreeNode, out: TreeNode[], skipSelf: boolean): void {
|
|
447
|
-
if (!skipSelf) out.push(node);
|
|
448
|
-
if ((skipSelf || node.expanded) && node.children.length > 0) {
|
|
449
|
-
for (const child of node.children) {
|
|
450
|
-
this._flatten(child, out, false);
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
private _collectMatching(node: TreeNode | null, q: string, out: TreeNode[]): void {
|
|
456
|
-
if (!node) return;
|
|
457
|
-
if (node.name.toLowerCase().includes(q)) out.push(node);
|
|
458
|
-
for (const child of node.children) this._collectMatching(child, q, out);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// ── Private: navigation ───────────────────────────────────────────────────
|
|
462
|
-
|
|
463
|
-
private _moveCursor(delta: number): void {
|
|
464
|
-
this._setCursor(this.cursor + delta);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
private _moveCursorPage(direction: 1 | -1, pageSize = 10): void {
|
|
468
|
-
this._setCursor(this.cursor + direction * pageSize);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
private _setCursor(idx: number): void {
|
|
472
|
-
this.cursor = Math.max(0, Math.min(idx, this.flat.length - 1));
|
|
473
|
-
this.markDirty();
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
private _clampScroll(viewHeight: number): void {
|
|
477
|
-
if (this.cursor < this.scrollTop) {
|
|
478
|
-
this.scrollTop = this.cursor;
|
|
479
|
-
} else if (this.cursor >= this.scrollTop + viewHeight) {
|
|
480
|
-
this.scrollTop = this.cursor - viewHeight + 1;
|
|
481
|
-
}
|
|
482
|
-
this.scrollTop = Math.max(0, this.scrollTop);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
private _activateNode(): void {
|
|
486
|
-
const node = this.flat[this.cursor];
|
|
487
|
-
if (!node) return;
|
|
488
|
-
if (node.isDir) {
|
|
489
|
-
node.expanded = !node.expanded;
|
|
490
|
-
this._rebuildFlat();
|
|
491
|
-
this.markDirty();
|
|
492
|
-
}
|
|
493
|
-
// For files: callers can read getFocusedNode() after the input returns true
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private _expandNode(): void {
|
|
497
|
-
const node = this.flat[this.cursor];
|
|
498
|
-
if (!node || !node.isDir || node.expanded) return;
|
|
499
|
-
node.expanded = true;
|
|
500
|
-
this._rebuildFlat();
|
|
501
|
-
this.markDirty();
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
private _collapseNode(): void {
|
|
505
|
-
const node = this.flat[this.cursor];
|
|
506
|
-
if (!node) return;
|
|
507
|
-
if (node.isDir && node.expanded) {
|
|
508
|
-
node.expanded = false;
|
|
509
|
-
this._rebuildFlat();
|
|
510
|
-
this.markDirty();
|
|
511
|
-
} else if (!node.isDir || !node.expanded) {
|
|
512
|
-
// Jump to parent dir
|
|
513
|
-
const parent = this._findParent(node);
|
|
514
|
-
if (parent) {
|
|
515
|
-
const idx = this.flat.indexOf(parent);
|
|
516
|
-
if (idx >= 0) this._setCursor(idx);
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
private _findParent(node: TreeNode): TreeNode | null {
|
|
522
|
-
return this._findParentIn(this.root, node);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
private _findParentIn(candidate: TreeNode | null, target: TreeNode): TreeNode | null {
|
|
526
|
-
if (!candidate) return null;
|
|
527
|
-
for (const child of candidate.children) {
|
|
528
|
-
if (child === target) return candidate;
|
|
529
|
-
const found = this._findParentIn(child, target);
|
|
530
|
-
if (found) return found;
|
|
531
|
-
}
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// ── Private: search ───────────────────────────────────────────────────────
|
|
536
|
-
|
|
537
|
-
private _enterSearch(): void {
|
|
538
|
-
this.searchMode = true;
|
|
539
|
-
this.searchQuery = '';
|
|
540
|
-
this._rebuildFlat();
|
|
541
|
-
this.markDirty();
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
private _handleSearchInput(key: string): boolean {
|
|
545
|
-
const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.cursor, itemCount: this.flat.length });
|
|
546
|
-
if (transition === 'focus-list') {
|
|
547
|
-
this.searchMode = false;
|
|
548
|
-
this.cursor = 0;
|
|
549
|
-
this.scrollTop = 0;
|
|
550
|
-
this.markDirty();
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
553
|
-
if (isPanelSearchCancel(key)) {
|
|
554
|
-
this.searchMode = false;
|
|
555
|
-
this.searchQuery = '';
|
|
556
|
-
this._rebuildFlat();
|
|
557
|
-
this.markDirty();
|
|
558
|
-
return true;
|
|
559
|
-
}
|
|
560
|
-
if (isPanelSearchCommit(key)) {
|
|
561
|
-
// Confirm search, stay in results, exit search-input mode
|
|
562
|
-
this.searchMode = false;
|
|
563
|
-
this.markDirty();
|
|
564
|
-
return true;
|
|
565
|
-
}
|
|
566
|
-
if (isPanelSearchBackspace(key)) {
|
|
567
|
-
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
568
|
-
this._rebuildFlat();
|
|
569
|
-
this.markDirty();
|
|
570
|
-
return true;
|
|
571
|
-
}
|
|
572
|
-
// Printable single characters
|
|
573
|
-
if (isPanelSearchPrintable(key)) {
|
|
574
|
-
this.searchQuery += key;
|
|
575
|
-
this._rebuildFlat();
|
|
576
|
-
this.markDirty();
|
|
577
|
-
return true;
|
|
578
|
-
}
|
|
579
|
-
// Navigation still works during search
|
|
580
|
-
if (key === 'up' || key === 'k') { this._moveCursor(-1); return true; }
|
|
581
|
-
if (key === 'down' || key === 'j') { this._moveCursor(1); return true; }
|
|
582
|
-
return false;
|
|
583
|
-
}
|
|
584
|
-
}
|