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,360 @@
1
+ import CodeMirror from "@uiw/react-codemirror";
2
+ import { PostgreSQL, sql, type SQLNamespace } from "@codemirror/lang-sql";
3
+ import { keymap } from "@codemirror/view";
4
+ import { Download } from "lucide-react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import {
7
+ formatShortcutDisplay,
8
+ useActiveDatabaseConfig,
9
+ useConsoleDiff,
10
+ useConsoleExecution,
11
+ useConsoleState,
12
+ useShortcut,
13
+ } from "../stores/hooks";
14
+ import { useStore } from "../stores/store";
15
+ import { CsvExportModal } from "./CsvExportModal";
16
+ import { DiffView } from "./DiffView";
17
+ import { Resizer } from "./Resizer";
18
+ import { DataGrid } from "./DataGrid";
19
+
20
+ /** Convert our shortcut format to CodeMirror's format */
21
+ function toCodeMirrorKey(shortcut: string): string {
22
+ return shortcut
23
+ .split("+")
24
+ .map((part) => {
25
+ const lower = part.toLowerCase();
26
+ if (lower === "mod") return "Mod";
27
+ if (lower === "ctrl") return "Ctrl";
28
+ if (lower === "alt") return "Alt";
29
+ if (lower === "shift") return "Shift";
30
+ if (lower === "enter") return "Enter";
31
+ if (lower === "escape") return "Escape";
32
+ return part;
33
+ })
34
+ .join("-");
35
+ }
36
+
37
+ interface ConsoleViewProps {
38
+ tabId: string;
39
+ }
40
+
41
+ export function ConsoleView({ tabId }: ConsoleViewProps) {
42
+ const consoleState = useConsoleState(tabId);
43
+ const setConsoleQueryText = useStore((state) => state.setConsoleQueryText);
44
+ const initConsoleState = useStore((state) => state.initConsoleState);
45
+ const runQueryShortcut = useShortcut("runQuery");
46
+ const { execute } = useConsoleExecution(tabId);
47
+ const { executeDiff } = useConsoleDiff(tabId);
48
+ const isDark = useStore((state) => state.darkMode);
49
+ const databaseConfig = useActiveDatabaseConfig();
50
+ const [editorHeight, setEditorHeight] = useState(200);
51
+ const [showCsvExport, setShowCsvExport] = useState(false);
52
+
53
+ // Build CodeMirror schema namespace from cached database metadata
54
+ const sqlSchema = useMemo((): SQLNamespace | undefined => {
55
+ const schemas = databaseConfig?.cache?.schemas;
56
+ if (!schemas?.length) return undefined;
57
+ const ns: {
58
+ [schema: string]: {
59
+ [table: string]: { label: string; type: string; detail: string }[];
60
+ };
61
+ } = {};
62
+ for (const schema of schemas) {
63
+ const tables: {
64
+ [table: string]: { label: string; type: string; detail: string }[];
65
+ } = {};
66
+ for (const table of schema.tables) {
67
+ tables[table.name] = table.columns.map((col) => ({
68
+ label: col.name,
69
+ type: "property",
70
+ detail: col.dataType,
71
+ }));
72
+ }
73
+ ns[schema.name] = tables;
74
+ }
75
+ return ns;
76
+ }, [databaseConfig?.cache?.schemas]);
77
+
78
+ const sqlExtension = useMemo(
79
+ () =>
80
+ sql({
81
+ dialect: PostgreSQL,
82
+ schema: sqlSchema,
83
+ defaultSchema: "public",
84
+ }),
85
+ [sqlSchema],
86
+ );
87
+
88
+ const handleEditorResize = useCallback((delta: number) => {
89
+ setEditorHeight((h) =>
90
+ Math.max(100, Math.min(h + delta, window.innerHeight - 200)),
91
+ );
92
+ }, []);
93
+
94
+ // Initialize state on mount if not exists
95
+ useEffect(() => {
96
+ initConsoleState(tabId);
97
+ }, [tabId, initConsoleState]);
98
+
99
+ // Create keybinding for run query (configurable)
100
+ const executeKeymap = useMemo(
101
+ () =>
102
+ keymap.of([
103
+ {
104
+ key: toCodeMirrorKey(runQueryShortcut),
105
+ run: () => {
106
+ execute();
107
+ return true;
108
+ },
109
+ },
110
+ ]),
111
+ [execute, runQueryShortcut],
112
+ );
113
+
114
+ // On Mac, also accept Cmd+Enter when shortcut is Ctrl+Enter.
115
+ // Native capture-phase listener so we intercept before CodeMirror.
116
+ const editorRef = useRef<HTMLDivElement>(null);
117
+ useEffect(() => {
118
+ const el = editorRef.current;
119
+ if (!el) return;
120
+ const isMac = navigator.platform.toUpperCase().includes("MAC");
121
+ if (!isMac) return;
122
+
123
+ const handler = (e: KeyboardEvent) => {
124
+ if (e.metaKey && !e.ctrlKey && e.key === "Enter") {
125
+ e.preventDefault();
126
+ e.stopPropagation();
127
+ execute();
128
+ }
129
+ };
130
+ el.addEventListener("keydown", handler, { capture: true });
131
+ return () => el.removeEventListener("keydown", handler, { capture: true });
132
+ }, [execute]);
133
+
134
+ const handleChange = (value: string) => {
135
+ setConsoleQueryText(tabId, value);
136
+ };
137
+
138
+ const { status, result, error, diffResult, lastAction } = consoleState;
139
+
140
+ return (
141
+ <div className="h-full w-full flex flex-col">
142
+ {/* Toolbar */}
143
+ <div className="flex-shrink-0 flex items-center gap-2 px-3 py-2 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
144
+ <button
145
+ onClick={execute}
146
+ disabled={status === "executing" || !consoleState.queryText.trim()}
147
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] font-medium rounded-md bg-stone-800 dark:bg-white text-white dark:text-stone-900 hover:bg-stone-700 dark:hover:bg-stone-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
148
+ >
149
+ {status === "executing" ? (
150
+ <>
151
+ <svg
152
+ className="animate-spin h-3.5 w-3.5"
153
+ viewBox="0 0 24 24"
154
+ fill="none"
155
+ >
156
+ <circle
157
+ className="opacity-25"
158
+ cx="12"
159
+ cy="12"
160
+ r="10"
161
+ stroke="currentColor"
162
+ strokeWidth="4"
163
+ />
164
+ <path
165
+ className="opacity-75"
166
+ fill="currentColor"
167
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
168
+ />
169
+ </svg>
170
+ Running...
171
+ </>
172
+ ) : (
173
+ <>
174
+ <svg
175
+ className="w-3.5 h-3.5"
176
+ viewBox="0 0 24 24"
177
+ fill="currentColor"
178
+ >
179
+ <path d="M8 5v14l11-7z" />
180
+ </svg>
181
+ Run
182
+ </>
183
+ )}
184
+ </button>
185
+ <span className="text-[11px] text-tertiary">
186
+ {formatShortcutDisplay(runQueryShortcut)}
187
+ </span>
188
+ <div className="w-px h-4 bg-stone-200 dark:bg-white/10 mx-1" />
189
+ <button
190
+ onClick={executeDiff}
191
+ disabled={status === "executing" || !consoleState.queryText.trim()}
192
+ className="flex items-center gap-1.5 px-3 py-1.5 text-[13px] font-medium rounded-md border border-stone-300 dark:border-white/15 text-secondary hover:bg-stone-100 dark:hover:bg-white/[0.06] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
193
+ >
194
+ <svg
195
+ className="w-3.5 h-3.5"
196
+ viewBox="0 0 24 24"
197
+ fill="none"
198
+ stroke="currentColor"
199
+ strokeWidth="2"
200
+ >
201
+ <path
202
+ d="M12 3v18M3 12h18M3 6h8M13 6h8M3 18h8M13 18h8"
203
+ strokeLinecap="round"
204
+ />
205
+ </svg>
206
+ Diff
207
+ </button>
208
+ </div>
209
+
210
+ {/* Editor section */}
211
+ <div
212
+ ref={editorRef}
213
+ className="flex-shrink-0"
214
+ style={{ height: editorHeight }}
215
+ >
216
+ <CodeMirror
217
+ className="h-full"
218
+ key={isDark ? "dark" : "light"}
219
+ value={consoleState.queryText}
220
+ onChange={handleChange}
221
+ height="100%"
222
+ autoFocus
223
+ theme={isDark ? "dark" : "light"}
224
+ extensions={[sqlExtension, executeKeymap]}
225
+ placeholder="-- Write your SQL query here... (Cmd/Ctrl+Enter to run)"
226
+ basicSetup={{
227
+ lineNumbers: true,
228
+ highlightActiveLineGutter: false,
229
+ highlightActiveLine: true,
230
+ foldGutter: false,
231
+ dropCursor: true,
232
+ allowMultipleSelections: true,
233
+ indentOnInput: true,
234
+ bracketMatching: true,
235
+ closeBrackets: true,
236
+ autocompletion: true,
237
+ rectangularSelection: true,
238
+ crosshairCursor: false,
239
+ highlightSelectionMatches: true,
240
+ searchKeymap: true,
241
+ }}
242
+ />
243
+ </div>
244
+
245
+ <Resizer direction="vertical" onResize={handleEditorResize} />
246
+
247
+ {/* Results section */}
248
+ <div className="flex-1 min-h-0 overflow-auto border-t border-stone-200 dark:border-white/[0.06]">
249
+ {status === "idle" && (
250
+ <div className="flex items-center justify-center h-full text-tertiary text-[13px]">
251
+ Press Cmd/Ctrl+Enter to run query
252
+ </div>
253
+ )}
254
+
255
+ {status === "executing" && (
256
+ <div className="flex items-center justify-center h-full">
257
+ <div className="flex items-center gap-3 text-secondary text-[13px]">
258
+ <svg
259
+ className="animate-spin h-4 w-4"
260
+ viewBox="0 0 24 24"
261
+ fill="none"
262
+ >
263
+ <circle
264
+ className="opacity-25"
265
+ cx="12"
266
+ cy="12"
267
+ r="10"
268
+ stroke="currentColor"
269
+ strokeWidth="4"
270
+ />
271
+ <path
272
+ className="opacity-75"
273
+ fill="currentColor"
274
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
275
+ />
276
+ </svg>
277
+ Executing query...
278
+ </div>
279
+ </div>
280
+ )}
281
+
282
+ {status === "error" && error && (
283
+ <div className="p-4">
284
+ <div className="rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800/50 p-4">
285
+ <div className="flex items-start gap-3">
286
+ <svg
287
+ className="w-5 h-5 text-red-500 dark:text-red-400 flex-shrink-0 mt-0.5"
288
+ viewBox="0 0 20 20"
289
+ fill="currentColor"
290
+ >
291
+ <path
292
+ fillRule="evenodd"
293
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
294
+ clipRule="evenodd"
295
+ />
296
+ </svg>
297
+ <div>
298
+ <p className="text-[13px] font-medium text-red-800 dark:text-red-300">
299
+ Query Error
300
+ </p>
301
+ <p className="text-[13px] text-red-700 dark:text-red-400 mt-1 font-mono whitespace-pre-wrap">
302
+ {error}
303
+ </p>
304
+ </div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ )}
309
+
310
+ {status === "completed" && lastAction === "diff" && diffResult && (
311
+ <DiffView diffResult={diffResult} />
312
+ )}
313
+
314
+ {status === "completed" && lastAction !== "diff" && result && (
315
+ <div className="h-full flex flex-col">
316
+ {/* Result header */}
317
+ <div className="flex-shrink-0 flex items-center justify-between px-4 py-2 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
318
+ <span className="text-[12px] text-secondary">
319
+ {result.rowCount !== null
320
+ ? `${result.rowCount} row${result.rowCount !== 1 ? "s" : ""}`
321
+ : "Query executed"}
322
+ {result.fields.length > 0 &&
323
+ ` • ${result.fields.length} column${
324
+ result.fields.length !== 1 ? "s" : ""
325
+ }`}
326
+ </span>
327
+ {result.fields.length > 0 && (
328
+ <button
329
+ onClick={() => setShowCsvExport(true)}
330
+ className="p-0.5 rounded hover:bg-stone-200 dark:hover:bg-white/10 text-secondary transition-colors"
331
+ title="Export to CSV"
332
+ >
333
+ <Download className="w-4 h-4" />
334
+ </button>
335
+ )}
336
+ </div>
337
+
338
+ {/* Result table */}
339
+ {result.fields.length > 0 ? (
340
+ <DataGrid columns={result.fields} rows={result.rows} />
341
+ ) : (
342
+ <div className="flex items-center justify-center h-full text-tertiary text-[13px]">
343
+ Query executed successfully
344
+ </div>
345
+ )}
346
+ </div>
347
+ )}
348
+ </div>
349
+
350
+ {showCsvExport && result && (
351
+ <CsvExportModal
352
+ onClose={() => setShowCsvExport(false)}
353
+ fields={result.fields}
354
+ currentRows={result.rows}
355
+ defaultFilename="query_results"
356
+ />
357
+ )}
358
+ </div>
359
+ );
360
+ }
@@ -0,0 +1,144 @@
1
+ import { useState } from "react";
2
+ import { useHotkey } from "../stores/hooks";
3
+ import { useStore } from "../stores/store";
4
+ import { generateCsv } from "../utils/csv";
5
+
6
+ interface CsvExportModalProps {
7
+ onClose: () => void;
8
+ fields: { name: string }[];
9
+ currentRows: Record<string, unknown>[];
10
+ defaultFilename: string;
11
+ totalRowCount?: number;
12
+ fetchAllRows?: () => Promise<Record<string, unknown>[]>;
13
+ }
14
+
15
+ export function CsvExportModal({
16
+ onClose,
17
+ fields,
18
+ currentRows,
19
+ defaultFilename,
20
+ totalRowCount,
21
+ fetchAllRows,
22
+ }: CsvExportModalProps) {
23
+ const csvExportPrefs = useStore((state) => state.csvExportPrefs);
24
+ const setCsvExportPrefs = useStore((state) => state.setCsvExportPrefs);
25
+ const [includeHeaders, setIncludeHeaders] = useState(
26
+ csvExportPrefs.includeHeaders,
27
+ );
28
+ const [scope, setScope] = useState<"current" | "all">(csvExportPrefs.scope);
29
+ const [exporting, setExporting] = useState(false);
30
+
31
+ useHotkey("closeModal", onClose);
32
+
33
+ async function doExport(destination: "file" | "clipboard") {
34
+ setExporting(true);
35
+ setCsvExportPrefs({ includeHeaders, scope });
36
+ try {
37
+ const rows =
38
+ scope === "all" && fetchAllRows ? await fetchAllRows() : currentRows;
39
+ const csv = generateCsv(fields, rows, { includeHeaders });
40
+
41
+ if (destination === "clipboard") {
42
+ await navigator.clipboard.writeText(csv);
43
+ } else {
44
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
45
+ const url = URL.createObjectURL(blob);
46
+ const a = document.createElement("a");
47
+ a.href = url;
48
+ a.download = `${defaultFilename}.csv`;
49
+ document.body.appendChild(a);
50
+ a.click();
51
+ a.remove();
52
+ URL.revokeObjectURL(url);
53
+ }
54
+ onClose();
55
+ } catch {
56
+ // Stay open on error so user can retry
57
+ } finally {
58
+ setExporting(false);
59
+ }
60
+ }
61
+
62
+ return (
63
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
64
+ <div
65
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
66
+ onClick={onClose}
67
+ />
68
+ <div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-sm mx-4 border border-stone-200 dark:border-white/10">
69
+ <div className="p-6">
70
+ <h2 className="text-[18px] font-semibold text-primary mb-5">
71
+ Export to CSV
72
+ </h2>
73
+
74
+ <div className="space-y-4">
75
+ {/* Include headers */}
76
+ <label className="flex items-center gap-2 cursor-pointer">
77
+ <input
78
+ type="checkbox"
79
+ checked={includeHeaders}
80
+ onChange={(e) => setIncludeHeaders(e.target.checked)}
81
+ className="rounded border-stone-300 dark:border-white/20"
82
+ />
83
+ <span className="text-[14px] text-primary">
84
+ Include column headers
85
+ </span>
86
+ </label>
87
+
88
+ {/* Data scope - only when fetchAllRows is available */}
89
+ {fetchAllRows && (
90
+ <div>
91
+ <p className="text-[13px] text-secondary mb-2">Data scope</p>
92
+ <div className="space-y-1.5">
93
+ <label className="flex items-center gap-2 cursor-pointer">
94
+ <input
95
+ type="radio"
96
+ name="scope"
97
+ checked={scope === "current"}
98
+ onChange={() => setScope("current")}
99
+ />
100
+ <span className="text-[14px] text-primary">
101
+ Current page ({currentRows.length.toLocaleString()} rows)
102
+ </span>
103
+ </label>
104
+ <label className="flex items-center gap-2 cursor-pointer">
105
+ <input
106
+ type="radio"
107
+ name="scope"
108
+ checked={scope === "all"}
109
+ onChange={() => setScope("all")}
110
+ />
111
+ <span className="text-[14px] text-primary">
112
+ All rows
113
+ {totalRowCount != null &&
114
+ ` (${totalRowCount.toLocaleString()} total)`}
115
+ </span>
116
+ </label>
117
+ </div>
118
+ </div>
119
+ )}
120
+ </div>
121
+
122
+ <div className="flex gap-3 pt-5">
123
+ <button
124
+ type="button"
125
+ onClick={() => doExport("clipboard")}
126
+ disabled={exporting}
127
+ className="flex-1 px-4 py-2.5 text-[14px] font-medium text-secondary bg-stone-100 dark:bg-white/5 hover:bg-stone-200 dark:hover:bg-white/10 disabled:opacity-50 rounded-lg transition-colors flex items-center justify-center gap-2"
128
+ >
129
+ {exporting ? "Exporting..." : "Copy to clipboard"}
130
+ </button>
131
+ <button
132
+ type="button"
133
+ onClick={() => doExport("file")}
134
+ disabled={exporting}
135
+ className="flex-1 px-4 py-2.5 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg transition-colors flex items-center justify-center gap-2"
136
+ >
137
+ {exporting ? "Exporting..." : "Save to file"}
138
+ </button>
139
+ </div>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }