@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.
- package/CHANGELOG.md +21 -0
- package/dist/web/assets/api-client-4Ni0i4Hl.js +1 -0
- package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-DkgRZpbj.js} +3 -3
- package/dist/web/assets/{code-editor-witrClmz.js → code-editor-CVMeIylx.js} +1 -1
- package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
- package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-B1vnegRS.js} +1 -1
- package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
- package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-Bi4PM-z2.js} +1 -1
- package/dist/web/assets/index-DSg2VjxL.css +2 -0
- package/dist/web/assets/{index-CcXQ5iQw.js → index-DUb5kwfL.js} +6 -6
- package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
- package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
- package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-ChvoCZNm.js} +1 -1
- package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
- package/dist/web/assets/settings-store-CfB0vCtQ.js +1 -0
- package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
- package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
- package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
- package/dist/web/assets/table-DCVKGOr2.js +1 -0
- package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-B_75oJaQ.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Dexl3s3E.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +41 -14
- package/docs/project-roadmap.md +31 -6
- package/docs/system-architecture.md +222 -7
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +21 -4
- package/src/server/index.ts +6 -0
- package/src/server/routes/chat.ts +2 -2
- package/src/server/routes/database.ts +261 -0
- package/src/services/database/adapter-registry.ts +13 -0
- package/src/services/database/init-adapters.ts +9 -0
- package/src/services/database/postgres-adapter.ts +42 -0
- package/src/services/database/readonly-check.ts +17 -0
- package/src/services/database/sqlite-adapter.ts +55 -0
- package/src/services/db.service.ts +77 -4
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/config.ts +10 -2
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +9 -4
- package/src/web/components/chat/tool-cards.tsx +2 -2
- package/src/web/components/database/connection-color-picker.tsx +67 -0
- package/src/web/components/database/connection-form-dialog.tsx +234 -0
- package/src/web/components/database/connection-list.tsx +257 -0
- package/src/web/components/database/database-sidebar.tsx +89 -0
- package/src/web/components/database/database-viewer.tsx +228 -0
- package/src/web/components/database/use-connections.ts +92 -0
- package/src/web/components/database/use-database.ts +117 -0
- package/src/web/components/layout/command-palette.tsx +56 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/sidebar.tsx +7 -3
- package/src/web/components/layout/tab-bar.tsx +1 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/postgres/postgres-viewer.tsx +42 -25
- package/src/web/components/postgres/use-postgres.ts +54 -21
- package/src/web/components/settings/ai-settings-section.tsx +0 -1
- package/src/web/components/sqlite/sqlite-viewer.tsx +43 -13
- package/src/web/components/sqlite/use-sqlite.ts +24 -15
- package/src/web/hooks/use-chat.ts +1 -1
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
- package/dist/web/assets/dist-PpKqMvyx.js +0 -16
- package/dist/web/assets/index-DyEgsogR.css +0 -2
- package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
- package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
- package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
- package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
- package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
- /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
- /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
|
|
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
|
+
}
|