@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,99 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
|
|
3
|
+
export interface Connection {
|
|
4
|
+
id: number;
|
|
5
|
+
type: "sqlite" | "postgres";
|
|
6
|
+
name: string;
|
|
7
|
+
group_name: string | null;
|
|
8
|
+
color: string | null;
|
|
9
|
+
readonly: number;
|
|
10
|
+
sort_order: number;
|
|
11
|
+
created_at: string;
|
|
12
|
+
updated_at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CachedTable {
|
|
16
|
+
connectionId: number;
|
|
17
|
+
tableName: string;
|
|
18
|
+
schemaName: string;
|
|
19
|
+
rowCount: number;
|
|
20
|
+
cachedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CreateConnectionData {
|
|
24
|
+
type: "sqlite" | "postgres";
|
|
25
|
+
name: string;
|
|
26
|
+
connectionConfig: { type: string; path?: string; connectionString?: string };
|
|
27
|
+
groupName?: string;
|
|
28
|
+
color?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UpdateConnectionData {
|
|
32
|
+
name?: string;
|
|
33
|
+
connectionConfig?: { type: string; path?: string; connectionString?: string };
|
|
34
|
+
groupName?: string | null;
|
|
35
|
+
color?: string | null;
|
|
36
|
+
readonly?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function apiFetch<T>(url: string, opts?: RequestInit): Promise<T> {
|
|
40
|
+
const res = await fetch(url, opts);
|
|
41
|
+
const json = await res.json() as { ok: boolean; data?: T; error?: string };
|
|
42
|
+
if (!json.ok) throw new Error(json.error ?? "Request failed");
|
|
43
|
+
return json.data as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function useConnections() {
|
|
47
|
+
const [connections, setConnections] = useState<Connection[]>([]);
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [cachedTables, setCachedTables] = useState<Map<number, CachedTable[]>>(new Map());
|
|
50
|
+
|
|
51
|
+
const fetchConnections = useCallback(async () => {
|
|
52
|
+
try {
|
|
53
|
+
const data = await apiFetch<Connection[]>("/api/db/connections");
|
|
54
|
+
setConnections(data);
|
|
55
|
+
} catch {
|
|
56
|
+
// ignore — server may not be ready
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
useEffect(() => { fetchConnections(); }, [fetchConnections]);
|
|
63
|
+
|
|
64
|
+
const createConnection = useCallback(async (data: CreateConnectionData): Promise<Connection> => {
|
|
65
|
+
const conn = await apiFetch<Connection>("/api/db/connections", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify(data),
|
|
69
|
+
});
|
|
70
|
+
setConnections((prev) => [...prev, conn]);
|
|
71
|
+
return conn;
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const updateConnection = useCallback(async (id: number, data: UpdateConnectionData): Promise<void> => {
|
|
75
|
+
const updated = await apiFetch<Connection>(`/api/db/connections/${id}`, {
|
|
76
|
+
method: "PUT",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify(data),
|
|
79
|
+
});
|
|
80
|
+
setConnections((prev) => prev.map((c) => (c.id === id ? updated : c)));
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const deleteConnection = useCallback(async (id: number): Promise<void> => {
|
|
84
|
+
await apiFetch(`/api/db/connections/${id}`, { method: "DELETE" });
|
|
85
|
+
setConnections((prev) => prev.filter((c) => c.id !== id));
|
|
86
|
+
setCachedTables((prev) => { const m = new Map(prev); m.delete(id); return m; });
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const testConnection = useCallback(async (id: number): Promise<{ ok: boolean; error?: string }> => {
|
|
90
|
+
return apiFetch(`/api/db/connections/${id}/test`, { method: "POST" });
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const refreshTables = useCallback(async (id: number): Promise<void> => {
|
|
94
|
+
const tables = await apiFetch<CachedTable[]>(`/api/db/connections/${id}/tables`);
|
|
95
|
+
setCachedTables((prev) => new Map(prev).set(id, tables));
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables };
|
|
99
|
+
}
|
|
@@ -25,7 +25,17 @@ interface CommandItem {
|
|
|
25
25
|
icon: React.ElementType;
|
|
26
26
|
action: () => void;
|
|
27
27
|
keywords?: string;
|
|
28
|
-
group: "action" | "file" | "fs";
|
|
28
|
+
group: "action" | "file" | "fs" | "db";
|
|
29
|
+
connectionColor?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DbSearchResult {
|
|
33
|
+
connectionId: number;
|
|
34
|
+
connectionName: string;
|
|
35
|
+
connectionType: string;
|
|
36
|
+
connectionColor: string | null;
|
|
37
|
+
tableName: string;
|
|
38
|
+
schemaName: string;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
/** Recursively flatten file tree into file-only list */
|
|
@@ -65,6 +75,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
65
75
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
66
76
|
const [fsFiles, setFsFiles] = useState<string[]>([]);
|
|
67
77
|
const [fsLoading, setFsLoading] = useState(false);
|
|
78
|
+
const [dbResults, setDbResults] = useState<DbSearchResult[]>([]);
|
|
68
79
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
69
80
|
const listRef = useRef<HTMLDivElement>(null);
|
|
70
81
|
|
|
@@ -103,6 +114,19 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
103
114
|
fetchFsFiles(dir);
|
|
104
115
|
}, [query, fetchFsFiles]);
|
|
105
116
|
|
|
117
|
+
// Debounced DB table search
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (isPathQuery(query) || query.trim().length < 2) { setDbResults([]); return; }
|
|
120
|
+
const timer = setTimeout(async () => {
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`/api/db/search?q=${encodeURIComponent(query.trim())}`);
|
|
123
|
+
const json = await res.json() as { ok: boolean; data?: DbSearchResult[] };
|
|
124
|
+
setDbResults(json.ok ? (json.data ?? []) : []);
|
|
125
|
+
} catch { setDbResults([]); }
|
|
126
|
+
}, 300);
|
|
127
|
+
return () => clearTimeout(timer);
|
|
128
|
+
}, [query]);
|
|
129
|
+
|
|
106
130
|
// Action commands
|
|
107
131
|
const actionCommands = useMemo<CommandItem[]>(() => {
|
|
108
132
|
const projectId = activeProject?.name ?? null;
|
|
@@ -186,6 +210,25 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
186
210
|
});
|
|
187
211
|
}, [fsFiles, activeProject, openTab, onClose]);
|
|
188
212
|
|
|
213
|
+
const dbCommands = useMemo<CommandItem[]>(() => dbResults.map((r) => ({
|
|
214
|
+
id: `db:${r.connectionId}:${r.schemaName}.${r.tableName}`,
|
|
215
|
+
label: r.tableName,
|
|
216
|
+
hint: `${r.connectionName} (${r.connectionType === "postgres" ? "PG" : "SQLite"})`,
|
|
217
|
+
icon: Database,
|
|
218
|
+
group: "db" as const,
|
|
219
|
+
connectionColor: r.connectionColor,
|
|
220
|
+
action: () => {
|
|
221
|
+
openTab({
|
|
222
|
+
type: r.connectionType === "postgres" ? "postgres" : "sqlite",
|
|
223
|
+
title: `${r.connectionName} · ${r.tableName}`,
|
|
224
|
+
projectId: null,
|
|
225
|
+
closable: true,
|
|
226
|
+
metadata: { connectionId: r.connectionId, tableName: r.tableName, schemaName: r.schemaName, connectionColor: r.connectionColor },
|
|
227
|
+
});
|
|
228
|
+
onClose();
|
|
229
|
+
},
|
|
230
|
+
})), [dbResults, openTab, onClose]);
|
|
231
|
+
|
|
189
232
|
const allCommands = useMemo(
|
|
190
233
|
() => [...actionCommands, ...fileCommands],
|
|
191
234
|
[actionCommands, fileCommands],
|
|
@@ -194,10 +237,9 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
194
237
|
const filtered = useMemo(() => {
|
|
195
238
|
// Path mode — search filesystem results using filename portion only
|
|
196
239
|
if (isPathQuery(query)) {
|
|
197
|
-
// Extract the part after the last / as the filename filter
|
|
198
240
|
const lastSlash = query.lastIndexOf("/");
|
|
199
241
|
const fileFilter = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : "";
|
|
200
|
-
if (!fileFilter) return fsCommands.slice(0, 50);
|
|
242
|
+
if (!fileFilter) return fsCommands.slice(0, 50);
|
|
201
243
|
return fsCommands.filter((c) => {
|
|
202
244
|
const name = c.label.toLowerCase();
|
|
203
245
|
const path = (c.keywords ?? "").toLowerCase();
|
|
@@ -217,10 +259,12 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
217
259
|
}
|
|
218
260
|
return true;
|
|
219
261
|
};
|
|
220
|
-
|
|
262
|
+
const matched = allCommands.filter(
|
|
221
263
|
(c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
|
|
222
264
|
);
|
|
223
|
-
|
|
265
|
+
// Prepend DB results (already filtered server-side) when query is 2+ chars
|
|
266
|
+
return query.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
267
|
+
}, [allCommands, actionCommands, fsCommands, dbCommands, query]);
|
|
224
268
|
|
|
225
269
|
// Reset state when opening
|
|
226
270
|
useEffect(() => {
|
|
@@ -228,6 +272,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
228
272
|
setQuery(initialQuery || "");
|
|
229
273
|
setSelectedIdx(0);
|
|
230
274
|
setFsFiles([]);
|
|
275
|
+
setDbResults([]);
|
|
231
276
|
requestAnimationFrame(() => inputRef.current?.focus());
|
|
232
277
|
}
|
|
233
278
|
}, [open]);
|
|
@@ -353,7 +398,13 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
353
398
|
<Icon className="size-4 shrink-0" />
|
|
354
399
|
<span className="truncate">{cmd.label}</span>
|
|
355
400
|
{cmd.hint && (
|
|
356
|
-
<span className="ml-auto text-xs text-text-subtle truncate max-w-[200px]">
|
|
401
|
+
<span className="ml-auto flex items-center gap-1.5 text-xs text-text-subtle truncate max-w-[200px]">
|
|
402
|
+
{cmd.connectionColor && (
|
|
403
|
+
<span
|
|
404
|
+
className="shrink-0 size-2 rounded-full"
|
|
405
|
+
style={{ backgroundColor: cmd.connectionColor }}
|
|
406
|
+
/>
|
|
407
|
+
)}
|
|
357
408
|
{cmd.hint}
|
|
358
409
|
</span>
|
|
359
410
|
)}
|
|
@@ -2,6 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import type { Tab, TabType } from "@/stores/tab-store";
|
|
4
4
|
import { cn } from "@/lib/utils";
|
|
5
|
+
import { isDarkColor } from "@/lib/color-utils";
|
|
5
6
|
|
|
6
7
|
interface DraggableTabProps {
|
|
7
8
|
tab: Tab;
|
|
@@ -41,6 +42,14 @@ export function DraggableTab({
|
|
|
41
42
|
}
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
const tabColor = tab.metadata?.connectionColor as string | undefined;
|
|
46
|
+
const colorStyle = tabColor
|
|
47
|
+
? {
|
|
48
|
+
backgroundColor: isActive ? tabColor : `${tabColor}33`,
|
|
49
|
+
color: isActive && isDarkColor(tabColor) ? "#fff" : undefined,
|
|
50
|
+
}
|
|
51
|
+
: undefined;
|
|
52
|
+
|
|
44
53
|
return (
|
|
45
54
|
<div className="relative flex items-center">
|
|
46
55
|
{showDropBefore && (
|
|
@@ -55,12 +64,14 @@ export function DraggableTab({
|
|
|
55
64
|
onDragStart={onDragStart}
|
|
56
65
|
onDragOver={onDragOver}
|
|
57
66
|
onDragEnd={onDragEnd}
|
|
67
|
+
style={colorStyle}
|
|
58
68
|
className={cn(
|
|
59
69
|
"group flex items-center gap-1 px-3 h-10 whitespace-nowrap text-xs transition-colors",
|
|
60
70
|
"border-b-2 -mb-px cursor-grab active:cursor-grabbing",
|
|
61
|
-
isActive
|
|
71
|
+
!colorStyle && (isActive
|
|
62
72
|
? "border-primary text-primary"
|
|
63
|
-
: "border-transparent text-text-secondary hover:text-foreground",
|
|
73
|
+
: "border-transparent text-text-secondary hover:text-foreground"),
|
|
74
|
+
colorStyle && "border-transparent",
|
|
64
75
|
)}
|
|
65
76
|
>
|
|
66
77
|
<Icon className="size-4" />
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect } from "react";
|
|
2
2
|
import {
|
|
3
|
-
X, Bug, FolderOpen, GitBranch, Settings,
|
|
3
|
+
X, Bug, FolderOpen, GitBranch, Settings, Database,
|
|
4
4
|
} from "lucide-react";
|
|
5
5
|
import { useProjectStore } from "@/stores/project-store";
|
|
6
6
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
7
7
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
8
8
|
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
9
9
|
import { SettingsTab } from "@/components/settings/settings-tab";
|
|
10
|
+
import { DatabaseSidebar } from "@/components/database/database-sidebar";
|
|
10
11
|
import { openBugReportPopup } from "@/lib/report-bug";
|
|
11
12
|
import { cn } from "@/lib/utils";
|
|
12
13
|
|
|
13
|
-
type DrawerTab = "explorer" | "git" | "settings";
|
|
14
|
+
type DrawerTab = "explorer" | "git" | "settings" | "database";
|
|
14
15
|
|
|
15
16
|
const TABS: { id: DrawerTab; label: string; icon: React.ElementType }[] = [
|
|
16
17
|
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
17
18
|
{ id: "git", label: "Git", icon: GitBranch },
|
|
19
|
+
{ id: "database", label: "Database", icon: Database },
|
|
18
20
|
{ id: "settings", label: "Settings", icon: Settings },
|
|
19
21
|
];
|
|
20
22
|
|
|
@@ -86,6 +88,9 @@ export function MobileDrawer({ isOpen, onClose, initialTab }: MobileDrawerProps)
|
|
|
86
88
|
{activeTab === "git" && (
|
|
87
89
|
<GitStatusPanel metadata={{ projectName: activeProject?.name }} onNavigate={onClose} />
|
|
88
90
|
)}
|
|
91
|
+
{activeTab === "database" && (
|
|
92
|
+
<DatabaseSidebar />
|
|
93
|
+
)}
|
|
89
94
|
{activeTab === "settings" && (
|
|
90
95
|
<SettingsTab />
|
|
91
96
|
)}
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { useCallback, useRef } from "react";
|
|
2
|
-
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings } from "lucide-react";
|
|
2
|
+
import { PanelLeftClose, PanelLeftOpen, FolderOpen, GitBranch, Settings, Database } from "lucide-react";
|
|
3
3
|
import { useProjectStore } from "@/stores/project-store";
|
|
4
4
|
import { useSettingsStore, type SidebarActiveTab } from "@/stores/settings-store";
|
|
5
5
|
import { FileTree } from "@/components/explorer/file-tree";
|
|
6
6
|
import { GitStatusPanel } from "@/components/git/git-status-panel";
|
|
7
7
|
import { SettingsTab } from "@/components/settings/settings-tab";
|
|
8
|
+
import { DatabaseSidebar } from "@/components/database/database-sidebar";
|
|
8
9
|
import { cn } from "@/lib/utils";
|
|
9
10
|
|
|
10
11
|
const TABS: { id: SidebarActiveTab; label: string; icon: React.ElementType }[] = [
|
|
11
12
|
{ id: "explorer", label: "Explorer", icon: FolderOpen },
|
|
12
13
|
{ id: "git", label: "Git", icon: GitBranch },
|
|
14
|
+
{ id: "database", label: "Database", icon: Database },
|
|
13
15
|
{ id: "settings", label: "Settings", icon: Settings },
|
|
14
16
|
];
|
|
15
17
|
|
|
@@ -123,6 +125,9 @@ export function Sidebar() {
|
|
|
123
125
|
{sidebarActiveTab === "git" && (
|
|
124
126
|
<GitStatusPanel metadata={{ projectName: activeProject?.name }} />
|
|
125
127
|
)}
|
|
128
|
+
{sidebarActiveTab === "database" && (
|
|
129
|
+
<DatabaseSidebar />
|
|
130
|
+
)}
|
|
126
131
|
{sidebarActiveTab === "settings" && (
|
|
127
132
|
<SettingsTab />
|
|
128
133
|
)}
|
|
@@ -9,11 +9,13 @@ interface Props { metadata?: Record<string, unknown>; tabId?: string }
|
|
|
9
9
|
|
|
10
10
|
export function PostgresViewer({ metadata }: Props) {
|
|
11
11
|
const initialConn = (metadata?.connectionString as string) ?? "";
|
|
12
|
-
const
|
|
12
|
+
const connectionId = metadata?.connectionId as number | undefined;
|
|
13
|
+
const pg = usePostgres(connectionId);
|
|
13
14
|
|
|
15
|
+
// When connectionId present, the hook auto-connects — skip connection form
|
|
14
16
|
if (!pg.connected) return <ConnectionForm initialValue={initialConn} onConnect={pg.connect} loading={pg.loading} error={pg.error} />;
|
|
15
17
|
|
|
16
|
-
return <ConnectedView pg={pg} />;
|
|
18
|
+
return <ConnectedView pg={pg} initialTable={metadata?.tableName as string | undefined} />;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/* ---------- Connection Form ---------- */
|
|
@@ -42,9 +44,16 @@ function ConnectionForm({ initialValue, onConnect, loading, error }: {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/* ---------- Connected View ---------- */
|
|
45
|
-
function ConnectedView({ pg }: { pg: ReturnType<typeof usePostgres
|
|
47
|
+
function ConnectedView({ pg, initialTable }: { pg: ReturnType<typeof usePostgres>; initialTable?: string }) {
|
|
46
48
|
const [queryPanelOpen, setQueryPanelOpen] = useState(false);
|
|
47
49
|
|
|
50
|
+
// Jump to initial table from sidebar click
|
|
51
|
+
const [didInit, setDidInit] = useState(false);
|
|
52
|
+
if (initialTable && !didInit && pg.tables.length > 0 && pg.selectedTable !== initialTable) {
|
|
53
|
+
const t = pg.tables.find((t) => t.name === initialTable);
|
|
54
|
+
if (t) { setDidInit(true); pg.selectTable(t.name, t.schema); }
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
return (
|
|
49
58
|
<div className="flex h-full w-full overflow-hidden">
|
|
50
59
|
{/* Table sidebar */}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect } from "react";
|
|
2
2
|
import { api } from "@/lib/api-client";
|
|
3
3
|
|
|
4
4
|
export interface PgTableInfo { name: string; schema: string; rowCount: number }
|
|
@@ -8,9 +8,11 @@ interface PgTableData { columns: string[]; rows: Record<string, unknown>[]; tota
|
|
|
8
8
|
|
|
9
9
|
const BASE = "/api/postgres";
|
|
10
10
|
|
|
11
|
-
export function usePostgres() {
|
|
11
|
+
export function usePostgres(connectionId?: number) {
|
|
12
12
|
const [connectionString, setConnectionString] = useState("");
|
|
13
13
|
const [connected, setConnected] = useState(false);
|
|
14
|
+
// Unified API base when connectionId is provided
|
|
15
|
+
const unifiedBase = connectionId ? `/api/db/connections/${connectionId}` : null;
|
|
14
16
|
const [tables, setTables] = useState<PgTableInfo[]>([]);
|
|
15
17
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
|
16
18
|
const [selectedSchema, setSelectedSchema] = useState("public");
|
|
@@ -31,7 +33,6 @@ export function usePostgres() {
|
|
|
31
33
|
if (!test.ok) { setError(test.error ?? "Connection failed"); return; }
|
|
32
34
|
setConnectionString(connStr);
|
|
33
35
|
setConnected(true);
|
|
34
|
-
// Fetch tables
|
|
35
36
|
const data = await api.post<PgTableInfo[]>(`${BASE}/tables`, { connectionString: connStr });
|
|
36
37
|
setTables(data);
|
|
37
38
|
if (data.length > 0) {
|
|
@@ -46,6 +47,19 @@ export function usePostgres() {
|
|
|
46
47
|
}, []);
|
|
47
48
|
|
|
48
49
|
const fetchTables = useCallback(async () => {
|
|
50
|
+
if (unifiedBase) {
|
|
51
|
+
setLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
const data = await api.get<PgTableInfo[]>(`${unifiedBase}/tables`);
|
|
54
|
+
setTables(data);
|
|
55
|
+
if (data.length > 0 && !selectedTable) {
|
|
56
|
+
setSelectedTable(data[0]!.name);
|
|
57
|
+
setSelectedSchema(data[0]!.schema ?? "public");
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { setError((e as Error).message); }
|
|
60
|
+
finally { setLoading(false); }
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
49
63
|
if (!connectionString) return;
|
|
50
64
|
setLoading(true);
|
|
51
65
|
try {
|
|
@@ -56,26 +70,44 @@ export function usePostgres() {
|
|
|
56
70
|
} finally {
|
|
57
71
|
setLoading(false);
|
|
58
72
|
}
|
|
59
|
-
}, [connectionString]);
|
|
73
|
+
}, [unifiedBase, connectionString, selectedTable]);
|
|
74
|
+
|
|
75
|
+
// Auto-connect via unified API when connectionId is provided
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (unifiedBase) {
|
|
78
|
+
setConnected(true);
|
|
79
|
+
fetchTables();
|
|
80
|
+
}
|
|
81
|
+
}, [unifiedBase]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
82
|
|
|
61
83
|
const fetchTableData = useCallback(async (table?: string, tableSchema?: string, p?: number) => {
|
|
62
84
|
const t = table ?? selectedTable;
|
|
63
85
|
const s = tableSchema ?? selectedSchema;
|
|
64
|
-
if (!
|
|
86
|
+
if (!t) return;
|
|
65
87
|
setLoading(true);
|
|
66
88
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
if (unifiedBase) {
|
|
90
|
+
const [data, cols] = await Promise.all([
|
|
91
|
+
api.get<PgTableData>(`${unifiedBase}/data?table=${encodeURIComponent(t)}&schema=${s}&page=${p ?? page}&limit=100`),
|
|
92
|
+
api.get<PgColumnInfo[]>(`${unifiedBase}/schema?table=${encodeURIComponent(t)}&schema=${s}`),
|
|
93
|
+
]);
|
|
94
|
+
setTableData(data);
|
|
95
|
+
setSchema(cols);
|
|
96
|
+
} else {
|
|
97
|
+
if (!connectionString) return;
|
|
98
|
+
const [data, cols] = await Promise.all([
|
|
99
|
+
api.post<PgTableData>(`${BASE}/data`, { connectionString, table: t, schema: s, page: p ?? page, limit: 100 }),
|
|
100
|
+
api.post<PgColumnInfo[]>(`${BASE}/schema`, { connectionString, table: t, schema: s }),
|
|
101
|
+
]);
|
|
102
|
+
setTableData(data);
|
|
103
|
+
setSchema(cols);
|
|
104
|
+
}
|
|
73
105
|
} catch (e) {
|
|
74
106
|
setError((e as Error).message);
|
|
75
107
|
} finally {
|
|
76
108
|
setLoading(false);
|
|
77
109
|
}
|
|
78
|
-
}, [connectionString, selectedTable, selectedSchema, page]);
|
|
110
|
+
}, [unifiedBase, connectionString, selectedTable, selectedSchema, page]);
|
|
79
111
|
|
|
80
112
|
const selectTable = useCallback((name: string, tableSchema = "public") => {
|
|
81
113
|
setSelectedTable(name);
|
|
@@ -91,11 +123,13 @@ export function usePostgres() {
|
|
|
91
123
|
}, [fetchTableData]);
|
|
92
124
|
|
|
93
125
|
const executeQuery = useCallback(async (sql: string) => {
|
|
94
|
-
if (!connectionString) return;
|
|
126
|
+
if (!unifiedBase && !connectionString) return;
|
|
95
127
|
setQueryLoading(true);
|
|
96
128
|
setQueryError(null);
|
|
97
129
|
try {
|
|
98
|
-
const result =
|
|
130
|
+
const result = unifiedBase
|
|
131
|
+
? await api.post<PgQueryResult>(`${unifiedBase}/query`, { sql })
|
|
132
|
+
: await api.post<PgQueryResult>(`${BASE}/query`, { connectionString, sql });
|
|
99
133
|
setQueryResult(result);
|
|
100
134
|
if (result.changeType === "modify") fetchTableData();
|
|
101
135
|
} catch (e) {
|
|
@@ -103,20 +137,22 @@ export function usePostgres() {
|
|
|
103
137
|
} finally {
|
|
104
138
|
setQueryLoading(false);
|
|
105
139
|
}
|
|
106
|
-
}, [connectionString, fetchTableData]);
|
|
140
|
+
}, [unifiedBase, connectionString, fetchTableData]);
|
|
107
141
|
|
|
108
142
|
const updateCell = useCallback(async (pkColumn: string, pkValue: unknown, column: string, value: unknown) => {
|
|
109
|
-
if (!
|
|
143
|
+
if (!selectedTable) return;
|
|
110
144
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
if (unifiedBase) {
|
|
146
|
+
await api.put(`${unifiedBase}/cell`, { table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
|
|
147
|
+
} else {
|
|
148
|
+
if (!connectionString) return;
|
|
149
|
+
await api.post(`${BASE}/cell`, { connectionString, table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
|
|
150
|
+
}
|
|
115
151
|
fetchTableData();
|
|
116
152
|
} catch (e) {
|
|
117
153
|
setError((e as Error).message);
|
|
118
154
|
}
|
|
119
|
-
}, [connectionString, selectedTable, selectedSchema, fetchTableData]);
|
|
155
|
+
}, [unifiedBase, connectionString, selectedTable, selectedSchema, fetchTableData]);
|
|
120
156
|
|
|
121
157
|
return {
|
|
122
158
|
connectionString, connected, connect,
|
|
@@ -13,8 +13,24 @@ interface SqliteViewerProps {
|
|
|
13
13
|
export function SqliteViewer({ metadata }: SqliteViewerProps) {
|
|
14
14
|
const filePath = metadata?.filePath as string | undefined;
|
|
15
15
|
const projectName = metadata?.projectName as string | undefined;
|
|
16
|
+
const connectionId = metadata?.connectionId as number | undefined;
|
|
17
|
+
const initialTable = metadata?.tableName as string | undefined;
|
|
16
18
|
const [queryPanelOpen, setQueryPanelOpen] = useState(false);
|
|
17
19
|
|
|
20
|
+
// Connection-based mode: skip file selection requirement
|
|
21
|
+
if (connectionId) {
|
|
22
|
+
return (
|
|
23
|
+
<SqliteViewerInner
|
|
24
|
+
projectName=""
|
|
25
|
+
dbPath=""
|
|
26
|
+
connectionId={connectionId}
|
|
27
|
+
initialTable={initialTable}
|
|
28
|
+
queryPanelOpen={queryPanelOpen}
|
|
29
|
+
onToggleQueryPanel={() => setQueryPanelOpen((v) => !v)}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
if (!filePath || !projectName) {
|
|
19
35
|
return (
|
|
20
36
|
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
@@ -34,11 +50,19 @@ export function SqliteViewer({ metadata }: SqliteViewerProps) {
|
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
function SqliteViewerInner({
|
|
37
|
-
projectName, dbPath, queryPanelOpen, onToggleQueryPanel,
|
|
53
|
+
projectName, dbPath, connectionId, initialTable, queryPanelOpen, onToggleQueryPanel,
|
|
38
54
|
}: {
|
|
39
|
-
projectName: string; dbPath: string;
|
|
55
|
+
projectName: string; dbPath: string; connectionId?: number; initialTable?: string;
|
|
56
|
+
queryPanelOpen: boolean; onToggleQueryPanel: () => void;
|
|
40
57
|
}) {
|
|
41
|
-
const sqlite = useSqlite(projectName, dbPath);
|
|
58
|
+
const sqlite = useSqlite(projectName, dbPath, connectionId);
|
|
59
|
+
|
|
60
|
+
// Jump to initial table from sidebar click
|
|
61
|
+
const [didInit, setDidInit] = useState(false);
|
|
62
|
+
if (initialTable && !didInit && sqlite.tables.length > 0 && sqlite.selectedTable !== initialTable) {
|
|
63
|
+
setDidInit(true);
|
|
64
|
+
sqlite.selectTable(initialTable);
|
|
65
|
+
}
|
|
42
66
|
|
|
43
67
|
if (sqlite.error && sqlite.tables.length === 0) {
|
|
44
68
|
return (
|
|
@@ -6,7 +6,7 @@ export interface ColumnInfo { cid: number; name: string; type: string; notnull:
|
|
|
6
6
|
export interface QueryResult { columns: string[]; rows: Record<string, unknown>[]; rowsAffected: number; changeType: "select" | "modify" }
|
|
7
7
|
interface TableData { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }
|
|
8
8
|
|
|
9
|
-
export function useSqlite(projectName: string, dbPath: string) {
|
|
9
|
+
export function useSqlite(projectName: string, dbPath: string, connectionId?: number) {
|
|
10
10
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
|
11
11
|
const [selectedTable, setSelectedTable] = useState<string | null>(null);
|
|
12
12
|
const [tableData, setTableData] = useState<TableData | null>(null);
|
|
@@ -18,15 +18,18 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
18
18
|
const [queryError, setQueryError] = useState<string | null>(null);
|
|
19
19
|
const [queryLoading, setQueryLoading] = useState(false);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
const
|
|
21
|
+
// When connectionId present, use unified API; otherwise use project-scoped API
|
|
22
|
+
const unifiedBase = connectionId ? `/api/db/connections/${connectionId}` : null;
|
|
23
|
+
const base = unifiedBase ?? `${projectUrl(projectName)}/sqlite`;
|
|
24
|
+
const qs = unifiedBase ? "" : `path=${encodeURIComponent(dbPath)}`;
|
|
23
25
|
|
|
24
26
|
// Fetch tables on mount
|
|
25
27
|
const fetchTables = useCallback(async () => {
|
|
26
28
|
setLoading(true);
|
|
27
29
|
setError(null);
|
|
28
30
|
try {
|
|
29
|
-
const
|
|
31
|
+
const qsPart = qs ? `?${qs}` : "";
|
|
32
|
+
const data = await api.get<TableInfo[]>(`${base}/tables${qsPart}`);
|
|
30
33
|
setTables(data);
|
|
31
34
|
if (data.length > 0 && !selectedTable) setSelectedTable(data[0]!.name);
|
|
32
35
|
} catch (e) {
|
|
@@ -43,9 +46,10 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
43
46
|
if (!selectedTable) return;
|
|
44
47
|
setLoading(true);
|
|
45
48
|
try {
|
|
49
|
+
const qsPrefix = qs ? `${qs}&` : "";
|
|
46
50
|
const [data, cols] = await Promise.all([
|
|
47
|
-
api.get<TableData>(`${base}/data?${
|
|
48
|
-
api.get<ColumnInfo[]>(`${base}/schema?${
|
|
51
|
+
api.get<TableData>(`${base}/data?${qsPrefix}table=${encodeURIComponent(selectedTable)}&page=${page}&limit=100`),
|
|
52
|
+
api.get<ColumnInfo[]>(`${base}/schema?${qsPrefix}table=${encodeURIComponent(selectedTable)}`),
|
|
49
53
|
]);
|
|
50
54
|
setTableData(data);
|
|
51
55
|
setSchema(cols);
|
|
@@ -68,25 +72,30 @@ export function useSqlite(projectName: string, dbPath: string) {
|
|
|
68
72
|
setQueryLoading(true);
|
|
69
73
|
setQueryError(null);
|
|
70
74
|
try {
|
|
71
|
-
const
|
|
75
|
+
const body = unifiedBase ? { sql } : { path: dbPath, sql };
|
|
76
|
+
const result = await api.post<QueryResult>(`${base}/query`, body);
|
|
72
77
|
setQueryResult(result);
|
|
73
|
-
if (result.changeType === "modify") fetchTableData();
|
|
78
|
+
if (result.changeType === "modify") fetchTableData();
|
|
74
79
|
} catch (e) {
|
|
75
80
|
setQueryError((e as Error).message);
|
|
76
81
|
} finally {
|
|
77
82
|
setQueryLoading(false);
|
|
78
83
|
}
|
|
79
|
-
}, [base, dbPath, fetchTableData]);
|
|
84
|
+
}, [base, unifiedBase, dbPath, fetchTableData]);
|
|
80
85
|
|
|
81
86
|
const updateCell = useCallback(async (rowid: number, column: string, value: unknown) => {
|
|
82
87
|
if (!selectedTable) return;
|
|
83
88
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
if (unifiedBase) {
|
|
90
|
+
await api.put(`${base}/cell`, { table: selectedTable, pkColumn: "rowid", pkValue: rowid, column, value });
|
|
91
|
+
} else {
|
|
92
|
+
await api.put(`${base}/cell`, { path: dbPath, table: selectedTable, rowid, column, value });
|
|
93
|
+
}
|
|
94
|
+
fetchTableData();
|
|
86
95
|
} catch (e) {
|
|
87
96
|
setError((e as Error).message);
|
|
88
97
|
}
|
|
89
|
-
}, [base, dbPath, selectedTable, fetchTableData]);
|
|
98
|
+
}, [base, unifiedBase, dbPath, selectedTable, fetchTableData]);
|
|
90
99
|
|
|
91
100
|
return {
|
|
92
101
|
tables, selectedTable, selectTable, tableData, schema,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const TOKEN_KEY = "ppm-auth-token";
|
|
2
|
+
const RELOAD_GUARD_KEY = "ppm-auth-reload-ts";
|
|
2
3
|
|
|
3
4
|
class ApiClient {
|
|
4
5
|
private baseUrl: string;
|
|
@@ -65,7 +66,12 @@ class ApiClient {
|
|
|
65
66
|
private async handleResponse<T>(res: Response): Promise<T> {
|
|
66
67
|
if (res.status === 401) {
|
|
67
68
|
localStorage.removeItem(TOKEN_KEY);
|
|
68
|
-
|
|
69
|
+
// Guard against infinite reload loops: skip reload if we already reloaded within 3s
|
|
70
|
+
const lastReload = Number(sessionStorage.getItem(RELOAD_GUARD_KEY) || "0");
|
|
71
|
+
if (Date.now() - lastReload > 3000) {
|
|
72
|
+
sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now()));
|
|
73
|
+
window.location.reload();
|
|
74
|
+
}
|
|
69
75
|
throw new Error("Unauthorized");
|
|
70
76
|
}
|
|
71
77
|
|