@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.
- package/CHANGELOG.md +6 -0
- package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
- package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-CDVCDw_H.js} +3 -3
- package/dist/web/assets/{code-editor-witrClmz.js → code-editor-wmS73ejX.js} +1 -1
- package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-BsYccTx1.js} +1 -1
- package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-BbWb6_Jq.js} +1 -1
- package/dist/web/assets/{index-CcXQ5iQw.js → index-DhuAmTQ1.js} +6 -6
- package/dist/web/assets/index-aIGuIMQ8.css +2 -0
- package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
- package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-aPdw9BhU.js} +1 -1
- package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
- package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
- package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
- package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
- package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-3tDV4RCn.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
- package/dist/web/index.html +4 -4
- 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/database.ts +259 -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/database.ts +50 -0
- package/src/web/app.tsx +9 -4
- 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 +208 -0
- package/src/web/components/database/database-sidebar.tsx +100 -0
- package/src/web/components/database/use-connections.ts +99 -0
- package/src/web/components/layout/command-palette.tsx +57 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/sidebar.tsx +6 -1
- package/src/web/components/postgres/postgres-viewer.tsx +12 -3
- package/src/web/components/postgres/use-postgres.ts +57 -21
- package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
- package/src/web/components/sqlite/use-sqlite.ts +21 -12
- 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/dist/web/assets/api-client-D0pZeYY8.js +0 -1
- 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
|
@@ -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
|
+
}
|