dbdiff-app 0.1.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 (69) hide show
  1. package/README.md +73 -0
  2. package/bin/cli.js +83 -0
  3. package/bin/install-local.js +57 -0
  4. package/electron/generate-icon.mjs +54 -0
  5. package/electron/icon.icns +0 -0
  6. package/electron/icon.png +0 -0
  7. package/electron/icon.svg +21 -0
  8. package/electron/main.js +169 -0
  9. package/electron/patch-dev-plist.js +31 -0
  10. package/electron/preload.cjs +18 -0
  11. package/electron/wait-for-vite.js +43 -0
  12. package/index.html +13 -0
  13. package/package.json +91 -0
  14. package/public/favicon.svg +15 -0
  15. package/public/vite.svg +1 -0
  16. package/server/export.ts +57 -0
  17. package/server/index.ts +392 -0
  18. package/src/App.css +1 -0
  19. package/src/App.tsx +543 -0
  20. package/src/assets/react.svg +1 -0
  21. package/src/components/CommandPalette.tsx +243 -0
  22. package/src/components/ConnectedView.tsx +78 -0
  23. package/src/components/ConnectionPicker.tsx +381 -0
  24. package/src/components/ConsoleView.tsx +360 -0
  25. package/src/components/CsvExportModal.tsx +144 -0
  26. package/src/components/DataGrid/DataGrid.tsx +262 -0
  27. package/src/components/DataGrid/DataGridCell.tsx +73 -0
  28. package/src/components/DataGrid/DataGridHeader.tsx +89 -0
  29. package/src/components/DataGrid/index.ts +20 -0
  30. package/src/components/DataGrid/types.ts +63 -0
  31. package/src/components/DataGrid/useColumnResize.ts +153 -0
  32. package/src/components/DataGrid/useDataGridSelection.ts +340 -0
  33. package/src/components/DataGrid/utils.ts +184 -0
  34. package/src/components/DatabaseMenu.tsx +93 -0
  35. package/src/components/DatabaseSwitcher.tsx +208 -0
  36. package/src/components/DiffView.tsx +215 -0
  37. package/src/components/EditConnectionModal.tsx +417 -0
  38. package/src/components/ErrorBoundary.tsx +69 -0
  39. package/src/components/GlobalShortcuts.tsx +201 -0
  40. package/src/components/InnerTabBar.tsx +129 -0
  41. package/src/components/JsonTreeViewer.tsx +387 -0
  42. package/src/components/MemberAccessEditor.tsx +443 -0
  43. package/src/components/MembersModal.tsx +446 -0
  44. package/src/components/NewConnectionModal.tsx +274 -0
  45. package/src/components/Resizer.tsx +66 -0
  46. package/src/components/ScanSuccessModal.tsx +113 -0
  47. package/src/components/ShortcutSettingsModal.tsx +318 -0
  48. package/src/components/Sidebar.tsx +532 -0
  49. package/src/components/TabBar.tsx +188 -0
  50. package/src/components/TableView.tsx +2147 -0
  51. package/src/components/ThemeToggle.tsx +44 -0
  52. package/src/components/index.ts +17 -0
  53. package/src/constants.ts +12 -0
  54. package/src/electron.d.ts +12 -0
  55. package/src/index.css +44 -0
  56. package/src/main.tsx +13 -0
  57. package/src/stores/hooks.ts +1146 -0
  58. package/src/stores/index.ts +12 -0
  59. package/src/stores/store.ts +1514 -0
  60. package/src/stores/useCloudSync.ts +274 -0
  61. package/src/stores/useSyncDatabase.ts +422 -0
  62. package/src/types.ts +277 -0
  63. package/src/utils/csv.ts +27 -0
  64. package/src/vite-env.d.ts +2 -0
  65. package/tsconfig.app.json +28 -0
  66. package/tsconfig.json +7 -0
  67. package/tsconfig.node.json +26 -0
  68. package/tsconfig.server.json +14 -0
  69. package/vite.config.ts +14 -0
@@ -0,0 +1,1146 @@
1
+ import { useCallback, useEffect, useRef } from "react";
2
+ import type {
3
+ CellChange,
4
+ ConsoleTabState,
5
+ DiffResponse,
6
+ InnerTab,
7
+ QueryResponse,
8
+ ShortcutAction,
9
+ TableCellEditState,
10
+ TableMetadata,
11
+ TableTabState,
12
+ } from "../types";
13
+ import { PAGE_SIZE } from "../constants";
14
+ import { DEFAULT_SHORTCUTS, useStore } from "./store";
15
+
16
+ // ============================================================================
17
+ // Keyboard Shortcuts
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Parse a shortcut string like "mod+shift+k" into its components.
22
+ * "mod" maps to Cmd on Mac, Ctrl on Windows/Linux.
23
+ */
24
+ function parseShortcut(shortcut: string) {
25
+ const parts = shortcut.toLowerCase().split("+");
26
+ return {
27
+ mod: parts.includes("mod"),
28
+ ctrl: parts.includes("ctrl"),
29
+ alt: parts.includes("alt"),
30
+ shift: parts.includes("shift"),
31
+ key: parts[parts.length - 1],
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Check if a keyboard event matches a shortcut config.
37
+ */
38
+ function matchesShortcut(e: KeyboardEvent, shortcut: string): boolean {
39
+ const parsed = parseShortcut(shortcut);
40
+ const isMac = navigator.platform.toUpperCase().includes("MAC");
41
+
42
+ // "mod" = Cmd on Mac, Ctrl elsewhere
43
+ const modPressed = isMac ? e.metaKey : e.ctrlKey;
44
+
45
+ // On macOS, Alt+letter produces special characters (e.g. Alt+T → †).
46
+ // Use e.code to get the physical key when Alt is held.
47
+ let key = e.key.toLowerCase();
48
+ if (e.altKey && e.code.startsWith("Key")) {
49
+ key = e.code.slice(3).toLowerCase();
50
+ }
51
+
52
+ // Check modifiers
53
+ if (parsed.mod && !modPressed) return false;
54
+ // On Mac, accept Cmd as equivalent to Ctrl (so Cmd+Enter works like Ctrl+Enter)
55
+ if (parsed.ctrl && !e.ctrlKey && !(isMac && e.metaKey)) return false;
56
+ if (parsed.alt && !e.altKey) return false;
57
+ if (parsed.shift && !e.shiftKey) return false;
58
+
59
+ // Check that we don't have extra modifiers
60
+ if (!parsed.mod && !parsed.ctrl && (e.ctrlKey || e.metaKey)) return false;
61
+ if (!parsed.alt && e.altKey) return false;
62
+ if (!parsed.shift && e.shiftKey) return false;
63
+
64
+ // Check key
65
+ const expectedKey = parsed.key;
66
+
67
+ // Handle special keys
68
+ if (expectedKey === "enter" && key === "enter") return true;
69
+ if (expectedKey === "escape" && key === "escape") return true;
70
+ if (expectedKey === "tab" && key === "tab") return true;
71
+ if (expectedKey === "[" && key === "[") return true;
72
+ if (expectedKey === "]" && key === "]") return true;
73
+ // Handle delete key (also match backspace since Mac keyboards use backspace for Delete)
74
+ if (expectedKey === "delete" && (key === "delete" || key === "backspace"))
75
+ return true;
76
+
77
+ return key === expectedKey;
78
+ }
79
+
80
+ interface UseHotkeyOptions {
81
+ /** Only trigger when this is true */
82
+ enabled?: boolean;
83
+ /** Prevent default browser behavior */
84
+ preventDefault?: boolean;
85
+ }
86
+
87
+ /**
88
+ * Get the configured shortcut for an action (override or default)
89
+ */
90
+ export function useShortcut(action: ShortcutAction): string {
91
+ const override = useStore((state) => state.shortcutOverrides[action]);
92
+ return override ?? DEFAULT_SHORTCUTS[action];
93
+ }
94
+
95
+ export function useHotkey(
96
+ action: ShortcutAction,
97
+ handler: () => void,
98
+ options: UseHotkeyOptions = {},
99
+ ) {
100
+ const { enabled = true, preventDefault = true } = options;
101
+ const shortcut = useShortcut(action);
102
+ const handlerRef = useRef(handler);
103
+
104
+ // Keep handler ref updated to avoid stale closures
105
+ useEffect(() => {
106
+ handlerRef.current = handler;
107
+ }, [handler]);
108
+
109
+ useEffect(() => {
110
+ if (!enabled || !shortcut) return;
111
+
112
+ const listener = (e: KeyboardEvent) => {
113
+ // Don't trigger if user is typing in an input/textarea (unless it's a special case)
114
+ const target = e.target as HTMLElement;
115
+ const isInput =
116
+ target.tagName === "INPUT" || target.tagName === "TEXTAREA";
117
+ const isContentEditable = target.isContentEditable;
118
+ const isInCodeMirror = !!target.closest?.(".cm-editor");
119
+
120
+ if (isInCodeMirror) {
121
+ // In CodeMirror: block only shortcuts that conflict with text editing
122
+ if (action === "deleteRows" || action === "selectAll") return;
123
+ } else if (isInput || isContentEditable) {
124
+ // In regular inputs: only allow specific shortcuts
125
+ const allowInInput = action === "runQuery" || action === "closeModal";
126
+ if (!allowInInput) return;
127
+ }
128
+
129
+ if (matchesShortcut(e, shortcut)) {
130
+ if (preventDefault) {
131
+ e.preventDefault();
132
+ }
133
+ handlerRef.current();
134
+ }
135
+ };
136
+
137
+ window.addEventListener("keydown", listener);
138
+ return () => window.removeEventListener("keydown", listener);
139
+ }, [shortcut, enabled, preventDefault, action]);
140
+ }
141
+
142
+ /**
143
+ * Get the display string for a shortcut (e.g., "⌘T" or "Ctrl+T")
144
+ */
145
+ export function useShortcutDisplay(action: ShortcutAction): string {
146
+ const shortcut = useShortcut(action);
147
+ return formatShortcutDisplay(shortcut);
148
+ }
149
+
150
+ export function formatShortcutDisplay(shortcut: string): string {
151
+ const isMac =
152
+ typeof navigator !== "undefined" &&
153
+ navigator.platform.toUpperCase().includes("MAC");
154
+
155
+ const parts = shortcut.toLowerCase().split("+");
156
+
157
+ const symbols: string[] = [];
158
+ for (const part of parts) {
159
+ switch (part) {
160
+ case "mod":
161
+ symbols.push(isMac ? "⌘" : "Ctrl");
162
+ break;
163
+ case "ctrl":
164
+ symbols.push(isMac ? "⌃" : "Ctrl");
165
+ break;
166
+ case "alt":
167
+ symbols.push(isMac ? "⌥" : "Alt");
168
+ break;
169
+ case "shift":
170
+ symbols.push(isMac ? "⇧" : "Shift");
171
+ break;
172
+ case "enter":
173
+ symbols.push(isMac ? "↵" : "Enter");
174
+ break;
175
+ case "escape":
176
+ symbols.push("Esc");
177
+ break;
178
+ case "delete":
179
+ symbols.push(isMac ? "⌫" : "Del");
180
+ break;
181
+ case "backspace":
182
+ symbols.push(isMac ? "⌫" : "Backspace");
183
+ break;
184
+ default:
185
+ symbols.push(part.toUpperCase());
186
+ }
187
+ }
188
+
189
+ return isMac ? symbols.join("") : symbols.join("+");
190
+ }
191
+
192
+ /** Get the database config for the currently active connection tab */
193
+ export function useActiveDatabaseConfig() {
194
+ return useStore((state) => {
195
+ const activeTab = state.connectionTabs.find(
196
+ (t) => t.id === state.activeTabId,
197
+ );
198
+ if (!activeTab?.databaseConfigId) return null;
199
+ return (
200
+ state.databaseConfigs.find((c) => c.id === activeTab.databaseConfigId) ??
201
+ null
202
+ );
203
+ });
204
+ }
205
+
206
+ export interface OpenTableTabOptions {
207
+ whereClause?: string;
208
+ forceNew?: boolean;
209
+ }
210
+
211
+ /** Open a table tab, or focus it if already open */
212
+ export function useOpenTableTab() {
213
+ const addInnerTab = useStore((state) => state.addInnerTab);
214
+ const selectInnerTab = useStore((state) => state.selectInnerTab);
215
+ const getActiveTab = useStore((state) => state.getActiveTab);
216
+ const initTableState = useStore((state) => state.initTableState);
217
+ const updateTableState = useStore((state) => state.updateTableState);
218
+
219
+ return (tableName: string, options?: OpenTableTabOptions) => {
220
+ const activeTab = getActiveTab();
221
+
222
+ // If a whereClause or forceNew is provided, always create a new tab (don't reuse)
223
+ if (!options?.whereClause && !options?.forceNew) {
224
+ const existingTab = activeTab?.innerTabs.find(
225
+ (t) => t.type === "table" && t.name === tableName,
226
+ );
227
+
228
+ if (existingTab) {
229
+ selectInnerTab(existingTab.id);
230
+
231
+ // Trigger refresh if no pending changes
232
+ const tableState = useStore.getState().tableStates[existingTab.id];
233
+ if (tableState) {
234
+ const hasPendingChanges =
235
+ Object.keys(tableState.cellEditState.pendingChanges).length > 0 ||
236
+ tableState.cellEditState.pendingNewRows.length > 0;
237
+
238
+ if (!hasPendingChanges) {
239
+ // Reset status to idle to trigger auto-execute in TableView
240
+ updateTableState(existingTab.id, { status: "idle" });
241
+ }
242
+ }
243
+ return;
244
+ }
245
+ }
246
+
247
+ const newInnerTab: InnerTab = {
248
+ id: Date.now().toString(),
249
+ type: "table",
250
+ name: tableName,
251
+ };
252
+ addInnerTab(newInnerTab);
253
+ // Pass initial whereClause so it's set before auto-execute triggers
254
+ initTableState(newInnerTab.id, tableName, options?.whereClause);
255
+ };
256
+ }
257
+
258
+ /** Create a new console tab with auto-numbered name */
259
+ export function useNewConsoleTab() {
260
+ const addInnerTab = useStore((state) => state.addInnerTab);
261
+ const getActiveTab = useStore((state) => state.getActiveTab);
262
+ const initConsoleState = useStore((state) => state.initConsoleState);
263
+
264
+ return () => {
265
+ const activeTab = getActiveTab();
266
+ const consoleCount =
267
+ activeTab?.innerTabs.filter((t) => t.type === "console").length ?? 0;
268
+
269
+ const newInnerTab: InnerTab = {
270
+ id: Date.now().toString(),
271
+ type: "console",
272
+ name: consoleCount === 0 ? "Console" : `Console ${consoleCount + 1}`,
273
+ };
274
+ addInnerTab(newInnerTab);
275
+ initConsoleState(newInnerTab.id);
276
+ };
277
+ }
278
+
279
+ /** Read the database config for the active connection tab (non-reactive, for use in callbacks) */
280
+ function getActiveDatabaseConfigSnapshot() {
281
+ const state = useStore.getState();
282
+ const activeTab = state.connectionTabs.find(
283
+ (t) => t.id === state.activeTabId,
284
+ );
285
+ if (!activeTab?.databaseConfigId) return null;
286
+ return (
287
+ state.databaseConfigs.find((c) => c.id === activeTab.databaseConfigId) ??
288
+ null
289
+ );
290
+ }
291
+
292
+ const DEFAULT_CONSOLE_STATE: ConsoleTabState = {
293
+ queryText: "",
294
+ status: "idle",
295
+ executionId: null,
296
+ startedAt: null,
297
+ completedAt: null,
298
+ result: null,
299
+ error: null,
300
+ diffResult: null,
301
+ lastAction: null,
302
+ };
303
+
304
+ /** Get console state for a specific tab */
305
+ export function useConsoleState(tabId: string) {
306
+ const consoleState = useStore((state) => state.consoleStates[tabId]);
307
+ const initConsoleState = useStore((state) => state.initConsoleState);
308
+
309
+ // Initialize state if it doesn't exist
310
+ if (!consoleState) {
311
+ initConsoleState(tabId);
312
+ return DEFAULT_CONSOLE_STATE;
313
+ }
314
+
315
+ return consoleState;
316
+ }
317
+
318
+ /** Hook for executing queries with race condition handling */
319
+ export function useConsoleExecution(tabId: string) {
320
+ const updateConsoleState = useStore((state) => state.updateConsoleState);
321
+ const getConsoleState = useCallback(
322
+ () => useStore.getState().consoleStates[tabId],
323
+ [tabId],
324
+ );
325
+
326
+ // Use ref to track current execution ID to handle race conditions
327
+ const currentExecutionRef = useRef<string | null>(null);
328
+
329
+ const execute = useCallback(async () => {
330
+ const consoleState = getConsoleState();
331
+ const databaseConfig = getActiveDatabaseConfigSnapshot();
332
+
333
+ if (!consoleState || !databaseConfig || !consoleState.queryText.trim()) {
334
+ return;
335
+ }
336
+
337
+ // Generate unique execution ID for race condition handling
338
+ const executionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
339
+ currentExecutionRef.current = executionId;
340
+
341
+ // Set executing state
342
+ updateConsoleState(tabId, {
343
+ status: "executing",
344
+ executionId,
345
+ startedAt: Date.now(),
346
+ completedAt: null,
347
+ result: null,
348
+ error: null,
349
+ diffResult: null,
350
+ lastAction: "run",
351
+ });
352
+
353
+ try {
354
+ const response = await fetch("/api/query", {
355
+ method: "POST",
356
+ headers: { "Content-Type": "application/json" },
357
+ body: JSON.stringify({
358
+ connection: databaseConfig.connection,
359
+ query: consoleState.queryText,
360
+ }),
361
+ });
362
+
363
+ // Check if this execution is still current (race condition check)
364
+ if (currentExecutionRef.current !== executionId) {
365
+ return; // Stale response, discard
366
+ }
367
+
368
+ const data = await response.json();
369
+
370
+ // Double-check after async operation
371
+ if (currentExecutionRef.current !== executionId) {
372
+ return; // Stale response, discard
373
+ }
374
+
375
+ if (!response.ok) {
376
+ updateConsoleState(tabId, {
377
+ status: "error",
378
+ completedAt: Date.now(),
379
+ error: data.error || "Query failed",
380
+ });
381
+ } else {
382
+ updateConsoleState(tabId, {
383
+ status: "completed",
384
+ completedAt: Date.now(),
385
+ result: data as QueryResponse,
386
+ });
387
+ }
388
+ } catch (err) {
389
+ // Check if this execution is still current
390
+ if (currentExecutionRef.current !== executionId) {
391
+ return; // Stale response, discard
392
+ }
393
+
394
+ updateConsoleState(tabId, {
395
+ status: "error",
396
+ completedAt: Date.now(),
397
+ error: err instanceof Error ? err.message : "Unknown error",
398
+ });
399
+ }
400
+ }, [tabId, getConsoleState, updateConsoleState]);
401
+
402
+ return { execute };
403
+ }
404
+
405
+ /** Hook for previewing query diff (rolled-back transaction) */
406
+ export function useConsoleDiff(tabId: string) {
407
+ const updateConsoleState = useStore((state) => state.updateConsoleState);
408
+ const getConsoleState = useCallback(
409
+ () => useStore.getState().consoleStates[tabId],
410
+ [tabId],
411
+ );
412
+
413
+ const currentExecutionRef = useRef<string | null>(null);
414
+
415
+ const executeDiff = useCallback(async () => {
416
+ const consoleState = getConsoleState();
417
+ const databaseConfig = getActiveDatabaseConfigSnapshot();
418
+
419
+ if (!consoleState || !databaseConfig || !consoleState.queryText.trim()) {
420
+ return;
421
+ }
422
+
423
+ const executionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
424
+ currentExecutionRef.current = executionId;
425
+
426
+ updateConsoleState(tabId, {
427
+ status: "executing",
428
+ executionId,
429
+ startedAt: Date.now(),
430
+ completedAt: null,
431
+ result: null,
432
+ error: null,
433
+ diffResult: null,
434
+ lastAction: "diff",
435
+ });
436
+
437
+ try {
438
+ const response = await fetch("/api/query-diff", {
439
+ method: "POST",
440
+ headers: { "Content-Type": "application/json" },
441
+ body: JSON.stringify({
442
+ connection: databaseConfig.connection,
443
+ query: consoleState.queryText,
444
+ }),
445
+ });
446
+
447
+ if (currentExecutionRef.current !== executionId) return;
448
+
449
+ const data = await response.json();
450
+
451
+ if (currentExecutionRef.current !== executionId) return;
452
+
453
+ if (!response.ok) {
454
+ updateConsoleState(tabId, {
455
+ status: "error",
456
+ completedAt: Date.now(),
457
+ error: data.error || "Diff failed",
458
+ });
459
+ } else {
460
+ updateConsoleState(tabId, {
461
+ status: "completed",
462
+ completedAt: Date.now(),
463
+ diffResult: data as DiffResponse,
464
+ });
465
+ }
466
+ } catch (err) {
467
+ if (currentExecutionRef.current !== executionId) return;
468
+
469
+ updateConsoleState(tabId, {
470
+ status: "error",
471
+ completedAt: Date.now(),
472
+ error: err instanceof Error ? err.message : "Unknown error",
473
+ });
474
+ }
475
+ }, [tabId, getConsoleState, updateConsoleState]);
476
+
477
+ return { executeDiff };
478
+ }
479
+
480
+ // Table tab hooks
481
+
482
+ const DEFAULT_TABLE_STATE: TableTabState = {
483
+ tableName: "",
484
+ whereClause: "",
485
+ sortColumns: [],
486
+ currentPage: 0,
487
+ totalRowCount: null,
488
+ status: "idle",
489
+ executionId: null,
490
+ startedAt: null,
491
+ completedAt: null,
492
+ result: null,
493
+ error: null,
494
+ cellEditState: {
495
+ selectedCell: null,
496
+ selectedRange: null,
497
+ isDragging: false,
498
+ editingCell: null,
499
+ editValue: "",
500
+ pendingChanges: {},
501
+ pendingNewRows: [],
502
+ pendingDeletions: [],
503
+ },
504
+ };
505
+
506
+ /** Get table state for a specific tab */
507
+ export function useTableState(tabId: string) {
508
+ const tableState = useStore((state) => state.tableStates[tabId]);
509
+ return tableState ?? DEFAULT_TABLE_STATE;
510
+ }
511
+
512
+ /** Hook for executing table queries with race condition handling */
513
+ export function useTableExecution(tabId: string) {
514
+ const updateTableState = useStore((state) => state.updateTableState);
515
+ const getTableState = useCallback(
516
+ () => useStore.getState().tableStates[tabId],
517
+ [tabId],
518
+ );
519
+
520
+ const currentExecutionRef = useRef<string | null>(null);
521
+
522
+ const execute = useCallback(async () => {
523
+ const tableState = getTableState();
524
+ const databaseConfig = getActiveDatabaseConfigSnapshot();
525
+
526
+ if (!tableState || !databaseConfig) {
527
+ return;
528
+ }
529
+
530
+ const executionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
531
+ currentExecutionRef.current = executionId;
532
+
533
+ updateTableState(tabId, {
534
+ status: "executing",
535
+ executionId,
536
+ startedAt: Date.now(),
537
+ completedAt: null,
538
+ // Keep previous result visible while loading
539
+ error: null,
540
+ });
541
+
542
+ // Build WHERE fragment (shared between data and count queries)
543
+ const whereFragment = tableState.whereClause.trim()
544
+ ? ` WHERE ${tableState.whereClause}`
545
+ : "";
546
+
547
+ const quotedTable = getQuotedTableName(tableState.tableName);
548
+
549
+ // Data query with LIMIT/OFFSET
550
+ let dataQuery = `SELECT * FROM ${quotedTable}${whereFragment}`;
551
+ if (tableState.sortColumns.length > 0) {
552
+ const orderByParts = tableState.sortColumns.map(
553
+ (s) => `"${s.column}" ${s.direction}`,
554
+ );
555
+ dataQuery += ` ORDER BY ${orderByParts.join(", ")}`;
556
+ }
557
+ const pageSize =
558
+ databaseConfig.tableConfigs?.[tableState.tableName]?.pageSize ??
559
+ PAGE_SIZE;
560
+ dataQuery += ` LIMIT ${pageSize} OFFSET ${tableState.currentPage * pageSize}`;
561
+
562
+ // Count query (no ORDER BY, no LIMIT)
563
+ const countQuery = `SELECT COUNT(*) AS count FROM ${quotedTable}${whereFragment}`;
564
+
565
+ try {
566
+ const [dataResponse, countResponse] = await Promise.all([
567
+ fetch("/api/query", {
568
+ method: "POST",
569
+ headers: { "Content-Type": "application/json" },
570
+ body: JSON.stringify({
571
+ connection: databaseConfig.connection,
572
+ query: dataQuery,
573
+ }),
574
+ }),
575
+ fetch("/api/query", {
576
+ method: "POST",
577
+ headers: { "Content-Type": "application/json" },
578
+ body: JSON.stringify({
579
+ connection: databaseConfig.connection,
580
+ query: countQuery,
581
+ }),
582
+ }),
583
+ ]);
584
+
585
+ if (currentExecutionRef.current !== executionId) {
586
+ return;
587
+ }
588
+
589
+ const [dataJson, countJson] = await Promise.all([
590
+ dataResponse.json(),
591
+ countResponse.json(),
592
+ ]);
593
+
594
+ if (currentExecutionRef.current !== executionId) {
595
+ return;
596
+ }
597
+
598
+ if (!dataResponse.ok) {
599
+ updateTableState(tabId, {
600
+ status: "error",
601
+ completedAt: Date.now(),
602
+ error: dataJson.error || "Query failed",
603
+ });
604
+ } else {
605
+ // Parse count — gracefully degrade if count query failed
606
+ let totalRowCount: number | null = null;
607
+ if (countResponse.ok && countJson.rows?.[0]?.count != null) {
608
+ totalRowCount = parseInt(countJson.rows[0].count, 10);
609
+ }
610
+
611
+ updateTableState(tabId, {
612
+ status: "completed",
613
+ completedAt: Date.now(),
614
+ result: dataJson as QueryResponse,
615
+ totalRowCount,
616
+ });
617
+ }
618
+ } catch (err) {
619
+ if (currentExecutionRef.current !== executionId) {
620
+ return;
621
+ }
622
+
623
+ updateTableState(tabId, {
624
+ status: "error",
625
+ completedAt: Date.now(),
626
+ error: err instanceof Error ? err.message : "Unknown error",
627
+ });
628
+ }
629
+ }, [tabId, getTableState, updateTableState]);
630
+
631
+ return { execute };
632
+ }
633
+
634
+ // ============================================================================
635
+ // Cell Editing Hooks
636
+ // ============================================================================
637
+
638
+ /** Get cell edit state for a table tab */
639
+ export function useTableCellEdit(tabId: string) {
640
+ const cellEditState = useStore(
641
+ (state) => state.tableStates[tabId]?.cellEditState,
642
+ );
643
+ const selectCell = useStore((state) => state.selectCell);
644
+ const selectCellRange = useStore((state) => state.selectCellRange);
645
+ const setCellDragging = useStore((state) => state.setCellDragging);
646
+ const startEditingCell = useStore((state) => state.startEditingCell);
647
+ const updateEditValue = useStore((state) => state.updateEditValue);
648
+ const commitCellEdit = useStore((state) => state.commitCellEdit);
649
+ const cancelCellEdit = useStore((state) => state.cancelCellEdit);
650
+ const clearPendingChanges = useStore((state) => state.clearPendingChanges);
651
+ const revertCellChangeStore = useStore((state) => state.revertCellChange);
652
+ const setCellToNullStore = useStore((state) => state.setCellToNull);
653
+ const addNewRowStore = useStore((state) => state.addNewRow);
654
+ const removeNewRowStore = useStore((state) => state.removeNewRow);
655
+ const setNewRowValueStore = useStore((state) => state.setNewRowValue);
656
+ const setNewRowToDefaultStore = useStore((state) => state.setNewRowToDefault);
657
+ const pasteCellRangeStore = useStore((state) => state.pasteCellRange);
658
+ const markRowsForDeletionStore = useStore(
659
+ (state) => state.markRowsForDeletion,
660
+ );
661
+
662
+ const defaultState: TableCellEditState = {
663
+ selectedCell: null,
664
+ selectedRange: null,
665
+ isDragging: false,
666
+ editingCell: null,
667
+ editValue: "",
668
+ pendingChanges: {},
669
+ pendingNewRows: [],
670
+ pendingDeletions: [],
671
+ };
672
+
673
+ return {
674
+ ...(cellEditState ?? defaultState),
675
+ selectCell: useCallback(
676
+ (cell: { rowIndex: number; columnName: string } | null) =>
677
+ selectCell(tabId, cell),
678
+ [tabId, selectCell],
679
+ ),
680
+ selectCellRange: useCallback(
681
+ (
682
+ range: {
683
+ start: { rowIndex: number; columnName: string };
684
+ end: { rowIndex: number; columnName: string };
685
+ } | null,
686
+ ) => selectCellRange(tabId, range),
687
+ [tabId, selectCellRange],
688
+ ),
689
+ setCellDragging: useCallback(
690
+ (isDragging: boolean) => setCellDragging(tabId, isDragging),
691
+ [tabId, setCellDragging],
692
+ ),
693
+ startEditingCell: useCallback(
694
+ (
695
+ cell: { rowIndex: number; columnName: string },
696
+ initialValue: string | null,
697
+ ) => startEditingCell(tabId, cell, initialValue),
698
+ [tabId, startEditingCell],
699
+ ),
700
+ updateEditValue: useCallback(
701
+ (value: string) => updateEditValue(tabId, value),
702
+ [tabId, updateEditValue],
703
+ ),
704
+ commitCellEdit: useCallback(
705
+ () => commitCellEdit(tabId),
706
+ [tabId, commitCellEdit],
707
+ ),
708
+ cancelCellEdit: useCallback(
709
+ () => cancelCellEdit(tabId),
710
+ [tabId, cancelCellEdit],
711
+ ),
712
+ clearPendingChanges: useCallback(
713
+ () => clearPendingChanges(tabId),
714
+ [tabId, clearPendingChanges],
715
+ ),
716
+ revertCellChange: useCallback(
717
+ (cell: { rowIndex: number; columnName: string }) =>
718
+ revertCellChangeStore(tabId, cell),
719
+ [tabId, revertCellChangeStore],
720
+ ),
721
+ setCellToNull: useCallback(
722
+ (cell: { rowIndex: number; columnName: string }) =>
723
+ setCellToNullStore(tabId, cell),
724
+ [tabId, setCellToNullStore],
725
+ ),
726
+ addNewRow: useCallback(
727
+ () => addNewRowStore(tabId),
728
+ [tabId, addNewRowStore],
729
+ ),
730
+ removeNewRow: useCallback(
731
+ (tempId: string) => removeNewRowStore(tabId, tempId),
732
+ [tabId, removeNewRowStore],
733
+ ),
734
+ setNewRowValue: useCallback(
735
+ (
736
+ tempId: string,
737
+ columnName: string,
738
+ value: string | null,
739
+ isExplicit: boolean,
740
+ ) => setNewRowValueStore(tabId, tempId, columnName, value, isExplicit),
741
+ [tabId, setNewRowValueStore],
742
+ ),
743
+ setNewRowToDefault: useCallback(
744
+ (tempId: string, columnName: string) =>
745
+ setNewRowToDefaultStore(tabId, tempId, columnName),
746
+ [tabId, setNewRowToDefaultStore],
747
+ ),
748
+ pasteCellRange: useCallback(
749
+ (
750
+ cells: Array<{
751
+ rowIndex: number;
752
+ columnName: string;
753
+ value: string | null;
754
+ }>,
755
+ ) => pasteCellRangeStore(tabId, cells),
756
+ [tabId, pasteCellRangeStore],
757
+ ),
758
+ markRowsForDeletion: useCallback(
759
+ (rowIndices: number[]) => markRowsForDeletionStore(tabId, rowIndices),
760
+ [tabId, markRowsForDeletionStore],
761
+ ),
762
+ };
763
+ }
764
+
765
+ /** Get primary key columns for a table from cached schema metadata */
766
+ export function useTablePrimaryKey(tableName: string): string[] {
767
+ const databaseConfig = useActiveDatabaseConfig();
768
+
769
+ if (!databaseConfig?.cache?.schemas) return [];
770
+
771
+ // Parse tableName - could be "schema.table" or just "table"
772
+ const parts = tableName.split(".");
773
+ let schemaName = "public";
774
+ let tableNameOnly = tableName;
775
+
776
+ if (parts.length === 2) {
777
+ schemaName = parts[0];
778
+ tableNameOnly = parts[1];
779
+ }
780
+
781
+ // Find the schema
782
+ const schema = databaseConfig.cache.schemas.find(
783
+ (s) => s.name === schemaName,
784
+ );
785
+ if (!schema) return [];
786
+
787
+ // Find the table
788
+ const tableMetadata = schema.tables.find((t) => t.name === tableNameOnly);
789
+ if (!tableMetadata) return [];
790
+
791
+ return tableMetadata.primaryKey;
792
+ }
793
+
794
+ /** Get full table metadata from cached schema */
795
+ export function useTableMetadata(tableName: string): TableMetadata | null {
796
+ const databaseConfig = useActiveDatabaseConfig();
797
+
798
+ if (!databaseConfig?.cache?.schemas) return null;
799
+
800
+ // Parse tableName - could be "schema.table" or just "table"
801
+ const parts = tableName.split(".");
802
+ let schemaName = "public";
803
+ let tableNameOnly = tableName;
804
+
805
+ if (parts.length === 2) {
806
+ schemaName = parts[0];
807
+ tableNameOnly = parts[1];
808
+ }
809
+
810
+ // Find the schema
811
+ const schema = databaseConfig.cache.schemas.find(
812
+ (s) => s.name === schemaName,
813
+ );
814
+ if (!schema) return null;
815
+
816
+ // Find the table
817
+ return schema.tables.find((t) => t.name === tableNameOnly) ?? null;
818
+ }
819
+
820
+ export interface ForeignKeyRef {
821
+ schema: string;
822
+ table: string;
823
+ column: string;
824
+ }
825
+
826
+ /** Get a map of column names to their foreign key references for quick lookup */
827
+ export function useForeignKeyMap(
828
+ tableName: string,
829
+ ): Map<string, ForeignKeyRef> {
830
+ const tableMetadata = useTableMetadata(tableName);
831
+
832
+ const map = new Map<string, ForeignKeyRef>();
833
+ if (!tableMetadata) return map;
834
+
835
+ for (const column of tableMetadata.columns) {
836
+ if (column.constraints.isForeignKey && column.constraints.foreignKeyRef) {
837
+ map.set(column.name, column.constraints.foreignKeyRef);
838
+ }
839
+ }
840
+
841
+ return map;
842
+ }
843
+
844
+ export interface IncomingForeignKey {
845
+ fromSchema: string;
846
+ fromTable: string;
847
+ fromColumn: string;
848
+ toColumn: string;
849
+ }
850
+
851
+ /** Get all foreign keys from other tables that reference this table */
852
+ export function useIncomingForeignKeys(
853
+ tableName: string,
854
+ ): IncomingForeignKey[] {
855
+ const databaseConfig = useActiveDatabaseConfig();
856
+
857
+ if (!databaseConfig?.cache?.schemas) return [];
858
+
859
+ // Parse tableName - could be "schema.table" or just "table"
860
+ const parts = tableName.split(".");
861
+ let targetSchema = "public";
862
+ let targetTable = tableName;
863
+
864
+ if (parts.length === 2) {
865
+ targetSchema = parts[0];
866
+ targetTable = parts[1];
867
+ }
868
+
869
+ const incomingFKs: IncomingForeignKey[] = [];
870
+
871
+ // Search all schemas and tables for foreign keys pointing to this table
872
+ for (const schema of databaseConfig.cache.schemas) {
873
+ for (const table of schema.tables) {
874
+ for (const column of table.columns) {
875
+ if (
876
+ column.constraints.isForeignKey &&
877
+ column.constraints.foreignKeyRef
878
+ ) {
879
+ const ref = column.constraints.foreignKeyRef;
880
+ if (ref.schema === targetSchema && ref.table === targetTable) {
881
+ incomingFKs.push({
882
+ fromSchema: schema.name,
883
+ fromTable: table.name,
884
+ fromColumn: column.name,
885
+ toColumn: ref.column,
886
+ });
887
+ }
888
+ }
889
+ }
890
+ }
891
+ }
892
+
893
+ return incomingFKs;
894
+ }
895
+
896
+ /**
897
+ * Escape a value for SQL string literal (single quotes)
898
+ */
899
+ export function escapeSqlString(value: string): string {
900
+ return value.replace(/'/g, "''");
901
+ }
902
+
903
+ /**
904
+ * Format a value for use in a WHERE clause based on its type
905
+ */
906
+ export function formatWhereValue(value: unknown): string {
907
+ if (value === null || value === undefined) {
908
+ return "NULL";
909
+ }
910
+ if (typeof value === "number" || typeof value === "bigint") {
911
+ return String(value);
912
+ }
913
+ if (typeof value === "boolean") {
914
+ return value ? "TRUE" : "FALSE";
915
+ }
916
+ // Default to string - escape single quotes
917
+ return `'${escapeSqlString(String(value))}'`;
918
+ }
919
+
920
+ /**
921
+ * Helper to get quoted table name
922
+ */
923
+ export function getQuotedTableName(tableName: string): string {
924
+ const parts = tableName.split(".");
925
+ if (parts.length === 2) {
926
+ return `"${parts[0]}"."${parts[1]}"`;
927
+ }
928
+ return `"${tableName}"`;
929
+ }
930
+
931
+ /**
932
+ * Generate INSERT queries for pending new rows
933
+ */
934
+ export function useGenerateInsertQueries(
935
+ tabId: string,
936
+ tableName: string,
937
+ ): () => string {
938
+ const pendingNewRows = useStore(
939
+ (state) => state.tableStates[tabId]?.cellEditState.pendingNewRows ?? [],
940
+ );
941
+
942
+ return useCallback(() => {
943
+ if (pendingNewRows.length === 0) return "";
944
+
945
+ const quotedTableName = getQuotedTableName(tableName);
946
+ const queries: string[] = [];
947
+
948
+ for (const newRow of pendingNewRows) {
949
+ const explicitColumns = Array.from(newRow.explicitlySetColumns);
950
+
951
+ if (explicitColumns.length === 0) {
952
+ // No explicit columns - use DEFAULT VALUES
953
+ queries.push(`INSERT INTO ${quotedTableName} DEFAULT VALUES;`);
954
+ } else {
955
+ // Build column list and values
956
+ const columnList = explicitColumns.map((col) => `"${col}"`).join(", ");
957
+ const valueList = explicitColumns
958
+ .map((col) => {
959
+ const value = newRow.values[col];
960
+ if (value === null) {
961
+ return "NULL";
962
+ }
963
+ return `'${escapeSqlString(value)}'`;
964
+ })
965
+ .join(", ");
966
+
967
+ queries.push(
968
+ `INSERT INTO ${quotedTableName} (${columnList}) VALUES (${valueList});`,
969
+ );
970
+ }
971
+ }
972
+
973
+ return queries.join("\n");
974
+ }, [pendingNewRows, tableName]);
975
+ }
976
+
977
+ /**
978
+ * Generate UPDATE queries for pending cell changes
979
+ */
980
+ export function useGenerateUpdateQueries(
981
+ tabId: string,
982
+ tableName: string,
983
+ rows: Record<string, unknown>[],
984
+ ): () => string {
985
+ const primaryKeyColumns = useTablePrimaryKey(tableName);
986
+ const pendingChanges = useStore(
987
+ (state) => state.tableStates[tabId]?.cellEditState.pendingChanges ?? {},
988
+ );
989
+
990
+ return useCallback(() => {
991
+ if (Object.keys(pendingChanges).length === 0) return "";
992
+ if (primaryKeyColumns.length === 0) {
993
+ return "-- ERROR: Cannot generate UPDATE queries without a primary key";
994
+ }
995
+
996
+ // Group changes by row
997
+ const changesByRow = new Map<number, CellChange[]>();
998
+ for (const change of Object.values(pendingChanges)) {
999
+ const existing = changesByRow.get(change.rowIndex) ?? [];
1000
+ existing.push(change);
1001
+ changesByRow.set(change.rowIndex, existing);
1002
+ }
1003
+
1004
+ // Generate UPDATE for each row
1005
+ const quotedTableName = getQuotedTableName(tableName);
1006
+ const queries: string[] = [];
1007
+ for (const [rowIndex, changes] of changesByRow) {
1008
+ const row = rows[rowIndex];
1009
+ if (!row) continue;
1010
+
1011
+ // Build SET clause
1012
+ const setClauses = changes.map((change) => {
1013
+ if (change.newValue === null) {
1014
+ return `"${change.columnName}" = NULL`;
1015
+ }
1016
+ const escapedValue = escapeSqlString(change.newValue);
1017
+ return `"${change.columnName}" = '${escapedValue}'`;
1018
+ });
1019
+
1020
+ // Build WHERE clause from primary key
1021
+ const whereClauses = primaryKeyColumns.map((pkCol) => {
1022
+ const pkValue = row[pkCol];
1023
+ if (pkValue === null || pkValue === undefined) {
1024
+ return `"${pkCol}" IS NULL`;
1025
+ }
1026
+ const escapedPkValue = escapeSqlString(String(pkValue));
1027
+ return `"${pkCol}" = '${escapedPkValue}'`;
1028
+ });
1029
+
1030
+ queries.push(
1031
+ `UPDATE ${quotedTableName} SET ${setClauses.join(", ")} WHERE ${whereClauses.join(" AND ")};`,
1032
+ );
1033
+ }
1034
+
1035
+ return queries.join("\n");
1036
+ }, [pendingChanges, primaryKeyColumns, rows, tableName]);
1037
+ }
1038
+
1039
+ /**
1040
+ * Generate DELETE queries for pending deletions
1041
+ */
1042
+ export function useGenerateDeleteQueries(
1043
+ tabId: string,
1044
+ tableName: string,
1045
+ rows: Record<string, unknown>[],
1046
+ ): () => string {
1047
+ const primaryKeyColumns = useTablePrimaryKey(tableName);
1048
+ const pendingDeletions = useStore(
1049
+ (state) => state.tableStates[tabId]?.cellEditState.pendingDeletions ?? [],
1050
+ );
1051
+
1052
+ return useCallback(() => {
1053
+ if (pendingDeletions.length === 0) return "";
1054
+ if (primaryKeyColumns.length === 0) {
1055
+ return "-- ERROR: Cannot generate DELETE queries without a primary key";
1056
+ }
1057
+
1058
+ const quotedTableName = getQuotedTableName(tableName);
1059
+ const queries: string[] = [];
1060
+
1061
+ for (const rowIndex of pendingDeletions) {
1062
+ const row = rows[rowIndex];
1063
+ if (!row) continue;
1064
+
1065
+ // Build WHERE clause from primary key
1066
+ const whereClauses = primaryKeyColumns.map((pkCol) => {
1067
+ const pkValue = row[pkCol];
1068
+ if (pkValue === null || pkValue === undefined) {
1069
+ return `"${pkCol}" IS NULL`;
1070
+ }
1071
+ const escapedPkValue = escapeSqlString(String(pkValue));
1072
+ return `"${pkCol}" = '${escapedPkValue}'`;
1073
+ });
1074
+
1075
+ queries.push(
1076
+ `DELETE FROM ${quotedTableName} WHERE ${whereClauses.join(" AND ")};`,
1077
+ );
1078
+ }
1079
+
1080
+ return queries.join("\n");
1081
+ }, [pendingDeletions, primaryKeyColumns, rows, tableName]);
1082
+ }
1083
+
1084
+ /**
1085
+ * Generate combined DELETE, UPDATE, and INSERT queries
1086
+ */
1087
+ export function useGenerateCombinedQueries(
1088
+ tabId: string,
1089
+ tableName: string,
1090
+ rows: Record<string, unknown>[],
1091
+ ): () => string {
1092
+ const generateDeleteQueries = useGenerateDeleteQueries(
1093
+ tabId,
1094
+ tableName,
1095
+ rows,
1096
+ );
1097
+ const generateUpdateQueries = useGenerateUpdateQueries(
1098
+ tabId,
1099
+ tableName,
1100
+ rows,
1101
+ );
1102
+ const generateInsertQueries = useGenerateInsertQueries(tabId, tableName);
1103
+
1104
+ return useCallback(() => {
1105
+ const deleteQueries = generateDeleteQueries();
1106
+ const updateQueries = generateUpdateQueries();
1107
+ const insertQueries = generateInsertQueries();
1108
+
1109
+ const parts: string[] = [];
1110
+ // Order: DELETE first, then UPDATE, then INSERT
1111
+ if (deleteQueries) parts.push(deleteQueries);
1112
+ if (updateQueries) parts.push(updateQueries);
1113
+ if (insertQueries) parts.push(insertQueries);
1114
+
1115
+ return parts.join("\n");
1116
+ }, [generateDeleteQueries, generateUpdateQueries, generateInsertQueries]);
1117
+ }
1118
+
1119
+ /** Open a new console tab with pre-filled SQL */
1120
+ export function useOpenConsoleWithQuery() {
1121
+ const addInnerTab = useStore((state) => state.addInnerTab);
1122
+ const getActiveTab = useStore((state) => state.getActiveTab);
1123
+ const initConsoleState = useStore((state) => state.initConsoleState);
1124
+ const setConsoleQueryText = useStore((state) => state.setConsoleQueryText);
1125
+
1126
+ return useCallback(
1127
+ (queryText: string) => {
1128
+ const activeTab = getActiveTab();
1129
+ const consoleCount =
1130
+ activeTab?.innerTabs.filter((t) => t.type === "console").length ?? 0;
1131
+
1132
+ const newInnerTab: InnerTab = {
1133
+ id: Date.now().toString(),
1134
+ type: "console",
1135
+ name: consoleCount === 0 ? "Console" : `Console ${consoleCount + 1}`,
1136
+ };
1137
+ addInnerTab(newInnerTab);
1138
+ initConsoleState(newInnerTab.id);
1139
+ // Set the query text after a microtask to ensure state is initialized
1140
+ setTimeout(() => {
1141
+ setConsoleQueryText(newInnerTab.id, queryText);
1142
+ }, 0);
1143
+ },
1144
+ [addInnerTab, getActiveTab, initConsoleState, setConsoleQueryText],
1145
+ );
1146
+ }