dbdiff-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,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
|
+
}
|