@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,102 @@
|
|
|
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
|
+
* ActionBar Component — Footer bar with action buttons
|
|
9
|
+
* and unsaved changes counter.
|
|
10
|
+
*/
|
|
11
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
12
|
+
|
|
13
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
interface ActionBarProps {
|
|
16
|
+
unsavedCount: number;
|
|
17
|
+
hasSelectedFile: boolean;
|
|
18
|
+
hasErrors: boolean;
|
|
19
|
+
onSave: () => void;
|
|
20
|
+
onValidate: () => void;
|
|
21
|
+
onDiff: () => void;
|
|
22
|
+
onFormat: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function ActionBar({
|
|
28
|
+
unsavedCount,
|
|
29
|
+
hasSelectedFile,
|
|
30
|
+
hasErrors,
|
|
31
|
+
onSave,
|
|
32
|
+
onValidate,
|
|
33
|
+
onDiff,
|
|
34
|
+
onFormat,
|
|
35
|
+
}: ActionBarProps) {
|
|
36
|
+
const disabledColor = Colors.dim;
|
|
37
|
+
const enabledColor = Colors.foreground;
|
|
38
|
+
const accentColor = Colors.accent;
|
|
39
|
+
// Fail-closed UX: cannot click Save when local syntax errors exist.
|
|
40
|
+
const canSave = hasSelectedFile && unsavedCount > 0 && !hasErrors;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<box
|
|
44
|
+
flexDirection="row"
|
|
45
|
+
justifyContent="space-between"
|
|
46
|
+
paddingTop={0}
|
|
47
|
+
paddingBottom={0}
|
|
48
|
+
paddingLeft={1}
|
|
49
|
+
paddingRight={1}
|
|
50
|
+
>
|
|
51
|
+
{/* Action buttons */}
|
|
52
|
+
<box flexDirection="row" gap={2}>
|
|
53
|
+
<text
|
|
54
|
+
fg={canSave ? accentColor : disabledColor}
|
|
55
|
+
bold={canSave}
|
|
56
|
+
dim={!canSave}
|
|
57
|
+
onMouseUp={canSave ? onSave : undefined}
|
|
58
|
+
>
|
|
59
|
+
[Save]
|
|
60
|
+
</text>
|
|
61
|
+
<text
|
|
62
|
+
fg={hasSelectedFile ? accentColor : disabledColor}
|
|
63
|
+
bold={hasSelectedFile}
|
|
64
|
+
dim={!hasSelectedFile}
|
|
65
|
+
onMouseUp={onValidate}
|
|
66
|
+
>
|
|
67
|
+
[Validate]
|
|
68
|
+
</text>
|
|
69
|
+
<text
|
|
70
|
+
fg={
|
|
71
|
+
hasSelectedFile && unsavedCount > 0 ? enabledColor : disabledColor
|
|
72
|
+
}
|
|
73
|
+
dim={!hasSelectedFile || unsavedCount === 0}
|
|
74
|
+
onMouseUp={onDiff}
|
|
75
|
+
>
|
|
76
|
+
[Diff]
|
|
77
|
+
</text>
|
|
78
|
+
<text
|
|
79
|
+
fg={hasSelectedFile ? enabledColor : disabledColor}
|
|
80
|
+
dim={!hasSelectedFile}
|
|
81
|
+
onMouseUp={onFormat}
|
|
82
|
+
>
|
|
83
|
+
[Format]
|
|
84
|
+
</text>
|
|
85
|
+
</box>
|
|
86
|
+
|
|
87
|
+
{/* Unsaved changes counter */}
|
|
88
|
+
<box flexDirection="row" gap={1}>
|
|
89
|
+
{unsavedCount > 0 && (
|
|
90
|
+
<text fg={Colors.warning} bold>
|
|
91
|
+
⚡ {unsavedCount} unsaved change{unsavedCount > 1 ? "s" : ""}
|
|
92
|
+
</text>
|
|
93
|
+
)}
|
|
94
|
+
{unsavedCount === 0 && hasSelectedFile && (
|
|
95
|
+
<text fg={Colors.success} dim>
|
|
96
|
+
✓ Saved
|
|
97
|
+
</text>
|
|
98
|
+
)}
|
|
99
|
+
</box>
|
|
100
|
+
</box>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,597 @@
|
|
|
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
|
+
* CodeEditor Component — Scrollable text editor with line numbers
|
|
9
|
+
* and basic TOML/JSON syntax highlighting via design tokens.
|
|
10
|
+
*/
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { Colors } from "@hoox-sh/hoox-shared";
|
|
13
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
14
|
+
import type { SyntaxErrorEntry, TokenSpan, FileType } from "./types";
|
|
15
|
+
|
|
16
|
+
// ─── Syntax Highlighting — TOML ───────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Simple TOML token types recognized by the highlighter. */
|
|
19
|
+
type TomlTokenType =
|
|
20
|
+
| "comment"
|
|
21
|
+
| "section"
|
|
22
|
+
| "key"
|
|
23
|
+
| "equals"
|
|
24
|
+
| "value"
|
|
25
|
+
| "string"
|
|
26
|
+
| "number"
|
|
27
|
+
| "boolean"
|
|
28
|
+
| "whitespace"
|
|
29
|
+
| "error";
|
|
30
|
+
|
|
31
|
+
/** Color map for TOML token types. */
|
|
32
|
+
const TOML_COLORS: Record<TomlTokenType, { fg: string; bold: boolean }> = {
|
|
33
|
+
comment: { fg: Colors.muted, bold: false },
|
|
34
|
+
section: { fg: Colors.accent, bold: true },
|
|
35
|
+
key: { fg: Colors.accent, bold: false },
|
|
36
|
+
equals: { fg: Colors.foreground, bold: false },
|
|
37
|
+
value: { fg: Colors.foreground, bold: false },
|
|
38
|
+
string: { fg: Colors.info, bold: false },
|
|
39
|
+
number: { fg: Colors.foreground, bold: false },
|
|
40
|
+
boolean: { fg: Colors.info, bold: false },
|
|
41
|
+
whitespace: { fg: Colors.foreground, bold: false },
|
|
42
|
+
error: { fg: Colors.error, bold: true },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tokenize a single line of TOML text into colored spans.
|
|
47
|
+
*
|
|
48
|
+
* Priority order:
|
|
49
|
+
* 1. Full-line comment (# at start or after whitespace)
|
|
50
|
+
* 2. Section header ([...])
|
|
51
|
+
* 3. Inline comment (trailing #)
|
|
52
|
+
* 4. key = value with string/number/boolean detection
|
|
53
|
+
*/
|
|
54
|
+
export function tokenizeTomlLine(line: string): TokenSpan[] {
|
|
55
|
+
const spans: TokenSpan[] = [];
|
|
56
|
+
const trimmed = line.trimStart();
|
|
57
|
+
const leadingWsLen = line.length - trimmed.length;
|
|
58
|
+
|
|
59
|
+
// Leading whitespace
|
|
60
|
+
if (leadingWsLen > 0) {
|
|
61
|
+
spans.push({
|
|
62
|
+
text: line.slice(0, leadingWsLen),
|
|
63
|
+
color: TOML_COLORS.whitespace.fg,
|
|
64
|
+
bold: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (trimmed.length === 0) return spans;
|
|
69
|
+
|
|
70
|
+
// Full-line comment
|
|
71
|
+
if (trimmed.startsWith("#")) {
|
|
72
|
+
spans.push({ text: trimmed, color: TOML_COLORS.comment.fg, bold: false });
|
|
73
|
+
return spans;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Section header [section.name]
|
|
77
|
+
if (trimmed.startsWith("[")) {
|
|
78
|
+
const end = trimmed.indexOf("]");
|
|
79
|
+
if (end !== -1) {
|
|
80
|
+
spans.push({
|
|
81
|
+
text: trimmed.slice(0, end + 1),
|
|
82
|
+
color: TOML_COLORS.section.fg,
|
|
83
|
+
bold: true,
|
|
84
|
+
});
|
|
85
|
+
const rest = trimmed.slice(end + 1);
|
|
86
|
+
if (rest.length > 0) {
|
|
87
|
+
if (rest.trimStart().startsWith("#")) {
|
|
88
|
+
spans.push({
|
|
89
|
+
text: rest,
|
|
90
|
+
color: TOML_COLORS.comment.fg,
|
|
91
|
+
bold: false,
|
|
92
|
+
});
|
|
93
|
+
} else {
|
|
94
|
+
spans.push({
|
|
95
|
+
text: rest,
|
|
96
|
+
color: TOML_COLORS.whitespace.fg,
|
|
97
|
+
bold: false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return spans;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// key = value
|
|
106
|
+
const eqIdx = trimmed.indexOf("=");
|
|
107
|
+
if (eqIdx !== -1) {
|
|
108
|
+
const keyPart = trimmed.slice(0, eqIdx).trimEnd();
|
|
109
|
+
// Check for trailing # comment after =
|
|
110
|
+
const postEq = trimmed.slice(eqIdx + 1);
|
|
111
|
+
const commentIdx = findCommentIndex(postEq);
|
|
112
|
+
const valuePart =
|
|
113
|
+
commentIdx !== -1 ? postEq.slice(0, commentIdx).trim() : postEq.trim();
|
|
114
|
+
const commentPart = commentIdx !== -1 ? postEq.slice(commentIdx) : "";
|
|
115
|
+
|
|
116
|
+
// Key
|
|
117
|
+
spans.push({ text: keyPart, color: TOML_COLORS.key.fg, bold: false });
|
|
118
|
+
// Equals
|
|
119
|
+
spans.push({ text: " = ", color: TOML_COLORS.equals.fg, bold: false });
|
|
120
|
+
|
|
121
|
+
// Value (detect type)
|
|
122
|
+
if (valuePart.startsWith('"') || valuePart.startsWith("'")) {
|
|
123
|
+
spans.push({
|
|
124
|
+
text: valuePart,
|
|
125
|
+
color: TOML_COLORS.string.fg,
|
|
126
|
+
bold: false,
|
|
127
|
+
});
|
|
128
|
+
} else if (valuePart === "true" || valuePart === "false") {
|
|
129
|
+
spans.push({
|
|
130
|
+
text: valuePart,
|
|
131
|
+
color: TOML_COLORS.boolean.fg,
|
|
132
|
+
bold: false,
|
|
133
|
+
});
|
|
134
|
+
} else if (/^-?\d+(\.\d+)?(e[+-]?\d+)?$/.test(valuePart)) {
|
|
135
|
+
spans.push({
|
|
136
|
+
text: valuePart,
|
|
137
|
+
color: TOML_COLORS.number.fg,
|
|
138
|
+
bold: false,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
spans.push({ text: valuePart, color: TOML_COLORS.value.fg, bold: false });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (commentPart) {
|
|
145
|
+
spans.push({
|
|
146
|
+
text: commentPart,
|
|
147
|
+
color: TOML_COLORS.comment.fg,
|
|
148
|
+
bold: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return spans;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fallback: plain text
|
|
155
|
+
spans.push({ text: trimmed, color: TOML_COLORS.value.fg, bold: false });
|
|
156
|
+
return spans;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ─── Syntax Highlighting — JSON ───────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
/** Color map for JSON token types. */
|
|
162
|
+
const JSON_COLORS: Record<string, { fg: string; bold: boolean }> = {
|
|
163
|
+
brace: { fg: Colors.foreground, bold: false },
|
|
164
|
+
key: { fg: Colors.accent, bold: false },
|
|
165
|
+
colon: { fg: Colors.foreground, bold: false },
|
|
166
|
+
string: { fg: Colors.info, bold: false },
|
|
167
|
+
number: { fg: Colors.foreground, bold: false },
|
|
168
|
+
boolean: { fg: Colors.info, bold: false },
|
|
169
|
+
null: { fg: Colors.muted, bold: false },
|
|
170
|
+
comma: { fg: Colors.foreground, bold: false },
|
|
171
|
+
whitespace: { fg: Colors.foreground, bold: false },
|
|
172
|
+
error: { fg: Colors.error, bold: true },
|
|
173
|
+
comment: { fg: Colors.muted, bold: false },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Tokenize a single line of JSON text into colored spans.
|
|
178
|
+
*/
|
|
179
|
+
export function tokenizeJsonLine(line: string): TokenSpan[] {
|
|
180
|
+
const trimmed = line.trimStart();
|
|
181
|
+
const leadingWsLen = line.length - trimmed.length;
|
|
182
|
+
const spans: TokenSpan[] = [];
|
|
183
|
+
|
|
184
|
+
if (leadingWsLen > 0) {
|
|
185
|
+
spans.push({
|
|
186
|
+
text: line.slice(0, leadingWsLen),
|
|
187
|
+
color: JSON_COLORS.whitespace.fg,
|
|
188
|
+
bold: false,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (trimmed.length === 0) return spans;
|
|
193
|
+
|
|
194
|
+
// // or /* comment */ style comments (non-standard JSON, but render them)
|
|
195
|
+
if (
|
|
196
|
+
trimmed.startsWith("//") ||
|
|
197
|
+
trimmed.startsWith("/*") ||
|
|
198
|
+
trimmed.startsWith("#")
|
|
199
|
+
) {
|
|
200
|
+
spans.push({ text: trimmed, color: JSON_COLORS.comment.fg, bold: false });
|
|
201
|
+
return spans;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Braces/brackets at start
|
|
205
|
+
if ("{]}[".includes(trimmed[0])) {
|
|
206
|
+
spans.push({ text: trimmed[0], color: JSON_COLORS.brace.fg, bold: true });
|
|
207
|
+
const rest = tokenizeJsonLine(trimmed.slice(1));
|
|
208
|
+
spans.push(...rest);
|
|
209
|
+
return spans;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Trailing comma/brace
|
|
213
|
+
if (
|
|
214
|
+
trimmed.endsWith(",") ||
|
|
215
|
+
trimmed.endsWith("{") ||
|
|
216
|
+
trimmed.endsWith("}") ||
|
|
217
|
+
trimmed.endsWith("[") ||
|
|
218
|
+
trimmed.endsWith("]")
|
|
219
|
+
) {
|
|
220
|
+
const last = trimmed[trimmed.length - 1];
|
|
221
|
+
const prefix = trimmed.slice(0, -1);
|
|
222
|
+
const prefixSpans = tokenizeJsonLine(prefix);
|
|
223
|
+
spans.push(...prefixSpans);
|
|
224
|
+
const isBrace = "{}[]".includes(last);
|
|
225
|
+
spans.push({
|
|
226
|
+
text: last,
|
|
227
|
+
color: isBrace ? JSON_COLORS.brace.fg : JSON_COLORS.comma.fg,
|
|
228
|
+
bold: isBrace,
|
|
229
|
+
});
|
|
230
|
+
return spans;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// "key": value pattern
|
|
234
|
+
const keyMatch = trimmed.match(/^"([^"\\]|\\.)*"\s*:/);
|
|
235
|
+
if (keyMatch) {
|
|
236
|
+
const keyStr = keyMatch[0];
|
|
237
|
+
spans.push({
|
|
238
|
+
text: keyStr.slice(0, keyStr.indexOf(":")),
|
|
239
|
+
color: JSON_COLORS.key.fg,
|
|
240
|
+
bold: false,
|
|
241
|
+
});
|
|
242
|
+
spans.push({
|
|
243
|
+
text: keyStr.slice(keyStr.indexOf(":")),
|
|
244
|
+
color: JSON_COLORS.colon.fg,
|
|
245
|
+
bold: false,
|
|
246
|
+
});
|
|
247
|
+
const rest = tokenizeJsonLine(trimmed.slice(keyMatch[0].length));
|
|
248
|
+
spans.push(...rest);
|
|
249
|
+
return spans;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// String value
|
|
253
|
+
if (trimmed.startsWith('"')) {
|
|
254
|
+
const endIdx = findStringEnd(trimmed, 0);
|
|
255
|
+
if (endIdx !== -1) {
|
|
256
|
+
spans.push({
|
|
257
|
+
text: trimmed.slice(0, endIdx + 1),
|
|
258
|
+
color: JSON_COLORS.string.fg,
|
|
259
|
+
bold: false,
|
|
260
|
+
});
|
|
261
|
+
const rest = tokenizeJsonLine(trimmed.slice(endIdx + 1));
|
|
262
|
+
spans.push(...rest);
|
|
263
|
+
} else {
|
|
264
|
+
spans.push({ text: trimmed, color: JSON_COLORS.string.fg, bold: false });
|
|
265
|
+
}
|
|
266
|
+
return spans;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Number/boolean/null
|
|
270
|
+
const wordMatch = trimmed.match(
|
|
271
|
+
/^(-?\d+(\.\d+)?([eE][+-]?\d+)?|true|false|null)/
|
|
272
|
+
);
|
|
273
|
+
if (wordMatch) {
|
|
274
|
+
const word = wordMatch[0];
|
|
275
|
+
const color =
|
|
276
|
+
word === "null"
|
|
277
|
+
? JSON_COLORS.null.fg
|
|
278
|
+
: word === "true" || word === "false"
|
|
279
|
+
? JSON_COLORS.boolean.fg
|
|
280
|
+
: JSON_COLORS.number.fg;
|
|
281
|
+
spans.push({ text: word, color, bold: false });
|
|
282
|
+
const rest = tokenizeJsonLine(trimmed.slice(word.length));
|
|
283
|
+
spans.push(...rest);
|
|
284
|
+
return spans;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Fallback
|
|
288
|
+
spans.push({ text: trimmed, color: JSON_COLORS.whitespace.fg, bold: false });
|
|
289
|
+
return spans;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ─── Low-level helpers ───────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
/** Find the start of an inline comment in TOML (# not inside a string). */
|
|
295
|
+
function findCommentIndex(s: string): number {
|
|
296
|
+
let inString: false | "'" | '"' = false;
|
|
297
|
+
for (let i = 0; i < s.length; i++) {
|
|
298
|
+
const ch = s[i];
|
|
299
|
+
if (inString) {
|
|
300
|
+
if (ch === "\\" && i + 1 < s.length) {
|
|
301
|
+
i++;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (ch === inString) {
|
|
305
|
+
inString = false;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
if (ch === '"' || ch === "'") {
|
|
310
|
+
inString = ch as "'" | '"';
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (ch === "#") return i;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return -1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Find the end of a JSON string starting at idx (handles \"). */
|
|
320
|
+
function findStringEnd(s: string, startIdx: number): number {
|
|
321
|
+
const quote = s[startIdx];
|
|
322
|
+
for (let i = startIdx + 1; i < s.length; i++) {
|
|
323
|
+
if (s[i] === "\\" && i + 1 < s.length) {
|
|
324
|
+
i++;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (s[i] === quote) return i;
|
|
328
|
+
}
|
|
329
|
+
return -1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Detect file type from path extension. */
|
|
333
|
+
export function detectFileType(path: string): FileType {
|
|
334
|
+
const name = path.toLowerCase();
|
|
335
|
+
if (name.endsWith(".toml")) return "toml";
|
|
336
|
+
if (name.endsWith(".json")) return "json";
|
|
337
|
+
if (name.endsWith(".env") || name.startsWith(".")) return "env";
|
|
338
|
+
return "unknown";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Tokenize a line based on file type. */
|
|
342
|
+
function tokenizeLine(
|
|
343
|
+
line: string,
|
|
344
|
+
fileType: FileType,
|
|
345
|
+
_lineNum: number,
|
|
346
|
+
_syntaxErrors: SyntaxErrorEntry[]
|
|
347
|
+
): TokenSpan[] {
|
|
348
|
+
if (fileType === "toml" || fileType === "env") {
|
|
349
|
+
return tokenizeTomlLine(line);
|
|
350
|
+
}
|
|
351
|
+
if (fileType === "json") {
|
|
352
|
+
return tokenizeJsonLine(line);
|
|
353
|
+
}
|
|
354
|
+
return [{ text: line, color: Colors.foreground, bold: false }];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─── Syntax Validation ───────────────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Map a smol-toml parser error to our SyntaxErrorEntry shape.
|
|
361
|
+
* Falls back to a generic 1/0 entry if the error is not a TomlError.
|
|
362
|
+
*/
|
|
363
|
+
function tomlErrorToEntry(err: unknown): SyntaxErrorEntry {
|
|
364
|
+
if (err instanceof TomlError) {
|
|
365
|
+
return {
|
|
366
|
+
line: err.line,
|
|
367
|
+
column: err.column,
|
|
368
|
+
message: err.message,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
372
|
+
return {
|
|
373
|
+
line: 1,
|
|
374
|
+
column: 0,
|
|
375
|
+
message: `TOML parser error: ${msg}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Validate TOML or JSON syntax and return an array of errors.
|
|
381
|
+
* For JSON: uses JSON.parse with detailed error extraction.
|
|
382
|
+
* For TOML: uses smol-toml (full TOML 1.1.0 parser) for accurate
|
|
383
|
+
* line/column errors. Multi-line strings, arrays, inline tables
|
|
384
|
+
* and every other TOML construct are handled by the parser — no
|
|
385
|
+
* hand-rolled bracket counting.
|
|
386
|
+
*/
|
|
387
|
+
export function validateSyntax(
|
|
388
|
+
content: string,
|
|
389
|
+
fileType: FileType
|
|
390
|
+
): SyntaxErrorEntry[] {
|
|
391
|
+
const errors: SyntaxErrorEntry[] = [];
|
|
392
|
+
|
|
393
|
+
if (fileType === "json") {
|
|
394
|
+
try {
|
|
395
|
+
JSON.parse(content);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
398
|
+
// Try to extract line/column from parser error
|
|
399
|
+
const lineMatch = msg.match(/line\s+(\d+)/i);
|
|
400
|
+
const colMatch =
|
|
401
|
+
msg.match(/column\s+(\d+)/i) ?? msg.match(/position\s+(\d+)/i);
|
|
402
|
+
errors.push({
|
|
403
|
+
line: lineMatch ? parseInt(lineMatch[1]) : 1,
|
|
404
|
+
column: colMatch ? parseInt(colMatch[1]) : 0,
|
|
405
|
+
message: msg,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (fileType === "toml") {
|
|
411
|
+
try {
|
|
412
|
+
// smol-toml is a full TOML 1.1.0 parser — supports multi-line
|
|
413
|
+
// strings ("""..."""), literal strings, arrays, inline tables,
|
|
414
|
+
// arrays of tables, dotted keys, dates/times, etc. Throws
|
|
415
|
+
// TomlError on the first invalid construct with 1-based
|
|
416
|
+
// line/column and a descriptive message.
|
|
417
|
+
parseToml(content);
|
|
418
|
+
} catch (e) {
|
|
419
|
+
errors.push(tomlErrorToEntry(e));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return errors;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Format TOML content (basic: normalize whitespace, sort sections). */
|
|
427
|
+
export function formatContent(content: string, fileType: FileType): string {
|
|
428
|
+
if (fileType === "json") {
|
|
429
|
+
try {
|
|
430
|
+
const parsed = JSON.parse(content);
|
|
431
|
+
return JSON.stringify(parsed, null, 2) + "\n";
|
|
432
|
+
} catch {
|
|
433
|
+
// If invalid JSON, return as-is
|
|
434
|
+
return content;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// For TOML: trim trailing whitespace, ensure single trailing newline
|
|
438
|
+
return (
|
|
439
|
+
content
|
|
440
|
+
.split("\n")
|
|
441
|
+
.map((l) => l.trimEnd())
|
|
442
|
+
.join("\n")
|
|
443
|
+
.trimEnd() + "\n"
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ─── CodeEditor Component ────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
interface CodeEditorProps {
|
|
450
|
+
content: string;
|
|
451
|
+
fileType: FileType;
|
|
452
|
+
fileName: string | null;
|
|
453
|
+
syntaxErrors: SyntaxErrorEntry[];
|
|
454
|
+
scrollOffset: number;
|
|
455
|
+
/** Cap rendered lines (perf); remaining lines summarized at bottom. */
|
|
456
|
+
maxDisplayLines?: number;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function CodeEditor({
|
|
460
|
+
content,
|
|
461
|
+
fileType,
|
|
462
|
+
fileName,
|
|
463
|
+
syntaxErrors,
|
|
464
|
+
scrollOffset: _scrollOffset,
|
|
465
|
+
maxDisplayLines = 5_000,
|
|
466
|
+
}: CodeEditorProps) {
|
|
467
|
+
const lines = useMemo(() => content.split("\n"), [content]);
|
|
468
|
+
const truncated = lines.length > maxDisplayLines;
|
|
469
|
+
const visibleLines = useMemo(
|
|
470
|
+
() => (truncated ? lines.slice(0, maxDisplayLines) : lines),
|
|
471
|
+
[lines, maxDisplayLines, truncated]
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Build a set of error line numbers for inline highlighting
|
|
475
|
+
const errorLineSet = useMemo(() => {
|
|
476
|
+
const set = new Set<number>();
|
|
477
|
+
for (const err of syntaxErrors) set.add(err.line);
|
|
478
|
+
return set;
|
|
479
|
+
}, [syntaxErrors]);
|
|
480
|
+
|
|
481
|
+
// Compute visible lines based on scroll offset
|
|
482
|
+
const maxLineWidth = String(lines.length).length;
|
|
483
|
+
const paddingLeft = maxLineWidth + 3; // " N │ "
|
|
484
|
+
|
|
485
|
+
return (
|
|
486
|
+
<box
|
|
487
|
+
flexDirection="column"
|
|
488
|
+
flexGrow={1}
|
|
489
|
+
border={true}
|
|
490
|
+
borderStyle="single"
|
|
491
|
+
borderColor={Colors.border}
|
|
492
|
+
paddingX={1}
|
|
493
|
+
paddingY={0}
|
|
494
|
+
>
|
|
495
|
+
{/* Header — file name (view-first: free typing not supported) */}
|
|
496
|
+
<box paddingLeft={1} paddingRight={1} paddingTop={0} paddingBottom={0}>
|
|
497
|
+
<text fg={Colors.foreground}>
|
|
498
|
+
{fileName
|
|
499
|
+
? `Viewing: ${fileName}${truncated ? ` (${lines.length} lines, showing first ${maxDisplayLines})` : ""}`
|
|
500
|
+
: "No file selected"}
|
|
501
|
+
</text>
|
|
502
|
+
</box>
|
|
503
|
+
|
|
504
|
+
<text fg={Colors.border} dim>
|
|
505
|
+
{"─".repeat(60)}
|
|
506
|
+
</text>
|
|
507
|
+
|
|
508
|
+
{/* Editor content with line numbers */}
|
|
509
|
+
{fileName ? (
|
|
510
|
+
<scrollbox width="100%" flexGrow={1} border={false}>
|
|
511
|
+
{visibleLines.length === 0 ? (
|
|
512
|
+
<text fg={Colors.muted} dim>
|
|
513
|
+
{" ".repeat(paddingLeft)}Empty file
|
|
514
|
+
</text>
|
|
515
|
+
) : (
|
|
516
|
+
<>
|
|
517
|
+
{visibleLines.map((line, idx) => {
|
|
518
|
+
const lineNum = idx + 1;
|
|
519
|
+
const lineNumStr = String(lineNum).padStart(maxLineWidth);
|
|
520
|
+
const hasError = errorLineSet.has(lineNum);
|
|
521
|
+
const tokens = tokenizeLine(line, fileType, lineNum, []);
|
|
522
|
+
|
|
523
|
+
return (
|
|
524
|
+
<box key={idx} flexDirection="row" gap={0}>
|
|
525
|
+
{/* Line number */}
|
|
526
|
+
<text
|
|
527
|
+
fg={hasError ? Colors.error : Colors.muted}
|
|
528
|
+
dim={!hasError}
|
|
529
|
+
>
|
|
530
|
+
{lineNumStr}
|
|
531
|
+
</text>
|
|
532
|
+
{/* Separator */}
|
|
533
|
+
<text fg={Colors.border} dim>
|
|
534
|
+
{" │ "}
|
|
535
|
+
</text>
|
|
536
|
+
{/* Tokenized line content */}
|
|
537
|
+
<text fg={Colors.foreground}>
|
|
538
|
+
{tokens.map((span, si) => (
|
|
539
|
+
<span key={si} fg={span.color}>
|
|
540
|
+
{span.text}
|
|
541
|
+
</span>
|
|
542
|
+
))}
|
|
543
|
+
</text>
|
|
544
|
+
{/* Error indicator */}
|
|
545
|
+
{hasError && (
|
|
546
|
+
<text fg={Colors.error} bold>
|
|
547
|
+
{" ←"}
|
|
548
|
+
</text>
|
|
549
|
+
)}
|
|
550
|
+
</box>
|
|
551
|
+
);
|
|
552
|
+
})}
|
|
553
|
+
{truncated && (
|
|
554
|
+
<text fg={Colors.warning} dim>
|
|
555
|
+
… {lines.length - maxDisplayLines} more lines not rendered
|
|
556
|
+
(edit externally for full file)
|
|
557
|
+
</text>
|
|
558
|
+
)}
|
|
559
|
+
</>
|
|
560
|
+
)}
|
|
561
|
+
</scrollbox>
|
|
562
|
+
) : (
|
|
563
|
+
<box flexDirection="column" flexGrow={1} padding={2} gap={1}>
|
|
564
|
+
<text fg={Colors.muted} dim>
|
|
565
|
+
Select a file from the tree to view (Format / Save available)
|
|
566
|
+
</text>
|
|
567
|
+
<text fg={Colors.dim} dim>
|
|
568
|
+
↑↓ navigate · Enter select · Tab switch pane · Ctrl+S save
|
|
569
|
+
</text>
|
|
570
|
+
</box>
|
|
571
|
+
)}
|
|
572
|
+
|
|
573
|
+
{/* Syntax error summary (messages scrubbed of secret-like patterns) */}
|
|
574
|
+
{syntaxErrors.length > 0 && (
|
|
575
|
+
<box flexDirection="column" paddingTop={0} paddingLeft={1}>
|
|
576
|
+
<text fg={Colors.error} bold>
|
|
577
|
+
⚠ {syntaxErrors.length} syntax error
|
|
578
|
+
{syntaxErrors.length > 1 ? "s" : ""}:
|
|
579
|
+
</text>
|
|
580
|
+
{syntaxErrors.slice(0, 3).map((err, i) => (
|
|
581
|
+
<text key={i} fg={Colors.error}>
|
|
582
|
+
Ln {err.line}, Col {err.column}:{" "}
|
|
583
|
+
{err.message.length > 120
|
|
584
|
+
? err.message.slice(0, 117) + "…"
|
|
585
|
+
: err.message}
|
|
586
|
+
</text>
|
|
587
|
+
))}
|
|
588
|
+
{syntaxErrors.length > 3 && (
|
|
589
|
+
<text fg={Colors.muted} dim>
|
|
590
|
+
... and {syntaxErrors.length - 3} more
|
|
591
|
+
</text>
|
|
592
|
+
)}
|
|
593
|
+
</box>
|
|
594
|
+
)}
|
|
595
|
+
</box>
|
|
596
|
+
);
|
|
597
|
+
}
|