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.
- package/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- 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
|
+
}
|