@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,1497 @@
|
|
|
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
|
+
* Settings View — 4-column layout for user preferences.
|
|
9
|
+
*
|
|
10
|
+
* Columns:
|
|
11
|
+
* 1. ThemePanel — dark-only note, refresh rate, default view, reset + connection readout
|
|
12
|
+
* 2. NotificationsPanel — alert/trade/debug/system checkboxes, sound toggle
|
|
13
|
+
* 3. KeyboardPanel — read-only shortcut reference table
|
|
14
|
+
* 4. DataPanel — cache/export/import actions + About info
|
|
15
|
+
*
|
|
16
|
+
* Reads/writes the Zustand config store (@hoox-sh/hoox-shared/stores/config-store).
|
|
17
|
+
* Keyboard: Tab cycles panels, ↑↓ navigates items within panel,
|
|
18
|
+
* Space toggles, ←→ cycles selects, Enter activates buttons.
|
|
19
|
+
*
|
|
20
|
+
* Follows Pattern 1 (View Composition) and Pattern 2 (Store Subscription).
|
|
21
|
+
*/
|
|
22
|
+
import { useMemo, useState, useCallback, useEffect } from "react";
|
|
23
|
+
import { useKeyboard } from "@opentui/react";
|
|
24
|
+
import {
|
|
25
|
+
Colors,
|
|
26
|
+
useServiceStore,
|
|
27
|
+
useUIStore,
|
|
28
|
+
useConfigStore,
|
|
29
|
+
readConfigSync,
|
|
30
|
+
} from "@hoox-sh/hoox-shared";
|
|
31
|
+
import { ErrorBoundary } from "../shared/error-boundary";
|
|
32
|
+
import { ViewHeader } from "../shared/view-header";
|
|
33
|
+
import { Panel } from "../shared/panel";
|
|
34
|
+
import { showConfirm } from "../ui/dialog";
|
|
35
|
+
import type { DialogHandle } from "../ui/dialog";
|
|
36
|
+
import { cliBridge as realCliBridge } from "../../services/cli-bridge";
|
|
37
|
+
import { getSettingsConnectionSnapshot } from "../../services/tui-connection";
|
|
38
|
+
import * as path from "path";
|
|
39
|
+
import * as os from "os";
|
|
40
|
+
import type { ViewId, NotificationPreferences } from "@hoox-sh/hoox-shared";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Prefer the process-wide CLI bridge test double when test-setup installed it
|
|
44
|
+
* (`globalThis.__hooxCliBridgeDouble`). Resolved at call time so the double is
|
|
45
|
+
* picked up even if this module evaluated before the preload finished.
|
|
46
|
+
*/
|
|
47
|
+
function cliBridge(): typeof realCliBridge {
|
|
48
|
+
const g = globalThis as unknown as {
|
|
49
|
+
__hooxCliBridgeDouble?: typeof realCliBridge;
|
|
50
|
+
};
|
|
51
|
+
return g.__hooxCliBridgeDouble ?? realCliBridge;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SettingsViewProps {
|
|
55
|
+
/** Dialog handle for destructive action confirmation. */
|
|
56
|
+
dialog?: DialogHandle;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Check Setup Types ────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Local mirror of the CLI's CheckReport shape (`packages/cli/src/commands/check/types.ts`).
|
|
63
|
+
* We re-declare the shape here because that type isn't exported from the
|
|
64
|
+
* shared package — the CLI's CheckReport flows over the wire as JSON, and
|
|
65
|
+
* `cliBridge.checkSetup()` returns it as `unknown`. Validating at the boundary
|
|
66
|
+
* keeps the view decoupled from the CLI's internal type module.
|
|
67
|
+
*/
|
|
68
|
+
interface CheckItem {
|
|
69
|
+
/** Human-readable check name (e.g. "D1 Database", "Wrangler Configs"). */
|
|
70
|
+
name: string;
|
|
71
|
+
/** Whether this check passed (no errors). */
|
|
72
|
+
success: boolean;
|
|
73
|
+
/** Failing conditions — must be addressed before deployment. */
|
|
74
|
+
errors: string[];
|
|
75
|
+
/** Non-blocking issues — should be reviewed. */
|
|
76
|
+
warnings: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface CheckCategoryResult {
|
|
80
|
+
/** Category label displayed in the report. */
|
|
81
|
+
name: string;
|
|
82
|
+
/** Per-check results within this category. */
|
|
83
|
+
checks: CheckItem[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface CheckReport {
|
|
87
|
+
/** True when every check in every category passed (zero errors). */
|
|
88
|
+
success: boolean;
|
|
89
|
+
/** Grouped category results. */
|
|
90
|
+
categories: CheckCategoryResult[];
|
|
91
|
+
/** Aggregated counts across all checks. */
|
|
92
|
+
summary: {
|
|
93
|
+
total: number;
|
|
94
|
+
passed: number;
|
|
95
|
+
failed: number;
|
|
96
|
+
warnings: number;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** UI state for the Check Setup results panel. */
|
|
101
|
+
type CheckSetupState =
|
|
102
|
+
| { kind: "idle" }
|
|
103
|
+
| { kind: "running" }
|
|
104
|
+
| { kind: "error"; message: string }
|
|
105
|
+
| { kind: "report"; report: CheckReport; durationMs: number };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Human-readable explanation + suggested fix for each known check.
|
|
109
|
+
* Used by the results panel to give operators actionable guidance when
|
|
110
|
+
* a check fails. Keys must match the `name` field of CLI's CheckResult.
|
|
111
|
+
*/
|
|
112
|
+
const SUGGESTED_FIXES: Record<string, { what: string; fix: string }> = {
|
|
113
|
+
"wrangler.jsonc validation": {
|
|
114
|
+
what: "Your wrangler.jsonc has invalid syntax or is missing required fields.",
|
|
115
|
+
fix: "Run `hoox init` to regenerate the config, or fix syntax errors manually.",
|
|
116
|
+
},
|
|
117
|
+
"Global config": {
|
|
118
|
+
what: "Required global fields (cloudflare_account_id, etc.) are missing.",
|
|
119
|
+
fix:
|
|
120
|
+
"Set `global.cloudflare_account_id` in wrangler.jsonc, and " +
|
|
121
|
+
"`global.cloudflare_api_token` via `wrangler secret put`.",
|
|
122
|
+
},
|
|
123
|
+
"Worker paths": {
|
|
124
|
+
what: "One or more workers have no `path` field in the config.",
|
|
125
|
+
fix: "Add a `path` field to each worker entry, or run `hoox init` to repair.",
|
|
126
|
+
},
|
|
127
|
+
"D1 Database": {
|
|
128
|
+
what: "Required D1 databases are missing from Cloudflare.",
|
|
129
|
+
fix: "Run `hoox infra provision` to create them automatically.",
|
|
130
|
+
},
|
|
131
|
+
"KV Namespaces": {
|
|
132
|
+
what: "Required KV namespaces are missing from Cloudflare.",
|
|
133
|
+
fix: "Run `hoox infra provision` to create them automatically.",
|
|
134
|
+
},
|
|
135
|
+
"R2 Buckets": {
|
|
136
|
+
what: "Required R2 buckets are missing from Cloudflare.",
|
|
137
|
+
fix: "Run `hoox infra provision` to create them automatically.",
|
|
138
|
+
},
|
|
139
|
+
Queues: {
|
|
140
|
+
what: "Required Cloudflare Queues are missing.",
|
|
141
|
+
fix: "Run `hoox infra provision` to create them automatically.",
|
|
142
|
+
},
|
|
143
|
+
"Secrets (local)": {
|
|
144
|
+
what: "Local .dev.vars files are missing required secrets.",
|
|
145
|
+
fix: "Run `hoox check fix` to create placeholder files, then fill in real values.",
|
|
146
|
+
},
|
|
147
|
+
"Secrets (remote)": {
|
|
148
|
+
what: "Remote Cloudflare secrets are missing for one or more workers.",
|
|
149
|
+
fix: "Run `hoox secrets update-cf` to push local secrets to Cloudflare.",
|
|
150
|
+
},
|
|
151
|
+
Database: {
|
|
152
|
+
what: "Required D1 database tables or schemas are missing.",
|
|
153
|
+
fix: "Run `hoox infra provision` and apply D1 migrations.",
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Fallback explanation when a check name is not in the SUGGESTED_FIXES map. */
|
|
158
|
+
const GENERIC_FAIL_HELP = {
|
|
159
|
+
what: "This check reported one or more errors.",
|
|
160
|
+
fix: "Review the error details below, then re-run `hoox check setup` after fixing.",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Refresh rate options in milliseconds */
|
|
166
|
+
const REFRESH_RATES = [250, 500, 1000, 2000, 5000] as const;
|
|
167
|
+
|
|
168
|
+
/** Refresh rate display labels */
|
|
169
|
+
function formatRefreshRate(ms: number): string {
|
|
170
|
+
if (ms >= 1000) return `${ms / 1000}s`;
|
|
171
|
+
return `${ms}ms`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** ViewId options for the default view selector */
|
|
175
|
+
const VIEW_OPTIONS: ViewId[] = [
|
|
176
|
+
"dashboard",
|
|
177
|
+
"workers",
|
|
178
|
+
"worker-detail",
|
|
179
|
+
"trade-monitor",
|
|
180
|
+
"logs-viewer",
|
|
181
|
+
"service-manager",
|
|
182
|
+
"config-editor",
|
|
183
|
+
"setup-wizard",
|
|
184
|
+
"settings",
|
|
185
|
+
"queue-depth",
|
|
186
|
+
"kv-viewer",
|
|
187
|
+
"secrets-viewer",
|
|
188
|
+
"db-query",
|
|
189
|
+
"ai-chat",
|
|
190
|
+
"edge-topology",
|
|
191
|
+
"worker-settings",
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/** Human-readable labels for ViewId values */
|
|
195
|
+
const VIEW_LABELS: Record<ViewId, string> = {
|
|
196
|
+
dashboard: "Dashboard",
|
|
197
|
+
workers: "Workers Overview",
|
|
198
|
+
"worker-detail": "Worker Detail",
|
|
199
|
+
"trade-monitor": "Trade Monitor",
|
|
200
|
+
"logs-viewer": "Logs Viewer",
|
|
201
|
+
"service-manager": "Service Manager",
|
|
202
|
+
"config-editor": "Config Editor",
|
|
203
|
+
"setup-wizard": "Setup Wizard",
|
|
204
|
+
settings: "Settings",
|
|
205
|
+
"queue-depth": "Queue Depth",
|
|
206
|
+
"kv-viewer": "KV Viewer",
|
|
207
|
+
"secrets-viewer": "Secrets Viewer",
|
|
208
|
+
"db-query": "DB Query",
|
|
209
|
+
"ai-chat": "AI Chat",
|
|
210
|
+
"edge-topology": "Edge Topology",
|
|
211
|
+
"worker-settings": "Worker Settings",
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Notification channel keys in display order */
|
|
215
|
+
const NOTIFICATION_CHANNELS: (keyof NotificationPreferences)[] = [
|
|
216
|
+
"alerts",
|
|
217
|
+
"trades",
|
|
218
|
+
"debug",
|
|
219
|
+
"system",
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
/** Human-readable labels for notification channels */
|
|
223
|
+
const NOTIFICATION_LABELS: Record<keyof NotificationPreferences, string> = {
|
|
224
|
+
alerts: "ALERTS",
|
|
225
|
+
trades: "TRADES",
|
|
226
|
+
debug: "DEBUG",
|
|
227
|
+
system: "SYSTEM",
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/** System-level keyboard shortcuts shown in the reference panel */
|
|
231
|
+
interface ShortcutEntry {
|
|
232
|
+
key: string;
|
|
233
|
+
action: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const SYSTEM_SHORTCUTS: ShortcutEntry[] = [
|
|
237
|
+
{ key: "Ctrl+1..9", action: "Switch to view 1-9" },
|
|
238
|
+
{ key: "Ctrl+0", action: "Switch to view 10" },
|
|
239
|
+
// Keep action labels short — Keyboard panel is ~34 cols; long text
|
|
240
|
+
// overflows into adjacent panels and corrupts the character frame.
|
|
241
|
+
{ key: "Ctrl+Alt+W", action: "Worker Settings" },
|
|
242
|
+
{ key: "Ctrl+P", action: "Command Palette" },
|
|
243
|
+
{ key: "Ctrl+B", action: "Toggle Sidebar" },
|
|
244
|
+
{ key: "Ctrl+Q", action: "Quit" },
|
|
245
|
+
{ key: "Esc", action: "Back / Close" },
|
|
246
|
+
{ key: "Tab", action: "Next panel / field" },
|
|
247
|
+
{ key: "/", action: "Focus search" },
|
|
248
|
+
{ key: "Space", action: "Toggle checkbox" },
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
// ─── Panel Count ──────────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const PANEL_COUNT = 4;
|
|
254
|
+
|
|
255
|
+
// ─── Sub-Components ────────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Toggle checkbox: "[x]" for checked, "[ ]" for unchecked.
|
|
259
|
+
* Responds to Space and mouse click.
|
|
260
|
+
*/
|
|
261
|
+
function Checkbox({
|
|
262
|
+
label,
|
|
263
|
+
checked,
|
|
264
|
+
onToggle,
|
|
265
|
+
active,
|
|
266
|
+
}: {
|
|
267
|
+
label: string;
|
|
268
|
+
checked: boolean;
|
|
269
|
+
onToggle: () => void;
|
|
270
|
+
active: boolean;
|
|
271
|
+
}) {
|
|
272
|
+
const marker = checked ? "x" : " ";
|
|
273
|
+
return (
|
|
274
|
+
<text
|
|
275
|
+
fg={checked ? Colors.success : Colors.muted}
|
|
276
|
+
bg={active ? Colors.background : undefined}
|
|
277
|
+
bold={checked}
|
|
278
|
+
dim={!checked}
|
|
279
|
+
onMouseUp={onToggle}
|
|
280
|
+
>
|
|
281
|
+
[{marker}] {label}
|
|
282
|
+
</text>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Column 1: ThemePanel ─────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* ThemePanel — dark-only theme note, refresh rate, default view, reset,
|
|
290
|
+
* plus a read-only connection/transport snapshot.
|
|
291
|
+
*
|
|
292
|
+
* Local item indices for keyboard navigation:
|
|
293
|
+
* 0 = refresh rate, 1 = default view, 2 = reset button
|
|
294
|
+
* (theme is display-only; connection is read-only)
|
|
295
|
+
*/
|
|
296
|
+
const THEME_ITEM_COUNT = 3;
|
|
297
|
+
|
|
298
|
+
function ThemePanel({
|
|
299
|
+
active,
|
|
300
|
+
activeItem,
|
|
301
|
+
onResetDefaults,
|
|
302
|
+
}: {
|
|
303
|
+
active: boolean;
|
|
304
|
+
activeItem: number;
|
|
305
|
+
onResetDefaults: () => void;
|
|
306
|
+
}) {
|
|
307
|
+
const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
308
|
+
const defaultView = useConfigStore((s) => s.defaultView);
|
|
309
|
+
|
|
310
|
+
const connection = useMemo(() => {
|
|
311
|
+
let configFallback: {
|
|
312
|
+
transport?: string;
|
|
313
|
+
apiUrl?: string;
|
|
314
|
+
apiToken?: string;
|
|
315
|
+
} = {};
|
|
316
|
+
try {
|
|
317
|
+
const cfg = readConfigSync();
|
|
318
|
+
// Presence only — never pass cleartext tokens into view/snapshot objects.
|
|
319
|
+
// A non-empty placeholder is enough for authSummary (Boolean check).
|
|
320
|
+
configFallback = {
|
|
321
|
+
transport: cfg.transport,
|
|
322
|
+
apiUrl: cfg.apiUrl,
|
|
323
|
+
apiToken: cfg.apiToken ? "1" : undefined,
|
|
324
|
+
};
|
|
325
|
+
} catch {
|
|
326
|
+
// config.json may be missing — env-only snapshot is fine
|
|
327
|
+
}
|
|
328
|
+
return getSettingsConnectionSnapshot(process.env, configFallback);
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<box flexDirection="column" gap={0} paddingX={1}>
|
|
333
|
+
{/* Dark-only theme (panel title is THEME; light not implemented) */}
|
|
334
|
+
<text fg={Colors.accent} bold>
|
|
335
|
+
(•) DARK
|
|
336
|
+
</text>
|
|
337
|
+
<text fg={Colors.muted} dim>
|
|
338
|
+
Light not implemented
|
|
339
|
+
</text>
|
|
340
|
+
|
|
341
|
+
{/* Refresh rate selector */}
|
|
342
|
+
<text fg={Colors.foreground} bold dim>
|
|
343
|
+
REFRESH
|
|
344
|
+
</text>
|
|
345
|
+
<text
|
|
346
|
+
fg={Colors.accent}
|
|
347
|
+
bg={active && activeItem === 0 ? Colors.background : undefined}
|
|
348
|
+
bold
|
|
349
|
+
>
|
|
350
|
+
{"◀ "}
|
|
351
|
+
{formatRefreshRate(refreshIntervalMs)}
|
|
352
|
+
{" ▶"}
|
|
353
|
+
</text>
|
|
354
|
+
|
|
355
|
+
{/* Default view dropdown */}
|
|
356
|
+
<text fg={Colors.foreground} bold dim>
|
|
357
|
+
DEFAULT VIEW
|
|
358
|
+
</text>
|
|
359
|
+
<text
|
|
360
|
+
fg={Colors.accent}
|
|
361
|
+
bg={active && activeItem === 1 ? Colors.background : undefined}
|
|
362
|
+
bold
|
|
363
|
+
>
|
|
364
|
+
{"◀ "}
|
|
365
|
+
{VIEW_LABELS[defaultView] ?? defaultView}
|
|
366
|
+
{" ▶"}
|
|
367
|
+
</text>
|
|
368
|
+
|
|
369
|
+
{/* Reset to Defaults */}
|
|
370
|
+
<text
|
|
371
|
+
fg={Colors.warning}
|
|
372
|
+
bg={active && activeItem === 2 ? Colors.background : undefined}
|
|
373
|
+
bold
|
|
374
|
+
onMouseUp={onResetDefaults}
|
|
375
|
+
>
|
|
376
|
+
{"[ RESET TO DEFAULTS ]"}
|
|
377
|
+
</text>
|
|
378
|
+
|
|
379
|
+
{/* Connection / transport (read-only; secrets never shown) */}
|
|
380
|
+
<text fg={Colors.foreground} bold dim>
|
|
381
|
+
CONNECTION
|
|
382
|
+
</text>
|
|
383
|
+
<text
|
|
384
|
+
fg={
|
|
385
|
+
connection.mode === "remote"
|
|
386
|
+
? Colors.info
|
|
387
|
+
: Colors["muted-foreground"]
|
|
388
|
+
}
|
|
389
|
+
bold
|
|
390
|
+
>
|
|
391
|
+
{connection.mode.toUpperCase()} · {connection.apiHost}
|
|
392
|
+
</text>
|
|
393
|
+
<text fg={Colors.accent}>
|
|
394
|
+
{connection.transport} · Auth {connection.authSummary}
|
|
395
|
+
</text>
|
|
396
|
+
<text fg={Colors.dim} dim>
|
|
397
|
+
{connection.hint}
|
|
398
|
+
</text>
|
|
399
|
+
</box>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ─── Column 2: NotificationsPanel ─────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* NotificationsPanel — toggle checkboxes for alert/trade/debug/system
|
|
407
|
+
* notification channels plus a master sound toggle.
|
|
408
|
+
*
|
|
409
|
+
* Local item indices:
|
|
410
|
+
* 0-3 = notification channels, 4 = sound toggle
|
|
411
|
+
*/
|
|
412
|
+
const NOTIF_ITEM_COUNT = 5;
|
|
413
|
+
|
|
414
|
+
function NotificationsPanel({
|
|
415
|
+
active,
|
|
416
|
+
activeItem,
|
|
417
|
+
}: {
|
|
418
|
+
active: boolean;
|
|
419
|
+
activeItem: number;
|
|
420
|
+
}) {
|
|
421
|
+
const notifications = useConfigStore((s) => s.notifications);
|
|
422
|
+
const soundEnabled = useConfigStore((s) => s.soundEnabled);
|
|
423
|
+
const toggleNotification = useConfigStore((s) => s.toggleNotification);
|
|
424
|
+
const updateConfig = useConfigStore((s) => s.updateConfig);
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<box flexDirection="column" gap={0}>
|
|
428
|
+
{/* Notification checkboxes */}
|
|
429
|
+
<text fg={Colors.foreground} bold dim>
|
|
430
|
+
CHANNELS
|
|
431
|
+
</text>
|
|
432
|
+
{NOTIFICATION_CHANNELS.map((channel, i) => (
|
|
433
|
+
<Checkbox
|
|
434
|
+
key={channel}
|
|
435
|
+
label={NOTIFICATION_LABELS[channel]}
|
|
436
|
+
checked={notifications[channel]}
|
|
437
|
+
onToggle={() => toggleNotification(channel)}
|
|
438
|
+
active={active && activeItem === i}
|
|
439
|
+
/>
|
|
440
|
+
))}
|
|
441
|
+
|
|
442
|
+
{/* Sound toggle */}
|
|
443
|
+
<box paddingTop={1}>
|
|
444
|
+
<Checkbox
|
|
445
|
+
label="SOUND"
|
|
446
|
+
checked={soundEnabled}
|
|
447
|
+
onToggle={() => updateConfig({ soundEnabled: !soundEnabled })}
|
|
448
|
+
active={active && activeItem === 4}
|
|
449
|
+
/>
|
|
450
|
+
</box>
|
|
451
|
+
</box>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ─── Column 3: KeyboardPanel ──────────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* KeyboardPanel — read-only reference table showing system-level
|
|
459
|
+
* keyboard shortcuts (key → action).
|
|
460
|
+
*/
|
|
461
|
+
function KeyboardPanel() {
|
|
462
|
+
return (
|
|
463
|
+
<box flexDirection="column" gap={0}>
|
|
464
|
+
<text fg={Colors.foreground} bold dim>
|
|
465
|
+
KEY
|
|
466
|
+
{" "}
|
|
467
|
+
ACTION
|
|
468
|
+
</text>
|
|
469
|
+
|
|
470
|
+
{SYSTEM_SHORTCUTS.map((entry) => (
|
|
471
|
+
<box key={entry.key} flexDirection="row" gap={1}>
|
|
472
|
+
<text fg={Colors.accent} bold>
|
|
473
|
+
{entry.key.padEnd(14)}
|
|
474
|
+
</text>
|
|
475
|
+
<text fg={Colors.muted} dim>
|
|
476
|
+
{entry.action}
|
|
477
|
+
</text>
|
|
478
|
+
</box>
|
|
479
|
+
))}
|
|
480
|
+
|
|
481
|
+
{/* Divider + hint */}
|
|
482
|
+
<box paddingTop={1}>
|
|
483
|
+
<text fg={Colors.dim} dim>
|
|
484
|
+
These shortcuts are global and cannot be changed.
|
|
485
|
+
</text>
|
|
486
|
+
</box>
|
|
487
|
+
</box>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ─── Column 4: DataPanel ──────────────────────────────────────────────────────
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* DataPanel — data management actions (Clear Cache, Export, Import, Check Setup, Auto-Repair)
|
|
495
|
+
* plus an About section with version and tech info.
|
|
496
|
+
*
|
|
497
|
+
* Local item indices:
|
|
498
|
+
* 0 = Clear Cache, 1 = Export Data, 2 = Import Data, 3 = Check Setup, 4 = Run Auto-Repair
|
|
499
|
+
*/
|
|
500
|
+
const DATA_ITEM_COUNT = 5;
|
|
501
|
+
|
|
502
|
+
function DataPanel({
|
|
503
|
+
active,
|
|
504
|
+
activeItem,
|
|
505
|
+
onClearCache,
|
|
506
|
+
onExportData,
|
|
507
|
+
onImportData,
|
|
508
|
+
onCheckSetup,
|
|
509
|
+
onRunAutoRepair,
|
|
510
|
+
}: {
|
|
511
|
+
active: boolean;
|
|
512
|
+
activeItem: number;
|
|
513
|
+
onClearCache: () => void;
|
|
514
|
+
onExportData: () => void;
|
|
515
|
+
onImportData: () => void;
|
|
516
|
+
onCheckSetup: () => void;
|
|
517
|
+
onRunAutoRepair: () => void;
|
|
518
|
+
}) {
|
|
519
|
+
return (
|
|
520
|
+
<box flexDirection="column" gap={0} flexGrow={1}>
|
|
521
|
+
{/* Data management header */}
|
|
522
|
+
<text fg={Colors.foreground} bold dim>
|
|
523
|
+
DATA MANAGEMENT
|
|
524
|
+
</text>
|
|
525
|
+
|
|
526
|
+
{/* Action buttons */}
|
|
527
|
+
<box flexDirection="column" gap={0} paddingLeft={1}>
|
|
528
|
+
<text
|
|
529
|
+
fg={Colors.accent}
|
|
530
|
+
bg={active && activeItem === 0 ? Colors.background : undefined}
|
|
531
|
+
bold
|
|
532
|
+
onMouseUp={onClearCache}
|
|
533
|
+
>
|
|
534
|
+
[ CLEAR CACHE ]
|
|
535
|
+
</text>
|
|
536
|
+
<text
|
|
537
|
+
fg={Colors.accent}
|
|
538
|
+
bg={active && activeItem === 1 ? Colors.background : undefined}
|
|
539
|
+
bold
|
|
540
|
+
onMouseUp={onExportData}
|
|
541
|
+
>
|
|
542
|
+
[ EXPORT DATA ]
|
|
543
|
+
</text>
|
|
544
|
+
<text
|
|
545
|
+
fg={Colors.accent}
|
|
546
|
+
bg={active && activeItem === 2 ? Colors.background : undefined}
|
|
547
|
+
bold
|
|
548
|
+
onMouseUp={onImportData}
|
|
549
|
+
>
|
|
550
|
+
[ IMPORT DATA ]
|
|
551
|
+
</text>
|
|
552
|
+
<text
|
|
553
|
+
fg={Colors.warning}
|
|
554
|
+
bg={active && activeItem === 3 ? Colors.background : undefined}
|
|
555
|
+
bold
|
|
556
|
+
onMouseUp={onCheckSetup}
|
|
557
|
+
>
|
|
558
|
+
[ CHECK SETUP ]
|
|
559
|
+
</text>
|
|
560
|
+
<text
|
|
561
|
+
fg={Colors.info}
|
|
562
|
+
bg={active && activeItem === 4 ? Colors.background : undefined}
|
|
563
|
+
bold
|
|
564
|
+
onMouseUp={onRunAutoRepair}
|
|
565
|
+
>
|
|
566
|
+
[ AUTO-REPAIR ]
|
|
567
|
+
</text>
|
|
568
|
+
</box>
|
|
569
|
+
|
|
570
|
+
{/* About section */}
|
|
571
|
+
<box paddingTop={1}>
|
|
572
|
+
<text fg={Colors.foreground} bold dim>
|
|
573
|
+
ABOUT
|
|
574
|
+
</text>
|
|
575
|
+
</box>
|
|
576
|
+
<box flexDirection="column" gap={0} paddingLeft={1}>
|
|
577
|
+
<text fg={Colors.foreground} bold>
|
|
578
|
+
HOOX CLI v1.0.0
|
|
579
|
+
</text>
|
|
580
|
+
<text fg={Colors.muted} dim>
|
|
581
|
+
OpenTUI + Bun
|
|
582
|
+
</text>
|
|
583
|
+
<text fg={Colors.info} dim>
|
|
584
|
+
github.com/hoox/hoox-cli
|
|
585
|
+
</text>
|
|
586
|
+
</box>
|
|
587
|
+
</box>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── Check Setup Results Panel ────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Status badge for a single check: PASS, FAIL, or WARN.
|
|
595
|
+
* Renders as a colored bracketed tag so it's easy to scan in a long list.
|
|
596
|
+
*/
|
|
597
|
+
function CheckStatusBadge({ status }: { status: "pass" | "fail" | "warn" }) {
|
|
598
|
+
if (status === "pass") {
|
|
599
|
+
return (
|
|
600
|
+
<text fg={Colors.success} bold>
|
|
601
|
+
{"[PASS]"}
|
|
602
|
+
</text>
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
if (status === "fail") {
|
|
606
|
+
return (
|
|
607
|
+
<text fg={Colors.error} bold>
|
|
608
|
+
{"[FAIL]"}
|
|
609
|
+
</text>
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
return (
|
|
613
|
+
<text fg={Colors.warning} bold>
|
|
614
|
+
{"[WARN]"}
|
|
615
|
+
</text>
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Status icon for a check row. Mirrors CheckStatusBadge but renders a
|
|
621
|
+
* single character — used in compact lists where width is tight.
|
|
622
|
+
*/
|
|
623
|
+
function checkStatusIcon(check: CheckItem): "pass" | "fail" | "warn" {
|
|
624
|
+
if (!check.success) return "fail";
|
|
625
|
+
if (check.warnings.length > 0) return "warn";
|
|
626
|
+
return "pass";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Renders a single failed check's "what it means" + "suggested fix" section.
|
|
631
|
+
* Only shown for failed checks; passes/warnings are listed without the
|
|
632
|
+
* extra block to keep the panel scannable.
|
|
633
|
+
*/
|
|
634
|
+
function CheckFixHelp({ check }: { check: CheckItem }) {
|
|
635
|
+
const help = SUGGESTED_FIXES[check.name] ?? GENERIC_FAIL_HELP;
|
|
636
|
+
return (
|
|
637
|
+
<box flexDirection="column" gap={0} paddingLeft={6}>
|
|
638
|
+
<box>
|
|
639
|
+
<text fg={Colors.foreground} dim>
|
|
640
|
+
{" what: "}
|
|
641
|
+
{help.what}
|
|
642
|
+
</text>
|
|
643
|
+
</box>
|
|
644
|
+
<box>
|
|
645
|
+
<text fg={Colors.info} dim>
|
|
646
|
+
{" fix: "}
|
|
647
|
+
{help.fix}
|
|
648
|
+
</text>
|
|
649
|
+
</box>
|
|
650
|
+
</box>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* CheckSetupResultsPanel — renders a full CheckReport below the 4-column
|
|
656
|
+
* settings layout. Shows a summary header, per-category sections, and a
|
|
657
|
+
* per-check row with PASS/FAIL/WARN status. Failed checks include a
|
|
658
|
+
* "what it means / suggested fix" block.
|
|
659
|
+
*
|
|
660
|
+
* Persists until the user dismisses with ESC or the [DISMISS] button.
|
|
661
|
+
*/
|
|
662
|
+
function CheckSetupResultsPanel({
|
|
663
|
+
state,
|
|
664
|
+
onDismiss,
|
|
665
|
+
onRerun,
|
|
666
|
+
}: {
|
|
667
|
+
state: CheckSetupState;
|
|
668
|
+
onDismiss: () => void;
|
|
669
|
+
onRerun: () => void;
|
|
670
|
+
}) {
|
|
671
|
+
return (
|
|
672
|
+
<box
|
|
673
|
+
flexDirection="column"
|
|
674
|
+
border={true}
|
|
675
|
+
borderStyle="single"
|
|
676
|
+
borderColor={Colors.accent}
|
|
677
|
+
backgroundColor={Colors.card}
|
|
678
|
+
paddingX={1}
|
|
679
|
+
paddingY={0}
|
|
680
|
+
gap={0}
|
|
681
|
+
>
|
|
682
|
+
{/* Header row: title + summary counts + dismiss button */}
|
|
683
|
+
<box flexDirection="row" gap={2}>
|
|
684
|
+
<text fg={Colors.accent} bold>
|
|
685
|
+
SETUP CHECK RESULTS
|
|
686
|
+
</text>
|
|
687
|
+
{state.kind === "report" && (
|
|
688
|
+
<>
|
|
689
|
+
<text fg={Colors.success} bold>
|
|
690
|
+
{state.report.summary.passed} passed
|
|
691
|
+
</text>
|
|
692
|
+
<text
|
|
693
|
+
fg={state.report.summary.failed > 0 ? Colors.error : Colors.muted}
|
|
694
|
+
bold={state.report.summary.failed > 0}
|
|
695
|
+
>
|
|
696
|
+
{state.report.summary.failed} failed
|
|
697
|
+
</text>
|
|
698
|
+
<text
|
|
699
|
+
fg={
|
|
700
|
+
state.report.summary.warnings > 0
|
|
701
|
+
? Colors.warning
|
|
702
|
+
: Colors.muted
|
|
703
|
+
}
|
|
704
|
+
bold={state.report.summary.warnings > 0}
|
|
705
|
+
>
|
|
706
|
+
{state.report.summary.warnings} warnings
|
|
707
|
+
</text>
|
|
708
|
+
<text fg={Colors.muted} dim>
|
|
709
|
+
{`(${(state.durationMs / 1000).toFixed(1)}s)`}
|
|
710
|
+
</text>
|
|
711
|
+
</>
|
|
712
|
+
)}
|
|
713
|
+
{state.kind === "running" && (
|
|
714
|
+
<text fg={Colors.info} bold>
|
|
715
|
+
running...
|
|
716
|
+
</text>
|
|
717
|
+
)}
|
|
718
|
+
<text fg={Colors.muted}>{" "}</text>
|
|
719
|
+
<text fg={Colors.accent} bold onMouseUp={onRerun}>
|
|
720
|
+
[ RE-RUN ]
|
|
721
|
+
</text>
|
|
722
|
+
<text fg={Colors.warning} bold onMouseUp={onDismiss}>
|
|
723
|
+
[ DISMISS ]
|
|
724
|
+
</text>
|
|
725
|
+
</box>
|
|
726
|
+
|
|
727
|
+
{/* Divider */}
|
|
728
|
+
<box>
|
|
729
|
+
<text fg={Colors.border} dim>
|
|
730
|
+
{"─".repeat(80)}
|
|
731
|
+
</text>
|
|
732
|
+
</box>
|
|
733
|
+
|
|
734
|
+
{/* Error state */}
|
|
735
|
+
{state.kind === "error" && (
|
|
736
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
737
|
+
<box>
|
|
738
|
+
<text fg={Colors.error} bold>
|
|
739
|
+
Setup check failed to run:
|
|
740
|
+
</text>
|
|
741
|
+
</box>
|
|
742
|
+
<box>
|
|
743
|
+
<text fg={Colors.foreground}>{state.message}</text>
|
|
744
|
+
</box>
|
|
745
|
+
</box>
|
|
746
|
+
)}
|
|
747
|
+
|
|
748
|
+
{/* Running state */}
|
|
749
|
+
{state.kind === "running" && (
|
|
750
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
751
|
+
<text fg={Colors.muted} dim>
|
|
752
|
+
Running `hoox check setup` — this may take up to 20 seconds.
|
|
753
|
+
</text>
|
|
754
|
+
</box>
|
|
755
|
+
)}
|
|
756
|
+
|
|
757
|
+
{/* Report state — show all categories and checks */}
|
|
758
|
+
{state.kind === "report" && (
|
|
759
|
+
<box flexDirection="column" gap={0} paddingTop={1}>
|
|
760
|
+
{state.report.categories.length === 0 ? (
|
|
761
|
+
<box>
|
|
762
|
+
<text fg={Colors.muted} dim>
|
|
763
|
+
No checks were run.
|
|
764
|
+
</text>
|
|
765
|
+
</box>
|
|
766
|
+
) : (
|
|
767
|
+
state.report.categories.map((cat) => (
|
|
768
|
+
<box key={cat.name} flexDirection="column" gap={0} paddingTop={1}>
|
|
769
|
+
{/* Category header */}
|
|
770
|
+
<text fg={Colors.foreground} bold dim>
|
|
771
|
+
{cat.name.toUpperCase()}
|
|
772
|
+
</text>
|
|
773
|
+
{/* Each check in the category */}
|
|
774
|
+
{cat.checks.map((check, idx) => {
|
|
775
|
+
const status = checkStatusIcon(check);
|
|
776
|
+
return (
|
|
777
|
+
<box
|
|
778
|
+
key={`${cat.name}-${idx}-${check.name}`}
|
|
779
|
+
flexDirection="column"
|
|
780
|
+
gap={0}
|
|
781
|
+
>
|
|
782
|
+
<box flexDirection="row" gap={1} paddingLeft={1}>
|
|
783
|
+
<box width={8}>
|
|
784
|
+
<CheckStatusBadge status={status} />
|
|
785
|
+
</box>
|
|
786
|
+
<text
|
|
787
|
+
fg={
|
|
788
|
+
status === "fail"
|
|
789
|
+
? Colors.foreground
|
|
790
|
+
: Colors.foreground
|
|
791
|
+
}
|
|
792
|
+
bold={status === "fail"}
|
|
793
|
+
>
|
|
794
|
+
{check.name}
|
|
795
|
+
</text>
|
|
796
|
+
</box>
|
|
797
|
+
{/* Error details (one line each) */}
|
|
798
|
+
{check.errors.map((err, i) => (
|
|
799
|
+
<box key={`err-${idx}-${i}`} paddingLeft={6}>
|
|
800
|
+
<text fg={Colors.error}>
|
|
801
|
+
{" - "}
|
|
802
|
+
{err}
|
|
803
|
+
</text>
|
|
804
|
+
</box>
|
|
805
|
+
))}
|
|
806
|
+
{/* Warning details */}
|
|
807
|
+
{check.warnings.map((warn, i) => (
|
|
808
|
+
<box key={`warn-${idx}-${i}`} paddingLeft={6}>
|
|
809
|
+
<text fg={Colors.warning}>
|
|
810
|
+
{" ~ "}
|
|
811
|
+
{warn}
|
|
812
|
+
</text>
|
|
813
|
+
</box>
|
|
814
|
+
))}
|
|
815
|
+
{/* What it means + suggested fix (failures only) */}
|
|
816
|
+
{status === "fail" && <CheckFixHelp check={check} />}
|
|
817
|
+
</box>
|
|
818
|
+
);
|
|
819
|
+
})}
|
|
820
|
+
</box>
|
|
821
|
+
))
|
|
822
|
+
)}
|
|
823
|
+
</box>
|
|
824
|
+
)}
|
|
825
|
+
</box>
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// ─── Main View ─────────────────────────────────────────────────────────────────
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* SettingsView — 4-column user preferences view.
|
|
833
|
+
*
|
|
834
|
+
* Keyboard model:
|
|
835
|
+
* Tab / Shift+Tab → cycle active panel (0-3)
|
|
836
|
+
* ↑↓ → navigate items within active panel
|
|
837
|
+
* Space → toggle checkbox / select radio
|
|
838
|
+
* ←→ → cycle select values (refresh rate, default view)
|
|
839
|
+
* Enter → activate button / toggle
|
|
840
|
+
*
|
|
841
|
+
* When the Check Setup results panel is visible, ESC dismisses it
|
|
842
|
+
* (overrides the import-mode ESC handler).
|
|
843
|
+
*
|
|
844
|
+
* Wrapped in ErrorBoundary for crash recovery.
|
|
845
|
+
*/
|
|
846
|
+
export function SettingsView({ dialog }: SettingsViewProps = {}) {
|
|
847
|
+
const activeView = useUIStore((s) => s.activeView);
|
|
848
|
+
const isActive = activeView === "settings";
|
|
849
|
+
|
|
850
|
+
const [activePanel, setActivePanel] = useState(0);
|
|
851
|
+
const [activeItem, setActiveItem] = useState(0);
|
|
852
|
+
const [importing, setImporting] = useState(false);
|
|
853
|
+
const [importPath, setImportPath] = useState("");
|
|
854
|
+
const [setupCheck, setSetupCheck] = useState<CheckSetupState>({
|
|
855
|
+
kind: "idle",
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
const updateConfig = useConfigStore((s) => s.updateConfig);
|
|
859
|
+
const theme = useConfigStore((s) => s.theme);
|
|
860
|
+
const refreshIntervalMs = useConfigStore((s) => s.refreshIntervalMs);
|
|
861
|
+
const defaultView = useConfigStore((s) => s.defaultView);
|
|
862
|
+
const resetDefaults = useConfigStore((s) => s.resetDefaults);
|
|
863
|
+
const toggleNotification = useConfigStore((s) => s.toggleNotification);
|
|
864
|
+
const soundEnabled = useConfigStore((s) => s.soundEnabled);
|
|
865
|
+
|
|
866
|
+
// Light theme is not implemented — coerce any stored "light" to dark.
|
|
867
|
+
useEffect(() => {
|
|
868
|
+
if (theme === "light") {
|
|
869
|
+
updateConfig({ theme: "dark" });
|
|
870
|
+
}
|
|
871
|
+
}, [theme, updateConfig]);
|
|
872
|
+
|
|
873
|
+
/** Fail-closed confirm helper for destructive settings actions. */
|
|
874
|
+
const confirmAction = useCallback(
|
|
875
|
+
async (title: string, message: string, confirmLabel: string) => {
|
|
876
|
+
if (!dialog) {
|
|
877
|
+
useServiceStore.getState().addAlert({
|
|
878
|
+
id: `settings-noconfirm-${Date.now()}`,
|
|
879
|
+
type: "config",
|
|
880
|
+
severity: "warning",
|
|
881
|
+
message: `${title} blocked: confirmation dialog unavailable`,
|
|
882
|
+
timestamp: Date.now(),
|
|
883
|
+
acknowledged: false,
|
|
884
|
+
});
|
|
885
|
+
return false;
|
|
886
|
+
}
|
|
887
|
+
return showConfirm(dialog, {
|
|
888
|
+
title,
|
|
889
|
+
message,
|
|
890
|
+
confirmLabel,
|
|
891
|
+
cancelLabel: "Cancel",
|
|
892
|
+
});
|
|
893
|
+
},
|
|
894
|
+
[dialog]
|
|
895
|
+
);
|
|
896
|
+
|
|
897
|
+
// ── Data panel handlers ────────────────────────────────────────────────────
|
|
898
|
+
|
|
899
|
+
const handleClearCache = useCallback(async () => {
|
|
900
|
+
const ok = await confirmAction(
|
|
901
|
+
"Clear Cache?",
|
|
902
|
+
"Delete ~/.hoox/cache? This cannot be undone.",
|
|
903
|
+
"Clear Cache"
|
|
904
|
+
);
|
|
905
|
+
if (!ok) return;
|
|
906
|
+
try {
|
|
907
|
+
const cachePath = path.join(os.homedir(), ".hoox", "cache");
|
|
908
|
+
const proc = Bun.spawn(["rm", "-rf", cachePath]);
|
|
909
|
+
await proc.exited;
|
|
910
|
+
useServiceStore.getState().addAlert({
|
|
911
|
+
id: `cache-${Date.now()}`,
|
|
912
|
+
type: "config",
|
|
913
|
+
severity: "info",
|
|
914
|
+
message: "Cache cleared successfully",
|
|
915
|
+
timestamp: Date.now(),
|
|
916
|
+
acknowledged: false,
|
|
917
|
+
});
|
|
918
|
+
} catch (err) {
|
|
919
|
+
useServiceStore.getState().addAlert({
|
|
920
|
+
id: `cache-err-${Date.now()}`,
|
|
921
|
+
type: "config",
|
|
922
|
+
severity: "error",
|
|
923
|
+
message: `Cache clear failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
924
|
+
timestamp: Date.now(),
|
|
925
|
+
acknowledged: false,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}, [confirmAction]);
|
|
929
|
+
|
|
930
|
+
const handleExportData = useCallback(async () => {
|
|
931
|
+
try {
|
|
932
|
+
const result = await cliBridge().configShow();
|
|
933
|
+
if (!result.success) {
|
|
934
|
+
useServiceStore.getState().addAlert({
|
|
935
|
+
id: `export-err-${Date.now()}`,
|
|
936
|
+
type: "config",
|
|
937
|
+
severity: "error",
|
|
938
|
+
message: `Export failed: ${result.stderr || result.stdout || "unknown error"}`,
|
|
939
|
+
timestamp: Date.now(),
|
|
940
|
+
acknowledged: false,
|
|
941
|
+
});
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
945
|
+
const filePath = path.join(
|
|
946
|
+
os.homedir(),
|
|
947
|
+
".hoox",
|
|
948
|
+
`config-export-${timestamp}.json`
|
|
949
|
+
);
|
|
950
|
+
await Bun.write(filePath, JSON.stringify(result.data, null, 2));
|
|
951
|
+
useServiceStore.getState().addAlert({
|
|
952
|
+
id: `export-${Date.now()}`,
|
|
953
|
+
type: "config",
|
|
954
|
+
severity: "info",
|
|
955
|
+
message: `Config exported to ${filePath}`,
|
|
956
|
+
timestamp: Date.now(),
|
|
957
|
+
acknowledged: false,
|
|
958
|
+
});
|
|
959
|
+
} catch (err) {
|
|
960
|
+
useServiceStore.getState().addAlert({
|
|
961
|
+
id: `export-err-${Date.now()}`,
|
|
962
|
+
type: "config",
|
|
963
|
+
severity: "error",
|
|
964
|
+
message: `Export error: ${err instanceof Error ? err.message : String(err)}`,
|
|
965
|
+
timestamp: Date.now(),
|
|
966
|
+
acknowledged: false,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}, []);
|
|
970
|
+
|
|
971
|
+
const handleImportData = useCallback(async () => {
|
|
972
|
+
setImporting(true);
|
|
973
|
+
setImportPath("");
|
|
974
|
+
}, []);
|
|
975
|
+
|
|
976
|
+
const handleImportConfirm = useCallback(async () => {
|
|
977
|
+
if (!importPath.trim()) return;
|
|
978
|
+
try {
|
|
979
|
+
const f = Bun.file(importPath.trim());
|
|
980
|
+
const exists = await f.exists();
|
|
981
|
+
if (!exists) {
|
|
982
|
+
useServiceStore.getState().addAlert({
|
|
983
|
+
id: `import-err-${Date.now()}`,
|
|
984
|
+
type: "config",
|
|
985
|
+
severity: "error",
|
|
986
|
+
message: `File not found: ${importPath.trim()}`,
|
|
987
|
+
timestamp: Date.now(),
|
|
988
|
+
acknowledged: false,
|
|
989
|
+
});
|
|
990
|
+
setImporting(false);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const text = await f.text();
|
|
994
|
+
const config = JSON.parse(text);
|
|
995
|
+
|
|
996
|
+
// Validate it has expected shape
|
|
997
|
+
if (typeof config !== "object" || config === null) {
|
|
998
|
+
throw new Error("Config must be a JSON object");
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// Apply config values if they match known keys (light theme coerced to dark)
|
|
1002
|
+
if (typeof config.theme === "string")
|
|
1003
|
+
updateConfig({
|
|
1004
|
+
theme: config.theme === "light" ? "dark" : (config.theme as "dark"),
|
|
1005
|
+
});
|
|
1006
|
+
if (typeof config.refreshIntervalMs === "number")
|
|
1007
|
+
updateConfig({ refreshIntervalMs: config.refreshIntervalMs });
|
|
1008
|
+
if (typeof config.defaultView === "string")
|
|
1009
|
+
updateConfig({ defaultView: config.defaultView });
|
|
1010
|
+
if (typeof config.soundEnabled === "boolean")
|
|
1011
|
+
updateConfig({ soundEnabled: config.soundEnabled });
|
|
1012
|
+
if (
|
|
1013
|
+
typeof config.notifications === "object" &&
|
|
1014
|
+
config.notifications !== null
|
|
1015
|
+
) {
|
|
1016
|
+
for (const key of Object.keys(config.notifications)) {
|
|
1017
|
+
if (typeof config.notifications[key] === "boolean") {
|
|
1018
|
+
toggleNotification(key as keyof NotificationPreferences);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (config.activeExchanges)
|
|
1023
|
+
updateConfig({ activeExchanges: config.activeExchanges });
|
|
1024
|
+
|
|
1025
|
+
useServiceStore.getState().addAlert({
|
|
1026
|
+
id: `import-${Date.now()}`,
|
|
1027
|
+
type: "config",
|
|
1028
|
+
severity: "info",
|
|
1029
|
+
message: `Config imported from ${importPath.trim()}`,
|
|
1030
|
+
timestamp: Date.now(),
|
|
1031
|
+
acknowledged: false,
|
|
1032
|
+
});
|
|
1033
|
+
} catch (err) {
|
|
1034
|
+
useServiceStore.getState().addAlert({
|
|
1035
|
+
id: `import-err-${Date.now()}`,
|
|
1036
|
+
type: "config",
|
|
1037
|
+
severity: "error",
|
|
1038
|
+
message: `Import failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1039
|
+
timestamp: Date.now(),
|
|
1040
|
+
acknowledged: false,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
setImporting(false);
|
|
1044
|
+
setImportPath("");
|
|
1045
|
+
}, [importPath, updateConfig, toggleNotification]);
|
|
1046
|
+
|
|
1047
|
+
const handleCheckSetup = useCallback(async () => {
|
|
1048
|
+
setSetupCheck({ kind: "running" });
|
|
1049
|
+
try {
|
|
1050
|
+
const result = await cliBridge().checkSetup();
|
|
1051
|
+
if (!result.success) {
|
|
1052
|
+
// Process-level failure (CLI exited non-zero, or binary not found).
|
|
1053
|
+
setSetupCheck({
|
|
1054
|
+
kind: "error",
|
|
1055
|
+
message: result.stderr || result.stdout || "unknown error from CLI",
|
|
1056
|
+
});
|
|
1057
|
+
useServiceStore.getState().addAlert({
|
|
1058
|
+
id: `setup-${Date.now()}`,
|
|
1059
|
+
type: "config",
|
|
1060
|
+
severity: "warning",
|
|
1061
|
+
message: `Setup check failed: ${result.stderr || result.stdout || "unknown error"}`,
|
|
1062
|
+
timestamp: Date.now(),
|
|
1063
|
+
acknowledged: false,
|
|
1064
|
+
});
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// CLI succeeded — `data` should be a CheckReport JSON object.
|
|
1068
|
+
const data = result.data as Partial<CheckReport> | null;
|
|
1069
|
+
if (
|
|
1070
|
+
!data ||
|
|
1071
|
+
!Array.isArray(data.categories) ||
|
|
1072
|
+
typeof data.summary !== "object" ||
|
|
1073
|
+
data.summary === null
|
|
1074
|
+
) {
|
|
1075
|
+
setSetupCheck({
|
|
1076
|
+
kind: "error",
|
|
1077
|
+
message:
|
|
1078
|
+
"CLI did not return a valid CheckReport (missing categories/summary).",
|
|
1079
|
+
});
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
// At this point we trust the shape — cast to CheckReport.
|
|
1083
|
+
const report: CheckReport = {
|
|
1084
|
+
success: Boolean(data.success),
|
|
1085
|
+
categories: data.categories as CheckCategoryResult[],
|
|
1086
|
+
summary: {
|
|
1087
|
+
total: Number(data.summary.total ?? 0),
|
|
1088
|
+
passed: Number(data.summary.passed ?? 0),
|
|
1089
|
+
failed: Number(data.summary.failed ?? 0),
|
|
1090
|
+
warnings: Number(data.summary.warnings ?? 0),
|
|
1091
|
+
},
|
|
1092
|
+
};
|
|
1093
|
+
setSetupCheck({
|
|
1094
|
+
kind: "report",
|
|
1095
|
+
report,
|
|
1096
|
+
durationMs: result.duration,
|
|
1097
|
+
});
|
|
1098
|
+
useServiceStore.getState().addAlert({
|
|
1099
|
+
id: `setup-${Date.now()}`,
|
|
1100
|
+
type: "config",
|
|
1101
|
+
severity: report.success ? "info" : "warning",
|
|
1102
|
+
message: report.success
|
|
1103
|
+
? `Setup check passed (${(result.duration / 1000).toFixed(1)}s)`
|
|
1104
|
+
: `Setup check found ${report.summary.failed} issue(s)`,
|
|
1105
|
+
timestamp: Date.now(),
|
|
1106
|
+
acknowledged: false,
|
|
1107
|
+
});
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1110
|
+
setSetupCheck({ kind: "error", message });
|
|
1111
|
+
useServiceStore.getState().addAlert({
|
|
1112
|
+
id: `setup-err-${Date.now()}`,
|
|
1113
|
+
type: "config",
|
|
1114
|
+
severity: "error",
|
|
1115
|
+
message: `Setup check error: ${message}`,
|
|
1116
|
+
timestamp: Date.now(),
|
|
1117
|
+
acknowledged: false,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
}, []);
|
|
1121
|
+
|
|
1122
|
+
/** Dismiss the Check Setup results panel. */
|
|
1123
|
+
const dismissCheckSetup = useCallback(() => {
|
|
1124
|
+
setSetupCheck({ kind: "idle" });
|
|
1125
|
+
}, []);
|
|
1126
|
+
|
|
1127
|
+
/** Run auto-repair via `hoox check fix`. */
|
|
1128
|
+
const handleRunAutoRepair = useCallback(async () => {
|
|
1129
|
+
const ok = await confirmAction(
|
|
1130
|
+
"Run Auto-Repair?",
|
|
1131
|
+
"Apply non-destructive config fixes (placeholders, flags, worker configs). Continue?",
|
|
1132
|
+
"Run Repair"
|
|
1133
|
+
);
|
|
1134
|
+
if (!ok) return;
|
|
1135
|
+
try {
|
|
1136
|
+
const result = await cliBridge().checkFix();
|
|
1137
|
+
if (!result.success) {
|
|
1138
|
+
useServiceStore.getState().addAlert({
|
|
1139
|
+
id: `auto-repair-${Date.now()}`,
|
|
1140
|
+
type: "config",
|
|
1141
|
+
severity: "warning",
|
|
1142
|
+
message: `Auto-repair failed: ${result.stderr || result.stdout || "unknown error"}`,
|
|
1143
|
+
timestamp: Date.now(),
|
|
1144
|
+
acknowledged: false,
|
|
1145
|
+
});
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Parse FixReport
|
|
1150
|
+
const data = result.data as {
|
|
1151
|
+
actions?: Array<{
|
|
1152
|
+
description: string;
|
|
1153
|
+
type: string;
|
|
1154
|
+
target: string;
|
|
1155
|
+
applied: boolean;
|
|
1156
|
+
error?: string;
|
|
1157
|
+
}>;
|
|
1158
|
+
summary?: {
|
|
1159
|
+
total: number;
|
|
1160
|
+
applied: number;
|
|
1161
|
+
skipped: number;
|
|
1162
|
+
failed: number;
|
|
1163
|
+
};
|
|
1164
|
+
} | null;
|
|
1165
|
+
|
|
1166
|
+
if (!data || !Array.isArray(data.actions)) {
|
|
1167
|
+
useServiceStore.getState().addAlert({
|
|
1168
|
+
id: `auto-repair-${Date.now()}`,
|
|
1169
|
+
type: "config",
|
|
1170
|
+
severity: "warning",
|
|
1171
|
+
message: "Auto-repair returned invalid result",
|
|
1172
|
+
timestamp: Date.now(),
|
|
1173
|
+
acknowledged: false,
|
|
1174
|
+
});
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const applied = data.actions.filter((a) => a.applied).length;
|
|
1179
|
+
const failed = data.actions.filter((a) => a.error).length;
|
|
1180
|
+
useServiceStore.getState().addAlert({
|
|
1181
|
+
id: `auto-repair-${Date.now()}`,
|
|
1182
|
+
type: "config",
|
|
1183
|
+
severity: failed > 0 ? "warning" : "info",
|
|
1184
|
+
message:
|
|
1185
|
+
failed > 0
|
|
1186
|
+
? `Auto-repair: ${applied} applied, ${failed} failed`
|
|
1187
|
+
: `Auto-repair: ${applied} fix(es) applied`,
|
|
1188
|
+
timestamp: Date.now(),
|
|
1189
|
+
acknowledged: false,
|
|
1190
|
+
});
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1193
|
+
useServiceStore.getState().addAlert({
|
|
1194
|
+
id: `auto-repair-err-${Date.now()}`,
|
|
1195
|
+
type: "config",
|
|
1196
|
+
severity: "error",
|
|
1197
|
+
message: `Auto-repair error: ${message}`,
|
|
1198
|
+
timestamp: Date.now(),
|
|
1199
|
+
acknowledged: false,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}, [confirmAction]);
|
|
1203
|
+
|
|
1204
|
+
const handleResetDefaults = useCallback(async () => {
|
|
1205
|
+
const ok = await confirmAction(
|
|
1206
|
+
"Reset Defaults?",
|
|
1207
|
+
"Reset theme, refresh rate, default view, and notification preferences to factory defaults?",
|
|
1208
|
+
"Reset"
|
|
1209
|
+
);
|
|
1210
|
+
if (!ok) return;
|
|
1211
|
+
resetDefaults();
|
|
1212
|
+
useServiceStore.getState().addAlert({
|
|
1213
|
+
id: `reset-${Date.now()}`,
|
|
1214
|
+
type: "config",
|
|
1215
|
+
severity: "info",
|
|
1216
|
+
message: "Settings reset to defaults",
|
|
1217
|
+
timestamp: Date.now(),
|
|
1218
|
+
acknowledged: false,
|
|
1219
|
+
});
|
|
1220
|
+
}, [confirmAction, resetDefaults]);
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Test hook: when `globalThis.__hooxTestAutoCheckSetup` is set, the
|
|
1224
|
+
* Check Setup check runs automatically once on mount. This lets the
|
|
1225
|
+
* test suite populate the results panel without simulating mouse/keyboard
|
|
1226
|
+
* events. Production code never sets this flag.
|
|
1227
|
+
*/
|
|
1228
|
+
useEffect(() => {
|
|
1229
|
+
const g = globalThis as unknown as {
|
|
1230
|
+
__hooxTestAutoCheckSetup?: boolean;
|
|
1231
|
+
};
|
|
1232
|
+
if (g.__hooxTestAutoCheckSetup) {
|
|
1233
|
+
void handleCheckSetup();
|
|
1234
|
+
}
|
|
1235
|
+
// Intentionally mount-only — tests set the flag then render, so
|
|
1236
|
+
// re-running on state changes would cause duplicate checks.
|
|
1237
|
+
}, []);
|
|
1238
|
+
|
|
1239
|
+
/** Max items per panel */
|
|
1240
|
+
const maxItemsPerPanel = useMemo((): Record<number, number> => {
|
|
1241
|
+
return {
|
|
1242
|
+
0: THEME_ITEM_COUNT,
|
|
1243
|
+
1: NOTIF_ITEM_COUNT,
|
|
1244
|
+
2: 1, // Keyboard panel has no interactive items (read-only)
|
|
1245
|
+
3: DATA_ITEM_COUNT,
|
|
1246
|
+
};
|
|
1247
|
+
}, []);
|
|
1248
|
+
|
|
1249
|
+
// ── Keyboard handling (only when Settings is the active view) ───────────────
|
|
1250
|
+
|
|
1251
|
+
useKeyboard((key) => {
|
|
1252
|
+
if (!isActive) return;
|
|
1253
|
+
// Import mode overrides
|
|
1254
|
+
if (importing) {
|
|
1255
|
+
if (key.name === "return" || key.name === "enter") {
|
|
1256
|
+
handleImportConfirm();
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (key.name === "escape") {
|
|
1260
|
+
setImporting(false);
|
|
1261
|
+
setImportPath("");
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
return; // block other keys during import
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Check Setup results panel: ESC dismisses (overrides panel navigation)
|
|
1268
|
+
if (setupCheck.kind !== "idle" && key.name === "escape") {
|
|
1269
|
+
dismissCheckSetup();
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Tab: cycle panels
|
|
1274
|
+
if (key.name === "tab") {
|
|
1275
|
+
setActivePanel((p) => (p + 1) % PANEL_COUNT);
|
|
1276
|
+
setActiveItem(0);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// Up/Down: navigate items within active panel
|
|
1281
|
+
if (key.name === "up") {
|
|
1282
|
+
setActiveItem((i) => Math.max(0, i - 1));
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
if (key.name === "down") {
|
|
1286
|
+
setActiveItem((i) =>
|
|
1287
|
+
Math.min((maxItemsPerPanel[activePanel] ?? 0) - 1, i + 1)
|
|
1288
|
+
);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// Panel-specific actions
|
|
1293
|
+
switch (activePanel) {
|
|
1294
|
+
case 0: {
|
|
1295
|
+
// Theme panel — items: 0 refresh, 1 default view, 2 reset
|
|
1296
|
+
switch (activeItem) {
|
|
1297
|
+
case 0: {
|
|
1298
|
+
// Refresh rate
|
|
1299
|
+
const currentIdx = REFRESH_RATES.indexOf(
|
|
1300
|
+
refreshIntervalMs as (typeof REFRESH_RATES)[number]
|
|
1301
|
+
);
|
|
1302
|
+
if (key.name === "left") {
|
|
1303
|
+
const prev =
|
|
1304
|
+
(currentIdx - 1 + REFRESH_RATES.length) % REFRESH_RATES.length;
|
|
1305
|
+
updateConfig({ refreshIntervalMs: REFRESH_RATES[prev] });
|
|
1306
|
+
} else if (key.name === "right") {
|
|
1307
|
+
const next = (currentIdx + 1) % REFRESH_RATES.length;
|
|
1308
|
+
updateConfig({ refreshIntervalMs: REFRESH_RATES[next] });
|
|
1309
|
+
}
|
|
1310
|
+
break;
|
|
1311
|
+
}
|
|
1312
|
+
case 1: {
|
|
1313
|
+
// Default view
|
|
1314
|
+
const currentIdx = VIEW_OPTIONS.indexOf(defaultView);
|
|
1315
|
+
if (key.name === "left") {
|
|
1316
|
+
const prev =
|
|
1317
|
+
(currentIdx - 1 + VIEW_OPTIONS.length) % VIEW_OPTIONS.length;
|
|
1318
|
+
updateConfig({ defaultView: VIEW_OPTIONS[prev] });
|
|
1319
|
+
} else if (key.name === "right") {
|
|
1320
|
+
const next = (currentIdx + 1) % VIEW_OPTIONS.length;
|
|
1321
|
+
updateConfig({ defaultView: VIEW_OPTIONS[next] });
|
|
1322
|
+
}
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
case 2: // Reset defaults
|
|
1326
|
+
if (key.name === "space" || key.name === "return")
|
|
1327
|
+
void handleResetDefaults();
|
|
1328
|
+
break;
|
|
1329
|
+
}
|
|
1330
|
+
break;
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
case 1: {
|
|
1334
|
+
// Notifications panel
|
|
1335
|
+
if (activeItem >= 0 && activeItem < 4) {
|
|
1336
|
+
if (key.name === "space") {
|
|
1337
|
+
const channel = NOTIFICATION_CHANNELS[activeItem];
|
|
1338
|
+
toggleNotification(channel);
|
|
1339
|
+
}
|
|
1340
|
+
} else if (activeItem === 4) {
|
|
1341
|
+
if (key.name === "space")
|
|
1342
|
+
updateConfig({ soundEnabled: !soundEnabled });
|
|
1343
|
+
}
|
|
1344
|
+
break;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
case 2: {
|
|
1348
|
+
// Keyboard panel — read-only, nothing to toggle
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
case 3: {
|
|
1353
|
+
// Data panel — buttons are activated on Enter/Space
|
|
1354
|
+
if (key.name === "space" || key.name === "return") {
|
|
1355
|
+
switch (activeItem) {
|
|
1356
|
+
case 0:
|
|
1357
|
+
handleClearCache();
|
|
1358
|
+
break;
|
|
1359
|
+
case 1:
|
|
1360
|
+
handleExportData();
|
|
1361
|
+
break;
|
|
1362
|
+
case 2:
|
|
1363
|
+
handleImportData();
|
|
1364
|
+
break;
|
|
1365
|
+
case 3:
|
|
1366
|
+
handleCheckSetup();
|
|
1367
|
+
break;
|
|
1368
|
+
case 4:
|
|
1369
|
+
handleRunAutoRepair();
|
|
1370
|
+
break;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// ── Render ──────────────────────────────────────────────────────────────────
|
|
1379
|
+
|
|
1380
|
+
return (
|
|
1381
|
+
<ErrorBoundary viewName="Settings">
|
|
1382
|
+
<box flexDirection="column" flexGrow={1} padding={1} gap={1}>
|
|
1383
|
+
<ViewHeader
|
|
1384
|
+
title="SETTINGS"
|
|
1385
|
+
meta={
|
|
1386
|
+
<text fg={Colors.muted} dim>
|
|
1387
|
+
Tab to switch panels · ↑↓ to navigate · ←→ to change · Space to
|
|
1388
|
+
toggle
|
|
1389
|
+
</text>
|
|
1390
|
+
}
|
|
1391
|
+
/>
|
|
1392
|
+
|
|
1393
|
+
{/* 4-column layout */}
|
|
1394
|
+
<box flexDirection="row" flexGrow={1} gap={1}>
|
|
1395
|
+
<Panel
|
|
1396
|
+
title="THEME"
|
|
1397
|
+
focused={activePanel === 0}
|
|
1398
|
+
width={28}
|
|
1399
|
+
flexGrow={1}
|
|
1400
|
+
compact
|
|
1401
|
+
>
|
|
1402
|
+
<ThemePanel
|
|
1403
|
+
active={activePanel === 0}
|
|
1404
|
+
activeItem={activeItem}
|
|
1405
|
+
onResetDefaults={() => void handleResetDefaults()}
|
|
1406
|
+
/>
|
|
1407
|
+
</Panel>
|
|
1408
|
+
|
|
1409
|
+
<Panel
|
|
1410
|
+
title="NOTIFICATIONS"
|
|
1411
|
+
focused={activePanel === 1}
|
|
1412
|
+
width={28}
|
|
1413
|
+
flexGrow={1}
|
|
1414
|
+
compact
|
|
1415
|
+
>
|
|
1416
|
+
<NotificationsPanel
|
|
1417
|
+
active={activePanel === 1}
|
|
1418
|
+
activeItem={activeItem}
|
|
1419
|
+
/>
|
|
1420
|
+
</Panel>
|
|
1421
|
+
|
|
1422
|
+
<Panel
|
|
1423
|
+
title="KEYBOARD"
|
|
1424
|
+
focused={activePanel === 2}
|
|
1425
|
+
width={34}
|
|
1426
|
+
flexGrow={1}
|
|
1427
|
+
compact
|
|
1428
|
+
>
|
|
1429
|
+
<KeyboardPanel />
|
|
1430
|
+
</Panel>
|
|
1431
|
+
|
|
1432
|
+
<Panel
|
|
1433
|
+
title="DATA"
|
|
1434
|
+
focused={activePanel === 3}
|
|
1435
|
+
width={24}
|
|
1436
|
+
flexGrow={1}
|
|
1437
|
+
compact
|
|
1438
|
+
>
|
|
1439
|
+
<DataPanel
|
|
1440
|
+
active={activePanel === 3}
|
|
1441
|
+
activeItem={activeItem}
|
|
1442
|
+
onClearCache={handleClearCache}
|
|
1443
|
+
onExportData={handleExportData}
|
|
1444
|
+
onImportData={handleImportData}
|
|
1445
|
+
onCheckSetup={handleCheckSetup}
|
|
1446
|
+
onRunAutoRepair={handleRunAutoRepair}
|
|
1447
|
+
/>
|
|
1448
|
+
</Panel>
|
|
1449
|
+
</box>
|
|
1450
|
+
|
|
1451
|
+
{/* Check Setup results panel — persists until dismissed or view change */}
|
|
1452
|
+
{setupCheck.kind !== "idle" && (
|
|
1453
|
+
<CheckSetupResultsPanel
|
|
1454
|
+
state={setupCheck}
|
|
1455
|
+
onDismiss={dismissCheckSetup}
|
|
1456
|
+
onRerun={handleCheckSetup}
|
|
1457
|
+
/>
|
|
1458
|
+
)}
|
|
1459
|
+
|
|
1460
|
+
{/* Import path input overlay */}
|
|
1461
|
+
{importing && (
|
|
1462
|
+
<box
|
|
1463
|
+
flexDirection="row"
|
|
1464
|
+
gap={1}
|
|
1465
|
+
paddingTop={1}
|
|
1466
|
+
paddingLeft={1}
|
|
1467
|
+
paddingRight={1}
|
|
1468
|
+
backgroundColor={Colors.card}
|
|
1469
|
+
>
|
|
1470
|
+
<text fg={Colors.accent} bold>
|
|
1471
|
+
Import path:
|
|
1472
|
+
</text>
|
|
1473
|
+
<input
|
|
1474
|
+
id="import-path"
|
|
1475
|
+
placeholder="~/.hoox/config.json"
|
|
1476
|
+
width={40}
|
|
1477
|
+
textColor={Colors.foreground}
|
|
1478
|
+
cursorColor={Colors.accent}
|
|
1479
|
+
onInput={(v: string) => setImportPath(v)}
|
|
1480
|
+
value={importPath}
|
|
1481
|
+
/>
|
|
1482
|
+
<text
|
|
1483
|
+
fg={importPath.trim() ? Colors.accent : Colors.muted}
|
|
1484
|
+
bold={!!importPath.trim()}
|
|
1485
|
+
onMouseUp={importPath.trim() ? handleImportConfirm : undefined}
|
|
1486
|
+
>
|
|
1487
|
+
[Import]
|
|
1488
|
+
</text>
|
|
1489
|
+
<text fg={Colors.muted} onMouseUp={() => setImporting(false)}>
|
|
1490
|
+
[Cancel]
|
|
1491
|
+
</text>
|
|
1492
|
+
</box>
|
|
1493
|
+
)}
|
|
1494
|
+
</box>
|
|
1495
|
+
</ErrorBoundary>
|
|
1496
|
+
);
|
|
1497
|
+
}
|