@pellux/goodvibes-tui 1.1.0 → 1.7.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 +85 -21
- package/README.md +20 -11
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +2 -2
- package/src/core/alert-gating.ts +67 -0
- package/src/core/approval-alert.ts +72 -0
- package/src/core/budget-breach-notifier.ts +106 -0
- package/src/core/conversation-rendering.ts +19 -0
- package/src/core/conversation.ts +18 -0
- package/src/core/focus-tracker.ts +41 -0
- package/src/core/long-task-notifier.ts +33 -6
- package/src/core/system-message-router.ts +37 -51
- package/src/core/turn-cancellation.ts +20 -0
- package/src/core/turn-event-wiring.ts +64 -3
- package/src/export/cost-utils.ts +36 -0
- package/src/input/command-registry.ts +38 -1
- package/src/input/commands/checkpoint-runtime.ts +280 -0
- package/src/input/commands/codebase-runtime.ts +192 -0
- package/src/input/commands/eval.ts +1 -1
- package/src/input/commands/health-runtime.ts +1 -1
- package/src/input/commands/image-runtime.ts +112 -0
- package/src/input/commands/intelligence-runtime.ts +11 -1
- package/src/input/commands/local-auth-runtime.ts +4 -1
- package/src/input/commands/local-runtime.ts +9 -3
- package/src/input/commands/marketplace-runtime.ts +2 -2
- package/src/input/commands/memory.ts +6 -1
- package/src/input/commands/operator-panel-runtime.ts +40 -24
- package/src/input/commands/planning-runtime.ts +26 -3
- package/src/input/commands/platform-sandbox-runtime.ts +1 -6
- package/src/input/commands/plugin-runtime.ts +2 -2
- package/src/input/commands/policy-dispatch.ts +21 -0
- package/src/input/commands/provider-accounts-runtime.ts +1 -1
- package/src/input/commands/qrcode-runtime.ts +3 -3
- package/src/input/commands/recall-review.ts +35 -0
- package/src/input/commands/remote-runtime.ts +3 -3
- package/src/input/commands/runtime-services.ts +54 -13
- package/src/input/commands/services-runtime.ts +1 -1
- package/src/input/commands/session-workflow.ts +16 -1
- package/src/input/commands/settings-sync-runtime.ts +1 -1
- package/src/input/commands/shell-core.ts +15 -7
- package/src/input/commands/skills-runtime.ts +2 -8
- package/src/input/commands/subscription-runtime.ts +2 -2
- package/src/input/commands/test-runtime.ts +277 -0
- package/src/input/commands/websearch-runtime.ts +101 -0
- package/src/input/commands/work-plan-runtime.ts +36 -10
- package/src/input/commands/workstream-runtime.ts +338 -0
- package/src/input/commands.ts +12 -0
- package/src/input/config-modal-types.ts +161 -0
- package/src/input/config-modal.ts +377 -0
- package/src/input/feed-context-factory.ts +7 -8
- package/src/input/handler-command-route.ts +27 -17
- package/src/input/handler-feed-routes.ts +61 -23
- package/src/input/handler-feed.ts +47 -23
- package/src/input/handler-interactions.ts +1 -3
- package/src/input/handler-modal-routes.ts +65 -0
- package/src/input/handler-modal-stack.ts +3 -5
- package/src/input/handler-modal-token-routes.ts +16 -49
- package/src/input/handler-picker-routes.ts +11 -94
- package/src/input/handler-shortcuts.ts +24 -14
- package/src/input/handler-types.ts +2 -6
- package/src/input/handler-ui-state.ts +10 -22
- package/src/input/handler.ts +6 -28
- package/src/input/keybindings.ts +22 -8
- package/src/input/model-picker-types.ts +24 -6
- package/src/input/model-picker.ts +58 -0
- package/src/input/panel-integration-actions.ts +9 -170
- package/src/input/settings-modal-data.ts +95 -0
- package/src/input/settings-modal-mutations.ts +6 -4
- package/src/main.ts +24 -27
- package/src/panels/agent-inspector-shared.ts +2 -1
- package/src/panels/base-panel.ts +22 -1
- package/src/panels/builtin/agent.ts +21 -107
- package/src/panels/builtin/development.ts +15 -61
- package/src/panels/builtin/knowledge.ts +8 -32
- package/src/panels/builtin/operations.ts +84 -428
- package/src/panels/builtin/session.ts +21 -112
- package/src/panels/builtin/shared.ts +9 -43
- package/src/panels/builtin-modals.ts +218 -0
- package/src/panels/builtin-panels.ts +5 -0
- package/src/panels/cost-tracker-panel.ts +36 -10
- package/src/panels/diff-panel.ts +12 -43
- package/src/panels/eval-registry.ts +60 -0
- package/src/panels/fleet-panel.ts +800 -0
- package/src/panels/fleet-read-model.ts +477 -0
- package/src/panels/fleet-steer.ts +92 -0
- package/src/panels/fleet-stop.ts +125 -0
- package/src/panels/fleet-tabs.ts +230 -0
- package/src/panels/fleet-transcript.ts +356 -0
- package/src/panels/index.ts +7 -31
- package/src/panels/modals/hooks-modal.ts +187 -0
- package/src/panels/modals/keybindings-modal.ts +151 -0
- package/src/panels/modals/knowledge-modal.ts +158 -0
- package/src/panels/modals/local-auth-modal.ts +132 -0
- package/src/panels/modals/marketplace-modal.ts +218 -0
- package/src/panels/modals/memory-modal.ts +180 -0
- package/src/panels/modals/modal-surface-helpers.ts +54 -0
- package/src/panels/modals/modal-theme.ts +36 -0
- package/src/panels/modals/pairing-modal.ts +144 -0
- package/src/panels/modals/planning-modal.ts +282 -0
- package/src/panels/modals/plugins-modal.ts +174 -0
- package/src/panels/modals/policy-modal.ts +256 -0
- package/src/panels/modals/provider-health-modal.ts +136 -0
- package/src/panels/modals/remote-modal.ts +115 -0
- package/src/panels/modals/sandbox-modal.ts +150 -0
- package/src/panels/modals/security-modal.ts +217 -0
- package/src/panels/modals/services-modal.ts +179 -0
- package/src/panels/modals/settings-sync-modal.ts +142 -0
- package/src/panels/modals/skills-modal.ts +189 -0
- package/src/panels/modals/subscription-modal.ts +172 -0
- package/src/panels/modals/work-plan-modal.ts +149 -0
- package/src/panels/panel-confirm-overlay.ts +72 -0
- package/src/panels/panel-manager.ts +108 -5
- package/src/panels/project-planning-answer-actions.ts +4 -2
- package/src/panels/skills-panel.ts +7 -51
- package/src/panels/types.ts +13 -0
- package/src/permissions/hunk-selection.ts +179 -0
- package/src/permissions/prompt.ts +60 -4
- package/src/renderer/compaction-history-modal.ts +19 -3
- package/src/renderer/compaction-preview.ts +13 -2
- package/src/renderer/compaction-quality.ts +100 -0
- package/src/renderer/config-modal.ts +81 -0
- package/src/renderer/conversation-overlays.ts +10 -17
- package/src/renderer/fleet-tab-strip.ts +56 -0
- package/src/renderer/footer-tips.ts +10 -2
- package/src/renderer/git-status.ts +40 -0
- package/src/renderer/help-overlay.ts +2 -2
- package/src/renderer/model-workspace.ts +44 -1
- package/src/renderer/panel-workspace-bar.ts +8 -2
- package/src/renderer/turn-injection.ts +114 -0
- package/src/runtime/bootstrap-command-context.ts +21 -1
- package/src/runtime/bootstrap-command-parts.ts +34 -7
- package/src/runtime/bootstrap-core.ts +14 -4
- package/src/runtime/bootstrap-shell.ts +29 -16
- package/src/runtime/bootstrap.ts +11 -17
- package/src/runtime/code-index-services.ts +112 -0
- package/src/runtime/orchestrator-core-services.ts +49 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +91 -43
- package/src/runtime/ui-services.ts +8 -0
- package/src/runtime/workstream-services.ts +195 -0
- package/src/shell/blocking-input.ts +22 -1
- package/src/shell/ui-openers.ts +129 -17
- package/src/utils/format-duration.ts +8 -18
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
- package/src/panels/agent-inspector-panel.ts +0 -786
- package/src/panels/approval-panel.ts +0 -252
- package/src/panels/automation-control-panel.ts +0 -479
- package/src/panels/cockpit-panel.ts +0 -481
- package/src/panels/cockpit-read-model.ts +0 -238
- package/src/panels/communication-panel.ts +0 -256
- package/src/panels/control-plane-panel.ts +0 -470
- package/src/panels/debug-panel.ts +0 -615
- package/src/panels/docs-panel.ts +0 -384
- package/src/panels/eval-panel.ts +0 -627
- package/src/panels/file-explorer-panel.ts +0 -673
- package/src/panels/file-preview-panel.ts +0 -517
- package/src/panels/hooks-panel.ts +0 -401
- package/src/panels/incident-review-panel.ts +0 -406
- package/src/panels/intelligence-panel.ts +0 -383
- package/src/panels/knowledge-graph-panel.ts +0 -515
- package/src/panels/marketplace-panel.ts +0 -399
- package/src/panels/memory-panel.ts +0 -558
- package/src/panels/ops-control-panel.ts +0 -329
- package/src/panels/ops-strategy-panel.ts +0 -321
- package/src/panels/orchestration-panel.ts +0 -465
- package/src/panels/panel-list-panel.ts +0 -566
- package/src/panels/plan-dashboard-panel.ts +0 -707
- package/src/panels/policy-panel.ts +0 -517
- package/src/panels/project-planning-panel.ts +0 -731
- package/src/panels/provider-health-panel.ts +0 -678
- package/src/panels/provider-health-tracker.ts +0 -310
- package/src/panels/provider-health-views.ts +0 -567
- package/src/panels/qr-panel.ts +0 -280
- package/src/panels/remote-panel.ts +0 -534
- package/src/panels/routes-panel.ts +0 -241
- package/src/panels/sandbox-panel.ts +0 -456
- package/src/panels/security-panel.ts +0 -447
- package/src/panels/services-panel.ts +0 -329
- package/src/panels/session-browser-panel.ts +0 -496
- package/src/panels/settings-sync-panel.ts +0 -398
- package/src/panels/subscription-panel.ts +0 -342
- package/src/panels/symbol-outline-panel.ts +0 -619
- package/src/panels/system-messages-panel.ts +0 -364
- package/src/panels/tasks-panel.ts +0 -608
- package/src/panels/thinking-panel.ts +0 -333
- package/src/panels/tool-inspector-panel.ts +0 -537
- package/src/panels/work-plan-panel.ts +0 -540
- package/src/panels/worktree-panel.ts +0 -360
- package/src/panels/wrfc-panel.ts +0 -790
- package/src/renderer/agent-detail-modal.ts +0 -466
- package/src/renderer/live-tail-modal.ts +0 -156
- package/src/renderer/process-modal.ts +0 -671
- package/src/renderer/qr-renderer.ts +0 -120
|
@@ -1,673 +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
|
-
buildKeyboardHints,
|
|
13
|
-
buildPanelLine,
|
|
14
|
-
buildSearchInputLine,
|
|
15
|
-
buildTreeRow,
|
|
16
|
-
buildPanelWorkspace,
|
|
17
|
-
resolveScrollablePanelSection,
|
|
18
|
-
DEFAULT_PANEL_PALETTE,
|
|
19
|
-
extendPalette,
|
|
20
|
-
} from './polish.ts';
|
|
21
|
-
import { isPanelSearchBackspace, isPanelSearchPrintable } from './search-focus.ts';
|
|
22
|
-
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
23
|
-
import { GitService } from '@pellux/goodvibes-sdk/platform/git';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Constants
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
/** Directories / files to skip (gitignore-style). */
|
|
30
|
-
const SKIP_NAMES = new Set([
|
|
31
|
-
'node_modules', '.git', '.svn', '.hg',
|
|
32
|
-
'dist', 'build', 'out', '.next', '.nuxt', '.output',
|
|
33
|
-
'__pycache__', '.pytest_cache', '.mypy_cache',
|
|
34
|
-
'coverage', '.nyc_output',
|
|
35
|
-
'.DS_Store', 'Thumbs.db',
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
const SKIP_PATTERNS: RegExp[] = [
|
|
39
|
-
/^\..*\.sw[px]$/, // vim swap files
|
|
40
|
-
];
|
|
41
|
-
|
|
42
|
-
// ---------------------------------------------------------------------------
|
|
43
|
-
// File-type icons (single-char safe for TUI columns)
|
|
44
|
-
// ---------------------------------------------------------------------------
|
|
45
|
-
const EXT_ICONS: Record<string, string> = {
|
|
46
|
-
ts: 'T', tsx: 'T', js: 'J', jsx: 'J',
|
|
47
|
-
json: 'J', jsonc: 'J',
|
|
48
|
-
md: 'M', mdx: 'M',
|
|
49
|
-
css: 'S', scss: 'S', sass: 'S',
|
|
50
|
-
html: 'H', htm: 'H',
|
|
51
|
-
py: 'P', rb: 'R', go: 'G', rs: 'R',
|
|
52
|
-
sh: '$', bash: '$', zsh: '$',
|
|
53
|
-
yaml: 'Y', yml: 'Y', toml: 'C',
|
|
54
|
-
lock: 'L', log: 'L',
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
function fileIcon(name: string): string {
|
|
58
|
-
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
59
|
-
return EXT_ICONS[ext] ?? 'f';
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
// Tree node
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
|
|
66
|
-
interface TreeNode {
|
|
67
|
-
path: string; // absolute path
|
|
68
|
-
name: string; // display name
|
|
69
|
-
isDir: boolean;
|
|
70
|
-
depth: number;
|
|
71
|
-
size: number; // bytes (0 for dirs)
|
|
72
|
-
expanded: boolean;
|
|
73
|
-
children: TreeNode[];
|
|
74
|
-
/** Whether children have been loaded. */
|
|
75
|
-
loaded: boolean;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// Colour palette
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
const C = extendPalette(DEFAULT_PANEL_PALETTE, {
|
|
82
|
-
dirColor: '#00ffff', // cyan — directories
|
|
83
|
-
fileColor: '#e0e0e0', // near-white — files
|
|
84
|
-
sizeColor: '244', // dim grey — sizes
|
|
85
|
-
cursorBg: '#1a2a3a', // cursor background
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
// ---------------------------------------------------------------------------
|
|
89
|
-
// Helpers
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
|
|
92
|
-
function shouldSkip(name: string): boolean {
|
|
93
|
-
if (SKIP_NAMES.has(name)) return true;
|
|
94
|
-
for (const re of SKIP_PATTERNS) if (re.test(name)) return true;
|
|
95
|
-
return false;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function formatSize(bytes: number): string {
|
|
99
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
100
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
|
|
101
|
-
return `${(bytes / 1024 / 1024).toFixed(1)}M`;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ---------------------------------------------------------------------------
|
|
105
|
-
// FileExplorerPanel
|
|
106
|
-
// ---------------------------------------------------------------------------
|
|
107
|
-
|
|
108
|
-
export class FileExplorerPanel extends BasePanel {
|
|
109
|
-
// --- tree state ---
|
|
110
|
-
private root: TreeNode | null = null;
|
|
111
|
-
private flat: TreeNode[] = []; // visible flattened list
|
|
112
|
-
private rootPath: string;
|
|
113
|
-
private readonly workingDirectory: string;
|
|
114
|
-
private cacheValid: boolean = false;
|
|
115
|
-
private readyPromise: Promise<void> | null = null;
|
|
116
|
-
|
|
117
|
-
// --- git status decorations ---
|
|
118
|
-
// Reuse one GitService instance across refreshes (I2 pattern, git-panel.ts).
|
|
119
|
-
private readonly git: GitService;
|
|
120
|
-
/** absolute file path -> single-char status code (git-panel.ts:146-151 classification). */
|
|
121
|
-
private gitStatus: Map<string, { code: string; tone: 'good' | 'warn' | 'bad' | 'dim' }> = new Map();
|
|
122
|
-
|
|
123
|
-
// --- navigation ---
|
|
124
|
-
private cursor: number = 0;
|
|
125
|
-
private scrollTop: number = 0;
|
|
126
|
-
|
|
127
|
-
// --- search ---
|
|
128
|
-
private searchMode: boolean = false;
|
|
129
|
-
private searchQuery: string = '';
|
|
130
|
-
|
|
131
|
-
constructor(rootPath: string | undefined, workingDirectory: string) {
|
|
132
|
-
super('explorer', 'Explorer', 'E', 'development');
|
|
133
|
-
this.workingDirectory = workingDirectory;
|
|
134
|
-
this.rootPath = rootPath ?? workingDirectory;
|
|
135
|
-
this.git = new GitService(this.workingDirectory);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
139
|
-
|
|
140
|
-
override onActivate(): void {
|
|
141
|
-
super.onActivate();
|
|
142
|
-
if (!this.cacheValid) {
|
|
143
|
-
void this._buildTreeAsync();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
override onDestroy(): void {
|
|
148
|
-
this.root = null;
|
|
149
|
-
this.flat = [];
|
|
150
|
-
this.cacheValid = false;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── Public API ─────────────────────────────────────────────────────────────
|
|
154
|
-
|
|
155
|
-
/** Force a full tree refresh from disk. */
|
|
156
|
-
refresh(): void {
|
|
157
|
-
this.cacheValid = false;
|
|
158
|
-
void this._buildTreeAsync();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Currently focused node (or null). */
|
|
162
|
-
getFocusedNode(): TreeNode | null {
|
|
163
|
-
return this.flat[this.cursor] ?? null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
getFocusedFilePath(): string | null {
|
|
167
|
-
const node = this.getFocusedNode();
|
|
168
|
-
return node && !node.isDir ? node.path : null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ── Input ──────────────────────────────────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* WO-153: converged modal '/' filter (mirrors ScrollableListPanel's
|
|
175
|
-
* _handleFilterKey). Returns `true`/`false` when consumed/ignored in
|
|
176
|
-
* filter context, or `null` to fall through to navigation/action keys.
|
|
177
|
-
*/
|
|
178
|
-
private _handleFilterKey(key: string): boolean | null {
|
|
179
|
-
if (this.searchMode) {
|
|
180
|
-
if (key === 'escape') {
|
|
181
|
-
this.searchMode = false;
|
|
182
|
-
this.searchQuery = '';
|
|
183
|
-
this._rebuildFlat();
|
|
184
|
-
this.markDirty();
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
if (key === 'return' || key === 'enter') {
|
|
188
|
-
this.searchMode = false; // commit; keep the query applied
|
|
189
|
-
this.markDirty();
|
|
190
|
-
return true;
|
|
191
|
-
}
|
|
192
|
-
if (isPanelSearchBackspace(key)) {
|
|
193
|
-
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
194
|
-
this._rebuildFlat();
|
|
195
|
-
this.markDirty();
|
|
196
|
-
return true;
|
|
197
|
-
}
|
|
198
|
-
// Arrow/paging keys navigate the filtered tree — fall through.
|
|
199
|
-
if (key === 'up' || key === 'down' || key === 'pageup' || key === 'pagedown' || key === 'home' || key === 'end') {
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
// Any printable character (including single-letter action keys like r/d) extends the query.
|
|
203
|
-
if (isPanelSearchPrintable(key)) {
|
|
204
|
-
this.searchQuery += key;
|
|
205
|
-
this._rebuildFlat();
|
|
206
|
-
this.markDirty();
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
211
|
-
if (key === '/') {
|
|
212
|
-
this._enterSearch();
|
|
213
|
-
return true;
|
|
214
|
-
}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* The `/`-to-search buffer wants every character of a burst (paste, or a
|
|
220
|
-
* fast-typed query landing in one input.feed() call), same as it always
|
|
221
|
-
* has — see the interface doc on `Panel.isCapturingTextBurst`.
|
|
222
|
-
*/
|
|
223
|
-
isCapturingTextBurst(): boolean {
|
|
224
|
-
return this.searchMode;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
handleInput(key: string): boolean {
|
|
228
|
-
const filterResult = this._handleFilterKey(key);
|
|
229
|
-
if (filterResult !== null) return filterResult;
|
|
230
|
-
|
|
231
|
-
switch (key) {
|
|
232
|
-
case 'up': case 'k': this._moveCursor(-1); return true;
|
|
233
|
-
case 'down': case 'j': this._moveCursor(1); return true;
|
|
234
|
-
case 'pageup': this._moveCursorPage(-1); return true;
|
|
235
|
-
case 'pagedown': this._moveCursorPage(1); return true;
|
|
236
|
-
case 'home': case 'g': this._setCursor(0); return true;
|
|
237
|
-
case 'end': case 'G': this._setCursor(this.flat.length - 1); return true;
|
|
238
|
-
case 'return': case 'enter': this._activateNode(); return true;
|
|
239
|
-
case 'right': this._expandNode(); return true;
|
|
240
|
-
case 'left': this._collapseNode(); return true;
|
|
241
|
-
case 'r': this.refresh(); return true;
|
|
242
|
-
case 'd': return this.getFocusedFilePath() !== null;
|
|
243
|
-
default: return false;
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
handleScroll(deltaRows: number): boolean {
|
|
248
|
-
const rows = Math.trunc(deltaRows);
|
|
249
|
-
if (this.flat.length === 0 || rows === 0) return false;
|
|
250
|
-
const previous = this.cursor;
|
|
251
|
-
this._setCursor(this.cursor + rows);
|
|
252
|
-
return this.cursor !== previous;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── Render ─────────────────────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
render(width: number, height: number): Line[] {
|
|
258
|
-
this.needsRender = false;
|
|
259
|
-
|
|
260
|
-
// Context-aware hints: only surface keys that act in the current mode.
|
|
261
|
-
const hintsLine = this.searchMode
|
|
262
|
-
? buildKeyboardHints(width, [
|
|
263
|
-
{ keys: 'type', label: 'filter' },
|
|
264
|
-
{ keys: '↑/↓', label: 'move' },
|
|
265
|
-
{ keys: 'Enter', label: 'keep results' },
|
|
266
|
-
{ keys: 'Esc', label: 'clear' },
|
|
267
|
-
], C)
|
|
268
|
-
: buildKeyboardHints(width, [
|
|
269
|
-
{ keys: '↑/↓', label: 'navigate' },
|
|
270
|
-
{ keys: 'Enter/→', label: 'expand' },
|
|
271
|
-
{ keys: '←', label: 'collapse' },
|
|
272
|
-
{ keys: '/', label: 'filter' },
|
|
273
|
-
{ keys: 'r', label: 'refresh' },
|
|
274
|
-
{ keys: 'd', label: 'diff' },
|
|
275
|
-
], C);
|
|
276
|
-
|
|
277
|
-
if (this.flat.length === 0) {
|
|
278
|
-
return buildPanelWorkspace(width, height, {
|
|
279
|
-
title: ' Explorer',
|
|
280
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
281
|
-
sections: [
|
|
282
|
-
{
|
|
283
|
-
lines: buildEmptyState(width, ' No files found', this.searchQuery ? 'No files or directories match the current search.' : 'This root did not produce any visible files after the explorer filters were applied.', this.searchQuery ? [{ command: 'Esc', summary: 'clear the filter to show the full tree again' }] : [{ command: 'r', summary: 'rescan this root from disk' }], C),
|
|
284
|
-
},
|
|
285
|
-
],
|
|
286
|
-
footerLines: [
|
|
287
|
-
this._buildFilterLine(width),
|
|
288
|
-
hintsLine,
|
|
289
|
-
],
|
|
290
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const summarySection = {
|
|
295
|
-
title: 'Summary',
|
|
296
|
-
lines: [
|
|
297
|
-
buildPanelLine(width, [
|
|
298
|
-
[' Root ', DEFAULT_PANEL_PALETTE.label],
|
|
299
|
-
[relative(this.workingDirectory, this.rootPath) || '.', DEFAULT_PANEL_PALETTE.value],
|
|
300
|
-
[' Visible ', DEFAULT_PANEL_PALETTE.label],
|
|
301
|
-
[String(this.flat.length), DEFAULT_PANEL_PALETTE.value],
|
|
302
|
-
[' Search ', DEFAULT_PANEL_PALETTE.label],
|
|
303
|
-
[this.searchQuery || 'none', this.searchQuery ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.dim],
|
|
304
|
-
]),
|
|
305
|
-
],
|
|
306
|
-
} as const;
|
|
307
|
-
const selected = this.flat[this.cursor];
|
|
308
|
-
const selectedSection = {
|
|
309
|
-
title: 'Selected',
|
|
310
|
-
lines: selected
|
|
311
|
-
? [
|
|
312
|
-
buildPanelLine(width, [
|
|
313
|
-
[' Name ', DEFAULT_PANEL_PALETTE.label],
|
|
314
|
-
[selected.name, DEFAULT_PANEL_PALETTE.value],
|
|
315
|
-
[' Type ', DEFAULT_PANEL_PALETTE.label],
|
|
316
|
-
[selected.isDir ? 'directory' : 'file', selected.isDir ? DEFAULT_PANEL_PALETTE.info : DEFAULT_PANEL_PALETTE.value],
|
|
317
|
-
]),
|
|
318
|
-
buildPanelLine(width, [
|
|
319
|
-
[' Path ', DEFAULT_PANEL_PALETTE.label],
|
|
320
|
-
[selected.path, DEFAULT_PANEL_PALETTE.dim],
|
|
321
|
-
]),
|
|
322
|
-
]
|
|
323
|
-
: [],
|
|
324
|
-
} as const;
|
|
325
|
-
const treeSection = resolveScrollablePanelSection(width, height, {
|
|
326
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
327
|
-
footerLines: [
|
|
328
|
-
this._buildFilterLine(width),
|
|
329
|
-
hintsLine,
|
|
330
|
-
],
|
|
331
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
332
|
-
beforeSections: [summarySection],
|
|
333
|
-
section: {
|
|
334
|
-
title: 'Tree',
|
|
335
|
-
scrollableLines: this.flat.map((node, absoluteIdx) => {
|
|
336
|
-
const isCursor = absoluteIdx === this.cursor;
|
|
337
|
-
return buildTreeRow(width, {
|
|
338
|
-
depth: node.depth,
|
|
339
|
-
label: node.name,
|
|
340
|
-
icon: node.isDir ? undefined : fileIcon(node.name),
|
|
341
|
-
expandable: node.isDir,
|
|
342
|
-
expanded: node.expanded,
|
|
343
|
-
labelColor: node.isDir ? C.dirColor : C.fileColor,
|
|
344
|
-
metadata: this._buildRowMetadata(node),
|
|
345
|
-
}, C, { selected: isCursor, selectedBg: C.cursorBg });
|
|
346
|
-
}),
|
|
347
|
-
selectedIndex: this.cursor,
|
|
348
|
-
scrollOffset: this.scrollTop,
|
|
349
|
-
minRows: 8,
|
|
350
|
-
},
|
|
351
|
-
afterSections: [selectedSection],
|
|
352
|
-
});
|
|
353
|
-
this.scrollTop = treeSection.scrollOffset;
|
|
354
|
-
return buildPanelWorkspace(width, height, {
|
|
355
|
-
title: ' Explorer',
|
|
356
|
-
intro: 'Browse the project tree, expand directories, and search for paths.',
|
|
357
|
-
sections: [
|
|
358
|
-
summarySection,
|
|
359
|
-
treeSection.section,
|
|
360
|
-
selectedSection,
|
|
361
|
-
],
|
|
362
|
-
footerLines: [
|
|
363
|
-
this._buildFilterLine(width),
|
|
364
|
-
hintsLine,
|
|
365
|
-
],
|
|
366
|
-
palette: DEFAULT_PANEL_PALETTE,
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ── Private: tree building ─────────────────────────────────────────────────
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Build (or rebuild) the tree root and load ONLY its immediate children.
|
|
374
|
-
* Deeper directories stay unloaded (`loaded: false`, empty `children`)
|
|
375
|
-
* until the user actually expands them via _expandNode()/_activateNode() \u2014
|
|
376
|
-
* this replaces the old eager depth-5 recursive scan, which walked (and
|
|
377
|
-
* silently truncated at a hardcoded depth cutoff) the entire visible tree
|
|
378
|
-
* up front and blocked opening the panel on large repos.
|
|
379
|
-
*/
|
|
380
|
-
private _buildTreeAsync(): Promise<void> {
|
|
381
|
-
const p = (async () => {
|
|
382
|
-
try {
|
|
383
|
-
await this.withLoading('Scanning directory\u2026', async () => {
|
|
384
|
-
const root: TreeNode = {
|
|
385
|
-
path: this.rootPath,
|
|
386
|
-
name: basename(this.rootPath) || this.rootPath,
|
|
387
|
-
isDir: true,
|
|
388
|
-
depth: 0,
|
|
389
|
-
size: 0,
|
|
390
|
-
expanded: true,
|
|
391
|
-
children: [],
|
|
392
|
-
loaded: false,
|
|
393
|
-
};
|
|
394
|
-
await this._loadChildren(root);
|
|
395
|
-
this.root = root;
|
|
396
|
-
this._rebuildFlat();
|
|
397
|
-
this.cacheValid = true;
|
|
398
|
-
});
|
|
399
|
-
// Best-effort \u2014 a missing/failed git status just means no decorations.
|
|
400
|
-
await this._loadGitStatus();
|
|
401
|
-
} catch (err) {
|
|
402
|
-
this.setError(summarizeError(err));
|
|
403
|
-
}
|
|
404
|
-
this.markDirty();
|
|
405
|
-
})();
|
|
406
|
-
this.readyPromise = p;
|
|
407
|
-
return p;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
/** Resolves when the current tree build has settled. */
|
|
411
|
-
public awaitReady(): Promise<void> {
|
|
412
|
-
return this.readyPromise ?? Promise.resolve();
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Read one directory's immediate entries and populate `node.children` with
|
|
417
|
-
* fully-resolved file nodes and unloaded directory stubs. Idempotent and
|
|
418
|
-
* safe to call on an already-loaded node (no-ops).
|
|
419
|
-
*/
|
|
420
|
-
private async _loadChildren(node: TreeNode): Promise<void> {
|
|
421
|
-
if (node.loaded || !node.isDir) return;
|
|
422
|
-
|
|
423
|
-
let entries: string[];
|
|
424
|
-
try {
|
|
425
|
-
entries = await fsPromises.readdir(node.path);
|
|
426
|
-
} catch {
|
|
427
|
-
node.loaded = true;
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const filtered = entries.filter(e => !shouldSkip(e));
|
|
432
|
-
const statResults = await Promise.all(
|
|
433
|
-
filtered.map(async (e) => {
|
|
434
|
-
try {
|
|
435
|
-
const s = await fsPromises.stat(join(node.path, e));
|
|
436
|
-
return { name: e, isDir: s.isDirectory(), size: s.size, stat: s };
|
|
437
|
-
} catch {
|
|
438
|
-
return { name: e, isDir: false, size: 0, stat: null };
|
|
439
|
-
}
|
|
440
|
-
}),
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
const sorted = statResults.sort((a, b) => {
|
|
444
|
-
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
445
|
-
return a.name.localeCompare(b.name);
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
const children: TreeNode[] = [];
|
|
449
|
-
for (const entry of sorted) {
|
|
450
|
-
if (entry.stat === null) continue;
|
|
451
|
-
const fullPath = join(node.path, entry.name);
|
|
452
|
-
children.push(entry.isDir
|
|
453
|
-
? {
|
|
454
|
-
path: fullPath,
|
|
455
|
-
name: entry.name,
|
|
456
|
-
isDir: true,
|
|
457
|
-
depth: node.depth + 1,
|
|
458
|
-
size: 0,
|
|
459
|
-
expanded: false,
|
|
460
|
-
children: [],
|
|
461
|
-
loaded: false,
|
|
462
|
-
}
|
|
463
|
-
: {
|
|
464
|
-
path: fullPath,
|
|
465
|
-
name: entry.name,
|
|
466
|
-
isDir: false,
|
|
467
|
-
depth: node.depth + 1,
|
|
468
|
-
size: entry.size,
|
|
469
|
-
expanded: false,
|
|
470
|
-
children: [],
|
|
471
|
-
loaded: true,
|
|
472
|
-
});
|
|
473
|
-
}
|
|
474
|
-
node.children = children;
|
|
475
|
-
node.loaded = true;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Fetch git status for the working directory and index it by absolute
|
|
480
|
-
* path for O(1) lookup from the tree renderer. Classification mirrors
|
|
481
|
-
* git-panel's staged/unstaged split from the raw porcelain index/
|
|
482
|
-
* working_dir columns (git-panel.ts:146-151), collapsed to a single
|
|
483
|
-
* decoration badge per file: working-tree changes take priority over
|
|
484
|
-
* staged-only changes, since that's the state most relevant while
|
|
485
|
-
* browsing the tree.
|
|
486
|
-
*/
|
|
487
|
-
private async _loadGitStatus(): Promise<void> {
|
|
488
|
-
try {
|
|
489
|
-
const status = await this.git.status();
|
|
490
|
-
const next = new Map<string, { code: string; tone: 'good' | 'warn' | 'bad' | 'dim' }>();
|
|
491
|
-
for (const f of status.files) {
|
|
492
|
-
const fullPath = join(this.workingDirectory, f.path);
|
|
493
|
-
if (f.index === '?' && f.working_dir === '?') {
|
|
494
|
-
next.set(fullPath, { code: '?', tone: 'dim' }); // untracked
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
const code = f.working_dir !== ' ' ? f.working_dir : f.index;
|
|
498
|
-
if (!code || code === ' ') continue;
|
|
499
|
-
const tone = code === 'D' ? 'bad' : code === 'A' ? 'good' : 'warn';
|
|
500
|
-
next.set(fullPath, { code, tone });
|
|
501
|
-
}
|
|
502
|
-
this.gitStatus = next;
|
|
503
|
-
} catch {
|
|
504
|
-
// Not a git repo (or git unavailable) \u2014 decorations are optional.
|
|
505
|
-
this.gitStatus = new Map();
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/** Right-aligned row metadata: git status badge (files only) + file size. */
|
|
510
|
-
private _buildRowMetadata(node: TreeNode): Array<{ text: string; fg: string }> | undefined {
|
|
511
|
-
if (node.isDir) return undefined;
|
|
512
|
-
const meta: Array<{ text: string; fg: string }> = [];
|
|
513
|
-
const status = this.gitStatus.get(node.path);
|
|
514
|
-
if (status) {
|
|
515
|
-
const fg = status.tone === 'good' ? DEFAULT_PANEL_PALETTE.good
|
|
516
|
-
: status.tone === 'bad' ? DEFAULT_PANEL_PALETTE.bad
|
|
517
|
-
: status.tone === 'warn' ? DEFAULT_PANEL_PALETTE.warn
|
|
518
|
-
: DEFAULT_PANEL_PALETTE.dim;
|
|
519
|
-
meta.push({ text: status.code, fg });
|
|
520
|
-
}
|
|
521
|
-
if (node.size > 0) meta.push({ text: formatSize(node.size), fg: C.sizeColor });
|
|
522
|
-
return meta.length > 0 ? meta : undefined;
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Flatten the tree into a visible list based on expansion state
|
|
527
|
-
* and the current search query.
|
|
528
|
-
*/
|
|
529
|
-
private _rebuildFlat(): void {
|
|
530
|
-
const q = this.searchQuery.trim().toLowerCase();
|
|
531
|
-
|
|
532
|
-
if (q) {
|
|
533
|
-
// In search mode: show all matching nodes (any depth), ignoring expand state
|
|
534
|
-
const results: TreeNode[] = [];
|
|
535
|
-
this._collectMatching(this.root, q, results);
|
|
536
|
-
this.flat = results;
|
|
537
|
-
} else {
|
|
538
|
-
const rows: TreeNode[] = [];
|
|
539
|
-
if (this.root) this._flatten(this.root, rows, /* skipSelf */ true);
|
|
540
|
-
this.flat = rows;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
// Clamp cursor
|
|
544
|
-
if (this.cursor >= this.flat.length) {
|
|
545
|
-
this.cursor = Math.max(0, this.flat.length - 1);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
private _flatten(node: TreeNode, out: TreeNode[], skipSelf: boolean): void {
|
|
550
|
-
if (!skipSelf) out.push(node);
|
|
551
|
-
if ((skipSelf || node.expanded) && node.children.length > 0) {
|
|
552
|
-
for (const child of node.children) {
|
|
553
|
-
this._flatten(child, out, false);
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
private _collectMatching(node: TreeNode | null, q: string, out: TreeNode[]): void {
|
|
559
|
-
if (!node) return;
|
|
560
|
-
if (node.name.toLowerCase().includes(q)) out.push(node);
|
|
561
|
-
for (const child of node.children) this._collectMatching(child, q, out);
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
// ── Private: navigation ───────────────────────────────────────────────────
|
|
565
|
-
|
|
566
|
-
private _moveCursor(delta: number): void {
|
|
567
|
-
this._setCursor(this.cursor + delta);
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
private _moveCursorPage(direction: 1 | -1, pageSize = 10): void {
|
|
571
|
-
this._setCursor(this.cursor + direction * pageSize);
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
private _setCursor(idx: number): void {
|
|
575
|
-
this.cursor = Math.max(0, Math.min(idx, this.flat.length - 1));
|
|
576
|
-
this.markDirty();
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
private _activateNode(): void {
|
|
580
|
-
const node = this.flat[this.cursor];
|
|
581
|
-
if (!node) return;
|
|
582
|
-
if (node.isDir) {
|
|
583
|
-
node.expanded = !node.expanded;
|
|
584
|
-
this._rebuildFlat();
|
|
585
|
-
this.markDirty();
|
|
586
|
-
if (node.expanded) this._loadNodeChildrenAndRefresh(node);
|
|
587
|
-
}
|
|
588
|
-
// For files: callers can read getFocusedNode() after the input returns true
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
private _expandNode(): void {
|
|
592
|
-
const node = this.flat[this.cursor];
|
|
593
|
-
if (!node || !node.isDir || node.expanded) return;
|
|
594
|
-
node.expanded = true;
|
|
595
|
-
this._rebuildFlat();
|
|
596
|
-
this.markDirty();
|
|
597
|
-
this._loadNodeChildrenAndRefresh(node);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Lazily populate a directory's children (if not already loaded) and
|
|
602
|
-
* refresh the flattened view once they arrive, without blocking the
|
|
603
|
-
* expand keystroke itself.
|
|
604
|
-
*/
|
|
605
|
-
private _loadNodeChildrenAndRefresh(node: TreeNode): void {
|
|
606
|
-
if (node.loaded) return;
|
|
607
|
-
void this._loadChildren(node).then(() => {
|
|
608
|
-
this._rebuildFlat();
|
|
609
|
-
this.markDirty();
|
|
610
|
-
});
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
private _collapseNode(): void {
|
|
614
|
-
const node = this.flat[this.cursor];
|
|
615
|
-
if (!node) return;
|
|
616
|
-
if (node.isDir && node.expanded) {
|
|
617
|
-
node.expanded = false;
|
|
618
|
-
this._rebuildFlat();
|
|
619
|
-
this.markDirty();
|
|
620
|
-
} else if (!node.isDir || !node.expanded) {
|
|
621
|
-
// Jump to parent dir
|
|
622
|
-
const parent = this._findParent(node);
|
|
623
|
-
if (parent) {
|
|
624
|
-
const idx = this.flat.indexOf(parent);
|
|
625
|
-
if (idx >= 0) this._setCursor(idx);
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
private _findParent(node: TreeNode): TreeNode | null {
|
|
631
|
-
return this._findParentIn(this.root, node);
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
private _findParentIn(candidate: TreeNode | null, target: TreeNode): TreeNode | null {
|
|
635
|
-
if (!candidate) return null;
|
|
636
|
-
for (const child of candidate.children) {
|
|
637
|
-
if (child === target) return candidate;
|
|
638
|
-
const found = this._findParentIn(child, target);
|
|
639
|
-
if (found) return found;
|
|
640
|
-
}
|
|
641
|
-
return null;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// ── Private: search ───────────────────────────────────────────────────────
|
|
645
|
-
|
|
646
|
-
private _enterSearch(): void {
|
|
647
|
-
// Re-entering search (e.g. pressing '/' again) must EDIT the existing
|
|
648
|
-
// query, not silently clear it — the bug this fixes unconditionally
|
|
649
|
-
// reset searchQuery to '' on every entry, discarding whatever filter
|
|
650
|
-
// was already active.
|
|
651
|
-
this.searchMode = true;
|
|
652
|
-
this._rebuildFlat();
|
|
653
|
-
this.markDirty();
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* The filter input line — pinned rendering contract shared with
|
|
658
|
-
* ScrollableListPanel.buildFilterLine: 'Filter: ' unfocused / '[Filter] '
|
|
659
|
-
* focused, literal trailing '_' cursor while active (active:false is
|
|
660
|
-
* passed to buildSearchInputLine to suppress its block-glyph cursor
|
|
661
|
-
* substitution).
|
|
662
|
-
*/
|
|
663
|
-
private _buildFilterLine(width: number): Line {
|
|
664
|
-
const label = this.searchMode ? '[Filter] ' : 'Filter: ';
|
|
665
|
-
const value = this.searchMode ? `${this.searchQuery}_` : this.searchQuery;
|
|
666
|
-
return buildSearchInputLine(width, label, value, DEFAULT_PANEL_PALETTE, {
|
|
667
|
-
active: false,
|
|
668
|
-
bg: this.searchMode ? DEFAULT_PANEL_PALETTE.inputBg : DEFAULT_PANEL_PALETTE.sectionBg,
|
|
669
|
-
emptyLabel: '(/ to filter)',
|
|
670
|
-
valueColor: this.searchMode ? DEFAULT_PANEL_PALETTE.info : (this.searchQuery ? DEFAULT_PANEL_PALETTE.value : DEFAULT_PANEL_PALETTE.dim),
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
}
|