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,417 @@
1
+ import { useState } from "react";
2
+ import type { DatabaseConfig } from "../types";
3
+ import { useHotkey } from "../stores/hooks";
4
+
5
+ const COLORS = [
6
+ "#ef4444", // red
7
+ "#f59e0b", // amber
8
+ "#22c55e", // green
9
+ "#3b82f6", // blue
10
+ "#8b5cf6", // violet
11
+ "#ec4899", // pink
12
+ "#14b8a6", // teal
13
+ "#f97316", // orange
14
+ ];
15
+
16
+ interface EditConnectionModalProps {
17
+ config: DatabaseConfig;
18
+ onClose: () => void;
19
+ onSave: (config: DatabaseConfig) => void;
20
+ onDelete: (id: string) => void;
21
+ isSaving?: boolean;
22
+ saveError?: string | null;
23
+ isDeleting?: boolean;
24
+ deleteError?: string | null;
25
+ }
26
+
27
+ export function EditConnectionModal({
28
+ config,
29
+ onClose,
30
+ onSave,
31
+ onDelete,
32
+ isSaving = false,
33
+ saveError = null,
34
+ isDeleting = false,
35
+ deleteError = null,
36
+ }: EditConnectionModalProps) {
37
+ const [connectionString, setConnectionString] = useState("");
38
+ const [name, setName] = useState(config.display.name);
39
+ const [color, setColor] = useState(config.display.color);
40
+ const [host, setHost] = useState(config.connection.host);
41
+ const [port, setPort] = useState(config.connection.port.toString());
42
+ const [database, setDatabase] = useState(config.connection.database);
43
+ const [username, setUsername] = useState(config.connection.username);
44
+ const [password, setPassword] = useState(config.connection.password);
45
+ const [extraParams, setExtraParams] = useState<Record<string, string>>(
46
+ config.connection.params ?? {},
47
+ );
48
+ const [paramsString, setParamsString] = useState(
49
+ config.connection.params
50
+ ? new URLSearchParams(config.connection.params).toString()
51
+ : "",
52
+ );
53
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
54
+
55
+ function parseParamsString(value: string) {
56
+ const params: Record<string, string> = {};
57
+ if (value.trim()) {
58
+ for (const pair of value.split("&")) {
59
+ const [k, ...rest] = pair.split("=");
60
+ if (k?.trim()) params[k.trim()] = rest.join("=") || "";
61
+ }
62
+ }
63
+ setExtraParams(params);
64
+ }
65
+
66
+ // Close delete confirmation first if open, otherwise close the modal
67
+ useHotkey("closeModal", () => {
68
+ if (showDeleteConfirm) {
69
+ setShowDeleteConfirm(false);
70
+ } else {
71
+ onClose();
72
+ }
73
+ });
74
+
75
+ function parseConnectionString(connStr: string) {
76
+ try {
77
+ const url = new URL(connStr);
78
+ if (url.protocol === "postgresql:" || url.protocol === "postgres:") {
79
+ if (url.hostname) setHost(url.hostname);
80
+ if (url.port) setPort(url.port);
81
+ if (url.username) setUsername(decodeURIComponent(url.username));
82
+ if (url.password) setPassword(decodeURIComponent(url.password));
83
+ if (url.pathname && url.pathname.length > 1) {
84
+ setDatabase(url.pathname.slice(1));
85
+ }
86
+ const params: Record<string, string> = {};
87
+ url.searchParams.forEach((value, key) => {
88
+ params[key] = value;
89
+ });
90
+ setExtraParams(params);
91
+ setParamsString(url.search ? url.searchParams.toString() : "");
92
+ }
93
+ } catch {
94
+ // Not a valid URL, ignore
95
+ }
96
+ }
97
+
98
+ function handleConnectionStringChange(value: string) {
99
+ setConnectionString(value);
100
+ parseConnectionString(value);
101
+ }
102
+
103
+ function handleSubmit(e: React.FormEvent) {
104
+ e.preventDefault();
105
+ const updatedConfig: DatabaseConfig = {
106
+ ...config,
107
+ display: { name: name || "Untitled Connection", color },
108
+ connection: {
109
+ type: "postgres",
110
+ host,
111
+ port: parseInt(port, 10) || 5432,
112
+ database,
113
+ username,
114
+ password,
115
+ ...(Object.keys(extraParams).length > 0 && { params: extraParams }),
116
+ },
117
+ };
118
+ onSave(updatedConfig);
119
+ }
120
+
121
+ function handleDelete() {
122
+ onDelete(config.id);
123
+ }
124
+
125
+ if (showDeleteConfirm) {
126
+ return (
127
+ <div className="fixed inset-0 z-50 overflow-y-auto">
128
+ <div className="flex min-h-full items-center justify-center p-4">
129
+ <div
130
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm"
131
+ onClick={() => !isDeleting && setShowDeleteConfirm(false)}
132
+ />
133
+ <div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-sm border border-stone-200 dark:border-white/10">
134
+ <div className="p-6">
135
+ <h2 className="text-[18px] font-semibold text-primary mb-2">
136
+ Delete Connection
137
+ </h2>
138
+ <p className="text-[14px] text-secondary mb-6">
139
+ Are you sure you want to delete "{config.display.name}"? This
140
+ action cannot be undone.
141
+ </p>
142
+ {deleteError && (
143
+ <div className="mb-4 p-3 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-200 dark:border-red-500/20">
144
+ {deleteError}
145
+ </div>
146
+ )}
147
+ <div className="flex gap-3">
148
+ <button
149
+ type="button"
150
+ onClick={() => setShowDeleteConfirm(false)}
151
+ disabled={isDeleting}
152
+ 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 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
153
+ >
154
+ Cancel
155
+ </button>
156
+ <button
157
+ type="button"
158
+ onClick={handleDelete}
159
+ disabled={isDeleting}
160
+ className="flex-1 px-4 py-2.5 text-[14px] font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
161
+ >
162
+ {isDeleting && (
163
+ <svg
164
+ className="animate-spin h-4 w-4"
165
+ xmlns="http://www.w3.org/2000/svg"
166
+ fill="none"
167
+ viewBox="0 0 24 24"
168
+ >
169
+ <circle
170
+ className="opacity-25"
171
+ cx="12"
172
+ cy="12"
173
+ r="10"
174
+ stroke="currentColor"
175
+ strokeWidth="4"
176
+ />
177
+ <path
178
+ className="opacity-75"
179
+ fill="currentColor"
180
+ 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"
181
+ />
182
+ </svg>
183
+ )}
184
+ {isDeleting ? "Deleting..." : "Delete"}
185
+ </button>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ return (
195
+ <div className="fixed inset-0 z-50 overflow-y-auto">
196
+ <div className="flex min-h-full items-center justify-center p-4">
197
+ <div
198
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm"
199
+ onClick={onClose}
200
+ />
201
+ <div className="relative bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-md border border-stone-200 dark:border-white/10">
202
+ <div className="p-6">
203
+ <h2 className="text-[18px] font-semibold text-primary mb-6">
204
+ Edit Connection
205
+ </h2>
206
+
207
+ <form
208
+ onSubmit={handleSubmit}
209
+ className="space-y-4"
210
+ autoComplete="off"
211
+ >
212
+ <div>
213
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
214
+ Connection String
215
+ </label>
216
+ <input
217
+ type="text"
218
+ value={connectionString}
219
+ onChange={(e) => handleConnectionStringChange(e.target.value)}
220
+ placeholder="postgresql://user:pass@host:5432/dbname"
221
+ disabled={isSaving}
222
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
223
+ />
224
+ <p className="text-[11px] text-tertiary mt-1">
225
+ Paste a connection string to auto-fill the fields below
226
+ </p>
227
+ </div>
228
+
229
+ <div className="border-t border-stone-200 dark:border-white/10 pt-4">
230
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
231
+ Connection Name
232
+ </label>
233
+ <input
234
+ type="text"
235
+ value={name}
236
+ onChange={(e) => setName(e.target.value)}
237
+ placeholder="My Database"
238
+ disabled={isSaving}
239
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
240
+ autoFocus
241
+ />
242
+ </div>
243
+
244
+ <div>
245
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
246
+ Color
247
+ </label>
248
+ <div className="flex gap-2">
249
+ {COLORS.map((c) => (
250
+ <button
251
+ key={c}
252
+ type="button"
253
+ onClick={() => setColor(c)}
254
+ disabled={isSaving}
255
+ className={`w-7 h-7 rounded-full transition-transform disabled:opacity-50 disabled:cursor-not-allowed ${
256
+ color === c
257
+ ? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-[#1a1a1a] ring-blue-500 scale-110"
258
+ : "hover:scale-110"
259
+ }`}
260
+ style={{ backgroundColor: c }}
261
+ />
262
+ ))}
263
+ </div>
264
+ </div>
265
+
266
+ <div className="grid grid-cols-3 gap-3">
267
+ <div className="col-span-2">
268
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
269
+ Host
270
+ </label>
271
+ <input
272
+ type="text"
273
+ value={host}
274
+ onChange={(e) => setHost(e.target.value)}
275
+ placeholder="localhost"
276
+ disabled={isSaving}
277
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
278
+ />
279
+ </div>
280
+ <div>
281
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
282
+ Port
283
+ </label>
284
+ <input
285
+ type="text"
286
+ value={port}
287
+ onChange={(e) => setPort(e.target.value)}
288
+ placeholder="5432"
289
+ disabled={isSaving}
290
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
291
+ />
292
+ </div>
293
+ </div>
294
+
295
+ <div>
296
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
297
+ Database
298
+ </label>
299
+ <input
300
+ type="text"
301
+ value={database}
302
+ onChange={(e) => setDatabase(e.target.value)}
303
+ placeholder="postgres"
304
+ disabled={isSaving}
305
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
306
+ />
307
+ </div>
308
+
309
+ <div className="grid grid-cols-2 gap-3">
310
+ <div>
311
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
312
+ Username
313
+ </label>
314
+ <input
315
+ type="text"
316
+ value={username}
317
+ onChange={(e) => setUsername(e.target.value)}
318
+ placeholder="postgres"
319
+ autoComplete="off"
320
+ disabled={isSaving}
321
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
322
+ />
323
+ </div>
324
+ <div>
325
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
326
+ Password
327
+ </label>
328
+ <input
329
+ type="password"
330
+ value={password}
331
+ onChange={(e) => setPassword(e.target.value)}
332
+ placeholder="••••••••"
333
+ autoComplete="new-password"
334
+ disabled={isSaving}
335
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
336
+ />
337
+ </div>
338
+ </div>
339
+
340
+ <div>
341
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
342
+ Parameters
343
+ </label>
344
+ <input
345
+ type="text"
346
+ value={paramsString}
347
+ onChange={(e) => {
348
+ setParamsString(e.target.value);
349
+ parseParamsString(e.target.value);
350
+ }}
351
+ placeholder="sslmode=require"
352
+ disabled={isSaving}
353
+ className="w-full px-3 py-2 text-[14px] bg-stone-50 dark:bg-white/5 border border-stone-200 dark:border-white/10 rounded-lg text-primary placeholder:text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed"
354
+ />
355
+ </div>
356
+
357
+ {saveError && (
358
+ <div className="p-3 text-[13px] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-200 dark:border-red-500/20">
359
+ {saveError}
360
+ </div>
361
+ )}
362
+
363
+ <div className="flex gap-3 pt-4">
364
+ <button
365
+ type="button"
366
+ onClick={() => setShowDeleteConfirm(true)}
367
+ disabled={isSaving}
368
+ className="px-4 py-2.5 text-[14px] font-medium text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
369
+ >
370
+ Delete
371
+ </button>
372
+ <div className="flex-1" />
373
+ <button
374
+ type="button"
375
+ onClick={onClose}
376
+ disabled={isSaving}
377
+ className="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 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
378
+ >
379
+ Cancel
380
+ </button>
381
+ <button
382
+ type="submit"
383
+ disabled={isSaving}
384
+ className="px-4 py-2.5 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
385
+ >
386
+ {isSaving && (
387
+ <svg
388
+ className="animate-spin h-4 w-4"
389
+ xmlns="http://www.w3.org/2000/svg"
390
+ fill="none"
391
+ viewBox="0 0 24 24"
392
+ >
393
+ <circle
394
+ className="opacity-25"
395
+ cx="12"
396
+ cy="12"
397
+ r="10"
398
+ stroke="currentColor"
399
+ strokeWidth="4"
400
+ />
401
+ <path
402
+ className="opacity-75"
403
+ fill="currentColor"
404
+ 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"
405
+ />
406
+ </svg>
407
+ )}
408
+ {isSaving ? "Saving..." : "Save Changes"}
409
+ </button>
410
+ </div>
411
+ </form>
412
+ </div>
413
+ </div>
414
+ </div>
415
+ </div>
416
+ );
417
+ }
@@ -0,0 +1,69 @@
1
+ import { Component } from "react";
2
+ import type { ReactNode, ErrorInfo } from "react";
3
+ import { useStore } from "../stores/store";
4
+
5
+ interface Props {
6
+ children: ReactNode;
7
+ }
8
+
9
+ interface State {
10
+ hasError: boolean;
11
+ error: Error | null;
12
+ }
13
+
14
+ export class ErrorBoundary extends Component<Props, State> {
15
+ constructor(props: Props) {
16
+ super(props);
17
+ this.state = { hasError: false, error: null };
18
+ }
19
+
20
+ static getDerivedStateFromError(error: Error): State {
21
+ return { hasError: true, error };
22
+ }
23
+
24
+ componentDidCatch(error: Error, info: ErrorInfo) {
25
+ console.error("ErrorBoundary caught:", error, info.componentStack);
26
+ }
27
+
28
+ handleReset = () => {
29
+ useStore.getState().resetUIState();
30
+ this.setState({ hasError: false, error: null });
31
+ };
32
+
33
+ render() {
34
+ if (this.state.hasError) {
35
+ const darkMode = useStore.getState().darkMode;
36
+ // Ensure the html element has the right class for dark mode
37
+ document.documentElement.classList.toggle("dark", darkMode);
38
+
39
+ return (
40
+ <div
41
+ className={`flex items-center justify-center h-screen ${
42
+ darkMode ? "dark bg-[#0a0a0a]" : "bg-stone-50"
43
+ }`}
44
+ >
45
+ <div className="max-w-md w-full mx-4 p-6 rounded-xl border border-stone-200 dark:border-white/10 bg-white dark:bg-[#1a1a1a]">
46
+ <h1 className="text-lg font-semibold text-primary mb-2">
47
+ Something went wrong
48
+ </h1>
49
+ <p className="text-sm text-secondary mb-4">
50
+ An unexpected error occurred. You can reset the UI state to
51
+ recover. Your database connections and settings will be preserved.
52
+ </p>
53
+ <pre className="text-xs text-tertiary bg-stone-100 dark:bg-white/5 rounded-lg p-3 mb-4 overflow-auto max-h-32">
54
+ {this.state.error?.message}
55
+ </pre>
56
+ <button
57
+ className="px-4 py-2 text-sm font-medium rounded-lg bg-stone-900 dark:bg-white text-white dark:text-black hover:opacity-90 transition-opacity"
58
+ onClick={this.handleReset}
59
+ >
60
+ Reset UI State
61
+ </button>
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return this.props.children;
68
+ }
69
+ }
@@ -0,0 +1,201 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { useHotkey, useNewConsoleTab } from "../stores/hooks";
3
+ import { useStore } from "../stores/store";
4
+
5
+ interface GlobalShortcutsProps {
6
+ onOpenTableSwitcher: () => void;
7
+ onOpenDatabaseSwitcher: () => void;
8
+ }
9
+
10
+ /**
11
+ * Registers global keyboard shortcuts for the app.
12
+ * Render this once at the app root.
13
+ */
14
+ export function GlobalShortcuts({
15
+ onOpenTableSwitcher,
16
+ onOpenDatabaseSwitcher,
17
+ }: GlobalShortcutsProps) {
18
+ // Prevent default on all ctrl/cmd shortcuts to avoid browser interference
19
+ useEffect(() => {
20
+ const isElectron = navigator.userAgent.includes("Electron");
21
+
22
+ const handler = (e: KeyboardEvent) => {
23
+ // Only intercept when ctrl or cmd is pressed (but not just shift/alt alone)
24
+ if (!e.ctrlKey && !e.metaKey) return;
25
+
26
+ const target = e.target as HTMLElement;
27
+ const key = e.key.toLowerCase();
28
+
29
+ // For CodeMirror editors: only prevent default for keys used by app shortcuts.
30
+ // Let all other key combos through so CodeMirror handles them (Cmd+D, Cmd+/, etc.)
31
+ const isInCodeMirror = !!target.closest?.(".cm-editor");
32
+ if (isInCodeMirror) {
33
+ const appShortcutKeys = new Set([
34
+ "t",
35
+ "w",
36
+ "p",
37
+ "o",
38
+ "r",
39
+ "k",
40
+ "j",
41
+ "tab",
42
+ "enter",
43
+ ]);
44
+ if (appShortcutKeys.has(key)) {
45
+ e.preventDefault();
46
+ }
47
+ return;
48
+ }
49
+
50
+ // In Electron, let Cmd+Q through so the app can quit natively
51
+ if (isElectron && e.metaKey && key === "q") return;
52
+
53
+ // Allow standard text editing shortcuts in input fields
54
+ const isInput =
55
+ target.tagName === "INPUT" ||
56
+ target.tagName === "TEXTAREA" ||
57
+ target.isContentEditable;
58
+
59
+ // Always allow these text editing shortcuts in inputs
60
+ const textEditingKeys = ["c", "v", "x", "z", "a"];
61
+ if (isInput && textEditingKeys.includes(key) && !e.shiftKey) {
62
+ return;
63
+ }
64
+
65
+ // Prevent default for all other ctrl/cmd combinations
66
+ e.preventDefault();
67
+ };
68
+
69
+ window.addEventListener("keydown", handler, { capture: true });
70
+ return () =>
71
+ window.removeEventListener("keydown", handler, { capture: true });
72
+ }, []);
73
+ const selectInnerTab = useStore((state) => state.selectInnerTab);
74
+ const closeInnerTab = useStore((state) => state.closeInnerTab);
75
+ const selectConnectionTab = useStore((state) => state.selectConnectionTab);
76
+ const createConnectionTab = useStore((state) => state.createConnectionTab);
77
+ const closeConnectionTab = useStore((state) => state.closeConnectionTab);
78
+ const connectionTabs = useStore((state) => state.connectionTabs);
79
+ const activeTabId = useStore((state) => state.activeTabId);
80
+ const getActiveTab = useStore((state) => state.getActiveTab);
81
+ const newConsoleTab = useNewConsoleTab();
82
+
83
+ // New console tab
84
+ useHotkey(
85
+ "newConsole",
86
+ useCallback(() => {
87
+ const activeTab = getActiveTab();
88
+ if (activeTab?.databaseConfigId) {
89
+ newConsoleTab();
90
+ }
91
+ }, [getActiveTab, newConsoleTab]),
92
+ );
93
+
94
+ // Close inner tab
95
+ useHotkey(
96
+ "closeInnerTab",
97
+ useCallback(() => {
98
+ const activeTab = getActiveTab();
99
+ if (activeTab?.activeInnerTabId) {
100
+ closeInnerTab(activeTab.activeInnerTabId);
101
+ }
102
+ }, [getActiveTab, closeInnerTab]),
103
+ );
104
+
105
+ // Next inner tab
106
+ useHotkey(
107
+ "nextInnerTab",
108
+ useCallback(() => {
109
+ const activeTab = getActiveTab();
110
+ if (!activeTab || activeTab.innerTabs.length === 0) return;
111
+ const currentIndex = activeTab.innerTabs.findIndex(
112
+ (t) => t.id === activeTab.activeInnerTabId,
113
+ );
114
+ const nextIndex = (currentIndex + 1) % activeTab.innerTabs.length;
115
+ selectInnerTab(activeTab.innerTabs[nextIndex].id);
116
+ }, [getActiveTab, selectInnerTab]),
117
+ );
118
+
119
+ // Previous inner tab
120
+ useHotkey(
121
+ "prevInnerTab",
122
+ useCallback(() => {
123
+ const activeTab = getActiveTab();
124
+ if (!activeTab || activeTab.innerTabs.length === 0) return;
125
+ const currentIndex = activeTab.innerTabs.findIndex(
126
+ (t) => t.id === activeTab.activeInnerTabId,
127
+ );
128
+ const prevIndex =
129
+ (currentIndex - 1 + activeTab.innerTabs.length) %
130
+ activeTab.innerTabs.length;
131
+ selectInnerTab(activeTab.innerTabs[prevIndex].id);
132
+ }, [getActiveTab, selectInnerTab]),
133
+ );
134
+
135
+ // New connection tab
136
+ useHotkey(
137
+ "newConnectionTab",
138
+ useCallback(() => {
139
+ createConnectionTab();
140
+ }, [createConnectionTab]),
141
+ );
142
+
143
+ // Close connection tab
144
+ useHotkey(
145
+ "closeConnectionTab",
146
+ useCallback(() => {
147
+ if (activeTabId) {
148
+ closeConnectionTab(activeTabId);
149
+ }
150
+ }, [activeTabId, closeConnectionTab]),
151
+ );
152
+
153
+ // Next connection tab
154
+ useHotkey(
155
+ "nextConnectionTab",
156
+ useCallback(() => {
157
+ if (connectionTabs.length === 0) return;
158
+ const currentIndex = connectionTabs.findIndex(
159
+ (t) => t.id === activeTabId,
160
+ );
161
+ const nextIndex = (currentIndex + 1) % connectionTabs.length;
162
+ selectConnectionTab(connectionTabs[nextIndex].id);
163
+ }, [connectionTabs, activeTabId, selectConnectionTab]),
164
+ );
165
+
166
+ // Previous connection tab
167
+ useHotkey(
168
+ "prevConnectionTab",
169
+ useCallback(() => {
170
+ if (connectionTabs.length === 0) return;
171
+ const currentIndex = connectionTabs.findIndex(
172
+ (t) => t.id === activeTabId,
173
+ );
174
+ const prevIndex =
175
+ (currentIndex - 1 + connectionTabs.length) % connectionTabs.length;
176
+ selectConnectionTab(connectionTabs[prevIndex].id);
177
+ }, [connectionTabs, activeTabId, selectConnectionTab]),
178
+ );
179
+
180
+ // Open table switcher
181
+ useHotkey(
182
+ "openTableSwitcher",
183
+ useCallback(() => {
184
+ const activeTab = getActiveTab();
185
+ if (activeTab?.databaseConfigId) {
186
+ onOpenTableSwitcher();
187
+ }
188
+ }, [getActiveTab, onOpenTableSwitcher]),
189
+ );
190
+
191
+ // Open database switcher
192
+ useHotkey(
193
+ "openDatabaseSwitcher",
194
+ useCallback(() => {
195
+ onOpenDatabaseSwitcher();
196
+ }, [onOpenDatabaseSwitcher]),
197
+ );
198
+
199
+ // This component doesn't render anything
200
+ return null;
201
+ }