@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
|
@@ -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" />
|
|
@@ -17,6 +17,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
17
17
|
terminal: lazy(() => import("@/components/terminal/terminal-tab").then((m) => ({ default: m.TerminalTab }))),
|
|
18
18
|
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
19
19
|
editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
|
|
20
|
+
database: lazy(() => import("@/components/database/database-viewer").then((m) => ({ default: m.DatabaseViewer }))),
|
|
20
21
|
sqlite: lazy(() => import("@/components/sqlite/sqlite-viewer").then((m) => ({ default: m.SqliteViewer }))),
|
|
21
22
|
postgres: lazy(() => import("@/components/postgres/postgres-viewer").then((m) => ({ default: m.PostgresViewer }))),
|
|
22
23
|
"git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
|
|
@@ -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
|
)}
|
|
@@ -21,7 +21,7 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
|
21
21
|
const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_TAB_OPTIONS.map((o) => [o.type, o.label]));
|
|
22
22
|
|
|
23
23
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
24
|
-
terminal: Terminal, chat: MessageSquare, editor: FileCode, sqlite: Database, postgres: Database,
|
|
24
|
+
terminal: Terminal, chat: MessageSquare, editor: FileCode, database: Database, sqlite: Database, postgres: Database,
|
|
25
25
|
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
|
|
26
26
|
};
|
|
27
27
|
|
|
@@ -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
|
|
|
@@ -95,8 +97,7 @@ export function Sidebar() {
|
|
|
95
97
|
: "border-transparent text-text-secondary hover:text-foreground",
|
|
96
98
|
)}
|
|
97
99
|
>
|
|
98
|
-
<Icon className="size-3.5" />
|
|
99
|
-
{sidebarWidth >= 240 && <span>{tab.label}</span>}
|
|
100
|
+
<Icon className="size-3.5" title={tab.label} />
|
|
100
101
|
</button>
|
|
101
102
|
);
|
|
102
103
|
})}
|
|
@@ -123,6 +124,9 @@ export function Sidebar() {
|
|
|
123
124
|
{sidebarActiveTab === "git" && (
|
|
124
125
|
<GitStatusPanel metadata={{ projectName: activeProject?.name }} />
|
|
125
126
|
)}
|
|
127
|
+
{sidebarActiveTab === "database" && (
|
|
128
|
+
<DatabaseSidebar />
|
|
129
|
+
)}
|
|
126
130
|
{sidebarActiveTab === "settings" && (
|
|
127
131
|
<SettingsTab />
|
|
128
132
|
)}
|
|
@@ -18,6 +18,11 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
18
18
|
default: m.CodeEditor,
|
|
19
19
|
})),
|
|
20
20
|
),
|
|
21
|
+
database: lazy(() =>
|
|
22
|
+
import("@/components/database/database-viewer").then((m) => ({
|
|
23
|
+
default: m.DatabaseViewer,
|
|
24
|
+
})),
|
|
25
|
+
),
|
|
21
26
|
sqlite: lazy(() =>
|
|
22
27
|
import("@/components/sqlite/sqlite-viewer").then((m) => ({
|
|
23
28
|
default: m.SqliteViewer,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useMemo } from "react";
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
2
|
import { Database, Loader2, AlertCircle, Play, ChevronLeft, ChevronRight, Table, RefreshCw } from "lucide-react";
|
|
3
3
|
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
|
|
4
4
|
import CodeMirror from "@uiw/react-codemirror";
|
|
@@ -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} hideTableList={!!connectionId} connectionName={metadata?.connectionName as string | undefined} />;
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/* ---------- Connection Form ---------- */
|
|
@@ -42,38 +44,53 @@ 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, hideTableList, connectionName }: { pg: ReturnType<typeof usePostgres>; initialTable?: string; hideTableList?: boolean; connectionName?: string }) {
|
|
46
48
|
const [queryPanelOpen, setQueryPanelOpen] = useState(false);
|
|
47
49
|
|
|
50
|
+
// Jump to initial table — when hideTableList, go direct (skip table list fetch)
|
|
51
|
+
const didInit = useRef(false);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!initialTable || didInit.current) return;
|
|
54
|
+
if (hideTableList && pg.connected) {
|
|
55
|
+
didInit.current = true;
|
|
56
|
+
pg.selectTable(initialTable);
|
|
57
|
+
} else if (pg.tables.length > 0) {
|
|
58
|
+
const t = pg.tables.find((t) => t.name === initialTable);
|
|
59
|
+
if (t) { didInit.current = true; pg.selectTable(t.name, t.schema); }
|
|
60
|
+
}
|
|
61
|
+
}, [initialTable, pg.connected, pg.tables]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
62
|
+
|
|
48
63
|
return (
|
|
49
64
|
<div className="flex h-full w-full overflow-hidden">
|
|
50
|
-
{/* Table sidebar */}
|
|
51
|
-
|
|
52
|
-
<div className="
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
</div>
|
|
58
|
-
<div className="flex-1 overflow-y-auto">
|
|
59
|
-
{pg.tables.map((t) => (
|
|
60
|
-
<button key={`${t.schema}.${t.name}`} type="button" onClick={() => pg.selectTable(t.name, t.schema)}
|
|
61
|
-
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
|
|
62
|
-
pg.selectedTable === t.name ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"}`}>
|
|
63
|
-
<Table className="size-3 shrink-0" />
|
|
64
|
-
<span className="truncate flex-1">{t.schema !== "public" ? `${t.schema}.` : ""}{t.name}</span>
|
|
65
|
-
<span className="text-[10px] opacity-60">{t.rowCount}</span>
|
|
65
|
+
{/* Table sidebar — hidden when opened from database sidebar */}
|
|
66
|
+
{!hideTableList && (
|
|
67
|
+
<div className="w-48 shrink-0 flex flex-col bg-background overflow-hidden">
|
|
68
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
|
69
|
+
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tables</span>
|
|
70
|
+
<button type="button" onClick={pg.refreshTables} className="text-muted-foreground hover:text-foreground transition-colors" title="Refresh">
|
|
71
|
+
<RefreshCw className="size-3" />
|
|
66
72
|
</button>
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
</div>
|
|
74
|
+
<div className="flex-1 overflow-y-auto">
|
|
75
|
+
{pg.tables.map((t) => (
|
|
76
|
+
<button key={`${t.schema}.${t.name}`} type="button" onClick={() => pg.selectTable(t.name, t.schema)}
|
|
77
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
|
|
78
|
+
pg.selectedTable === t.name ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"}`}>
|
|
79
|
+
<Table className="size-3 shrink-0" />
|
|
80
|
+
<span className="truncate flex-1">{t.schema !== "public" ? `${t.schema}.` : ""}{t.name}</span>
|
|
81
|
+
<span className="text-[10px] opacity-60">{t.rowCount}</span>
|
|
82
|
+
</button>
|
|
83
|
+
))}
|
|
84
|
+
{pg.tables.length === 0 && <p className="px-3 py-4 text-xs text-muted-foreground text-center">No tables found</p>}
|
|
85
|
+
</div>
|
|
69
86
|
</div>
|
|
70
|
-
|
|
87
|
+
)}
|
|
71
88
|
|
|
72
89
|
{/* Main area */}
|
|
73
|
-
<div className=
|
|
90
|
+
<div className={`flex-1 flex flex-col overflow-hidden ${!hideTableList ? "border-l border-border" : ""}`}>
|
|
74
91
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-background shrink-0">
|
|
75
92
|
<Database className="size-3.5 text-muted-foreground" />
|
|
76
|
-
<span className="text-xs text-muted-foreground truncate">PostgreSQL</span>
|
|
93
|
+
<span className="text-xs text-muted-foreground truncate">{connectionName ?? "PostgreSQL"}</span>
|
|
77
94
|
{pg.selectedTable && <span className="text-xs text-muted-foreground">/ {pg.selectedTable}</span>}
|
|
78
95
|
<div className="ml-auto">
|
|
79
96
|
<button type="button" onClick={() => setQueryPanelOpen((v) => !v)}
|
|
@@ -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,16 @@ export function usePostgres() {
|
|
|
46
47
|
}, []);
|
|
47
48
|
|
|
48
49
|
const fetchTables = useCallback(async () => {
|
|
50
|
+
if (unifiedBase) {
|
|
51
|
+
setLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
// Use cached tables (no live DB query) — sidebar handles sync
|
|
54
|
+
const data = await api.get<PgTableInfo[]>(`${unifiedBase}/tables?cached=1`);
|
|
55
|
+
setTables(data);
|
|
56
|
+
} catch (e) { setError((e as Error).message); }
|
|
57
|
+
finally { setLoading(false); }
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
49
60
|
if (!connectionString) return;
|
|
50
61
|
setLoading(true);
|
|
51
62
|
try {
|
|
@@ -56,26 +67,44 @@ export function usePostgres() {
|
|
|
56
67
|
} finally {
|
|
57
68
|
setLoading(false);
|
|
58
69
|
}
|
|
59
|
-
}, [connectionString]);
|
|
70
|
+
}, [unifiedBase, connectionString, selectedTable]);
|
|
71
|
+
|
|
72
|
+
// Auto-connect via unified API when connectionId is provided
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (unifiedBase) {
|
|
75
|
+
setConnected(true);
|
|
76
|
+
fetchTables();
|
|
77
|
+
}
|
|
78
|
+
}, [unifiedBase]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
79
|
|
|
61
80
|
const fetchTableData = useCallback(async (table?: string, tableSchema?: string, p?: number) => {
|
|
62
81
|
const t = table ?? selectedTable;
|
|
63
82
|
const s = tableSchema ?? selectedSchema;
|
|
64
|
-
if (!
|
|
83
|
+
if (!t) return;
|
|
65
84
|
setLoading(true);
|
|
66
85
|
try {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
if (unifiedBase) {
|
|
87
|
+
const [data, cols] = await Promise.all([
|
|
88
|
+
api.get<PgTableData>(`${unifiedBase}/data?table=${encodeURIComponent(t)}&schema=${s}&page=${p ?? page}&limit=100`),
|
|
89
|
+
api.get<PgColumnInfo[]>(`${unifiedBase}/schema?table=${encodeURIComponent(t)}&schema=${s}`),
|
|
90
|
+
]);
|
|
91
|
+
setTableData(data);
|
|
92
|
+
setSchema(cols);
|
|
93
|
+
} else {
|
|
94
|
+
if (!connectionString) return;
|
|
95
|
+
const [data, cols] = await Promise.all([
|
|
96
|
+
api.post<PgTableData>(`${BASE}/data`, { connectionString, table: t, schema: s, page: p ?? page, limit: 100 }),
|
|
97
|
+
api.post<PgColumnInfo[]>(`${BASE}/schema`, { connectionString, table: t, schema: s }),
|
|
98
|
+
]);
|
|
99
|
+
setTableData(data);
|
|
100
|
+
setSchema(cols);
|
|
101
|
+
}
|
|
73
102
|
} catch (e) {
|
|
74
103
|
setError((e as Error).message);
|
|
75
104
|
} finally {
|
|
76
105
|
setLoading(false);
|
|
77
106
|
}
|
|
78
|
-
}, [connectionString, selectedTable, selectedSchema, page]);
|
|
107
|
+
}, [unifiedBase, connectionString, selectedTable, selectedSchema, page]);
|
|
79
108
|
|
|
80
109
|
const selectTable = useCallback((name: string, tableSchema = "public") => {
|
|
81
110
|
setSelectedTable(name);
|
|
@@ -91,11 +120,13 @@ export function usePostgres() {
|
|
|
91
120
|
}, [fetchTableData]);
|
|
92
121
|
|
|
93
122
|
const executeQuery = useCallback(async (sql: string) => {
|
|
94
|
-
if (!connectionString) return;
|
|
123
|
+
if (!unifiedBase && !connectionString) return;
|
|
95
124
|
setQueryLoading(true);
|
|
96
125
|
setQueryError(null);
|
|
97
126
|
try {
|
|
98
|
-
const result =
|
|
127
|
+
const result = unifiedBase
|
|
128
|
+
? await api.post<PgQueryResult>(`${unifiedBase}/query`, { sql })
|
|
129
|
+
: await api.post<PgQueryResult>(`${BASE}/query`, { connectionString, sql });
|
|
99
130
|
setQueryResult(result);
|
|
100
131
|
if (result.changeType === "modify") fetchTableData();
|
|
101
132
|
} catch (e) {
|
|
@@ -103,20 +134,22 @@ export function usePostgres() {
|
|
|
103
134
|
} finally {
|
|
104
135
|
setQueryLoading(false);
|
|
105
136
|
}
|
|
106
|
-
}, [connectionString, fetchTableData]);
|
|
137
|
+
}, [unifiedBase, connectionString, fetchTableData]);
|
|
107
138
|
|
|
108
139
|
const updateCell = useCallback(async (pkColumn: string, pkValue: unknown, column: string, value: unknown) => {
|
|
109
|
-
if (!
|
|
140
|
+
if (!selectedTable) return;
|
|
110
141
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
142
|
+
if (unifiedBase) {
|
|
143
|
+
await api.put(`${unifiedBase}/cell`, { table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
|
|
144
|
+
} else {
|
|
145
|
+
if (!connectionString) return;
|
|
146
|
+
await api.post(`${BASE}/cell`, { connectionString, table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
|
|
147
|
+
}
|
|
115
148
|
fetchTableData();
|
|
116
149
|
} catch (e) {
|
|
117
150
|
setError((e as Error).message);
|
|
118
151
|
}
|
|
119
|
-
}, [connectionString, selectedTable, selectedSchema, fetchTableData]);
|
|
152
|
+
}, [unifiedBase, connectionString, selectedTable, selectedSchema, fetchTableData]);
|
|
120
153
|
|
|
121
154
|
return {
|
|
122
155
|
connectionString, connected, connect,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
1
|
+
import { useState, useEffect, useRef } from "react";
|
|
2
2
|
import { Database, Loader2, AlertCircle } from "lucide-react";
|
|
3
3
|
import { useSqlite } from "./use-sqlite";
|
|
4
4
|
import { SqliteTableList } from "./sqlite-table-list";
|
|
@@ -13,8 +13,26 @@ 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
|
+
connectionName={metadata?.connectionName as string | undefined}
|
|
28
|
+
initialTable={initialTable}
|
|
29
|
+
queryPanelOpen={queryPanelOpen}
|
|
30
|
+
onToggleQueryPanel={() => setQueryPanelOpen((v) => !v)}
|
|
31
|
+
hideTableList
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
18
36
|
if (!filePath || !projectName) {
|
|
19
37
|
return (
|
|
20
38
|
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
|
@@ -34,11 +52,21 @@ export function SqliteViewer({ metadata }: SqliteViewerProps) {
|
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
function SqliteViewerInner({
|
|
37
|
-
projectName, dbPath, queryPanelOpen, onToggleQueryPanel,
|
|
55
|
+
projectName, dbPath, connectionId, connectionName, initialTable, queryPanelOpen, onToggleQueryPanel, hideTableList,
|
|
38
56
|
}: {
|
|
39
|
-
projectName: string; dbPath: string;
|
|
57
|
+
projectName: string; dbPath: string; connectionId?: number; connectionName?: string; initialTable?: string;
|
|
58
|
+
queryPanelOpen: boolean; onToggleQueryPanel: () => void; hideTableList?: boolean;
|
|
40
59
|
}) {
|
|
41
|
-
const sqlite = useSqlite(projectName, dbPath);
|
|
60
|
+
const sqlite = useSqlite(projectName, dbPath, connectionId);
|
|
61
|
+
|
|
62
|
+
// Jump to initial table from sidebar click
|
|
63
|
+
const didInit = useRef(false);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (initialTable && !didInit.current && sqlite.tables.length > 0) {
|
|
66
|
+
didInit.current = true;
|
|
67
|
+
sqlite.selectTable(initialTable);
|
|
68
|
+
}
|
|
69
|
+
}, [initialTable, sqlite.tables]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
42
70
|
|
|
43
71
|
if (sqlite.error && sqlite.tables.length === 0) {
|
|
44
72
|
return (
|
|
@@ -60,20 +88,22 @@ function SqliteViewerInner({
|
|
|
60
88
|
|
|
61
89
|
return (
|
|
62
90
|
<div className="flex h-full w-full overflow-hidden">
|
|
63
|
-
{/* Left sidebar — table list */}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
91
|
+
{/* Left sidebar — table list (hidden when opened from database sidebar) */}
|
|
92
|
+
{!hideTableList && (
|
|
93
|
+
<SqliteTableList
|
|
94
|
+
tables={sqlite.tables}
|
|
95
|
+
selectedTable={sqlite.selectedTable}
|
|
96
|
+
onSelect={sqlite.selectTable}
|
|
97
|
+
onRefresh={sqlite.refreshTables}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
70
100
|
|
|
71
101
|
{/* Main content area */}
|
|
72
|
-
<div className=
|
|
102
|
+
<div className={`flex-1 flex flex-col overflow-hidden ${!hideTableList ? "border-l border-border" : ""}`}>
|
|
73
103
|
{/* Toolbar */}
|
|
74
104
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-background shrink-0">
|
|
75
105
|
<Database className="size-3.5 text-muted-foreground" />
|
|
76
|
-
<span className="text-xs text-muted-foreground truncate">{dbPath}</span>
|
|
106
|
+
<span className="text-xs text-muted-foreground truncate">{connectionName ?? dbPath}</span>
|
|
77
107
|
<span className="text-xs text-muted-foreground">
|
|
78
108
|
{sqlite.selectedTable && `/ ${sqlite.selectedTable}`}
|
|
79
109
|
</span>
|
|
@@ -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,23 +18,26 @@ 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
|
-
// Fetch tables on mount
|
|
26
|
+
// Fetch tables on mount — use cache when connectionId (sidebar handles live sync)
|
|
25
27
|
const fetchTables = useCallback(async () => {
|
|
26
28
|
setLoading(true);
|
|
27
29
|
setError(null);
|
|
28
30
|
try {
|
|
29
|
-
const
|
|
31
|
+
const qsPart = unifiedBase ? "?cached=1" : qs ? `?${qs}` : "";
|
|
32
|
+
const data = await api.get<TableInfo[]>(`${base}/tables${qsPart}`);
|
|
30
33
|
setTables(data);
|
|
31
|
-
if (data.length > 0 && !selectedTable) setSelectedTable(data[0]!.name);
|
|
34
|
+
if (!unifiedBase && data.length > 0 && !selectedTable) setSelectedTable(data[0]!.name);
|
|
32
35
|
} catch (e) {
|
|
33
36
|
setError((e as Error).message);
|
|
34
37
|
} finally {
|
|
35
38
|
setLoading(false);
|
|
36
39
|
}
|
|
37
|
-
}, [base, qs]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [base, qs, unifiedBase]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
38
41
|
|
|
39
42
|
useEffect(() => { fetchTables(); }, [fetchTables]);
|
|
40
43
|
|
|
@@ -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,
|
|
@@ -33,7 +33,7 @@ interface UseChatReturn {
|
|
|
33
33
|
isConnected: boolean;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export function useChat(sessionId: string | null, providerId = "claude
|
|
36
|
+
export function useChat(sessionId: string | null, providerId = "claude", projectName = ""): UseChatReturn {
|
|
37
37
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
38
38
|
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
39
39
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
@@ -12,7 +12,7 @@ interface UseUsageReturn {
|
|
|
12
12
|
refreshUsage: () => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export function useUsage(projectName: string, providerId = "claude
|
|
15
|
+
export function useUsage(projectName: string, providerId = "claude"): UseUsageReturn {
|
|
16
16
|
const [usageInfo, setUsageInfo] = useState<UsageInfo>({});
|
|
17
17
|
const [usageLoading, setUsageLoading] = useState(false);
|
|
18
18
|
const [lastFetchedAt, setLastFetchedAt] = useState<string | null>(null);
|
|
@@ -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
|
|