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,274 @@
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 NewConnectionModalProps {
17
+ onClose: () => void;
18
+ onSave: (config: DatabaseConfig) => void;
19
+ }
20
+
21
+ export function NewConnectionModal({
22
+ onClose,
23
+ onSave,
24
+ }: NewConnectionModalProps) {
25
+ const [connectionString, setConnectionString] = useState("");
26
+ const [name, setName] = useState("");
27
+ const [color, setColor] = useState(COLORS[0]);
28
+ const [host, setHost] = useState("localhost");
29
+ const [port, setPort] = useState("5432");
30
+ const [database, setDatabase] = useState("");
31
+ const [username, setUsername] = useState("postgres");
32
+ const [password, setPassword] = useState("");
33
+ const [extraParams, setExtraParams] = useState<Record<string, string>>({});
34
+ const [paramsString, setParamsString] = useState("");
35
+
36
+ useHotkey("closeModal", onClose);
37
+
38
+ function parseParamsString(value: string) {
39
+ const params: Record<string, string> = {};
40
+ if (value.trim()) {
41
+ for (const pair of value.split("&")) {
42
+ const [k, ...rest] = pair.split("=");
43
+ if (k?.trim()) params[k.trim()] = rest.join("=") || "";
44
+ }
45
+ }
46
+ setExtraParams(params);
47
+ }
48
+
49
+ function parseConnectionString(connStr: string) {
50
+ // Parse: postgresql://username:password@host:port/database
51
+ // or: postgres://username:password@host:port/database
52
+ try {
53
+ const url = new URL(connStr);
54
+ if (url.protocol === "postgresql:" || url.protocol === "postgres:") {
55
+ if (url.hostname) setHost(url.hostname);
56
+ if (url.port) setPort(url.port);
57
+ if (url.username) setUsername(decodeURIComponent(url.username));
58
+ if (url.password) setPassword(decodeURIComponent(url.password));
59
+ if (url.pathname && url.pathname.length > 1) {
60
+ setDatabase(url.pathname.slice(1)); // remove leading /
61
+ }
62
+ const params: Record<string, string> = {};
63
+ url.searchParams.forEach((value, key) => {
64
+ params[key] = value;
65
+ });
66
+ setExtraParams(params);
67
+ setParamsString(url.search ? url.searchParams.toString() : "");
68
+ }
69
+ } catch {
70
+ // Not a valid URL, ignore
71
+ }
72
+ }
73
+
74
+ function handleConnectionStringChange(value: string) {
75
+ setConnectionString(value);
76
+ parseConnectionString(value);
77
+ }
78
+
79
+ function handleSubmit(e: React.FormEvent) {
80
+ e.preventDefault();
81
+ const config: DatabaseConfig = {
82
+ id: Date.now().toString(),
83
+ display: { name: name || "Untitled Connection", color },
84
+ connection: {
85
+ type: "postgres",
86
+ host,
87
+ port: parseInt(port, 10) || 5432,
88
+ database,
89
+ username,
90
+ password,
91
+ ...(Object.keys(extraParams).length > 0 && { params: extraParams }),
92
+ },
93
+ cache: {},
94
+ source: "local",
95
+ };
96
+ onSave(config);
97
+ }
98
+
99
+ return (
100
+ <div className="fixed inset-0 z-50 overflow-y-auto">
101
+ <div className="flex min-h-full items-center justify-center p-4">
102
+ <div
103
+ className="fixed inset-0 bg-black/50 backdrop-blur-sm"
104
+ onClick={onClose}
105
+ />
106
+ <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">
107
+ <div className="p-6">
108
+ <h2 className="text-[18px] font-semibold text-primary mb-6">
109
+ New Connection
110
+ </h2>
111
+
112
+ <form
113
+ onSubmit={handleSubmit}
114
+ className="space-y-4"
115
+ autoComplete="off"
116
+ >
117
+ <div>
118
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
119
+ Connection String
120
+ </label>
121
+ <input
122
+ type="text"
123
+ value={connectionString}
124
+ onChange={(e) => handleConnectionStringChange(e.target.value)}
125
+ placeholder="postgresql://user:pass@host:5432/dbname"
126
+ 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"
127
+ autoFocus
128
+ />
129
+ <p className="text-[11px] text-tertiary mt-1">
130
+ Paste a connection string to auto-fill the fields below
131
+ </p>
132
+ </div>
133
+
134
+ <div className="border-t border-stone-200 dark:border-white/10 pt-4">
135
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
136
+ Connection Name
137
+ </label>
138
+ <input
139
+ type="text"
140
+ value={name}
141
+ onChange={(e) => setName(e.target.value)}
142
+ placeholder="My Database"
143
+ 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"
144
+ />
145
+ </div>
146
+
147
+ <div>
148
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
149
+ Color
150
+ </label>
151
+ <div className="flex gap-2">
152
+ {COLORS.map((c) => (
153
+ <button
154
+ key={c}
155
+ type="button"
156
+ onClick={() => setColor(c)}
157
+ className={`w-7 h-7 rounded-full transition-transform ${
158
+ color === c
159
+ ? "ring-2 ring-offset-2 ring-offset-white dark:ring-offset-[#1a1a1a] ring-blue-500 scale-110"
160
+ : "hover:scale-110"
161
+ }`}
162
+ style={{ backgroundColor: c }}
163
+ />
164
+ ))}
165
+ </div>
166
+ </div>
167
+
168
+ <div className="grid grid-cols-3 gap-3">
169
+ <div className="col-span-2">
170
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
171
+ Host
172
+ </label>
173
+ <input
174
+ type="text"
175
+ value={host}
176
+ onChange={(e) => setHost(e.target.value)}
177
+ placeholder="localhost"
178
+ 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"
179
+ />
180
+ </div>
181
+ <div>
182
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
183
+ Port
184
+ </label>
185
+ <input
186
+ type="text"
187
+ value={port}
188
+ onChange={(e) => setPort(e.target.value)}
189
+ placeholder="5432"
190
+ 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"
191
+ />
192
+ </div>
193
+ </div>
194
+
195
+ <div>
196
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
197
+ Database
198
+ </label>
199
+ <input
200
+ type="text"
201
+ value={database}
202
+ onChange={(e) => setDatabase(e.target.value)}
203
+ placeholder="postgres"
204
+ 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"
205
+ />
206
+ </div>
207
+
208
+ <div className="grid grid-cols-2 gap-3">
209
+ <div>
210
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
211
+ Username
212
+ </label>
213
+ <input
214
+ type="text"
215
+ value={username}
216
+ onChange={(e) => setUsername(e.target.value)}
217
+ placeholder="postgres"
218
+ autoComplete="off"
219
+ 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"
220
+ />
221
+ </div>
222
+ <div>
223
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
224
+ Password
225
+ </label>
226
+ <input
227
+ type="password"
228
+ value={password}
229
+ onChange={(e) => setPassword(e.target.value)}
230
+ placeholder="••••••••"
231
+ autoComplete="new-password"
232
+ 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"
233
+ />
234
+ </div>
235
+ </div>
236
+
237
+ <div>
238
+ <label className="block text-[12px] font-medium text-secondary mb-1.5">
239
+ Parameters
240
+ </label>
241
+ <input
242
+ type="text"
243
+ value={paramsString}
244
+ onChange={(e) => {
245
+ setParamsString(e.target.value);
246
+ parseParamsString(e.target.value);
247
+ }}
248
+ placeholder="sslmode=require"
249
+ 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"
250
+ />
251
+ </div>
252
+
253
+ <div className="flex gap-3 pt-4">
254
+ <button
255
+ type="button"
256
+ onClick={onClose}
257
+ 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"
258
+ >
259
+ Cancel
260
+ </button>
261
+ <button
262
+ type="submit"
263
+ className="flex-1 px-4 py-2.5 text-[14px] font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
264
+ >
265
+ Save Connection
266
+ </button>
267
+ </div>
268
+ </form>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ );
274
+ }
@@ -0,0 +1,66 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ interface ResizerProps {
4
+ direction: "horizontal" | "vertical";
5
+ onResize: (delta: number) => void;
6
+ }
7
+
8
+ export function Resizer({ direction, onResize }: ResizerProps) {
9
+ const [isDragging, setIsDragging] = useState(false);
10
+
11
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
12
+ e.preventDefault();
13
+ setIsDragging(true);
14
+ }, []);
15
+
16
+ useEffect(() => {
17
+ if (!isDragging) return;
18
+
19
+ let lastPos = direction === "horizontal" ? 0 : 0;
20
+
21
+ const handleMouseMove = (e: MouseEvent) => {
22
+ const currentPos = direction === "horizontal" ? e.clientX : e.clientY;
23
+ if (lastPos !== 0) {
24
+ const delta = currentPos - lastPos;
25
+ onResize(delta);
26
+ }
27
+ lastPos = currentPos;
28
+ };
29
+
30
+ const handleMouseUp = () => {
31
+ setIsDragging(false);
32
+ };
33
+
34
+ document.addEventListener("mousemove", handleMouseMove);
35
+ document.addEventListener("mouseup", handleMouseUp);
36
+
37
+ return () => {
38
+ document.removeEventListener("mousemove", handleMouseMove);
39
+ document.removeEventListener("mouseup", handleMouseUp);
40
+ };
41
+ }, [isDragging, direction, onResize]);
42
+
43
+ const isHorizontal = direction === "horizontal";
44
+
45
+ return (
46
+ <div
47
+ onMouseDown={handleMouseDown}
48
+ className={`
49
+ ${isHorizontal ? "w-1 cursor-col-resize" : "h-1 cursor-row-resize"}
50
+ flex-shrink-0 bg-transparent hover:bg-blue-500/50 active:bg-blue-500/50 transition-colors
51
+ ${isDragging ? "bg-blue-500/50" : ""}
52
+ `}
53
+ style={{
54
+ // Expand hit area
55
+ ...(isHorizontal
56
+ ? { marginLeft: -4, marginRight: -4, paddingLeft: 4, paddingRight: 4 }
57
+ : {
58
+ marginTop: -4,
59
+ marginBottom: -4,
60
+ paddingTop: 4,
61
+ paddingBottom: 4,
62
+ }),
63
+ }}
64
+ />
65
+ );
66
+ }
@@ -0,0 +1,113 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useHotkey } from "../stores/hooks";
3
+
4
+ interface ScanSuccessModalProps {
5
+ count: number;
6
+ onClose: () => void;
7
+ }
8
+
9
+ const CONFETTI_COLORS = [
10
+ "#ef4444",
11
+ "#f59e0b",
12
+ "#22c55e",
13
+ "#3b82f6",
14
+ "#8b5cf6",
15
+ "#ec4899",
16
+ "#14b8a6",
17
+ "#f97316",
18
+ ];
19
+
20
+ function ConfettiPiece({ index }: { index: number }) {
21
+ const [style] = useState(() => {
22
+ const color =
23
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
24
+ const left = Math.random() * 100;
25
+ const delay = Math.random() * 0.5;
26
+ const duration = 1.5 + Math.random() * 1.5;
27
+ const size = 6 + Math.random() * 6;
28
+ const rotation = Math.random() * 360;
29
+ const isCircle = index % 3 === 0;
30
+
31
+ return {
32
+ left: `${left}%`,
33
+ width: `${size}px`,
34
+ height: `${size}px`,
35
+ backgroundColor: color,
36
+ borderRadius: isCircle ? "50%" : "2px",
37
+ animationDelay: `${delay}s`,
38
+ animationDuration: `${duration}s`,
39
+ transform: `rotate(${rotation}deg)`,
40
+ } as React.CSSProperties;
41
+ });
42
+
43
+ return <div className="confetti-piece" style={style} />;
44
+ }
45
+
46
+ export function ScanSuccessModal({ count, onClose }: ScanSuccessModalProps) {
47
+ useHotkey("closeModal", onClose);
48
+
49
+ useEffect(() => {
50
+ const timer = setTimeout(onClose, 4000);
51
+ return () => clearTimeout(timer);
52
+ }, [onClose]);
53
+
54
+ return (
55
+ <div className="fixed inset-0 z-50 flex items-center justify-center">
56
+ <div
57
+ className="absolute inset-0 bg-black/50 backdrop-blur-sm"
58
+ onClick={onClose}
59
+ />
60
+ <div className="confetti-container">
61
+ {Array.from({ length: 40 }, (_, i) => (
62
+ <ConfettiPiece key={i} index={i} />
63
+ ))}
64
+ </div>
65
+ <div className="relative z-[2] bg-white dark:bg-[#1a1a1a] rounded-xl shadow-2xl w-full max-w-sm mx-4 border border-stone-200 dark:border-white/10">
66
+ <div className="p-8 text-center">
67
+ <div className="text-5xl mb-4">🎉</div>
68
+ <h2 className="text-[20px] font-semibold text-primary mb-2">
69
+ Congrats!
70
+ </h2>
71
+ <p className="text-[14px] text-secondary mb-6">
72
+ {count} new database{count !== 1 ? "s" : ""} found on localhost
73
+ </p>
74
+ <button
75
+ onClick={onClose}
76
+ className="px-6 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"
77
+ >
78
+ Okay
79
+ </button>
80
+ </div>
81
+ </div>
82
+
83
+ <style>{`
84
+ .confetti-container {
85
+ position: fixed;
86
+ inset: 0;
87
+ pointer-events: none;
88
+ overflow: hidden;
89
+ z-index: 1;
90
+ }
91
+ .confetti-piece {
92
+ position: absolute;
93
+ top: -10px;
94
+ opacity: 0;
95
+ animation: confetti-fall linear forwards;
96
+ }
97
+ @keyframes confetti-fall {
98
+ 0% {
99
+ opacity: 1;
100
+ transform: translateY(0) rotate(0deg);
101
+ }
102
+ 80% {
103
+ opacity: 1;
104
+ }
105
+ 100% {
106
+ opacity: 0;
107
+ transform: translateY(100vh) rotate(720deg);
108
+ }
109
+ }
110
+ `}</style>
111
+ </div>
112
+ );
113
+ }