@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,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></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
|
+
}
|