@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-CDVCDw_H.js} +3 -3
  4. package/dist/web/assets/{code-editor-witrClmz.js → code-editor-wmS73ejX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-BsYccTx1.js} +1 -1
  6. package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-BbWb6_Jq.js} +1 -1
  7. package/dist/web/assets/{index-CcXQ5iQw.js → index-DhuAmTQ1.js} +6 -6
  8. package/dist/web/assets/index-aIGuIMQ8.css +2 -0
  9. package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-aPdw9BhU.js} +1 -1
  11. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
  12. package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
  13. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  14. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
  15. package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-3tDV4RCn.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/docs/codebase-summary.md +41 -14
  20. package/docs/project-roadmap.md +31 -6
  21. package/docs/system-architecture.md +222 -7
  22. package/package.json +1 -1
  23. package/src/cli/commands/db-cmd.ts +21 -4
  24. package/src/server/index.ts +6 -0
  25. package/src/server/routes/database.ts +259 -0
  26. package/src/services/database/adapter-registry.ts +13 -0
  27. package/src/services/database/init-adapters.ts +9 -0
  28. package/src/services/database/postgres-adapter.ts +42 -0
  29. package/src/services/database/readonly-check.ts +17 -0
  30. package/src/services/database/sqlite-adapter.ts +55 -0
  31. package/src/services/db.service.ts +77 -4
  32. package/src/services/table-cache.service.ts +75 -0
  33. package/src/types/database.ts +50 -0
  34. package/src/web/app.tsx +9 -4
  35. package/src/web/components/database/connection-color-picker.tsx +67 -0
  36. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  37. package/src/web/components/database/connection-list.tsx +208 -0
  38. package/src/web/components/database/database-sidebar.tsx +100 -0
  39. package/src/web/components/database/use-connections.ts +99 -0
  40. package/src/web/components/layout/command-palette.tsx +57 -6
  41. package/src/web/components/layout/draggable-tab.tsx +13 -2
  42. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  43. package/src/web/components/layout/sidebar.tsx +6 -1
  44. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  45. package/src/web/components/postgres/use-postgres.ts +57 -21
  46. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  47. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  48. package/src/web/lib/api-client.ts +7 -1
  49. package/src/web/lib/color-utils.ts +23 -0
  50. package/src/web/stores/settings-store.ts +2 -2
  51. package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
  52. package/dist/web/assets/index-DyEgsogR.css +0 -2
  53. package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
  54. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
  55. package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
  56. package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
  57. 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); // show all if query ends with /
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
- return allCommands.filter(
262
+ const matched = allCommands.filter(
221
263
  (c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
222
264
  );
223
- }, [allCommands, actionCommands, fsCommands, query]);
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 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} />;
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 (!connectionString || !t) return;
86
+ if (!t) return;
65
87
  setLoading(true);
66
88
  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);
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 = await api.post<PgQueryResult>(`${BASE}/query`, { connectionString, sql });
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 (!connectionString || !selectedTable) return;
143
+ if (!selectedTable) return;
110
144
  try {
111
- await api.post(`${BASE}/cell`, {
112
- connectionString, table: selectedTable, schema: selectedSchema,
113
- pkColumn, pkValue, column, value,
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; queryPanelOpen: boolean; onToggleQueryPanel: () => void;
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
- 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
26
  // Fetch tables on mount
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 = 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?${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,
@@ -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