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,532 @@
1
+ import { useState, useEffect, useRef, type ReactNode } from "react";
2
+ import {
3
+ RefreshCw,
4
+ ChevronRight,
5
+ Key,
6
+ Link2,
7
+ Fingerprint,
8
+ Hash,
9
+ Type,
10
+ HashIcon,
11
+ ToggleLeft,
12
+ Calendar,
13
+ Clock,
14
+ Binary,
15
+ List,
16
+ Braces,
17
+ MapPin,
18
+ Circle,
19
+ FileText,
20
+ Boxes,
21
+ HelpCircle,
22
+ } from "lucide-react";
23
+ import type {
24
+ DatabaseConfig,
25
+ SchemaMetadata,
26
+ TableMetadata,
27
+ ColumnInfo,
28
+ } from "../types";
29
+
30
+ interface SidebarProps {
31
+ schemas: SchemaMetadata[];
32
+ databaseConfig: DatabaseConfig | null;
33
+ onTableClick: (tableName: string) => void;
34
+ onTableOpenNewTab: (tableName: string) => void;
35
+ onRefresh: () => void;
36
+ isRefreshing?: boolean;
37
+ width?: number;
38
+ activeTableName?: string | null;
39
+ }
40
+
41
+ // Map data types to icons
42
+ function DataTypeIcon({ dataType }: { dataType: string }) {
43
+ const baseType = dataType.toLowerCase().split("(")[0].trim();
44
+ const iconClass = "w-3 h-3 text-tertiary flex-shrink-0";
45
+
46
+ // Text types
47
+ if (
48
+ [
49
+ "text",
50
+ "varchar",
51
+ "char",
52
+ "character",
53
+ "character varying",
54
+ "name",
55
+ "citext",
56
+ ].includes(baseType)
57
+ ) {
58
+ return <Type className={iconClass} strokeWidth={2} />;
59
+ }
60
+
61
+ // Numeric types
62
+ if (
63
+ [
64
+ "integer",
65
+ "int",
66
+ "int2",
67
+ "int4",
68
+ "int8",
69
+ "smallint",
70
+ "bigint",
71
+ "serial",
72
+ "bigserial",
73
+ "smallserial",
74
+ "numeric",
75
+ "decimal",
76
+ "real",
77
+ "float",
78
+ "float4",
79
+ "float8",
80
+ "double precision",
81
+ "money",
82
+ ].includes(baseType)
83
+ ) {
84
+ return <HashIcon className={iconClass} strokeWidth={2} />;
85
+ }
86
+
87
+ // Boolean
88
+ if (["boolean", "bool"].includes(baseType)) {
89
+ return <ToggleLeft className={iconClass} strokeWidth={2} />;
90
+ }
91
+
92
+ // Date types
93
+ if (["date"].includes(baseType)) {
94
+ return <Calendar className={iconClass} strokeWidth={2} />;
95
+ }
96
+
97
+ // Time/timestamp types
98
+ if (
99
+ [
100
+ "time",
101
+ "timetz",
102
+ "timestamp",
103
+ "timestamptz",
104
+ "timestamp without time zone",
105
+ "timestamp with time zone",
106
+ "interval",
107
+ ].includes(baseType)
108
+ ) {
109
+ return <Clock className={iconClass} strokeWidth={2} />;
110
+ }
111
+
112
+ // Binary types
113
+ if (["bytea", "bit", "bit varying", "varbit"].includes(baseType)) {
114
+ return <Binary className={iconClass} strokeWidth={2} />;
115
+ }
116
+
117
+ // Array types
118
+ if (baseType.endsWith("[]") || baseType === "array") {
119
+ return <List className={iconClass} strokeWidth={2} />;
120
+ }
121
+
122
+ // JSON types
123
+ if (["json", "jsonb"].includes(baseType)) {
124
+ return <Braces className={iconClass} strokeWidth={2} />;
125
+ }
126
+
127
+ // UUID
128
+ if (["uuid"].includes(baseType)) {
129
+ return <Fingerprint className={iconClass} strokeWidth={2} />;
130
+ }
131
+
132
+ // Geometric/spatial types
133
+ if (
134
+ [
135
+ "point",
136
+ "line",
137
+ "lseg",
138
+ "box",
139
+ "path",
140
+ "polygon",
141
+ "circle",
142
+ "geometry",
143
+ "geography",
144
+ ].includes(baseType)
145
+ ) {
146
+ return <MapPin className={iconClass} strokeWidth={2} />;
147
+ }
148
+
149
+ // Network types
150
+ if (["inet", "cidr", "macaddr", "macaddr8"].includes(baseType)) {
151
+ return <Circle className={iconClass} strokeWidth={2} />;
152
+ }
153
+
154
+ // Text search types
155
+ if (["tsvector", "tsquery"].includes(baseType)) {
156
+ return <FileText className={iconClass} strokeWidth={2} />;
157
+ }
158
+
159
+ // Range types
160
+ if (
161
+ baseType.endsWith("range") ||
162
+ [
163
+ "int4range",
164
+ "int8range",
165
+ "numrange",
166
+ "tsrange",
167
+ "tstzrange",
168
+ "daterange",
169
+ ].includes(baseType)
170
+ ) {
171
+ return <Boxes className={iconClass} strokeWidth={2} />;
172
+ }
173
+
174
+ // XML
175
+ if (["xml"].includes(baseType)) {
176
+ return <FileText className={iconClass} strokeWidth={2} />;
177
+ }
178
+
179
+ // OID and system types
180
+ if (
181
+ [
182
+ "oid",
183
+ "regclass",
184
+ "regtype",
185
+ "regproc",
186
+ "regoper",
187
+ "regconfig",
188
+ "regdictionary",
189
+ ].includes(baseType)
190
+ ) {
191
+ return <Circle className={iconClass} strokeWidth={2} />;
192
+ }
193
+
194
+ // Default/unknown
195
+ return <HelpCircle className={iconClass} strokeWidth={2} />;
196
+ }
197
+
198
+ // Constraint icons shown on the right
199
+ function ConstraintIcons({ column }: { column: ColumnInfo }) {
200
+ const { constraints } = column;
201
+ const icons: ReactNode[] = [];
202
+
203
+ if (constraints.isPrimaryKey) {
204
+ icons.push(
205
+ <span key="pk" title="Primary Key">
206
+ <Key className="w-3 h-3 text-amber-500" strokeWidth={2} />
207
+ </span>,
208
+ );
209
+ }
210
+ if (constraints.isForeignKey) {
211
+ const fkTitle = constraints.foreignKeyRef
212
+ ? `FK → ${constraints.foreignKeyRef.table}.${constraints.foreignKeyRef.column}`
213
+ : "Foreign Key";
214
+ icons.push(
215
+ <span key="fk" title={fkTitle}>
216
+ <Link2 className="w-3 h-3 text-blue-500" strokeWidth={2} />
217
+ </span>,
218
+ );
219
+ }
220
+ if (constraints.isUnique) {
221
+ icons.push(
222
+ <span key="uq" title="Unique">
223
+ <Fingerprint className="w-3 h-3 text-purple-500" strokeWidth={2} />
224
+ </span>,
225
+ );
226
+ }
227
+ if (constraints.isIndexed) {
228
+ icons.push(
229
+ <span key="idx" title="Indexed">
230
+ <Hash className="w-3 h-3 text-interactive-subtle" strokeWidth={2} />
231
+ </span>,
232
+ );
233
+ }
234
+
235
+ if (icons.length === 0) return null;
236
+
237
+ return (
238
+ <div className="flex items-center gap-0.5 flex-shrink-0 ml-1">{icons}</div>
239
+ );
240
+ }
241
+
242
+ function TableRow({
243
+ table,
244
+ schema,
245
+ isExpanded,
246
+ isActive,
247
+ onToggle,
248
+ onTableClick,
249
+ onTableOpenNewTab,
250
+ onContextMenu,
251
+ rowRef,
252
+ }: {
253
+ table: TableMetadata;
254
+ schema: SchemaMetadata;
255
+ isExpanded: boolean;
256
+ isActive: boolean;
257
+ onToggle: () => void;
258
+ onTableClick: (tableName: string) => void;
259
+ onTableOpenNewTab: (tableName: string) => void;
260
+ onContextMenu: (e: React.MouseEvent, tableName: string) => void;
261
+ rowRef?: React.RefObject<HTMLLIElement | null>;
262
+ }) {
263
+ const displayName = table.name;
264
+ const qualifiedName =
265
+ schema.name === "public" ? table.name : `${schema.name}.${table.name}`;
266
+
267
+ return (
268
+ <li ref={rowRef}>
269
+ <div
270
+ className={`flex items-center px-2 py-1.5 text-[13px] rounded-md cursor-pointer transition-all duration-150 ${
271
+ isActive
272
+ ? "bg-blue-100 dark:bg-blue-900/30 text-primary"
273
+ : "text-secondary hover:text-primary hover:bg-stone-200/70 dark:hover:bg-white/[0.06]"
274
+ }`}
275
+ onClick={(e) => {
276
+ if (e.metaKey || e.ctrlKey) {
277
+ onTableOpenNewTab(qualifiedName);
278
+ } else {
279
+ onTableClick(qualifiedName);
280
+ }
281
+ }}
282
+ onContextMenu={(e) => {
283
+ e.preventDefault();
284
+ onContextMenu(e, qualifiedName);
285
+ }}
286
+ >
287
+ <button
288
+ className="p-0.5 -ml-0.5 mr-1 rounded hover:bg-stone-300/50 dark:hover:bg-white/10 transition-colors"
289
+ onClick={(e) => {
290
+ e.stopPropagation();
291
+ onToggle();
292
+ }}
293
+ >
294
+ <ChevronRight
295
+ className={`w-3.5 h-3.5 text-tertiary transition-transform duration-150 ${
296
+ isExpanded ? "rotate-90" : ""
297
+ }`}
298
+ strokeWidth={2}
299
+ />
300
+ </button>
301
+ <span className="font-mono truncate">{displayName}</span>
302
+ </div>
303
+
304
+ {isExpanded && (
305
+ <ul className="ml-3 border-l border-stone-200 dark:border-white/[0.08] pl-1 py-1">
306
+ {table.columns.map((column) => (
307
+ <li
308
+ key={column.name}
309
+ className="flex items-center gap-1.5 px-2 py-1 text-[11px] text-tertiary font-mono min-w-0"
310
+ title={getColumnTooltip(column)}
311
+ >
312
+ <DataTypeIcon dataType={column.dataType} />
313
+ <span className="truncate flex-shrink min-w-0">
314
+ {column.name}
315
+ </span>
316
+ <span className="text-interactive-subtle text-[10px] truncate flex-shrink-[2] min-w-0">
317
+ {column.dataType}
318
+ </span>
319
+ <ConstraintIcons column={column} />
320
+ </li>
321
+ ))}
322
+ </ul>
323
+ )}
324
+ </li>
325
+ );
326
+ }
327
+
328
+ function getColumnTooltip(column: ColumnInfo): string {
329
+ const parts: string[] = [column.dataType];
330
+
331
+ if (column.constraints.isPrimaryKey) parts.push("Primary Key");
332
+ if (column.constraints.isForeignKey && column.constraints.foreignKeyRef) {
333
+ const ref = column.constraints.foreignKeyRef;
334
+ parts.push(`FK → ${ref.schema}.${ref.table}.${ref.column}`);
335
+ }
336
+ if (column.constraints.isUnique) parts.push("Unique");
337
+ if (column.constraints.isIndexed) parts.push("Indexed");
338
+ if (!column.isNullable) parts.push("NOT NULL");
339
+ if (column.defaultValue) parts.push(`Default: ${column.defaultValue}`);
340
+
341
+ return parts.join(" | ");
342
+ }
343
+
344
+ export function Sidebar({
345
+ schemas,
346
+ databaseConfig,
347
+ onTableClick,
348
+ onTableOpenNewTab,
349
+ onRefresh,
350
+ isRefreshing,
351
+ width,
352
+ activeTableName,
353
+ }: SidebarProps) {
354
+ const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
355
+ const [collapsedSchemas, setCollapsedSchemas] = useState<Set<string>>(
356
+ new Set(),
357
+ );
358
+ const activeRowRef = useRef<HTMLLIElement | null>(null);
359
+ const [contextMenu, setContextMenu] = useState<{
360
+ x: number;
361
+ y: number;
362
+ tableName: string;
363
+ } | null>(null);
364
+ const contextMenuRef = useRef<HTMLDivElement | null>(null);
365
+
366
+ // Dismiss context menu on click-outside, Escape, or scroll
367
+ useEffect(() => {
368
+ if (!contextMenu) return;
369
+ const handleClick = (e: MouseEvent) => {
370
+ if (
371
+ contextMenuRef.current &&
372
+ !contextMenuRef.current.contains(e.target as Node)
373
+ ) {
374
+ setContextMenu(null);
375
+ }
376
+ };
377
+ const handleKey = (e: KeyboardEvent) => {
378
+ if (e.key === "Escape") setContextMenu(null);
379
+ };
380
+ const handleScroll = () => setContextMenu(null);
381
+ document.addEventListener("mousedown", handleClick);
382
+ document.addEventListener("keydown", handleKey);
383
+ document.addEventListener("scroll", handleScroll, true);
384
+ return () => {
385
+ document.removeEventListener("mousedown", handleClick);
386
+ document.removeEventListener("keydown", handleKey);
387
+ document.removeEventListener("scroll", handleScroll, true);
388
+ };
389
+ }, [contextMenu]);
390
+
391
+ // Scroll to active table when it changes
392
+ useEffect(() => {
393
+ if (activeTableName && activeRowRef.current) {
394
+ activeRowRef.current.scrollIntoView({
395
+ block: "nearest",
396
+ });
397
+ }
398
+ }, [activeTableName]);
399
+
400
+ const toggleTable = (tableKey: string) => {
401
+ setExpandedTables((prev) => {
402
+ const next = new Set(prev);
403
+ if (next.has(tableKey)) {
404
+ next.delete(tableKey);
405
+ } else {
406
+ next.add(tableKey);
407
+ }
408
+ return next;
409
+ });
410
+ };
411
+
412
+ const toggleSchema = (schemaName: string) => {
413
+ setCollapsedSchemas((prev) => {
414
+ const next = new Set(prev);
415
+ if (next.has(schemaName)) {
416
+ next.delete(schemaName);
417
+ } else {
418
+ next.add(schemaName);
419
+ }
420
+ return next;
421
+ });
422
+ };
423
+
424
+ const showSchemaHeaders = schemas.length > 1;
425
+
426
+ return (
427
+ <div
428
+ className="bg-stone-100 dark:bg-[#0a0a0a] border-r border-stone-200 dark:border-white/[0.06] flex flex-col flex-shrink-0"
429
+ style={{ width: width ?? 208 }}
430
+ >
431
+ <div className="px-4 py-4 flex items-center justify-between">
432
+ <div className="flex items-center gap-2">
433
+ <span
434
+ className="w-2 h-2 rounded-full"
435
+ style={{ backgroundColor: databaseConfig?.display.color }}
436
+ />
437
+ <span className="text-[11px] font-semibold text-tertiary uppercase tracking-[0.08em]">
438
+ Schemas
439
+ </span>
440
+ </div>
441
+ <button
442
+ onClick={onRefresh}
443
+ disabled={isRefreshing}
444
+ className="p-1 rounded hover:bg-stone-200/70 dark:hover:bg-white/[0.06] text-interactive transition-colors disabled:opacity-50"
445
+ title="Refresh tables"
446
+ >
447
+ <RefreshCw
448
+ className={`w-3.5 h-3.5 ${isRefreshing ? "animate-[spin_2s_linear_infinite]" : ""}`}
449
+ strokeWidth={2}
450
+ />
451
+ </button>
452
+ </div>
453
+ <ul className="flex-1 overflow-y-auto px-2 pb-4 select-none">
454
+ {schemas.map((schema) => {
455
+ const isCollapsed = collapsedSchemas.has(schema.name);
456
+
457
+ const tableRows = schema.tables.map((table) => {
458
+ const tableKey = `${schema.name}.${table.name}`;
459
+ const qualifiedName =
460
+ schema.name === "public"
461
+ ? table.name
462
+ : `${schema.name}.${table.name}`;
463
+ const isActive = activeTableName === qualifiedName;
464
+ return (
465
+ <TableRow
466
+ key={tableKey}
467
+ table={table}
468
+ schema={schema}
469
+ isExpanded={expandedTables.has(tableKey)}
470
+ isActive={isActive}
471
+ onToggle={() => toggleTable(tableKey)}
472
+ onTableClick={onTableClick}
473
+ onTableOpenNewTab={onTableOpenNewTab}
474
+ onContextMenu={(e, name) =>
475
+ setContextMenu({
476
+ x: e.clientX,
477
+ y: e.clientY,
478
+ tableName: name,
479
+ })
480
+ }
481
+ rowRef={isActive ? activeRowRef : undefined}
482
+ />
483
+ );
484
+ });
485
+
486
+ if (!showSchemaHeaders) return tableRows;
487
+
488
+ return (
489
+ <li key={schema.name}>
490
+ <button
491
+ className="flex items-center gap-1.5 w-full px-1 py-1.5 mt-1 first:mt-0 cursor-pointer group"
492
+ onClick={() => toggleSchema(schema.name)}
493
+ >
494
+ <ChevronRight
495
+ className={`w-3 h-3 text-tertiary transition-transform duration-150 ${
496
+ !isCollapsed ? "rotate-90" : ""
497
+ }`}
498
+ strokeWidth={2}
499
+ />
500
+ <span className="text-[11px] font-semibold text-tertiary uppercase tracking-[0.08em]">
501
+ {schema.name}
502
+ </span>
503
+ <span className="text-[10px] text-interactive-subtle">
504
+ ({schema.tables.length})
505
+ </span>
506
+ </button>
507
+ {!isCollapsed && <ul>{tableRows}</ul>}
508
+ </li>
509
+ );
510
+ })}
511
+ </ul>
512
+
513
+ {contextMenu && (
514
+ <div
515
+ ref={contextMenuRef}
516
+ className="fixed z-50 min-w-[160px] py-1 bg-white dark:bg-[#1a1a1a] border border-stone-200 dark:border-white/[0.1] rounded-lg shadow-lg"
517
+ style={{ left: contextMenu.x, top: contextMenu.y }}
518
+ >
519
+ <button
520
+ className="w-full text-left px-3 py-1.5 text-[13px] text-secondary hover:text-primary hover:bg-stone-100 dark:hover:bg-white/[0.06] transition-colors"
521
+ onClick={() => {
522
+ onTableOpenNewTab(contextMenu.tableName);
523
+ setContextMenu(null);
524
+ }}
525
+ >
526
+ Open in new tab
527
+ </button>
528
+ </div>
529
+ )}
530
+ </div>
531
+ );
532
+ }