@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/web/assets/api-client-4Ni0i4Hl.js +1 -0
  3. package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-DkgRZpbj.js} +3 -3
  4. package/dist/web/assets/{code-editor-witrClmz.js → code-editor-CVMeIylx.js} +1 -1
  5. package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
  6. package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-B1vnegRS.js} +1 -1
  7. package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
  8. package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-Bi4PM-z2.js} +1 -1
  9. package/dist/web/assets/index-DSg2VjxL.css +2 -0
  10. package/dist/web/assets/{index-CcXQ5iQw.js → index-DUb5kwfL.js} +6 -6
  11. package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
  12. package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
  13. package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-ChvoCZNm.js} +1 -1
  14. package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
  15. package/dist/web/assets/settings-store-CfB0vCtQ.js +1 -0
  16. package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
  17. package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
  18. package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
  19. package/dist/web/assets/table-DCVKGOr2.js +1 -0
  20. package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-B_75oJaQ.js} +1 -1
  21. package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Dexl3s3E.js} +1 -1
  22. package/dist/web/index.html +8 -8
  23. package/dist/web/sw.js +1 -1
  24. package/docs/codebase-summary.md +41 -14
  25. package/docs/project-roadmap.md +31 -6
  26. package/docs/system-architecture.md +222 -7
  27. package/package.json +1 -1
  28. package/src/cli/commands/db-cmd.ts +21 -4
  29. package/src/server/index.ts +6 -0
  30. package/src/server/routes/chat.ts +2 -2
  31. package/src/server/routes/database.ts +261 -0
  32. package/src/services/database/adapter-registry.ts +13 -0
  33. package/src/services/database/init-adapters.ts +9 -0
  34. package/src/services/database/postgres-adapter.ts +42 -0
  35. package/src/services/database/readonly-check.ts +17 -0
  36. package/src/services/database/sqlite-adapter.ts +55 -0
  37. package/src/services/db.service.ts +77 -4
  38. package/src/services/table-cache.service.ts +75 -0
  39. package/src/types/config.ts +10 -2
  40. package/src/types/database.ts +50 -0
  41. package/src/web/app.tsx +9 -4
  42. package/src/web/components/chat/tool-cards.tsx +2 -2
  43. package/src/web/components/database/connection-color-picker.tsx +67 -0
  44. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  45. package/src/web/components/database/connection-list.tsx +257 -0
  46. package/src/web/components/database/database-sidebar.tsx +89 -0
  47. package/src/web/components/database/database-viewer.tsx +228 -0
  48. package/src/web/components/database/use-connections.ts +92 -0
  49. package/src/web/components/database/use-database.ts +117 -0
  50. package/src/web/components/layout/command-palette.tsx +56 -6
  51. package/src/web/components/layout/draggable-tab.tsx +13 -2
  52. package/src/web/components/layout/editor-panel.tsx +1 -0
  53. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  54. package/src/web/components/layout/mobile-nav.tsx +1 -1
  55. package/src/web/components/layout/sidebar.tsx +7 -3
  56. package/src/web/components/layout/tab-bar.tsx +1 -0
  57. package/src/web/components/layout/tab-content.tsx +5 -0
  58. package/src/web/components/postgres/postgres-viewer.tsx +42 -25
  59. package/src/web/components/postgres/use-postgres.ts +54 -21
  60. package/src/web/components/settings/ai-settings-section.tsx +0 -1
  61. package/src/web/components/sqlite/sqlite-viewer.tsx +43 -13
  62. package/src/web/components/sqlite/use-sqlite.ts +24 -15
  63. package/src/web/hooks/use-chat.ts +1 -1
  64. package/src/web/hooks/use-usage.ts +1 -1
  65. package/src/web/lib/api-client.ts +7 -1
  66. package/src/web/lib/color-utils.ts +23 -0
  67. package/src/web/stores/settings-store.ts +2 -2
  68. package/src/web/stores/tab-store.ts +1 -0
  69. package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
  70. package/dist/web/assets/dist-PpKqMvyx.js +0 -16
  71. package/dist/web/assets/index-DyEgsogR.css +0 -2
  72. package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
  73. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
  74. package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
  75. package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
  76. package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
  77. /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
  78. /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
  )}
@@ -22,6 +22,7 @@ const TAB_ICONS: Record<TabType, React.ElementType> = {
22
22
  terminal: Terminal,
23
23
  chat: MessageSquare,
24
24
  editor: FileCode,
25
+ database: Database,
25
26
  sqlite: Database,
26
27
  postgres: Database,
27
28
  "git-graph": GitBranch,
@@ -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 pg = usePostgres();
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
- <div className="w-48 shrink-0 flex flex-col bg-background overflow-hidden">
52
- <div className="flex items-center justify-between px-3 py-2 border-b border-border">
53
- <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tables</span>
54
- <button type="button" onClick={pg.refreshTables} className="text-muted-foreground hover:text-foreground transition-colors" title="Refresh">
55
- <RefreshCw className="size-3" />
56
- </button>
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
- {pg.tables.length === 0 && <p className="px-3 py-4 text-xs text-muted-foreground text-center">No tables found</p>}
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
- </div>
87
+ )}
71
88
 
72
89
  {/* Main area */}
73
- <div className="flex-1 flex flex-col overflow-hidden border-l border-border">
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 (!connectionString || !t) return;
83
+ if (!t) return;
65
84
  setLoading(true);
66
85
  try {
67
- const [data, cols] = await Promise.all([
68
- api.post<PgTableData>(`${BASE}/data`, { connectionString, table: t, schema: s, page: p ?? page, limit: 100 }),
69
- api.post<PgColumnInfo[]>(`${BASE}/schema`, { connectionString, table: t, schema: s }),
70
- ]);
71
- setTableData(data);
72
- setSchema(cols);
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 = await api.post<PgQueryResult>(`${BASE}/query`, { connectionString, sql });
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 (!connectionString || !selectedTable) return;
140
+ if (!selectedTable) return;
110
141
  try {
111
- await api.post(`${BASE}/cell`, {
112
- connectionString, table: selectedTable, schema: selectedSchema,
113
- pkColumn, pkValue, column, value,
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,
@@ -20,7 +20,6 @@ const EFFORT_OPTIONS = [
20
20
  { value: "low", label: "Low" },
21
21
  { value: "medium", label: "Medium" },
22
22
  { value: "high", label: "High" },
23
- { value: "max", label: "Max" },
24
23
  ];
25
24
 
26
25
  export function AISettingsSection({ compact }: { compact?: boolean } = {}) {
@@ -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; queryPanelOpen: boolean; onToggleQueryPanel: () => void;
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
- <SqliteTableList
65
- tables={sqlite.tables}
66
- selectedTable={sqlite.selectedTable}
67
- onSelect={sqlite.selectTable}
68
- onRefresh={sqlite.refreshTables}
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="flex-1 flex flex-col overflow-hidden border-l border-border">
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
- const base = `${projectUrl(projectName)}/sqlite`;
22
- const qs = `path=${encodeURIComponent(dbPath)}`;
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 data = await api.get<TableInfo[]>(`${base}/tables?${qs}`);
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?${qs}&table=${encodeURIComponent(selectedTable)}&page=${page}&limit=100`),
48
- api.get<ColumnInfo[]>(`${base}/schema?${qs}&table=${encodeURIComponent(selectedTable)}`),
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 result = await api.post<QueryResult>(`${base}/query`, { path: dbPath, sql });
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(); // Refresh table after modification
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
- await api.put(`${base}/cell`, { path: dbPath, table: selectedTable, rowid, column, value });
85
- fetchTableData(); // Refresh
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-sdk", projectName = ""): UseChatReturn {
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-sdk"): UseUsageReturn {
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
- window.location.reload();
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