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,129 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { InnerTab } from "../types";
3
+
4
+ interface InnerTabBarProps {
5
+ innerTabs: InnerTab[];
6
+ activeInnerTabId: string | null;
7
+ draggedInnerTabId: string | null;
8
+ onTabSelect: (tabId: string) => void;
9
+ onTabClose: (tabId: string) => void;
10
+ onNewConsole: () => void;
11
+ onDragStart: (e: React.DragEvent, tabId: string) => void;
12
+ onDragOver: (e: React.DragEvent, tabId: string) => void;
13
+ onDragEnd: () => void;
14
+ }
15
+
16
+ export function InnerTabBar({
17
+ innerTabs,
18
+ activeInnerTabId,
19
+ draggedInnerTabId,
20
+ onTabSelect,
21
+ onTabClose,
22
+ onNewConsole,
23
+ onDragStart,
24
+ onDragOver,
25
+ onDragEnd,
26
+ }: InnerTabBarProps) {
27
+ const activeTabRef = useRef<HTMLDivElement | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (activeInnerTabId && activeTabRef.current) {
31
+ activeTabRef.current.scrollIntoView({
32
+ behavior: "smooth",
33
+ block: "nearest",
34
+ inline: "nearest",
35
+ });
36
+ }
37
+ }, [activeInnerTabId]);
38
+
39
+ return (
40
+ <div className="flex items-center min-h-9 bg-stone-50 dark:bg-[#0f0f0f] border-b border-stone-200 dark:border-white/[0.06] gap-1 px-2 py-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
41
+ {innerTabs.map((tab) => (
42
+ <div
43
+ key={tab.id}
44
+ ref={tab.id === activeInnerTabId ? activeTabRef : undefined}
45
+ draggable
46
+ onDragStart={(e) => onDragStart(e, tab.id)}
47
+ onDragOver={(e) => onDragOver(e, tab.id)}
48
+ onDragEnd={onDragEnd}
49
+ className={`group flex items-center gap-1.5 pl-3 pr-1.5 h-7 rounded-md cursor-pointer select-none transition-all duration-150 flex-shrink-0 ${
50
+ tab.id === activeInnerTabId
51
+ ? "bg-white dark:bg-white/[0.06] text-primary shadow-sm dark:shadow-none"
52
+ : "text-tertiary hover:text-primary hover:bg-stone-100 dark:hover:bg-white/[0.03]"
53
+ } ${draggedInnerTabId === tab.id ? "opacity-50" : ""}`}
54
+ onClick={() => onTabSelect(tab.id)}
55
+ >
56
+ {tab.type === "table" && (
57
+ <svg
58
+ className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
59
+ viewBox="0 0 24 24"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="1.5"
63
+ >
64
+ <path d="M3 6h18M3 12h18M3 18h18M9 6v12M15 6v12" />
65
+ </svg>
66
+ )}
67
+ {tab.type === "console" && (
68
+ <svg
69
+ className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
70
+ viewBox="0 0 24 24"
71
+ fill="none"
72
+ stroke="currentColor"
73
+ strokeWidth="1.5"
74
+ >
75
+ <path d="M4 17l6-6-6-6M12 19h8" />
76
+ </svg>
77
+ )}
78
+ {tab.type === "query" && (
79
+ <svg
80
+ className="w-3.5 h-3.5 flex-shrink-0 opacity-60"
81
+ viewBox="0 0 24 24"
82
+ fill="none"
83
+ stroke="currentColor"
84
+ strokeWidth="1.5"
85
+ >
86
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
87
+ <path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" />
88
+ </svg>
89
+ )}
90
+ <span className="text-[12px] font-medium tracking-[-0.01em] truncate max-w-[100px]">
91
+ {tab.name}
92
+ </span>
93
+ <button
94
+ className="w-4 h-4 flex items-center justify-center rounded-full transition-all hover:bg-stone-200 dark:hover:bg-white/10 opacity-0 group-hover:opacity-40 hover:!opacity-100 cursor-pointer"
95
+ onClick={(e) => {
96
+ e.stopPropagation();
97
+ onTabClose(tab.id);
98
+ }}
99
+ >
100
+ <svg
101
+ className="w-2.5 h-2.5"
102
+ viewBox="0 0 12 12"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ strokeWidth="1.5"
106
+ >
107
+ <path d="M3 3l6 6M9 3l-6 6" />
108
+ </svg>
109
+ </button>
110
+ </div>
111
+ ))}
112
+ <button
113
+ className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md text-interactive-subtle hover:bg-stone-100 dark:hover:bg-white/[0.03] transition-all duration-150 focus:outline-none"
114
+ onClick={onNewConsole}
115
+ title="New Console"
116
+ >
117
+ <svg
118
+ className="w-3.5 h-3.5"
119
+ viewBox="0 0 16 16"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="1.5"
123
+ >
124
+ <path d="M8 3v10M3 8h10" />
125
+ </svg>
126
+ </button>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,387 @@
1
+ import React, { useCallback, useMemo, useRef, useState } from "react";
2
+ import { ChevronRight, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
3
+
4
+ interface JsonTreeViewerProps {
5
+ data: unknown;
6
+ columnName: string;
7
+ onEdit?: (newData: unknown) => void;
8
+ canEdit: boolean;
9
+ }
10
+
11
+ export function JsonTreeViewer({
12
+ data,
13
+ columnName,
14
+ onEdit,
15
+ canEdit,
16
+ }: JsonTreeViewerProps) {
17
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
18
+ () => new Set(["$"]),
19
+ );
20
+
21
+ const allPaths = useMemo(() => {
22
+ const paths = new Set<string>();
23
+ function collect(value: unknown, path: string) {
24
+ if (value !== null && typeof value === "object") {
25
+ paths.add(path);
26
+ if (Array.isArray(value)) {
27
+ value.forEach((item, i) => collect(item, `${path}[${i}]`));
28
+ } else {
29
+ Object.keys(value as Record<string, unknown>).forEach((key) =>
30
+ collect((value as Record<string, unknown>)[key], `${path}.${key}`),
31
+ );
32
+ }
33
+ }
34
+ }
35
+ collect(data, "$");
36
+ return paths;
37
+ }, [data]);
38
+
39
+ const togglePath = useCallback((path: string) => {
40
+ setExpandedPaths((prev) => {
41
+ const next = new Set(prev);
42
+ if (next.has(path)) {
43
+ next.delete(path);
44
+ } else {
45
+ next.add(path);
46
+ }
47
+ return next;
48
+ });
49
+ }, []);
50
+
51
+ const expandAll = useCallback(() => {
52
+ setExpandedPaths(new Set(allPaths));
53
+ }, [allPaths]);
54
+
55
+ const collapseAll = useCallback(() => {
56
+ setExpandedPaths(new Set());
57
+ }, []);
58
+
59
+ const handleEdit = useCallback(
60
+ (path: string[], newValue: unknown) => {
61
+ if (!onEdit) return;
62
+ // Deep clone and set value at path
63
+ const cloned = JSON.parse(JSON.stringify(data));
64
+ let target = cloned;
65
+ for (let i = 0; i < path.length - 1; i++) {
66
+ target = target[path[i]];
67
+ }
68
+ target[path[path.length - 1]] = newValue;
69
+ onEdit(cloned);
70
+ },
71
+ [data, onEdit],
72
+ );
73
+
74
+ const isAllExpanded = expandedPaths.size >= allPaths.size;
75
+
76
+ return (
77
+ <div className="h-full flex flex-col">
78
+ <div className="flex-shrink-0 flex items-center justify-between px-3 py-1.5 border-b border-stone-200 dark:border-white/[0.06] bg-stone-50 dark:bg-white/[0.02]">
79
+ <span className="text-[11px] font-medium text-tertiary uppercase tracking-wide">
80
+ JSON: {columnName}
81
+ </span>
82
+ <button
83
+ onClick={isAllExpanded ? collapseAll : expandAll}
84
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[11px] text-tertiary hover:text-secondary rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] transition-colors"
85
+ >
86
+ {isAllExpanded ? (
87
+ <>
88
+ <ChevronsDownUp className="w-3 h-3" />
89
+ Collapse All
90
+ </>
91
+ ) : (
92
+ <>
93
+ <ChevronsUpDown className="w-3 h-3" />
94
+ Expand All
95
+ </>
96
+ )}
97
+ </button>
98
+ </div>
99
+ <div className="flex-1 overflow-auto p-2 font-mono text-[12px]">
100
+ <JsonNode
101
+ value={data}
102
+ path="$"
103
+ keyPath={[]}
104
+ expandedPaths={expandedPaths}
105
+ onToggle={togglePath}
106
+ canEdit={canEdit}
107
+ onEdit={handleEdit}
108
+ />
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ const ARRAY_TRUNCATE_LIMIT = 100;
115
+
116
+ interface JsonNodeProps {
117
+ value: unknown;
118
+ path: string;
119
+ keyPath: string[];
120
+ keyName?: string;
121
+ expandedPaths: Set<string>;
122
+ onToggle: (path: string) => void;
123
+ canEdit: boolean;
124
+ onEdit: (path: string[], newValue: unknown) => void;
125
+ isArrayItem?: boolean;
126
+ arrayIndex?: number;
127
+ }
128
+
129
+ const JsonNode = React.memo(function JsonNode({
130
+ value,
131
+ path,
132
+ keyPath,
133
+ keyName,
134
+ expandedPaths,
135
+ onToggle,
136
+ canEdit,
137
+ onEdit,
138
+ isArrayItem,
139
+ arrayIndex,
140
+ }: JsonNodeProps) {
141
+ const isExpanded = expandedPaths.has(path);
142
+ const isObject = value !== null && typeof value === "object";
143
+ const isArray = Array.isArray(value);
144
+
145
+ if (!isObject) {
146
+ return (
147
+ <div className="flex items-start">
148
+ {keyName !== undefined && (
149
+ <span className="text-secondary">
150
+ {keyName}
151
+ <span className="text-tertiary">: </span>
152
+ </span>
153
+ )}
154
+ {isArrayItem && arrayIndex !== undefined && (
155
+ <span className="text-tertiary mr-1">{arrayIndex}: </span>
156
+ )}
157
+ <LeafValue
158
+ value={value}
159
+ canEdit={canEdit}
160
+ onEdit={(newVal) => onEdit(keyPath, newVal)}
161
+ />
162
+ </div>
163
+ );
164
+ }
165
+
166
+ const entries = isArray
167
+ ? (value as unknown[])
168
+ : Object.entries(value as Record<string, unknown>);
169
+ const entryCount = isArray
170
+ ? (value as unknown[]).length
171
+ : Object.keys(value as Record<string, unknown>).length;
172
+
173
+ const collapsedPreview = isArray
174
+ ? `[${entryCount} item${entryCount !== 1 ? "s" : ""}]`
175
+ : getObjectPreview(value as Record<string, unknown>);
176
+
177
+ return (
178
+ <div>
179
+ <div
180
+ className="flex items-start cursor-pointer hover:bg-stone-100 dark:hover:bg-white/[0.04] rounded px-0.5 -mx-0.5"
181
+ onClick={() => onToggle(path)}
182
+ >
183
+ <ChevronRight
184
+ className={`w-3 h-3 mt-0.5 flex-shrink-0 text-tertiary transition-transform ${isExpanded ? "rotate-90" : ""}`}
185
+ />
186
+ <span className="ml-0.5">
187
+ {keyName !== undefined && (
188
+ <span className="text-secondary">
189
+ {keyName}
190
+ <span className="text-tertiary">: </span>
191
+ </span>
192
+ )}
193
+ {isArrayItem && arrayIndex !== undefined && (
194
+ <span className="text-tertiary">{arrayIndex}: </span>
195
+ )}
196
+ {!isExpanded && (
197
+ <span className="text-tertiary">{collapsedPreview}</span>
198
+ )}
199
+ {isExpanded && (
200
+ <span className="text-tertiary">{isArray ? "[" : "{"}</span>
201
+ )}
202
+ </span>
203
+ </div>
204
+ {isExpanded && (
205
+ <div className="pl-4">
206
+ {isArray
207
+ ? (entries as unknown[]).map((item, i) => {
208
+ if (i >= ARRAY_TRUNCATE_LIMIT) {
209
+ if (i === ARRAY_TRUNCATE_LIMIT) {
210
+ return (
211
+ <ShowMoreButton
212
+ key="__show_more__"
213
+ remaining={entryCount - ARRAY_TRUNCATE_LIMIT}
214
+ onToggle={() => onToggle(path)}
215
+ />
216
+ );
217
+ }
218
+ return null;
219
+ }
220
+ return (
221
+ <JsonNode
222
+ key={i}
223
+ value={item}
224
+ path={`${path}[${i}]`}
225
+ keyPath={[...keyPath, String(i)]}
226
+ expandedPaths={expandedPaths}
227
+ onToggle={onToggle}
228
+ canEdit={canEdit}
229
+ onEdit={onEdit}
230
+ isArrayItem
231
+ arrayIndex={i}
232
+ />
233
+ );
234
+ })
235
+ : (entries as [string, unknown][]).map(([k, v]) => (
236
+ <JsonNode
237
+ key={k}
238
+ value={v}
239
+ path={`${path}.${k}`}
240
+ keyPath={[...keyPath, k]}
241
+ keyName={k}
242
+ expandedPaths={expandedPaths}
243
+ onToggle={onToggle}
244
+ canEdit={canEdit}
245
+ onEdit={onEdit}
246
+ />
247
+ ))}
248
+ <div
249
+ className="text-tertiary px-0.5 cursor-pointer hover:bg-stone-100 dark:hover:bg-white/[0.04] rounded -mx-0.5"
250
+ onClick={() => onToggle(path)}
251
+ >
252
+ {isArray ? "]" : "}"}
253
+ </div>
254
+ </div>
255
+ )}
256
+ </div>
257
+ );
258
+ });
259
+
260
+ function ShowMoreButton({
261
+ remaining,
262
+ onToggle,
263
+ }: {
264
+ remaining: number;
265
+ onToggle: () => void;
266
+ }) {
267
+ return (
268
+ <div
269
+ className="px-0.5 -mx-0.5 text-blue-600 dark:text-blue-400 cursor-pointer hover:underline"
270
+ onClick={(e) => {
271
+ e.stopPropagation();
272
+ onToggle();
273
+ }}
274
+ >
275
+ ... {remaining} more item{remaining !== 1 ? "s" : ""} (collapse to reset)
276
+ </div>
277
+ );
278
+ }
279
+
280
+ interface LeafValueProps {
281
+ value: unknown;
282
+ canEdit: boolean;
283
+ onEdit: (newValue: unknown) => void;
284
+ }
285
+
286
+ function LeafValue({ value, canEdit, onEdit }: LeafValueProps) {
287
+ const [isEditing, setIsEditing] = useState(false);
288
+ const [editText, setEditText] = useState("");
289
+ const inputRef = useRef<HTMLInputElement>(null);
290
+
291
+ const handleDoubleClick = (e: React.MouseEvent) => {
292
+ e.stopPropagation();
293
+ if (!canEdit) return;
294
+ setEditText(value === null ? "" : String(value));
295
+ setIsEditing(true);
296
+ setTimeout(() => {
297
+ inputRef.current?.focus();
298
+ inputRef.current?.select();
299
+ }, 0);
300
+ };
301
+
302
+ const handleCommit = () => {
303
+ setIsEditing(false);
304
+ const coerced = coerceValue(editText, value);
305
+ onEdit(coerced);
306
+ };
307
+
308
+ const handleCancel = () => {
309
+ setIsEditing(false);
310
+ };
311
+
312
+ const handleKeyDown = (e: React.KeyboardEvent) => {
313
+ if (e.key === "Enter") {
314
+ e.preventDefault();
315
+ handleCommit();
316
+ } else if (e.key === "Escape") {
317
+ e.preventDefault();
318
+ handleCancel();
319
+ }
320
+ };
321
+
322
+ const handleCopy = (e: React.MouseEvent) => {
323
+ e.stopPropagation();
324
+ if (isEditing) return;
325
+ const text = value === null ? "null" : String(value);
326
+ navigator.clipboard.writeText(text);
327
+ };
328
+
329
+ if (isEditing) {
330
+ return (
331
+ <input
332
+ ref={inputRef}
333
+ type="text"
334
+ value={editText}
335
+ onChange={(e) => setEditText(e.target.value)}
336
+ onKeyDown={handleKeyDown}
337
+ onBlur={handleCommit}
338
+ onClick={(e) => e.stopPropagation()}
339
+ onMouseDown={(e) => e.stopPropagation()}
340
+ className="px-1 py-0 text-[12px] font-mono bg-white dark:bg-stone-800 border border-blue-500 dark:border-blue-400 rounded outline-none min-w-[60px]"
341
+ />
342
+ );
343
+ }
344
+
345
+ return (
346
+ <span
347
+ className={`${getValueColorClass(value)} cursor-pointer hover:underline decoration-dotted`}
348
+ onClick={handleCopy}
349
+ onDoubleClick={handleDoubleClick}
350
+ title={canEdit ? "Click to copy, double-click to edit" : "Click to copy"}
351
+ >
352
+ {formatLeafValue(value)}
353
+ </span>
354
+ );
355
+ }
356
+
357
+ function getValueColorClass(value: unknown): string {
358
+ if (value === null) return "text-tertiary italic";
359
+ if (typeof value === "string") return "text-green-600 dark:text-green-400";
360
+ if (typeof value === "number") return "text-blue-600 dark:text-blue-400";
361
+ if (typeof value === "boolean") return "text-purple-600 dark:text-purple-400";
362
+ return "text-secondary";
363
+ }
364
+
365
+ function formatLeafValue(value: unknown): string {
366
+ if (value === null) return "null";
367
+ if (typeof value === "string") return `"${value}"`;
368
+ return String(value);
369
+ }
370
+
371
+ function getObjectPreview(obj: Record<string, unknown>): string {
372
+ const keys = Object.keys(obj);
373
+ if (keys.length === 0) return "{}";
374
+ if (keys.length <= 3) return `{ ${keys.join(", ")} }`;
375
+ return `{ ${keys.slice(0, 3).join(", ")}, ... }`;
376
+ }
377
+
378
+ function coerceValue(text: string, originalValue: unknown): unknown {
379
+ if (text === "" || text === "null") return null;
380
+ if (text === "true") return true;
381
+ if (text === "false") return false;
382
+ if (typeof originalValue === "number") {
383
+ const num = Number(text);
384
+ if (!isNaN(num)) return num;
385
+ }
386
+ return text;
387
+ }