@hienlh/ppm 0.6.2 → 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 (74) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
  4. package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
  5. package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
  6. package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
  7. package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
  8. package/dist/web/assets/index-DhuAmTQ1.js +21 -0
  9. package/dist/web/assets/index-aIGuIMQ8.css +2 -0
  10. package/dist/web/assets/input-CCCPR1s4.js +41 -0
  11. package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
  12. package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
  15. package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
  16. package/dist/web/assets/react-l9v2XLcs.js +1 -0
  17. package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
  18. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  19. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
  20. package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
  21. package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-3tDV4RCn.js} +1 -1
  22. package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  23. package/dist/web/index.html +9 -8
  24. package/dist/web/sw.js +1 -1
  25. package/docs/codebase-summary.md +41 -14
  26. package/docs/project-roadmap.md +31 -6
  27. package/docs/system-architecture.md +222 -7
  28. package/package.json +1 -1
  29. package/src/cli/commands/db-cmd.ts +355 -0
  30. package/src/server/index.ts +6 -0
  31. package/src/server/routes/database.ts +259 -0
  32. package/src/server/routes/settings.ts +33 -0
  33. package/src/services/database/adapter-registry.ts +13 -0
  34. package/src/services/database/init-adapters.ts +9 -0
  35. package/src/services/database/postgres-adapter.ts +42 -0
  36. package/src/services/database/readonly-check.ts +17 -0
  37. package/src/services/database/sqlite-adapter.ts +55 -0
  38. package/src/services/db.service.ts +173 -2
  39. package/src/services/table-cache.service.ts +75 -0
  40. package/src/types/database.ts +50 -0
  41. package/src/web/app.tsx +11 -1
  42. package/src/web/components/database/connection-color-picker.tsx +67 -0
  43. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  44. package/src/web/components/database/connection-list.tsx +208 -0
  45. package/src/web/components/database/database-sidebar.tsx +100 -0
  46. package/src/web/components/database/use-connections.ts +99 -0
  47. package/src/web/components/layout/command-palette.tsx +57 -6
  48. package/src/web/components/layout/draggable-tab.tsx +13 -2
  49. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  50. package/src/web/components/layout/sidebar.tsx +6 -1
  51. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  52. package/src/web/components/postgres/use-postgres.ts +57 -21
  53. package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
  54. package/src/web/components/settings/settings-tab.tsx +5 -0
  55. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  56. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  57. package/src/web/hooks/use-global-keybindings.ts +74 -14
  58. package/src/web/lib/api-client.ts +7 -1
  59. package/src/web/lib/color-utils.ts +23 -0
  60. package/src/web/stores/keybindings-store.ts +192 -0
  61. package/src/web/stores/settings-store.ts +2 -2
  62. package/dist/web/assets/api-client-DPWUomlf.js +0 -1
  63. package/dist/web/assets/code-editor-soN1frMc.js +0 -1
  64. package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
  65. package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
  66. package/dist/web/assets/index-CmrE0Xoy.js +0 -21
  67. package/dist/web/assets/index-g11aaU-x.css +0 -2
  68. package/dist/web/assets/input-DMu1FA4M.js +0 -41
  69. package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
  70. package/dist/web/assets/react-Bo97Lrzq.js +0 -1
  71. package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
  72. package/dist/web/assets/settings-store-BRFvbsHd.js +0 -1
  73. package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
  74. package/dist/web/assets/sqlite-viewer-BbgWU-v3.js +0 -1
@@ -0,0 +1,75 @@
1
+ import {
2
+ getCachedTables, upsertTableCache, deleteTableCache, searchTableCache,
3
+ getConnectionById, type TableCacheRow,
4
+ } from "./db.service.ts";
5
+ import { getAdapter } from "./database/adapter-registry.ts";
6
+ import type { DbConnectionConfig } from "../types/database.ts";
7
+
8
+ export interface CachedTable {
9
+ connectionId: number;
10
+ tableName: string;
11
+ schemaName: string;
12
+ rowCount: number;
13
+ cachedAt: string;
14
+ }
15
+
16
+ export interface TableSearchResult {
17
+ connectionId: number;
18
+ connectionName: string;
19
+ connectionType: string;
20
+ connectionColor: string | null;
21
+ tableName: string;
22
+ schemaName: string;
23
+ }
24
+
25
+ function rowToTable(r: TableCacheRow): CachedTable {
26
+ return {
27
+ connectionId: r.connection_id,
28
+ tableName: r.table_name,
29
+ schemaName: r.schema_name,
30
+ rowCount: r.row_count,
31
+ cachedAt: r.cached_at,
32
+ };
33
+ }
34
+
35
+ /** Get cached tables for a connection (no live fetch) */
36
+ export function getTablesFromCache(connectionId: number): CachedTable[] {
37
+ return getCachedTables(connectionId).map(rowToTable);
38
+ }
39
+
40
+ /** Fetch live tables via adapter, update cache, return result */
41
+ export async function syncTables(connectionId: number): Promise<CachedTable[]> {
42
+ const conn = getConnectionById(connectionId);
43
+ if (!conn) throw new Error(`Connection not found: ${connectionId}`);
44
+
45
+ const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
46
+ const adapter = getAdapter(conn.type);
47
+ const tables = await adapter.getTables(config);
48
+
49
+ // Delete stale cache entries, then upsert fresh ones
50
+ deleteTableCache(connectionId);
51
+ for (const t of tables) {
52
+ upsertTableCache(connectionId, t.name, t.schema || "main", t.rowCount);
53
+ }
54
+
55
+ return tables.map((t) => ({
56
+ connectionId,
57
+ tableName: t.name,
58
+ schemaName: t.schema || "main",
59
+ rowCount: t.rowCount,
60
+ cachedAt: new Date().toISOString(),
61
+ }));
62
+ }
63
+
64
+ /** Search cached tables across all connections (for command palette) */
65
+ export function searchTables(query: string): TableSearchResult[] {
66
+ if (!query || query.length < 2) return [];
67
+ return searchTableCache(query).map((r) => ({
68
+ connectionId: r.connection_id,
69
+ connectionName: r.connection_name,
70
+ connectionType: r.connection_type,
71
+ connectionColor: r.connection_color,
72
+ tableName: r.table_name,
73
+ schemaName: r.schema_name,
74
+ }));
75
+ }
@@ -0,0 +1,50 @@
1
+ export type DbType = "sqlite" | "postgres";
2
+
3
+ export interface DbConnectionConfig {
4
+ type: DbType;
5
+ path?: string; // sqlite
6
+ connectionString?: string; // postgres
7
+ [key: string]: unknown;
8
+ }
9
+
10
+ export interface DbTableInfo {
11
+ name: string;
12
+ schema: string; // "main" for sqlite, actual schema for postgres
13
+ rowCount: number;
14
+ }
15
+
16
+ export interface DbColumnInfo {
17
+ name: string;
18
+ type: string;
19
+ nullable: boolean;
20
+ pk: boolean;
21
+ defaultValue: string | null;
22
+ }
23
+
24
+ export interface DbQueryResult {
25
+ columns: string[];
26
+ rows: Record<string, unknown>[];
27
+ rowsAffected: number;
28
+ changeType: "select" | "modify";
29
+ }
30
+
31
+ export interface DbPagedData {
32
+ columns: string[];
33
+ rows: Record<string, unknown>[];
34
+ total: number;
35
+ page: number;
36
+ limit: number;
37
+ }
38
+
39
+ export interface DatabaseAdapter {
40
+ testConnection(config: DbConnectionConfig): Promise<{ ok: boolean; error?: string }>;
41
+ getTables(config: DbConnectionConfig): Promise<DbTableInfo[]>;
42
+ getTableSchema(config: DbConnectionConfig, table: string, schema?: string): Promise<DbColumnInfo[]>;
43
+ getTableData(config: DbConnectionConfig, table: string, opts: {
44
+ schema?: string; page?: number; limit?: number; orderBy?: string; orderDir?: "ASC" | "DESC";
45
+ }): Promise<DbPagedData>;
46
+ executeQuery(config: DbConnectionConfig, sql: string): Promise<DbQueryResult>;
47
+ updateCell(config: DbConnectionConfig, table: string, opts: {
48
+ schema?: string; pkColumn: string; pkValue: unknown; column: string; value: unknown;
49
+ }): Promise<void>;
50
+ }
package/src/web/app.tsx CHANGED
@@ -59,7 +59,9 @@ export function App() {
59
59
  }, [theme]);
60
60
 
61
61
  // Fetch server info on mount (before auth — shown on login screen)
62
- useEffect(() => { fetchServerInfo(); }, [fetchServerInfo]);
62
+ useEffect(() => {
63
+ fetchServerInfo();
64
+ }, [fetchServerInfo]);
63
65
 
64
66
  // Auth check on mount
65
67
  useEffect(() => {
@@ -97,6 +99,14 @@ export function App() {
97
99
  // Health check — detects server crash/restart
98
100
  useHealthCheck();
99
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
+
100
110
  // Fetch projects after auth, then restore from URL if applicable
101
111
  useEffect(() => {
102
112
  if (authState !== "authenticated") return;
@@ -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
+ }