@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
@@ -0,0 +1,89 @@
1
+ import { useState } from "react";
2
+ import { Plus } from "lucide-react";
3
+ import { useTabStore } from "@/stores/tab-store";
4
+ import { ConnectionList } from "./connection-list";
5
+ import { ConnectionFormDialog } from "./connection-form-dialog";
6
+ import { useConnections, type Connection, type CreateConnectionData, type UpdateConnectionData } from "./use-connections";
7
+
8
+ export function DatabaseSidebar() {
9
+ const { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables } = useConnections();
10
+ const openTab = useTabStore((s) => s.openTab);
11
+ const [addOpen, setAddOpen] = useState(false);
12
+ const [editConn, setEditConn] = useState<Connection | null>(null);
13
+
14
+ const handleOpenTable = (conn: Connection, tableName: string, schemaName: string) => {
15
+ openTab({
16
+ type: "database",
17
+ title: `${conn.name} · ${tableName}`,
18
+ projectId: null,
19
+ closable: true,
20
+ metadata: { connectionId: conn.id, connectionName: conn.name, dbType: conn.type, tableName, schemaName, connectionColor: conn.color },
21
+ });
22
+ };
23
+
24
+ const handleDelete = async (id: number) => {
25
+ if (!confirm("Delete this connection?")) return;
26
+ await deleteConnection(id);
27
+ };
28
+
29
+ const handleCreate = async (data: CreateConnectionData) => {
30
+ const created = await createConnection(data);
31
+ // Auto-refresh tables after creating (use return value to avoid stale closure)
32
+ if (created) refreshTables(created.id).catch(() => {});
33
+ };
34
+
35
+ const handleUpdate = async (id: number, data: UpdateConnectionData) => {
36
+ await updateConnection(id, data);
37
+ };
38
+
39
+ return (
40
+ <div className="flex flex-col h-full">
41
+ {/* Header */}
42
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
43
+ <span className="text-[10px] font-semibold text-text-subtle uppercase tracking-wider">Database</span>
44
+ <button
45
+ onClick={() => setAddOpen(true)}
46
+ className="flex items-center justify-center size-5 rounded hover:bg-surface-elevated transition-colors text-text-subtle hover:text-foreground"
47
+ title="Add connection"
48
+ >
49
+ <Plus className="size-3.5" />
50
+ </button>
51
+ </div>
52
+
53
+ {/* Connection list */}
54
+ <div className="flex-1 overflow-y-auto min-h-0">
55
+ {loading ? (
56
+ <p className="px-4 py-6 text-xs text-text-subtle text-center">Loading…</p>
57
+ ) : (
58
+ <ConnectionList
59
+ connections={connections}
60
+ cachedTables={cachedTables}
61
+ onOpenTable={handleOpenTable}
62
+ onRefreshTables={refreshTables}
63
+ onEdit={setEditConn}
64
+ onDelete={handleDelete}
65
+ />
66
+ )}
67
+ </div>
68
+
69
+ {/* Add dialog */}
70
+ <ConnectionFormDialog
71
+ open={addOpen}
72
+ onClose={() => setAddOpen(false)}
73
+ onSave={handleCreate}
74
+ onTest={() => Promise.resolve({ ok: false, error: "Save connection first" })}
75
+ />
76
+
77
+ {/* Edit dialog */}
78
+ {editConn && (
79
+ <ConnectionFormDialog
80
+ open={!!editConn}
81
+ onClose={() => setEditConn(null)}
82
+ connection={editConn}
83
+ onUpdate={handleUpdate}
84
+ onTest={(id) => testConnection(id)}
85
+ />
86
+ )}
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,228 @@
1
+ import { useState, useCallback, useMemo, useEffect, useRef } from "react";
2
+ import { Database, Loader2, Play, ChevronLeft, ChevronRight } from "lucide-react";
3
+ import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from "@tanstack/react-table";
4
+ import CodeMirror from "@uiw/react-codemirror";
5
+ import { sql, PostgreSQL, SQLite } from "@codemirror/lang-sql";
6
+ import { useDatabase, type DbColumnInfo, type DbQueryResult } from "./use-database";
7
+
8
+ const SQL_DIALECTS: Record<string, typeof PostgreSQL> = { postgres: PostgreSQL, sqlite: SQLite };
9
+
10
+ interface Props { metadata?: Record<string, unknown>; tabId?: string }
11
+
12
+ /** Generic database viewer — works for any DB type via unified API */
13
+ export function DatabaseViewer({ metadata }: Props) {
14
+ const connectionId = metadata?.connectionId as number;
15
+ const connectionName = metadata?.connectionName as string | undefined;
16
+ const dbType = (metadata?.dbType as string) ?? "postgres";
17
+ const initialTable = metadata?.tableName as string | undefined;
18
+ const initialSchema = (metadata?.schemaName as string) ?? "public";
19
+
20
+ const db = useDatabase(connectionId);
21
+ const [queryPanelOpen, setQueryPanelOpen] = useState(false);
22
+
23
+ // Jump to initial table
24
+ const didInit = useRef(false);
25
+ useEffect(() => {
26
+ if (!initialTable || didInit.current) return;
27
+ didInit.current = true;
28
+ db.selectTable(initialTable, initialSchema);
29
+ }, [initialTable, initialSchema]); // eslint-disable-line react-hooks/exhaustive-deps
30
+
31
+ return (
32
+ <div className="flex h-full w-full overflow-hidden">
33
+ <div className="flex-1 flex flex-col overflow-hidden">
34
+ {/* Toolbar */}
35
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-background shrink-0">
36
+ <Database className="size-3.5 text-muted-foreground" />
37
+ <span className="text-xs text-muted-foreground truncate">{connectionName ?? "Database"}</span>
38
+ {db.selectedTable && <span className="text-xs text-muted-foreground">/ {db.selectedTable}</span>}
39
+ <div className="ml-auto">
40
+ <button type="button" onClick={() => setQueryPanelOpen((v) => !v)}
41
+ className={`px-2 py-1 rounded text-xs transition-colors ${queryPanelOpen ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}>
42
+ SQL
43
+ </button>
44
+ </div>
45
+ </div>
46
+
47
+ {/* Data grid */}
48
+ <div className={`flex-1 overflow-hidden ${queryPanelOpen ? "max-h-[60%]" : ""}`}>
49
+ <DataGrid tableData={db.tableData} schema={db.schema} loading={db.loading}
50
+ page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} />
51
+ </div>
52
+
53
+ {/* Query editor */}
54
+ {queryPanelOpen && (
55
+ <div className="border-t border-border h-[40%] shrink-0">
56
+ <QueryEditor dialect={SQL_DIALECTS[dbType] ?? PostgreSQL}
57
+ onExecute={db.executeQuery} result={db.queryResult} error={db.queryError} loading={db.queryLoading} />
58
+ </div>
59
+ )}
60
+ </div>
61
+ </div>
62
+ );
63
+ }
64
+
65
+ /* ---------- Data Grid ---------- */
66
+ function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: {
67
+ tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
68
+ schema: DbColumnInfo[]; loading: boolean; page: number;
69
+ onPageChange: (p: number) => void;
70
+ onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
71
+ }) {
72
+ const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
73
+ const [editValue, setEditValue] = useState("");
74
+
75
+ const pkCol = useMemo(() => schema.find((c) => c.pk)?.name ?? null, [schema]);
76
+
77
+ const startEdit = useCallback((rowIdx: number, col: string, val: unknown) => {
78
+ setEditingCell({ rowIdx, col });
79
+ setEditValue(val == null ? "" : String(val));
80
+ }, []);
81
+
82
+ const commitEdit = useCallback(() => {
83
+ if (!editingCell || !tableData || !pkCol) return;
84
+ const row = tableData.rows[editingCell.rowIdx];
85
+ if (!row) return;
86
+ const oldVal = row[editingCell.col];
87
+ if (String(oldVal ?? "") !== editValue) {
88
+ onCellUpdate(pkCol, row[pkCol], editingCell.col, editValue === "" ? null : editValue);
89
+ }
90
+ setEditingCell(null);
91
+ }, [editingCell, editValue, tableData, pkCol, onCellUpdate]);
92
+
93
+ const cancelEdit = useCallback(() => setEditingCell(null), []);
94
+
95
+ const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
96
+ (tableData?.columns ?? []).map((col) => ({
97
+ id: col,
98
+ accessorFn: (row) => row[col],
99
+ header: () => <span className={schema.find((c) => c.name === col)?.pk ? "font-bold" : ""}>{col}</span>,
100
+ cell: ({ row, getValue }) => {
101
+ const isEditing = editingCell?.rowIdx === row.index && editingCell?.col === col;
102
+ const val = getValue();
103
+ if (isEditing) {
104
+ return (
105
+ <input autoFocus className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
106
+ value={editValue} onChange={(e) => setEditValue(e.target.value)}
107
+ onBlur={commitEdit} onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") cancelEdit(); }} />
108
+ );
109
+ }
110
+ return (
111
+ <span className={`cursor-pointer truncate block ${val == null ? "text-muted-foreground/40 italic" : ""}`}
112
+ onDoubleClick={() => pkCol && startEdit(row.index, col, val)} title={val == null ? "NULL" : String(val)}>
113
+ {val == null ? "NULL" : String(val)}
114
+ </span>
115
+ );
116
+ },
117
+ })),
118
+ [tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol]);
119
+
120
+ const table = useReactTable({ data: tableData?.rows ?? [], columns, getCoreRowModel: getCoreRowModel() });
121
+
122
+ if (!tableData) {
123
+ return (
124
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
125
+ {loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ const totalPages = Math.ceil(tableData.total / tableData.limit) || 1;
131
+
132
+ return (
133
+ <div className="flex flex-col h-full overflow-hidden">
134
+ <div className="flex-1 overflow-auto">
135
+ <table className="w-full text-xs border-collapse">
136
+ <thead className="sticky top-0 z-10 bg-muted">
137
+ {table.getHeaderGroups().map((hg) => (
138
+ <tr key={hg.id}>
139
+ {hg.headers.map((h) => (
140
+ <th key={h.id} className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
141
+ {flexRender(h.column.columnDef.header, h.getContext())}
142
+ </th>
143
+ ))}
144
+ </tr>
145
+ ))}
146
+ </thead>
147
+ <tbody>
148
+ {table.getRowModel().rows.map((row) => (
149
+ <tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
150
+ {row.getVisibleCells().map((cell) => (
151
+ <td key={cell.id} className="px-2 py-1 max-w-[300px]">
152
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
153
+ </td>
154
+ ))}
155
+ </tr>
156
+ ))}
157
+ {tableData.rows.length === 0 && (
158
+ <tr><td colSpan={tableData.columns.length} className="px-2 py-8 text-center text-muted-foreground">No data</td></tr>
159
+ )}
160
+ </tbody>
161
+ </table>
162
+ </div>
163
+ <div className="flex items-center justify-between px-3 py-1.5 border-t border-border bg-background shrink-0 text-xs text-muted-foreground">
164
+ <span>{tableData.total.toLocaleString()} rows</span>
165
+ <div className="flex items-center gap-2">
166
+ <button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
167
+ <ChevronLeft className="size-3.5" />
168
+ </button>
169
+ <span>{page} / {totalPages}</span>
170
+ <button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
171
+ <ChevronRight className="size-3.5" />
172
+ </button>
173
+ </div>
174
+ </div>
175
+ </div>
176
+ );
177
+ }
178
+
179
+ /* ---------- Query Editor ---------- */
180
+ function QueryEditor({ dialect, onExecute, result, error, loading }: {
181
+ dialect: typeof PostgreSQL; onExecute: (sql: string) => void; result: DbQueryResult | null; error: string | null; loading: boolean;
182
+ }) {
183
+ const [query, setQuery] = useState("SELECT * FROM ");
184
+
185
+ const handleExecute = useCallback(() => { const t = query.trim(); if (t) onExecute(t); }, [query, onExecute]);
186
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
187
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleExecute(); }
188
+ }, [handleExecute]);
189
+
190
+ return (
191
+ <div className="flex flex-col h-full overflow-hidden">
192
+ <div className="flex items-start gap-1 border-b border-border bg-background" onKeyDown={handleKeyDown}>
193
+ <div className="flex-1 max-h-[120px] overflow-auto">
194
+ <CodeMirror value={query} onChange={setQuery} extensions={[sql({ dialect })]}
195
+ basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
196
+ className="text-xs [&_.cm-editor]:!outline-none [&_.cm-scroller]:!overflow-auto" />
197
+ </div>
198
+ <button type="button" onClick={handleExecute} disabled={loading} title="Execute (Cmd+Enter)"
199
+ className="shrink-0 m-1 p-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors">
200
+ {loading ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
201
+ </button>
202
+ </div>
203
+ <div className="flex-1 overflow-auto text-xs">
204
+ {error && <div className="px-3 py-2 text-destructive bg-destructive/5">{error}</div>}
205
+ {result?.changeType === "modify" && <div className="px-3 py-2 text-green-500">Query executed. {result.rowsAffected} row(s) affected.</div>}
206
+ {result?.changeType === "select" && result.rows.length > 0 && (
207
+ <table className="w-full border-collapse">
208
+ <thead className="sticky top-0 bg-muted">
209
+ <tr>{result.columns.map((c) => <th key={c} className="px-2 py-1 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">{c}</th>)}</tr>
210
+ </thead>
211
+ <tbody>
212
+ {result.rows.map((row, i) => (
213
+ <tr key={i} className="hover:bg-muted/30 border-b border-border/50">
214
+ {result.columns.map((c) => (
215
+ <td key={c} className="px-2 py-1 max-w-[300px] truncate" title={row[c] == null ? "NULL" : String(row[c])}>
216
+ {row[c] == null ? <span className="text-muted-foreground/40 italic">NULL</span> : String(row[c])}
217
+ </td>
218
+ ))}
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ )}
224
+ {result?.changeType === "select" && result.rows.length === 0 && <div className="px-3 py-2 text-muted-foreground">No results</div>}
225
+ </div>
226
+ </div>
227
+ );
228
+ }
@@ -0,0 +1,92 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { api } from "../../lib/api-client";
3
+
4
+ export interface Connection {
5
+ id: number;
6
+ type: "sqlite" | "postgres";
7
+ name: string;
8
+ group_name: string | null;
9
+ color: string | null;
10
+ readonly: number;
11
+ sort_order: number;
12
+ created_at: string;
13
+ updated_at: string;
14
+ }
15
+
16
+ export interface CachedTable {
17
+ connectionId: number;
18
+ tableName: string;
19
+ schemaName: string;
20
+ rowCount: number;
21
+ cachedAt: string;
22
+ }
23
+
24
+ export interface CreateConnectionData {
25
+ type: "sqlite" | "postgres";
26
+ name: string;
27
+ connectionConfig: { type: string; path?: string; connectionString?: string };
28
+ groupName?: string;
29
+ color?: string;
30
+ }
31
+
32
+ export interface UpdateConnectionData {
33
+ name?: string;
34
+ connectionConfig?: { type: string; path?: string; connectionString?: string };
35
+ groupName?: string | null;
36
+ color?: string | null;
37
+ readonly?: number;
38
+ }
39
+
40
+ export function useConnections() {
41
+ const [connections, setConnections] = useState<Connection[]>([]);
42
+ const [loading, setLoading] = useState(true);
43
+ const [cachedTables, setCachedTables] = useState<Map<number, CachedTable[]>>(new Map());
44
+
45
+ const fetchConnections = useCallback(async () => {
46
+ try {
47
+ const data = await api.get<Connection[]>("/api/db/connections");
48
+ setConnections(data);
49
+ } catch {
50
+ // ignore — server may not be ready
51
+ } finally {
52
+ setLoading(false);
53
+ }
54
+ }, []);
55
+
56
+ useEffect(() => { fetchConnections(); }, [fetchConnections]);
57
+
58
+ const createConnection = useCallback(async (data: CreateConnectionData): Promise<Connection> => {
59
+ const conn = await api.post<Connection>("/api/db/connections", data);
60
+ setConnections((prev) => [...prev, conn]);
61
+ return conn;
62
+ }, []);
63
+
64
+ const updateConnection = useCallback(async (id: number, data: UpdateConnectionData): Promise<void> => {
65
+ const updated = await api.put<Connection>(`/api/db/connections/${id}`, data);
66
+ setConnections((prev) => prev.map((c) => (c.id === id ? updated : c)));
67
+ }, []);
68
+
69
+ const deleteConnection = useCallback(async (id: number): Promise<void> => {
70
+ await api.del(`/api/db/connections/${id}`);
71
+ setConnections((prev) => prev.filter((c) => c.id !== id));
72
+ setCachedTables((prev) => { const m = new Map(prev); m.delete(id); return m; });
73
+ }, []);
74
+
75
+ const testConnection = useCallback(async (id: number): Promise<{ ok: boolean; error?: string }> => {
76
+ return api.post(`/api/db/connections/${id}/test`);
77
+ }, []);
78
+
79
+ const refreshTables = useCallback(async (id: number): Promise<void> => {
80
+ const raw = await api.get<{ name: string; schema: string; rowCount: number }[]>(`/api/db/connections/${id}/tables`);
81
+ const tables: CachedTable[] = raw.map((t) => ({
82
+ connectionId: id,
83
+ tableName: t.name,
84
+ schemaName: t.schema,
85
+ rowCount: t.rowCount,
86
+ cachedAt: new Date().toISOString(),
87
+ }));
88
+ setCachedTables((prev) => new Map(prev).set(id, tables));
89
+ }, []);
90
+
91
+ return { connections, loading, cachedTables, createConnection, updateConnection, deleteConnection, testConnection, refreshTables };
92
+ }
@@ -0,0 +1,117 @@
1
+ import { useState, useCallback } from "react";
2
+ import { api } from "@/lib/api-client";
3
+
4
+ export interface DbTableInfo { name: string; schema: string; rowCount: number }
5
+ export interface DbColumnInfo { name: string; type: string; nullable: boolean; pk: boolean; defaultValue: string | null }
6
+ export interface DbQueryResult { columns: string[]; rows: Record<string, unknown>[]; rowsAffected: number; changeType: "select" | "modify" }
7
+ interface DbTableData { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }
8
+
9
+ /** SessionStorage cache key for table data */
10
+ function cacheKey(connectionId: number, table: string, schema: string, page: number) {
11
+ return `ppm-db-${connectionId}-${schema}.${table}-p${page}`;
12
+ }
13
+
14
+ function readCache(connectionId: number, table: string, schema: string, page: number): { data: DbTableData; cols: DbColumnInfo[] } | null {
15
+ try {
16
+ const raw = sessionStorage.getItem(cacheKey(connectionId, table, schema, page));
17
+ return raw ? JSON.parse(raw) : null;
18
+ } catch { return null; }
19
+ }
20
+
21
+ function writeCache(connectionId: number, table: string, schema: string, page: number, data: DbTableData, cols: DbColumnInfo[]) {
22
+ try { sessionStorage.setItem(cacheKey(connectionId, table, schema, page), JSON.stringify({ data, cols })); } catch { /* quota */ }
23
+ }
24
+
25
+ /**
26
+ * Generic database hook for unified API (/api/db/connections/:id/...).
27
+ * Works for any DB type (postgres, sqlite, mysql, etc.) via adapter pattern.
28
+ * No auto-fetch on mount — viewer calls selectTable() to start loading.
29
+ */
30
+ export function useDatabase(connectionId: number) {
31
+ const base = `/api/db/connections/${connectionId}`;
32
+ const [selectedTable, setSelectedTable] = useState<string | null>(null);
33
+ const [selectedSchema, setSelectedSchema] = useState("public");
34
+ const [tableData, setTableData] = useState<DbTableData | null>(null);
35
+ const [schema, setSchema] = useState<DbColumnInfo[]>([]);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState<string | null>(null);
38
+ const [page, setPageState] = useState(1);
39
+ const [queryResult, setQueryResult] = useState<DbQueryResult | null>(null);
40
+ const [queryError, setQueryError] = useState<string | null>(null);
41
+ const [queryLoading, setQueryLoading] = useState(false);
42
+
43
+ // Fetch table data + schema for current selection
44
+ const fetchTableData = useCallback(async (table?: string, tableSchema?: string, p?: number) => {
45
+ const t = table ?? selectedTable;
46
+ const s = tableSchema ?? selectedSchema;
47
+ if (!t) return;
48
+ setLoading(true);
49
+ try {
50
+ const [data, cols] = await Promise.all([
51
+ api.get<DbTableData>(`${base}/data?table=${encodeURIComponent(t)}&schema=${s}&page=${p ?? page}&limit=100`),
52
+ api.get<DbColumnInfo[]>(`${base}/schema?table=${encodeURIComponent(t)}&schema=${s}`),
53
+ ]);
54
+ setTableData(data);
55
+ setSchema(cols);
56
+ writeCache(connectionId, t, s, p ?? page, data, cols);
57
+ } catch (e) {
58
+ setError((e as Error).message);
59
+ } finally {
60
+ setLoading(false);
61
+ }
62
+ }, [base, connectionId, selectedTable, selectedSchema, page]);
63
+
64
+ const selectTable = useCallback((name: string, tableSchema = "public") => {
65
+ setSelectedTable(name);
66
+ setSelectedSchema(tableSchema);
67
+ setPageState(1);
68
+ setQueryResult(null);
69
+ // Show cached data instantly, then refresh in background
70
+ const cached = readCache(connectionId, name, tableSchema, 1);
71
+ if (cached) {
72
+ setTableData(cached.data);
73
+ setSchema(cached.cols);
74
+ setLoading(false);
75
+ // Still fetch fresh data in background
76
+ fetchTableData(name, tableSchema, 1);
77
+ } else {
78
+ fetchTableData(name, tableSchema, 1);
79
+ }
80
+ }, [connectionId, fetchTableData]);
81
+
82
+ const changePage = useCallback((p: number) => {
83
+ setPageState(p);
84
+ fetchTableData(undefined, undefined, p);
85
+ }, [fetchTableData]);
86
+
87
+ const executeQuery = useCallback(async (sqlText: string) => {
88
+ setQueryLoading(true);
89
+ setQueryError(null);
90
+ try {
91
+ const result = await api.post<DbQueryResult>(`${base}/query`, { sql: sqlText });
92
+ setQueryResult(result);
93
+ if (result.changeType === "modify") fetchTableData();
94
+ } catch (e) {
95
+ setQueryError((e as Error).message);
96
+ } finally {
97
+ setQueryLoading(false);
98
+ }
99
+ }, [base, fetchTableData]);
100
+
101
+ const updateCell = useCallback(async (pkColumn: string, pkValue: unknown, column: string, value: unknown) => {
102
+ if (!selectedTable) return;
103
+ try {
104
+ await api.put(`${base}/cell`, { table: selectedTable, schema: selectedSchema, pkColumn, pkValue, column, value });
105
+ fetchTableData();
106
+ } catch (e) {
107
+ setError((e as Error).message);
108
+ }
109
+ }, [base, selectedTable, selectedSchema, fetchTableData]);
110
+
111
+ return {
112
+ selectedTable, selectTable, tableData, schema,
113
+ loading, error, page, setPage: changePage,
114
+ queryResult, queryError, queryLoading, executeQuery,
115
+ updateCell, refreshData: fetchTableData,
116
+ };
117
+ }
@@ -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,18 @@ 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 data = await api.get<DbSearchResult[]>(`/api/db/search?q=${encodeURIComponent(query.trim())}`);
123
+ setDbResults(data ?? []);
124
+ } catch { setDbResults([]); }
125
+ }, 300);
126
+ return () => clearTimeout(timer);
127
+ }, [query]);
128
+
106
129
  // Action commands
107
130
  const actionCommands = useMemo<CommandItem[]>(() => {
108
131
  const projectId = activeProject?.name ?? null;
@@ -186,6 +209,25 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
186
209
  });
187
210
  }, [fsFiles, activeProject, openTab, onClose]);
188
211
 
212
+ const dbCommands = useMemo<CommandItem[]>(() => dbResults.map((r) => ({
213
+ id: `db:${r.connectionId}:${r.schemaName}.${r.tableName}`,
214
+ label: r.tableName,
215
+ hint: `${r.connectionName} (${r.connectionType === "postgres" ? "PG" : "SQLite"})`,
216
+ icon: Database,
217
+ group: "db" as const,
218
+ connectionColor: r.connectionColor,
219
+ action: () => {
220
+ openTab({
221
+ type: "database",
222
+ title: `${r.connectionName} · ${r.tableName}`,
223
+ projectId: null,
224
+ closable: true,
225
+ metadata: { connectionId: r.connectionId, connectionName: r.connectionName, dbType: r.connectionType, tableName: r.tableName, schemaName: r.schemaName, connectionColor: r.connectionColor },
226
+ });
227
+ onClose();
228
+ },
229
+ })), [dbResults, openTab, onClose]);
230
+
189
231
  const allCommands = useMemo(
190
232
  () => [...actionCommands, ...fileCommands],
191
233
  [actionCommands, fileCommands],
@@ -194,10 +236,9 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
194
236
  const filtered = useMemo(() => {
195
237
  // Path mode — search filesystem results using filename portion only
196
238
  if (isPathQuery(query)) {
197
- // Extract the part after the last / as the filename filter
198
239
  const lastSlash = query.lastIndexOf("/");
199
240
  const fileFilter = lastSlash >= 0 ? query.slice(lastSlash + 1).toLowerCase() : "";
200
- if (!fileFilter) return fsCommands.slice(0, 50); // show all if query ends with /
241
+ if (!fileFilter) return fsCommands.slice(0, 50);
201
242
  return fsCommands.filter((c) => {
202
243
  const name = c.label.toLowerCase();
203
244
  const path = (c.keywords ?? "").toLowerCase();
@@ -217,10 +258,12 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
217
258
  }
218
259
  return true;
219
260
  };
220
- return allCommands.filter(
261
+ const matched = allCommands.filter(
221
262
  (c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
222
263
  );
223
- }, [allCommands, actionCommands, fsCommands, query]);
264
+ // Prepend DB results (already filtered server-side) when query is 2+ chars
265
+ return query.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
266
+ }, [allCommands, actionCommands, fsCommands, dbCommands, query]);
224
267
 
225
268
  // Reset state when opening
226
269
  useEffect(() => {
@@ -228,6 +271,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
228
271
  setQuery(initialQuery || "");
229
272
  setSelectedIdx(0);
230
273
  setFsFiles([]);
274
+ setDbResults([]);
231
275
  requestAnimationFrame(() => inputRef.current?.focus());
232
276
  }
233
277
  }, [open]);
@@ -353,7 +397,13 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
353
397
  <Icon className="size-4 shrink-0" />
354
398
  <span className="truncate">{cmd.label}</span>
355
399
  {cmd.hint && (
356
- <span className="ml-auto text-xs text-text-subtle truncate max-w-[200px]">
400
+ <span className="ml-auto flex items-center gap-1.5 text-xs text-text-subtle truncate max-w-[200px]">
401
+ {cmd.connectionColor && (
402
+ <span
403
+ className="shrink-0 size-2 rounded-full"
404
+ style={{ backgroundColor: cmd.connectionColor }}
405
+ />
406
+ )}
357
407
  {cmd.hint}
358
408
  </span>
359
409
  )}