@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,849 @@
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
+ * DB Query View — Read-only SQL query panel for Cloudflare D1.
9
+ *
10
+ * Layout:
11
+ * ┌───────────────────────────────────────────────────────────┐
12
+ * │ DB QUERY ◉ read-only · Ctrl+Enter to run │
13
+ * │───────────────────────────────────────────────────────────
14
+ * │ SQL> SELECT * FROM migrations ORDER BY id DESC LIMIT 5 │
15
+ * │ ✓ Ready · Press Enter or Ctrl+Enter to execute │
16
+ * │───────────────────────────────────────────────────────────
17
+ * │ id | applied_at | name │
18
+ * │ ──────────────────────────────────────────────────────── │
19
+ * │ 3 | 2026-01-15T10:30:00Z | add_trades_table │
20
+ * │ 2 | 2026-01-10T08:00:00Z | add_workers_index │
21
+ * │ 1 | 2026-01-01T00:00:00Z | init_schema │
22
+ * │───────────────────────────────────────────────────────────
23
+ * │ ✓ 3 rows in 12.4ms · [CLEAR] [HISTORY] │
24
+ * └───────────────────────────────────────────────────────────┘
25
+ *
26
+ * Security:
27
+ * - Client-side pre-validation via validateReadOnlySql() before
28
+ * ever spawning the CLI. INSERT/UPDATE/DELETE/DROP/CREATE/ALTER
29
+ * and multi-statement payloads are rejected with actionable feedback.
30
+ * - The CLI also enforces read-only; defence in depth.
31
+ * - History is stored under $HOME/.hoox/.tui-state/ — no server exposure.
32
+ *
33
+ * Pattern established for the TUI feature-parity batch:
34
+ * - Pure function component, no props required
35
+ * - Subscribes to useUIStore (so auto-refresh can pause when not active)
36
+ * - Wraps content in <ErrorBoundary viewName="DB Query">
37
+ * - Renders an explicit empty/error state instead of throwing
38
+ */
39
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
40
+ import { useKeyboard } from "@opentui/react";
41
+ import { Colors, useUIStore } from "@hoox-sh/hoox-shared";
42
+ import { ErrorBoundary } from "../shared/error-boundary";
43
+ import { Spinner, EmptyState } from "../shared/spinner";
44
+ import { ViewHeader } from "../shared/view-header";
45
+ import {
46
+ cliBridge,
47
+ validateReadOnlySql,
48
+ type DbQueryResult,
49
+ type SqlValidationResult,
50
+ } from "../../services/cli-bridge";
51
+ import {
52
+ readJsonState,
53
+ TuiStateFiles,
54
+ writeJsonState,
55
+ } from "../../services/tui-storage";
56
+
57
+ // ─── Constants ────────────────────────────────────────────────────────────────
58
+
59
+ /** Maximum number of history entries to persist. */
60
+ export const MAX_HISTORY = 20;
61
+
62
+ /** Cap rendered result rows so huge SELECTs don't freeze the TUI. */
63
+ export const MAX_VISIBLE_ROWS = 200;
64
+
65
+ /** Column width strategy: auto-computed from data. */
66
+ const COLUMN_WIDTH_STRATEGY: {
67
+ /** Fallback width when column has no data. */
68
+ fallback: number;
69
+ /** Width per character category. */
70
+ perChar: number;
71
+ /** Minimum column width. */
72
+ min: number;
73
+ /** Maximum column width. */
74
+ max: number;
75
+ } = {
76
+ fallback: 20,
77
+ perChar: 1,
78
+ min: 8,
79
+ max: 40,
80
+ };
81
+
82
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Format execution time for display.
86
+ * Shows milliseconds with 1 decimal place, or "0ms" when null.
87
+ */
88
+ function formatExecutionTime(ms: number | null): string {
89
+ if (ms === null) return "—";
90
+ if (ms < 1) return "<1ms";
91
+ if (ms < 1000) return `${ms.toFixed(1)}ms`;
92
+ return `${(ms / 1000).toFixed(2)}s`;
93
+ }
94
+
95
+ /**
96
+ * Strip C0 control characters from cell text so query results cannot
97
+ * inject terminal control sequences into the TUI table.
98
+ */
99
+ export function sanitizeCellText(value: string): string {
100
+ return value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
101
+ }
102
+
103
+ /** Format a value for table cell display. Truncates long strings. */
104
+ export function formatCell(value: unknown, maxWidth: number): string {
105
+ if (value === null || value === undefined) return "NULL";
106
+ if (typeof value === "boolean") return value ? "true" : "false";
107
+ if (typeof value === "number") {
108
+ if (!Number.isFinite(value)) return String(value);
109
+ return String(value);
110
+ }
111
+ const str = sanitizeCellText(String(value)).replace(/[\t\n\r]+/g, " ");
112
+ if (str.length > maxWidth) return str.slice(0, maxWidth - 1) + "…";
113
+ return str;
114
+ }
115
+
116
+ /**
117
+ * Compare two cell values for client-side sort.
118
+ * Nulls always sort last (stable UX for sparse columns).
119
+ */
120
+ export function compareCells(
121
+ av: unknown,
122
+ bv: unknown,
123
+ dir: "asc" | "desc"
124
+ ): number {
125
+ const aNull = av === null || av === undefined;
126
+ const bNull = bv === null || bv === undefined;
127
+ if (aNull && bNull) return 0;
128
+ if (aNull) return 1;
129
+ if (bNull) return -1;
130
+ const cmp =
131
+ typeof av === "number" && typeof bv === "number"
132
+ ? av - bv
133
+ : String(av).localeCompare(String(bv));
134
+ return dir === "asc" ? cmp : -cmp;
135
+ }
136
+
137
+ /**
138
+ * Compute optimal column widths from column names and row data.
139
+ * Returns a Map of column name → display width.
140
+ */
141
+ function computeColumnWidths(
142
+ columns: string[],
143
+ rows: Record<string, unknown>[]
144
+ ): Map<string, number> {
145
+ const widths = new Map<string, number>();
146
+ for (const col of columns) {
147
+ let max = col.length;
148
+ for (const row of rows) {
149
+ const val = row[col];
150
+ const display = formatCell(val, 999);
151
+ max = Math.max(max, display.length);
152
+ }
153
+ const computed = Math.min(
154
+ COLUMN_WIDTH_STRATEGY.max,
155
+ Math.max(COLUMN_WIDTH_STRATEGY.min, max)
156
+ );
157
+ widths.set(col, computed);
158
+ }
159
+ return widths;
160
+ }
161
+
162
+ /** Derive the display label for a validation result. */
163
+ function validationLabel(v: SqlValidationResult): string {
164
+ if (v.readonly) return "✓ Ready";
165
+ return `✗ ${v.reason}`;
166
+ }
167
+
168
+ /** Derive the display color for a validation result. */
169
+ function validationColor(v: SqlValidationResult): string {
170
+ return v.readonly ? Colors.success : Colors.error;
171
+ }
172
+
173
+ // ─── Sub-component: Table Header ─────────────────────────────────────────────
174
+
175
+ interface TableHeaderProps {
176
+ columns: string[];
177
+ widths: Map<string, number>;
178
+ onSort: (col: string) => void;
179
+ sortCol: string | null;
180
+ sortDir: "asc" | "desc";
181
+ }
182
+
183
+ function TableHeader({
184
+ columns,
185
+ widths,
186
+ onSort,
187
+ sortCol,
188
+ sortDir,
189
+ }: TableHeaderProps) {
190
+ return (
191
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
192
+ {columns.map((col) => {
193
+ const w = widths.get(col) ?? COLUMN_WIDTH_STRATEGY.fallback;
194
+ const isSortCol = col === sortCol;
195
+ const dirMark = isSortCol ? (sortDir === "asc" ? " ↑" : " ↓") : "";
196
+ const label = col + dirMark;
197
+ return (
198
+ <text
199
+ key={col}
200
+ fg={isSortCol ? Colors.accent : Colors.muted}
201
+ dim={!isSortCol}
202
+ onMouseUp={() => onSort(col)}
203
+ >
204
+ {label.padEnd(w)}
205
+ </text>
206
+ );
207
+ })}
208
+ </box>
209
+ );
210
+ }
211
+
212
+ // ─── Sub-component: Table Body ────────────────────────────────────────────────
213
+
214
+ interface TableBodyProps {
215
+ columns: string[];
216
+ rows: Record<string, unknown>[];
217
+ widths: Map<string, number>;
218
+ selectedRow: number;
219
+ onSelectRow: (idx: number) => void;
220
+ }
221
+
222
+ function TableBody({
223
+ columns,
224
+ rows,
225
+ widths,
226
+ selectedRow,
227
+ onSelectRow,
228
+ }: TableBodyProps) {
229
+ return (
230
+ <scrollbox width="100%" flexGrow={1}>
231
+ {rows.map((row, idx) => (
232
+ <box
233
+ key={idx}
234
+ flexDirection="row"
235
+ gap={1}
236
+ paddingLeft={1}
237
+ paddingRight={1}
238
+ backgroundColor={idx === selectedRow ? Colors.highlight : undefined}
239
+ onMouseUp={() => onSelectRow(idx)}
240
+ >
241
+ {columns.map((col) => {
242
+ const w = widths.get(col) ?? COLUMN_WIDTH_STRATEGY.fallback;
243
+ const raw = row[col];
244
+ const display = formatCell(raw, w);
245
+ // Color NULL values distinctly
246
+ const isNull = raw === null || raw === undefined;
247
+ return (
248
+ <text
249
+ key={col}
250
+ fg={isNull ? Colors.muted : Colors.foreground}
251
+ dim={isNull}
252
+ >
253
+ {display.padEnd(w)}
254
+ </text>
255
+ );
256
+ })}
257
+ </box>
258
+ ))}
259
+ </scrollbox>
260
+ );
261
+ }
262
+
263
+ // ─── Sub-component: History Overlay ──────────────────────────────────────────
264
+
265
+ interface HistoryOverlayProps {
266
+ history: string[];
267
+ onSelect: (sql: string) => void;
268
+ onClose: () => void;
269
+ }
270
+
271
+ function HistoryOverlay({ history, onSelect, onClose }: HistoryOverlayProps) {
272
+ // Keyboard: Escape closes, Up/Down navigate, Enter selects
273
+ useKeyboard((key) => {
274
+ if (key.name === "escape") {
275
+ onClose();
276
+ return;
277
+ }
278
+ });
279
+
280
+ if (history.length === 0) {
281
+ return (
282
+ <box
283
+ flexDirection="column"
284
+ flexGrow={1}
285
+ padding={2}
286
+ border={true}
287
+ borderStyle="single"
288
+ borderColor={Colors.border}
289
+ backgroundColor={Colors.card}
290
+ >
291
+ <text fg={Colors.muted} dim>
292
+ No query history yet.
293
+ </text>
294
+ <text fg={Colors.muted} dim>
295
+ Press Escape to close.
296
+ </text>
297
+ </box>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <box
303
+ flexDirection="column"
304
+ flexGrow={1}
305
+ border={true}
306
+ borderStyle="single"
307
+ borderColor={Colors.border}
308
+ backgroundColor={Colors.card}
309
+ paddingX={1}
310
+ paddingY={0}
311
+ >
312
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
313
+ <text fg={Colors.accent} bold>
314
+ QUERY HISTORY
315
+ </text>
316
+ <text fg={Colors.muted} dim>
317
+ ({history.length} · Esc to close)
318
+ </text>
319
+ </box>
320
+ <text fg={Colors.border} dim>
321
+ {"─".repeat(60)}
322
+ </text>
323
+ <scrollbox width="100%" flexGrow={1}>
324
+ {[...history].reverse().map((sql, revIdx) => {
325
+ const idx = history.length - 1 - revIdx;
326
+ return (
327
+ <box
328
+ key={idx}
329
+ flexDirection="column"
330
+ paddingLeft={1}
331
+ paddingRight={1}
332
+ gap={0}
333
+ onMouseUp={() => {
334
+ onSelect(sql);
335
+ onClose();
336
+ }}
337
+ >
338
+ <text fg={Colors.muted} dim>
339
+ [{idx + 1}]
340
+ </text>
341
+ <text fg={Colors.foreground}>
342
+ {sql.length > 70 ? sql.slice(0, 67) + "…" : sql}
343
+ </text>
344
+ </box>
345
+ );
346
+ })}
347
+ </scrollbox>
348
+ </box>
349
+ );
350
+ }
351
+
352
+ // ─── Sub-component: SQL Input Row ─────────────────────────────────────────────
353
+
354
+ interface SqlInputRowProps {
355
+ sql: string;
356
+ validation: SqlValidationResult | null;
357
+ inputActive: boolean;
358
+ onActivate: () => void;
359
+ onDeactivate: () => void;
360
+ onChange: (next: string) => void;
361
+ }
362
+
363
+ function SqlInputRow({
364
+ sql,
365
+ validation,
366
+ inputActive,
367
+ onActivate,
368
+ onDeactivate,
369
+ onChange,
370
+ }: SqlInputRowProps) {
371
+ // Capture keyboard when active.
372
+ useKeyboard((key) => {
373
+ if (!inputActive) return;
374
+ if (key.name === "escape") {
375
+ onDeactivate();
376
+ return;
377
+ }
378
+ if (key.name === "backspace") {
379
+ onChange(sql.slice(0, -1));
380
+ return;
381
+ }
382
+ if (key.name === "return") return; // Handled at the view level
383
+ // Character input: only printable ASCII + basic Unicode word chars
384
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
385
+ onChange(sql + key.sequence);
386
+ }
387
+ });
388
+
389
+ const label = validation ? validationLabel(validation) : "Type a query…";
390
+ const color = validation ? validationColor(validation) : Colors.muted;
391
+
392
+ // Cursor: show "_" blink when active
393
+ const displaySql =
394
+ sql.length > 0 ? sql : inputActive ? "" : "(press Enter to execute)";
395
+ const cursor = inputActive ? "_" : "";
396
+
397
+ return (
398
+ <box
399
+ flexDirection="column"
400
+ gap={0}
401
+ border={true}
402
+ borderStyle="single"
403
+ borderColor={inputActive ? Colors.accent : Colors.border}
404
+ onMouseUp={onActivate}
405
+ >
406
+ {/* SQL prompt line */}
407
+ <box flexDirection="row" gap={1} paddingLeft={1} paddingRight={1}>
408
+ <text fg={Colors.muted}>SQL&gt;</text>
409
+ <text fg={Colors.foreground}>
410
+ {displaySql}
411
+ {cursor}
412
+ </text>
413
+ </box>
414
+ {/* Validation feedback */}
415
+ <box paddingLeft={1} paddingRight={1}>
416
+ <text fg={color} dim={!inputActive}>
417
+ {label} · Enter to run · ↑↓ history
418
+ </text>
419
+ </box>
420
+ </box>
421
+ );
422
+ }
423
+
424
+ // ─── Main DbQuery View ────────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * DbQuery — Main view for read-only D1 SQL queries.
428
+ *
429
+ * Pattern for the TUI feature-parity batch:
430
+ * 1. Pure function component, no required props
431
+ * 2. Subscribes to `useUIStore.activeView` to pause auto-refresh
432
+ * when the user navigates away
433
+ * 3. Uses `cliBridge.dbQuery(sql)` for execution
434
+ * 4. Wraps in <ErrorBoundary viewName="DB Query"> so a render bug
435
+ * in this view never crashes the whole TUI
436
+ * 5. Renders an explicit empty/error state instead of throwing
437
+ *
438
+ * Keyboard shortcuts (when this view is active):
439
+ * - Enter / Ctrl+Enter → execute query (when SQL is non-empty)
440
+ * - ↑ → previous history entry
441
+ * - ↓ → next history entry
442
+ * - Ctrl+H → open history overlay
443
+ * - Escape → deactivate SQL input / close history
444
+ */
445
+ export function DbQueryView() {
446
+ const activeView = useUIStore((s) => s.activeView);
447
+ const isActive = activeView === "db-query";
448
+
449
+ // ── SQL input state ────────────────────────────────────────────────────────
450
+ const [sql, setSql] = useState("");
451
+ const [validation, setValidation] = useState<SqlValidationResult | null>(
452
+ null
453
+ );
454
+ const [inputActive, setInputActive] = useState(false);
455
+
456
+ // ── Query result state ─────────────────────────────────────────────────────
457
+ const [result, setResult] = useState<DbQueryResult | null>(null);
458
+ const [loading, setLoading] = useState(false);
459
+ const [queryError, setQueryError] = useState<string | null>(null);
460
+
461
+ // ── History state ──────────────────────────────────────────────────────────
462
+ const [history, setHistory] = useState<string[]>([]);
463
+ const [historyReady, setHistoryReady] = useState(false);
464
+ const [historyIdx, setHistoryIdx] = useState(-1);
465
+ const [showHistory, setShowHistory] = useState(false);
466
+
467
+ // ── Table navigation ───────────────────────────────────────────────────────
468
+ const [sortCol, setSortCol] = useState<string | null>(null);
469
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
470
+ const [selectedRow, setSelectedRow] = useState(0);
471
+ /** Invalidates in-flight query results after unmount or re-execute. */
472
+ const queryGenRef = useRef(0);
473
+
474
+ // Load query history from disk on mount.
475
+ useEffect(() => {
476
+ let cancelled = false;
477
+ void (async () => {
478
+ const stored = await readJsonState<string[]>(
479
+ TuiStateFiles.dbQueryHistory,
480
+ []
481
+ );
482
+ if (cancelled) return;
483
+ if (Array.isArray(stored)) {
484
+ setHistory(stored.slice(0, MAX_HISTORY));
485
+ }
486
+ setHistoryReady(true);
487
+ })();
488
+ return () => {
489
+ cancelled = true;
490
+ queryGenRef.current += 1;
491
+ };
492
+ }, []);
493
+
494
+ // Persist history to disk whenever it changes (after initial load).
495
+ useEffect(() => {
496
+ if (!historyReady) return;
497
+ void writeJsonState(TuiStateFiles.dbQueryHistory, history);
498
+ }, [history, historyReady]);
499
+
500
+ // ── Execute query ──────────────────────────────────────────────────────────
501
+ const executeQuery = useCallback(async (query: string) => {
502
+ const trimmed = query.trim();
503
+ if (trimmed.length === 0) return;
504
+
505
+ // Client-side pre-validation (defence in depth). UI cannot bypass this —
506
+ // cliBridge.dbQuery also re-validates before spawning the CLI.
507
+ const v = validateReadOnlySql(trimmed);
508
+ setValidation(v);
509
+ if (!v.readonly) {
510
+ setResult(null);
511
+ setQueryError(v.reason ?? "Query rejected: not read-only");
512
+ return;
513
+ }
514
+
515
+ const gen = ++queryGenRef.current;
516
+ setLoading(true);
517
+ setQueryError(null);
518
+ const res = await cliBridge.dbQuery(trimmed);
519
+ if (gen !== queryGenRef.current) return; // superseded or unmounted
520
+ if (res.success && res.data) {
521
+ setResult(res.data);
522
+ setQueryError(null);
523
+ // Append to history (dedupe: remove if already present).
524
+ setHistory((prev) => {
525
+ const without = prev.filter((h) => h !== trimmed);
526
+ return [trimmed, ...without].slice(0, MAX_HISTORY);
527
+ });
528
+ setHistoryIdx(-1);
529
+ setSelectedRow(0);
530
+ setSortCol(null);
531
+ } else {
532
+ setResult(null);
533
+ setQueryError(res.stderr || res.stdout || "Query failed");
534
+ }
535
+ setLoading(false);
536
+ }, []);
537
+
538
+ // ── Validation feedback as user types ─────────────────────────────────────
539
+ useEffect(() => {
540
+ if (sql.trim().length === 0) {
541
+ setValidation(null);
542
+ return;
543
+ }
544
+ const v = validateReadOnlySql(sql.trim());
545
+ setValidation(v);
546
+ }, [sql]);
547
+
548
+ // ── SQL input activation ───────────────────────────────────────────────────
549
+ const handleActivateInput = useCallback(() => {
550
+ setInputActive(true);
551
+ }, []);
552
+
553
+ const handleDeactivateInput = useCallback(() => {
554
+ setInputActive(false);
555
+ }, []);
556
+
557
+ // ── Sort handler ────────────────────────────────────────────────────────────
558
+ const handleSort = useCallback((col: string) => {
559
+ setSortCol((prev) => {
560
+ if (prev === col) {
561
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
562
+ return col;
563
+ }
564
+ setSortDir("asc");
565
+ return col;
566
+ });
567
+ }, []);
568
+
569
+ // ── History navigation ─────────────────────────────────────────────────────
570
+ const handleHistoryUp = useCallback(() => {
571
+ if (history.length === 0) return;
572
+ const next = Math.min(historyIdx + 1, history.length - 1);
573
+ setHistoryIdx(next);
574
+ setSql(history[next] ?? "");
575
+ setValidation(null); // Reset validation until user edits
576
+ }, [history, historyIdx]);
577
+
578
+ const handleHistoryDown = useCallback(() => {
579
+ if (historyIdx <= 0) {
580
+ setHistoryIdx(-1);
581
+ setSql("");
582
+ return;
583
+ }
584
+ const next = historyIdx - 1;
585
+ setHistoryIdx(next);
586
+ setSql(history[next] ?? "");
587
+ }, [historyIdx, history]);
588
+
589
+ // ── Global keyboard shortcuts (active view only) ───────────────────────────
590
+ useKeyboard((key) => {
591
+ if (!isActive) return;
592
+ if (showHistory) return; // History overlay handles its own keys
593
+
594
+ // Ctrl+H: toggle history
595
+ if (key.ctrl && key.name === "h") {
596
+ setShowHistory((v) => !v);
597
+ return;
598
+ }
599
+
600
+ // If input is not active, activate it on any printable character
601
+ // or when Enter is pressed.
602
+ if (!inputActive) {
603
+ if (key.name === "return") {
604
+ if (sql.trim().length > 0) {
605
+ void executeQuery(sql);
606
+ }
607
+ return;
608
+ }
609
+ if (key.name === "up") {
610
+ void handleHistoryUp();
611
+ return;
612
+ }
613
+ if (key.name === "down") {
614
+ void handleHistoryDown();
615
+ return;
616
+ }
617
+ // Any printable character activates input
618
+ if (key.sequence && key.sequence.length === 1 && key.sequence >= " ") {
619
+ setInputActive(true);
620
+ setSql(sql + key.sequence);
621
+ return;
622
+ }
623
+ return;
624
+ }
625
+
626
+ // Input is active
627
+ if (key.name === "return") {
628
+ if (key.ctrl || sql.includes("\n")) {
629
+ // Ctrl+Enter or multiline: execute
630
+ void executeQuery(sql);
631
+ setInputActive(false);
632
+ } else {
633
+ // Plain Enter: execute and keep input active for chaining
634
+ void executeQuery(sql);
635
+ }
636
+ return;
637
+ }
638
+
639
+ if (key.name === "up") {
640
+ void handleHistoryUp();
641
+ return;
642
+ }
643
+ if (key.name === "down") {
644
+ void handleHistoryDown();
645
+ return;
646
+ }
647
+ });
648
+
649
+ // ── Derived data ────────────────────────────────────────────────────────────
650
+ const columns = result?.columns ?? [];
651
+ const rows = result?.rows ?? [];
652
+
653
+ // Client-side sort if a sort column is selected; then cap for render.
654
+ const sortedRows = useMemo(() => {
655
+ if (!sortCol || rows.length === 0) return rows;
656
+ return [...rows].sort((a, b) =>
657
+ compareCells(a[sortCol], b[sortCol], sortDir)
658
+ );
659
+ }, [rows, sortCol, sortDir]);
660
+
661
+ const visibleRows = useMemo(
662
+ () => sortedRows.slice(0, MAX_VISIBLE_ROWS),
663
+ [sortedRows]
664
+ );
665
+
666
+ // Compute column widths from the visible window (not the full result set).
667
+ const columnWidths = useMemo(
668
+ () => computeColumnWidths(columns, visibleRows),
669
+ [columns, visibleRows]
670
+ );
671
+
672
+ // Total table width (for separator lines).
673
+ const totalWidth = columns.reduce(
674
+ (acc, col) =>
675
+ acc + (columnWidths.get(col) ?? COLUMN_WIDTH_STRATEGY.fallback) + 1,
676
+ 0
677
+ );
678
+
679
+ // ── Render ──────────────────────────────────────────────────────────────────
680
+ return (
681
+ <ErrorBoundary viewName="DB Query">
682
+ <box flexDirection="column" flexGrow={1} padding={1} gap={1}>
683
+ <ViewHeader
684
+ title="DB QUERY"
685
+ showDivider={false}
686
+ meta={
687
+ <box flexDirection="row" gap={2} alignItems="center">
688
+ {result ? (
689
+ <text fg={Colors.muted} dim>
690
+ {result.rowCount} row{result.rowCount === 1 ? "" : "s"}
691
+ {sortedRows.length > MAX_VISIBLE_ROWS
692
+ ? ` · showing ${MAX_VISIBLE_ROWS}`
693
+ : ""}
694
+ </text>
695
+ ) : null}
696
+ <text fg={Colors.success} dim>
697
+ ◉ read-only
698
+ </text>
699
+ </box>
700
+ }
701
+ />
702
+
703
+ {/* SQL input row */}
704
+ <SqlInputRow
705
+ sql={sql}
706
+ validation={validation}
707
+ inputActive={inputActive}
708
+ onActivate={handleActivateInput}
709
+ onDeactivate={handleDeactivateInput}
710
+ onChange={setSql}
711
+ />
712
+
713
+ {/* Main content area: results table or history */}
714
+ <box
715
+ flexGrow={1}
716
+ border={true}
717
+ borderStyle="single"
718
+ borderColor={Colors.border}
719
+ flexDirection="column"
720
+ paddingX={1}
721
+ paddingY={0}
722
+ >
723
+ {showHistory ? (
724
+ <HistoryOverlay
725
+ history={history}
726
+ onSelect={(selected) => {
727
+ setSql(selected);
728
+ setValidation(null);
729
+ setInputActive(false);
730
+ setShowHistory(false);
731
+ }}
732
+ onClose={() => setShowHistory(false)}
733
+ />
734
+ ) : loading ? (
735
+ <box
736
+ padding={1}
737
+ alignItems="center"
738
+ justifyContent="center"
739
+ flexGrow={1}
740
+ >
741
+ <Spinner label="Executing query..." />
742
+ </box>
743
+ ) : queryError && rows.length === 0 ? (
744
+ <box padding={1} flexDirection="column" gap={0}>
745
+ <text fg={Colors.error} bold>
746
+ !{" "}
747
+ {queryError.length > 70
748
+ ? queryError.slice(0, 67) + "…"
749
+ : queryError}
750
+ </text>
751
+ <text fg={Colors.muted} dim>
752
+ Make sure wrangler is installed and authenticated.
753
+ </text>
754
+ </box>
755
+ ) : !result ? (
756
+ <box padding={1} flexGrow={1}>
757
+ <EmptyState
758
+ message="Enter a SELECT query above and press Enter to execute."
759
+ suggestion="Example: SELECT name, sql FROM sqlite_master WHERE type='table'"
760
+ icon="🔍"
761
+ />
762
+ </box>
763
+ ) : rows.length === 0 ? (
764
+ <box padding={1} flexGrow={1}>
765
+ <EmptyState message="Query returned 0 rows." icon="∅" />
766
+ </box>
767
+ ) : (
768
+ <>
769
+ {/* Column headers */}
770
+ <TableHeader
771
+ columns={columns}
772
+ widths={columnWidths}
773
+ onSort={handleSort}
774
+ sortCol={sortCol}
775
+ sortDir={sortDir}
776
+ />
777
+ {/* Divider */}
778
+ <text fg={Colors.border} dim>
779
+ {"─".repeat(Math.min(totalWidth, 120))}
780
+ </text>
781
+ {/* Data rows (capped) */}
782
+ <TableBody
783
+ columns={columns}
784
+ rows={visibleRows}
785
+ widths={columnWidths}
786
+ selectedRow={selectedRow}
787
+ onSelectRow={setSelectedRow}
788
+ />
789
+ {sortedRows.length > MAX_VISIBLE_ROWS ? (
790
+ <text fg={Colors.muted} dim>
791
+ Showing first {MAX_VISIBLE_ROWS} of {sortedRows.length} rows.
792
+ Add LIMIT to your query for full control.
793
+ </text>
794
+ ) : null}
795
+ </>
796
+ )}
797
+ </box>
798
+
799
+ {/* Footer */}
800
+ <box
801
+ flexDirection="row"
802
+ justifyContent="space-between"
803
+ alignItems="center"
804
+ >
805
+ <box flexDirection="row" gap={2} alignItems="center">
806
+ {result ? (
807
+ <>
808
+ <text fg={Colors.success} dim>
809
+ ✓ {result.rowCount} row{result.rowCount === 1 ? "" : "s"}
810
+ </text>
811
+ <text fg={Colors.muted} dim>
812
+ in {formatExecutionTime(result.executionTimeMs)}
813
+ </text>
814
+ </>
815
+ ) : queryError ? (
816
+ <text fg={Colors.warning} dim>
817
+ !{" "}
818
+ {queryError.length > 50
819
+ ? queryError.slice(0, 47) + "…"
820
+ : queryError}
821
+ </text>
822
+ ) : null}
823
+ </box>
824
+ <box flexDirection="row" gap={1} alignItems="center">
825
+ <text
826
+ fg={Colors.muted}
827
+ bg={Colors.card}
828
+ onMouseUp={() => {
829
+ setResult(null);
830
+ setSql("");
831
+ setValidation(null);
832
+ setQueryError(null);
833
+ }}
834
+ >
835
+ {" [CLEAR] "}
836
+ </text>
837
+ <text
838
+ fg={showHistory ? Colors.accent : Colors.muted}
839
+ bg={Colors.card}
840
+ onMouseUp={() => setShowHistory((v) => !v)}
841
+ >
842
+ {" [HISTORY] "}
843
+ </text>
844
+ </box>
845
+ </box>
846
+ </box>
847
+ </ErrorBoundary>
848
+ );
849
+ }