@hienlh/ppm 0.6.3 → 0.6.4

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 (57) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-CDVCDw_H.js} +3 -3
  4. package/dist/web/assets/{code-editor-witrClmz.js → code-editor-wmS73ejX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-BsYccTx1.js} +1 -1
  6. package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-BbWb6_Jq.js} +1 -1
  7. package/dist/web/assets/{index-CcXQ5iQw.js → index-DhuAmTQ1.js} +6 -6
  8. package/dist/web/assets/index-aIGuIMQ8.css +2 -0
  9. package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-aPdw9BhU.js} +1 -1
  11. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
  12. package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
  13. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  14. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
  15. package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-3tDV4RCn.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/docs/codebase-summary.md +41 -14
  20. package/docs/project-roadmap.md +31 -6
  21. package/docs/system-architecture.md +222 -7
  22. package/package.json +1 -1
  23. package/src/cli/commands/db-cmd.ts +21 -4
  24. package/src/server/index.ts +6 -0
  25. package/src/server/routes/database.ts +259 -0
  26. package/src/services/database/adapter-registry.ts +13 -0
  27. package/src/services/database/init-adapters.ts +9 -0
  28. package/src/services/database/postgres-adapter.ts +42 -0
  29. package/src/services/database/readonly-check.ts +17 -0
  30. package/src/services/database/sqlite-adapter.ts +55 -0
  31. package/src/services/db.service.ts +77 -4
  32. package/src/services/table-cache.service.ts +75 -0
  33. package/src/types/database.ts +50 -0
  34. package/src/web/app.tsx +9 -4
  35. package/src/web/components/database/connection-color-picker.tsx +67 -0
  36. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  37. package/src/web/components/database/connection-list.tsx +208 -0
  38. package/src/web/components/database/database-sidebar.tsx +100 -0
  39. package/src/web/components/database/use-connections.ts +99 -0
  40. package/src/web/components/layout/command-palette.tsx +57 -6
  41. package/src/web/components/layout/draggable-tab.tsx +13 -2
  42. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  43. package/src/web/components/layout/sidebar.tsx +6 -1
  44. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  45. package/src/web/components/postgres/use-postgres.ts +57 -21
  46. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  47. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  48. package/src/web/lib/api-client.ts +7 -1
  49. package/src/web/lib/color-utils.ts +23 -0
  50. package/src/web/stores/settings-store.ts +2 -2
  51. package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
  52. package/dist/web/assets/index-DyEgsogR.css +0 -2
  53. package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
  54. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
  55. package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
  56. package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
  57. package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
@@ -0,0 +1,67 @@
1
+ import { cn } from "@/lib/utils";
2
+
3
+ const COLOR_PRESETS = [
4
+ "#ef4444", "#f97316", "#eab308", "#22c55e",
5
+ "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
6
+ "#6b7280", "#000000",
7
+ ];
8
+
9
+ interface ConnectionColorPickerProps {
10
+ value: string | null;
11
+ onChange: (color: string | null) => void;
12
+ }
13
+
14
+ export function ConnectionColorPicker({ value, onChange }: ConnectionColorPickerProps) {
15
+ return (
16
+ <div className="space-y-2">
17
+ <div className="flex flex-wrap gap-1.5">
18
+ {/* No color option */}
19
+ <button
20
+ type="button"
21
+ onClick={() => onChange(null)}
22
+ className={cn(
23
+ "size-6 rounded-full border-2 transition-all",
24
+ !value ? "border-primary scale-110" : "border-border hover:scale-105",
25
+ "bg-transparent relative",
26
+ )}
27
+ title="No color"
28
+ >
29
+ <span className="absolute inset-0 flex items-center justify-center text-[8px] text-text-subtle">×</span>
30
+ </button>
31
+ {/* Preset colors */}
32
+ {COLOR_PRESETS.map((color) => (
33
+ <button
34
+ key={color}
35
+ type="button"
36
+ onClick={() => onChange(color)}
37
+ className={cn(
38
+ "size-6 rounded-full border-2 transition-all hover:scale-105",
39
+ value === color ? "border-primary scale-110" : "border-transparent",
40
+ )}
41
+ style={{ backgroundColor: color }}
42
+ title={color}
43
+ />
44
+ ))}
45
+ </div>
46
+ {/* Custom hex input */}
47
+ <div className="flex items-center gap-2">
48
+ <div
49
+ className="size-6 rounded-full border border-border shrink-0"
50
+ style={{ backgroundColor: value ?? "transparent" }}
51
+ />
52
+ <input
53
+ type="text"
54
+ value={value ?? ""}
55
+ onChange={(e) => {
56
+ const v = e.target.value.trim();
57
+ if (v === "") { onChange(null); return; }
58
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v);
59
+ else onChange(v); // allow partial typing
60
+ }}
61
+ placeholder="#3b82f6"
62
+ className="flex-1 h-7 text-xs px-2 rounded-md border border-border bg-background focus:outline-none focus:border-primary font-mono"
63
+ />
64
+ </div>
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,234 @@
1
+ import { useState, useEffect } from "react";
2
+ import {
3
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
4
+ } from "@/components/ui/dialog";
5
+ import { Button } from "@/components/ui/button";
6
+ import { ConnectionColorPicker } from "./connection-color-picker";
7
+ import type { Connection, CreateConnectionData, UpdateConnectionData } from "./use-connections";
8
+
9
+ interface ConnectionFormDialogProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ /** If provided, dialog is in edit mode */
13
+ connection?: Connection;
14
+ onSave?: (data: CreateConnectionData) => Promise<void>;
15
+ onUpdate?: (id: number, data: UpdateConnectionData) => Promise<void>;
16
+ onTest: (id: number) => Promise<{ ok: boolean; error?: string }>;
17
+ }
18
+
19
+ interface FormState {
20
+ name: string;
21
+ type: "sqlite" | "postgres";
22
+ path: string;
23
+ connectionString: string;
24
+ groupName: string;
25
+ color: string | null;
26
+ readonly: boolean;
27
+ }
28
+
29
+ export function ConnectionFormDialog({
30
+ open, onClose, connection, onSave, onUpdate, onTest,
31
+ }: ConnectionFormDialogProps) {
32
+ const isEdit = !!connection;
33
+ const [form, setForm] = useState<FormState>({
34
+ name: "", type: "postgres", path: "", connectionString: "", groupName: "", color: null, readonly: true,
35
+ });
36
+ const [testing, setTesting] = useState(false);
37
+ const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
38
+ const [saving, setSaving] = useState(false);
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ useEffect(() => {
42
+ if (!open) { setTestResult(null); setError(null); return; }
43
+ if (connection) {
44
+ // connection_config is not exposed by the API — path/connectionString start empty in edit mode
45
+ setForm({
46
+ name: connection.name,
47
+ type: connection.type,
48
+ path: "",
49
+ connectionString: "",
50
+ groupName: connection.group_name ?? "",
51
+ color: connection.color,
52
+ readonly: connection.readonly === 1,
53
+ });
54
+ } else {
55
+ setForm({ name: "", type: "postgres", path: "", connectionString: "", groupName: "", color: null, readonly: true });
56
+ }
57
+ }, [open, connection]);
58
+
59
+ const set = (key: keyof FormState, value: unknown) => {
60
+ setForm((f) => ({ ...f, [key]: value }));
61
+ setTestResult(null);
62
+ };
63
+
64
+ const handleTest = async () => {
65
+ if (!isEdit) return;
66
+ setTesting(true);
67
+ setTestResult(null);
68
+ try {
69
+ const result = await onTest(connection!.id);
70
+ setTestResult(result);
71
+ } finally {
72
+ setTesting(false);
73
+ }
74
+ };
75
+
76
+ const handleSave = async () => {
77
+ setError(null);
78
+ if (!form.name.trim()) { setError("Name is required"); return; }
79
+
80
+ setSaving(true);
81
+ try {
82
+ if (isEdit && onUpdate) {
83
+ // Only send connectionConfig if user entered a new value (API doesn't return existing config)
84
+ const hasNewConfig = form.type === "postgres" ? !!form.connectionString.trim() : !!form.path.trim();
85
+ const config = hasNewConfig
86
+ ? (form.type === "postgres"
87
+ ? { type: "postgres" as const, connectionString: form.connectionString }
88
+ : { type: "sqlite" as const, path: form.path })
89
+ : undefined;
90
+ await onUpdate(connection!.id, {
91
+ name: form.name.trim(),
92
+ ...(config !== undefined && { connectionConfig: config }),
93
+ groupName: form.groupName.trim() || null,
94
+ color: form.color,
95
+ readonly: form.readonly ? 1 : 0,
96
+ });
97
+ } else if (onSave) {
98
+ const config = form.type === "postgres"
99
+ ? { type: "postgres" as const, connectionString: form.connectionString }
100
+ : { type: "sqlite" as const, path: form.path };
101
+ await onSave({
102
+ type: form.type,
103
+ name: form.name.trim(),
104
+ connectionConfig: config,
105
+ groupName: form.groupName.trim() || undefined,
106
+ color: form.color ?? undefined,
107
+ });
108
+ }
109
+ onClose();
110
+ } catch (e) {
111
+ setError((e as Error).message);
112
+ } finally {
113
+ setSaving(false);
114
+ }
115
+ };
116
+
117
+ return (
118
+ <Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
119
+ <DialogContent className="max-w-md">
120
+ <DialogHeader>
121
+ <DialogTitle>{isEdit ? "Edit Connection" : "Add Connection"}</DialogTitle>
122
+ </DialogHeader>
123
+
124
+ <div className="space-y-3 py-1">
125
+ {/* Name */}
126
+ <div>
127
+ <label className="text-xs font-medium text-text-secondary mb-1 block">Name *</label>
128
+ <input
129
+ value={form.name}
130
+ onChange={(e) => set("name", e.target.value)}
131
+ placeholder="my-database"
132
+ className="w-full h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary"
133
+ />
134
+ </div>
135
+
136
+ {/* Type */}
137
+ <div>
138
+ <label className="text-xs font-medium text-text-secondary mb-1 block">Type</label>
139
+ <select
140
+ value={form.type}
141
+ onChange={(e) => set("type", e.target.value as "sqlite" | "postgres")}
142
+ className="w-full h-8 text-sm px-2 rounded-md border border-border bg-background focus:outline-none focus:border-primary"
143
+ >
144
+ <option value="postgres">PostgreSQL</option>
145
+ <option value="sqlite">SQLite</option>
146
+ </select>
147
+ </div>
148
+
149
+ {/* Connection config */}
150
+ {form.type === "postgres" ? (
151
+ <div>
152
+ <label className="text-xs font-medium text-text-secondary mb-1 block">Connection String *</label>
153
+ <input
154
+ type="password"
155
+ value={form.connectionString}
156
+ onChange={(e) => set("connectionString", e.target.value)}
157
+ placeholder="postgresql://user:pass@host:5432/db"
158
+ className="w-full h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary font-mono"
159
+ />
160
+ </div>
161
+ ) : (
162
+ <div>
163
+ <label className="text-xs font-medium text-text-secondary mb-1 block">File Path *</label>
164
+ <input
165
+ value={form.path}
166
+ onChange={(e) => set("path", e.target.value)}
167
+ placeholder="/path/to/database.db"
168
+ className="w-full h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary font-mono"
169
+ />
170
+ </div>
171
+ )}
172
+
173
+ {/* Group */}
174
+ <div>
175
+ <label className="text-xs font-medium text-text-secondary mb-1 block">Group</label>
176
+ <input
177
+ value={form.groupName}
178
+ onChange={(e) => set("groupName", e.target.value)}
179
+ placeholder="Production"
180
+ className="w-full h-8 text-sm px-2.5 rounded-md border border-border bg-background focus:outline-none focus:border-primary"
181
+ />
182
+ </div>
183
+
184
+ {/* Color */}
185
+ <div>
186
+ <label className="text-xs font-medium text-text-secondary mb-1 block">Tab Color</label>
187
+ <ConnectionColorPicker value={form.color} onChange={(c) => set("color", c)} />
188
+ </div>
189
+
190
+ {/* Readonly toggle (edit only) */}
191
+ {isEdit && (
192
+ <div className="flex items-center justify-between py-1">
193
+ <div>
194
+ <p className="text-xs font-medium">Readonly</p>
195
+ <p className="text-[10px] text-text-subtle">Block non-SELECT queries (AI protection)</p>
196
+ </div>
197
+ <button
198
+ type="button"
199
+ onClick={() => set("readonly", !form.readonly)}
200
+ className={`relative w-9 h-5 rounded-full transition-colors ${form.readonly ? "bg-primary" : "bg-border"}`}
201
+ >
202
+ <span
203
+ className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-white transition-transform ${form.readonly ? "translate-x-4" : ""}`}
204
+ />
205
+ </button>
206
+ </div>
207
+ )}
208
+
209
+ {/* Test result */}
210
+ {testResult && (
211
+ <p className={`text-xs ${testResult.ok ? "text-green-500" : "text-red-500"}`}>
212
+ {testResult.ok ? "✓ Connection successful" : `✗ ${testResult.error}`}
213
+ </p>
214
+ )}
215
+
216
+ {/* Error */}
217
+ {error && <p className="text-xs text-red-500">{error}</p>}
218
+ </div>
219
+
220
+ <DialogFooter>
221
+ {isEdit && (
222
+ <Button variant="outline" size="sm" onClick={handleTest} disabled={testing} className="mr-auto">
223
+ {testing ? "Testing…" : "Test Connection"}
224
+ </Button>
225
+ )}
226
+ <Button variant="outline" size="sm" onClick={onClose}>Cancel</Button>
227
+ <Button size="sm" onClick={handleSave} disabled={saving}>
228
+ {saving ? "Saving…" : isEdit ? "Save" : "Add"}
229
+ </Button>
230
+ </DialogFooter>
231
+ </DialogContent>
232
+ </Dialog>
233
+ );
234
+ }
@@ -0,0 +1,208 @@
1
+ import { useState } from "react";
2
+ import { ChevronRight, ChevronDown, Database, RefreshCw, Pencil, Trash2, Lock } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+ import type { Connection, CachedTable } from "./use-connections";
5
+
6
+ interface ConnectionListProps {
7
+ connections: Connection[];
8
+ cachedTables: Map<number, CachedTable[]>;
9
+ onOpenConnection: (conn: Connection) => void;
10
+ onOpenTable: (conn: Connection, tableName: string, schemaName: string) => void;
11
+ onRefreshTables: (id: number) => Promise<void>;
12
+ onEdit: (conn: Connection) => void;
13
+ onDelete: (id: number) => void;
14
+ }
15
+
16
+ interface GroupMap {
17
+ [group: string]: Connection[];
18
+ }
19
+
20
+ const MAX_VISIBLE_TABLES = 10;
21
+
22
+ export function ConnectionList({
23
+ connections, cachedTables,
24
+ onOpenConnection, onOpenTable, onRefreshTables, onEdit, onDelete,
25
+ }: ConnectionListProps) {
26
+ const [expandedConns, setExpandedConns] = useState<Set<number>>(new Set());
27
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["__ungrouped__"]));
28
+ const [refreshingIds, setRefreshingIds] = useState<Set<number>>(new Set());
29
+ const [showAllTables, setShowAllTables] = useState<Set<number>>(new Set());
30
+
31
+ const toggleConn = (id: number) => {
32
+ setExpandedConns((prev) => {
33
+ const next = new Set(prev);
34
+ if (next.has(id)) next.delete(id); else next.add(id);
35
+ return next;
36
+ });
37
+ };
38
+
39
+ const toggleGroup = (group: string) => {
40
+ setExpandedGroups((prev) => {
41
+ const next = new Set(prev);
42
+ if (next.has(group)) next.delete(group); else next.add(group);
43
+ return next;
44
+ });
45
+ };
46
+
47
+ const handleRefresh = async (id: number) => {
48
+ setRefreshingIds((p) => new Set(p).add(id));
49
+ try { await onRefreshTables(id); } finally {
50
+ setRefreshingIds((p) => { const n = new Set(p); n.delete(id); return n; });
51
+ }
52
+ };
53
+
54
+ // Group connections
55
+ const groups: GroupMap = {};
56
+ for (const conn of connections) {
57
+ const key = conn.group_name ?? "__ungrouped__";
58
+ (groups[key] ??= []).push(conn);
59
+ }
60
+ const groupKeys = Object.keys(groups).sort((a, b) => {
61
+ if (a === "__ungrouped__") return 1;
62
+ if (b === "__ungrouped__") return -1;
63
+ return a.localeCompare(b);
64
+ });
65
+
66
+ if (connections.length === 0) {
67
+ return (
68
+ <p className="px-4 py-6 text-xs text-text-subtle text-center">
69
+ No connections yet.<br />Click + to add one.
70
+ </p>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className="py-1">
76
+ {groupKeys.map((group) => {
77
+ const isGroupExpanded = expandedGroups.has(group);
78
+ const label = group === "__ungrouped__" ? "Ungrouped" : group;
79
+ const groupConns = groups[group]!;
80
+
81
+ return (
82
+ <div key={group}>
83
+ {/* Group header (only shown when there are multiple groups or named group) */}
84
+ {(groupKeys.length > 1 || group !== "__ungrouped__") && (
85
+ <button
86
+ onClick={() => toggleGroup(group)}
87
+ className="w-full flex items-center gap-1 px-2 py-1 text-[10px] font-semibold text-text-subtle uppercase tracking-wider hover:text-text-secondary transition-colors"
88
+ >
89
+ {isGroupExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
90
+ {label}
91
+ </button>
92
+ )}
93
+
94
+ {isGroupExpanded && groupConns.map((conn) => {
95
+ const isExpanded = expandedConns.has(conn.id);
96
+ const tables = cachedTables.get(conn.id) ?? [];
97
+ const isRefreshing = refreshingIds.has(conn.id);
98
+ const showAll = showAllTables.has(conn.id);
99
+ const visibleTables = showAll ? tables : tables.slice(0, MAX_VISIBLE_TABLES);
100
+
101
+ return (
102
+ <div key={conn.id}>
103
+ {/* Connection row */}
104
+ <div className="group flex items-center gap-1 px-2 py-1 hover:bg-surface-elevated transition-colors">
105
+ {/* Expand arrow */}
106
+ <button
107
+ onClick={() => {
108
+ toggleConn(conn.id);
109
+ if (!expandedConns.has(conn.id) && tables.length === 0) {
110
+ handleRefresh(conn.id);
111
+ }
112
+ }}
113
+ className="shrink-0 text-text-subtle hover:text-foreground transition-colors"
114
+ >
115
+ {isExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
116
+ </button>
117
+
118
+ {/* Color dot */}
119
+ <span
120
+ className="shrink-0 size-2 rounded-full border border-border"
121
+ style={{ backgroundColor: conn.color ?? "transparent" }}
122
+ />
123
+
124
+ {/* Name — click opens connection viewer */}
125
+ <button
126
+ className="flex-1 text-left text-xs truncate hover:text-primary transition-colors"
127
+ onClick={() => onOpenConnection(conn)}
128
+ >
129
+ {conn.name}
130
+ </button>
131
+
132
+ {/* DB type badge */}
133
+ <span className="shrink-0 text-[9px] text-text-subtle uppercase px-1 rounded bg-surface-elevated">
134
+ {conn.type === "postgres" ? "PG" : "DB"}
135
+ </span>
136
+
137
+ {/* Readonly lock */}
138
+ {conn.readonly === 1 && (
139
+ <span title="Readonly">
140
+ <Lock className="shrink-0 size-2.5 text-text-subtle" aria-label="Readonly" />
141
+ </span>
142
+ )}
143
+
144
+ {/* Actions (hover) */}
145
+ <div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
146
+ <button
147
+ onClick={() => handleRefresh(conn.id)}
148
+ disabled={isRefreshing}
149
+ className="p-0.5 text-text-subtle hover:text-foreground transition-colors"
150
+ title="Refresh tables"
151
+ >
152
+ <RefreshCw className={cn("size-3", isRefreshing && "animate-spin")} />
153
+ </button>
154
+ <button
155
+ onClick={() => onEdit(conn)}
156
+ className="p-0.5 text-text-subtle hover:text-foreground transition-colors"
157
+ title="Edit"
158
+ >
159
+ <Pencil className="size-3" />
160
+ </button>
161
+ <button
162
+ onClick={() => onDelete(conn.id)}
163
+ className="p-0.5 text-text-subtle hover:text-red-500 transition-colors"
164
+ title="Delete"
165
+ >
166
+ <Trash2 className="size-3" />
167
+ </button>
168
+ </div>
169
+ </div>
170
+
171
+ {/* Table list (expanded) */}
172
+ {isExpanded && (
173
+ <div className="pl-6">
174
+ {isRefreshing && tables.length === 0 && (
175
+ <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>
176
+ )}
177
+ {!isRefreshing && tables.length === 0 && (
178
+ <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
179
+ )}
180
+ {visibleTables.map((t) => (
181
+ <button
182
+ key={`${t.schemaName}.${t.tableName}`}
183
+ onClick={() => onOpenTable(conn, t.tableName, t.schemaName)}
184
+ className="w-full flex items-center gap-1.5 px-2 py-0.5 text-[11px] text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors text-left truncate"
185
+ >
186
+ <Database className="size-2.5 shrink-0 text-text-subtle" />
187
+ <span className="truncate">{t.tableName}</span>
188
+ </button>
189
+ ))}
190
+ {tables.length > MAX_VISIBLE_TABLES && !showAll && (
191
+ <button
192
+ onClick={() => setShowAllTables((p) => new Set(p).add(conn.id))}
193
+ className="w-full text-left px-2 py-0.5 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
194
+ >
195
+ +{tables.length - MAX_VISIBLE_TABLES} more…
196
+ </button>
197
+ )}
198
+ </div>
199
+ )}
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+ );
205
+ })}
206
+ </div>
207
+ );
208
+ }
@@ -0,0 +1,100 @@
1
+ import { useState } from "react";
2
+ import { Plus } from "lucide-react";
3
+ import { useTabStore } from "@/stores/tab-store";
4
+ import { ConnectionList } from "./connection-list";
5
+ import { ConnectionFormDialog } from "./connection-form-dialog";
6
+ import { useConnections, type Connection, type CreateConnectionData, type UpdateConnectionData } from "./use-connections";
7
+
8
+ export function DatabaseSidebar() {
9
+ const { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables } = useConnections();
10
+ const openTab = useTabStore((s) => s.openTab);
11
+ const [addOpen, setAddOpen] = useState(false);
12
+ const [editConn, setEditConn] = useState<Connection | null>(null);
13
+
14
+ const handleOpenConnection = (conn: Connection) => {
15
+ openTab({
16
+ type: conn.type === "postgres" ? "postgres" : "sqlite",
17
+ title: conn.name,
18
+ projectId: null,
19
+ closable: true,
20
+ metadata: { connectionId: conn.id, connectionColor: conn.color },
21
+ });
22
+ };
23
+
24
+ const handleOpenTable = (conn: Connection, tableName: string, schemaName: string) => {
25
+ openTab({
26
+ type: conn.type === "postgres" ? "postgres" : "sqlite",
27
+ title: `${conn.name} · ${tableName}`,
28
+ projectId: null,
29
+ closable: true,
30
+ metadata: { connectionId: conn.id, tableName, schemaName, connectionColor: conn.color },
31
+ });
32
+ };
33
+
34
+ const handleDelete = async (id: number) => {
35
+ if (!confirm("Delete this connection?")) return;
36
+ await deleteConnection(id);
37
+ };
38
+
39
+ const handleCreate = async (data: CreateConnectionData) => {
40
+ const created = await createConnection(data);
41
+ // Auto-refresh tables after creating (use return value to avoid stale closure)
42
+ if (created) refreshTables(created.id).catch(() => {});
43
+ };
44
+
45
+ const handleUpdate = async (id: number, data: UpdateConnectionData) => {
46
+ await updateConnection(id, data);
47
+ };
48
+
49
+ return (
50
+ <div className="flex flex-col h-full">
51
+ {/* Header */}
52
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
53
+ <span className="text-[10px] font-semibold text-text-subtle uppercase tracking-wider">Database</span>
54
+ <button
55
+ onClick={() => setAddOpen(true)}
56
+ className="flex items-center justify-center size-5 rounded hover:bg-surface-elevated transition-colors text-text-subtle hover:text-foreground"
57
+ title="Add connection"
58
+ >
59
+ <Plus className="size-3.5" />
60
+ </button>
61
+ </div>
62
+
63
+ {/* Connection list */}
64
+ <div className="flex-1 overflow-y-auto min-h-0">
65
+ {loading ? (
66
+ <p className="px-4 py-6 text-xs text-text-subtle text-center">Loading…</p>
67
+ ) : (
68
+ <ConnectionList
69
+ connections={connections}
70
+ cachedTables={cachedTables}
71
+ onOpenConnection={handleOpenConnection}
72
+ onOpenTable={handleOpenTable}
73
+ onRefreshTables={refreshTables}
74
+ onEdit={setEditConn}
75
+ onDelete={handleDelete}
76
+ />
77
+ )}
78
+ </div>
79
+
80
+ {/* Add dialog */}
81
+ <ConnectionFormDialog
82
+ open={addOpen}
83
+ onClose={() => setAddOpen(false)}
84
+ onSave={handleCreate}
85
+ onTest={() => Promise.resolve({ ok: false, error: "Save connection first" })}
86
+ />
87
+
88
+ {/* Edit dialog */}
89
+ {editConn && (
90
+ <ConnectionFormDialog
91
+ open={!!editConn}
92
+ onClose={() => setEditConn(null)}
93
+ connection={editConn}
94
+ onUpdate={handleUpdate}
95
+ onTest={(id) => testConnection(id)}
96
+ />
97
+ )}
98
+ </div>
99
+ );
100
+ }