@hienlh/ppm 0.6.4 → 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 (55) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/web/assets/{chat-tab-CDVCDw_H.js → chat-tab-DkgRZpbj.js} +3 -3
  3. package/dist/web/assets/{code-editor-wmS73ejX.js → code-editor-CVMeIylx.js} +1 -1
  4. package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
  5. package/dist/web/assets/{diff-viewer-BsYccTx1.js → diff-viewer-B1vnegRS.js} +1 -1
  6. package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
  7. package/dist/web/assets/{git-graph-BbWb6_Jq.js → git-graph-Bi4PM-z2.js} +1 -1
  8. package/dist/web/assets/index-DSg2VjxL.css +2 -0
  9. package/dist/web/assets/{index-DhuAmTQ1.js → index-DUb5kwfL.js} +6 -6
  10. package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
  11. package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-aPdw9BhU.js → markdown-renderer-ChvoCZNm.js} +1 -1
  13. package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
  14. package/dist/web/assets/{settings-store-DgOSmeGL.js → settings-store-CfB0vCtQ.js} +1 -1
  15. package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
  16. package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
  17. package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
  18. package/dist/web/assets/table-DCVKGOr2.js +1 -0
  19. package/dist/web/assets/{terminal-tab-3tDV4RCn.js → terminal-tab-B_75oJaQ.js} +1 -1
  20. package/dist/web/assets/{use-monaco-theme-Ccqh1RD4.js → use-monaco-theme-Dexl3s3E.js} +1 -1
  21. package/dist/web/index.html +8 -8
  22. package/dist/web/sw.js +1 -1
  23. package/package.json +1 -1
  24. package/src/server/routes/chat.ts +2 -2
  25. package/src/server/routes/database.ts +5 -3
  26. package/src/types/config.ts +10 -2
  27. package/src/web/components/chat/tool-cards.tsx +2 -2
  28. package/src/web/components/database/connection-list.tsx +82 -33
  29. package/src/web/components/database/database-sidebar.tsx +2 -13
  30. package/src/web/components/database/database-viewer.tsx +228 -0
  31. package/src/web/components/database/use-connections.ts +14 -21
  32. package/src/web/components/database/use-database.ts +117 -0
  33. package/src/web/components/layout/command-palette.tsx +4 -5
  34. package/src/web/components/layout/editor-panel.tsx +1 -0
  35. package/src/web/components/layout/mobile-nav.tsx +1 -1
  36. package/src/web/components/layout/sidebar.tsx +1 -2
  37. package/src/web/components/layout/tab-bar.tsx +1 -0
  38. package/src/web/components/layout/tab-content.tsx +5 -0
  39. package/src/web/components/postgres/postgres-viewer.tsx +38 -30
  40. package/src/web/components/postgres/use-postgres.ts +2 -5
  41. package/src/web/components/settings/ai-settings-section.tsx +0 -1
  42. package/src/web/components/sqlite/sqlite-viewer.tsx +24 -18
  43. package/src/web/components/sqlite/use-sqlite.ts +4 -4
  44. package/src/web/hooks/use-chat.ts +1 -1
  45. package/src/web/hooks/use-usage.ts +1 -1
  46. package/src/web/stores/tab-store.ts +1 -0
  47. package/dist/web/assets/dist-PpKqMvyx.js +0 -16
  48. package/dist/web/assets/index-aIGuIMQ8.css +0 -2
  49. package/dist/web/assets/keybindings-store-BqgrTQAC.js +0 -1
  50. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +0 -1
  51. package/dist/web/assets/settings-tab-DwsKpk9T.js +0 -1
  52. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +0 -1
  53. /package/dist/web/assets/{api-client-BHpHp5Lz.js → api-client-4Ni0i4Hl.js} +0 -0
  54. /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
  55. /package/dist/web/assets/{utils-CAPYyGV3.js → utils-siJJ3uG0.js} +0 -0
@@ -134,12 +134,12 @@ function ToolSummary({ name, input }: { name: string; input: Record<string, unkn
134
134
  case "Task":
135
135
  return <><Bot className="size-3 inline" /> {name} <span className="text-text-subtle">{truncate(s(input.description || input.prompt), 60)}</span></>;
136
136
  case "TodoWrite": {
137
- const todos = (input.todos as Array<{ content: string; status: string }>) ?? [];
137
+ const todos = Array.isArray(input.todos) ? input.todos as Array<{ content: string; status: string }> : [];
138
138
  const done = todos.filter((t) => t.status === "completed").length;
139
139
  return <><ListTodo className="size-3 inline" /> {name} <span className="text-text-subtle">{done}/{todos.length} done</span></>;
140
140
  }
141
141
  case "AskUserQuestion": {
142
- const qs = (input.questions as Array<{ question: string }>) ?? [];
142
+ const qs = Array.isArray(input.questions) ? input.questions as Array<{ question: string }> : [];
143
143
  const hasAns = !!(input.answers);
144
144
  return <>{name} <span className="text-text-subtle">{qs.length} question{qs.length !== 1 ? "s" : ""}{hasAns ? " ✓" : ""}</span></>;
145
145
  }
@@ -1,12 +1,11 @@
1
- import { useState } from "react";
2
- import { ChevronRight, ChevronDown, Database, RefreshCw, Pencil, Trash2, Lock } from "lucide-react";
1
+ import { useState, useMemo } from "react";
2
+ import { ChevronRight, ChevronDown, Database, RefreshCw, Pencil, Trash2, Lock, Search } from "lucide-react";
3
3
  import { cn } from "@/lib/utils";
4
4
  import type { Connection, CachedTable } from "./use-connections";
5
5
 
6
6
  interface ConnectionListProps {
7
7
  connections: Connection[];
8
8
  cachedTables: Map<number, CachedTable[]>;
9
- onOpenConnection: (conn: Connection) => void;
10
9
  onOpenTable: (conn: Connection, tableName: string, schemaName: string) => void;
11
10
  onRefreshTables: (id: number) => Promise<void>;
12
11
  onEdit: (conn: Connection) => void;
@@ -17,16 +16,14 @@ interface GroupMap {
17
16
  [group: string]: Connection[];
18
17
  }
19
18
 
20
- const MAX_VISIBLE_TABLES = 10;
21
-
22
19
  export function ConnectionList({
23
20
  connections, cachedTables,
24
- onOpenConnection, onOpenTable, onRefreshTables, onEdit, onDelete,
21
+ onOpenTable, onRefreshTables, onEdit, onDelete,
25
22
  }: ConnectionListProps) {
26
23
  const [expandedConns, setExpandedConns] = useState<Set<number>>(new Set());
27
24
  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["__ungrouped__"]));
28
25
  const [refreshingIds, setRefreshingIds] = useState<Set<number>>(new Set());
29
- const [showAllTables, setShowAllTables] = useState<Set<number>>(new Set());
26
+ const [tableFilter, setTableFilter] = useState<Map<number, string>>(new Map());
30
27
 
31
28
  const toggleConn = (id: number) => {
32
29
  setExpandedConns((prev) => {
@@ -78,10 +75,12 @@ export function ConnectionList({
78
75
  const label = group === "__ungrouped__" ? "Ungrouped" : group;
79
76
  const groupConns = groups[group]!;
80
77
 
78
+ const hasGroup = groupKeys.length > 1 || group !== "__ungrouped__";
79
+
81
80
  return (
82
81
  <div key={group}>
83
82
  {/* Group header (only shown when there are multiple groups or named group) */}
84
- {(groupKeys.length > 1 || group !== "__ungrouped__") && (
83
+ {hasGroup && (
85
84
  <button
86
85
  onClick={() => toggleGroup(group)}
87
86
  className="w-full flex items-center gap-1 px-2 py-1 text-[10px] font-semibold text-text-subtle uppercase tracking-wider hover:text-text-secondary transition-colors"
@@ -91,17 +90,18 @@ export function ConnectionList({
91
90
  </button>
92
91
  )}
93
92
 
94
- {isGroupExpanded && groupConns.map((conn) => {
93
+ {/* Connections indented with tree guide line when inside a group */}
94
+ {isGroupExpanded && (
95
+ <div className={hasGroup ? "ml-[11px] border-l border-dashed border-border" : ""}>
96
+ {groupConns.map((conn) => {
95
97
  const isExpanded = expandedConns.has(conn.id);
96
98
  const tables = cachedTables.get(conn.id) ?? [];
97
99
  const isRefreshing = refreshingIds.has(conn.id);
98
- const showAll = showAllTables.has(conn.id);
99
- const visibleTables = showAll ? tables : tables.slice(0, MAX_VISIBLE_TABLES);
100
100
 
101
101
  return (
102
102
  <div key={conn.id}>
103
103
  {/* Connection row */}
104
- <div className="group flex items-center gap-1 px-2 py-1 hover:bg-surface-elevated transition-colors">
104
+ <div className={cn("group flex items-center gap-1 py-1 hover:bg-surface-elevated transition-colors", hasGroup ? "pl-3 pr-2" : "px-2")}>
105
105
  {/* Expand arrow */}
106
106
  <button
107
107
  onClick={() => {
@@ -121,10 +121,15 @@ export function ConnectionList({
121
121
  style={{ backgroundColor: conn.color ?? "transparent" }}
122
122
  />
123
123
 
124
- {/* Name — click opens connection viewer */}
124
+ {/* Name — click toggles expand */}
125
125
  <button
126
126
  className="flex-1 text-left text-xs truncate hover:text-primary transition-colors"
127
- onClick={() => onOpenConnection(conn)}
127
+ onClick={() => {
128
+ toggleConn(conn.id);
129
+ if (!expandedConns.has(conn.id) && tables.length === 0) {
130
+ handleRefresh(conn.id);
131
+ }
132
+ }}
128
133
  >
129
134
  {conn.name}
130
135
  </button>
@@ -168,41 +173,85 @@ export function ConnectionList({
168
173
  </div>
169
174
  </div>
170
175
 
171
- {/* Table list (expanded) */}
176
+ {/* Table list (expanded) with tree guide line */}
172
177
  {isExpanded && (
173
- <div className="pl-6">
178
+ <div className="ml-[11px] border-l border-dashed border-border pl-3">
174
179
  {isRefreshing && tables.length === 0 && (
175
180
  <p className="text-[10px] text-text-subtle px-2 py-1">Loading…</p>
176
181
  )}
177
182
  {!isRefreshing && tables.length === 0 && (
178
183
  <p className="text-[10px] text-text-subtle px-2 py-1">No tables cached</p>
179
184
  )}
180
- {visibleTables.map((t) => (
181
- <button
182
- key={`${t.schemaName}.${t.tableName}`}
183
- onClick={() => onOpenTable(conn, t.tableName, t.schemaName)}
184
- className="w-full flex items-center gap-1.5 px-2 py-0.5 text-[11px] text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors text-left truncate"
185
- >
186
- <Database className="size-2.5 shrink-0 text-text-subtle" />
187
- <span className="truncate">{t.tableName}</span>
188
- </button>
189
- ))}
190
- {tables.length > MAX_VISIBLE_TABLES && !showAll && (
191
- <button
192
- onClick={() => setShowAllTables((p) => new Set(p).add(conn.id))}
193
- className="w-full text-left px-2 py-0.5 text-[10px] text-text-subtle hover:text-text-secondary transition-colors"
194
- >
195
- +{tables.length - MAX_VISIBLE_TABLES} more…
196
- </button>
185
+ {tables.length > 0 && (
186
+ <TableListWithFilter
187
+ connId={conn.id}
188
+ tables={tables}
189
+ filter={tableFilter.get(conn.id) ?? ""}
190
+ onFilterChange={(v) => setTableFilter((prev) => new Map(prev).set(conn.id, v))}
191
+ onOpenTable={(tableName, schemaName) => onOpenTable(conn, tableName, schemaName)}
192
+ />
197
193
  )}
198
194
  </div>
199
195
  )}
200
196
  </div>
201
197
  );
202
198
  })}
199
+ </div>
200
+ )}
203
201
  </div>
204
202
  );
205
203
  })}
206
204
  </div>
207
205
  );
208
206
  }
207
+
208
+ /* ---------- Table list with filter ---------- */
209
+ const MAX_TABLE_HEIGHT = 200; // px
210
+
211
+ function TableListWithFilter({ connId, tables, filter, onFilterChange, onOpenTable }: {
212
+ connId: number;
213
+ tables: CachedTable[];
214
+ filter: string;
215
+ onFilterChange: (v: string) => void;
216
+ onOpenTable: (tableName: string, schemaName: string) => void;
217
+ }) {
218
+ const filtered = useMemo(() => {
219
+ if (!filter) return tables;
220
+ const q = filter.toLowerCase();
221
+ return tables.filter((t) => t.tableName.toLowerCase().includes(q));
222
+ }, [tables, filter]);
223
+
224
+ return (
225
+ <div>
226
+ {/* Filter input — show when many tables */}
227
+ {tables.length > 5 && (
228
+ <div className="flex items-center gap-1 px-1 py-0.5">
229
+ <Search className="size-2.5 text-text-subtle shrink-0" />
230
+ <input
231
+ type="text"
232
+ value={filter}
233
+ onChange={(e) => onFilterChange(e.target.value)}
234
+ placeholder="Filter tables…"
235
+ className="w-full text-[10px] bg-transparent border-none outline-none text-foreground placeholder:text-text-subtle"
236
+ />
237
+ </div>
238
+ )}
239
+ {/* Scrollable table list */}
240
+ <div className="overflow-y-auto" style={{ maxHeight: MAX_TABLE_HEIGHT }}>
241
+ {filtered.map((t) => (
242
+ <button
243
+ key={`${connId}-${t.schemaName}.${t.tableName}`}
244
+ onClick={() => onOpenTable(t.tableName, t.schemaName)}
245
+ className="w-full flex items-center gap-1.5 px-2 py-0.5 text-[11px] text-text-secondary hover:text-foreground hover:bg-surface-elevated transition-colors text-left truncate"
246
+ >
247
+ <Database className="size-2.5 shrink-0 text-text-subtle" />
248
+ <span className="truncate">{t.tableName}</span>
249
+ </button>
250
+ ))}
251
+ {filter && filtered.length === 0 && (
252
+ <p className="text-[10px] text-text-subtle px-2 py-1">No match</p>
253
+ )}
254
+ </div>
255
+ </div>
256
+ );
257
+ }
@@ -11,23 +11,13 @@ export function DatabaseSidebar() {
11
11
  const [addOpen, setAddOpen] = useState(false);
12
12
  const [editConn, setEditConn] = useState<Connection | null>(null);
13
13
 
14
- const handleOpenConnection = (conn: Connection) => {
15
- openTab({
16
- type: conn.type === "postgres" ? "postgres" : "sqlite",
17
- title: conn.name,
18
- projectId: null,
19
- closable: true,
20
- metadata: { connectionId: conn.id, connectionColor: conn.color },
21
- });
22
- };
23
-
24
14
  const handleOpenTable = (conn: Connection, tableName: string, schemaName: string) => {
25
15
  openTab({
26
- type: conn.type === "postgres" ? "postgres" : "sqlite",
16
+ type: "database",
27
17
  title: `${conn.name} · ${tableName}`,
28
18
  projectId: null,
29
19
  closable: true,
30
- metadata: { connectionId: conn.id, tableName, schemaName, connectionColor: conn.color },
20
+ metadata: { connectionId: conn.id, connectionName: conn.name, dbType: conn.type, tableName, schemaName, connectionColor: conn.color },
31
21
  });
32
22
  };
33
23
 
@@ -68,7 +58,6 @@ export function DatabaseSidebar() {
68
58
  <ConnectionList
69
59
  connections={connections}
70
60
  cachedTables={cachedTables}
71
- onOpenConnection={handleOpenConnection}
72
61
  onOpenTable={handleOpenTable}
73
62
  onRefreshTables={refreshTables}
74
63
  onEdit={setEditConn}
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
+ import { api } from "../../lib/api-client";
2
3
 
3
4
  export interface Connection {
4
5
  id: number;
@@ -36,13 +37,6 @@ export interface UpdateConnectionData {
36
37
  readonly?: number;
37
38
  }
38
39
 
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
40
  export function useConnections() {
47
41
  const [connections, setConnections] = useState<Connection[]>([]);
48
42
  const [loading, setLoading] = useState(true);
@@ -50,7 +44,7 @@ export function useConnections() {
50
44
 
51
45
  const fetchConnections = useCallback(async () => {
52
46
  try {
53
- const data = await apiFetch<Connection[]>("/api/db/connections");
47
+ const data = await api.get<Connection[]>("/api/db/connections");
54
48
  setConnections(data);
55
49
  } catch {
56
50
  // ignore — server may not be ready
@@ -62,36 +56,35 @@ export function useConnections() {
62
56
  useEffect(() => { fetchConnections(); }, [fetchConnections]);
63
57
 
64
58
  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
- });
59
+ const conn = await api.post<Connection>("/api/db/connections", data);
70
60
  setConnections((prev) => [...prev, conn]);
71
61
  return conn;
72
62
  }, []);
73
63
 
74
64
  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
- });
65
+ const updated = await api.put<Connection>(`/api/db/connections/${id}`, data);
80
66
  setConnections((prev) => prev.map((c) => (c.id === id ? updated : c)));
81
67
  }, []);
82
68
 
83
69
  const deleteConnection = useCallback(async (id: number): Promise<void> => {
84
- await apiFetch(`/api/db/connections/${id}`, { method: "DELETE" });
70
+ await api.del(`/api/db/connections/${id}`);
85
71
  setConnections((prev) => prev.filter((c) => c.id !== id));
86
72
  setCachedTables((prev) => { const m = new Map(prev); m.delete(id); return m; });
87
73
  }, []);
88
74
 
89
75
  const testConnection = useCallback(async (id: number): Promise<{ ok: boolean; error?: string }> => {
90
- return apiFetch(`/api/db/connections/${id}/test`, { method: "POST" });
76
+ return api.post(`/api/db/connections/${id}/test`);
91
77
  }, []);
92
78
 
93
79
  const refreshTables = useCallback(async (id: number): Promise<void> => {
94
- const tables = await apiFetch<CachedTable[]>(`/api/db/connections/${id}/tables`);
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
+ }));
95
88
  setCachedTables((prev) => new Map(prev).set(id, tables));
96
89
  }, []);
97
90
 
@@ -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
+ }