@hienlh/ppm 0.6.3 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/web/assets/api-client-4Ni0i4Hl.js +1 -0
- package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-DkgRZpbj.js} +3 -3
- package/dist/web/assets/{code-editor-witrClmz.js → code-editor-CVMeIylx.js} +1 -1
- package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
- package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-B1vnegRS.js} +1 -1
- package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
- package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-Bi4PM-z2.js} +1 -1
- package/dist/web/assets/index-DSg2VjxL.css +2 -0
- package/dist/web/assets/{index-CcXQ5iQw.js → index-DUb5kwfL.js} +6 -6
- package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
- package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
- package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-ChvoCZNm.js} +1 -1
- package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
- package/dist/web/assets/settings-store-CfB0vCtQ.js +1 -0
- package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
- package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
- package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
- package/dist/web/assets/table-DCVKGOr2.js +1 -0
- package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-B_75oJaQ.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Dexl3s3E.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +41 -14
- package/docs/project-roadmap.md +31 -6
- package/docs/system-architecture.md +222 -7
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +21 -4
- package/src/server/index.ts +6 -0
- package/src/server/routes/chat.ts +2 -2
- package/src/server/routes/database.ts +261 -0
- package/src/services/database/adapter-registry.ts +13 -0
- package/src/services/database/init-adapters.ts +9 -0
- package/src/services/database/postgres-adapter.ts +42 -0
- package/src/services/database/readonly-check.ts +17 -0
- package/src/services/database/sqlite-adapter.ts +55 -0
- package/src/services/db.service.ts +77 -4
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/config.ts +10 -2
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +9 -4
- package/src/web/components/chat/tool-cards.tsx +2 -2
- package/src/web/components/database/connection-color-picker.tsx +67 -0
- package/src/web/components/database/connection-form-dialog.tsx +234 -0
- package/src/web/components/database/connection-list.tsx +257 -0
- package/src/web/components/database/database-sidebar.tsx +89 -0
- package/src/web/components/database/database-viewer.tsx +228 -0
- package/src/web/components/database/use-connections.ts +92 -0
- package/src/web/components/database/use-database.ts +117 -0
- package/src/web/components/layout/command-palette.tsx +56 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/sidebar.tsx +7 -3
- package/src/web/components/layout/tab-bar.tsx +1 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/postgres/postgres-viewer.tsx +42 -25
- package/src/web/components/postgres/use-postgres.ts +54 -21
- package/src/web/components/settings/ai-settings-section.tsx +0 -1
- package/src/web/components/sqlite/sqlite-viewer.tsx +43 -13
- package/src/web/components/sqlite/use-sqlite.ts +24 -15
- package/src/web/hooks/use-chat.ts +1 -1
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
- package/dist/web/assets/dist-PpKqMvyx.js +0 -16
- package/dist/web/assets/index-DyEgsogR.css +0 -2
- package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
- package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
- package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
- package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
- package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
- /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
- /package/dist/web/assets/{utils-CAPYyGV3.js → utils-siJJ3uG0.js} +0 -0
|
@@ -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);
|
|
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
|
-
|
|
261
|
+
const matched = allCommands.filter(
|
|
221
262
|
(c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
|
|
222
263
|
);
|
|
223
|
-
|
|
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
|
)}
|