@hoox-sh/hoox-tui 0.2.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/README.md +247 -0
- package/bin/hoox-tui.js +35 -0
- package/dist/main.js +17594 -0
- package/package.json +72 -0
- package/src/app.tsx +696 -0
- package/src/cli-bridge-test-double.ts +212 -0
- package/src/components/layout/layout.test.tsx +205 -0
- package/src/components/layout/sidebar.tsx +69 -0
- package/src/components/layout/statusbar.test.tsx +423 -0
- package/src/components/layout/statusbar.tsx +348 -0
- package/src/components/shared/animated-border.tsx +58 -0
- package/src/components/shared/chrome.test.tsx +29 -0
- package/src/components/shared/command-palette.tsx +361 -0
- package/src/components/shared/cool-brackets.tsx +75 -0
- package/src/components/shared/crash-screen.tsx +167 -0
- package/src/components/shared/error-boundary.tsx +99 -0
- package/src/components/shared/keybinding-hint.tsx +37 -0
- package/src/components/shared/panel.tsx +61 -0
- package/src/components/shared/quit-modal.tsx +65 -0
- package/src/components/shared/shared.test.tsx +538 -0
- package/src/components/shared/spinner.tsx +85 -0
- package/src/components/shared/status-dot.tsx +54 -0
- package/src/components/shared/view-header.tsx +53 -0
- package/src/components/ui/connection-toasts.test.ts +50 -0
- package/src/components/ui/connection-toasts.ts +215 -0
- package/src/components/ui/dialog.tsx +281 -0
- package/src/components/ui/select.tsx +197 -0
- package/src/components/ui/toast.tsx +185 -0
- package/src/components/views/ai-chat.test.tsx +81 -0
- package/src/components/views/ai-chat.tsx +576 -0
- package/src/components/views/config-editor/action-bar.tsx +102 -0
- package/src/components/views/config-editor/code-editor.tsx +597 -0
- package/src/components/views/config-editor/file-tree.tsx +113 -0
- package/src/components/views/config-editor/types.ts +37 -0
- package/src/components/views/config-editor.test.tsx +465 -0
- package/src/components/views/config-editor.tsx +709 -0
- package/src/components/views/dashboard/alerts-panel.tsx +154 -0
- package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
- package/src/components/views/dashboard/model-health-section.tsx +276 -0
- package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
- package/src/components/views/dashboard.test.tsx +347 -0
- package/src/components/views/dashboard.tsx +647 -0
- package/src/components/views/db-query.test.tsx +115 -0
- package/src/components/views/db-query.tsx +849 -0
- package/src/components/views/edge-topology.test.tsx +198 -0
- package/src/components/views/edge-topology.tsx +572 -0
- package/src/components/views/kv-viewer.test.tsx +241 -0
- package/src/components/views/kv-viewer.tsx +717 -0
- package/src/components/views/logs-viewer.test.tsx +273 -0
- package/src/components/views/logs-viewer.tsx +616 -0
- package/src/components/views/queue-depth.test.tsx +221 -0
- package/src/components/views/queue-depth.tsx +376 -0
- package/src/components/views/secrets-viewer.test.tsx +65 -0
- package/src/components/views/secrets-viewer.tsx +548 -0
- package/src/components/views/service-manager.test.tsx +358 -0
- package/src/components/views/service-manager.tsx +946 -0
- package/src/components/views/settings.test.tsx +648 -0
- package/src/components/views/settings.tsx +1497 -0
- package/src/components/views/setup-wizard.test.tsx +297 -0
- package/src/components/views/setup-wizard.tsx +1321 -0
- package/src/components/views/trade-monitor.test.tsx +592 -0
- package/src/components/views/trade-monitor.tsx +745 -0
- package/src/components/views/worker-detail.test.tsx +289 -0
- package/src/components/views/worker-detail.tsx +676 -0
- package/src/components/views/worker-settings.test.tsx +43 -0
- package/src/components/views/worker-settings.tsx +563 -0
- package/src/components/views/workers-overview.test.tsx +260 -0
- package/src/components/views/workers-overview.tsx +631 -0
- package/src/hooks/hooks.test.ts +72 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/renderer-ref.ts +22 -0
- package/src/hooks/use-keyboard.ts +130 -0
- package/src/hooks/use-polling.ts +73 -0
- package/src/hooks/use-service-data.ts +18 -0
- package/src/main.tsx +117 -0
- package/src/network-test-double.ts +133 -0
- package/src/opentui-augment.d.ts +45 -0
- package/src/opentui-jsx.d.ts +70 -0
- package/src/services/cli-bridge/index.ts +1662 -0
- package/src/services/cli-bridge/standalone.ts +315 -0
- package/src/services/cli-bridge/types.ts +307 -0
- package/src/services/cli-bridge.test.ts +406 -0
- package/src/services/clipboard.test.ts +57 -0
- package/src/services/clipboard.ts +180 -0
- package/src/services/dashboard-settings-loader.ts +31 -0
- package/src/services/dev-log.test.ts +168 -0
- package/src/services/dev-log.ts +194 -0
- package/src/services/hoox-path-service.test.ts +183 -0
- package/src/services/hoox-path-service.ts +185 -0
- package/src/services/tui-connection.test.ts +212 -0
- package/src/services/tui-connection.ts +236 -0
- package/src/services/tui-storage.test.ts +92 -0
- package/src/services/tui-storage.ts +86 -0
- package/src/stores/config-store.test.ts +360 -0
- package/src/stores/service-store.test.ts +1026 -0
- package/src/stores/session-restore.test.ts +134 -0
- package/src/stores/ui-store.test.ts +276 -0
- package/src/test-setup.ts +265 -0
- package/src/test-utils.ts +179 -0
- package/src/types.ts +153 -0
- package/src/utils/colors.test.ts +326 -0
- package/src/utils/formatters.test.ts +236 -0
- package/src/view-registry.test.ts +132 -0
- package/src/view-registry.tsx +368 -0
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 HOOX · HOOX · jango-blockchained (hoox-sh)
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** @jsxImportSource @opentui/react */
|
|
7
|
+
/**
|
|
8
|
+
* Config Editor View — VS Code-style split pane for editing TOML/JSON config.
|
|
9
|
+
*
|
|
10
|
+
* Layout (left → right, bottom bar):
|
|
11
|
+
* 1. FileTree (left pane, 25%): directory structure of config/ files.
|
|
12
|
+
* Unsaved files marked with [*] indicator.
|
|
13
|
+
* 2. CodeEditor (right pane, 75%): scrollable text with line numbers
|
|
14
|
+
* and basic TOML/JSON syntax highlighting via design tokens.
|
|
15
|
+
* 3. ActionBar (footer): [Save] [Validate] [Diff] [Format] + unsaved counter.
|
|
16
|
+
*
|
|
17
|
+
* Keyboard:
|
|
18
|
+
* - Tab: switch focus between tree and editor panes
|
|
19
|
+
* - ↑↓: navigate file tree / scroll editor
|
|
20
|
+
* - Enter: select file in tree
|
|
21
|
+
* - Ctrl+S: save current file
|
|
22
|
+
* - Ctrl+Z: undo last change
|
|
23
|
+
* - Ctrl+Y: redo last change
|
|
24
|
+
* - Ctrl+F: find in file
|
|
25
|
+
*
|
|
26
|
+
* File operations use Bun native I/O. Config directory path is resolved from
|
|
27
|
+
* cwd (./config/) with a fallback to the hoox config dir (~/.hoox/config/).
|
|
28
|
+
*
|
|
29
|
+
* Follows TUI Patterns 1 (View Composition), 2 (Store Subscription),
|
|
30
|
+
* 5 (Color Token Usage), 8 (ScrollBox).
|
|
31
|
+
* Colors from @hoox-sh/hoox-shared design tokens. No CSS, no DOM.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
34
|
+
import { isAbsolute, relative, resolve as pathResolve } from "node:path";
|
|
35
|
+
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|
36
|
+
import { useKeyboard } from "@opentui/react";
|
|
37
|
+
import {
|
|
38
|
+
Colors,
|
|
39
|
+
getHooxRepoPath,
|
|
40
|
+
resolveHooxRuntimeRoot,
|
|
41
|
+
useUIStore,
|
|
42
|
+
} from "@hoox-sh/hoox-shared";
|
|
43
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
44
|
+
import { ViewHeader } from "../shared/view-header";
|
|
45
|
+
import { cliBridge } from "../../services/cli-bridge";
|
|
46
|
+
import { redactSecretsInText } from "../../services/dev-log";
|
|
47
|
+
|
|
48
|
+
// ─── Extracted submodules ─────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
import type {
|
|
51
|
+
FileNode,
|
|
52
|
+
SyntaxErrorEntry,
|
|
53
|
+
ActivePane,
|
|
54
|
+
} from "./config-editor/types";
|
|
55
|
+
import { FileTree } from "./config-editor/file-tree";
|
|
56
|
+
import {
|
|
57
|
+
CodeEditor,
|
|
58
|
+
detectFileType,
|
|
59
|
+
validateSyntax,
|
|
60
|
+
formatContent,
|
|
61
|
+
} from "./config-editor/code-editor";
|
|
62
|
+
import { ActionBar } from "./config-editor/action-bar";
|
|
63
|
+
|
|
64
|
+
// Re-export extracted types and functions for backward compatibility
|
|
65
|
+
export type {
|
|
66
|
+
FileNode,
|
|
67
|
+
SyntaxErrorEntry,
|
|
68
|
+
ActivePane,
|
|
69
|
+
TokenSpan,
|
|
70
|
+
FileType,
|
|
71
|
+
} from "./config-editor/types";
|
|
72
|
+
export { flattenTree } from "./config-editor/file-tree";
|
|
73
|
+
export {
|
|
74
|
+
tokenizeTomlLine,
|
|
75
|
+
tokenizeJsonLine,
|
|
76
|
+
detectFileType,
|
|
77
|
+
validateSyntax,
|
|
78
|
+
formatContent,
|
|
79
|
+
} from "./config-editor/code-editor";
|
|
80
|
+
|
|
81
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/** Hard cap on config files loaded into the editor (1 MiB). */
|
|
84
|
+
export const MAX_CONFIG_FILE_BYTES = 1024 * 1024;
|
|
85
|
+
|
|
86
|
+
/** Cap rendered lines so huge files do not freeze the TUI. */
|
|
87
|
+
export const MAX_EDITOR_DISPLAY_LINES = 5_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Project-root candidates that may contain a `config/` directory.
|
|
91
|
+
* Blueprint paths are always relative to the *project root* (e.g.
|
|
92
|
+
* `config/wrangler.toml`), never to the config dir itself — that
|
|
93
|
+
* avoids the double-prefix bug (`…/config/config/…`).
|
|
94
|
+
*/
|
|
95
|
+
const PROJECT_ROOT_CANDIDATES = [".", "..", "../..", "packages/tui"];
|
|
96
|
+
|
|
97
|
+
/** Known config files — used as the tree blueprint. Content is lazy-loaded. */
|
|
98
|
+
export const CONFIG_TREE_BLUEPRINT: FileNode[] = [
|
|
99
|
+
{
|
|
100
|
+
name: "config",
|
|
101
|
+
path: "config",
|
|
102
|
+
type: "directory",
|
|
103
|
+
children: [
|
|
104
|
+
{ name: "wrangler.toml", path: "config/wrangler.toml", type: "file" },
|
|
105
|
+
{
|
|
106
|
+
name: "trade.config.json",
|
|
107
|
+
path: "config/trade.config.json",
|
|
108
|
+
type: "file",
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "risk.config.json",
|
|
112
|
+
path: "config/risk.config.json",
|
|
113
|
+
type: "file",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "strategies",
|
|
117
|
+
path: "config/strategies",
|
|
118
|
+
type: "directory",
|
|
119
|
+
children: [
|
|
120
|
+
{
|
|
121
|
+
name: "grid.config.json",
|
|
122
|
+
path: "config/strategies/grid.config.json",
|
|
123
|
+
type: "file",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "macd.config.json",
|
|
127
|
+
path: "config/strategies/macd.config.json",
|
|
128
|
+
type: "file",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "scalping.config.json",
|
|
132
|
+
path: "config/strategies/scalping.config.json",
|
|
133
|
+
type: "file",
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{ name: ".env", path: "config/.env", type: "file" },
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// ─── Helpers — File I/O (Bun native) ──────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Cache of resolved project root (lazy, memoized). */
|
|
145
|
+
let _resolvedProjectRoot: string | null = null;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolve the monorepo/project root that contains a `config/` folder.
|
|
149
|
+
* Blueprint file paths are joined under this root.
|
|
150
|
+
*
|
|
151
|
+
* @internal Exported for tests; call {@link resetResolvedConfigDir} between tests.
|
|
152
|
+
*/
|
|
153
|
+
export function resolveConfigDir(): string {
|
|
154
|
+
if (_resolvedProjectRoot) return _resolvedProjectRoot;
|
|
155
|
+
const cwd =
|
|
156
|
+
typeof process !== "undefined" && process.cwd ? process.cwd() : ".";
|
|
157
|
+
|
|
158
|
+
// Prefer resolved runtime monorepo (cwd / HOOX_REPO / ~/.hoox/repo)
|
|
159
|
+
const runtime = resolveHooxRuntimeRoot({ cwd });
|
|
160
|
+
for (const base of [runtime.root, getHooxRepoPath()].filter(
|
|
161
|
+
Boolean
|
|
162
|
+
) as string[]) {
|
|
163
|
+
if (configDirExists(base)) {
|
|
164
|
+
_resolvedProjectRoot = normalizePath(base);
|
|
165
|
+
return _resolvedProjectRoot;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const cand of PROJECT_ROOT_CANDIDATES) {
|
|
170
|
+
const base = normalizePath(`${cwd}/${cand}`);
|
|
171
|
+
if (configDirExists(base)) {
|
|
172
|
+
_resolvedProjectRoot = base;
|
|
173
|
+
return base;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Fallback: cwd (blueprint paths still start with config/…)
|
|
178
|
+
_resolvedProjectRoot = normalizePath(cwd);
|
|
179
|
+
return _resolvedProjectRoot;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Reset memoized project root (tests only). */
|
|
183
|
+
export function resetResolvedConfigDir(): void {
|
|
184
|
+
_resolvedProjectRoot = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizePath(p: string): string {
|
|
188
|
+
return (
|
|
189
|
+
p
|
|
190
|
+
.replace(/\/\.\//g, "/")
|
|
191
|
+
.replace(/\/+/g, "/")
|
|
192
|
+
.replace(/\/$/, "") || "."
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function configDirExists(projectRoot: string): boolean {
|
|
197
|
+
try {
|
|
198
|
+
if (existsSync(`${projectRoot}/config`)) return true;
|
|
199
|
+
// Monorepo root without a top-level config/ still needs project root so
|
|
200
|
+
// blueprint paths (`config/…`) never double-prefix under …/config/config.
|
|
201
|
+
if (
|
|
202
|
+
existsSync(`${projectRoot}/package.json`) &&
|
|
203
|
+
(existsSync(`${projectRoot}/packages`) ||
|
|
204
|
+
existsSync(`${projectRoot}/wrangler.jsonc`))
|
|
205
|
+
) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
} catch {
|
|
209
|
+
// ignore
|
|
210
|
+
}
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reject path traversal / absolute paths before any disk I/O.
|
|
216
|
+
* Blueprint paths must stay relative (e.g. `config/wrangler.toml`).
|
|
217
|
+
*
|
|
218
|
+
* @internal Exported for unit tests.
|
|
219
|
+
*/
|
|
220
|
+
export function isSafeConfigRelativePath(relativePath: string): boolean {
|
|
221
|
+
if (!relativePath || typeof relativePath !== "string") return false;
|
|
222
|
+
if (relativePath.includes("\0")) return false;
|
|
223
|
+
// Windows + POSIX absolute
|
|
224
|
+
if (isAbsolute(relativePath)) return false;
|
|
225
|
+
if (/^[a-zA-Z]:[\\/]/.test(relativePath)) return false;
|
|
226
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
227
|
+
if (!normalized || normalized === ".") return false;
|
|
228
|
+
const parts = normalized.split("/").filter((p) => p.length > 0);
|
|
229
|
+
if (parts.some((p) => p === "..")) return false;
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Truncate CLI / syntax error text for the status bar.
|
|
235
|
+
* Never echo full file bodies (may contain secrets from `.env`).
|
|
236
|
+
*/
|
|
237
|
+
export function sanitizeConfigStatus(message: string, maxLen = 160): string {
|
|
238
|
+
const scrubbed = redactSecretsInText(message).replace(/\s+/g, " ").trim();
|
|
239
|
+
if (scrubbed.length <= maxLen) return scrubbed;
|
|
240
|
+
return scrubbed.slice(0, maxLen - 1) + "…";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Join a blueprint-relative path onto the resolved project root.
|
|
245
|
+
* Throws when the path escapes the project root (path traversal).
|
|
246
|
+
*/
|
|
247
|
+
export function resolveConfigFilePath(relativePath: string): string {
|
|
248
|
+
if (!isSafeConfigRelativePath(relativePath)) {
|
|
249
|
+
throw new Error(`Unsafe config path rejected: ${relativePath}`);
|
|
250
|
+
}
|
|
251
|
+
const root = resolveConfigDir();
|
|
252
|
+
// Strip a leading `./` and collapse accidental double `config/config`
|
|
253
|
+
let rel = relativePath.replace(/^\.\//, "").replace(/\\/g, "/");
|
|
254
|
+
if (root.endsWith("/config") && rel.startsWith("config/")) {
|
|
255
|
+
rel = rel.slice("config/".length);
|
|
256
|
+
}
|
|
257
|
+
const rootAbs = pathResolve(root);
|
|
258
|
+
const fullAbs = pathResolve(rootAbs, rel);
|
|
259
|
+
const relToRoot = relative(rootAbs, fullAbs);
|
|
260
|
+
if (
|
|
261
|
+
!relToRoot ||
|
|
262
|
+
relToRoot.startsWith("..") ||
|
|
263
|
+
isAbsolute(relToRoot) ||
|
|
264
|
+
relToRoot.split(/[/\\]/).includes("..")
|
|
265
|
+
) {
|
|
266
|
+
throw new Error(`Path escapes config root: ${relativePath}`);
|
|
267
|
+
}
|
|
268
|
+
return fullAbs;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Read a config file from disk.
|
|
273
|
+
* Enforces path safety + max size. Returns empty string if missing.
|
|
274
|
+
*/
|
|
275
|
+
async function loadFileContent(relativePath: string): Promise<string> {
|
|
276
|
+
let fullPath: string;
|
|
277
|
+
try {
|
|
278
|
+
fullPath = resolveConfigFilePath(relativePath);
|
|
279
|
+
} catch (e) {
|
|
280
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
281
|
+
return `# ${relativePath}\n# ${msg}\n`;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const f = Bun.file(fullPath);
|
|
285
|
+
const exists = await f.exists();
|
|
286
|
+
if (!exists) return "";
|
|
287
|
+
if (typeof f.size === "number" && f.size > MAX_CONFIG_FILE_BYTES) {
|
|
288
|
+
return (
|
|
289
|
+
`# ${relativePath}\n` +
|
|
290
|
+
`# File too large (${f.size} bytes > ${MAX_CONFIG_FILE_BYTES} max). ` +
|
|
291
|
+
`Edit externally.\n`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
const text = await f.text();
|
|
295
|
+
// Defensive: size may be 0/unknown for some streams
|
|
296
|
+
if (text.length > MAX_CONFIG_FILE_BYTES) {
|
|
297
|
+
return (
|
|
298
|
+
`# ${relativePath}\n` +
|
|
299
|
+
`# File too large (${text.length} chars > ${MAX_CONFIG_FILE_BYTES} max). ` +
|
|
300
|
+
`Edit externally.\n`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return text;
|
|
304
|
+
} catch {
|
|
305
|
+
return `# ${relativePath}\n# File not found at ${fullPath}\n`;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Write content to a config file on disk (path-safe). */
|
|
310
|
+
async function saveFileContent(
|
|
311
|
+
relativePath: string,
|
|
312
|
+
content: string
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
if (content.length > MAX_CONFIG_FILE_BYTES) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Content exceeds max size (${MAX_CONFIG_FILE_BYTES} bytes); refuse to write`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
const fullPath = resolveConfigFilePath(relativePath);
|
|
320
|
+
await Bun.write(fullPath, content);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── Main ConfigEditor View ──────────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
export function ConfigEditor() {
|
|
326
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
327
|
+
const isActive = activeView === "config-editor";
|
|
328
|
+
|
|
329
|
+
// ── State ──────────────────────────────────────────────────────────────
|
|
330
|
+
const [fileContents, setFileContents] = useState<Map<string, string>>(
|
|
331
|
+
new Map()
|
|
332
|
+
);
|
|
333
|
+
const [originalContents, setOriginalContents] = useState<Map<string, string>>(
|
|
334
|
+
new Map()
|
|
335
|
+
);
|
|
336
|
+
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
337
|
+
const [unsavedPaths, setUnsavedPaths] = useState<Set<string>>(new Set());
|
|
338
|
+
const [syntaxErrors, setSyntaxErrors] = useState<SyntaxErrorEntry[]>([]);
|
|
339
|
+
const [activePane, setActivePane] = useState<ActivePane>("tree");
|
|
340
|
+
const [focusedTreeIdx, setFocusedTreeIdx] = useState(0);
|
|
341
|
+
const [editorScrollOffset, setEditorScrollOffset] = useState(0);
|
|
342
|
+
const [statusMessage, setStatusMessage] = useState<string>("");
|
|
343
|
+
const [validationError, setValidationError] = useState<string | null>(null);
|
|
344
|
+
const [undoStack, setUndoStack] = useState<string[]>([]);
|
|
345
|
+
const [redoStack, setRedoStack] = useState<string[]>([]);
|
|
346
|
+
|
|
347
|
+
// ── Derived ────────────────────────────────────────────────────────────
|
|
348
|
+
const fileTree = useMemo(() => CONFIG_TREE_BLUEPRINT, []);
|
|
349
|
+
const flatFiles = useMemo(() => {
|
|
350
|
+
const files: FileNode[] = [];
|
|
351
|
+
function walk(nodes: FileNode[]) {
|
|
352
|
+
for (const n of nodes) {
|
|
353
|
+
if (n.type === "file") files.push(n);
|
|
354
|
+
if (n.children) walk(n.children);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
walk(fileTree);
|
|
358
|
+
return files;
|
|
359
|
+
}, [fileTree]);
|
|
360
|
+
|
|
361
|
+
const currentContent = selectedFile
|
|
362
|
+
? (fileContents.get(selectedFile) ?? "")
|
|
363
|
+
: "";
|
|
364
|
+
const fileType = selectedFile ? detectFileType(selectedFile) : "unknown";
|
|
365
|
+
const unsavedCount = unsavedPaths.size;
|
|
366
|
+
|
|
367
|
+
const safeFocusedIdx = Math.max(
|
|
368
|
+
0,
|
|
369
|
+
Math.min(focusedTreeIdx, flatFiles.length - 1)
|
|
370
|
+
);
|
|
371
|
+
const focusedFilePath = flatFiles[safeFocusedIdx]?.path ?? null;
|
|
372
|
+
|
|
373
|
+
// ── Callbacks ──────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
const selectFile = useCallback(
|
|
376
|
+
async (path: string) => {
|
|
377
|
+
if (fileContents.has(path)) {
|
|
378
|
+
// Already loaded — just switch
|
|
379
|
+
setSelectedFile(path);
|
|
380
|
+
setActivePane("editor");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
// Lazy-load content from disk
|
|
384
|
+
const content = await loadFileContent(path);
|
|
385
|
+
setFileContents((prev) => new Map(prev).set(path, content));
|
|
386
|
+
setOriginalContents((prev) => new Map(prev).set(path, content));
|
|
387
|
+
setSelectedFile(path);
|
|
388
|
+
setActivePane("editor");
|
|
389
|
+
setSyntaxErrors([]);
|
|
390
|
+
setEditorScrollOffset(0);
|
|
391
|
+
setValidationError(null);
|
|
392
|
+
setStatusMessage(`Loaded: ${path}`);
|
|
393
|
+
},
|
|
394
|
+
[fileContents]
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
const prevContentRef = useRef(currentContent);
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (!selectedFile || !currentContent) return;
|
|
400
|
+
if (
|
|
401
|
+
prevContentRef.current !== currentContent &&
|
|
402
|
+
prevContentRef.current !== ""
|
|
403
|
+
) {
|
|
404
|
+
setUndoStack((stack) => {
|
|
405
|
+
const next = [...stack, prevContentRef.current];
|
|
406
|
+
return next.slice(-50);
|
|
407
|
+
});
|
|
408
|
+
setRedoStack([]);
|
|
409
|
+
}
|
|
410
|
+
prevContentRef.current = currentContent;
|
|
411
|
+
}, [currentContent, selectedFile]);
|
|
412
|
+
|
|
413
|
+
const handleSave = useCallback(async () => {
|
|
414
|
+
if (!selectedFile) return;
|
|
415
|
+
try {
|
|
416
|
+
// Fail-closed: never write content with local syntax errors.
|
|
417
|
+
const localErrors = validateSyntax(currentContent, fileType);
|
|
418
|
+
setSyntaxErrors(localErrors);
|
|
419
|
+
if (localErrors.length > 0) {
|
|
420
|
+
const first = localErrors[0]!;
|
|
421
|
+
const errMsg = sanitizeConfigStatus(
|
|
422
|
+
`Save blocked: ${localErrors.length} syntax error(s) — Ln ${first.line}: ${first.message}`
|
|
423
|
+
);
|
|
424
|
+
setValidationError(errMsg);
|
|
425
|
+
setStatusMessage(errMsg);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await saveFileContent(selectedFile, currentContent);
|
|
430
|
+
setOriginalContents((prev) =>
|
|
431
|
+
new Map(prev).set(selectedFile, currentContent)
|
|
432
|
+
);
|
|
433
|
+
setUnsavedPaths((prev) => {
|
|
434
|
+
const next = new Set(prev);
|
|
435
|
+
next.delete(selectedFile);
|
|
436
|
+
return next;
|
|
437
|
+
});
|
|
438
|
+
setValidationError(null);
|
|
439
|
+
setStatusMessage(`Saved: ${selectedFile}`);
|
|
440
|
+
|
|
441
|
+
// Project-level validation (CLI). Failure is reported but disk write already committed.
|
|
442
|
+
const result = await cliBridge.configValidate();
|
|
443
|
+
if (!result.success) {
|
|
444
|
+
const errMsg = sanitizeConfigStatus(
|
|
445
|
+
result.stderr || result.stdout || "Unknown validation error"
|
|
446
|
+
);
|
|
447
|
+
setValidationError(errMsg);
|
|
448
|
+
setStatusMessage(`Saved — project validation failed: ${errMsg}`);
|
|
449
|
+
} else {
|
|
450
|
+
setValidationError(null);
|
|
451
|
+
setStatusMessage("Saved — Config valid");
|
|
452
|
+
}
|
|
453
|
+
} catch (e) {
|
|
454
|
+
const msg = sanitizeConfigStatus(
|
|
455
|
+
e instanceof Error ? e.message : String(e)
|
|
456
|
+
);
|
|
457
|
+
setStatusMessage(`Failed to save: ${msg}`);
|
|
458
|
+
}
|
|
459
|
+
}, [selectedFile, currentContent, fileType]);
|
|
460
|
+
|
|
461
|
+
const handleValidate = useCallback(async () => {
|
|
462
|
+
if (!selectedFile) return;
|
|
463
|
+
const errors = validateSyntax(currentContent, fileType);
|
|
464
|
+
setSyntaxErrors(errors);
|
|
465
|
+
setValidationError(null);
|
|
466
|
+
if (errors.length > 0) {
|
|
467
|
+
const first = errors[0]!;
|
|
468
|
+
setStatusMessage(
|
|
469
|
+
sanitizeConfigStatus(
|
|
470
|
+
`Found ${errors.length} syntax error${errors.length > 1 ? "s" : ""} — Ln ${first.line}: ${first.message}`
|
|
471
|
+
)
|
|
472
|
+
);
|
|
473
|
+
// Still run CLI validate for project-wide signal, but local failures take precedence.
|
|
474
|
+
}
|
|
475
|
+
const result = await cliBridge.configValidate();
|
|
476
|
+
if (!result.success) {
|
|
477
|
+
const errMsg = sanitizeConfigStatus(
|
|
478
|
+
result.stderr || result.stdout || "Unknown validation error"
|
|
479
|
+
);
|
|
480
|
+
setValidationError(errMsg);
|
|
481
|
+
setStatusMessage(`Validation failed: ${errMsg}`);
|
|
482
|
+
} else if (errors.length === 0) {
|
|
483
|
+
setValidationError(null);
|
|
484
|
+
setStatusMessage("Validation passed — Config is valid");
|
|
485
|
+
}
|
|
486
|
+
}, [selectedFile, currentContent, fileType]);
|
|
487
|
+
|
|
488
|
+
const handleDiff = useCallback(() => {
|
|
489
|
+
// Basic diff: show original vs current line counts
|
|
490
|
+
const original = selectedFile
|
|
491
|
+
? (originalContents.get(selectedFile) ?? "")
|
|
492
|
+
: "";
|
|
493
|
+
const origLines = original.split("\n").length;
|
|
494
|
+
const currLines = currentContent.split("\n").length;
|
|
495
|
+
const diff = currLines - origLines;
|
|
496
|
+
if (diff === 0) {
|
|
497
|
+
setStatusMessage(`No changes: ${origLines} lines unchanged`);
|
|
498
|
+
} else {
|
|
499
|
+
setStatusMessage(
|
|
500
|
+
`Diff: ${origLines} → ${currLines} lines (${diff > 0 ? "+" : ""}${diff})`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}, [selectedFile, originalContents, currentContent]);
|
|
504
|
+
|
|
505
|
+
const handleFormat = useCallback(() => {
|
|
506
|
+
if (!selectedFile) return;
|
|
507
|
+
const formatted = formatContent(currentContent, fileType);
|
|
508
|
+
setFileContents((prev) => new Map(prev).set(selectedFile, formatted));
|
|
509
|
+
// Mark as unsaved only if content actually changed
|
|
510
|
+
if (formatted !== currentContent) {
|
|
511
|
+
setUnsavedPaths((prev) => new Set(prev).add(selectedFile));
|
|
512
|
+
}
|
|
513
|
+
setStatusMessage(`Formatted: ${selectedFile}`);
|
|
514
|
+
}, [selectedFile, currentContent, fileType]);
|
|
515
|
+
|
|
516
|
+
const handleUndo = useCallback(() => {
|
|
517
|
+
if (undoStack.length === 0 || !selectedFile) return;
|
|
518
|
+
const prev = undoStack[undoStack.length - 1];
|
|
519
|
+
setUndoStack((s) => s.slice(0, -1));
|
|
520
|
+
setRedoStack((s) => [...s, currentContent]);
|
|
521
|
+
setFileContents((contents) => new Map(contents).set(selectedFile!, prev));
|
|
522
|
+
setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
|
|
523
|
+
setStatusMessage("Undo");
|
|
524
|
+
}, [undoStack, currentContent, selectedFile]);
|
|
525
|
+
|
|
526
|
+
const handleRedo = useCallback(() => {
|
|
527
|
+
if (redoStack.length === 0 || !selectedFile) return;
|
|
528
|
+
const next = redoStack[redoStack.length - 1];
|
|
529
|
+
setRedoStack((s) => s.slice(0, -1));
|
|
530
|
+
setUndoStack((s) => [...s, currentContent]);
|
|
531
|
+
setFileContents((prev) => new Map(prev).set(selectedFile!, next));
|
|
532
|
+
setUnsavedPaths((paths) => new Set(paths).add(selectedFile!));
|
|
533
|
+
setStatusMessage("Redo");
|
|
534
|
+
}, [redoStack, currentContent, selectedFile]);
|
|
535
|
+
|
|
536
|
+
const [findMode, setFindMode] = useState(false);
|
|
537
|
+
const [findQuery, setFindQuery] = useState("");
|
|
538
|
+
|
|
539
|
+
const handleFind = useCallback(() => {
|
|
540
|
+
setFindMode((p) => !p);
|
|
541
|
+
if (!findMode) {
|
|
542
|
+
setFindQuery("");
|
|
543
|
+
setStatusMessage("Find: type to search, Esc to close");
|
|
544
|
+
} else {
|
|
545
|
+
setStatusMessage("");
|
|
546
|
+
}
|
|
547
|
+
}, [findMode]);
|
|
548
|
+
|
|
549
|
+
// ── Keyboard (only when this view is active) ───────────────────────────
|
|
550
|
+
|
|
551
|
+
useKeyboard((key) => {
|
|
552
|
+
if (!isActive) return;
|
|
553
|
+
if (key.ctrl && key.name === "z") {
|
|
554
|
+
handleUndo();
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (key.ctrl && key.name === "y") {
|
|
558
|
+
handleRedo();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (key.ctrl && key.name === "f") {
|
|
562
|
+
handleFind();
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (findMode) {
|
|
567
|
+
if (key.name === "escape") {
|
|
568
|
+
setFindMode(false);
|
|
569
|
+
setStatusMessage("");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (key.name === "backspace" || key.name === "delete") {
|
|
573
|
+
setFindQuery((q) => q.slice(0, -1));
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (key.name === "return") {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
|
|
580
|
+
setFindQuery((q) => q + key.sequence);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (key.name === "tab") {
|
|
587
|
+
setActivePane((p) => (p === "tree" ? "editor" : "tree"));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (activePane === "tree") {
|
|
591
|
+
switch (key.name) {
|
|
592
|
+
case "up":
|
|
593
|
+
setFocusedTreeIdx((i) => Math.max(0, i - 1));
|
|
594
|
+
break;
|
|
595
|
+
case "down":
|
|
596
|
+
setFocusedTreeIdx((i) => Math.min(flatFiles.length - 1, i + 1));
|
|
597
|
+
break;
|
|
598
|
+
case "enter":
|
|
599
|
+
if (focusedFilePath) selectFile(focusedFilePath);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (activePane === "editor") {
|
|
605
|
+
switch (key.name) {
|
|
606
|
+
case "up":
|
|
607
|
+
setEditorScrollOffset((o) => Math.max(0, o - 1));
|
|
608
|
+
break;
|
|
609
|
+
case "down":
|
|
610
|
+
setEditorScrollOffset((o) => o + 1);
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (key.ctrl && key.name === "s" && selectedFile) {
|
|
616
|
+
handleSave();
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// ── Render ─────────────────────────────────────────────────────────────
|
|
621
|
+
|
|
622
|
+
return (
|
|
623
|
+
<ErrorBoundary viewName="Config Editor">
|
|
624
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={0}>
|
|
625
|
+
<ViewHeader
|
|
626
|
+
title="CONFIG VIEWER"
|
|
627
|
+
meta={
|
|
628
|
+
<box flexDirection="row" gap={1} alignItems="center">
|
|
629
|
+
<text fg={Colors.muted} dim>
|
|
630
|
+
format/save only · free-text edit via external editor
|
|
631
|
+
</text>
|
|
632
|
+
{statusMessage ? (
|
|
633
|
+
<text fg={Colors.info} dim>
|
|
634
|
+
· {statusMessage}
|
|
635
|
+
</text>
|
|
636
|
+
) : null}
|
|
637
|
+
</box>
|
|
638
|
+
}
|
|
639
|
+
/>
|
|
640
|
+
|
|
641
|
+
{/* Main split pane: FileTree | CodeEditor */}
|
|
642
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
643
|
+
{/* Left: FileTree */}
|
|
644
|
+
<box width={28} flexDirection="column">
|
|
645
|
+
<FileTree
|
|
646
|
+
nodes={fileTree}
|
|
647
|
+
selectedPath={selectedFile}
|
|
648
|
+
unsavedPaths={unsavedPaths}
|
|
649
|
+
focusedPath={activePane === "tree" ? focusedFilePath : null}
|
|
650
|
+
onSelectFile={selectFile}
|
|
651
|
+
/>
|
|
652
|
+
</box>
|
|
653
|
+
|
|
654
|
+
{/* Right: CodeEditor */}
|
|
655
|
+
<box flexGrow={1} flexDirection="column">
|
|
656
|
+
<CodeEditor
|
|
657
|
+
content={currentContent}
|
|
658
|
+
fileType={fileType}
|
|
659
|
+
fileName={selectedFile}
|
|
660
|
+
syntaxErrors={syntaxErrors}
|
|
661
|
+
scrollOffset={editorScrollOffset}
|
|
662
|
+
maxDisplayLines={MAX_EDITOR_DISPLAY_LINES}
|
|
663
|
+
/>
|
|
664
|
+
</box>
|
|
665
|
+
</box>
|
|
666
|
+
|
|
667
|
+
{/* Validation status */}
|
|
668
|
+
{validationError && (
|
|
669
|
+
<box paddingLeft={1} paddingBottom={0} flexDirection="row" gap={1}>
|
|
670
|
+
<text fg={Colors.error} bold>
|
|
671
|
+
⚠
|
|
672
|
+
</text>
|
|
673
|
+
<text fg={Colors.error}>
|
|
674
|
+
Validation: {sanitizeConfigStatus(validationError, 200)}
|
|
675
|
+
</text>
|
|
676
|
+
</box>
|
|
677
|
+
)}
|
|
678
|
+
|
|
679
|
+
{findMode && (
|
|
680
|
+
<box flexDirection="row" gap={1} paddingLeft={1} paddingBottom={0}>
|
|
681
|
+
<text fg={Colors.accent} bold>
|
|
682
|
+
Find:
|
|
683
|
+
</text>
|
|
684
|
+
<text fg={Colors.foreground}>{findQuery}</text>
|
|
685
|
+
<text fg={Colors.muted} dim>
|
|
686
|
+
{findQuery
|
|
687
|
+
? ` (${currentContent.toLowerCase().split(findQuery.toLowerCase()).length - 1} matches)`
|
|
688
|
+
: ""}
|
|
689
|
+
</text>
|
|
690
|
+
</box>
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
{/* Footer: ActionBar */}
|
|
694
|
+
<text fg={Colors.border} dim>
|
|
695
|
+
{"─".repeat(80)}
|
|
696
|
+
</text>
|
|
697
|
+
<ActionBar
|
|
698
|
+
unsavedCount={unsavedCount}
|
|
699
|
+
hasSelectedFile={selectedFile !== null}
|
|
700
|
+
hasErrors={syntaxErrors.length > 0}
|
|
701
|
+
onSave={handleSave}
|
|
702
|
+
onValidate={handleValidate}
|
|
703
|
+
onDiff={handleDiff}
|
|
704
|
+
onFormat={handleFormat}
|
|
705
|
+
/>
|
|
706
|
+
</box>
|
|
707
|
+
</ErrorBoundary>
|
|
708
|
+
);
|
|
709
|
+
}
|