@hienlh/ppm 0.6.1 → 0.6.3
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 +16 -0
- package/bun.lock +3 -0
- package/dist/web/assets/api-client-D0pZeYY8.js +1 -0
- package/dist/web/assets/{chat-tab-CjKO_uYf.js → chat-tab-DjE_8Csw.js} +5 -5
- package/dist/web/assets/code-editor-witrClmz.js +1 -0
- package/dist/web/assets/diff-viewer-DSU--yFW.js +4 -0
- package/dist/web/assets/dist-PpKqMvyx.js +16 -0
- package/dist/web/assets/git-graph-HpcOYt3G.js +1 -0
- package/dist/web/assets/index-CcXQ5iQw.js +21 -0
- package/dist/web/assets/index-DyEgsogR.css +2 -0
- package/dist/web/assets/input-CCCPR1s4.js +41 -0
- package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
- package/dist/web/assets/keybindings-store-C_KQKrsc.js +1 -0
- package/dist/web/assets/{markdown-renderer-BKfKwtec.js → markdown-renderer-DSw-4oxk.js} +1 -1
- package/dist/web/assets/postgres-viewer-BnkGPi0L.js +1 -0
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
- package/dist/web/assets/react-l9v2XLcs.js +1 -0
- package/dist/web/assets/{settings-store-BGF8--S9.js → settings-store-B5g1Gis-.js} +1 -1
- package/dist/web/assets/settings-tab-DpQdg9OW.js +1 -0
- package/dist/web/assets/sqlite-viewer-JZvegGV-.js +1 -0
- package/dist/web/assets/{tab-store-L0a7ao4c.js → tab-store-DhXold0e.js} +1 -1
- package/dist/web/assets/{terminal-tab-CmdZtyZW.js → terminal-tab-CAQvs2wj.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-RFoGvnp0.js → use-monaco-theme-GX0lrqac.js} +1 -1
- package/dist/web/index.html +10 -9
- package/dist/web/sw.js +1 -1
- package/package.json +2 -1
- package/src/cli/commands/db-cmd.ts +338 -0
- package/src/server/index.ts +2 -0
- package/src/server/routes/postgres.ts +92 -0
- package/src/server/routes/settings.ts +33 -0
- package/src/services/db.service.ts +99 -1
- package/src/services/postgres.service.ts +170 -0
- package/src/web/app.tsx +7 -2
- package/src/web/components/layout/command-palette.tsx +2 -0
- 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/tab-bar.tsx +1 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/postgres/postgres-viewer.tsx +264 -0
- package/src/web/components/postgres/use-postgres.ts +128 -0
- package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/hooks/use-global-keybindings.ts +74 -14
- package/src/web/stores/keybindings-store.ts +192 -0
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/api-client-ANLU-Irq.js +0 -1
- package/dist/web/assets/code-editor-CCvD-8SS.js +0 -1
- package/dist/web/assets/diff-viewer-D_bM4Kmw.js +0 -4
- package/dist/web/assets/git-graph-zmdDLInW.js +0 -1
- package/dist/web/assets/index-CP_2zE5O.css +0 -2
- package/dist/web/assets/index-l7z-nYoz.js +0 -21
- package/dist/web/assets/input-DV4tynJq.js +0 -41
- package/dist/web/assets/react-WvgCEYPV.js +0 -1
- package/dist/web/assets/rotate-ccw-BesidNnx.js +0 -1
- package/dist/web/assets/settings-tab-CP5UZGRD.js +0 -1
- package/dist/web/assets/sqlite-viewer-C1MIuoOX.js +0 -16
- /package/dist/web/assets/{utils-C2KxHr1H.js → utils-CAPYyGV3.js} +0 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { postgresService } from "../../services/postgres.service.ts";
|
|
3
|
+
import { ok, err } from "../../types/api.ts";
|
|
4
|
+
|
|
5
|
+
export const postgresRoutes = new Hono();
|
|
6
|
+
|
|
7
|
+
/** POST /postgres/test — body: { connectionString } */
|
|
8
|
+
postgresRoutes.post("/test", async (c) => {
|
|
9
|
+
try {
|
|
10
|
+
const body = await c.req.json<{ connectionString: string }>();
|
|
11
|
+
if (!body.connectionString) return c.json(err("Missing connectionString"), 400);
|
|
12
|
+
const result = await postgresService.testConnection(body.connectionString);
|
|
13
|
+
return c.json(ok(result));
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return c.json(err((e as Error).message), 500);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/** POST /postgres/tables — body: { connectionString } */
|
|
20
|
+
postgresRoutes.post("/tables", async (c) => {
|
|
21
|
+
try {
|
|
22
|
+
const body = await c.req.json<{ connectionString: string }>();
|
|
23
|
+
if (!body.connectionString) return c.json(err("Missing connectionString"), 400);
|
|
24
|
+
const tables = await postgresService.getTables(body.connectionString);
|
|
25
|
+
return c.json(ok(tables));
|
|
26
|
+
} catch (e) {
|
|
27
|
+
return c.json(err((e as Error).message), 500);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** POST /postgres/schema — body: { connectionString, table, schema? } */
|
|
32
|
+
postgresRoutes.post("/schema", async (c) => {
|
|
33
|
+
try {
|
|
34
|
+
const body = await c.req.json<{ connectionString: string; table: string; schema?: string }>();
|
|
35
|
+
if (!body.connectionString || !body.table) return c.json(err("Missing connectionString or table"), 400);
|
|
36
|
+
const schema = await postgresService.getTableSchema(body.connectionString, body.table, body.schema);
|
|
37
|
+
return c.json(ok(schema));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return c.json(err((e as Error).message), 500);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** POST /postgres/data — body: { connectionString, table, schema?, page?, limit?, orderBy?, orderDir? } */
|
|
44
|
+
postgresRoutes.post("/data", async (c) => {
|
|
45
|
+
try {
|
|
46
|
+
const body = await c.req.json<{
|
|
47
|
+
connectionString: string; table: string; schema?: string;
|
|
48
|
+
page?: number; limit?: number; orderBy?: string; orderDir?: string;
|
|
49
|
+
}>();
|
|
50
|
+
if (!body.connectionString || !body.table) return c.json(err("Missing connectionString or table"), 400);
|
|
51
|
+
const limit = Math.min(body.limit ?? 100, 1000);
|
|
52
|
+
const data = await postgresService.getTableData(
|
|
53
|
+
body.connectionString, body.table, body.schema, body.page ?? 1, limit,
|
|
54
|
+
body.orderBy, (body.orderDir as "ASC" | "DESC") ?? "ASC",
|
|
55
|
+
);
|
|
56
|
+
return c.json(ok(data));
|
|
57
|
+
} catch (e) {
|
|
58
|
+
return c.json(err((e as Error).message), 500);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/** POST /postgres/query — body: { connectionString, sql } */
|
|
63
|
+
postgresRoutes.post("/query", async (c) => {
|
|
64
|
+
try {
|
|
65
|
+
const body = await c.req.json<{ connectionString: string; sql: string }>();
|
|
66
|
+
if (!body.connectionString || !body.sql) return c.json(err("Missing connectionString or sql"), 400);
|
|
67
|
+
const result = await postgresService.executeQuery(body.connectionString, body.sql);
|
|
68
|
+
return c.json(ok(result));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return c.json(err((e as Error).message), 500);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** POST /postgres/cell — body: { connectionString, table, schema?, pkColumn, pkValue, column, value } */
|
|
75
|
+
postgresRoutes.post("/cell", async (c) => {
|
|
76
|
+
try {
|
|
77
|
+
const body = await c.req.json<{
|
|
78
|
+
connectionString: string; table: string; schema?: string;
|
|
79
|
+
pkColumn: string; pkValue: unknown; column: string; value: unknown;
|
|
80
|
+
}>();
|
|
81
|
+
if (!body.connectionString || !body.table || !body.pkColumn || !body.column) {
|
|
82
|
+
return c.json(err("Missing required fields"), 400);
|
|
83
|
+
}
|
|
84
|
+
await postgresService.updateCell(
|
|
85
|
+
body.connectionString, body.table, body.schema,
|
|
86
|
+
body.pkColumn, body.pkValue, body.column, body.value,
|
|
87
|
+
);
|
|
88
|
+
return c.json(ok({ updated: true }));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
return c.json(err((e as Error).message), 500);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { configService } from "../../services/config.service.ts";
|
|
3
|
+
import { getConfigValue, setConfigValue } from "../../services/db.service.ts";
|
|
3
4
|
import {
|
|
4
5
|
validateAIProviderConfig,
|
|
5
6
|
validateDefaultProvider,
|
|
@@ -102,3 +103,35 @@ settingsRoutes.put("/ai", async (c) => {
|
|
|
102
103
|
return c.json(err((e as Error).message), 400);
|
|
103
104
|
}
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
// ── Keybindings ──────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const KEYBINDINGS_KEY = "keybindings";
|
|
110
|
+
|
|
111
|
+
/** GET /settings/keybindings — return user overrides (partial) */
|
|
112
|
+
settingsRoutes.get("/keybindings", (c) => {
|
|
113
|
+
const raw = getConfigValue(KEYBINDINGS_KEY);
|
|
114
|
+
const overrides: Record<string, string> = raw ? JSON.parse(raw) : {};
|
|
115
|
+
return c.json(ok(overrides));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** PUT /settings/keybindings — save user overrides (partial, only changed keys) */
|
|
119
|
+
settingsRoutes.put("/keybindings", async (c) => {
|
|
120
|
+
try {
|
|
121
|
+
const body = await c.req.json<Record<string, string | null>>();
|
|
122
|
+
// Merge with existing overrides
|
|
123
|
+
const raw = getConfigValue(KEYBINDINGS_KEY);
|
|
124
|
+
const current: Record<string, string> = raw ? JSON.parse(raw) : {};
|
|
125
|
+
for (const [actionId, combo] of Object.entries(body)) {
|
|
126
|
+
if (combo === null) {
|
|
127
|
+
delete current[actionId]; // reset to default
|
|
128
|
+
} else {
|
|
129
|
+
current[actionId] = combo;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
setConfigValue(KEYBINDINGS_KEY, JSON.stringify(current));
|
|
133
|
+
return c.json(ok(current));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return c.json(err((e as Error).message), 400);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 2;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -121,6 +121,27 @@ function runMigrations(database: Database): void {
|
|
|
121
121
|
PRAGMA user_version = 1;
|
|
122
122
|
`);
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
if (current < 2) {
|
|
126
|
+
database.exec(`
|
|
127
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
128
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
129
|
+
type TEXT NOT NULL CHECK(type IN ('sqlite', 'postgres')),
|
|
130
|
+
name TEXT NOT NULL UNIQUE,
|
|
131
|
+
connection_config TEXT NOT NULL,
|
|
132
|
+
group_name TEXT,
|
|
133
|
+
color TEXT,
|
|
134
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
136
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_connections_type ON connections(type);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_connections_group ON connections(group_name);
|
|
141
|
+
|
|
142
|
+
PRAGMA user_version = 2;
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
124
145
|
}
|
|
125
146
|
|
|
126
147
|
// ---------------------------------------------------------------------------
|
|
@@ -299,5 +320,82 @@ export function getDbFilePath(): string {
|
|
|
299
320
|
return getDbPath();
|
|
300
321
|
}
|
|
301
322
|
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Connection helpers
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
export interface ConnectionRow {
|
|
328
|
+
id: number;
|
|
329
|
+
type: "sqlite" | "postgres";
|
|
330
|
+
name: string;
|
|
331
|
+
connection_config: string;
|
|
332
|
+
group_name: string | null;
|
|
333
|
+
color: string | null;
|
|
334
|
+
sort_order: number;
|
|
335
|
+
created_at: string;
|
|
336
|
+
updated_at: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Parsed config stored in connection_config JSON */
|
|
340
|
+
export type ConnectionConfig =
|
|
341
|
+
| { type: "sqlite"; path: string }
|
|
342
|
+
| { type: "postgres"; connectionString: string };
|
|
343
|
+
|
|
344
|
+
export function getConnections(): ConnectionRow[] {
|
|
345
|
+
return getDb().query(
|
|
346
|
+
"SELECT * FROM connections ORDER BY sort_order, id",
|
|
347
|
+
).all() as ConnectionRow[];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function getConnectionById(id: number): ConnectionRow | null {
|
|
351
|
+
return getDb().query("SELECT * FROM connections WHERE id = ?").get(id) as ConnectionRow | null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function getConnectionByName(name: string): ConnectionRow | null {
|
|
355
|
+
return getDb().query("SELECT * FROM connections WHERE name = ?").get(name) as ConnectionRow | null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Resolve a connection by name or numeric ID */
|
|
359
|
+
export function resolveConnection(nameOrId: string): ConnectionRow | null {
|
|
360
|
+
const asNum = Number(nameOrId);
|
|
361
|
+
if (!Number.isNaN(asNum) && Number.isInteger(asNum)) {
|
|
362
|
+
return getConnectionById(asNum) ?? getConnectionByName(nameOrId);
|
|
363
|
+
}
|
|
364
|
+
return getConnectionByName(nameOrId);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function insertConnection(
|
|
368
|
+
type: "sqlite" | "postgres", name: string, config: ConnectionConfig,
|
|
369
|
+
groupName?: string | null, color?: string | null,
|
|
370
|
+
): ConnectionRow {
|
|
371
|
+
const maxOrder = (getDb().query("SELECT COALESCE(MAX(sort_order), -1) as m FROM connections").get() as { m: number }).m;
|
|
372
|
+
getDb().query(
|
|
373
|
+
"INSERT INTO connections (type, name, connection_config, group_name, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
|
374
|
+
).run(type, name, JSON.stringify(config), groupName ?? null, color ?? null, maxOrder + 1);
|
|
375
|
+
return getConnectionByName(name)!;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function deleteConnection(nameOrId: string): boolean {
|
|
379
|
+
const conn = resolveConnection(nameOrId);
|
|
380
|
+
if (!conn) return false;
|
|
381
|
+
getDb().query("DELETE FROM connections WHERE id = ?").run(conn.id);
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function updateConnection(
|
|
386
|
+
id: number, updates: { name?: string; config?: ConnectionConfig; groupName?: string | null; color?: string | null },
|
|
387
|
+
): void {
|
|
388
|
+
const sets: string[] = [];
|
|
389
|
+
const vals: unknown[] = [];
|
|
390
|
+
if (updates.name !== undefined) { sets.push("name = ?"); vals.push(updates.name); }
|
|
391
|
+
if (updates.config !== undefined) { sets.push("connection_config = ?"); vals.push(JSON.stringify(updates.config)); }
|
|
392
|
+
if (updates.groupName !== undefined) { sets.push("group_name = ?"); vals.push(updates.groupName); }
|
|
393
|
+
if (updates.color !== undefined) { sets.push("color = ?"); vals.push(updates.color); }
|
|
394
|
+
if (sets.length === 0) return;
|
|
395
|
+
sets.push("updated_at = datetime('now')");
|
|
396
|
+
vals.push(id);
|
|
397
|
+
getDb().query(`UPDATE connections SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
398
|
+
}
|
|
399
|
+
|
|
302
400
|
// Auto-close on process exit
|
|
303
401
|
process.on("beforeExit", closeDb);
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import postgres from "postgres";
|
|
2
|
+
|
|
3
|
+
export interface PgTableInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
schema: string;
|
|
6
|
+
rowCount: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PgColumnInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
type: string;
|
|
12
|
+
nullable: boolean;
|
|
13
|
+
pk: boolean;
|
|
14
|
+
defaultValue: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PgQueryResult {
|
|
18
|
+
columns: string[];
|
|
19
|
+
rows: Record<string, unknown>[];
|
|
20
|
+
rowsAffected: number;
|
|
21
|
+
changeType: "select" | "modify";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Auto-close idle connections after 5 minutes */
|
|
25
|
+
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
interface CachedConn {
|
|
28
|
+
sql: postgres.Sql;
|
|
29
|
+
timer: ReturnType<typeof setTimeout>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class PostgresService {
|
|
33
|
+
private cache = new Map<string, CachedConn>();
|
|
34
|
+
|
|
35
|
+
/** Get or create a cached connection */
|
|
36
|
+
private connect(connectionString: string): postgres.Sql {
|
|
37
|
+
const cached = this.cache.get(connectionString);
|
|
38
|
+
if (cached) {
|
|
39
|
+
clearTimeout(cached.timer);
|
|
40
|
+
cached.timer = setTimeout(() => this.disconnect(connectionString), IDLE_TIMEOUT_MS);
|
|
41
|
+
return cached.sql;
|
|
42
|
+
}
|
|
43
|
+
const sql = postgres(connectionString, { max: 3, idle_timeout: 60 });
|
|
44
|
+
const timer = setTimeout(() => this.disconnect(connectionString), IDLE_TIMEOUT_MS);
|
|
45
|
+
this.cache.set(connectionString, { sql, timer });
|
|
46
|
+
return sql;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Close and remove from cache */
|
|
50
|
+
private async disconnect(connectionString: string) {
|
|
51
|
+
const cached = this.cache.get(connectionString);
|
|
52
|
+
if (!cached) return;
|
|
53
|
+
clearTimeout(cached.timer);
|
|
54
|
+
try { await cached.sql.end(); } catch { /* already closed */ }
|
|
55
|
+
this.cache.delete(connectionString);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Test connection */
|
|
59
|
+
async testConnection(connectionString: string): Promise<{ ok: boolean; error?: string }> {
|
|
60
|
+
try {
|
|
61
|
+
const sql = this.connect(connectionString);
|
|
62
|
+
await sql`SELECT 1`;
|
|
63
|
+
return { ok: true };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { ok: false, error: (e as Error).message };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** List all user tables with row counts */
|
|
70
|
+
async getTables(connectionString: string): Promise<PgTableInfo[]> {
|
|
71
|
+
const sql = this.connect(connectionString);
|
|
72
|
+
const tables = await sql`
|
|
73
|
+
SELECT t.schemaname as schema, t.tablename as name,
|
|
74
|
+
COALESCE(s.n_live_tup, 0)::int as row_count
|
|
75
|
+
FROM pg_tables t
|
|
76
|
+
LEFT JOIN pg_stat_user_tables s ON t.schemaname = s.schemaname AND t.tablename = s.relname
|
|
77
|
+
WHERE t.schemaname NOT IN ('pg_catalog', 'information_schema')
|
|
78
|
+
ORDER BY t.schemaname, t.tablename
|
|
79
|
+
`;
|
|
80
|
+
return tables.map((t) => ({
|
|
81
|
+
name: t.name as string, schema: t.schema as string, rowCount: t.row_count as number,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get column schema for a table */
|
|
86
|
+
async getTableSchema(connectionString: string, table: string, schema = "public"): Promise<PgColumnInfo[]> {
|
|
87
|
+
const sql = this.connect(connectionString);
|
|
88
|
+
const cols = await sql`
|
|
89
|
+
SELECT c.column_name as name, c.data_type as type,
|
|
90
|
+
c.is_nullable = 'YES' as nullable, c.column_default as default_value,
|
|
91
|
+
COALESCE(tc.constraint_type = 'PRIMARY KEY', false) as pk
|
|
92
|
+
FROM information_schema.columns c
|
|
93
|
+
LEFT JOIN information_schema.key_column_usage kcu
|
|
94
|
+
ON c.table_schema = kcu.table_schema AND c.table_name = kcu.table_name AND c.column_name = kcu.column_name
|
|
95
|
+
LEFT JOIN information_schema.table_constraints tc
|
|
96
|
+
ON kcu.constraint_name = tc.constraint_name AND tc.constraint_type = 'PRIMARY KEY'
|
|
97
|
+
WHERE c.table_schema = ${schema} AND c.table_name = ${table}
|
|
98
|
+
ORDER BY c.ordinal_position
|
|
99
|
+
`;
|
|
100
|
+
return cols.map((c) => ({
|
|
101
|
+
name: c.name as string,
|
|
102
|
+
type: c.type as string,
|
|
103
|
+
nullable: c.nullable as boolean,
|
|
104
|
+
pk: c.pk as boolean,
|
|
105
|
+
defaultValue: c.default_value as string | null,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Get paginated rows from a table */
|
|
110
|
+
async getTableData(
|
|
111
|
+
connectionString: string, table: string, schema = "public",
|
|
112
|
+
page = 1, limit = 100, orderBy?: string, orderDir: "ASC" | "DESC" = "ASC",
|
|
113
|
+
): Promise<{ columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }> {
|
|
114
|
+
const sql = this.connect(connectionString);
|
|
115
|
+
const fullTable = sql(`${schema}.${table}`);
|
|
116
|
+
|
|
117
|
+
const [countRow] = await sql`SELECT COUNT(*)::int as cnt FROM ${fullTable}`;
|
|
118
|
+
const total = (countRow?.cnt as number) ?? 0;
|
|
119
|
+
const offset = (page - 1) * limit;
|
|
120
|
+
|
|
121
|
+
let rows: postgres.RowList<postgres.Row[]>;
|
|
122
|
+
if (orderBy) {
|
|
123
|
+
const orderCol = sql(orderBy);
|
|
124
|
+
rows = orderDir === "DESC"
|
|
125
|
+
? await sql`SELECT * FROM ${fullTable} ORDER BY ${orderCol} DESC LIMIT ${limit} OFFSET ${offset}`
|
|
126
|
+
: await sql`SELECT * FROM ${fullTable} ORDER BY ${orderCol} ASC LIMIT ${limit} OFFSET ${offset}`;
|
|
127
|
+
} else {
|
|
128
|
+
rows = await sql`SELECT * FROM ${fullTable} LIMIT ${limit} OFFSET ${offset}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const columns = rows.length > 0 ? Object.keys(rows[0]!) : [];
|
|
132
|
+
return { columns, rows: rows as unknown as Record<string, unknown>[], total, page, limit };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Execute arbitrary SQL */
|
|
136
|
+
async executeQuery(connectionString: string, sqlText: string): Promise<PgQueryResult> {
|
|
137
|
+
const sql = this.connect(connectionString);
|
|
138
|
+
const trimmed = sqlText.trim().toUpperCase();
|
|
139
|
+
const isSelect = trimmed.startsWith("SELECT") || trimmed.startsWith("EXPLAIN") ||
|
|
140
|
+
trimmed.startsWith("SHOW") || trimmed.startsWith("\\D");
|
|
141
|
+
|
|
142
|
+
if (isSelect) {
|
|
143
|
+
const rows = await sql.unsafe(sqlText);
|
|
144
|
+
const columns = rows.length > 0 ? Object.keys(rows[0]!) : [];
|
|
145
|
+
return { columns, rows: rows as unknown as Record<string, unknown>[], rowsAffected: 0, changeType: "select" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = await sql.unsafe(sqlText);
|
|
149
|
+
return { columns: [], rows: [], rowsAffected: result.count ?? 0, changeType: "modify" };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Update a single cell value */
|
|
153
|
+
async updateCell(
|
|
154
|
+
connectionString: string, table: string, schema = "public",
|
|
155
|
+
pkColumn: string, pkValue: unknown, column: string, value: unknown,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
const sql = this.connect(connectionString);
|
|
158
|
+
await sql.unsafe(
|
|
159
|
+
`UPDATE "${schema}"."${table}" SET "${column}" = $1 WHERE "${pkColumn}" = $2`,
|
|
160
|
+
[value as never, pkValue as never],
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Close all cached connections */
|
|
165
|
+
async closeAll() {
|
|
166
|
+
for (const key of this.cache.keys()) await this.disconnect(key);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const postgresService = new PostgresService();
|
package/src/web/app.tsx
CHANGED
|
@@ -58,8 +58,13 @@ export function App() {
|
|
|
58
58
|
}
|
|
59
59
|
}, [theme]);
|
|
60
60
|
|
|
61
|
-
// Fetch server info on mount (before auth — shown on login screen)
|
|
62
|
-
useEffect(() => {
|
|
61
|
+
// Fetch server info + keybindings on mount (before auth — shown on login screen)
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
fetchServerInfo();
|
|
64
|
+
import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
|
|
65
|
+
useKeybindingsStore.getState().loadFromServer();
|
|
66
|
+
});
|
|
67
|
+
}, [fetchServerInfo]);
|
|
63
68
|
|
|
64
69
|
// Auth check on mount
|
|
65
70
|
useEffect(() => {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
GitBranch,
|
|
6
6
|
GitCommitHorizontal,
|
|
7
7
|
Settings,
|
|
8
|
+
Database,
|
|
8
9
|
Search,
|
|
9
10
|
FileCode,
|
|
10
11
|
FolderOpen,
|
|
@@ -116,6 +117,7 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
116
117
|
{ id: "terminal", label: "New Terminal", icon: Terminal, action: openNewTab("terminal", "Terminal"), keywords: "bash shell console", group: "action" },
|
|
117
118
|
{ id: "chat", label: "New AI Chat", icon: MessageSquare, action: openNewTab("chat", "AI Chat"), keywords: "ai assistant claude", group: "action" },
|
|
118
119
|
{ id: "git-graph", label: "Git Graph", icon: GitBranch, action: openNewTab("git-graph", "Git Graph"), keywords: "branch history log", group: "action" },
|
|
120
|
+
{ id: "postgres", label: "PostgreSQL", icon: Database, action: openNewTab("postgres", "PostgreSQL"), keywords: "database pg sql query", group: "action" },
|
|
119
121
|
{ id: "git-status", label: "Git Status", icon: GitCommitHorizontal, action: () => { setSidebarActiveTab("git"); onClose(); }, keywords: "changes diff staged", group: "action" },
|
|
120
122
|
{
|
|
121
123
|
id: "settings", label: "Settings", icon: Settings,
|
|
@@ -18,6 +18,7 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
18
18
|
chat: lazy(() => import("@/components/chat/chat-tab").then((m) => ({ default: m.ChatTab }))),
|
|
19
19
|
editor: lazy(() => import("@/components/editor/code-editor").then((m) => ({ default: m.CodeEditor }))),
|
|
20
20
|
sqlite: lazy(() => import("@/components/sqlite/sqlite-viewer").then((m) => ({ default: m.SqliteViewer }))),
|
|
21
|
+
postgres: lazy(() => import("@/components/postgres/postgres-viewer").then((m) => ({ default: m.PostgresViewer }))),
|
|
21
22
|
"git-graph": lazy(() => import("@/components/git/git-graph").then((m) => ({ default: m.GitGraph }))),
|
|
22
23
|
"git-diff": lazy(() => import("@/components/editor/diff-viewer").then((m) => ({ default: m.DiffViewer }))),
|
|
23
24
|
settings: lazy(() => import("@/components/settings/settings-tab").then((m) => ({ default: m.SettingsTab }))),
|
|
@@ -21,7 +21,7 @@ const NEW_TAB_OPTIONS: { type: TabType; label: string }[] = [
|
|
|
21
21
|
const NEW_TAB_LABELS: Partial<Record<TabType, string>> = Object.fromEntries(NEW_TAB_OPTIONS.map((o) => [o.type, o.label]));
|
|
22
22
|
|
|
23
23
|
const TAB_ICONS: Record<TabType, React.ElementType> = {
|
|
24
|
-
terminal: Terminal, chat: MessageSquare, editor: FileCode, sqlite: Database,
|
|
24
|
+
terminal: Terminal, chat: MessageSquare, editor: FileCode, sqlite: Database, postgres: Database,
|
|
25
25
|
"git-graph": GitBranch, "git-diff": FileDiff, settings: Settings,
|
|
26
26
|
};
|
|
27
27
|
|
|
@@ -23,6 +23,11 @@ const TAB_COMPONENTS: Record<TabType, React.LazyExoticComponent<React.ComponentT
|
|
|
23
23
|
default: m.SqliteViewer,
|
|
24
24
|
})),
|
|
25
25
|
),
|
|
26
|
+
postgres: lazy(() =>
|
|
27
|
+
import("@/components/postgres/postgres-viewer").then((m) => ({
|
|
28
|
+
default: m.PostgresViewer,
|
|
29
|
+
})),
|
|
30
|
+
),
|
|
26
31
|
"git-graph": lazy(() =>
|
|
27
32
|
import("@/components/git/git-graph").then((m) => ({
|
|
28
33
|
default: m.GitGraph,
|