@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/bun.lock +3 -0
  3. package/dist/web/assets/api-client-D0pZeYY8.js +1 -0
  4. package/dist/web/assets/{chat-tab-CjKO_uYf.js → chat-tab-DjE_8Csw.js} +5 -5
  5. package/dist/web/assets/code-editor-witrClmz.js +1 -0
  6. package/dist/web/assets/diff-viewer-DSU--yFW.js +4 -0
  7. package/dist/web/assets/dist-PpKqMvyx.js +16 -0
  8. package/dist/web/assets/git-graph-HpcOYt3G.js +1 -0
  9. package/dist/web/assets/index-CcXQ5iQw.js +21 -0
  10. package/dist/web/assets/index-DyEgsogR.css +2 -0
  11. package/dist/web/assets/input-CCCPR1s4.js +41 -0
  12. package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
  13. package/dist/web/assets/keybindings-store-C_KQKrsc.js +1 -0
  14. package/dist/web/assets/{markdown-renderer-BKfKwtec.js → markdown-renderer-DSw-4oxk.js} +1 -1
  15. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +1 -0
  16. package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
  17. package/dist/web/assets/react-l9v2XLcs.js +1 -0
  18. package/dist/web/assets/{settings-store-BGF8--S9.js → settings-store-B5g1Gis-.js} +1 -1
  19. package/dist/web/assets/settings-tab-DpQdg9OW.js +1 -0
  20. package/dist/web/assets/sqlite-viewer-JZvegGV-.js +1 -0
  21. package/dist/web/assets/{tab-store-L0a7ao4c.js → tab-store-DhXold0e.js} +1 -1
  22. package/dist/web/assets/{terminal-tab-CmdZtyZW.js → terminal-tab-CAQvs2wj.js} +1 -1
  23. package/dist/web/assets/{use-monaco-theme-RFoGvnp0.js → use-monaco-theme-GX0lrqac.js} +1 -1
  24. package/dist/web/index.html +10 -9
  25. package/dist/web/sw.js +1 -1
  26. package/package.json +2 -1
  27. package/src/cli/commands/db-cmd.ts +338 -0
  28. package/src/server/index.ts +2 -0
  29. package/src/server/routes/postgres.ts +92 -0
  30. package/src/server/routes/settings.ts +33 -0
  31. package/src/services/db.service.ts +99 -1
  32. package/src/services/postgres.service.ts +170 -0
  33. package/src/web/app.tsx +7 -2
  34. package/src/web/components/layout/command-palette.tsx +2 -0
  35. package/src/web/components/layout/editor-panel.tsx +1 -0
  36. package/src/web/components/layout/mobile-nav.tsx +1 -1
  37. package/src/web/components/layout/tab-bar.tsx +1 -0
  38. package/src/web/components/layout/tab-content.tsx +5 -0
  39. package/src/web/components/postgres/postgres-viewer.tsx +264 -0
  40. package/src/web/components/postgres/use-postgres.ts +128 -0
  41. package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
  42. package/src/web/components/settings/settings-tab.tsx +5 -0
  43. package/src/web/hooks/use-global-keybindings.ts +74 -14
  44. package/src/web/stores/keybindings-store.ts +192 -0
  45. package/src/web/stores/tab-store.ts +1 -0
  46. package/dist/web/assets/api-client-ANLU-Irq.js +0 -1
  47. package/dist/web/assets/code-editor-CCvD-8SS.js +0 -1
  48. package/dist/web/assets/diff-viewer-D_bM4Kmw.js +0 -4
  49. package/dist/web/assets/git-graph-zmdDLInW.js +0 -1
  50. package/dist/web/assets/index-CP_2zE5O.css +0 -2
  51. package/dist/web/assets/index-l7z-nYoz.js +0 -21
  52. package/dist/web/assets/input-DV4tynJq.js +0 -41
  53. package/dist/web/assets/react-WvgCEYPV.js +0 -1
  54. package/dist/web/assets/rotate-ccw-BesidNnx.js +0 -1
  55. package/dist/web/assets/settings-tab-CP5UZGRD.js +0 -1
  56. package/dist/web/assets/sqlite-viewer-C1MIuoOX.js +0 -16
  57. /package/dist/web/assets/{utils-C2KxHr1H.js → utils-CAPYyGV3.js} +0 -0
@@ -0,0 +1,264 @@
1
+ import { useState, useCallback, useMemo } from "react";
2
+ import { Database, Loader2, AlertCircle, Play, ChevronLeft, ChevronRight, Table, 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 } from "@codemirror/lang-sql";
6
+ import { usePostgres, type PgColumnInfo, type PgQueryResult } from "./use-postgres";
7
+
8
+ interface Props { metadata?: Record<string, unknown>; tabId?: string }
9
+
10
+ export function PostgresViewer({ metadata }: Props) {
11
+ const initialConn = (metadata?.connectionString as string) ?? "";
12
+ const pg = usePostgres();
13
+
14
+ if (!pg.connected) return <ConnectionForm initialValue={initialConn} onConnect={pg.connect} loading={pg.loading} error={pg.error} />;
15
+
16
+ return <ConnectedView pg={pg} />;
17
+ }
18
+
19
+ /* ---------- Connection Form ---------- */
20
+ function ConnectionForm({ initialValue, onConnect, loading, error }: {
21
+ initialValue: string; onConnect: (s: string) => void; loading: boolean; error: string | null;
22
+ }) {
23
+ const [value, setValue] = useState(initialValue);
24
+ return (
25
+ <div className="flex items-center justify-center h-full">
26
+ <div className="flex flex-col gap-3 w-full max-w-lg px-4">
27
+ <div className="flex items-center gap-2 text-sm font-medium"><Database className="size-4" /> Connect to PostgreSQL</div>
28
+ <input
29
+ className="w-full px-3 py-2 rounded border border-border bg-background text-sm font-mono outline-none focus:border-primary"
30
+ placeholder="postgresql://user:pass@host:5432/db"
31
+ value={value} onChange={(e) => setValue(e.target.value)}
32
+ onKeyDown={(e) => { if (e.key === "Enter" && value.trim()) onConnect(value.trim()); }}
33
+ />
34
+ {error && <p className="text-xs text-destructive flex items-center gap-1"><AlertCircle className="size-3" />{error}</p>}
35
+ <button type="button" disabled={loading || !value.trim()} onClick={() => onConnect(value.trim())}
36
+ className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50 transition-colors">
37
+ {loading ? <Loader2 className="size-4 animate-spin mx-auto" /> : "Connect"}
38
+ </button>
39
+ </div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ /* ---------- Connected View ---------- */
45
+ function ConnectedView({ pg }: { pg: ReturnType<typeof usePostgres> }) {
46
+ const [queryPanelOpen, setQueryPanelOpen] = useState(false);
47
+
48
+ return (
49
+ <div className="flex h-full w-full overflow-hidden">
50
+ {/* Table sidebar */}
51
+ <div className="w-48 shrink-0 flex flex-col bg-background overflow-hidden">
52
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
53
+ <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Tables</span>
54
+ <button type="button" onClick={pg.refreshTables} className="text-muted-foreground hover:text-foreground transition-colors" title="Refresh">
55
+ <RefreshCw className="size-3" />
56
+ </button>
57
+ </div>
58
+ <div className="flex-1 overflow-y-auto">
59
+ {pg.tables.map((t) => (
60
+ <button key={`${t.schema}.${t.name}`} type="button" onClick={() => pg.selectTable(t.name, t.schema)}
61
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors ${
62
+ pg.selectedTable === t.name ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"}`}>
63
+ <Table className="size-3 shrink-0" />
64
+ <span className="truncate flex-1">{t.schema !== "public" ? `${t.schema}.` : ""}{t.name}</span>
65
+ <span className="text-[10px] opacity-60">{t.rowCount}</span>
66
+ </button>
67
+ ))}
68
+ {pg.tables.length === 0 && <p className="px-3 py-4 text-xs text-muted-foreground text-center">No tables found</p>}
69
+ </div>
70
+ </div>
71
+
72
+ {/* Main area */}
73
+ <div className="flex-1 flex flex-col overflow-hidden border-l border-border">
74
+ <div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-background shrink-0">
75
+ <Database className="size-3.5 text-muted-foreground" />
76
+ <span className="text-xs text-muted-foreground truncate">PostgreSQL</span>
77
+ {pg.selectedTable && <span className="text-xs text-muted-foreground">/ {pg.selectedTable}</span>}
78
+ <div className="ml-auto">
79
+ <button type="button" onClick={() => setQueryPanelOpen((v) => !v)}
80
+ className={`px-2 py-1 rounded text-xs transition-colors ${queryPanelOpen ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"}`}>
81
+ SQL
82
+ </button>
83
+ </div>
84
+ </div>
85
+
86
+ <div className={`flex-1 overflow-hidden ${queryPanelOpen ? "max-h-[60%]" : ""}`}>
87
+ <PgDataGrid tableData={pg.tableData} schema={pg.schema} loading={pg.loading}
88
+ page={pg.page} onPageChange={pg.setPage} onCellUpdate={pg.updateCell} />
89
+ </div>
90
+
91
+ {queryPanelOpen && (
92
+ <div className="border-t border-border h-[40%] shrink-0">
93
+ <PgQueryEditor onExecute={pg.executeQuery} result={pg.queryResult} error={pg.queryError} loading={pg.queryLoading} />
94
+ </div>
95
+ )}
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ /* ---------- Data Grid ---------- */
102
+ function PgDataGrid({ tableData, schema, loading, page, onPageChange, onCellUpdate }: {
103
+ tableData: { columns: string[]; rows: Record<string, unknown>[]; total: number; limit: number } | null;
104
+ schema: PgColumnInfo[]; loading: boolean; page: number;
105
+ onPageChange: (p: number) => void;
106
+ onCellUpdate: (pkCol: string, pkVal: unknown, col: string, val: unknown) => void;
107
+ }) {
108
+ const [editingCell, setEditingCell] = useState<{ rowIdx: number; col: string } | null>(null);
109
+ const [editValue, setEditValue] = useState("");
110
+
111
+ const pkCol = useMemo(() => schema.find((c) => c.pk)?.name ?? null, [schema]);
112
+
113
+ const startEdit = useCallback((rowIdx: number, col: string, val: unknown) => {
114
+ setEditingCell({ rowIdx, col });
115
+ setEditValue(val == null ? "" : String(val));
116
+ }, []);
117
+
118
+ const commitEdit = useCallback(() => {
119
+ if (!editingCell || !tableData || !pkCol) return;
120
+ const row = tableData.rows[editingCell.rowIdx];
121
+ if (!row) return;
122
+ const oldVal = row[editingCell.col];
123
+ if (String(oldVal ?? "") !== editValue) {
124
+ onCellUpdate(pkCol, row[pkCol], editingCell.col, editValue === "" ? null : editValue);
125
+ }
126
+ setEditingCell(null);
127
+ }, [editingCell, editValue, tableData, pkCol, onCellUpdate]);
128
+
129
+ const cancelEdit = useCallback(() => setEditingCell(null), []);
130
+
131
+ const columns = useMemo<ColumnDef<Record<string, unknown>>[]>(() =>
132
+ (tableData?.columns ?? []).map((col) => ({
133
+ id: col,
134
+ accessorFn: (row) => row[col],
135
+ header: () => <span className={schema.find((c) => c.name === col)?.pk ? "font-bold" : ""}>{col}</span>,
136
+ cell: ({ row, getValue }) => {
137
+ const isEditing = editingCell?.rowIdx === row.index && editingCell?.col === col;
138
+ const val = getValue();
139
+ if (isEditing) {
140
+ return (
141
+ <input autoFocus className="w-full bg-transparent border border-primary/50 rounded px-1 py-0 text-xs outline-none"
142
+ value={editValue} onChange={(e) => setEditValue(e.target.value)}
143
+ onBlur={commitEdit} onKeyDown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") cancelEdit(); }} />
144
+ );
145
+ }
146
+ return (
147
+ <span className={`cursor-pointer truncate block ${val == null ? "text-muted-foreground/40 italic" : ""}`}
148
+ onDoubleClick={() => pkCol && startEdit(row.index, col, val)} title={val == null ? "NULL" : String(val)}>
149
+ {val == null ? "NULL" : String(val)}
150
+ </span>
151
+ );
152
+ },
153
+ })),
154
+ [tableData?.columns, schema, editingCell, editValue, commitEdit, cancelEdit, startEdit, pkCol]);
155
+
156
+ const table = useReactTable({ data: tableData?.rows ?? [], columns, getCoreRowModel: getCoreRowModel() });
157
+
158
+ if (!tableData) {
159
+ return (
160
+ <div className="flex items-center justify-center h-full text-xs text-muted-foreground">
161
+ {loading ? <Loader2 className="size-4 animate-spin" /> : "Select a table"}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ const totalPages = Math.ceil(tableData.total / tableData.limit) || 1;
167
+
168
+ return (
169
+ <div className="flex flex-col h-full overflow-hidden">
170
+ <div className="flex-1 overflow-auto">
171
+ <table className="w-full text-xs border-collapse">
172
+ <thead className="sticky top-0 z-10 bg-muted">
173
+ {table.getHeaderGroups().map((hg) => (
174
+ <tr key={hg.id}>
175
+ {hg.headers.map((h) => (
176
+ <th key={h.id} className="px-2 py-1.5 text-left font-medium text-muted-foreground border-b border-border whitespace-nowrap">
177
+ {flexRender(h.column.columnDef.header, h.getContext())}
178
+ </th>
179
+ ))}
180
+ </tr>
181
+ ))}
182
+ </thead>
183
+ <tbody>
184
+ {table.getRowModel().rows.map((row) => (
185
+ <tr key={row.id} className="hover:bg-muted/30 border-b border-border/50">
186
+ {row.getVisibleCells().map((cell) => (
187
+ <td key={cell.id} className="px-2 py-1 max-w-[300px]">
188
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
189
+ </td>
190
+ ))}
191
+ </tr>
192
+ ))}
193
+ {tableData.rows.length === 0 && (
194
+ <tr><td colSpan={tableData.columns.length} className="px-2 py-8 text-center text-muted-foreground">No data</td></tr>
195
+ )}
196
+ </tbody>
197
+ </table>
198
+ </div>
199
+ <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">
200
+ <span>{tableData.total.toLocaleString()} rows</span>
201
+ <div className="flex items-center gap-2">
202
+ <button type="button" disabled={page <= 1} onClick={() => onPageChange(page - 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
203
+ <ChevronLeft className="size-3.5" />
204
+ </button>
205
+ <span>{page} / {totalPages}</span>
206
+ <button type="button" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)} className="p-0.5 rounded hover:bg-muted disabled:opacity-30">
207
+ <ChevronRight className="size-3.5" />
208
+ </button>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ );
213
+ }
214
+
215
+ /* ---------- Query Editor ---------- */
216
+ function PgQueryEditor({ onExecute, result, error, loading }: {
217
+ onExecute: (sql: string) => void; result: PgQueryResult | null; error: string | null; loading: boolean;
218
+ }) {
219
+ const [query, setQuery] = useState("SELECT * FROM ");
220
+
221
+ const handleExecute = useCallback(() => { const t = query.trim(); if (t) onExecute(t); }, [query, onExecute]);
222
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
223
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleExecute(); }
224
+ }, [handleExecute]);
225
+
226
+ return (
227
+ <div className="flex flex-col h-full overflow-hidden">
228
+ <div className="flex items-start gap-1 border-b border-border bg-background" onKeyDown={handleKeyDown}>
229
+ <div className="flex-1 max-h-[120px] overflow-auto">
230
+ <CodeMirror value={query} onChange={setQuery} extensions={[sql({ dialect: PostgreSQL })]}
231
+ basicSetup={{ lineNumbers: false, foldGutter: false, highlightActiveLine: false }}
232
+ className="text-xs [&_.cm-editor]:!outline-none [&_.cm-scroller]:!overflow-auto" />
233
+ </div>
234
+ <button type="button" onClick={handleExecute} disabled={loading} title="Execute (Cmd+Enter)"
235
+ className="shrink-0 m-1 p-1.5 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors">
236
+ {loading ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
237
+ </button>
238
+ </div>
239
+ <div className="flex-1 overflow-auto text-xs">
240
+ {error && <div className="px-3 py-2 text-destructive bg-destructive/5">{error}</div>}
241
+ {result?.changeType === "modify" && <div className="px-3 py-2 text-green-500">Query executed. {result.rowsAffected} row(s) affected.</div>}
242
+ {result?.changeType === "select" && result.rows.length > 0 && (
243
+ <table className="w-full border-collapse">
244
+ <thead className="sticky top-0 bg-muted">
245
+ <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>
246
+ </thead>
247
+ <tbody>
248
+ {result.rows.map((row, i) => (
249
+ <tr key={i} className="hover:bg-muted/30 border-b border-border/50">
250
+ {result.columns.map((c) => (
251
+ <td key={c} className="px-2 py-1 max-w-[300px] truncate" title={row[c] == null ? "NULL" : String(row[c])}>
252
+ {row[c] == null ? <span className="text-muted-foreground/40 italic">NULL</span> : String(row[c])}
253
+ </td>
254
+ ))}
255
+ </tr>
256
+ ))}
257
+ </tbody>
258
+ </table>
259
+ )}
260
+ {result?.changeType === "select" && result.rows.length === 0 && <div className="px-3 py-2 text-muted-foreground">No results</div>}
261
+ </div>
262
+ </div>
263
+ );
264
+ }
@@ -0,0 +1,128 @@
1
+ import { useState, useCallback } from "react";
2
+ import { api } from "@/lib/api-client";
3
+
4
+ export interface PgTableInfo { name: string; schema: string; rowCount: number }
5
+ export interface PgColumnInfo { name: string; type: string; nullable: boolean; pk: boolean; defaultValue: string | null }
6
+ export interface PgQueryResult { columns: string[]; rows: Record<string, unknown>[]; rowsAffected: number; changeType: "select" | "modify" }
7
+ interface PgTableData { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number }
8
+
9
+ const BASE = "/api/postgres";
10
+
11
+ export function usePostgres() {
12
+ const [connectionString, setConnectionString] = useState("");
13
+ const [connected, setConnected] = useState(false);
14
+ const [tables, setTables] = useState<PgTableInfo[]>([]);
15
+ const [selectedTable, setSelectedTable] = useState<string | null>(null);
16
+ const [selectedSchema, setSelectedSchema] = useState("public");
17
+ const [tableData, setTableData] = useState<PgTableData | null>(null);
18
+ const [schema, setSchema] = useState<PgColumnInfo[]>([]);
19
+ const [loading, setLoading] = useState(false);
20
+ const [error, setError] = useState<string | null>(null);
21
+ const [page, setPage] = useState(1);
22
+ const [queryResult, setQueryResult] = useState<PgQueryResult | null>(null);
23
+ const [queryError, setQueryError] = useState<string | null>(null);
24
+ const [queryLoading, setQueryLoading] = useState(false);
25
+
26
+ const connect = useCallback(async (connStr: string) => {
27
+ setLoading(true);
28
+ setError(null);
29
+ try {
30
+ const test = await api.post<{ ok: boolean; error?: string }>(`${BASE}/test`, { connectionString: connStr });
31
+ if (!test.ok) { setError(test.error ?? "Connection failed"); return; }
32
+ setConnectionString(connStr);
33
+ setConnected(true);
34
+ // Fetch tables
35
+ const data = await api.post<PgTableInfo[]>(`${BASE}/tables`, { connectionString: connStr });
36
+ setTables(data);
37
+ if (data.length > 0) {
38
+ setSelectedTable(data[0]!.name);
39
+ setSelectedSchema(data[0]!.schema);
40
+ }
41
+ } catch (e) {
42
+ setError((e as Error).message);
43
+ } finally {
44
+ setLoading(false);
45
+ }
46
+ }, []);
47
+
48
+ const fetchTables = useCallback(async () => {
49
+ if (!connectionString) return;
50
+ setLoading(true);
51
+ try {
52
+ const data = await api.post<PgTableInfo[]>(`${BASE}/tables`, { connectionString });
53
+ setTables(data);
54
+ } catch (e) {
55
+ setError((e as Error).message);
56
+ } finally {
57
+ setLoading(false);
58
+ }
59
+ }, [connectionString]);
60
+
61
+ const fetchTableData = useCallback(async (table?: string, tableSchema?: string, p?: number) => {
62
+ const t = table ?? selectedTable;
63
+ const s = tableSchema ?? selectedSchema;
64
+ if (!connectionString || !t) return;
65
+ setLoading(true);
66
+ try {
67
+ const [data, cols] = await Promise.all([
68
+ api.post<PgTableData>(`${BASE}/data`, { connectionString, table: t, schema: s, page: p ?? page, limit: 100 }),
69
+ api.post<PgColumnInfo[]>(`${BASE}/schema`, { connectionString, table: t, schema: s }),
70
+ ]);
71
+ setTableData(data);
72
+ setSchema(cols);
73
+ } catch (e) {
74
+ setError((e as Error).message);
75
+ } finally {
76
+ setLoading(false);
77
+ }
78
+ }, [connectionString, selectedTable, selectedSchema, page]);
79
+
80
+ const selectTable = useCallback((name: string, tableSchema = "public") => {
81
+ setSelectedTable(name);
82
+ setSelectedSchema(tableSchema);
83
+ setPage(1);
84
+ setQueryResult(null);
85
+ fetchTableData(name, tableSchema, 1);
86
+ }, [fetchTableData]);
87
+
88
+ const changePage = useCallback((p: number) => {
89
+ setPage(p);
90
+ fetchTableData(undefined, undefined, p);
91
+ }, [fetchTableData]);
92
+
93
+ const executeQuery = useCallback(async (sql: string) => {
94
+ if (!connectionString) return;
95
+ setQueryLoading(true);
96
+ setQueryError(null);
97
+ try {
98
+ const result = await api.post<PgQueryResult>(`${BASE}/query`, { connectionString, sql });
99
+ setQueryResult(result);
100
+ if (result.changeType === "modify") fetchTableData();
101
+ } catch (e) {
102
+ setQueryError((e as Error).message);
103
+ } finally {
104
+ setQueryLoading(false);
105
+ }
106
+ }, [connectionString, fetchTableData]);
107
+
108
+ const updateCell = useCallback(async (pkColumn: string, pkValue: unknown, column: string, value: unknown) => {
109
+ if (!connectionString || !selectedTable) return;
110
+ try {
111
+ await api.post(`${BASE}/cell`, {
112
+ connectionString, table: selectedTable, schema: selectedSchema,
113
+ pkColumn, pkValue, column, value,
114
+ });
115
+ fetchTableData();
116
+ } catch (e) {
117
+ setError((e as Error).message);
118
+ }
119
+ }, [connectionString, selectedTable, selectedSchema, fetchTableData]);
120
+
121
+ return {
122
+ connectionString, connected, connect,
123
+ tables, selectedTable, selectTable, tableData, schema,
124
+ loading, error, page, setPage: changePage,
125
+ queryResult, queryError, queryLoading, executeQuery,
126
+ updateCell, refreshTables: fetchTables, refreshData: fetchTableData,
127
+ };
128
+ }
@@ -0,0 +1,182 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { RotateCcw, AlertTriangle, Lock } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ KEY_ACTIONS,
6
+ useKeybindingsStore,
7
+ formatCombo,
8
+ comboFromEvent,
9
+ type KeyCategory,
10
+ } from "@/stores/keybindings-store";
11
+
12
+ const CATEGORIES: { key: KeyCategory; label: string }[] = [
13
+ { key: "general", label: "General" },
14
+ { key: "tabs", label: "Tabs" },
15
+ { key: "projects", label: "Projects" },
16
+ ];
17
+
18
+ const BROWSER_RESERVED = [
19
+ "Ctrl+T", "Ctrl+W", "Ctrl+N", "Ctrl+Tab",
20
+ "Ctrl+L", "Ctrl+H", "Ctrl+J", "F5", "Ctrl+R",
21
+ "Ctrl+Shift+I", "Ctrl+Shift+J",
22
+ ];
23
+
24
+ /** A single shortcut badge — click to record, Escape to cancel */
25
+ function ShortcutBadge({
26
+ actionId,
27
+ combo,
28
+ locked,
29
+ }: {
30
+ actionId: string;
31
+ combo: string;
32
+ locked?: boolean;
33
+ }) {
34
+ const [recording, setRecording] = useState(false);
35
+ const setBinding = useKeybindingsStore((s) => s.setBinding);
36
+ const badgeRef = useRef<HTMLButtonElement>(null);
37
+
38
+ const handleRecord = useCallback(
39
+ (e: KeyboardEvent) => {
40
+ e.preventDefault();
41
+ e.stopPropagation();
42
+ if (e.key === "Escape") {
43
+ setRecording(false);
44
+ return;
45
+ }
46
+ const newCombo = comboFromEvent(e);
47
+ if (newCombo) {
48
+ setBinding(actionId, newCombo);
49
+ setRecording(false);
50
+ }
51
+ },
52
+ [actionId, setBinding],
53
+ );
54
+
55
+ useEffect(() => {
56
+ if (!recording) return;
57
+ document.addEventListener("keydown", handleRecord, true);
58
+ return () => document.removeEventListener("keydown", handleRecord, true);
59
+ }, [recording, handleRecord]);
60
+
61
+ // Close recording on outside click
62
+ useEffect(() => {
63
+ if (!recording) return;
64
+ const handler = (e: MouseEvent) => {
65
+ if (badgeRef.current && !badgeRef.current.contains(e.target as Node)) {
66
+ setRecording(false);
67
+ }
68
+ };
69
+ document.addEventListener("mousedown", handler);
70
+ return () => document.removeEventListener("mousedown", handler);
71
+ }, [recording]);
72
+
73
+ if (locked) {
74
+ return (
75
+ <span className="inline-flex items-center gap-1 rounded border border-border bg-muted px-2 py-0.5 text-[11px] font-mono text-muted-foreground">
76
+ <Lock className="size-2.5" />
77
+ {formatCombo(combo)}
78
+ </span>
79
+ );
80
+ }
81
+
82
+ if (recording) {
83
+ return (
84
+ <button
85
+ ref={badgeRef}
86
+ className="inline-flex items-center rounded border-2 border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-mono text-primary animate-pulse"
87
+ >
88
+ Press keys...
89
+ </button>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <button
95
+ ref={badgeRef}
96
+ onClick={() => setRecording(true)}
97
+ className="inline-flex items-center rounded border border-border bg-surface px-2 py-0.5 text-[11px] font-mono text-foreground hover:border-primary hover:bg-primary/5 transition-colors cursor-pointer"
98
+ title="Click to change shortcut"
99
+ >
100
+ {formatCombo(combo)}
101
+ </button>
102
+ );
103
+ }
104
+
105
+ export function KeyboardShortcutsSection() {
106
+ const { getBinding, resetBinding, resetAll, overrides } = useKeybindingsStore();
107
+
108
+ return (
109
+ <div className="space-y-3">
110
+ <div className="flex items-center justify-between">
111
+ <h3 className="text-xs font-medium text-text-secondary">Keyboard Shortcuts</h3>
112
+ {Object.keys(overrides).length > 0 && (
113
+ <Button
114
+ variant="ghost"
115
+ size="sm"
116
+ className="h-6 text-[10px] text-muted-foreground"
117
+ onClick={resetAll}
118
+ >
119
+ <RotateCcw className="size-3 mr-1" />
120
+ Reset all
121
+ </Button>
122
+ )}
123
+ </div>
124
+
125
+ {/* Browser warning */}
126
+ <div className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-2.5 py-2">
127
+ <AlertTriangle className="size-3.5 text-amber-500 shrink-0 mt-0.5" />
128
+ <p className="text-[10px] text-muted-foreground leading-relaxed">
129
+ Some shortcuts ({BROWSER_RESERVED.slice(0, 4).join(", ")}...) are reserved by the browser and cannot be overridden.
130
+ </p>
131
+ </div>
132
+
133
+ {/* Categories */}
134
+ {CATEGORIES.map((cat) => {
135
+ const actions = KEY_ACTIONS.filter((a) => a.category === cat.key);
136
+ if (actions.length === 0) return null;
137
+ return (
138
+ <div key={cat.key} className="space-y-1">
139
+ <span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
140
+ {cat.label}
141
+ </span>
142
+ <div className="space-y-0.5">
143
+ {actions.map((action) => {
144
+ const currentCombo = getBinding(action.id);
145
+ const isOverridden = action.id in overrides;
146
+ return (
147
+ <div
148
+ key={action.id}
149
+ className="flex items-center justify-between py-1 px-1 rounded hover:bg-surface-elevated/50 transition-colors"
150
+ >
151
+ <div className="flex flex-col min-w-0">
152
+ <span className="text-xs text-foreground">{action.label}</span>
153
+ {action.note && (
154
+ <span className="text-[10px] text-muted-foreground">{action.note}</span>
155
+ )}
156
+ </div>
157
+ <div className="flex items-center gap-1 shrink-0 ml-2">
158
+ <ShortcutBadge
159
+ actionId={action.id}
160
+ combo={currentCombo}
161
+ locked={action.locked}
162
+ />
163
+ {isOverridden && !action.locked && (
164
+ <button
165
+ onClick={() => resetBinding(action.id)}
166
+ className="flex items-center justify-center size-5 rounded text-muted-foreground hover:text-foreground hover:bg-surface-elevated transition-colors"
167
+ title="Reset to default"
168
+ >
169
+ <RotateCcw className="size-3" />
170
+ </button>
171
+ )}
172
+ </div>
173
+ </div>
174
+ );
175
+ })}
176
+ </div>
177
+ </div>
178
+ );
179
+ })}
180
+ </div>
181
+ );
182
+ }
@@ -4,6 +4,7 @@ import { Separator } from "@/components/ui/separator";
4
4
  import { useSettingsStore, type Theme } from "@/stores/settings-store";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { AISettingsSection } from "./ai-settings-section";
7
+ import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
7
8
  import { usePushNotification } from "@/hooks/use-push-notification";
8
9
 
9
10
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
@@ -109,6 +110,10 @@ export function SettingsTab() {
109
110
 
110
111
  <Separator />
111
112
 
113
+ <KeyboardShortcutsSection />
114
+
115
+ <Separator />
116
+
112
117
  <div className="space-y-1.5">
113
118
  <h3 className="text-xs font-medium text-text-secondary">About</h3>
114
119
  <p className="text-xs text-text-secondary">