@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.
Files changed (104) hide show
  1. package/README.md +247 -0
  2. package/bin/hoox-tui.js +35 -0
  3. package/dist/main.js +17594 -0
  4. package/package.json +72 -0
  5. package/src/app.tsx +696 -0
  6. package/src/cli-bridge-test-double.ts +212 -0
  7. package/src/components/layout/layout.test.tsx +205 -0
  8. package/src/components/layout/sidebar.tsx +69 -0
  9. package/src/components/layout/statusbar.test.tsx +423 -0
  10. package/src/components/layout/statusbar.tsx +348 -0
  11. package/src/components/shared/animated-border.tsx +58 -0
  12. package/src/components/shared/chrome.test.tsx +29 -0
  13. package/src/components/shared/command-palette.tsx +361 -0
  14. package/src/components/shared/cool-brackets.tsx +75 -0
  15. package/src/components/shared/crash-screen.tsx +167 -0
  16. package/src/components/shared/error-boundary.tsx +99 -0
  17. package/src/components/shared/keybinding-hint.tsx +37 -0
  18. package/src/components/shared/panel.tsx +61 -0
  19. package/src/components/shared/quit-modal.tsx +65 -0
  20. package/src/components/shared/shared.test.tsx +538 -0
  21. package/src/components/shared/spinner.tsx +85 -0
  22. package/src/components/shared/status-dot.tsx +54 -0
  23. package/src/components/shared/view-header.tsx +53 -0
  24. package/src/components/ui/connection-toasts.test.ts +50 -0
  25. package/src/components/ui/connection-toasts.ts +215 -0
  26. package/src/components/ui/dialog.tsx +281 -0
  27. package/src/components/ui/select.tsx +197 -0
  28. package/src/components/ui/toast.tsx +185 -0
  29. package/src/components/views/ai-chat.test.tsx +81 -0
  30. package/src/components/views/ai-chat.tsx +576 -0
  31. package/src/components/views/config-editor/action-bar.tsx +102 -0
  32. package/src/components/views/config-editor/code-editor.tsx +597 -0
  33. package/src/components/views/config-editor/file-tree.tsx +113 -0
  34. package/src/components/views/config-editor/types.ts +37 -0
  35. package/src/components/views/config-editor.test.tsx +465 -0
  36. package/src/components/views/config-editor.tsx +709 -0
  37. package/src/components/views/dashboard/alerts-panel.tsx +154 -0
  38. package/src/components/views/dashboard/auto-repair-panel.tsx +229 -0
  39. package/src/components/views/dashboard/model-health-section.tsx +276 -0
  40. package/src/components/views/dashboard/pyne-health-section.tsx +112 -0
  41. package/src/components/views/dashboard.test.tsx +347 -0
  42. package/src/components/views/dashboard.tsx +647 -0
  43. package/src/components/views/db-query.test.tsx +115 -0
  44. package/src/components/views/db-query.tsx +849 -0
  45. package/src/components/views/edge-topology.test.tsx +198 -0
  46. package/src/components/views/edge-topology.tsx +572 -0
  47. package/src/components/views/kv-viewer.test.tsx +241 -0
  48. package/src/components/views/kv-viewer.tsx +717 -0
  49. package/src/components/views/logs-viewer.test.tsx +273 -0
  50. package/src/components/views/logs-viewer.tsx +616 -0
  51. package/src/components/views/queue-depth.test.tsx +221 -0
  52. package/src/components/views/queue-depth.tsx +376 -0
  53. package/src/components/views/secrets-viewer.test.tsx +65 -0
  54. package/src/components/views/secrets-viewer.tsx +548 -0
  55. package/src/components/views/service-manager.test.tsx +358 -0
  56. package/src/components/views/service-manager.tsx +946 -0
  57. package/src/components/views/settings.test.tsx +648 -0
  58. package/src/components/views/settings.tsx +1497 -0
  59. package/src/components/views/setup-wizard.test.tsx +297 -0
  60. package/src/components/views/setup-wizard.tsx +1321 -0
  61. package/src/components/views/trade-monitor.test.tsx +592 -0
  62. package/src/components/views/trade-monitor.tsx +745 -0
  63. package/src/components/views/worker-detail.test.tsx +289 -0
  64. package/src/components/views/worker-detail.tsx +676 -0
  65. package/src/components/views/worker-settings.test.tsx +43 -0
  66. package/src/components/views/worker-settings.tsx +563 -0
  67. package/src/components/views/workers-overview.test.tsx +260 -0
  68. package/src/components/views/workers-overview.tsx +631 -0
  69. package/src/hooks/hooks.test.ts +72 -0
  70. package/src/hooks/index.ts +11 -0
  71. package/src/hooks/renderer-ref.ts +22 -0
  72. package/src/hooks/use-keyboard.ts +130 -0
  73. package/src/hooks/use-polling.ts +73 -0
  74. package/src/hooks/use-service-data.ts +18 -0
  75. package/src/main.tsx +117 -0
  76. package/src/network-test-double.ts +133 -0
  77. package/src/opentui-augment.d.ts +45 -0
  78. package/src/opentui-jsx.d.ts +70 -0
  79. package/src/services/cli-bridge/index.ts +1662 -0
  80. package/src/services/cli-bridge/standalone.ts +315 -0
  81. package/src/services/cli-bridge/types.ts +307 -0
  82. package/src/services/cli-bridge.test.ts +406 -0
  83. package/src/services/clipboard.test.ts +57 -0
  84. package/src/services/clipboard.ts +180 -0
  85. package/src/services/dashboard-settings-loader.ts +31 -0
  86. package/src/services/dev-log.test.ts +168 -0
  87. package/src/services/dev-log.ts +194 -0
  88. package/src/services/hoox-path-service.test.ts +183 -0
  89. package/src/services/hoox-path-service.ts +185 -0
  90. package/src/services/tui-connection.test.ts +212 -0
  91. package/src/services/tui-connection.ts +236 -0
  92. package/src/services/tui-storage.test.ts +92 -0
  93. package/src/services/tui-storage.ts +86 -0
  94. package/src/stores/config-store.test.ts +360 -0
  95. package/src/stores/service-store.test.ts +1026 -0
  96. package/src/stores/session-restore.test.ts +134 -0
  97. package/src/stores/ui-store.test.ts +276 -0
  98. package/src/test-setup.ts +265 -0
  99. package/src/test-utils.ts +179 -0
  100. package/src/types.ts +153 -0
  101. package/src/utils/colors.test.ts +326 -0
  102. package/src/utils/formatters.test.ts +236 -0
  103. package/src/view-registry.test.ts +132 -0
  104. 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
+ }