@hienlh/ppm 0.6.3 → 0.6.5

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 (78) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/web/assets/api-client-4Ni0i4Hl.js +1 -0
  3. package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-DkgRZpbj.js} +3 -3
  4. package/dist/web/assets/{code-editor-witrClmz.js → code-editor-CVMeIylx.js} +1 -1
  5. package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
  6. package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-B1vnegRS.js} +1 -1
  7. package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
  8. package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-Bi4PM-z2.js} +1 -1
  9. package/dist/web/assets/index-DSg2VjxL.css +2 -0
  10. package/dist/web/assets/{index-CcXQ5iQw.js → index-DUb5kwfL.js} +6 -6
  11. package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
  12. package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-ChvoCZNm.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
  15. package/dist/web/assets/settings-store-CfB0vCtQ.js +1 -0
  16. package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
  17. package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
  18. package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
  19. package/dist/web/assets/table-DCVKGOr2.js +1 -0
  20. package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-B_75oJaQ.js} +1 -1
  21. package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Dexl3s3E.js} +1 -1
  22. package/dist/web/index.html +8 -8
  23. package/dist/web/sw.js +1 -1
  24. package/docs/codebase-summary.md +41 -14
  25. package/docs/project-roadmap.md +31 -6
  26. package/docs/system-architecture.md +222 -7
  27. package/package.json +1 -1
  28. package/src/cli/commands/db-cmd.ts +21 -4
  29. package/src/server/index.ts +6 -0
  30. package/src/server/routes/chat.ts +2 -2
  31. package/src/server/routes/database.ts +261 -0
  32. package/src/services/database/adapter-registry.ts +13 -0
  33. package/src/services/database/init-adapters.ts +9 -0
  34. package/src/services/database/postgres-adapter.ts +42 -0
  35. package/src/services/database/readonly-check.ts +17 -0
  36. package/src/services/database/sqlite-adapter.ts +55 -0
  37. package/src/services/db.service.ts +77 -4
  38. package/src/services/table-cache.service.ts +75 -0
  39. package/src/types/config.ts +10 -2
  40. package/src/types/database.ts +50 -0
  41. package/src/web/app.tsx +9 -4
  42. package/src/web/components/chat/tool-cards.tsx +2 -2
  43. package/src/web/components/database/connection-color-picker.tsx +67 -0
  44. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  45. package/src/web/components/database/connection-list.tsx +257 -0
  46. package/src/web/components/database/database-sidebar.tsx +89 -0
  47. package/src/web/components/database/database-viewer.tsx +228 -0
  48. package/src/web/components/database/use-connections.ts +92 -0
  49. package/src/web/components/database/use-database.ts +117 -0
  50. package/src/web/components/layout/command-palette.tsx +56 -6
  51. package/src/web/components/layout/draggable-tab.tsx +13 -2
  52. package/src/web/components/layout/editor-panel.tsx +1 -0
  53. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  54. package/src/web/components/layout/mobile-nav.tsx +1 -1
  55. package/src/web/components/layout/sidebar.tsx +7 -3
  56. package/src/web/components/layout/tab-bar.tsx +1 -0
  57. package/src/web/components/layout/tab-content.tsx +5 -0
  58. package/src/web/components/postgres/postgres-viewer.tsx +42 -25
  59. package/src/web/components/postgres/use-postgres.ts +54 -21
  60. package/src/web/components/settings/ai-settings-section.tsx +0 -1
  61. package/src/web/components/sqlite/sqlite-viewer.tsx +43 -13
  62. package/src/web/components/sqlite/use-sqlite.ts +24 -15
  63. package/src/web/hooks/use-chat.ts +1 -1
  64. package/src/web/hooks/use-usage.ts +1 -1
  65. package/src/web/lib/api-client.ts +7 -1
  66. package/src/web/lib/color-utils.ts +23 -0
  67. package/src/web/stores/settings-store.ts +2 -2
  68. package/src/web/stores/tab-store.ts +1 -0
  69. package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
  70. package/dist/web/assets/dist-PpKqMvyx.js +0 -16
  71. package/dist/web/assets/index-DyEgsogR.css +0 -2
  72. package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
  73. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
  74. package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
  75. package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
  76. package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
  77. /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
  78. /package/dist/web/assets/{utils-CAPYyGV3.js → utils-siJJ3uG0.js} +0 -0
package/src/web/app.tsx CHANGED
@@ -58,12 +58,9 @@ export function App() {
58
58
  }
59
59
  }, [theme]);
60
60
 
61
- // Fetch server info + keybindings on mount (before auth — shown on login screen)
61
+ // Fetch server info on mount (before auth — shown on login screen)
62
62
  useEffect(() => {
63
63
  fetchServerInfo();
64
- import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
65
- useKeybindingsStore.getState().loadFromServer();
66
- });
67
64
  }, [fetchServerInfo]);
68
65
 
69
66
  // Auth check on mount
@@ -102,6 +99,14 @@ export function App() {
102
99
  // Health check — detects server crash/restart
103
100
  useHealthCheck();
104
101
 
102
+ // Load keybindings after auth confirmed (must not call ApiClient before auth)
103
+ useEffect(() => {
104
+ if (authState !== "authenticated") return;
105
+ import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
106
+ useKeybindingsStore.getState().loadFromServer();
107
+ });
108
+ }, [authState]);
109
+
105
110
  // Fetch projects after auth, then restore from URL if applicable
106
111
  useEffect(() => {
107
112
  if (authState !== "authenticated") return;
@@ -134,12 +134,12 @@ function ToolSummary({ name, input }: { name: string; input: Record<string, unkn
134
134
  case "Task":
135
135
  return <><Bot className="size-3 inline" /> {name} <span className="text-text-subtle">{truncate(s(input.description || input.prompt), 60)}</span></>;
136
136
  case "TodoWrite": {
137
- const todos = (input.todos as Array<{ content: string; status: string }>) ?? [];
137
+ const todos = Array.isArray(input.todos) ? input.todos as Array<{ content: string; status: string }> : [];
138
138
  const done = todos.filter((t) => t.status === "completed").length;
139
139
  return <><ListTodo className="size-3 inline" /> {name} <span className="text-text-subtle">{done}/{todos.length} done</span></>;
140
140
  }
141
141
  case "AskUserQuestion": {
142
- const qs = (input.questions as Array<{ question: string }>) ?? [];
142
+ const qs = Array.isArray(input.questions) ? input.questions as Array<{ question: string }> : [];
143
143
  const hasAns = !!(input.answers);
144
144
  return <>{name} <span className="text-text-subtle">{qs.length} question{qs.length !== 1 ? "s" : ""}{hasAns ? " ✓" : ""}</span></>;
145
145
  }
@@ -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,257 @@
1
+ import { useState, useMemo } from "react";
2
+ import { ChevronRight, ChevronDown, Database, RefreshCw, Pencil, Trash2, Lock, Search } 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
+ onOpenTable: (conn: Connection, tableName: string, schemaName: string) => void;
10
+ onRefreshTables: (id: number) => Promise<void>;
11
+ onEdit: (conn: Connection) => void;
12
+ onDelete: (id: number) => void;
13
+ }
14
+
15
+ interface GroupMap {
16
+ [group: string]: Connection[];
17
+ }
18
+
19
+ export function ConnectionList({
20
+ connections, cachedTables,
21
+ onOpenTable, onRefreshTables, onEdit, onDelete,
22
+ }: ConnectionListProps) {
23
+ const [expandedConns, setExpandedConns] = useState<Set<number>>(new Set());
24
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["__ungrouped__"]));
25
+ const [refreshingIds, setRefreshingIds] = useState<Set<number>>(new Set());
26
+ const [tableFilter, setTableFilter] = useState<Map<number, string>>(new Map());
27
+
28
+ const toggleConn = (id: number) => {
29
+ setExpandedConns((prev) => {
30
+ const next = new Set(prev);
31
+ if (next.has(id)) next.delete(id); else next.add(id);
32
+ return next;
33
+ });
34
+ };
35
+
36
+ const toggleGroup = (group: string) => {
37
+ setExpandedGroups((prev) => {
38
+ const next = new Set(prev);
39
+ if (next.has(group)) next.delete(group); else next.add(group);
40
+ return next;
41
+ });
42
+ };
43
+
44
+ const handleRefresh = async (id: number) => {
45
+ setRefreshingIds((p) => new Set(p).add(id));
46
+ try { await onRefreshTables(id); } finally {
47
+ setRefreshingIds((p) => { const n = new Set(p); n.delete(id); return n; });
48
+ }
49
+ };
50
+
51
+ // Group connections
52
+ const groups: GroupMap = {};
53
+ for (const conn of connections) {
54
+ const key = conn.group_name ?? "__ungrouped__";
55
+ (groups[key] ??= []).push(conn);
56
+ }
57
+ const groupKeys = Object.keys(groups).sort((a, b) => {
58
+ if (a === "__ungrouped__") return 1;
59
+ if (b === "__ungrouped__") return -1;
60
+ return a.localeCompare(b);
61
+ });
62
+
63
+ if (connections.length === 0) {
64
+ return (
65
+ <p className="px-4 py-6 text-xs text-text-subtle text-center">
66
+ No connections yet.<br />Click + to add one.
67
+ </p>
68
+ );
69
+ }
70
+
71
+ return (
72
+ <div className="py-1">
73
+ {groupKeys.map((group) => {
74
+ const isGroupExpanded = expandedGroups.has(group);
75
+ const label = group === "__ungrouped__" ? "Ungrouped" : group;
76
+ const groupConns = groups[group]!;
77
+
78
+ const hasGroup = groupKeys.length > 1 || group !== "__ungrouped__";
79
+
80
+ return (
81
+ <div key={group}>
82
+ {/* Group header (only shown when there are multiple groups or named group) */}
83
+ {hasGroup && (
84
+ <button
85
+ onClick={() => toggleGroup(group)}
86
+ 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"
87
+ >
88
+ {isGroupExpanded ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
89
+ {label}
90
+ </button>
91
+ )}
92
+
93
+ {/* Connections — indented with tree guide line when inside a group */}
94
+ {isGroupExpanded && (
95
+ <div className={hasGroup ? "ml-[11px] border-l border-dashed border-border" : ""}>
96
+ {groupConns.map((conn) => {
97
+ const isExpanded = expandedConns.has(conn.id);
98
+ const tables = cachedTables.get(conn.id) ?? [];
99
+ const isRefreshing = refreshingIds.has(conn.id);
100
+
101
+ return (
102
+ <div key={conn.id}>
103
+ {/* Connection row */}
104
+ <div className={cn("group flex items-center gap-1 py-1 hover:bg-surface-elevated transition-colors", hasGroup ? "pl-3 pr-2" : "px-2")}>
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 toggles expand */}
125
+ <button
126
+ className="flex-1 text-left text-xs truncate hover:text-primary transition-colors"
127
+ onClick={() => {
128
+ toggleConn(conn.id);
129
+ if (!expandedConns.has(conn.id) && tables.length === 0) {
130
+ handleRefresh(conn.id);
131
+ }
132
+ }}
133
+ >
134
+ {conn.name}
135
+ </button>
136
+
137
+ {/* DB type badge */}
138
+ <span className="shrink-0 text-[9px] text-text-subtle uppercase px-1 rounded bg-surface-elevated">
139
+ {conn.type === "postgres" ? "PG" : "DB"}
140
+ </span>
141
+
142
+ {/* Readonly lock */}
143
+ {conn.readonly === 1 && (
144
+ <span title="Readonly">
145
+ <Lock className="shrink-0 size-2.5 text-text-subtle" aria-label="Readonly" />
146
+ </span>
147
+ )}
148
+
149
+ {/* Actions (hover) */}
150
+ <div className="hidden group-hover:flex items-center gap-0.5 shrink-0">
151
+ <button
152
+ onClick={() => handleRefresh(conn.id)}
153
+ disabled={isRefreshing}
154
+ className="p-0.5 text-text-subtle hover:text-foreground transition-colors"
155
+ title="Refresh tables"
156
+ >
157
+ <RefreshCw className={cn("size-3", isRefreshing && "animate-spin")} />
158
+ </button>
159
+ <button
160
+ onClick={() => onEdit(conn)}
161
+ className="p-0.5 text-text-subtle hover:text-foreground transition-colors"
162
+ title="Edit"
163
+ >
164
+ <Pencil className="size-3" />
165
+ </button>
166
+ <button
167
+ onClick={() => onDelete(conn.id)}
168
+ className="p-0.5 text-text-subtle hover:text-red-500 transition-colors"
169
+ title="Delete"
170
+ >
171
+ <Trash2 className="size-3" />
172
+ </button>
173
+ </div>
174
+ </div>
175
+
176
+ {/* Table list (expanded) with tree guide line */}
177
+ {isExpanded && (
178
+ <div className="ml-[11px] border-l border-dashed border-border pl-3">
179
+ {isRefreshing && tables.length === 0 && (
180
+ <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>
181
+ )}
182
+ {!isRefreshing && tables.length === 0 && (
183
+ <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
184
+ )}
185
+ {tables.length > 0 && (
186
+ <TableListWithFilter
187
+ connId={conn.id}
188
+ tables={tables}
189
+ filter={tableFilter.get(conn.id) ?? ""}
190
+ onFilterChange={(v) => setTableFilter((prev) => new Map(prev).set(conn.id, v))}
191
+ onOpenTable={(tableName, schemaName) => onOpenTable(conn, tableName, schemaName)}
192
+ />
193
+ )}
194
+ </div>
195
+ )}
196
+ </div>
197
+ );
198
+ })}
199
+ </div>
200
+ )}
201
+ </div>
202
+ );
203
+ })}
204
+ </div>
205
+ );
206
+ }
207
+
208
+ /* ---------- Table list with filter ---------- */
209
+ const MAX_TABLE_HEIGHT = 200; // px
210
+
211
+ function TableListWithFilter({ connId, tables, filter, onFilterChange, onOpenTable }: {
212
+ connId: number;
213
+ tables: CachedTable[];
214
+ filter: string;
215
+ onFilterChange: (v: string) => void;
216
+ onOpenTable: (tableName: string, schemaName: string) => void;
217
+ }) {
218
+ const filtered = useMemo(() => {
219
+ if (!filter) return tables;
220
+ const q = filter.toLowerCase();
221
+ return tables.filter((t) => t.tableName.toLowerCase().includes(q));
222
+ }, [tables, filter]);
223
+
224
+ return (
225
+ <div>
226
+ {/* Filter input — show when many tables */}
227
+ {tables.length > 5 && (
228
+ <div className="flex items-center gap-1 px-1 py-0.5">
229
+ <Search className="size-2.5 text-text-subtle shrink-0" />
230
+ <input
231
+ type="text"
232
+ value={filter}
233
+ onChange={(e) => onFilterChange(e.target.value)}
234
+ placeholder="Filter tables…"
235
+ className="w-full text-[10px] bg-transparent border-none outline-none text-foreground placeholder:text-text-subtle"
236
+ />
237
+ </div>
238
+ )}
239
+ {/* Scrollable table list */}
240
+ <div className="overflow-y-auto" style={{ maxHeight: MAX_TABLE_HEIGHT }}>
241
+ {filtered.map((t) => (
242
+ <button
243
+ key={`${connId}-${t.schemaName}.${t.tableName}`}
244
+ onClick={() => onOpenTable(t.tableName, t.schemaName)}
245
+ 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"
246
+ >
247
+ <Database className="size-2.5 shrink-0 text-text-subtle" />
248
+ <span className="truncate">{t.tableName}</span>
249
+ </button>
250
+ ))}
251
+ {filter && filtered.length === 0 && (
252
+ <p className="text-[10px] text-text-subtle px-2 py-1">No match</p>
253
+ )}
254
+ </div>
255
+ </div>
256
+ );
257
+ }