lopata 0.0.1

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 (147) hide show
  1. package/README.md +15 -0
  2. package/package.json +51 -0
  3. package/runtime/bindings/ai.ts +132 -0
  4. package/runtime/bindings/analytics-engine.ts +96 -0
  5. package/runtime/bindings/browser.ts +64 -0
  6. package/runtime/bindings/cache.ts +179 -0
  7. package/runtime/bindings/cf-streams.ts +56 -0
  8. package/runtime/bindings/container-docker.ts +225 -0
  9. package/runtime/bindings/container.ts +662 -0
  10. package/runtime/bindings/crypto-extras.ts +89 -0
  11. package/runtime/bindings/d1.ts +315 -0
  12. package/runtime/bindings/do-executor-inprocess.ts +140 -0
  13. package/runtime/bindings/do-executor-worker.ts +368 -0
  14. package/runtime/bindings/do-executor.ts +45 -0
  15. package/runtime/bindings/do-websocket-bridge.ts +70 -0
  16. package/runtime/bindings/do-worker-entry.ts +220 -0
  17. package/runtime/bindings/do-worker-env.ts +74 -0
  18. package/runtime/bindings/durable-object.ts +992 -0
  19. package/runtime/bindings/email.ts +180 -0
  20. package/runtime/bindings/html-rewriter.ts +84 -0
  21. package/runtime/bindings/hyperdrive.ts +130 -0
  22. package/runtime/bindings/images.ts +381 -0
  23. package/runtime/bindings/kv.ts +359 -0
  24. package/runtime/bindings/queue.ts +507 -0
  25. package/runtime/bindings/r2.ts +759 -0
  26. package/runtime/bindings/rpc-stub.ts +267 -0
  27. package/runtime/bindings/scheduled.ts +172 -0
  28. package/runtime/bindings/service-binding.ts +217 -0
  29. package/runtime/bindings/static-assets.ts +481 -0
  30. package/runtime/bindings/websocket-pair.ts +182 -0
  31. package/runtime/bindings/workflow.ts +858 -0
  32. package/runtime/bunflare-config.ts +56 -0
  33. package/runtime/cli/cache.ts +39 -0
  34. package/runtime/cli/context.ts +105 -0
  35. package/runtime/cli/d1.ts +163 -0
  36. package/runtime/cli/dev.ts +392 -0
  37. package/runtime/cli/kv.ts +84 -0
  38. package/runtime/cli/queues.ts +109 -0
  39. package/runtime/cli/r2.ts +140 -0
  40. package/runtime/cli/traces.ts +251 -0
  41. package/runtime/cli.ts +102 -0
  42. package/runtime/config.ts +148 -0
  43. package/runtime/d1-migrate.ts +37 -0
  44. package/runtime/dashboard/api.ts +174 -0
  45. package/runtime/dashboard/app.tsx +220 -0
  46. package/runtime/dashboard/components/breadcrumb.tsx +16 -0
  47. package/runtime/dashboard/components/buttons.tsx +13 -0
  48. package/runtime/dashboard/components/code-block.tsx +5 -0
  49. package/runtime/dashboard/components/detail-field.tsx +8 -0
  50. package/runtime/dashboard/components/empty-state.tsx +8 -0
  51. package/runtime/dashboard/components/filter-input.tsx +11 -0
  52. package/runtime/dashboard/components/index.ts +16 -0
  53. package/runtime/dashboard/components/key-value-table.tsx +23 -0
  54. package/runtime/dashboard/components/modal.tsx +23 -0
  55. package/runtime/dashboard/components/page-header.tsx +11 -0
  56. package/runtime/dashboard/components/pill-button.tsx +14 -0
  57. package/runtime/dashboard/components/refresh-button.tsx +7 -0
  58. package/runtime/dashboard/components/service-info.tsx +45 -0
  59. package/runtime/dashboard/components/status-badge.tsx +7 -0
  60. package/runtime/dashboard/components/table-link.tsx +5 -0
  61. package/runtime/dashboard/components/table.tsx +26 -0
  62. package/runtime/dashboard/components.tsx +19 -0
  63. package/runtime/dashboard/index.html +23 -0
  64. package/runtime/dashboard/lib.ts +45 -0
  65. package/runtime/dashboard/rpc/client.ts +20 -0
  66. package/runtime/dashboard/rpc/handlers/ai.ts +71 -0
  67. package/runtime/dashboard/rpc/handlers/analytics-engine.ts +53 -0
  68. package/runtime/dashboard/rpc/handlers/cache.ts +24 -0
  69. package/runtime/dashboard/rpc/handlers/config.ts +137 -0
  70. package/runtime/dashboard/rpc/handlers/containers.ts +194 -0
  71. package/runtime/dashboard/rpc/handlers/d1.ts +84 -0
  72. package/runtime/dashboard/rpc/handlers/do.ts +117 -0
  73. package/runtime/dashboard/rpc/handlers/email.ts +82 -0
  74. package/runtime/dashboard/rpc/handlers/errors.ts +32 -0
  75. package/runtime/dashboard/rpc/handlers/generations.ts +60 -0
  76. package/runtime/dashboard/rpc/handlers/kv.ts +76 -0
  77. package/runtime/dashboard/rpc/handlers/overview.ts +94 -0
  78. package/runtime/dashboard/rpc/handlers/queue.ts +79 -0
  79. package/runtime/dashboard/rpc/handlers/r2.ts +72 -0
  80. package/runtime/dashboard/rpc/handlers/scheduled.ts +91 -0
  81. package/runtime/dashboard/rpc/handlers/traces.ts +64 -0
  82. package/runtime/dashboard/rpc/handlers/workers.ts +65 -0
  83. package/runtime/dashboard/rpc/handlers/workflows.ts +171 -0
  84. package/runtime/dashboard/rpc/hooks.ts +132 -0
  85. package/runtime/dashboard/rpc/server.ts +70 -0
  86. package/runtime/dashboard/rpc/types.ts +396 -0
  87. package/runtime/dashboard/sql-browser/data-browser-tab.tsx +122 -0
  88. package/runtime/dashboard/sql-browser/editable-cell.tsx +117 -0
  89. package/runtime/dashboard/sql-browser/filter-row.tsx +99 -0
  90. package/runtime/dashboard/sql-browser/history-panels.tsx +110 -0
  91. package/runtime/dashboard/sql-browser/hooks.ts +137 -0
  92. package/runtime/dashboard/sql-browser/index.ts +4 -0
  93. package/runtime/dashboard/sql-browser/insert-row-form.tsx +85 -0
  94. package/runtime/dashboard/sql-browser/modals.tsx +116 -0
  95. package/runtime/dashboard/sql-browser/schema-browser-tab.tsx +67 -0
  96. package/runtime/dashboard/sql-browser/sql-browser.tsx +52 -0
  97. package/runtime/dashboard/sql-browser/sql-console-tab.tsx +124 -0
  98. package/runtime/dashboard/sql-browser/table-data-view.tsx +566 -0
  99. package/runtime/dashboard/sql-browser/table-sidebar.tsx +38 -0
  100. package/runtime/dashboard/sql-browser/types.ts +61 -0
  101. package/runtime/dashboard/sql-browser/utils.ts +167 -0
  102. package/runtime/dashboard/style.css +177 -0
  103. package/runtime/dashboard/views/ai.tsx +152 -0
  104. package/runtime/dashboard/views/analytics-engine.tsx +169 -0
  105. package/runtime/dashboard/views/cache.tsx +93 -0
  106. package/runtime/dashboard/views/containers.tsx +197 -0
  107. package/runtime/dashboard/views/d1.tsx +81 -0
  108. package/runtime/dashboard/views/do.tsx +168 -0
  109. package/runtime/dashboard/views/email.tsx +235 -0
  110. package/runtime/dashboard/views/errors.tsx +558 -0
  111. package/runtime/dashboard/views/home.tsx +287 -0
  112. package/runtime/dashboard/views/kv.tsx +273 -0
  113. package/runtime/dashboard/views/queue.tsx +193 -0
  114. package/runtime/dashboard/views/r2.tsx +202 -0
  115. package/runtime/dashboard/views/scheduled.tsx +89 -0
  116. package/runtime/dashboard/views/trace-waterfall.tsx +410 -0
  117. package/runtime/dashboard/views/traces.tsx +768 -0
  118. package/runtime/dashboard/views/workers.tsx +55 -0
  119. package/runtime/dashboard/views/workflows.tsx +473 -0
  120. package/runtime/db.ts +258 -0
  121. package/runtime/env.ts +362 -0
  122. package/runtime/error-page/app.tsx +394 -0
  123. package/runtime/error-page/build.ts +269 -0
  124. package/runtime/error-page/index.html +16 -0
  125. package/runtime/error-page/style.css +31 -0
  126. package/runtime/execution-context.ts +18 -0
  127. package/runtime/file-watcher.ts +57 -0
  128. package/runtime/generation-manager.ts +230 -0
  129. package/runtime/generation.ts +411 -0
  130. package/runtime/plugin.ts +292 -0
  131. package/runtime/request-cf.ts +28 -0
  132. package/runtime/rpc-validate.ts +154 -0
  133. package/runtime/tracing/context.ts +40 -0
  134. package/runtime/tracing/db.ts +73 -0
  135. package/runtime/tracing/frames.ts +75 -0
  136. package/runtime/tracing/instrument.ts +186 -0
  137. package/runtime/tracing/span.ts +138 -0
  138. package/runtime/tracing/store.ts +499 -0
  139. package/runtime/tracing/types.ts +47 -0
  140. package/runtime/vite-plugin/config-plugin.ts +68 -0
  141. package/runtime/vite-plugin/dev-server-plugin.ts +493 -0
  142. package/runtime/vite-plugin/dist/index.mjs +52333 -0
  143. package/runtime/vite-plugin/globals-plugin.ts +94 -0
  144. package/runtime/vite-plugin/index.ts +43 -0
  145. package/runtime/vite-plugin/modules-plugin.ts +88 -0
  146. package/runtime/vite-plugin/react-router-plugin.ts +95 -0
  147. package/runtime/worker-registry.ts +52 -0
@@ -0,0 +1,124 @@
1
+ import { useState, useEffect } from "preact/hooks";
2
+ import type { QueryResult } from "../rpc/types";
3
+ import type { useHistory } from "./hooks";
4
+ import { HistoryPanel } from "./history-panels";
5
+
6
+ export function SqlConsoleTab({ execQuery, initialSql, history }: {
7
+ execQuery: (sql: string) => Promise<QueryResult>;
8
+ initialSql?: string;
9
+ history: ReturnType<typeof useHistory>;
10
+ }) {
11
+ const [sql, setSql] = useState(initialSql ?? "");
12
+ const [result, setResult] = useState<QueryResult | null>(null);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [loading, setLoading] = useState(false);
15
+ const [showHistory, setShowHistory] = useState(false);
16
+
17
+ // Update SQL when initialSql changes (e.g. from "open in console")
18
+ useEffect(() => {
19
+ if (initialSql) setSql(initialSql);
20
+ }, [initialSql]);
21
+
22
+ const run = async () => {
23
+ if (!sql.trim() || loading) return;
24
+ history.add(sql);
25
+ setLoading(true);
26
+ setError(null);
27
+ setResult(null);
28
+ try {
29
+ const res = await execQuery(sql);
30
+ if (res.error) setError(res.error);
31
+ else setResult(res);
32
+ } catch (e: any) {
33
+ setError(e.message ?? String(e));
34
+ } finally {
35
+ setLoading(false);
36
+ }
37
+ };
38
+
39
+ return (
40
+ <>
41
+ <div class="bg-panel rounded-lg border border-border p-5 mb-6">
42
+ <textarea
43
+ value={sql}
44
+ onInput={e => setSql((e.target as HTMLTextAreaElement).value)}
45
+ onKeyDown={e => { if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) run(); }}
46
+ placeholder="SELECT * FROM ..."
47
+ class="w-full bg-panel-secondary border border-border rounded-lg px-4 py-3 font-mono text-sm outline-none min-h-[100px] resize-y focus:border-border focus:ring-1 focus:ring-border transition-all mb-4"
48
+ />
49
+ <div class="flex items-center gap-3">
50
+ <button
51
+ onClick={run}
52
+ disabled={loading || !sql.trim()}
53
+ class="rounded-md px-4 py-2 text-sm font-medium bg-ink text-surface hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed transition-all"
54
+ >
55
+ {loading ? "Running..." : "Run Query"}
56
+ </button>
57
+ <button
58
+ onClick={() => setShowHistory(v => !v)}
59
+ class={`rounded-md px-3 py-2 text-sm font-medium transition-all ${
60
+ showHistory
61
+ ? "bg-ink text-surface"
62
+ : "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
63
+ }`}
64
+ >
65
+ History{history.entries.length > 0 ? ` (${history.entries.length})` : ""}
66
+ </button>
67
+ <span class="text-xs text-text-muted">Ctrl+Enter to run</span>
68
+ </div>
69
+ </div>
70
+
71
+ {showHistory && (
72
+ <HistoryPanel
73
+ entries={history.entries}
74
+ onSelect={(entry) => { setSql(entry.sql); setShowHistory(false); }}
75
+ onClear={history.clear}
76
+ />
77
+ )}
78
+
79
+ {error ? (
80
+ <div class="bg-red-50 text-red-600 p-4 rounded-lg text-sm font-medium">{error}</div>
81
+ ) : result ? (
82
+ <div>
83
+ {result.message ? (
84
+ <div class="bg-emerald-50 text-emerald-700 p-4 rounded-lg text-sm font-medium">{result.message}</div>
85
+ ) : result.columns.length > 0 ? (
86
+ <div>
87
+ <div class="text-sm text-text-muted mb-3 font-medium">{result.count} row(s)</div>
88
+ <ResultTable columns={result.columns} rows={result.rows} />
89
+ </div>
90
+ ) : null}
91
+ </div>
92
+ ) : null}
93
+ </>
94
+ );
95
+ }
96
+
97
+ // ─── ResultTable (read-only results) ─────────────────────────────────
98
+
99
+ function ResultTable({ columns, rows }: { columns: string[]; rows: Record<string, unknown>[] }) {
100
+ return (
101
+ <div class="bg-panel rounded-lg border border-border overflow-x-auto">
102
+ <table class="w-full text-sm">
103
+ <thead>
104
+ <tr class="border-b border-border-subtle">
105
+ {columns.map(col => (
106
+ <th key={col} class="text-left px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono">{col}</th>
107
+ ))}
108
+ </tr>
109
+ </thead>
110
+ <tbody>
111
+ {rows.map((row, i) => (
112
+ <tr key={i} class="group border-b border-border-row last:border-0 hover:bg-panel-hover/50 transition-colors">
113
+ {columns.map(col => (
114
+ <td key={col} class="px-4 py-2.5 font-mono text-xs">
115
+ {row[col] === null ? <span class="text-text-dim italic">NULL</span> : String(row[col])}
116
+ </td>
117
+ ))}
118
+ </tr>
119
+ ))}
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+ );
124
+ }
@@ -0,0 +1,566 @@
1
+ import { useState, useEffect, useRef, useCallback } from "preact/hooks";
2
+ import type { D1Table, QueryResult } from "../rpc/types";
3
+ import { replaceRoute } from "../lib";
4
+ import type { SortDir, ForeignKeyInfo, BrowserHistoryEntry } from "./types";
5
+ import { PAGE_SIZE } from "./types";
6
+ import type { useHistory, useBrowserHistory } from "./hooks";
7
+ import { loadTableState, saveTableState, clearTableState } from "./hooks";
8
+ import { parseCreateTable, sqlLiteral, quoteId, buildWhereClause, exportCSV, exportJSON } from "./utils";
9
+ import { EditableCell } from "./editable-cell";
10
+ import { InsertRowForm } from "./insert-row-form";
11
+ import { FilterRow } from "./filter-row";
12
+ import { FilterHelpModal } from "./filter-row";
13
+ import { BrowserHistoryPanel } from "./history-panels";
14
+ import { RowDetailModal, CellInspectorModal } from "./modals";
15
+
16
+ export function TableDataView({ table, execQuery, onOpenInConsole, history, browserHistory, onRestoreHistory, onNavigateFK, historyScope, basePath, routeQuery }: {
17
+ table: D1Table;
18
+ execQuery: (sql: string) => Promise<QueryResult>;
19
+ onOpenInConsole: (sql: string) => void;
20
+ history: ReturnType<typeof useHistory>;
21
+ browserHistory: ReturnType<typeof useBrowserHistory>;
22
+ onRestoreHistory: (entry: BrowserHistoryEntry) => void;
23
+ onNavigateFK: (targetTable: string, targetColumn: string, value: unknown) => void;
24
+ historyScope?: string;
25
+ basePath?: string;
26
+ routeQuery?: URLSearchParams;
27
+ }) {
28
+ const schema = parseCreateTable(table.sql);
29
+ const pkCols = schema.primaryKeys.length > 0 ? schema.primaryKeys : ["rowid"];
30
+ const needsRowid = schema.primaryKeys.length === 0;
31
+
32
+ // FK map for quick lookup
33
+ const fkMap = new Map<string, ForeignKeyInfo>();
34
+ for (const col of schema.columns) {
35
+ if (col.foreignKey) fkMap.set(col.name, col.foreignKey);
36
+ }
37
+
38
+ // Numeric columns for right-alignment
39
+ const numericTypes = /\b(INT|INTEGER|REAL|FLOAT|DOUBLE|DECIMAL|NUMERIC|BIGINT|SMALLINT|TINYINT|MEDIUMINT)\b/i;
40
+ const numericCols = new Set(schema.columns.filter(c => numericTypes.test(c.type)).map(c => c.name));
41
+
42
+ // Initialize state from URL query params, falling back to saved table state
43
+ const initState = () => {
44
+ const f: Record<string, string> = {};
45
+ let s: string | null = null;
46
+ let d: SortDir = "ASC";
47
+ let hasUrlParams = false;
48
+
49
+ if (routeQuery) {
50
+ for (const [key, val] of routeQuery.entries()) {
51
+ if (key.startsWith("f.")) { f[key.slice(2)] = val; hasUrlParams = true; }
52
+ }
53
+ const urlSort = routeQuery.get("s");
54
+ if (urlSort) { s = urlSort; hasUrlParams = true; }
55
+ const urlDir = routeQuery.get("d");
56
+ if (urlDir === "DESC") d = "DESC";
57
+ }
58
+
59
+ if (!hasUrlParams) {
60
+ const saved = loadTableState(table.name, historyScope);
61
+ if (saved) {
62
+ return { filters: saved.filters, sortCol: saved.sortCol, sortDir: saved.sortDir };
63
+ }
64
+ }
65
+ return { filters: f, sortCol: s, sortDir: d };
66
+ };
67
+
68
+ const initial = initState();
69
+ const [rows, setRows] = useState<Record<string, unknown>[]>([]);
70
+ const [columns, setColumns] = useState<string[]>([]);
71
+ const [totalCount, setTotalCount] = useState<number>(table.rows);
72
+ const [offset, setOffset] = useState(() => {
73
+ const o = routeQuery?.get("o");
74
+ return o ? parseInt(o, 10) || 0 : 0;
75
+ });
76
+ const [sortCol, setSortCol] = useState<string | null>(initial.sortCol);
77
+ const [sortDir, setSortDir] = useState<SortDir>(initial.sortDir);
78
+ const [loading, setLoading] = useState(false);
79
+ const [error, setError] = useState<string | null>(null);
80
+ const [showInsert, setShowInsert] = useState(false);
81
+ const [filters, setFilters] = useState<Record<string, string>>(initial.filters);
82
+ const [showFilters, setShowFilters] = useState(() => Object.keys(initial.filters).length > 0);
83
+ const [showFilterHelp, setShowFilterHelp] = useState(false);
84
+ const [showBrowserHistory, setShowBrowserHistory] = useState(false);
85
+
86
+ // Bulk select
87
+ const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
88
+ const rowKey = (row: Record<string, unknown>) => pkCols.map(pk => String(row[pk] ?? "")).join("\0");
89
+
90
+ // Row detail & cell inspector modals
91
+ const [detailRow, setDetailRow] = useState<Record<string, unknown> | null>(null);
92
+ const [inspectCell, setInspectCell] = useState<{ column: string; value: unknown } | null>(null);
93
+
94
+ // Export dropdown
95
+ const [showExport, setShowExport] = useState(false);
96
+
97
+ // Sync state → URL + persist table view state
98
+ useEffect(() => {
99
+ // Save to localStorage
100
+ const hasState = Object.values(filters).some(v => v.trim()) || sortCol !== null;
101
+ if (hasState) {
102
+ saveTableState(table.name, { filters, sortCol, sortDir }, historyScope);
103
+ }
104
+
105
+ if (!basePath) return;
106
+ const params = new URLSearchParams();
107
+ for (const [col, val] of Object.entries(filters)) {
108
+ if (val.trim()) params.set("f." + col, val);
109
+ }
110
+ if (sortCol) {
111
+ params.set("s", sortCol);
112
+ params.set("d", sortDir);
113
+ }
114
+ if (offset > 0) params.set("o", String(offset));
115
+ const qs = params.toString();
116
+ replaceRoute(basePath + "/data/" + encodeURIComponent(table.name) + (qs ? "?" + qs : ""));
117
+ }, [filters, sortCol, sortDir, offset, basePath, table.name, historyScope]);
118
+
119
+ const filtersKey = JSON.stringify(filters);
120
+ const loadGenRef = useRef(0);
121
+
122
+ const loadData = useCallback(async (newOffset: number) => {
123
+ const gen = ++loadGenRef.current;
124
+ setLoading(true);
125
+ setError(null);
126
+ try {
127
+ const selectCols = needsRowid ? `rowid, *` : `*`;
128
+ const where = buildWhereClause(filters);
129
+ const orderBy = sortCol ? ` ORDER BY ${quoteId(sortCol)} ${sortDir}` : "";
130
+ const dataSql = `SELECT ${selectCols} FROM ${quoteId(table.name)}${where}${orderBy} LIMIT ${PAGE_SIZE} OFFSET ${newOffset}`;
131
+ const countSql = `SELECT COUNT(*) as cnt FROM ${quoteId(table.name)}${where}`;
132
+
133
+ const [dataRes, countRes] = await Promise.all([
134
+ execQuery(dataSql),
135
+ execQuery(countSql),
136
+ ]);
137
+
138
+ if (gen !== loadGenRef.current) return;
139
+
140
+ if (dataRes.error) {
141
+ setError(dataRes.error);
142
+ return;
143
+ }
144
+
145
+ setRows(dataRes.rows);
146
+ setColumns(dataRes.columns);
147
+ setOffset(newOffset);
148
+ if (countRes.rows?.[0]) {
149
+ setTotalCount(Number(countRes.rows[0].cnt));
150
+ }
151
+ // Save to browser history when there are filters or sort
152
+ if (where || orderBy) {
153
+ browserHistory.add({ table: table.name, filters, sortCol, sortDir });
154
+ }
155
+ } catch (e: any) {
156
+ if (gen !== loadGenRef.current) return;
157
+ setError(e.message ?? String(e));
158
+ } finally {
159
+ if (gen === loadGenRef.current) setLoading(false);
160
+ }
161
+ }, [table.name, sortCol, sortDir, filtersKey, needsRowid, execQuery]);
162
+
163
+ // Reload when table, sort, or filters change
164
+ useEffect(() => {
165
+ setOffset(0);
166
+ setShowInsert(false);
167
+ setSelectedRows(new Set());
168
+ loadData(0);
169
+ }, [table.name, sortCol, sortDir, filtersKey]);
170
+
171
+ const handleSort = (col: string) => {
172
+ if (sortCol === col) {
173
+ setSortDir(d => d === "ASC" ? "DESC" : "ASC");
174
+ } else {
175
+ setSortCol(col);
176
+ setSortDir("ASC");
177
+ }
178
+ };
179
+
180
+ const handleUpdate = async (row: Record<string, unknown>, col: string, value: unknown) => {
181
+ const where = pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ");
182
+ const sql = `UPDATE ${quoteId(table.name)} SET ${quoteId(col)} = ${sqlLiteral(value)} WHERE ${where}`;
183
+ try {
184
+ const res = await execQuery(sql);
185
+ if (res.error) {
186
+ setError(res.error);
187
+ return;
188
+ }
189
+ await loadData(offset);
190
+ } catch (e: any) {
191
+ setError(e.message ?? String(e));
192
+ }
193
+ };
194
+
195
+ const handleDelete = async (row: Record<string, unknown>) => {
196
+ if (!confirm("Delete this row?")) return;
197
+ const where = pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ");
198
+ const sql = `DELETE FROM ${quoteId(table.name)} WHERE ${where}`;
199
+ try {
200
+ const res = await execQuery(sql);
201
+ if (res.error) {
202
+ setError(res.error);
203
+ return;
204
+ }
205
+ await loadData(offset);
206
+ } catch (e: any) {
207
+ setError(e.message ?? String(e));
208
+ }
209
+ };
210
+
211
+ const handleInsert = async (values: Record<string, unknown>) => {
212
+ const cols = Object.keys(values);
213
+ const vals = cols.map(c => sqlLiteral(values[c]));
214
+ const sql = `INSERT INTO ${quoteId(table.name)} (${cols.map(quoteId).join(", ")}) VALUES (${vals.join(", ")})`;
215
+ try {
216
+ const res = await execQuery(sql);
217
+ if (res.error) {
218
+ setError(res.error);
219
+ return;
220
+ }
221
+ setShowInsert(false);
222
+ await loadData(offset);
223
+ } catch (e: any) {
224
+ setError(e.message ?? String(e));
225
+ }
226
+ };
227
+
228
+ // Bulk select handlers
229
+ const toggleRow = (row: Record<string, unknown>) => {
230
+ const key = rowKey(row);
231
+ setSelectedRows(prev => {
232
+ const next = new Set(prev);
233
+ if (next.has(key)) next.delete(key);
234
+ else next.add(key);
235
+ return next;
236
+ });
237
+ };
238
+
239
+ const toggleAll = () => {
240
+ if (selectedRows.size === rows.length && rows.length > 0) {
241
+ setSelectedRows(new Set());
242
+ } else {
243
+ setSelectedRows(new Set(rows.map(rowKey)));
244
+ }
245
+ };
246
+
247
+ const handleBulkDelete = async () => {
248
+ if (selectedRows.size === 0) return;
249
+ if (!confirm(`Delete ${selectedRows.size} row(s)?`)) return;
250
+ const toDelete = rows.filter(r => selectedRows.has(rowKey(r)));
251
+ const conditions = toDelete.map(row =>
252
+ `(${pkCols.map(pk => `${quoteId(pk)} = ${sqlLiteral(row[pk])}`).join(" AND ")})`
253
+ );
254
+ const sql = `DELETE FROM ${quoteId(table.name)} WHERE ${conditions.join(" OR ")}`;
255
+ try {
256
+ const res = await execQuery(sql);
257
+ if (res.error) {
258
+ setError(res.error);
259
+ return;
260
+ }
261
+ setSelectedRows(new Set());
262
+ await loadData(offset);
263
+ } catch (e: any) {
264
+ setError(e.message ?? String(e));
265
+ }
266
+ };
267
+
268
+ // Columns to display (hide rowid if it was added just for PK tracking)
269
+ const displayCols = columns.filter(c => !(needsRowid && c === "rowid"));
270
+ const activeFilterCount = Object.values(filters).filter(v => v.trim()).length;
271
+ const hasActiveState = activeFilterCount > 0 || sortCol !== null;
272
+
273
+ const handleReset = () => {
274
+ setFilters({});
275
+ setSortCol(null);
276
+ setSortDir("ASC");
277
+ setShowFilters(false);
278
+ clearTableState(table.name, historyScope);
279
+ };
280
+
281
+ // Current query SQL (for display / open-in-console)
282
+ const where = buildWhereClause(filters);
283
+ const orderBy = sortCol ? ` ORDER BY ${quoteId(sortCol)} ${sortDir}` : "";
284
+ const currentSql = `SELECT * FROM ${quoteId(table.name)}${where}${orderBy}`;
285
+
286
+ const totalPages = Math.ceil(totalCount / PAGE_SIZE);
287
+ const currentPage = Math.floor(offset / PAGE_SIZE) + 1;
288
+ const rangeStart = totalCount === 0 ? 0 : offset + 1;
289
+ const rangeEnd = Math.min(offset + PAGE_SIZE, totalCount);
290
+
291
+ return (
292
+ <div>
293
+ {/* Toolbar */}
294
+ <div class="flex items-center justify-between mb-4">
295
+ <div class="flex items-center gap-3">
296
+ <h3 class="text-lg font-bold font-mono">{table.name}</h3>
297
+ <span class="text-xs text-text-muted tabular-nums">{totalCount} row(s)</span>
298
+ {selectedRows.size > 0 && (
299
+ <button
300
+ onClick={handleBulkDelete}
301
+ class="rounded-md px-3 py-1.5 text-sm font-medium bg-red-500 text-white hover:bg-red-600 transition-all"
302
+ >
303
+ Delete selected ({selectedRows.size})
304
+ </button>
305
+ )}
306
+ </div>
307
+ <div class="flex items-center gap-2">
308
+ <button
309
+ onClick={() => setShowFilters(v => !v)}
310
+ class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
311
+ showFilters || activeFilterCount > 0
312
+ ? "bg-ink text-surface"
313
+ : "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
314
+ }`}
315
+ >
316
+ Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
317
+ </button>
318
+ {hasActiveState && (
319
+ <button
320
+ onClick={handleReset}
321
+ class="rounded-md px-3 py-1.5 text-sm font-medium text-red-400 bg-panel border border-border hover:text-red-600 hover:bg-red-50 transition-all"
322
+ title="Clear all filters and sorting"
323
+ >
324
+ Reset
325
+ </button>
326
+ )}
327
+ <button
328
+ onClick={() => setShowFilterHelp(true)}
329
+ class="rounded-md w-7 h-7 text-sm font-bold bg-panel border border-border text-text-muted hover:text-text-data hover:bg-panel-hover transition-all"
330
+ title="Filter syntax help"
331
+ >
332
+ ?
333
+ </button>
334
+ <button
335
+ onClick={() => setShowBrowserHistory(v => !v)}
336
+ class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
337
+ showBrowserHistory
338
+ ? "bg-ink text-surface"
339
+ : "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
340
+ }`}
341
+ >
342
+ History{browserHistory.entries.length > 0 ? ` (${browserHistory.entries.length})` : ""}
343
+ </button>
344
+ <div class="relative">
345
+ <button
346
+ onClick={() => setShowExport(v => !v)}
347
+ class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
348
+ showExport
349
+ ? "bg-ink text-surface"
350
+ : "bg-panel border border-border text-text-secondary hover:bg-panel-hover"
351
+ }`}
352
+ >
353
+ Export
354
+ </button>
355
+ {showExport && (
356
+ <div class="absolute right-0 top-full mt-1 bg-panel rounded-lg border border-border shadow-lg z-10 py-1 min-w-[120px]">
357
+ <button
358
+ onClick={() => { exportCSV(displayCols, rows, table.name); setShowExport(false); }}
359
+ class="w-full text-left px-3 py-2 text-sm text-text-data hover:bg-panel-hover transition-colors"
360
+ >
361
+ CSV
362
+ </button>
363
+ <button
364
+ onClick={() => { exportJSON(rows, table.name); setShowExport(false); }}
365
+ class="w-full text-left px-3 py-2 text-sm text-text-data hover:bg-panel-hover transition-colors"
366
+ >
367
+ JSON
368
+ </button>
369
+ </div>
370
+ )}
371
+ </div>
372
+ <button
373
+ onClick={() => setShowInsert(!showInsert)}
374
+ class={`rounded-md px-3 py-1.5 text-sm font-medium transition-all ${
375
+ showInsert
376
+ ? "bg-panel-active text-text-data"
377
+ : "bg-ink text-surface hover:opacity-80"
378
+ }`}
379
+ >
380
+ {showInsert ? "Cancel" : "+ Add Row"}
381
+ </button>
382
+ <button
383
+ onClick={() => loadData(offset)}
384
+ disabled={loading}
385
+ class="rounded-md px-3 py-1.5 text-sm font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 transition-all"
386
+ >
387
+ Refresh
388
+ </button>
389
+ </div>
390
+ </div>
391
+
392
+ {/* Current SQL */}
393
+ <div
394
+ onClick={() => onOpenInConsole(currentSql)}
395
+ class="mb-4 px-3 py-2 bg-panel-secondary border border-border rounded-lg flex items-center gap-2 cursor-pointer hover:bg-panel-hover hover:border-border transition-colors group"
396
+ title="Open in SQL Console"
397
+ >
398
+ <code class="flex-1 text-xs font-mono text-text-secondary truncate">{currentSql}</code>
399
+ <span class="text-xs text-text-dim group-hover:text-text-secondary transition-colors flex-shrink-0">&rarr; SQL Console</span>
400
+ </div>
401
+
402
+ {/* Browser history */}
403
+ {showBrowserHistory && (
404
+ <BrowserHistoryPanel
405
+ entries={browserHistory.entries}
406
+ currentTable={table.name}
407
+ onSelect={(entry) => { onRestoreHistory(entry); setShowBrowserHistory(false); }}
408
+ onClear={browserHistory.clear}
409
+ />
410
+ )}
411
+
412
+ {/* Error banner */}
413
+ {error && (
414
+ <div class="bg-red-50 text-red-600 p-3 rounded-lg text-sm font-medium mb-4 flex items-center justify-between">
415
+ <span>{error}</span>
416
+ <button onClick={() => setError(null)} class="text-red-400 hover:text-red-600 ml-3 text-xs">dismiss</button>
417
+ </div>
418
+ )}
419
+
420
+ {/* Data table */}
421
+ <div class="bg-panel rounded-lg border border-border overflow-x-auto">
422
+ <table class="w-full text-sm">
423
+ <thead>
424
+ <tr class="border-b border-border-subtle">
425
+ <th class="w-10 px-3 py-2.5">
426
+ <input
427
+ type="checkbox"
428
+ checked={rows.length > 0 && selectedRows.size === rows.length}
429
+ onChange={toggleAll}
430
+ class="rounded border-border accent-ink"
431
+ />
432
+ </th>
433
+ {displayCols.map(col => (
434
+ <th
435
+ key={col}
436
+ onClick={() => handleSort(col)}
437
+ class={`${numericCols.has(col) ? "text-right" : "text-left"} px-4 py-2.5 font-medium text-xs text-text-muted uppercase tracking-wider font-mono cursor-pointer hover:text-text-data select-none`}
438
+ >
439
+ {col}
440
+ {fkMap.has(col) && <span class="ml-1 text-link text-[10px]" title={`FK → ${fkMap.get(col)!.targetTable}`}>FK</span>}
441
+ {sortCol === col && (
442
+ <span class="ml-1">{sortDir === "ASC" ? "\u2191" : "\u2193"}</span>
443
+ )}
444
+ </th>
445
+ ))}
446
+ <th class="w-24 px-4 py-2.5"></th>
447
+ </tr>
448
+ {showFilters && (
449
+ <FilterRow
450
+ columns={displayCols}
451
+ filters={filters}
452
+ onFilterChange={(col, val) => setFilters(f => {
453
+ const next = { ...f };
454
+ if (val) next[col] = val;
455
+ else delete next[col];
456
+ return next;
457
+ })}
458
+ onClearAll={() => setFilters({})}
459
+ hasCheckboxCol
460
+ />
461
+ )}
462
+ </thead>
463
+ <tbody>
464
+ {showInsert && (
465
+ <InsertRowForm
466
+ schema={schema}
467
+ displayCols={displayCols}
468
+ onSave={handleInsert}
469
+ onCancel={() => setShowInsert(false)}
470
+ hasCheckboxCol
471
+ />
472
+ )}
473
+ {loading && rows.length === 0 ? (
474
+ <tr>
475
+ <td colSpan={displayCols.length + 2} class="px-4 py-8 text-center text-text-muted text-sm">Loading...</td>
476
+ </tr>
477
+ ) : rows.length === 0 ? (
478
+ <tr>
479
+ <td colSpan={displayCols.length + 2} class="px-4 py-8 text-center text-text-muted text-sm">No rows</td>
480
+ </tr>
481
+ ) : (
482
+ rows.map((row, i) => (
483
+ <tr key={i} class={`group border-b border-border-subtle last:border-0 hover:bg-panel-hover/50 transition-colors ${selectedRows.has(rowKey(row)) ? "bg-blue-500/10" : i % 2 === 1 ? "bg-panel-hover/20" : ""}`}>
484
+ <td class="px-3 py-2">
485
+ <input
486
+ type="checkbox"
487
+ checked={selectedRows.has(rowKey(row))}
488
+ onChange={() => toggleRow(row)}
489
+ class="rounded border-border accent-ink"
490
+ />
491
+ </td>
492
+ {displayCols.map(col => (
493
+ <td key={col} class="px-4 py-0">
494
+ <EditableCell
495
+ value={row[col]}
496
+ onSave={(v) => handleUpdate(row, col, v)}
497
+ foreignKey={fkMap.get(col) ?? null}
498
+ onNavigateFK={(fk) => onNavigateFK(fk.targetTable, fk.targetColumn, row[col])}
499
+ onInspect={() => setInspectCell({ column: col, value: row[col] })}
500
+ alignRight={numericCols.has(col)}
501
+ />
502
+ </td>
503
+ ))}
504
+ <td class="px-4 py-2 text-right whitespace-nowrap">
505
+ <button
506
+ onClick={() => setDetailRow(row)}
507
+ class="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-data text-xs font-medium rounded-md px-2 py-1 hover:bg-panel-hover transition-all mr-1"
508
+ >
509
+ Detail
510
+ </button>
511
+ <button
512
+ onClick={() => handleDelete(row)}
513
+ class="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-600 text-xs font-medium rounded-md px-2 py-1 hover:bg-red-50 transition-all"
514
+ >
515
+ Delete
516
+ </button>
517
+ </td>
518
+ </tr>
519
+ ))
520
+ )}
521
+ </tbody>
522
+ </table>
523
+ </div>
524
+
525
+ {/* Pagination */}
526
+ <div class="flex items-center justify-between mt-4">
527
+ <span class="text-xs text-text-muted tabular-nums">{rangeStart}–{rangeEnd} of {totalCount}</span>
528
+ <div class="flex items-center gap-2">
529
+ <button
530
+ onClick={() => loadData(offset - PAGE_SIZE)}
531
+ disabled={offset === 0 || loading}
532
+ class="rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
533
+ >
534
+ Prev
535
+ </button>
536
+ <span class="text-xs text-text-muted tabular-nums">{currentPage} / {totalPages}</span>
537
+ <button
538
+ onClick={() => loadData(offset + PAGE_SIZE)}
539
+ disabled={offset + PAGE_SIZE >= totalCount || loading}
540
+ class="rounded-md px-3 py-1.5 text-xs font-medium bg-panel border border-border text-text-secondary hover:bg-panel-hover disabled:opacity-40 disabled:cursor-not-allowed transition-all"
541
+ >
542
+ Next
543
+ </button>
544
+ </div>
545
+ </div>
546
+
547
+ {showFilterHelp && <FilterHelpModal onClose={() => setShowFilterHelp(false)} />}
548
+ {detailRow && (
549
+ <RowDetailModal
550
+ columns={displayCols}
551
+ row={detailRow}
552
+ fkMap={fkMap}
553
+ onClose={() => setDetailRow(null)}
554
+ onNavigateFK={(t, c, v) => { setDetailRow(null); onNavigateFK(t, c, v); }}
555
+ />
556
+ )}
557
+ {inspectCell && (
558
+ <CellInspectorModal
559
+ column={inspectCell.column}
560
+ value={inspectCell.value}
561
+ onClose={() => setInspectCell(null)}
562
+ />
563
+ )}
564
+ </div>
565
+ );
566
+ }