@hienlh/ppm 0.6.4 → 0.6.6
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/{chat-tab-CDVCDw_H.js → chat-tab-dwpaSkQD.js} +3 -3
- package/dist/web/assets/{code-editor-wmS73ejX.js → code-editor-ZFl5kZ4-.js} +1 -1
- package/dist/web/assets/database-viewer-DPpOsMqa.js +1 -0
- package/dist/web/assets/{diff-viewer-BsYccTx1.js → diff-viewer-CX74l6lV.js} +1 -1
- package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
- package/dist/web/assets/{git-graph-BbWb6_Jq.js → git-graph-Dju1rygf.js} +1 -1
- package/dist/web/assets/index-DSg2VjxL.css +2 -0
- package/dist/web/assets/{index-DhuAmTQ1.js → index-DXOEmhRm.js} +6 -6
- package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
- package/dist/web/assets/keybindings-store-VhiJwp77.js +1 -0
- package/dist/web/assets/{markdown-renderer-aPdw9BhU.js → markdown-renderer-Bke6DHFh.js} +1 -1
- package/dist/web/assets/postgres-viewer-DaNYnInA.js +1 -0
- package/dist/web/assets/{settings-store-DgOSmeGL.js → settings-store-CfB0vCtQ.js} +1 -1
- package/dist/web/assets/settings-tab-DD05d8rM.js +1 -0
- package/dist/web/assets/sqlite-viewer-Cx7tLyT-.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-3tDV4RCn.js → terminal-tab-_farMLMO.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-Ccqh1RD4.js → use-monaco-theme-Dexl3s3E.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/server/routes/chat.ts +2 -2
- package/src/server/routes/database.ts +5 -3
- package/src/services/database/sqlite-adapter.ts +1 -2
- package/src/services/sqlite.service.ts +2 -1
- package/src/types/config.ts +10 -2
- package/src/web/components/chat/tool-cards.tsx +2 -2
- package/src/web/components/database/connection-list.tsx +82 -33
- package/src/web/components/database/database-sidebar.tsx +2 -13
- package/src/web/components/database/database-viewer.tsx +232 -0
- package/src/web/components/database/use-connections.ts +14 -21
- package/src/web/components/database/use-database.ts +120 -0
- package/src/web/components/layout/command-palette.tsx +4 -5
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/sidebar.tsx +1 -2
- 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 +38 -30
- package/src/web/components/postgres/use-postgres.ts +2 -5
- package/src/web/components/settings/ai-settings-section.tsx +0 -1
- package/src/web/components/sqlite/sqlite-viewer.tsx +24 -18
- package/src/web/components/sqlite/use-sqlite.ts +4 -4
- package/src/web/hooks/use-chat.ts +1 -1
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/dist-PpKqMvyx.js +0 -16
- package/dist/web/assets/index-aIGuIMQ8.css +0 -2
- package/dist/web/assets/keybindings-store-BqgrTQAC.js +0 -1
- package/dist/web/assets/postgres-viewer-V4hKmmzV.js +0 -1
- package/dist/web/assets/settings-tab-DwsKpk9T.js +0 -1
- package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +0 -1
- /package/dist/web/assets/{api-client-BHpHp5Lz.js → api-client-4Ni0i4Hl.js} +0 -0
- /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
- /package/dist/web/assets/{utils-CAPYyGV3.js → utils-siJJ3uG0.js} +0 -0
package/src/types/config.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface AIProviderConfig {
|
|
|
38
38
|
api_key_env?: string;
|
|
39
39
|
// Agent SDK-specific settings (ignored by mock provider)
|
|
40
40
|
model?: string;
|
|
41
|
-
effort?: "low" | "medium" | "high"
|
|
41
|
+
effort?: "low" | "medium" | "high";
|
|
42
42
|
max_turns?: number;
|
|
43
43
|
max_budget_usd?: number;
|
|
44
44
|
thinking_budget_tokens?: number;
|
|
@@ -66,7 +66,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const VALID_TYPES = ["agent-sdk", "mock"] as const;
|
|
69
|
-
const VALID_EFFORTS = ["low", "medium", "high"
|
|
69
|
+
const VALID_EFFORTS = ["low", "medium", "high"] as const;
|
|
70
70
|
const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"] as const;
|
|
71
71
|
/** Only these values are allowed for default_provider in config */
|
|
72
72
|
export const VALID_PROVIDERS = ["claude"] as const;
|
|
@@ -130,5 +130,13 @@ export function sanitizeConfig(config: PpmConfig): boolean {
|
|
|
130
130
|
dirty = true;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Downgrade "max" effort → "high" (not available for Claude.ai subscribers)
|
|
134
|
+
for (const provider of Object.values(config.ai.providers)) {
|
|
135
|
+
if ((provider as any).effort === "max") {
|
|
136
|
+
provider.effort = "high";
|
|
137
|
+
dirty = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
return dirty;
|
|
134
142
|
}
|
|
@@ -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
|
-
|
|
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 [
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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={() =>
|
|
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-
|
|
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
|
-
{
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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:
|
|
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,232 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
|
2
|
+
import { Database, Loader2, Play, ChevronLeft, ChevronRight, RefreshCw } 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 flex items-center gap-1">
|
|
40
|
+
<button type="button" onClick={() => db.refreshData()} title="Reload data"
|
|
41
|
+
className="p-1 rounded text-muted-foreground hover:text-foreground transition-colors">
|
|
42
|
+
<RefreshCw className={`size-3 ${db.loading ? "animate-spin" : ""}`} />
|
|
43
|
+
</button>
|
|
44
|
+
<button type="button" onClick={() => setQueryPanelOpen((v) => !v)}
|
|
45
|
+
className={`px-2 py-1 rounded text-xs transition-colors ${queryPanelOpen ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}>
|
|
46
|
+
SQL
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Data grid */}
|
|
52
|
+
<div className={`flex-1 overflow-hidden ${queryPanelOpen ? "max-h-[60%]" : ""}`}>
|
|
53
|
+
<DataGrid tableData={db.tableData} schema={db.schema} loading={db.loading}
|
|
54
|
+
page={db.page} onPageChange={db.setPage} onCellUpdate={db.updateCell} />
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
{/* Query editor */}
|
|
58
|
+
{queryPanelOpen && (
|
|
59
|
+
<div className="border-t border-border h-[40%] shrink-0">
|
|
60
|
+
<QueryEditor dialect={SQL_DIALECTS[dbType] ?? PostgreSQL}
|
|
61
|
+
onExecute={db.executeQuery} result={db.queryResult} error={db.queryError} loading={db.queryLoading} />
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------- Data Grid ---------- */
|
|
70
|
+
function DataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: {
|
|
71
|
+
tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
|
|
72
|
+
schema: DbColumnInfo[]; loading: boolean; page: number;
|
|
73
|
+
onPageChange: (p: number) => void;
|
|
74
|
+
onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
|
|
75
|
+
}) {
|
|
76
|
+
const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
|
|
77
|
+
const [editValue, setEditValue] = useState("");
|
|
78
|
+
|
|
79
|
+
const pkCol = useMemo(() => schema.find((c) => c.pk)?.name ?? null, [schema]);
|
|
80
|
+
|
|
81
|
+
const startEdit = useCallback((rowIdx: number, col: string, val: unknown) => {
|
|
82
|
+
setEditingCell({ rowIdx, col });
|
|
83
|
+
setEditValue(val == null ? "" : String(val));
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const commitEdit = useCallback(() => {
|
|
87
|
+
if (!editingCell || !tableData || !pkCol) return;
|
|
88
|
+
const row = tableData.rows[editingCell.rowIdx];
|
|
89
|
+
if (!row) return;
|
|
90
|
+
const oldVal = row[editingCell.col];
|
|
91
|
+
if (String(oldVal ?? "") !== editValue) {
|
|
92
|
+
onCellUpdate(pkCol, row[pkCol], editingCell.col, editValue === "" ? null : editValue);
|
|
93
|
+
}
|
|
94
|
+
setEditingCell(null);
|
|
95
|
+
}, [editingCell, editValue, tableData, pkCol, onCellUpdate]);
|
|
96
|
+
|
|
97
|
+
const cancelEdit = useCallback(() => setEditingCell(null), []);
|
|
98
|
+
|
|
99
|
+
const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
|
|
100
|
+
(tableData?.columns ?? []).map((col) => ({
|
|
101
|
+
id: col,
|
|
102
|
+
accessorFn: (row) => row[col],
|
|
103
|
+
header: () => <span className={schema.find((c) => c.name === col)?.pk ? "font-bold" : ""}>{col}</span>,
|
|
104
|
+
cell: ({ row, getValue }) => {
|
|
105
|
+
const isEditing = editingCell?.rowIdx === row.index && editingCell?.col === col;
|
|
106
|
+
const val = getValue();
|
|
107
|
+
if (isEditing) {
|
|
108
|
+
return (
|
|
109
|
+
<input autoFocus className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
|
|
110
|
+
value={editValue} onChange={(e) => setEditValue(e.target.value)}
|
|
111
|
+
onBlur={commitEdit} onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") cancelEdit(); }} />
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<span className={`cursor-pointer truncate block ${val == null ? "text-muted-foreground/40 italic" : ""}`}
|
|
116
|
+
onDoubleClick={() => pkCol && startEdit(row.index, col, val)} title={val == null ? "NULL" : String(val)}>
|
|
117
|
+
{val == null ? "NULL" : String(val)}
|
|
118
|
+
</span>
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
})),
|
|
122
|
+
[tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol]);
|
|
123
|
+
|
|
124
|
+
const table = useReactTable({ data: tableData?.rows ?? [], columns, getCoreRowModel: getCoreRowModel() });
|
|
125
|
+
|
|
126
|
+
if (!tableData) {
|
|
127
|
+
return (
|
|
128
|
+
<div className="flex items-center justify-center h-full text-xs text-muted-foreground">
|
|
129
|
+
{loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const totalPages = Math.ceil(tableData.total / tableData.limit) || 1;
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
138
|
+
<div className="flex-1 overflow-auto">
|
|
139
|
+
<table className="w-full text-xs border-collapse">
|
|
140
|
+
<thead className="sticky top-0 z-10 bg-muted">
|
|
141
|
+
{table.getHeaderGroups().map((hg) => (
|
|
142
|
+
<tr key={hg.id}>
|
|
143
|
+
{hg.headers.map((h) => (
|
|
144
|
+
<th key={h.id} className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
|
|
145
|
+
{flexRender(h.column.columnDef.header, h.getContext())}
|
|
146
|
+
</th>
|
|
147
|
+
))}
|
|
148
|
+
</tr>
|
|
149
|
+
))}
|
|
150
|
+
</thead>
|
|
151
|
+
<tbody>
|
|
152
|
+
{table.getRowModel().rows.map((row) => (
|
|
153
|
+
<tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
|
|
154
|
+
{row.getVisibleCells().map((cell) => (
|
|
155
|
+
<td key={cell.id} className="px-2 py-1 max-w-[300px]">
|
|
156
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
157
|
+
</td>
|
|
158
|
+
))}
|
|
159
|
+
</tr>
|
|
160
|
+
))}
|
|
161
|
+
{tableData.rows.length === 0 && (
|
|
162
|
+
<tr><td colSpan={tableData.columns.length} className="px-2 py-8 text-center text-muted-foreground">No data</td></tr>
|
|
163
|
+
)}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
</div>
|
|
167
|
+
<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">
|
|
168
|
+
<span>{tableData.total.toLocaleString()} rows</span>
|
|
169
|
+
<div className="flex items-center gap-2">
|
|
170
|
+
<button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
171
|
+
<ChevronLeft className="size-3.5" />
|
|
172
|
+
</button>
|
|
173
|
+
<span>{page} / {totalPages}</span>
|
|
174
|
+
<button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
|
|
175
|
+
<ChevronRight className="size-3.5" />
|
|
176
|
+
</button>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* ---------- Query Editor ---------- */
|
|
184
|
+
function QueryEditor({ dialect, onExecute, result, error, loading }: {
|
|
185
|
+
dialect: typeof PostgreSQL; onExecute: (sql: string) => void; result: DbQueryResult | null; error: string | null; loading: boolean;
|
|
186
|
+
}) {
|
|
187
|
+
const [query, setQuery] = useState("SELECT * FROM ");
|
|
188
|
+
|
|
189
|
+
const handleExecute = useCallback(() => { const t = query.trim(); if (t) onExecute(t); }, [query, onExecute]);
|
|
190
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
191
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleExecute(); }
|
|
192
|
+
}, [handleExecute]);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
196
|
+
<div className="flex items-start gap-1 border-b border-border bg-background" onKeyDown={handleKeyDown}>
|
|
197
|
+
<div className="flex-1 max-h-[120px] overflow-auto">
|
|
198
|
+
<CodeMirror value={query} onChange={setQuery} extensions={[sql({ dialect })]}
|
|
199
|
+
basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
|
|
200
|
+
className="text-xs [&_.cm-editor]:!outline-none [&_.cm-scroller]:!overflow-auto" />
|
|
201
|
+
</div>
|
|
202
|
+
<button type="button" onClick={handleExecute} disabled={loading} title="Execute (Cmd+Enter)"
|
|
203
|
+
className="shrink-0 m-1 p-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors">
|
|
204
|
+
{loading ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div className="flex-1 overflow-auto text-xs">
|
|
208
|
+
{error && <div className="px-3 py-2 text-destructive bg-destructive/5">{error}</div>}
|
|
209
|
+
{result?.changeType === "modify" && <div className="px-3 py-2 text-green-500">Query executed. {result.rowsAffected} row(s) affected.</div>}
|
|
210
|
+
{result?.changeType === "select" && result.rows.length > 0 && (
|
|
211
|
+
<table className="w-full border-collapse">
|
|
212
|
+
<thead className="sticky top-0 bg-muted">
|
|
213
|
+
<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>
|
|
214
|
+
</thead>
|
|
215
|
+
<tbody>
|
|
216
|
+
{result.rows.map((row, i) => (
|
|
217
|
+
<tr key={i} className="hover:bg-muted/30 border-b border-border/50">
|
|
218
|
+
{result.columns.map((c) => (
|
|
219
|
+
<td key={c} className="px-2 py-1 max-w-[300px] truncate" title={row[c] == null ? "NULL" : String(row[c])}>
|
|
220
|
+
{row[c] == null ? <span className="text-muted-foreground/40 italic">NULL</span> : String(row[c])}
|
|
221
|
+
</td>
|
|
222
|
+
))}
|
|
223
|
+
</tr>
|
|
224
|
+
))}
|
|
225
|
+
</tbody>
|
|
226
|
+
</table>
|
|
227
|
+
)}
|
|
228
|
+
{result?.changeType === "select" && result.rows.length === 0 && <div className="px-3 py-2 text-muted-foreground">No results</div>}
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|