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,1514 @@
1
+ import { create } from "zustand";
2
+ import { persist } from "zustand/middleware";
3
+ import type {
4
+ CellChange,
5
+ CellPosition,
6
+ CellRange,
7
+ CloudConnectionInfo,
8
+ ConfigSyncState,
9
+ ConnectionTab,
10
+ ConsoleTabState,
11
+ DatabaseConfig,
12
+ DatabaseConfigCache,
13
+ InnerTab,
14
+ PendingNewRow,
15
+ ShortcutAction,
16
+ ShortcutConfig,
17
+ SortColumn,
18
+ TableCellEditState,
19
+ TableTabState,
20
+ } from "../types";
21
+
22
+ // Environment detection (evaluated once at module load)
23
+ const isElectronMac =
24
+ typeof navigator !== "undefined" &&
25
+ navigator.userAgent.includes("Electron") &&
26
+ navigator.userAgent.includes("Macintosh");
27
+
28
+ // Browser/generic defaults
29
+ const BROWSER_SHORTCUTS: ShortcutConfig = {
30
+ newConsole: "alt+t",
31
+ closeInnerTab: "alt+w",
32
+ nextInnerTab: "alt+tab",
33
+ prevInnerTab: "alt+shift+tab",
34
+ newConnectionTab: "mod+alt+n",
35
+ closeConnectionTab: "mod+alt+w",
36
+ prevConnectionTab: "mod+alt+j",
37
+ nextConnectionTab: "mod+alt+k",
38
+ runQuery: "ctrl+enter",
39
+ closeModal: "escape",
40
+ openTableSwitcher: "mod+o",
41
+ openDatabaseSwitcher: "mod+p",
42
+ deleteRows: "delete",
43
+ selectAll: "mod+a",
44
+ refreshTable: "mod+r",
45
+ };
46
+
47
+ // Electron on macOS: use native-feeling Cmd shortcuts since we control the menu
48
+ const ELECTRON_MAC_SHORTCUTS: ShortcutConfig = {
49
+ ...BROWSER_SHORTCUTS,
50
+ newConsole: "mod+t",
51
+ closeInnerTab: "mod+w",
52
+ nextInnerTab: "ctrl+tab",
53
+ prevInnerTab: "ctrl+shift+tab",
54
+ newConnectionTab: "mod+shift+n",
55
+ closeConnectionTab: "mod+shift+w",
56
+ };
57
+
58
+ // Default keyboard shortcuts
59
+ export const DEFAULT_SHORTCUTS: ShortcutConfig = isElectronMac
60
+ ? ELECTRON_MAC_SHORTCUTS
61
+ : BROWSER_SHORTCUTS;
62
+
63
+ // Default database configs (seeded on first load; empty so auto-scan discovers local databases)
64
+ const DEFAULT_DATABASE_CONFIGS: DatabaseConfig[] = [];
65
+
66
+ const DEFAULT_CONNECTION_TAB: ConnectionTab = {
67
+ id: "1",
68
+ name: "New Connection",
69
+ databaseConfigId: null,
70
+ innerTabs: [],
71
+ activeInnerTabId: null,
72
+ };
73
+
74
+ const DEFAULT_CELL_EDIT_STATE: TableCellEditState = {
75
+ selectedCell: null,
76
+ selectedRange: null,
77
+ isDragging: false,
78
+ editingCell: null,
79
+ editValue: "",
80
+ pendingChanges: {},
81
+ pendingNewRows: [],
82
+ pendingDeletions: [],
83
+ };
84
+
85
+ /** Apply the current edit value to pendingChanges/pendingNewRows without clearing editingCell */
86
+ function applyPendingEdit(
87
+ cellEditState: TableCellEditState,
88
+ rows: Record<string, unknown>[] | undefined,
89
+ ): TableCellEditState {
90
+ const { editingCell, editValue, pendingChanges, pendingNewRows } =
91
+ cellEditState;
92
+ if (!editingCell) return cellEditState;
93
+ const { rowIndex, columnName } = editingCell;
94
+
95
+ if (rowIndex < 0) {
96
+ const newRowIndex = Math.abs(rowIndex) - 1;
97
+ const newRow = pendingNewRows[newRowIndex];
98
+ if (!newRow) return cellEditState;
99
+ const updatedNewRows = pendingNewRows.map((row, idx) => {
100
+ if (idx !== newRowIndex) return row;
101
+ const newExplicitlySet = new Set(row.explicitlySetColumns);
102
+ newExplicitlySet.add(columnName);
103
+ return {
104
+ ...row,
105
+ explicitlySetColumns: newExplicitlySet,
106
+ values: {
107
+ ...row.values,
108
+ [columnName]: editValue,
109
+ },
110
+ };
111
+ });
112
+ return { ...cellEditState, pendingNewRows: updatedNewRows };
113
+ }
114
+
115
+ const key = `${rowIndex}:${columnName}`;
116
+ const existingChange = pendingChanges[key];
117
+ const originalValue =
118
+ existingChange?.originalValue ?? rows?.[rowIndex]?.[columnName];
119
+ const originalStr =
120
+ originalValue === null ? null : String(originalValue ?? "");
121
+ const newChanges = { ...pendingChanges };
122
+
123
+ if (editValue === originalStr) {
124
+ delete newChanges[key];
125
+ } else {
126
+ newChanges[key] = {
127
+ rowIndex,
128
+ columnName,
129
+ originalValue,
130
+ newValue: editValue,
131
+ } as CellChange;
132
+ }
133
+ return { ...cellEditState, pendingChanges: newChanges };
134
+ }
135
+
136
+ export interface CloudSyncState {
137
+ status: "idle" | "syncing" | "completed" | "error";
138
+ lastSyncedAt: number | null;
139
+ error: string | null;
140
+ }
141
+
142
+ export interface CloudConnection {
143
+ id: string;
144
+ name: string;
145
+ config: {
146
+ display: { name: string; color: string };
147
+ connection: {
148
+ type: "postgres";
149
+ host: string;
150
+ port: number;
151
+ database: string;
152
+ username: string;
153
+ password: string;
154
+ params?: Record<string, string>;
155
+ };
156
+ };
157
+ role: "owner" | "member";
158
+ access?: Record<string, "write" | "read" | "none">;
159
+ ownerId: string;
160
+ ownerEmail: string;
161
+ updatedAt: string;
162
+ }
163
+
164
+ interface AppState {
165
+ // Persisted state
166
+ databaseConfigs: DatabaseConfig[];
167
+ darkMode: boolean;
168
+ shortcutOverrides: Partial<ShortcutConfig>;
169
+ cloudApiKey: string | null;
170
+ csvExportPrefs: { includeHeaders: boolean; scope: "current" | "all" };
171
+
172
+ // Session-only state
173
+ connectionTabs: ConnectionTab[];
174
+ activeTabId: string;
175
+ draggedTabId: string | null;
176
+ draggedInnerTabId: string | null;
177
+ consoleStates: Record<string, ConsoleTabState>;
178
+ tableStates: Record<string, TableTabState>;
179
+ configSyncStates: Record<string, ConfigSyncState>;
180
+ cloudSyncState: CloudSyncState;
181
+
182
+ // Config actions
183
+ addConfig: (config: DatabaseConfig) => void;
184
+ updateConfig: (id: string, updates: Partial<DatabaseConfig>) => void;
185
+ deleteConfig: (id: string) => void;
186
+ updateConfigCache: (id: string, cache: Partial<DatabaseConfigCache>) => void;
187
+
188
+ // Connection tab actions
189
+ createConnectionTab: () => void;
190
+ closeConnectionTab: (tabId: string) => void;
191
+ selectConnectionTab: (tabId: string) => void;
192
+ connectToDatabase: (databaseConfigId: string) => void;
193
+ reorderConnectionTabs: (fromIndex: number, toIndex: number) => void;
194
+
195
+ // Inner tab actions
196
+ addInnerTab: (innerTab: InnerTab) => void;
197
+ selectInnerTab: (innerTabId: string) => void;
198
+ closeInnerTab: (innerTabId: string) => void;
199
+ reorderInnerTabs: (fromIndex: number, toIndex: number) => void;
200
+
201
+ // Drag actions
202
+ setDraggedTabId: (tabId: string | null) => void;
203
+ setDraggedInnerTabId: (tabId: string | null) => void;
204
+
205
+ // Console state actions
206
+ initConsoleState: (tabId: string) => void;
207
+ updateConsoleState: (
208
+ tabId: string,
209
+ updates: Partial<ConsoleTabState>,
210
+ ) => void;
211
+ setConsoleQueryText: (tabId: string, text: string) => void;
212
+
213
+ // Table state actions
214
+ initTableState: (
215
+ tabId: string,
216
+ tableName: string,
217
+ initialWhereClause?: string,
218
+ ) => void;
219
+ updateTableState: (tabId: string, updates: Partial<TableTabState>) => void;
220
+ setTableWhereClause: (tabId: string, whereClause: string) => void;
221
+ setTablePage: (tabId: string, page: number) => void;
222
+ toggleTableSort: (
223
+ tabId: string,
224
+ column: string,
225
+ addToExisting: boolean,
226
+ ) => void;
227
+ setTableSortColumns: (tabId: string, sortColumns: SortColumn[]) => void;
228
+
229
+ // Cell editing actions
230
+ selectCell: (tabId: string, cell: CellPosition | null) => void;
231
+ selectCellRange: (tabId: string, range: CellRange | null) => void;
232
+ setCellDragging: (tabId: string, isDragging: boolean) => void;
233
+ startEditingCell: (
234
+ tabId: string,
235
+ cell: CellPosition,
236
+ initialValue: string | null,
237
+ ) => void;
238
+ updateEditValue: (tabId: string, value: string | null) => void;
239
+ commitCellEdit: (tabId: string) => void;
240
+ cancelCellEdit: (tabId: string) => void;
241
+ clearPendingChanges: (tabId: string) => void;
242
+ revertCellChange: (tabId: string, cell: CellPosition) => void;
243
+ setCellToNull: (tabId: string, cell: CellPosition) => void;
244
+
245
+ // Batch paste action
246
+ pasteCellRange: (
247
+ tabId: string,
248
+ cells: Array<{
249
+ rowIndex: number;
250
+ columnName: string;
251
+ value: string | null;
252
+ }>,
253
+ ) => void;
254
+
255
+ // New row actions
256
+ addNewRow: (tabId: string) => void;
257
+ removeNewRow: (tabId: string, tempId: string) => void;
258
+ setNewRowValue: (
259
+ tabId: string,
260
+ tempId: string,
261
+ columnName: string,
262
+ value: string | null,
263
+ isExplicit: boolean,
264
+ ) => void;
265
+ setNewRowToDefault: (
266
+ tabId: string,
267
+ tempId: string,
268
+ columnName: string,
269
+ ) => void;
270
+
271
+ // Deletion actions
272
+ markRowsForDeletion: (tabId: string, rowIndices: number[]) => void;
273
+
274
+ // Config sync state actions
275
+ updateConfigSyncState: (
276
+ configId: string,
277
+ updates: Partial<ConfigSyncState>,
278
+ ) => void;
279
+
280
+ // Theme actions
281
+ setDarkMode: (dark: boolean) => void;
282
+ toggleDarkMode: () => void;
283
+
284
+ // CSV export prefs
285
+ setCsvExportPrefs: (prefs: {
286
+ includeHeaders: boolean;
287
+ scope: "current" | "all";
288
+ }) => void;
289
+
290
+ // Shortcut actions
291
+ setShortcut: (action: ShortcutAction, keys: string) => void;
292
+ resetShortcut: (action: ShortcutAction) => void;
293
+ resetAllShortcuts: () => void;
294
+ getShortcut: (action: ShortcutAction) => string;
295
+ getAllShortcuts: () => ShortcutConfig;
296
+
297
+ // Reset UI state (preserves configs/darkMode/shortcuts/cloudApiKey)
298
+ resetUIState: () => void;
299
+
300
+ // Cloud actions
301
+ setCloudApiKey: (key: string) => void;
302
+ clearCloudApiKey: () => void;
303
+ setCloudSyncState: (updates: Partial<CloudSyncState>) => void;
304
+ syncCloudConfigs: (cloudConnections: CloudConnection[]) => void;
305
+ convertToCloudConfig: (
306
+ localId: string,
307
+ cloudInfo: CloudConnectionInfo,
308
+ ) => void;
309
+
310
+ // Getters
311
+ getActiveTab: () => ConnectionTab | undefined;
312
+ getActiveInnerTab: () => InnerTab | undefined;
313
+ }
314
+
315
+ export const useStore = create<AppState>()(
316
+ persist(
317
+ (set, get) => ({
318
+ // Initial state
319
+ databaseConfigs: DEFAULT_DATABASE_CONFIGS,
320
+ darkMode:
321
+ typeof window !== "undefined"
322
+ ? window.matchMedia("(prefers-color-scheme: dark)").matches
323
+ : true,
324
+ shortcutOverrides: {},
325
+ cloudApiKey: null,
326
+ csvExportPrefs: { includeHeaders: true, scope: "current" },
327
+ connectionTabs: [DEFAULT_CONNECTION_TAB],
328
+ activeTabId: "1",
329
+ draggedTabId: null,
330
+ draggedInnerTabId: null,
331
+ consoleStates: {},
332
+ tableStates: {},
333
+ configSyncStates: {},
334
+ cloudSyncState: {
335
+ status: "idle",
336
+ lastSyncedAt: null,
337
+ error: null,
338
+ },
339
+
340
+ // Config actions
341
+ addConfig: (config) =>
342
+ set((state) => ({
343
+ databaseConfigs: [...state.databaseConfigs, config],
344
+ })),
345
+
346
+ updateConfig: (id, updates) =>
347
+ set((state) => ({
348
+ databaseConfigs: state.databaseConfigs.map((c) =>
349
+ c.id === id ? { ...c, ...updates } : c,
350
+ ),
351
+ })),
352
+
353
+ deleteConfig: (id) =>
354
+ set((state) => ({
355
+ databaseConfigs: state.databaseConfigs.filter((c) => c.id !== id),
356
+ })),
357
+
358
+ updateConfigCache: (id, cacheUpdates) =>
359
+ set((state) => ({
360
+ databaseConfigs: state.databaseConfigs.map((c) =>
361
+ c.id === id ? { ...c, cache: { ...c.cache, ...cacheUpdates } } : c,
362
+ ),
363
+ })),
364
+
365
+ // Connection tab actions
366
+ createConnectionTab: () => {
367
+ const newId = Date.now().toString();
368
+ set((state) => ({
369
+ connectionTabs: [
370
+ ...state.connectionTabs,
371
+ {
372
+ id: newId,
373
+ name: "New Connection",
374
+ databaseConfigId: null,
375
+ innerTabs: [],
376
+ activeInnerTabId: null,
377
+ },
378
+ ],
379
+ activeTabId: newId,
380
+ }));
381
+ },
382
+
383
+ closeConnectionTab: (tabId) =>
384
+ set((state) => {
385
+ const newTabs = state.connectionTabs.filter((t) => t.id !== tabId);
386
+ if (newTabs.length === 0) {
387
+ // Closing the last tab — immediately open a fresh one
388
+ const newId = Date.now().toString();
389
+ return {
390
+ connectionTabs: [
391
+ {
392
+ id: newId,
393
+ name: "New Connection",
394
+ databaseConfigId: null,
395
+ innerTabs: [],
396
+ activeInnerTabId: null,
397
+ },
398
+ ],
399
+ activeTabId: newId,
400
+ };
401
+ }
402
+ return {
403
+ connectionTabs: newTabs,
404
+ activeTabId:
405
+ state.activeTabId === tabId
406
+ ? newTabs[newTabs.length - 1].id
407
+ : state.activeTabId,
408
+ };
409
+ }),
410
+
411
+ selectConnectionTab: (tabId) => set({ activeTabId: tabId }),
412
+
413
+ connectToDatabase: (databaseConfigId) => {
414
+ const config = get().databaseConfigs.find(
415
+ (c) => c.id === databaseConfigId,
416
+ );
417
+ if (!config) return;
418
+
419
+ // If another tab is already connected to this database, switch to it
420
+ const existingTab = get().connectionTabs.find(
421
+ (t) =>
422
+ t.databaseConfigId === databaseConfigId &&
423
+ t.id !== get().activeTabId,
424
+ );
425
+ if (existingTab) {
426
+ // Switch to the existing tab and close the current unconnected tab
427
+ const activeTabId = get().activeTabId;
428
+ set((state) => ({
429
+ connectionTabs: state.connectionTabs.filter(
430
+ (t) => t.id !== activeTabId || t.databaseConfigId !== null,
431
+ ),
432
+ activeTabId: existingTab.id,
433
+ }));
434
+ return;
435
+ }
436
+
437
+ set((state) => ({
438
+ connectionTabs: state.connectionTabs.map((t) =>
439
+ t.id === state.activeTabId
440
+ ? {
441
+ ...t,
442
+ name: config.display.name,
443
+ databaseConfigId,
444
+ innerTabs: [],
445
+ activeInnerTabId: null,
446
+ }
447
+ : t,
448
+ ),
449
+ }));
450
+ },
451
+
452
+ reorderConnectionTabs: (fromIndex, toIndex) =>
453
+ set((state) => {
454
+ const newTabs = [...state.connectionTabs];
455
+ const [draggedTab] = newTabs.splice(fromIndex, 1);
456
+ newTabs.splice(toIndex, 0, draggedTab);
457
+ return { connectionTabs: newTabs };
458
+ }),
459
+
460
+ // Inner tab actions
461
+ addInnerTab: (innerTab) =>
462
+ set((state) => ({
463
+ connectionTabs: state.connectionTabs.map((t) =>
464
+ t.id === state.activeTabId
465
+ ? {
466
+ ...t,
467
+ innerTabs: [...t.innerTabs, innerTab],
468
+ activeInnerTabId: innerTab.id,
469
+ }
470
+ : t,
471
+ ),
472
+ })),
473
+
474
+ selectInnerTab: (innerTabId) =>
475
+ set((state) => ({
476
+ connectionTabs: state.connectionTabs.map((t) =>
477
+ t.id === state.activeTabId
478
+ ? { ...t, activeInnerTabId: innerTabId }
479
+ : t,
480
+ ),
481
+ })),
482
+
483
+ closeInnerTab: (innerTabId) =>
484
+ set((state) => {
485
+ // Clean up console/table state if closing a console/table tab
486
+ const { [innerTabId]: _console, ...remainingConsoleStates } =
487
+ state.consoleStates;
488
+ const { [innerTabId]: _table, ...remainingTableStates } =
489
+ state.tableStates;
490
+ return {
491
+ consoleStates: remainingConsoleStates,
492
+ tableStates: remainingTableStates,
493
+ connectionTabs: state.connectionTabs.map((t) => {
494
+ if (t.id !== state.activeTabId) return t;
495
+ const newInnerTabs = t.innerTabs.filter(
496
+ (it) => it.id !== innerTabId,
497
+ );
498
+ return {
499
+ ...t,
500
+ innerTabs: newInnerTabs,
501
+ activeInnerTabId:
502
+ t.activeInnerTabId === innerTabId
503
+ ? newInnerTabs.length > 0
504
+ ? newInnerTabs[newInnerTabs.length - 1].id
505
+ : null
506
+ : t.activeInnerTabId,
507
+ };
508
+ }),
509
+ };
510
+ }),
511
+
512
+ reorderInnerTabs: (fromIndex, toIndex) =>
513
+ set((state) => ({
514
+ connectionTabs: state.connectionTabs.map((t) => {
515
+ if (t.id !== state.activeTabId) return t;
516
+ const newInnerTabs = [...t.innerTabs];
517
+ const [draggedTab] = newInnerTabs.splice(fromIndex, 1);
518
+ newInnerTabs.splice(toIndex, 0, draggedTab);
519
+ return { ...t, innerTabs: newInnerTabs };
520
+ }),
521
+ })),
522
+
523
+ // Drag actions
524
+ setDraggedTabId: (tabId) => set({ draggedTabId: tabId }),
525
+ setDraggedInnerTabId: (tabId) => set({ draggedInnerTabId: tabId }),
526
+
527
+ // Console state actions
528
+ initConsoleState: (tabId) =>
529
+ set((state) => {
530
+ if (state.consoleStates[tabId]) return state;
531
+ return {
532
+ consoleStates: {
533
+ ...state.consoleStates,
534
+ [tabId]: {
535
+ queryText: "",
536
+ status: "idle",
537
+ executionId: null,
538
+ startedAt: null,
539
+ completedAt: null,
540
+ result: null,
541
+ error: null,
542
+ diffResult: null,
543
+ lastAction: null,
544
+ },
545
+ },
546
+ };
547
+ }),
548
+
549
+ updateConsoleState: (tabId, updates) =>
550
+ set((state) => {
551
+ const existing = state.consoleStates[tabId];
552
+ if (!existing) return state;
553
+ return {
554
+ consoleStates: {
555
+ ...state.consoleStates,
556
+ [tabId]: { ...existing, ...updates },
557
+ },
558
+ };
559
+ }),
560
+
561
+ setConsoleQueryText: (tabId, text) =>
562
+ set((state) => {
563
+ const existing = state.consoleStates[tabId];
564
+ if (!existing) return state;
565
+ return {
566
+ consoleStates: {
567
+ ...state.consoleStates,
568
+ [tabId]: { ...existing, queryText: text },
569
+ },
570
+ };
571
+ }),
572
+
573
+ // Table state actions
574
+ initTableState: (tabId, tableName, initialWhereClause) =>
575
+ set((state) => {
576
+ if (state.tableStates[tabId]) return state;
577
+ return {
578
+ tableStates: {
579
+ ...state.tableStates,
580
+ [tabId]: {
581
+ tableName,
582
+ whereClause: initialWhereClause ?? "",
583
+ sortColumns: [],
584
+ currentPage: 0,
585
+ totalRowCount: null,
586
+ status: "idle",
587
+ executionId: null,
588
+ startedAt: null,
589
+ completedAt: null,
590
+ result: null,
591
+ error: null,
592
+ cellEditState: { ...DEFAULT_CELL_EDIT_STATE },
593
+ },
594
+ },
595
+ };
596
+ }),
597
+
598
+ updateTableState: (tabId, updates) =>
599
+ set((state) => {
600
+ const existing = state.tableStates[tabId];
601
+ if (!existing) return state;
602
+ return {
603
+ tableStates: {
604
+ ...state.tableStates,
605
+ [tabId]: { ...existing, ...updates },
606
+ },
607
+ };
608
+ }),
609
+
610
+ setTableWhereClause: (tabId, whereClause) =>
611
+ set((state) => {
612
+ const existing = state.tableStates[tabId];
613
+ if (!existing) return state;
614
+ return {
615
+ tableStates: {
616
+ ...state.tableStates,
617
+ [tabId]: { ...existing, whereClause, currentPage: 0 },
618
+ },
619
+ };
620
+ }),
621
+
622
+ setTablePage: (tabId, page) =>
623
+ set((state) => {
624
+ const existing = state.tableStates[tabId];
625
+ if (!existing) return state;
626
+ return {
627
+ tableStates: {
628
+ ...state.tableStates,
629
+ [tabId]: {
630
+ ...existing,
631
+ currentPage: page,
632
+ cellEditState: { ...DEFAULT_CELL_EDIT_STATE },
633
+ },
634
+ },
635
+ };
636
+ }),
637
+
638
+ toggleTableSort: (tabId, column, addToExisting) =>
639
+ set((state) => {
640
+ const existing = state.tableStates[tabId];
641
+ if (!existing) return state;
642
+
643
+ const currentSort = existing.sortColumns.find(
644
+ (s) => s.column === column,
645
+ );
646
+ let newSortColumns: SortColumn[];
647
+
648
+ if (addToExisting) {
649
+ // Ctrl+click: add to existing sort or cycle through
650
+ if (!currentSort) {
651
+ // Add new column with ASC
652
+ newSortColumns = [
653
+ ...existing.sortColumns,
654
+ { column, direction: "ASC" },
655
+ ];
656
+ } else if (currentSort.direction === "ASC") {
657
+ // Change to DESC
658
+ newSortColumns = existing.sortColumns.map((s) =>
659
+ s.column === column ? { ...s, direction: "DESC" as const } : s,
660
+ );
661
+ } else {
662
+ // Remove from sort
663
+ newSortColumns = existing.sortColumns.filter(
664
+ (s) => s.column !== column,
665
+ );
666
+ }
667
+ } else {
668
+ // Regular click: replace all sorts
669
+ if (!currentSort) {
670
+ // Set as only sort with ASC
671
+ newSortColumns = [{ column, direction: "ASC" }];
672
+ } else if (currentSort.direction === "ASC") {
673
+ // Change to DESC
674
+ newSortColumns = [{ column, direction: "DESC" }];
675
+ } else {
676
+ // Remove sort entirely
677
+ newSortColumns = [];
678
+ }
679
+ }
680
+
681
+ return {
682
+ tableStates: {
683
+ ...state.tableStates,
684
+ [tabId]: {
685
+ ...existing,
686
+ sortColumns: newSortColumns,
687
+ currentPage: 0,
688
+ },
689
+ },
690
+ };
691
+ }),
692
+
693
+ setTableSortColumns: (tabId, sortColumns) =>
694
+ set((state) => {
695
+ const existing = state.tableStates[tabId];
696
+ if (!existing) return state;
697
+ return {
698
+ tableStates: {
699
+ ...state.tableStates,
700
+ [tabId]: { ...existing, sortColumns, currentPage: 0 },
701
+ },
702
+ };
703
+ }),
704
+
705
+ // Cell editing actions
706
+ selectCell: (tabId, cell) =>
707
+ set((state) => {
708
+ const existing = state.tableStates[tabId];
709
+ if (!existing) return state;
710
+
711
+ // If there's an active edit, commit it before selecting the new cell
712
+ const committed = applyPendingEdit(
713
+ existing.cellEditState,
714
+ existing.result?.rows,
715
+ );
716
+
717
+ return {
718
+ tableStates: {
719
+ ...state.tableStates,
720
+ [tabId]: {
721
+ ...existing,
722
+ cellEditState: {
723
+ ...committed,
724
+ selectedCell: cell,
725
+ selectedRange: null,
726
+ editingCell: null,
727
+ editValue: "",
728
+ },
729
+ },
730
+ },
731
+ };
732
+ }),
733
+
734
+ selectCellRange: (tabId, range) =>
735
+ set((state) => {
736
+ const existing = state.tableStates[tabId];
737
+ if (!existing) return state;
738
+ return {
739
+ tableStates: {
740
+ ...state.tableStates,
741
+ [tabId]: {
742
+ ...existing,
743
+ cellEditState: {
744
+ ...existing.cellEditState,
745
+ selectedRange: range,
746
+ },
747
+ },
748
+ },
749
+ };
750
+ }),
751
+
752
+ setCellDragging: (tabId, isDragging) =>
753
+ set((state) => {
754
+ const existing = state.tableStates[tabId];
755
+ if (!existing) return state;
756
+ return {
757
+ tableStates: {
758
+ ...state.tableStates,
759
+ [tabId]: {
760
+ ...existing,
761
+ cellEditState: {
762
+ ...existing.cellEditState,
763
+ isDragging,
764
+ },
765
+ },
766
+ },
767
+ };
768
+ }),
769
+
770
+ startEditingCell: (tabId, cell, initialValue) =>
771
+ set((state) => {
772
+ const existing = state.tableStates[tabId];
773
+ if (!existing) return state;
774
+ return {
775
+ tableStates: {
776
+ ...state.tableStates,
777
+ [tabId]: {
778
+ ...existing,
779
+ cellEditState: {
780
+ ...existing.cellEditState,
781
+ editingCell: cell,
782
+ editValue: initialValue,
783
+ selectedCell: cell,
784
+ selectedRange: null,
785
+ },
786
+ },
787
+ },
788
+ };
789
+ }),
790
+
791
+ updateEditValue: (tabId, value) =>
792
+ set((state) => {
793
+ const existing = state.tableStates[tabId];
794
+ if (!existing) return state;
795
+ return {
796
+ tableStates: {
797
+ ...state.tableStates,
798
+ [tabId]: {
799
+ ...existing,
800
+ cellEditState: {
801
+ ...existing.cellEditState,
802
+ editValue: value,
803
+ },
804
+ },
805
+ },
806
+ };
807
+ }),
808
+
809
+ commitCellEdit: (tabId) =>
810
+ set((state) => {
811
+ const existing = state.tableStates[tabId];
812
+ if (!existing?.cellEditState.editingCell) return state;
813
+
814
+ const committed = applyPendingEdit(
815
+ existing.cellEditState,
816
+ existing.result?.rows,
817
+ );
818
+
819
+ return {
820
+ tableStates: {
821
+ ...state.tableStates,
822
+ [tabId]: {
823
+ ...existing,
824
+ cellEditState: {
825
+ ...committed,
826
+ editingCell: null,
827
+ editValue: "",
828
+ },
829
+ },
830
+ },
831
+ };
832
+ }),
833
+
834
+ cancelCellEdit: (tabId) =>
835
+ set((state) => {
836
+ const existing = state.tableStates[tabId];
837
+ if (!existing) return state;
838
+ return {
839
+ tableStates: {
840
+ ...state.tableStates,
841
+ [tabId]: {
842
+ ...existing,
843
+ cellEditState: {
844
+ ...existing.cellEditState,
845
+ editingCell: null,
846
+ editValue: "",
847
+ },
848
+ },
849
+ },
850
+ };
851
+ }),
852
+
853
+ clearPendingChanges: (tabId) =>
854
+ set((state) => {
855
+ const existing = state.tableStates[tabId];
856
+ if (!existing) return state;
857
+ return {
858
+ tableStates: {
859
+ ...state.tableStates,
860
+ [tabId]: {
861
+ ...existing,
862
+ cellEditState: {
863
+ ...existing.cellEditState,
864
+ pendingChanges: {},
865
+ pendingNewRows: [],
866
+ pendingDeletions: [],
867
+ },
868
+ },
869
+ },
870
+ };
871
+ }),
872
+
873
+ revertCellChange: (tabId, cell) =>
874
+ set((state) => {
875
+ const existing = state.tableStates[tabId];
876
+ if (!existing) return state;
877
+
878
+ const { rowIndex, columnName } = cell;
879
+ const key = `${rowIndex}:${columnName}`;
880
+ const { pendingChanges } = existing.cellEditState;
881
+
882
+ if (!(key in pendingChanges)) return state;
883
+
884
+ const newChanges = { ...pendingChanges };
885
+ delete newChanges[key];
886
+
887
+ return {
888
+ tableStates: {
889
+ ...state.tableStates,
890
+ [tabId]: {
891
+ ...existing,
892
+ cellEditState: {
893
+ ...existing.cellEditState,
894
+ pendingChanges: newChanges,
895
+ // Clear editing state if we were editing this cell
896
+ editingCell:
897
+ existing.cellEditState.editingCell?.rowIndex === rowIndex &&
898
+ existing.cellEditState.editingCell?.columnName ===
899
+ columnName
900
+ ? null
901
+ : existing.cellEditState.editingCell,
902
+ editValue:
903
+ existing.cellEditState.editingCell?.rowIndex === rowIndex &&
904
+ existing.cellEditState.editingCell?.columnName ===
905
+ columnName
906
+ ? ""
907
+ : existing.cellEditState.editValue,
908
+ },
909
+ },
910
+ },
911
+ };
912
+ }),
913
+
914
+ setCellToNull: (tabId, cell) =>
915
+ set((state) => {
916
+ const existing = state.tableStates[tabId];
917
+ if (!existing) return state;
918
+
919
+ const { rowIndex, columnName } = cell;
920
+
921
+ // Handle new rows (negative indices)
922
+ if (rowIndex < 0) {
923
+ const newRowIndex = Math.abs(rowIndex) - 1;
924
+ const { pendingNewRows } = existing.cellEditState;
925
+ const newRow = pendingNewRows[newRowIndex];
926
+ if (!newRow) return state;
927
+
928
+ const updatedNewRows = pendingNewRows.map((row, idx) => {
929
+ if (idx !== newRowIndex) return row;
930
+ const newExplicitlySet = new Set(row.explicitlySetColumns);
931
+ newExplicitlySet.add(columnName);
932
+ return {
933
+ ...row,
934
+ explicitlySetColumns: newExplicitlySet,
935
+ values: { ...row.values, [columnName]: null },
936
+ };
937
+ });
938
+
939
+ return {
940
+ tableStates: {
941
+ ...state.tableStates,
942
+ [tabId]: {
943
+ ...existing,
944
+ cellEditState: {
945
+ ...existing.cellEditState,
946
+ pendingNewRows: updatedNewRows,
947
+ editingCell:
948
+ existing.cellEditState.editingCell?.rowIndex ===
949
+ rowIndex &&
950
+ existing.cellEditState.editingCell?.columnName ===
951
+ columnName
952
+ ? null
953
+ : existing.cellEditState.editingCell,
954
+ editValue:
955
+ existing.cellEditState.editingCell?.rowIndex ===
956
+ rowIndex &&
957
+ existing.cellEditState.editingCell?.columnName ===
958
+ columnName
959
+ ? ""
960
+ : existing.cellEditState.editValue,
961
+ },
962
+ },
963
+ },
964
+ };
965
+ }
966
+
967
+ const key = `${rowIndex}:${columnName}`;
968
+ const { pendingChanges } = existing.cellEditState;
969
+
970
+ // Get the original value
971
+ const existingChange = pendingChanges[key];
972
+ const originalValue =
973
+ existingChange?.originalValue ??
974
+ existing.result?.rows[rowIndex]?.[columnName];
975
+
976
+ // If original was already null, remove the change
977
+ const newChanges = { ...pendingChanges };
978
+ if (originalValue === null) {
979
+ delete newChanges[key];
980
+ } else {
981
+ newChanges[key] = {
982
+ rowIndex,
983
+ columnName,
984
+ originalValue,
985
+ newValue: null,
986
+ } as CellChange;
987
+ }
988
+
989
+ return {
990
+ tableStates: {
991
+ ...state.tableStates,
992
+ [tabId]: {
993
+ ...existing,
994
+ cellEditState: {
995
+ ...existing.cellEditState,
996
+ pendingChanges: newChanges,
997
+ // Clear editing state if we were editing this cell
998
+ editingCell:
999
+ existing.cellEditState.editingCell?.rowIndex === rowIndex &&
1000
+ existing.cellEditState.editingCell?.columnName ===
1001
+ columnName
1002
+ ? null
1003
+ : existing.cellEditState.editingCell,
1004
+ editValue:
1005
+ existing.cellEditState.editingCell?.rowIndex === rowIndex &&
1006
+ existing.cellEditState.editingCell?.columnName ===
1007
+ columnName
1008
+ ? ""
1009
+ : existing.cellEditState.editValue,
1010
+ },
1011
+ },
1012
+ },
1013
+ };
1014
+ }),
1015
+
1016
+ // Batch paste action
1017
+ pasteCellRange: (tabId, cells) =>
1018
+ set((state) => {
1019
+ const existing = state.tableStates[tabId];
1020
+ if (!existing) return state;
1021
+
1022
+ let { pendingChanges, pendingNewRows } = existing.cellEditState;
1023
+ pendingChanges = { ...pendingChanges };
1024
+ pendingNewRows = pendingNewRows.map((row) => ({ ...row }));
1025
+
1026
+ for (const cell of cells) {
1027
+ const { rowIndex, columnName, value } = cell;
1028
+
1029
+ if (rowIndex < 0) {
1030
+ // New row
1031
+ const newRowIndex = Math.abs(rowIndex) - 1;
1032
+ const newRow = pendingNewRows[newRowIndex];
1033
+ if (!newRow) continue;
1034
+ const newExplicitlySet = new Set(newRow.explicitlySetColumns);
1035
+ newExplicitlySet.add(columnName);
1036
+ pendingNewRows[newRowIndex] = {
1037
+ ...newRow,
1038
+ explicitlySetColumns: newExplicitlySet,
1039
+ values: { ...newRow.values, [columnName]: value },
1040
+ };
1041
+ } else {
1042
+ // Existing row
1043
+ const key = `${rowIndex}:${columnName}`;
1044
+ const existingChange = pendingChanges[key];
1045
+ const originalValue =
1046
+ existingChange?.originalValue ??
1047
+ existing.result?.rows[rowIndex]?.[columnName];
1048
+ const originalStr =
1049
+ originalValue === null ? null : String(originalValue ?? "");
1050
+
1051
+ if (value === originalStr) {
1052
+ delete pendingChanges[key];
1053
+ } else {
1054
+ pendingChanges[key] = {
1055
+ rowIndex,
1056
+ columnName,
1057
+ originalValue,
1058
+ newValue: value,
1059
+ } as CellChange;
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ return {
1065
+ tableStates: {
1066
+ ...state.tableStates,
1067
+ [tabId]: {
1068
+ ...existing,
1069
+ cellEditState: {
1070
+ ...existing.cellEditState,
1071
+ pendingChanges,
1072
+ pendingNewRows,
1073
+ editingCell: null,
1074
+ editValue: "",
1075
+ },
1076
+ },
1077
+ },
1078
+ };
1079
+ }),
1080
+
1081
+ // New row actions
1082
+ addNewRow: (tabId) =>
1083
+ set((state) => {
1084
+ const existing = state.tableStates[tabId];
1085
+ if (!existing) return state;
1086
+
1087
+ const newRow: PendingNewRow = {
1088
+ tempId: Date.now().toString(),
1089
+ explicitlySetColumns: new Set<string>(),
1090
+ values: {},
1091
+ };
1092
+
1093
+ return {
1094
+ tableStates: {
1095
+ ...state.tableStates,
1096
+ [tabId]: {
1097
+ ...existing,
1098
+ cellEditState: {
1099
+ ...existing.cellEditState,
1100
+ pendingNewRows: [
1101
+ ...existing.cellEditState.pendingNewRows,
1102
+ newRow,
1103
+ ],
1104
+ },
1105
+ },
1106
+ },
1107
+ };
1108
+ }),
1109
+
1110
+ removeNewRow: (tabId, tempId) =>
1111
+ set((state) => {
1112
+ const existing = state.tableStates[tabId];
1113
+ if (!existing) return state;
1114
+
1115
+ return {
1116
+ tableStates: {
1117
+ ...state.tableStates,
1118
+ [tabId]: {
1119
+ ...existing,
1120
+ cellEditState: {
1121
+ ...existing.cellEditState,
1122
+ pendingNewRows: existing.cellEditState.pendingNewRows.filter(
1123
+ (row) => row.tempId !== tempId,
1124
+ ),
1125
+ },
1126
+ },
1127
+ },
1128
+ };
1129
+ }),
1130
+
1131
+ setNewRowValue: (tabId, tempId, columnName, value, isExplicit) =>
1132
+ set((state) => {
1133
+ const existing = state.tableStates[tabId];
1134
+ if (!existing) return state;
1135
+
1136
+ const updatedNewRows = existing.cellEditState.pendingNewRows.map(
1137
+ (row) => {
1138
+ if (row.tempId !== tempId) return row;
1139
+ const newExplicitlySet = new Set(row.explicitlySetColumns);
1140
+ if (isExplicit) {
1141
+ newExplicitlySet.add(columnName);
1142
+ }
1143
+ return {
1144
+ ...row,
1145
+ explicitlySetColumns: newExplicitlySet,
1146
+ values: { ...row.values, [columnName]: value },
1147
+ };
1148
+ },
1149
+ );
1150
+
1151
+ return {
1152
+ tableStates: {
1153
+ ...state.tableStates,
1154
+ [tabId]: {
1155
+ ...existing,
1156
+ cellEditState: {
1157
+ ...existing.cellEditState,
1158
+ pendingNewRows: updatedNewRows,
1159
+ },
1160
+ },
1161
+ },
1162
+ };
1163
+ }),
1164
+
1165
+ setNewRowToDefault: (tabId, tempId, columnName) =>
1166
+ set((state) => {
1167
+ const existing = state.tableStates[tabId];
1168
+ if (!existing) return state;
1169
+
1170
+ const updatedNewRows = existing.cellEditState.pendingNewRows.map(
1171
+ (row) => {
1172
+ if (row.tempId !== tempId) return row;
1173
+ const newExplicitlySet = new Set(row.explicitlySetColumns);
1174
+ newExplicitlySet.delete(columnName);
1175
+ const newValues = { ...row.values };
1176
+ delete newValues[columnName];
1177
+ return {
1178
+ ...row,
1179
+ explicitlySetColumns: newExplicitlySet,
1180
+ values: newValues,
1181
+ };
1182
+ },
1183
+ );
1184
+
1185
+ return {
1186
+ tableStates: {
1187
+ ...state.tableStates,
1188
+ [tabId]: {
1189
+ ...existing,
1190
+ cellEditState: {
1191
+ ...existing.cellEditState,
1192
+ pendingNewRows: updatedNewRows,
1193
+ },
1194
+ },
1195
+ },
1196
+ };
1197
+ }),
1198
+
1199
+ // Deletion actions
1200
+ markRowsForDeletion: (tabId, rowIndices) =>
1201
+ set((state) => {
1202
+ const existing = state.tableStates[tabId];
1203
+ if (!existing) return state;
1204
+
1205
+ // Separate new rows (negative indices) from existing rows (positive)
1206
+ const newRowIndicesToRemove: number[] = [];
1207
+ const existingRowsToMark: number[] = [];
1208
+
1209
+ for (const idx of rowIndices) {
1210
+ if (idx < 0) {
1211
+ // New row: get the array index (e.g., -1 → 0, -2 → 1)
1212
+ newRowIndicesToRemove.push(Math.abs(idx) - 1);
1213
+ } else {
1214
+ existingRowsToMark.push(idx);
1215
+ }
1216
+ }
1217
+
1218
+ // Remove new rows immediately
1219
+ const updatedNewRows = existing.cellEditState.pendingNewRows.filter(
1220
+ (_, idx) => !newRowIndicesToRemove.includes(idx),
1221
+ );
1222
+
1223
+ // Add existing rows to pendingDeletions (deduplicated)
1224
+ const existingDeletions = new Set(
1225
+ existing.cellEditState.pendingDeletions,
1226
+ );
1227
+ for (const idx of existingRowsToMark) {
1228
+ existingDeletions.add(idx);
1229
+ }
1230
+
1231
+ return {
1232
+ tableStates: {
1233
+ ...state.tableStates,
1234
+ [tabId]: {
1235
+ ...existing,
1236
+ cellEditState: {
1237
+ ...existing.cellEditState,
1238
+ pendingNewRows: updatedNewRows,
1239
+ pendingDeletions: Array.from(existingDeletions),
1240
+ // Clear selection after marking
1241
+ selectedCell: null,
1242
+ selectedRange: null,
1243
+ },
1244
+ },
1245
+ },
1246
+ };
1247
+ }),
1248
+
1249
+ // Config sync state actions
1250
+ updateConfigSyncState: (configId, updates) =>
1251
+ set((state) => ({
1252
+ configSyncStates: {
1253
+ ...state.configSyncStates,
1254
+ [configId]: {
1255
+ ...(state.configSyncStates[configId] ?? {
1256
+ status: "idle",
1257
+ executionId: null,
1258
+ startedAt: null,
1259
+ completedAt: null,
1260
+ error: null,
1261
+ }),
1262
+ ...updates,
1263
+ },
1264
+ },
1265
+ })),
1266
+
1267
+ // Theme actions
1268
+ setDarkMode: (dark) => set({ darkMode: dark }),
1269
+ toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
1270
+
1271
+ // CSV export prefs
1272
+ setCsvExportPrefs: (prefs) => set({ csvExportPrefs: prefs }),
1273
+
1274
+ // Shortcut actions
1275
+ setShortcut: (action, keys) =>
1276
+ set((state) => ({
1277
+ shortcutOverrides: { ...state.shortcutOverrides, [action]: keys },
1278
+ })),
1279
+ resetShortcut: (action) =>
1280
+ set((state) => {
1281
+ const { [action]: _, ...rest } = state.shortcutOverrides;
1282
+ return { shortcutOverrides: rest };
1283
+ }),
1284
+ resetAllShortcuts: () => set({ shortcutOverrides: {} }),
1285
+ getShortcut: (action) =>
1286
+ get().shortcutOverrides[action] ?? DEFAULT_SHORTCUTS[action],
1287
+ getAllShortcuts: () => ({
1288
+ ...DEFAULT_SHORTCUTS,
1289
+ ...get().shortcutOverrides,
1290
+ }),
1291
+
1292
+ // Reset UI state (preserves configs/darkMode/shortcuts/cloudApiKey)
1293
+ resetUIState: () =>
1294
+ set({
1295
+ connectionTabs: [
1296
+ { ...DEFAULT_CONNECTION_TAB, id: Date.now().toString() },
1297
+ ],
1298
+ activeTabId: Date.now().toString(),
1299
+ draggedTabId: null,
1300
+ draggedInnerTabId: null,
1301
+ consoleStates: {},
1302
+ tableStates: {},
1303
+ configSyncStates: {},
1304
+ cloudSyncState: { status: "idle", lastSyncedAt: null, error: null },
1305
+ }),
1306
+
1307
+ // Cloud actions
1308
+ setCloudApiKey: (key) => set({ cloudApiKey: key }),
1309
+ clearCloudApiKey: () =>
1310
+ set((state) => ({
1311
+ cloudApiKey: null,
1312
+ // Remove all cloud configs when unlinking
1313
+ databaseConfigs: state.databaseConfigs.filter(
1314
+ (c) => c.source !== "cloud",
1315
+ ),
1316
+ cloudSyncState: {
1317
+ status: "idle",
1318
+ lastSyncedAt: null,
1319
+ error: null,
1320
+ },
1321
+ })),
1322
+ setCloudSyncState: (updates) =>
1323
+ set((state) => ({
1324
+ cloudSyncState: { ...state.cloudSyncState, ...updates },
1325
+ })),
1326
+ syncCloudConfigs: (cloudConnections) =>
1327
+ set((state) => {
1328
+ // Keep all local configs unchanged
1329
+ const localConfigs = state.databaseConfigs.filter(
1330
+ (c) => c.source === "local",
1331
+ );
1332
+
1333
+ // Build a map of existing cloud configs to preserve their cache
1334
+ const existingCloudConfigs = new Map(
1335
+ state.databaseConfigs
1336
+ .filter((c) => c.source === "cloud")
1337
+ .map((c) => [c.id, c]),
1338
+ );
1339
+
1340
+ // Create new cloud configs from API response, preserving existing cache
1341
+ const cloudConfigs: DatabaseConfig[] = cloudConnections.map(
1342
+ (conn) => {
1343
+ const configId = `cloud_${conn.id}`;
1344
+ const existingConfig = existingCloudConfigs.get(configId);
1345
+ return {
1346
+ id: configId,
1347
+ display: conn.config.display,
1348
+ connection: conn.config.connection,
1349
+ cache: existingConfig?.cache ?? {},
1350
+ source: "cloud" as const,
1351
+ cloud: {
1352
+ id: conn.id,
1353
+ ownerId: conn.ownerId,
1354
+ ownerEmail: conn.ownerEmail,
1355
+ role: conn.role,
1356
+ access: conn.access,
1357
+ updatedAt: conn.updatedAt,
1358
+ },
1359
+ };
1360
+ },
1361
+ );
1362
+
1363
+ return {
1364
+ databaseConfigs: [...localConfigs, ...cloudConfigs],
1365
+ };
1366
+ }),
1367
+ convertToCloudConfig: (localId, cloudInfo) =>
1368
+ set((state) => ({
1369
+ databaseConfigs: state.databaseConfigs.map((c) =>
1370
+ c.id === localId
1371
+ ? {
1372
+ ...c,
1373
+ id: `cloud_${cloudInfo.id}`,
1374
+ source: "cloud" as const,
1375
+ cloud: cloudInfo,
1376
+ }
1377
+ : c,
1378
+ ),
1379
+ })),
1380
+
1381
+ // Getters
1382
+ getActiveTab: () => {
1383
+ const state = get();
1384
+ return state.connectionTabs.find((t) => t.id === state.activeTabId);
1385
+ },
1386
+
1387
+ getActiveInnerTab: () => {
1388
+ const activeTab = get().getActiveTab();
1389
+ if (!activeTab?.activeInnerTabId) return undefined;
1390
+ return activeTab.innerTabs.find(
1391
+ (t) => t.id === activeTab.activeInnerTabId,
1392
+ );
1393
+ },
1394
+ }),
1395
+ {
1396
+ name: "dbdiff-storage",
1397
+ version: 2,
1398
+ storage: {
1399
+ getItem: (name) => {
1400
+ const raw = localStorage.getItem(name);
1401
+ if (!raw) return null;
1402
+ const parsed = JSON.parse(raw);
1403
+ // Rehydrate Set<string> fields in pendingNewRows
1404
+ if (parsed?.state?.tableStates) {
1405
+ for (const ts of Object.values(
1406
+ parsed.state.tableStates,
1407
+ ) as TableTabState[]) {
1408
+ if (ts.cellEditState?.pendingNewRows) {
1409
+ ts.cellEditState.pendingNewRows =
1410
+ ts.cellEditState.pendingNewRows.map(
1411
+ (
1412
+ row: PendingNewRow & {
1413
+ explicitlySetColumns: string[] | Set<string>;
1414
+ },
1415
+ ) => ({
1416
+ ...row,
1417
+ explicitlySetColumns: Array.isArray(
1418
+ row.explicitlySetColumns,
1419
+ )
1420
+ ? new Set(row.explicitlySetColumns)
1421
+ : row.explicitlySetColumns,
1422
+ }),
1423
+ );
1424
+ }
1425
+ }
1426
+ }
1427
+ return parsed;
1428
+ },
1429
+ setItem: (name, value) => {
1430
+ // Serialize Set<string> fields to arrays before storing
1431
+ const clone = JSON.parse(
1432
+ JSON.stringify(value, (_key, val) =>
1433
+ val instanceof Set ? [...val] : val,
1434
+ ),
1435
+ );
1436
+ localStorage.setItem(name, JSON.stringify(clone));
1437
+ },
1438
+ removeItem: (name) => localStorage.removeItem(name),
1439
+ },
1440
+ partialize: (state) =>
1441
+ ({
1442
+ databaseConfigs: state.databaseConfigs,
1443
+ darkMode: state.darkMode,
1444
+ shortcutOverrides: state.shortcutOverrides,
1445
+ cloudApiKey: state.cloudApiKey,
1446
+ csvExportPrefs: state.csvExportPrefs,
1447
+ connectionTabs: state.connectionTabs,
1448
+ activeTabId: state.activeTabId,
1449
+ // Strip result/error/transient execution state from consoleStates; keep queryText
1450
+ consoleStates: Object.fromEntries(
1451
+ Object.entries(state.consoleStates).map(([id, cs]) => [
1452
+ id,
1453
+ {
1454
+ queryText: cs.queryText,
1455
+ status: "idle" as const,
1456
+ executionId: null,
1457
+ startedAt: null,
1458
+ completedAt: null,
1459
+ result: null,
1460
+ error: null,
1461
+ diffResult: null,
1462
+ lastAction: null,
1463
+ },
1464
+ ]),
1465
+ ),
1466
+ // Strip result/error/transient state from tableStates; keep filters, sorts, pending user work
1467
+ tableStates: Object.fromEntries(
1468
+ Object.entries(state.tableStates).map(([id, ts]) => [
1469
+ id,
1470
+ {
1471
+ tableName: ts.tableName,
1472
+ whereClause: ts.whereClause,
1473
+ sortColumns: ts.sortColumns,
1474
+ currentPage: ts.currentPage,
1475
+ totalRowCount: null,
1476
+ status: "idle" as const,
1477
+ executionId: null,
1478
+ startedAt: null,
1479
+ completedAt: null,
1480
+ result: null,
1481
+ error: null,
1482
+ cellEditState: {
1483
+ selectedCell: null,
1484
+ selectedRange: null,
1485
+ isDragging: false,
1486
+ editingCell: null,
1487
+ editValue: "",
1488
+ pendingChanges: ts.cellEditState.pendingChanges,
1489
+ pendingNewRows: ts.cellEditState.pendingNewRows,
1490
+ pendingDeletions: ts.cellEditState.pendingDeletions,
1491
+ },
1492
+ },
1493
+ ]),
1494
+ ),
1495
+ }) as unknown as AppState,
1496
+ migrate: (persistedState, version) => {
1497
+ const state = persistedState as Partial<AppState>;
1498
+
1499
+ // Migration from version 0 to 1: add source field to existing configs
1500
+ if (version === 0) {
1501
+ if (state.databaseConfigs) {
1502
+ state.databaseConfigs = state.databaseConfigs.map((config) => ({
1503
+ ...config,
1504
+ source: config.source ?? ("local" as const),
1505
+ }));
1506
+ }
1507
+ }
1508
+
1509
+ // Migration from version 1 to 2: no-op, new fields fall back to defaults
1510
+ return state as AppState;
1511
+ },
1512
+ },
1513
+ ),
1514
+ );