From a8fb0e4f4550328793d185b6afc9d58e4b3ba814 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Wed, 29 Apr 2026 23:11:17 +0530 Subject: [PATCH 01/17] Enhance Whats New feature and telemetry tracking - Added a new command to show "What's New in PgStudio" in the command palette. - Integrated WhatsNewManager into command registration and activation flow. - Removed the TelemetryStatusBar component and adjusted telemetry tracking methods for better performance. - Updated the .gitignore to include new files and directories. - Improved telemetry event tracking for connection management and query execution. --- .gitignore | 4 +- package.json | 5 ++ src/activation/TelemetryStatusBar.ts | 41 --------- src/activation/WhatsNewManager.ts | 122 ++++++++++++++++++++++++++- src/activation/commandRegistry.ts | 3 + src/activation/commandSpecs.ts | 8 ++ src/extension.ts | 27 +++--- src/providers/NotebookKernel.ts | 1 - src/providers/kernel/SqlExecutor.ts | 17 +--- src/services/ConnectionManager.ts | 6 +- src/services/TelemetryService.ts | 35 +++----- 11 files changed, 172 insertions(+), 97 deletions(-) delete mode 100644 src/activation/TelemetryStatusBar.ts diff --git a/.gitignore b/.gitignore index 232593e..cdb87dd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ docs/.next/ .nycrc roadmap.md FIXES_APPLIED.md -.cursor/ \ No newline at end of file +.cursor/ +.agents/ +skills-lock.json diff --git a/package.json b/package.json index 637527a..cdc7f58 100644 --- a/package.json +++ b/package.json @@ -1164,6 +1164,11 @@ "title": "Telemetry: Detailed", "category": "PgStudio" }, + { + "command": "postgres-explorer.showWhatsNew", + "title": "What's New in PgStudio", + "category": "PgStudio" + }, { "command": "postgres-explorer.listTriggers", "title": "List Triggers", diff --git a/src/activation/TelemetryStatusBar.ts b/src/activation/TelemetryStatusBar.ts deleted file mode 100644 index 8b1ceef..0000000 --- a/src/activation/TelemetryStatusBar.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as vscode from 'vscode'; - -const TELEMETRY_CONFIG = 'postgresExplorer.telemetry'; - -/** - * Status bar control for the current telemetry mode. Click opens the mode picker - * (same as Command Palette: PgStudio: Set Telemetry Mode). - */ -export class TelemetryStatusBar implements vscode.Disposable { - private readonly item: vscode.StatusBarItem; - private readonly disposables: vscode.Disposable[] = []; - - constructor() { - this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, -100); - this.item.command = 'postgres-explorer.telemetry.openModePicker'; - this.disposables.push( - this.item, - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(TELEMETRY_CONFIG)) { - this.refresh(); - } - }), - ); - this.refresh(); - this.item.show(); - } - - private refresh(): void { - const mode = vscode.workspace.getConfiguration(TELEMETRY_CONFIG).get('mode', 'basic'); - const icon = - mode === 'off' ? '$(circle-slash)' : mode === 'basic' ? '$(pulse)' : '$(graph-line)'; - this.item.text = `${icon} PgStudio telemetry: ${mode}`; - this.item.tooltip = `Telemetry mode: ${mode}. Click to switch between off, basic, or detailed.`; - } - - dispose(): void { - for (const d of this.disposables) { - d.dispose(); - } - } -} diff --git a/src/activation/WhatsNewManager.ts b/src/activation/WhatsNewManager.ts index be72b3a..37fd840 100644 --- a/src/activation/WhatsNewManager.ts +++ b/src/activation/WhatsNewManager.ts @@ -44,12 +44,25 @@ export class WhatsNewManager { ); panel.webview.html = await this.getWebviewContent(panel.webview, version); + + const messageSub = panel.webview.onDidReceiveMessage(async (message: { type?: string; command?: string }) => { + if (message?.type !== 'runCommand' || typeof message.command !== 'string') { + return; + } + if (!message.command.startsWith('postgres-explorer.')) { + return; + } + await vscode.commands.executeCommand(message.command); + }); + panel.onDidDispose(() => messageSub.dispose()); } private async getWebviewContent(webview: vscode.Webview, version: string): Promise { const changelogContent = await this.getChangelogContent(); const logoPath = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'postgres-explorer.png')); const markedUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'marked.min.js')); + const highlightScriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'highlight.min.js')); + const highlightCssUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'highlight.css')); const encodedChangelog = Buffer.from(changelogContent).toString('base64'); @@ -60,10 +73,14 @@ export class WhatsNewManager { What's New in PgStudio + + - - - -
-

📊 ERD —

- - - - -
- -
-
- - - - - - - - - - -
-
- -
- - - -
- - - -`; } public dispose(): void { this._panel.dispose(); while (this._disposables.length) { const d = this._disposables.pop(); - if (d) { d.dispose(); } + if (d) { + d.dispose(); + } } } } diff --git a/src/schemaDesigner/erd/erdDbmlImport.ts b/src/schemaDesigner/erd/erdDbmlImport.ts new file mode 100644 index 0000000..701d98d --- /dev/null +++ b/src/schemaDesigner/erd/erdDbmlImport.ts @@ -0,0 +1,79 @@ +import { Parser } from '@dbml/core'; + +function identPg(s: string): string { + return `"${String(s).replace(/"/g, '""')}"`; +} + +function fieldTypeToSql(typeVal: unknown): string { + if (typeVal == null) { + return 'text'; + } + if (typeof typeVal === 'string') { + return typeVal; + } + if (typeof typeVal === 'object' && typeVal !== null && 'type_name' in typeVal) { + const o = typeVal as { type_name: string; args?: string | null }; + const base = String(o.type_name); + return o.args ? `${base}(${o.args})` : base; + } + return String(typeVal); +} + +/** + * Parse DBML text and emit PostgreSQL CREATE TABLE statements (best-effort). + * Does not emit FK Ref lines as ALTER (optional follow-up). + */ +export function dbmlToPostgresCreateTables(dbmlText: string): { sql: string[]; errors: string[] } { + const errors: string[] = []; + let sql: string[] = []; + + try { + let db; + try { + db = new Parser().parse(dbmlText.trim(), 'dbmlv2'); + } catch { + db = new Parser().parse(dbmlText.trim(), 'dbml'); + } + const stmts: string[] = []; + + for (const schema of db.schemas) { + const schemaName = schema.name || 'public'; + for (const table of schema.tables) { + const colDefs: string[] = []; + const pkCols: string[] = []; + + for (const field of table.fields) { + const colName = identPg(field.name); + const typ = fieldTypeToSql(field.type); + const parts: string[] = [colName, typ]; + if (field.not_null) { + parts.push('NOT NULL'); + } + if (field.dbdefault != null && String(field.dbdefault).length > 0) { + parts.push(`DEFAULT ${field.dbdefault}`); + } + if (field.unique && !field.pk) { + parts.push('UNIQUE'); + } + colDefs.push(parts.join(' ')); + if (field.pk) { + pkCols.push(colName); + } + } + + if (pkCols.length > 0) { + colDefs.push(`PRIMARY KEY (${pkCols.join(', ')})`); + } + + const tSql = `CREATE TABLE ${identPg(schemaName)}.${identPg(table.name)} (\n ${colDefs.join(',\n ')}\n);`; + stmts.push(tSql); + } + } + + sql = stmts; + } catch (e: unknown) { + errors.push(e instanceof Error ? e.message : String(e)); + } + + return { sql, errors }; +} diff --git a/src/schemaDesigner/erd/erdExportSerializers.ts b/src/schemaDesigner/erd/erdExportSerializers.ts new file mode 100644 index 0000000..5573b9b --- /dev/null +++ b/src/schemaDesigner/erd/erdExportSerializers.ts @@ -0,0 +1,119 @@ +import type { ErdForeignKey, ErdSnapshot, ErdTable } from './erdTypes'; +import { tableQual } from './erdTypes'; + +function mermaidId(schema: string, table: string): string { + const s = `${schema}_${table}`.replace(/[^a-zA-Z0-9_]/g, '_'); + return s.length > 0 ? s : 'T'; +} + +function escMermaidType(t: string): string { + return String(t).replace(/[{}[\]"']/g, '_'); +} + +/** + * Mermaid erDiagram source for the current snapshot (FK layer). + */ +export function buildMermaidErDiagram(snapshot: ErdSnapshot): string { + const lines: string[] = ['erDiagram']; + const seen = new Set(); + + for (const tbl of snapshot.tables) { + const id = mermaidId(tbl.schema, tbl.name); + if (seen.has(id)) { + continue; + } + seen.add(id); + lines.push(` ${id} {`); + for (const c of tbl.columns) { + const marker = c.isPk ? ' PK' : ''; + lines.push(` ${escMermaidType(c.type)} ${c.name.replace(/\s/g, '_')}${marker}`); + } + lines.push(' }'); + } + + const fkSeen = new Set(); + for (const fk of snapshot.foreignKeys) { + const a = mermaidId(fk.fromSchema, fk.fromTable); + const b = mermaidId(fk.toSchema, fk.toTable); + const key = `${fk.constraintName}|${a}|${b}`; + if (fkSeen.has(key)) { + continue; + } + fkSeen.add(key); + lines.push(` ${a} }o--|| ${b} : "${fk.constraintName}"`); + } + + return lines.join('\n'); +} + +function dbmlQuoteIdent(s: string): string { + return `"${String(s).replace(/"/g, '""')}"`; +} + +/** + * DBML document for tables and refs (Postgres-oriented types as-is). + */ +export function buildDbml(snapshot: ErdSnapshot): string { + const blocks: string[] = []; + const tableKeys = new Set(snapshot.tables.map((t) => tableQual(t.schema, t.name))); + + for (const t of snapshot.tables) { + const header = `Table ${dbmlQuoteIdent(t.schema)}.${dbmlQuoteIdent(t.name)} {`; + const fieldLines = t.columns.map((c) => { + const flags: string[] = []; + if (c.isPk) { + flags.push('pk'); + } + if (c.notNull) { + flags.push('not null'); + } + const opt = flags.length > 0 ? ` [${flags.join(', ')}]` : ''; + return ` ${dbmlQuoteIdent(c.name)} ${c.type}${opt}`; + }); + blocks.push([header, ...fieldLines, '}', ''].join('\n')); + } + + let refIdx = 0; + const refSeen = new Set(); + for (const fk of snapshot.foreignKeys) { + const fromQ = `${dbmlQuoteIdent(fk.fromSchema)}.${dbmlQuoteIdent(fk.fromTable)}`; + const toQ = `${dbmlQuoteIdent(fk.toSchema)}.${dbmlQuoteIdent(fk.toTable)}`; + if (!tableKeys.has(tableQual(fk.fromSchema, fk.fromTable)) || !tableKeys.has(tableQual(fk.toSchema, fk.toTable))) { + continue; + } + const key = `${fromQ}.${fk.fromColumn}->${toQ}.${fk.toColumn}`; + if (refSeen.has(key)) { + continue; + } + refSeen.add(key); + refIdx += 1; + blocks.push( + `Ref r${refIdx} {\n ${fromQ}.${dbmlQuoteIdent(fk.fromColumn)} > ${toQ}.${dbmlQuoteIdent(fk.toColumn)}\n}\n` + ); + } + + return blocks.join('\n'); +} + +/** Subset for webview when tables were edited client-side. */ +export function buildMermaidFromTables(tables: ErdTable[], foreignKeys: ErdForeignKey[]): string { + return buildMermaidErDiagram({ + schemas: [...new Set(tables.map((t) => t.schema))].sort(), + tables, + foreignKeys, + indexes: [], + rls: [], + partitions: [], + }); +} + +export function buildDbmlFromTables(tables: ErdTable[], foreignKeys: ErdForeignKey[]): string { + return buildDbml({ + schemas: [...new Set(tables.map((t) => t.schema))].sort(), + tables, + foreignKeys, + indexes: [], + rls: [], + partitions: [], + }); +} diff --git a/src/schemaDesigner/erd/erdMigrationDraft.ts b/src/schemaDesigner/erd/erdMigrationDraft.ts new file mode 100644 index 0000000..ef84d6c --- /dev/null +++ b/src/schemaDesigner/erd/erdMigrationDraft.ts @@ -0,0 +1,54 @@ +/** + * Pure mapping from ERD edit patches to PostgreSQL DDL (reviewed in a notebook). + */ + +export type ErdModelPatch = + | { kind: 'renameTable'; schema: string; from: string; to: string } + | { kind: 'renameColumn'; schema: string; table: string; from: string; to: string } + | { + kind: 'addColumn'; + schema: string; + table: string; + name: string; + dataType: string; + notNull: boolean; + }; + +function ident(s: string): string { + return `"${String(s).replace(/"/g, '""')}"`; +} + +/** + * Produce ordered DDL statements for patches. Caller wraps with transaction boilerplate. + */ +export function patchesToMigrationSql(patches: ErdModelPatch[]): string[] { + const stmts: string[] = []; + const renames = patches.filter((p): p is Extract => p.kind === 'renameTable'); + const colRenames = patches.filter((p): p is Extract => p.kind === 'renameColumn'); + const adds = patches.filter((p): p is Extract => p.kind === 'addColumn'); + + for (const p of renames) { + const a = ident(p.schema); + const f = ident(p.from); + const t = ident(p.to); + stmts.push(`ALTER TABLE ${a}.${f} RENAME TO ${t};`); + } + + for (const p of colRenames) { + const sch = ident(p.schema); + const tbl = ident(p.table); + const c1 = ident(p.from); + const c2 = ident(p.to); + stmts.push(`ALTER TABLE ${sch}.${tbl} RENAME COLUMN ${c1} TO ${c2};`); + } + + for (const p of adds) { + const sch = ident(p.schema); + const tbl = ident(p.table); + const col = ident(p.name); + const nn = p.notNull ? ' NOT NULL' : ''; + stmts.push(`ALTER TABLE ${sch}.${tbl} ADD COLUMN ${col} ${p.dataType.trim()}${nn};`); + } + + return stmts; +} diff --git a/src/schemaDesigner/erd/erdQueries.ts b/src/schemaDesigner/erd/erdQueries.ts new file mode 100644 index 0000000..7da06fd --- /dev/null +++ b/src/schemaDesigner/erd/erdQueries.ts @@ -0,0 +1,256 @@ +import type { + ErdColumn, + ErdForeignKey, + ErdIndexRow, + ErdPartitionEdge, + ErdRlsInfo, + ErdSnapshot, + ErdTable, +} from './erdTypes'; + +export interface PgQueryable { + query: (text: string, params?: unknown[]) => Promise<{ rows: Record[] }>; +} + +const EMPTY_SNAPSHOT: ErdSnapshot = { + schemas: [], + tables: [], + foreignKeys: [], + indexes: [], + rls: [], + partitions: [], +}; + +/** + * Load ERD data for one or more schemas in a single round-trip batch (no per-table column queries). + */ +export async function fetchErdSnapshot(client: PgQueryable, schemas: string[]): Promise { + if (schemas.length === 0) { + return { ...EMPTY_SNAPSHOT, schemas: [] }; + } + + const tablesResult = await client.query( + `SELECT n.nspname AS schema_name, + c.relname AS table_name, + CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::bigint END AS est_rows + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = ANY($1::text[]) + AND c.relkind = 'r' + ORDER BY n.nspname, c.relname`, + [schemas] + ); + + const columnsResult = await client.query( + `SELECT n.nspname AS schema_name, + c.relname AS table_name, + a.attname AS column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, + a.attnotnull AS not_null + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid + WHERE n.nspname = ANY($1::text[]) + AND c.relkind = 'r' + AND a.attnum > 0 + AND NOT a.attisdropped + ORDER BY n.nspname, c.relname, a.attnum`, + [schemas] + ); + + const pkResult = await client.query( + `SELECT tc.table_schema, kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = ANY($1::text[])`, + [schemas] + ); + + const fkColResult = await client.query( + `SELECT tc.table_schema, kcu.table_name, kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = ANY($1::text[])`, + [schemas] + ); + + // Pair FK and referenced PK/UNIQUE columns by ordinal_position. Do not use + // constraint_column_usage for this — in PostgreSQL it has no ordinal_position. + const fkResult = await client.query( + `SELECT tc.constraint_name, + tc.table_schema AS from_schema, + tc.table_name AS from_table, + kcu.column_name AS from_column, + ref_kcu.table_schema AS to_schema, + ref_kcu.table_name AS to_table, + ref_kcu.column_name AS to_column + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_catalog = kcu.constraint_catalog + AND tc.constraint_schema = kcu.constraint_schema + AND tc.constraint_name = kcu.constraint_name + JOIN information_schema.referential_constraints rc + ON tc.constraint_catalog = rc.constraint_catalog + AND tc.constraint_schema = rc.constraint_schema + AND tc.constraint_name = rc.constraint_name + JOIN information_schema.key_column_usage ref_kcu + ON rc.unique_constraint_catalog = ref_kcu.constraint_catalog + AND rc.unique_constraint_schema = ref_kcu.constraint_schema + AND rc.unique_constraint_name = ref_kcu.constraint_name + AND kcu.ordinal_position = ref_kcu.ordinal_position + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = ANY($1::text[]) + ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position`, + [schemas] + ); + + const idxResult = await client.query( + `SELECT n.nspname AS schema_name, + c.relname AS table_name, + i.relname AS index_name + FROM pg_index x + JOIN pg_class c ON c.oid = x.indrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_class i ON i.oid = x.indexrelid + WHERE n.nspname = ANY($1::text[]) + AND c.relkind = 'r' + AND NOT x.indisprimary + ORDER BY n.nspname, c.relname, i.relname`, + [schemas] + ); + + const rlsResult = await client.query( + `SELECT n.nspname AS schema_name, + c.relname AS table_name, + c.relrowsecurity AS relrowsecurity, + COALESCE(array_agg(pol.polname ORDER BY pol.polname) FILTER (WHERE pol.polname IS NOT NULL), '{}') AS policy_names + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_policy pol ON pol.polrelid = c.oid + WHERE n.nspname = ANY($1::text[]) + AND c.relkind = 'r' + GROUP BY n.nspname, c.relname, c.relrowsecurity`, + [schemas] + ); + + const partResult = await client.query( + `SELECT pn.nspname AS parent_schema, + p.relname AS parent_table, + cn.nspname AS child_schema, + c.relname AS child_table + FROM pg_inherits inh + JOIN pg_class p ON p.oid = inh.inhparent + JOIN pg_namespace pn ON pn.oid = p.relnamespace + JOIN pg_class c ON c.oid = inh.inhrelid + JOIN pg_namespace cn ON cn.oid = c.relnamespace + WHERE (pn.nspname = ANY($1::text[]) OR cn.nspname = ANY($1::text[])) + AND p.relkind IN ('r', 'p')`, + [schemas] + ); + + const pkMap = new Map>(); + for (const row of pkResult.rows) { + const key = `${String(row.table_schema)}.${String(row.table_name)}`; + if (!pkMap.has(key)) { + pkMap.set(key, new Set()); + } + pkMap.get(key)!.add(String(row.column_name)); + } + + const fkMap = new Map>(); + for (const row of fkColResult.rows) { + const key = `${String(row.table_schema)}.${String(row.table_name)}`; + if (!fkMap.has(key)) { + fkMap.set(key, new Set()); + } + fkMap.get(key)!.add(String(row.column_name)); + } + + const columnsByTable = new Map(); + for (const row of columnsResult.rows) { + const schema = String(row.schema_name); + const tableName = String(row.table_name); + const key = `${schema}.${tableName}`; + const pkCols = pkMap.get(key) ?? new Set(); + const fkCols = fkMap.get(key) ?? new Set(); + const colName = String(row.column_name); + const col: ErdColumn = { + name: colName, + type: String(row.data_type), + notNull: Boolean(row.not_null), + isPk: pkCols.has(colName), + isFk: fkCols.has(colName), + }; + if (!columnsByTable.has(key)) { + columnsByTable.set(key, []); + } + columnsByTable.get(key)!.push(col); + } + + const tables: ErdTable[] = []; + for (const row of tablesResult.rows) { + const schema = String(row.schema_name); + const name = String(row.table_name); + const key = `${schema}.${name}`; + const rawEst = row.est_rows; + const estRows = + rawEst !== null && rawEst !== undefined && !Number.isNaN(Number(rawEst)) + ? Number(rawEst) + : undefined; + tables.push({ + schema, + name, + ...(estRows !== undefined ? { estRows } : {}), + columns: columnsByTable.get(key) ?? [], + }); + } + + const foreignKeys: ErdForeignKey[] = fkResult.rows.map((r) => ({ + constraintName: String(r.constraint_name), + fromSchema: String(r.from_schema), + fromTable: String(r.from_table), + fromColumn: String(r.from_column), + toSchema: String(r.to_schema), + toTable: String(r.to_table), + toColumn: String(r.to_column), + })); + + const indexes: ErdIndexRow[] = idxResult.rows.map((r) => ({ + schema: String(r.schema_name), + tableName: String(r.table_name), + indexName: String(r.index_name), + })); + + const rls: ErdRlsInfo[] = rlsResult.rows.map((r) => ({ + schema: String(r.schema_name), + tableName: String(r.table_name), + relrowsecurity: Boolean(r.relrowsecurity), + policies: Array.isArray(r.policy_names) + ? (r.policy_names as string[]).filter(Boolean) + : [], + })); + + const partitions: ErdPartitionEdge[] = partResult.rows.map((r) => ({ + parentSchema: String(r.parent_schema), + parentTable: String(r.parent_table), + childSchema: String(r.child_schema), + childTable: String(r.child_table), + })); + + return { + schemas: [...schemas].sort(), + tables, + foreignKeys, + indexes, + rls, + partitions, + }; +} diff --git a/src/schemaDesigner/erd/erdTypes.ts b/src/schemaDesigner/erd/erdTypes.ts new file mode 100644 index 0000000..58b7eed --- /dev/null +++ b/src/schemaDesigner/erd/erdTypes.ts @@ -0,0 +1,69 @@ +/** + * Shared types for ERD 2.0 (extension host + webview payload). + */ + +export interface ErdColumn { + name: string; + type: string; + notNull: boolean; + isPk: boolean; + isFk: boolean; +} + +export interface ErdTable { + name: string; + schema: string; + estRows?: number; + columns: ErdColumn[]; +} + +export interface ErdForeignKey { + constraintName: string; + fromSchema: string; + fromTable: string; + fromColumn: string; + toSchema: string; + toTable: string; + toColumn: string; +} + +export interface ErdIndexRow { + schema: string; + tableName: string; + indexName: string; +} + +export interface ErdRlsInfo { + schema: string; + tableName: string; + relrowsecurity: boolean; + policies: string[]; +} + +export interface ErdPartitionEdge { + parentSchema: string; + parentTable: string; + childSchema: string; + childTable: string; +} + +/** Full snapshot fetched on the host for one or more schemas. */ +export interface ErdSnapshot { + schemas: string[]; + tables: ErdTable[]; + foreignKeys: ErdForeignKey[]; + indexes: ErdIndexRow[]; + rls: ErdRlsInfo[]; + partitions: ErdPartitionEdge[]; +} + +/** Wire format injected into the webview. */ +export interface ErdWebviewPayload { + snapshot: ErdSnapshot; + /** True when the connection profile forces read-only execution (informational for migration draft). */ + readOnlyConnection: boolean; +} + +export function tableQual(schema: string, name: string): string { + return `${schema}.${name}`; +} diff --git a/src/schemaDesigner/erd/erdWebviewHtml.ts b/src/schemaDesigner/erd/erdWebviewHtml.ts new file mode 100644 index 0000000..5ac07eb --- /dev/null +++ b/src/schemaDesigner/erd/erdWebviewHtml.ts @@ -0,0 +1,228 @@ +import * as vscode from 'vscode'; +import type { ErdWebviewPayload } from './erdTypes'; + +/** + * Build ERD webview document with CSP, external bundle, and initial state JSON. + */ +export function buildErdWebviewHtml( + webview: vscode.Webview, + extensionUri: vscode.Uri, + payload: ErdWebviewPayload +): string { + const nonce = getNonce(); + const csp = [ + "default-src 'none'", + `style-src 'unsafe-inline' ${webview.cspSource}`, + `img-src ${webview.cspSource} data: blob:`, + `script-src 'nonce-${nonce}' ${webview.cspSource}`, + ].join('; '); + + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'dist', 'erd-webview.js')); + const safeJson = JSON.stringify(payload).replace(/ + + + + + ERD + + + +
+
+
+
+

ERD —

+ + +
+ + + + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + +
+
+
+ + + +
+
+
+ + + +`; +} + +function getNonce(): string { + let s = ''; + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + for (let i = 0; i < 32; i += 1) { + s += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return s; +} diff --git a/src/schemaDesigner/erd/webview/main.ts b/src/schemaDesigner/erd/webview/main.ts new file mode 100644 index 0000000..de6bbf1 --- /dev/null +++ b/src/schemaDesigner/erd/webview/main.ts @@ -0,0 +1,1047 @@ +/** + * ERD webview (bundled to dist/erd-webview.js). + */ +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, +} from 'd3-force'; +import type { + ErdForeignKey, + ErdPartitionEdge, + ErdRlsInfo, + ErdTable, + ErdWebviewPayload, +} from '../erdTypes'; +import { tableQual } from '../erdTypes'; +import { buildDbmlFromTables, buildMermaidFromTables } from '../erdExportSerializers'; + +declare function acquireVsCodeApi(): { postMessage(msg: unknown): void }; + +const vscode = acquireVsCodeApi(); + +const TABLE_W = 220; +const COL_H = 22; +const HEADER_BASE = 36; +const LAYER_INDEX_ROW = 18; +const LAYER_RLS_ROW = 16; + +type ErdModelPatch = + | { kind: 'renameTable'; schema: string; from: string; to: string } + | { kind: 'renameColumn'; schema: string; table: string; from: string; to: string } + | { + kind: 'addColumn'; + schema: string; + table: string; + name: string; + dataType: string; + notNull: boolean; + }; + +interface LayerState { + tables: boolean; + fk: boolean; + indexes: boolean; + rls: boolean; + partitions: boolean; +} + +const layers: LayerState = { + tables: true, + fk: true, + indexes: true, + rls: true, + partitions: true, +}; + +let payload = (window as unknown as { __ERD_INITIAL__?: ErdWebviewPayload }).__ERD_INITIAL__!; +let tables: ErdTable[] = []; +let foreignKeys: ErdForeignKey[] = []; +let partitionEdges: ErdPartitionEdge[] = []; +let rlsInfo: ErdRlsInfo[] = []; +const indexByTable = new Map(); +const patches: ErdModelPatch[] = []; +let collapsedSchemas = new Set(); +let selectedTable: string | null = null; +let positions: Record = {}; +let scale = 1; +let panX = 0; +let panY = 0; +let isPanning = false; +let panStartX = 0; +let panStartY = 0; +let dragEl: HTMLElement | null = null; +let dragName: string | null = null; +let dragOffX = 0; +let dragOffY = 0; + +const canvasWrap = () => document.getElementById('canvas-wrap')!; +const canvas = () => document.getElementById('canvas')!; +const svgLayer = () => document.getElementById('fk-layer')!; +const schemaStripEl = () => document.getElementById('schema-strip')!; + +function initFromPayload(): void { + tables = JSON.parse(JSON.stringify(payload.snapshot.tables)) as ErdTable[]; + foreignKeys = [...payload.snapshot.foreignKeys]; + partitionEdges = [...payload.snapshot.partitions]; + rlsInfo = [...payload.snapshot.rls]; + indexByTable.clear(); + for (const row of payload.snapshot.indexes) { + const k = tableQual(row.schema, row.tableName); + if (!indexByTable.has(k)) { + indexByTable.set(k, []); + } + indexByTable.get(k)!.push(row.indexName); + } + patches.length = 0; + collapsedSchemas = new Set(); +} + +function tableHeight(t: ErdTable): number { + let h = HEADER_BASE; + if (layers.indexes && indexFor(t)) { + h += LAYER_INDEX_ROW; + } + if (layers.rls && rlsFor(t)) { + h += LAYER_RLS_ROW; + } + h += t.columns.length * COL_H + 8; + return h; +} + +function indexFor(t: ErdTable): string | undefined { + const list = indexByTable.get(tableQual(t.schema, t.name)); + if (!list || list.length === 0) { + return undefined; + } + return `${list.length} idx`; +} + +function rlsFor(t: ErdTable): ErdRlsInfo | undefined { + return rlsInfo.find((r) => r.schema === t.schema && r.tableName === t.name); +} + +function schemaVisibleTables(): ErdTable[] { + return tables.filter((t) => !collapsedSchemas.has(t.schema)); +} + +/** Tables drawn on canvas (respects layer toggle). */ +function canvasTables(): ErdTable[] { + if (!layers.tables) { + return []; + } + return schemaVisibleTables(); +} + +function initGridLayout(): void { + const vis = schemaVisibleTables(); + const cols = Math.ceil(Math.sqrt(Math.max(1, vis.length))); + const padX = 40; + const padY = 40; + const gapX = 60; + const gapY = 40; + vis.forEach((t, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + positions[tableQual(t.schema, t.name)] = { + x: padX + col * (TABLE_W + gapX), + y: padY + row * (tableHeight(t) + gapY), + }; + }); +} + +function runForceLayout(): void { + const vis = schemaVisibleTables(); + if (vis.length === 0) { + return; + } + const schemaOrder = [...new Set(payload.snapshot.schemas)].sort(); + const schemaStrength = 0.12; + + type SimNode = { + id: string; + schema: string; + x: number; + y: number; + vx?: number; + vy?: number; + fx?: number | null; + fy?: number | null; + }; + + const nodes: SimNode[] = vis.map((t) => { + const id = tableQual(t.schema, t.name); + const p = positions[id] ?? { x: 200, y: 200 }; + return { id, schema: t.schema, x: p.x, y: p.y }; + }); + + const linkSet = new Set(); + const links: { source: string; target: string }[] = []; + if (layers.fk) { + for (const fk of foreignKeys) { + const from = tableQual(fk.fromSchema, fk.fromTable); + const toFix = tableQual(fk.toSchema, fk.toTable); + if (!vis.some((t) => tableQual(t.schema, t.name) === from)) { + continue; + } + if (!vis.some((t) => tableQual(t.schema, t.name) === toFix)) { + continue; + } + const key = `${from}->${toFix}`; + if (linkSet.has(key)) { + continue; + } + linkSet.add(key); + links.push({ source: from, target: toFix }); + } + } + + const simLinks = links.map((l) => ({ + source: l.source, + target: l.target, + })); + + const si = (s: string) => schemaOrder.indexOf(s); + const sim = forceSimulation(nodes as SimNode[]) + .force( + 'link', + forceLink(simLinks) + .id((d: unknown) => (d as SimNode).id) + .distance(140) + .strength(0.5) + ) + .force('charge', forceManyBody().strength(-520)) + .force('center', forceCenter(500, 400)) + .force( + 'x', + forceX() + .x((d: unknown) => 200 + Math.max(0, si((d as SimNode).schema)) * 420) + .strength(schemaStrength) + ) + .force( + 'collide', + forceCollide().radius((d: unknown) => { + const id = (d as SimNode).id; + const t = tables.find((x) => tableQual(x.schema, x.name) === id); + return t ? tableHeight(t) / 2 + 24 : 80; + }) + ); + + for (let i = 0; i < 360; i += 1) { + sim.tick(); + } + sim.stop(); + + for (const n of nodes) { + positions[n.id] = { x: n.x, y: n.y }; + } +} + +function renderSchemaStrip(): void { + const el = schemaStripEl(); + el.innerHTML = ''; + const schemas = [...new Set(payload.snapshot.schemas)].sort(); + for (const sch of schemas) { + const row = document.createElement('div'); + row.className = 'erd-strip-row'; + const collapsed = collapsedSchemas.has(sch); + row.innerHTML = + ``; + row.querySelector('button')!.addEventListener('click', () => { + if (collapsedSchemas.has(sch)) { + collapsedSchemas.delete(sch); + } else { + collapsedSchemas.add(sch); + } + initGridLayout(); + renderAll(); + setTimeout(fitView, 30); + }); + el.appendChild(row); + } +} + +function escHtml(s: string): string { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escAttr(s: string): string { + return escHtml(s).replace(/'/g, '''); +} + +function renderTables(): void { + document.querySelectorAll('.erd-table').forEach((el) => el.remove()); + const c = canvas(); + if (!layers.tables) { + return; + } + + for (const t of canvasTables()) { + const q = tableQual(t.schema, t.name); + const pos = positions[q] ?? { x: 0, y: 0 }; + const el = document.createElement('div'); + el.className = 'erd-table'; + el.id = 'tbl-' + safeId(q); + el.style.left = `${pos.x}px`; + el.style.top = `${pos.y}px`; + el.style.width = `${TABLE_W}px`; + el.dataset.qual = q; + + const header = document.createElement('div'); + header.className = 'erd-table-header'; + const meta = + t.estRows !== undefined && + t.estRows !== null && + !Number.isNaN(Number(t.estRows)) + ? `
${escHtml(formatEstRows(t.estRows))}
` + : ''; + + const idxLine = + layers.indexes && indexFor(t) + ? `
📇 ${escHtml(indexFor(t)!)}
` + : ''; + const r = layers.rls ? rlsFor(t) : undefined; + const rlsLine = + r && (r.relrowsecurity || r.policies.length > 0) + ? `
` + + `${r.relrowsecurity ? '🔒 RLS' : 'RLS off'}${r.policies.length ? ` · ${r.policies.length} pol.` : ''}` + + `
` + : ''; + + header.innerHTML = + `
` + + `${escHtml(t.name)}` + + `
` + + `
${escHtml(t.schema)}
` + + meta + + idxLine + + rlsLine; + + el.appendChild(header); + + const titleEl = header.querySelector('.hdr-title') as HTMLElement; + titleEl.addEventListener('dblclick', (e) => { + e.stopPropagation(); + startRenameTable(q, t.schema, t.name, titleEl); + }); + + (header.querySelector('.hdr-addcol') as HTMLButtonElement).addEventListener('click', (e) => { + e.stopPropagation(); + promptAddColumn(q, t.schema, t.name); + }); + + header.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('.hdr-addcol')) { + return; + } + e.stopPropagation(); + selectTable(selectedTable === q ? null : q); + }); + + const body = document.createElement('div'); + body.className = 'erd-table-body'; + for (const col of t.columns) { + const row = document.createElement('div'); + const cls = col.isPk ? 'pk' : col.isFk ? 'fk' : ''; + row.className = 'erd-col' + (cls ? ` ${cls}` : ''); + const icon = col.isPk ? '🔑' : col.isFk ? '🔗' : '◦'; + row.innerHTML = + `${icon}` + + `${escHtml(col.name)}` + + `${escHtml(col.type)}`; + row.addEventListener('dblclick', (e) => { + e.stopPropagation(); + const cn = row.querySelector('.col-name') as HTMLElement; + startRenameColumn(q, t.schema, t.name, col.name, cn); + }); + body.appendChild(row); + } + el.appendChild(body); + + el.addEventListener('mousedown', (e) => { + if (e.button !== 0) { + return; + } + const tgt = e.target as HTMLElement; + if ( + tgt.closest('.erd-table-body') || + tgt.closest('.hdr-title') || + tgt.closest('.hdr-addcol') || + tgt.closest('button') + ) { + return; + } + startDrag(e, q, el); + }); + + c.appendChild(el); + } +} + +function startRenameTable(qual: string, schema: string, current: string, _el: HTMLElement): void { + vscode.postMessage({ type: 'erdRenameTable', qual, schema, currentName: current }); +} + +function startRenameColumn( + qual: string, + schema: string, + table: string, + current: string, + _el: HTMLElement +): void { + vscode.postMessage({ type: 'erdRenameColumn', qual, schema, table, currentColumn: current }); +} + +function applyRenameTableResult(msg: Record): void { + const qual = String(msg.qual ?? ''); + const schema = String(msg.schema ?? ''); + const from = String(msg.from ?? '').trim(); + const to = String(msg.to ?? '').trim(); + if (!qual || !to || to === from) { + return; + } + const t = tables.find((x) => tableQual(x.schema, x.name) === qual); + if (!t || t.name !== from) { + return; + } + patches.push({ kind: 'renameTable', schema, from, to }); + const oldQual = qual; + const newQual = tableQual(schema, to); + t.name = to; + if (positions[oldQual]) { + positions[newQual] = positions[oldQual]; + delete positions[oldQual]; + } + if (selectedTable === oldQual) { + selectedTable = newQual; + } + renderAll(); +} + +function applyRenameColumnResult(msg: Record): void { + const qual = String(msg.qual ?? ''); + const schema = String(msg.schema ?? ''); + const tableName = String(msg.table ?? ''); + const from = String(msg.from ?? '').trim(); + const toCol = String(msg.to ?? '').trim(); + if (!qual || !toCol || toCol === from) { + return; + } + const t = tables.find((x) => tableQual(x.schema, x.name) === qual); + if (!t) { + return; + } + const col = t.columns.find((c) => c.name === from); + if (!col) { + return; + } + patches.push({ kind: 'renameColumn', schema, table: tableName, from, to: toCol }); + col.name = toCol; + renderAll(); +} + +function promptAddColumn(qual: string, schema: string, table: string): void { + vscode.postMessage({ type: 'erdAddColumn', qual, schema, table }); +} + +function applyAddColumnResult(msg: Record): void { + const qual = String(msg.qual ?? ''); + const schema = String(msg.schema ?? ''); + const tableName = String(msg.table ?? ''); + const name = String(msg.name ?? '').trim(); + const dataType = String(msg.dataType ?? 'text').trim(); + const notNull = Boolean(msg.notNull); + if (!name) { + return; + } + const t = tables.find((x) => tableQual(x.schema, x.name) === qual); + if (!t) { + return; + } + patches.push({ + kind: 'addColumn', + schema, + table: tableName, + name, + dataType, + notNull, + }); + t.columns.push({ + name, + type: dataType, + notNull, + isPk: false, + isFk: false, + }); + renderAll(); +} + +function wireHostToWebviewMessages(): void { + window.addEventListener('message', (ev: MessageEvent) => { + const m = ev.data as Record | undefined; + if (!m || typeof m !== 'object') { + return; + } + if (m.type === 'erdAddColumnResult') { + applyAddColumnResult(m); + } else if (m.type === 'erdRenameTableResult') { + applyRenameTableResult(m); + } else if (m.type === 'erdRenameColumnResult') { + applyRenameColumnResult(m); + } + }); +} + +function selectTable(name: string | null): void { + selectedTable = name; + document.querySelectorAll('.erd-table').forEach((el) => el.classList.remove('highlighted')); + document.querySelectorAll('.fk-line').forEach((el) => { + el.classList.remove('active'); + (el as SVGElement).setAttribute('marker-end', 'url(#arrow)'); + }); + document.querySelectorAll('.part-line').forEach((el) => { + el.classList.remove('active'); + }); + + if (!name) { + return; + } + const tblEl = document.getElementById('tbl-' + safeId(name)); + if (tblEl) { + tblEl.classList.add('highlighted'); + } + + document.querySelectorAll(`.fk-line[data-from="${cssEsc(name)}"], .fk-line[data-to="${cssEsc(name)}"]`).forEach((line) => { + line.classList.add('active'); + (line as SVGElement).setAttribute('marker-end', 'url(#arrow-active)'); + const peer = + line.getAttribute('data-from') === name ? line.getAttribute('data-to') : line.getAttribute('data-from'); + if (peer) { + const peerEl = document.getElementById('tbl-' + safeId(peer)); + if (peerEl) { + peerEl.classList.add('highlighted'); + } + } + }); +} + +function cssEsc(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function safeId(q: string): string { + return encodeURIComponent(q).replace(/%/g, '_'); +} + +function colIndex(tableQualKey: string, colName: string): number { + const t = tables.find((x) => tableQual(x.schema, x.name) === tableQualKey); + if (!t) { + return 0; + } + const idx = t.columns.findIndex((c) => c.name === colName); + return idx < 0 ? 0 : idx; +} + +function renderFkLines(): void { + const svg = svgLayer(); + svg.querySelectorAll('.fk-line').forEach((el) => el.remove()); + if (!layers.fk) { + return; + } + + const visSet = new Set(canvasTables().map((t) => tableQual(t.schema, t.name))); + for (const fk of foreignKeys) { + const fq = tableQual(fk.fromSchema, fk.fromTable); + const tq = tableQual(fk.toSchema, fk.toTable); + if (!visSet.has(fq) || !visSet.has(tq)) { + continue; + } + drawFkLine(fk); + } +} + +function drawFkLine(fk: ErdForeignKey): void { + const fromPos = positions[tableQual(fk.fromSchema, fk.fromTable)]; + const toPos = positions[tableQual(fk.toSchema, fk.toTable)]; + if (!fromPos || !toPos) { + return; + } + const fq = tableQual(fk.fromSchema, fk.fromTable); + const tq = tableQual(fk.toSchema, fk.toTable); + const fromT = tables.find((x) => tableQual(x.schema, x.name) === fq)!; + const hdr = HEADER_BASE + (layers.indexes && indexFor(fromT) ? LAYER_INDEX_ROW : 0) + (layers.rls && rlsFor(fromT) ? LAYER_RLS_ROW : 0); + + const fromH = hdr + colIndex(fq, fk.fromColumn) * COL_H + COL_H / 2; + const toT = tables.find((x) => tableQual(x.schema, x.name) === tq)!; + const hdrT = + HEADER_BASE + + (layers.indexes && indexFor(toT) ? LAYER_INDEX_ROW : 0) + + (layers.rls && rlsFor(toT) ? LAYER_RLS_ROW : 0); + const toH = hdrT + colIndex(tq, fk.toColumn) * COL_H + COL_H / 2; + + const x1 = fromPos.x + TABLE_W; + const y1 = fromPos.y + fromH; + const x2 = toPos.x; + const y2 = toPos.y + toH; + + const [sx, sy, ex, ey] = + x1 < x2 + ? [fromPos.x + TABLE_W, fromPos.y + fromH, toPos.x, toPos.y + toH] + : [fromPos.x, fromPos.y + fromH, toPos.x + TABLE_W, toPos.y + toH]; + + const midX = (sx + ex) / 2; + const d = `M ${sx} ${sy} C ${midX} ${sy}, ${midX} ${ey}, ${ex} ${ey}`; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('class', 'fk-line'); + path.setAttribute('marker-end', 'url(#arrow)'); + path.setAttribute('data-from', fq); + path.setAttribute('data-to', tq); + path.setAttribute('title', fk.constraintName); + svgLayer().appendChild(path); +} + +function renderPartitionLines(): void { + const svg = svgLayer(); + svg.querySelectorAll('.part-line').forEach((el) => el.remove()); + if (!layers.partitions) { + return; + } + const visSet = new Set(canvasTables().map((t) => tableQual(t.schema, t.name))); + for (const pe of partitionEdges) { + const pq = tableQual(pe.parentSchema, pe.parentTable); + const cq = tableQual(pe.childSchema, pe.childTable); + if (!visSet.has(pq) || !visSet.has(cq)) { + continue; + } + const p1 = positions[pq]; + const p2 = positions[cq]; + if (!p1 || !p2) { + continue; + } + const pt = tables.find((x) => tableQual(x.schema, x.name) === cq)!; + const hdr = + HEADER_BASE + + (layers.indexes && indexFor(pt) ? LAYER_INDEX_ROW : 0) + + (layers.rls && rlsFor(pt) ? LAYER_RLS_ROW : 0); + const cy = p2.y + hdr / 2; + const px = p1.x + TABLE_W / 2; + const py = p1.y + tableHeight(tables.find((x) => tableQual(x.schema, x.name) === pq)!) / 2; + const d = `M ${p2.x + TABLE_W / 2} ${cy} L ${px} ${py}`; + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', d); + path.setAttribute('class', 'part-line'); + path.setAttribute('data-from', cq); + path.setAttribute('data-to', pq); + path.setAttribute('title', 'partition'); + svg.appendChild(path); + } +} + +function startDrag(e: MouseEvent, name: string, el: HTMLElement): void { + dragEl = el; + dragName = name; + const rect = el.getBoundingClientRect(); + dragOffX = (e.clientX - rect.left) / scale; + dragOffY = (e.clientY - rect.top) / scale; + e.preventDefault(); + e.stopPropagation(); + window.addEventListener('mousemove', onDragMove); + window.addEventListener('mouseup', onDragEnd); +} + +function onDragMove(e: MouseEvent): void { + if (!dragEl || !dragName) { + return; + } + const cr = canvas().getBoundingClientRect(); + const x = (e.clientX - cr.left) / scale - dragOffX; + const y = (e.clientY - cr.top) / scale - dragOffY; + positions[dragName] = { x, y }; + dragEl.style.left = `${x}px`; + dragEl.style.top = `${y}px`; + renderFkLines(); + renderPartitionLines(); +} + +function onDragEnd(): void { + dragEl = null; + dragName = null; + window.removeEventListener('mousemove', onDragMove); + window.removeEventListener('mouseup', onDragEnd); +} + +function applyTransform(): void { + canvas().style.transform = `translate(${panX}px,${panY}px) scale(${scale})`; +} + +function zoom(delta: number, cx?: number, cy?: number): void { + const newScale = Math.max(0.15, Math.min(3, scale + delta)); + if (cx !== undefined && cy !== undefined) { + const canvasRect = canvasWrap().getBoundingClientRect(); + const mouseX = cx - canvasRect.left; + const mouseY = cy - canvasRect.top; + panX = mouseX - (mouseX - panX) * (newScale / scale); + panY = mouseY - (mouseY - panY) * (newScale / scale); + } + scale = newScale; + applyTransform(); +} + +function resetZoom(): void { + scale = 1; + panX = 0; + panY = 0; + applyTransform(); +} + +function fitView(): void { + const vis = schemaVisibleTables(); + if (vis.length === 0) { + return; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const t of vis) { + const q = tableQual(t.schema, t.name); + const pos = positions[q]; + if (!pos) { + continue; + } + const h = tableHeight(t); + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + TABLE_W); + maxY = Math.max(maxY, pos.y + h); + } + const wrapRect = canvasWrap().getBoundingClientRect(); + const cw = wrapRect.width; + const ch = wrapRect.height; + const contentW = maxX - minX + 80; + const contentH = maxY - minY + 80; + const newScale = Math.min(cw / contentW, ch / contentH, 1); + scale = newScale; + panX = (cw - contentW * scale) / 2 - minX * scale + 40 * scale; + panY = (ch - contentH * scale) / 2 - minY * scale + 40 * scale; + applyTransform(); +} + +function renderAll(): void { + renderSchemaStrip(); + renderTables(); + renderFkLines(); + renderPartitionLines(); + updateStats(); +} + +function updateStats(): void { + const el = document.getElementById('stats-label'); + if (el) { + el.textContent = `${schemaVisibleTables().length} tables · ${foreignKeys.length} FK · ${patches.length} pending edits`; + } +} + +function formatEstRows(n: number): string { + const x = Number(n); + if (!Number.isFinite(x) || x < 0) { + return ''; + } + if (x >= 1e9) { + return `~${trimTrailingZero((x / 1e9).toFixed(1))}B rows (est.)`; + } + if (x >= 1e6) { + return `~${trimTrailingZero((x / 1e6).toFixed(1))}M rows (est.)`; + } + if (x >= 1e3) { + return `~${trimTrailingZero((x / 1e3).toFixed(1))}k rows (est.)`; + } + return `~${x} rows (est.)`; +} + +function trimTrailingZero(s: string): string { + return s.replace(/\.0$/, ''); +} + +function buildExportSvg(): string { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const t of schemaVisibleTables()) { + const q = tableQual(t.schema, t.name); + const pos = positions[q]; + if (!pos) { + continue; + } + const h = tableHeight(t); + minX = Math.min(minX, pos.x); + minY = Math.min(minY, pos.y); + maxX = Math.max(maxX, pos.x + TABLE_W); + maxY = Math.max(maxY, pos.y + h); + } + const pad = 30; + const W = maxX - minX + pad * 2; + const H = maxY - minY + pad * 2; + let svg = ``; + svg += + ''; + + if (layers.fk) { + const visSet = new Set(schemaVisibleTables().map((t) => tableQual(t.schema, t.name))); + for (const fk of foreignKeys) { + const fq = tableQual(fk.fromSchema, fk.fromTable); + const tq = tableQual(fk.toSchema, fk.toTable); + if (!visSet.has(fq) || !visSet.has(tq)) { + continue; + } + const fromPos = positions[fq]; + const toPos = positions[tq]; + if (!fromPos || !toPos) { + continue; + } + const fromT = tables.find((x) => tableQual(x.schema, x.name) === fq)!; + const toT = tables.find((x) => tableQual(x.schema, x.name) === tq)!; + const hdrF = + HEADER_BASE + + (layers.indexes && indexFor(fromT) ? LAYER_INDEX_ROW : 0) + + (layers.rls && rlsFor(fromT) ? LAYER_RLS_ROW : 0); + const hdrT = + HEADER_BASE + + (layers.indexes && indexFor(toT) ? LAYER_INDEX_ROW : 0) + + (layers.rls && rlsFor(toT) ? LAYER_RLS_ROW : 0); + const fi = colIndex(fq, fk.fromColumn); + const ti = colIndex(tq, fk.toColumn); + const [sx, sy, ex, ey] = + fromPos.x < toPos.x + ? [ + fromPos.x + TABLE_W, + fromPos.y + hdrF + fi * COL_H + COL_H / 2, + toPos.x, + toPos.y + hdrT + ti * COL_H + COL_H / 2, + ] + : [ + fromPos.x, + fromPos.y + hdrF + fi * COL_H + COL_H / 2, + toPos.x + TABLE_W, + toPos.y + hdrT + ti * COL_H + COL_H / 2, + ]; + const mx = (sx + ex) / 2; + const ox = sx - minX + pad; + const oy = sy - minY + pad; + const dx = ex - minX + pad; + const dy = ey - minY + pad; + svg += ``; + } + } + + for (const t of schemaVisibleTables()) { + const q = tableQual(t.schema, t.name); + const pos = positions[q]; + if (!pos) { + continue; + } + const h = tableHeight(t); + const tx = pos.x - minX + pad; + const ty = pos.y - minY + pad; + svg += ``; + svg += ``; + svg += `${escHtml(t.name)}`; + let yOff = HEADER_BASE; + if (layers.indexes && indexFor(t)) { + svg += `idx: ${escHtml(indexFor(t)!)}`; + yOff += LAYER_INDEX_ROW; + } + if (layers.rls) { + const r = rlsFor(t); + if (r && (r.relrowsecurity || r.policies.length)) { + svg += `RLS ${r.policies.length}`; + yOff += LAYER_RLS_ROW; + } + } + t.columns.forEach((c, i) => { + const cy2 = ty + yOff + i * COL_H + 15; + const icon = c.isPk ? '🔑' : c.isFk ? '🔗' : '·'; + const color = c.isPk ? '#f39c12' : c.isFk ? '#3498db' : '#ccc'; + svg += `${icon} ${escHtml(c.name)} ${escHtml(c.type)}`; + }); + } + + svg += ''; + return svg; +} + +function exportSvg(): void { + vscode.postMessage({ type: 'exportSvg', svg: buildExportSvg() }); +} + +function exportPng(): void { + const svg = buildExportSvg(); + const img = new Image(); + const blob = new Blob([svg], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + img.onload = () => { + const canvasEl = document.createElement('canvas'); + canvasEl.width = img.width || 1200; + canvasEl.height = img.height || 800; + const ctx = canvasEl.getContext('2d'); + if (ctx) { + ctx.fillStyle = '#1e1e1e'; + ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); + ctx.drawImage(img, 0, 0); + const data = canvasEl.toDataURL('image/png').split(',')[1]; + vscode.postMessage({ type: 'exportPng', base64: data }); + } + URL.revokeObjectURL(url); + }; + img.onerror = () => URL.revokeObjectURL(url); + img.src = url; +} + +function exportText(kind: 'mermaid' | 'dbml'): void { + const mermaid = buildMermaidFromTables(tables, foreignKeys); + const dbml = buildDbmlFromTables(tables, foreignKeys); + vscode.postMessage({ + type: 'exportText', + kind, + content: kind === 'mermaid' ? mermaid : dbml, + }); +} + +function syncMigration(): void { + vscode.postMessage({ + type: 'syncMigration', + patches: [...patches], + readOnly: payload.readOnlyConnection, + }); +} + +function wireToolbar(): void { + const toolbarActions: Record void> = { + autoLayout: () => { + runForceLayout(); + renderAll(); + fitView(); + }, + resetLayout: () => { + initGridLayout(); + runForceLayout(); + renderAll(); + fitView(); + }, + fitView, + syncMigration, + exportSvg, + exportPng, + exportMermaid: () => exportText('mermaid'), + exportDbml: () => exportText('dbml'), + printErd: () => window.print(), + }; + + document.querySelectorAll('[data-erd-action]').forEach((btn) => { + const action = btn.dataset.erdAction; + if (!action || !toolbarActions[action]) { + return; + } + btn.addEventListener('click', () => toolbarActions[action]()); + }); + + document.querySelectorAll('[data-erd-zoom]').forEach((btn) => { + const z = btn.dataset.erdZoom; + btn.addEventListener('click', () => { + if (z === 'in') { + zoom(0.15); + } else if (z === 'out') { + zoom(-0.15); + } else if (z === 'reset') { + resetZoom(); + } + }); + }); + + const bindLayer = (id: string, key: keyof LayerState) => { + const el = document.getElementById(id) as HTMLInputElement | null; + if (el) { + el.checked = layers[key]; + el.addEventListener('change', () => { + layers[key] = el.checked; + renderAll(); + }); + } + }; + bindLayer('layer-tables', 'tables'); + bindLayer('layer-fk', 'fk'); + bindLayer('layer-idx', 'indexes'); + bindLayer('layer-rls', 'rls'); + bindLayer('layer-part', 'partitions'); +} + +function wireCanvas(): void { + canvasWrap().addEventListener('mousedown', (e) => { + if (e.button !== 0 || dragEl) { + return; + } + isPanning = true; + panStartX = e.clientX - panX; + panStartY = e.clientY - panY; + canvasWrap().classList.add('grabbing'); + }); + window.addEventListener('mousemove', (e) => { + if (!isPanning) { + return; + } + panX = e.clientX - panStartX; + panY = e.clientY - panStartY; + applyTransform(); + }); + window.addEventListener('mouseup', () => { + isPanning = false; + canvasWrap().classList.remove('grabbing'); + }); + canvasWrap().addEventListener( + 'wheel', + (e) => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 0.1 : -0.1; + zoom(delta, e.clientX, e.clientY); + }, + { passive: false } + ); +} + +function boot(): void { + payload = (window as unknown as { __ERD_INITIAL__: ErdWebviewPayload }).__ERD_INITIAL__; + if (!payload?.snapshot) { + return; + } + initFromPayload(); + document.getElementById('schema-title')!.textContent = payload.snapshot.schemas.join(', '); + document.getElementById('read-badge')!.style.display = payload.readOnlyConnection ? 'inline' : 'none'; + + if (tables.length === 0) { + canvasWrap().innerHTML = + '
📂

No tables in selected schema(s).

'; + return; + } + + initGridLayout(); + runForceLayout(); + wireHostToWebviewMessages(); + wireToolbar(); + wireCanvas(); + renderAll(); + setTimeout(fitView, 50); +} + +boot(); diff --git a/src/services/SecretStorageService.ts b/src/services/SecretStorageService.ts index 57bfbd7..da1f0d5 100644 --- a/src/services/SecretStorageService.ts +++ b/src/services/SecretStorageService.ts @@ -22,6 +22,10 @@ export class SecretStorageService { return await this.context.secrets.get('postgresExplorer.aiApiKey'); } + public async getCursorApiKey(): Promise { + return await this.context.secrets.get('postgresExplorer.cursorApiKey'); + } + public async setPassword(connectionId: string, password: string): Promise { await this.context.secrets.store(`postgres-password-${connectionId}`, password); } @@ -30,6 +34,10 @@ export class SecretStorageService { await this.context.secrets.store('postgresExplorer.aiApiKey', apiKey); } + public async setCursorApiKey(apiKey: string): Promise { + await this.context.secrets.store('postgresExplorer.cursorApiKey', apiKey); + } + public async deletePassword(connectionId: string): Promise { await this.context.secrets.delete(`postgres-password-${connectionId}`); } @@ -38,6 +46,10 @@ export class SecretStorageService { await this.context.secrets.delete('postgresExplorer.aiApiKey'); } + public async deleteCursorApiKey(): Promise { + await this.context.secrets.delete('postgresExplorer.cursorApiKey'); + } + /** GitHub PAT with `gist` scope — used only for “Publish notebook to Gist”. */ public async getGithubGistToken(): Promise { return await this.context.secrets.get('postgresExplorer.githubGistToken'); diff --git a/src/test/unit/AiService.test.ts b/src/test/unit/AiService.test.ts index d628a7a..ff470bc 100644 --- a/src/test/unit/AiService.test.ts +++ b/src/test/unit/AiService.test.ts @@ -157,6 +157,41 @@ describe('AiService', () => { expect(githubDefault).to.equal('openai/gpt-4.1'); }); + it('uses the Cursor SDK for model info and request execution', async () => { + const service = new AiService(); + const cursorModels = [ + { id: 'composer-2', displayName: 'Composer 2' }, + { id: 'auto', displayName: 'Auto' } + ]; + const loadCursorSdkStub = sandbox.stub(service as any, '_loadCursorSdk').resolves({ + Cursor: { + me: sandbox.stub().resolves({ userEmail: 'user@example.com' }), + models: { + list: sandbox.stub().resolves(cursorModels) + } + }, + Agent: { + create: sandbox.stub().resolves({ + send: sandbox.stub().resolves({ + wait: sandbox.stub().resolves({ status: 'finished', result: 'Cursor answer', durationMs: 1234 }), + cancel: sandbox.stub() + }), + close: sandbox.stub() + }) + } + }); + sandbox.stub(SecretStorageService, 'getInstance').returns({ + getCursorApiKey: sandbox.stub().resolves('cursor-secret') + } as any); + + const modelInfo = await service.getModelInfo('cursor', createConfig({})); + expect(modelInfo).to.equal('Composer 2'); + + const result = await service.callProvider('cursor', 'Summarize the query', createConfig({ aiModel: 'composer-2' }), 'Use concise SQL guidance'); + expect(result.text).to.equal('Cursor answer'); + expect(loadCursorSdkStub.called).to.be.true; + }); + it('calls VS Code LM with image attachments and streamed text', async () => { const service = new AiService(); service.setMessages([createImageMessage() as any]); diff --git a/src/test/unit/ChatViewProvider.test.ts b/src/test/unit/ChatViewProvider.test.ts index 6aa69b4..b46a3bd 100644 --- a/src/test/unit/ChatViewProvider.test.ts +++ b/src/test/unit/ChatViewProvider.test.ts @@ -36,6 +36,12 @@ function createProviderHarness(sandbox: sinon.SinonSandbox) { const aiService = { getModelInfo: sandbox.stub().resolves('Mock AI'), + callProvider: sandbox.stub().callsFake(async (provider: string, message: string, config: any, systemPrompt?: string) => { + if (provider === 'vscode-lm') { + return await aiService.callVsCodeLm(message, config, systemPrompt); + } + return await aiService.callDirectApi(provider, message, config, systemPrompt); + }), callVsCodeLm: sandbox.stub().resolves({ text: 'SELECT 1;', usage: 'usage' }), callDirectApi: sandbox.stub(), setMessages: sandbox.stub(), @@ -345,7 +351,7 @@ describe('ChatViewProvider', () => { await clock.tickAsync(200); await attachPromise; - expect(showStub.calledOnceWithExactly(true)).to.be.true; + expect(showStub.called).to.be.false; expect(getObjectSchemaStub.calledOnce).to.be.true; expect(postMessage.calledWithMatch({ type: 'addMentionFromTree', diff --git a/templates/ai-settings/index.html b/templates/ai-settings/index.html index c6b3db0..1a078dc 100644 --- a/templates/ai-settings/index.html +++ b/templates/ai-settings/index.html @@ -37,6 +37,7 @@

AI Configuration

+ +
+ + + +
+ +
diff --git a/templates/ai-settings/scripts.js b/templates/ai-settings/scripts.js index 550832a..561186d 100644 --- a/templates/ai-settings/scripts.js +++ b/templates/ai-settings/scripts.js @@ -58,6 +58,11 @@ function autoLoadModels(provider, apiKey, endpoint, options = {}) { settings: { provider: 'github', apiKey: '', endpoint: '' } }); } + } else if (provider === 'cursor') { + vscode.postMessage({ + command: 'listModels', + settings: { provider: 'cursor', apiKey: apiKey || '', endpoint: endpoint || '' } + }); } else if (provider === 'anthropic') { // Prefer to fetch Anthropic models from the API when API key is provided if (apiKey && apiKey.length > 0) { @@ -134,6 +139,13 @@ function getFormData() { model = (selectEl && !selectEl.classList.contains('hidden') && selectEl.value) ? selectEl.value : inputEl.value; + } else if (provider === 'cursor') { + apiKey = document.getElementById('apiKey-cursor').value; + const selectEl = document.getElementById('model-cursor-select'); + const inputEl = document.getElementById('model-cursor'); + model = (selectEl && !selectEl.classList.contains('hidden') && selectEl.value) + ? selectEl.value + : inputEl.value; } else if (provider === 'custom') { apiKey = document.getElementById('apiKey-custom').value; model = document.getElementById('model-custom').value; @@ -166,6 +178,9 @@ function setFormData(settings) { document.getElementById('model-gemini').value = settings.model || ''; } else if (settings.provider === 'github') { document.getElementById('model-github').value = settings.model || ''; + } else if (settings.provider === 'cursor') { + document.getElementById('apiKey-cursor').value = settings.cursorApiKey || ''; + document.getElementById('model-cursor').value = settings.model || ''; } else if (settings.provider === 'custom') { document.getElementById('apiKey-custom').value = settings.apiKey || ''; document.getElementById('model-custom').value = settings.model || ''; @@ -225,6 +240,16 @@ document.querySelectorAll('.list-models-btn').forEach(btn => { return; } + if (provider === 'cursor') { + this.disabled = true; + this.textContent = 'Loading models...'; + vscode.postMessage({ + command: 'listModels', + settings: { provider: 'cursor', apiKey: settings.apiKey, endpoint: '' } + }); + return; + } + // VS Code LM does not require an API key if (provider === 'custom' && !settings.endpoint) { showMessage('Please enter an endpoint first', true); @@ -247,7 +272,7 @@ document.querySelectorAll('.list-models-btn').forEach(btn => { }); // Model select change handlers -['vscode-lm', 'github', 'openai', 'anthropic', 'gemini', 'ollama', 'lmstudio'].forEach(provider => { +['vscode-lm', 'github', 'cursor', 'openai', 'anthropic', 'gemini', 'ollama', 'lmstudio'].forEach(provider => { const selectEl = document.getElementById('model-' + provider + '-select'); const inputEl = document.getElementById('model-' + provider); if (selectEl && inputEl) { @@ -259,8 +284,8 @@ document.querySelectorAll('.list-models-btn').forEach(btn => { } }); -// Auto-load models when API key is entered for OpenAI and Gemini -['openai', 'gemini'].forEach(provider => { +// Auto-load models when API key is entered for OpenAI, Gemini, and Cursor +['openai', 'gemini', 'cursor'].forEach(provider => { const apiKeyInput = document.getElementById('apiKey-' + provider); if (apiKeyInput) { apiKeyInput.addEventListener('blur', function () { @@ -323,7 +348,7 @@ window.addEventListener('message', event => { // Auto-load models for the current provider const settings = message.settings; if (settings && settings.provider) { - autoLoadModels(settings.provider, settings.apiKey || '', settings.endpoint || '', { + autoLoadModels(settings.provider, settings.cursorApiKey || settings.apiKey || '', settings.endpoint || '', { allowPrompt: !!settings.githubAuth?.connected }); } @@ -404,8 +429,13 @@ function handleModelsListed(models) { selectEl.innerHTML = ''; models.forEach(model => { const option = document.createElement('option'); - option.value = model; - option.textContent = model; + if (model && typeof model === 'object') { + option.value = model.id || model.displayName || ''; + option.textContent = model.displayName || model.id || ''; + } else { + option.value = model; + option.textContent = model; + } selectEl.appendChild(option); }); diff --git a/templates/chat/index.html b/templates/chat/index.html index b11379e..56def0a 100644 --- a/templates/chat/index.html +++ b/templates/chat/index.html @@ -16,11 +16,11 @@
-
-
+
+

📚 Chat History

-
No chat history yet
@@ -39,20 +39,20 @@

📚 Chat History

- -
- - - - - - - - - + + + + + +
@@ -139,13 +139,13 @@

📚 Chat History

- - -
@@ -157,8 +157,7 @@

📚 Chat History

+ placeholder="Search tables, views, functions...">
Loading database objects...
@@ -167,32 +166,31 @@

📚 Chat History

- +
+ + + diff --git a/templates/backup-restore/scripts.js b/templates/backup-restore/scripts.js new file mode 100644 index 0000000..f873fb2 --- /dev/null +++ b/templates/backup-restore/scripts.js @@ -0,0 +1,729 @@ + const vscode = acquireVsCodeApi(); + let state = {}; + let lastLog = ''; + const selectedTables = new Set(); + const selectedSchemas = new Set(); + /** Schemas excluded from the table list; empty = show all. */ + const tableSchemaExclude = new Set(); + let schemaNsPickerOpen = false; + let tablePickerOpen = false; + + function getSchemaChoices() { + return state.schemas || []; + } + + function visibleSchemaRows() { + var searchEl = document.getElementById('d_sn_search'); + var q = searchEl ? String(searchEl.value || '').toLowerCase().trim() : ''; + return getSchemaChoices().filter(function(s) { + if (q && String(s).toLowerCase().indexOf(q) < 0) return false; + return true; + }); + } + + function updateSchemaNsTriggerSummary() { + var el = document.getElementById('d_sn_trigger_summary'); + if (!el) return; + var total = getSchemaChoices().length; + if (total === 0) { + el.textContent = 'No schemas in catalog'; + return; + } + var n = selectedSchemas.size; + if (n === 0) { + el.textContent = 'No schema filter for -n (click to choose)'; + return; + } + if (n === total) { + el.textContent = 'All ' + total + ' schemas for -n'; + return; + } + var sorted = Array.from(selectedSchemas).sort(); + if (n <= 3) { + el.textContent = n + ' for -n: ' + sorted.join(', '); + return; + } + el.textContent = n + ' of ' + total + ' schemas for -n'; + } + + function updateSchemaNsInteraction() { + var wrap = document.getElementById('d_sn_ms_wrap'); + var btn = document.getElementById('d_sn_btn'); + if (wrap) wrap.classList.toggle('schema-ns-disabled', selectedTables.size > 0); + if (btn) { + btn.disabled = selectedTables.size > 0; + btn.title = selectedTables.size > 0 + ? 'Clear table selections (-t) to edit schema (-n) filter' + : ''; + } + } + + function renderSchemaNsChips() { + var wrap = document.getElementById('d_sn_chips'); + if (!wrap) return; + wrap.innerHTML = ''; + if (selectedSchemas.size === 0) { + wrap.hidden = true; + return; + } + wrap.hidden = false; + Array.from(selectedSchemas).sort().forEach(function(schemaName) { + var chip = document.createElement('span'); + chip.className = 'picker-chip'; + chip.setAttribute('role', 'listitem'); + chip.appendChild(document.createTextNode(schemaName)); + var rm = document.createElement('button'); + rm.type = 'button'; + rm.className = 'picker-chip-remove'; + rm.setAttribute('aria-label', 'Remove schema ' + schemaName); + rm.appendChild(document.createTextNode('\u00D7')); + rm.addEventListener('click', function(e) { + e.preventDefault(); + selectedSchemas.delete(schemaName); + renderSchemaNsPickerList(); + onDumpSchemasChanged(); + }); + chip.appendChild(rm); + wrap.appendChild(chip); + }); + } + + function renderSchemaNsPickerList() { + var list = document.getElementById('d_sn_list'); + var empty = document.getElementById('d_sn_empty'); + if (!list) return; + var choices = getSchemaChoices(); + list.innerHTML = ''; + if (!choices.length) { + if (empty) { + empty.hidden = false; + empty.textContent = 'No schemas in catalog.'; + } + renderSchemaNsChips(); + updateSchemaNsTriggerSummary(); + updateSchemaNsInteraction(); + return; + } + if (empty) empty.hidden = true; + visibleSchemaRows().forEach(function(schemaName) { + var lab = document.createElement('label'); + lab.className = 'table-picker-row' + (selectedSchemas.has(schemaName) ? ' is-selected' : ''); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = selectedSchemas.has(schemaName); + cb.dataset.schema = schemaName; + cb.addEventListener('change', function() { + if (cb.checked) selectedSchemas.add(schemaName); + else selectedSchemas.delete(schemaName); + lab.classList.toggle('is-selected', cb.checked); + updateSchemaNsTriggerSummary(); + renderSchemaNsChips(); + onDumpSchemasChanged(); + }); + lab.appendChild(cb); + lab.appendChild(document.createTextNode(schemaName)); + list.appendChild(lab); + }); + if (choices.length && visibleSchemaRows().length === 0) { + var miss = document.createElement('p'); + miss.className = 'ms-dropdown-empty field-hint'; + miss.textContent = 'No schemas match your search.'; + list.appendChild(miss); + } + renderSchemaNsChips(); + updateSchemaNsTriggerSummary(); + updateSchemaNsInteraction(); + } + + function getTableChoices() { + return state.tableChoices || []; + } + + /** Tables allowed for -t while schema (-n) subset is selected; otherwise full catalog. */ + function tableChoicesForDump() { + var all = state.tableChoices || []; + if (selectedSchemas.size === 0) return all; + return all.filter(function(row) { return selectedSchemas.has(row.schema); }); + } + + function pruneInvalidTableSelections() { + if (selectedSchemas.size === 0) return; + var toRemove = []; + selectedTables.forEach(function(q) { + var row = (state.tableChoices || []).find(function(r) { return r.qualified === q; }); + var sch = row ? row.schema : ''; + if (!sch || !selectedSchemas.has(sch)) toRemove.push(q); + }); + toRemove.forEach(function(q) { selectedTables.delete(q); }); + } + + function schemaNamesForTableFilter() { + var seen = Object.create(null); + var out = []; + tableChoicesForDump().forEach(function(row) { + var s = row.schema; + if (s && !seen[s]) { + seen[s] = true; + out.push(s); + } + }); + out.sort(); + return out; + } + + function pruneTableSchemaExclude() { + var allowed = Object.create(null); + schemaNamesForTableFilter().forEach(function(s) { allowed[s] = true; }); + Array.from(tableSchemaExclude).forEach(function(s) { + if (!allowed[s]) tableSchemaExclude.delete(s); + }); + } + + function onDumpSchemasChanged() { + pruneInvalidTableSelections(); + pruneTableSchemaExclude(); + renderSchemaFilterPanel(); + updateTableTriggerSummary(); + renderTablePickerList(); + } + + function visibleTableRows() { + var searchEl = document.getElementById('d_table_search'); + var q = searchEl ? String(searchEl.value || '').toLowerCase().trim() : ''; + return tableChoicesForDump().filter(function(row) { + if (tableSchemaExclude.has(row.schema)) return false; + if (q && String(row.qualified).toLowerCase().indexOf(q) < 0) return false; + return true; + }); + } + + function tableSchemaVisibilitySuffix() { + var names = schemaNamesForTableFilter(); + var n = names.length; + if (n === 0) return ''; + var shown = names.filter(function(s) { return !tableSchemaExclude.has(s); }); + if (tableSchemaExclude.size === 0) return ''; + if (shown.length === 0) return ' · list: no schemas visible'; + if (shown.length <= 2) return ' · list: ' + shown.join(', '); + return ' · list: ' + shown.length + '/' + n + ' schemas'; + } + + function updateTableTriggerSummary() { + var el = document.getElementById('d_table_trigger_summary'); + if (!el) return; + var total = tableChoicesForDump().length; + if (total === 0) { + el.textContent = 'No tables in catalog (click to open picker)'; + return; + } + var n = selectedTables.size; + var base = n === 0 ? ('0 of ' + total + ' tables') : (n + ' of ' + total + ' tables'); + el.textContent = base + tableSchemaVisibilitySuffix(); + } + + function setSnPickerOpen(open) { + schemaNsPickerOpen = !!open; + var panel = document.getElementById('d_sn_panel'); + var btn = document.getElementById('d_sn_btn'); + var wrap = document.getElementById('d_sn_ms_wrap'); + if (panel) panel.hidden = !schemaNsPickerOpen; + if (btn) btn.setAttribute('aria-expanded', schemaNsPickerOpen ? 'true' : 'false'); + if (wrap) wrap.classList.toggle('is-open', schemaNsPickerOpen); + } + + function setTablePickerOpen(open) { + tablePickerOpen = !!open; + var panel = document.getElementById('d_table_panel'); + var btn = document.getElementById('d_table_btn'); + var wrap = document.getElementById('d_table_ms_wrap'); + if (panel) panel.hidden = !tablePickerOpen; + if (btn) btn.setAttribute('aria-expanded', tablePickerOpen ? 'true' : 'false'); + if (wrap) wrap.classList.toggle('is-open', tablePickerOpen); + } + + function renderSchemaFilterPanel() { + var list = document.getElementById('d_sch_filter_list'); + if (!list) return; + list.innerHTML = ''; + var names = schemaNamesForTableFilter(); + names.forEach(function(s) { + var lab = document.createElement('label'); + lab.className = 'ms-dropdown-option'; + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = !tableSchemaExclude.has(s); + cb.addEventListener('change', function() { + if (cb.checked) tableSchemaExclude.delete(s); + else tableSchemaExclude.add(s); + updateTableTriggerSummary(); + renderTablePickerList(); + }); + lab.appendChild(cb); + lab.appendChild(document.createTextNode(s)); + list.appendChild(lab); + }); + updateTableTriggerSummary(); + } + + function renderTablePickerList() { + var list = document.getElementById('d_table_list'); + var empty = document.getElementById('d_table_empty'); + if (!list) return; + var choices = tableChoicesForDump(); + var visible = choices.length ? visibleTableRows() : []; + if (empty) { + if (!choices.length) { + empty.hidden = false; + empty.textContent = selectedSchemas.size > 0 + ? 'No tables in the selected schemas.' + : 'No tables found (permissions or empty database).'; + } else if (visible.length === 0) { + empty.hidden = false; + empty.textContent = 'No tables match the current search or schema visibility filter. Clear the search, use "Show all", or adjust schema checkboxes above.'; + } else { + empty.hidden = true; + } + } + list.innerHTML = ''; + if (!choices.length) { + updateTableTriggerSummary(); + updateSchemaNsInteraction(); + return; + } + visible.forEach(function(row) { + var lab = document.createElement('label'); + lab.className = 'table-picker-row' + (selectedTables.has(row.qualified) ? ' is-selected' : ''); + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = selectedTables.has(row.qualified); + cb.dataset.qualified = row.qualified; + cb.addEventListener('change', function() { + if (cb.checked) selectedTables.add(row.qualified); + else selectedTables.delete(row.qualified); + lab.classList.toggle('is-selected', cb.checked); + updateTableTriggerSummary(); + updateSchemaNsInteraction(); + }); + lab.appendChild(cb); + lab.appendChild(document.createTextNode(row.qualified)); + list.appendChild(lab); + }); + updateTableTriggerSummary(); + updateSchemaNsInteraction(); + } + + window.addEventListener('message', event => { + const message = event.data; + if (message.type === 'init') { + state = message.payload; + selectedTables.clear(); + selectedSchemas.clear(); + tableSchemaExclude.clear(); + setSnPickerOpen(false); + setTablePickerOpen(false); + document.getElementById('d_db').value = state.databaseName || ''; + document.getElementById('r_target').innerHTML = (state.databases || []) + .map(d => '') + .join(''); + var sub = document.getElementById('backupSubtitle'); + if (sub) sub.textContent = state.databaseLabel ? ('Target · ' + state.databaseLabel) : ''; + const b = document.getElementById('banner'); + let h = ''; + if (state.versionMismatchDump || state.versionMismatchRestore) { + h += ''; + } + if (state.sshEnabled) { + h += '
SSH: CLI tools use the same tunnel as the SQL driver (local port forward).' + + '
'; + } + b.innerHTML = h; + renderSchemaFilterPanel(); + renderSchemaNsPickerList(); + renderTablePickerList(); + updateSchemaNsTriggerSummary(); + updateTableTriggerSummary(); + switchTab(state.initialTab || 'dump'); + refreshLogAssistVisibility(); + } + if (message.type === 'pickedPath') { + if (message.kind === 'save') document.getElementById('d_out').value = message.path; + if (message.kind === 'open') document.getElementById('r_in').value = message.path; + if (message.kind === 'dir') document.getElementById('d_out').value = message.path; + } + if (message.type === 'logChunk') { + lastLog += message.chunk; + document.getElementById('log').textContent = lastLog; + refreshLogAssistVisibility(); + } + if (message.type === 'runDone') { + lastLog = message.log || lastLog; + document.getElementById('log').textContent = lastLog; + refreshLogAssistVisibility(); + } + if (message.type === 'listResult') { + const toc = document.getElementById('toc'); + const wrap = document.getElementById('tocWrap'); + toc.innerHTML = ''; + if (message.error) { + wrap.style.display = 'block'; + toc.textContent = message.error + '\n' + (message.raw || ''); + return; + } + wrap.style.display = 'block'; + (message.rows || []).forEach((row, i) => { + const id = 'toc_' + i; + const lab = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.checked = true; + cb.dataset.line = row.rawLine; + lab.appendChild(cb); + lab.appendChild(document.createTextNode(row.kind + ' · ' + row.rawLine.slice(0, 120))); + toc.appendChild(lab); + }); + } + }); + + function switchTab(name) { + document.querySelectorAll('.backup-tab').forEach(function(t) { + var on = t.getAttribute('data-tab') === name; + t.classList.toggle('is-active', on); + t.setAttribute('aria-selected', on ? 'true' : 'false'); + }); + document.querySelectorAll('.backup-panel').forEach(function(p) { + p.classList.toggle('is-visible', p.id === name); + }); + } + + document.querySelectorAll('.backup-tab').forEach(function(t) { + t.addEventListener('click', function() { switchTab(t.getAttribute('data-tab')); }); + }); + + function backupLogLooksLikeFailure(log) { + if (!log || !String(log).trim()) return false; + var s = String(log); + return /\b(ERROR|FATAL)\s*:/i.test(s) || + /:\s*error:/i.test(s) || + /\bpg_restore:\s*error/i.test(s) || + /\bpg_dump:\s*error/i.test(s) || + /\bpg_dumpall:\s*error/i.test(s) || + /\[\s*exit\s+[1-9]\d*\s*\]/i.test(s) || + /exited\s+with\s+code\s+[1-9]/i.test(s) || + /errors\s+ignored\s+on\s+restore/i.test(s); + } + + function refreshLogAssistVisibility() { + var btn = document.getElementById('nb_assist'); + if (!btn) return; + btn.hidden = !backupLogLooksLikeFailure(lastLog); + } + + var backupRoot = document.querySelector('.backup-root'); + if (backupRoot) { + backupRoot.addEventListener('click', function(e) { + var t = e.target; + if (!t || !t.closest) return; + var btn = t.closest('[data-backup-assist]'); + if (!btn) return; + e.preventDefault(); + var scenario = btn.getAttribute('data-backup-assist') || 'tool_log'; + vscode.postMessage({ type: 'backupToolsAssist', payload: { scenario: scenario } }); + }); + } + + document.getElementById('d_browse_file').onclick = () => vscode.postMessage({ type: 'pickSaveFile', payload: { defaultName: (document.getElementById('d_db').value || 'db') + '_backup.dump' } }); + document.getElementById('d_browse_dir').onclick = () => vscode.postMessage({ type: 'pickDirectory' }); + document.getElementById('r_browse_in').onclick = () => vscode.postMessage({ type: 'pickOpenFile' }); + document.getElementById('a_browse').onclick = () => vscode.postMessage({ type: 'pickSaveFile', payload: { defaultName: 'dumpall.sql' } }); + + var dSearch = document.getElementById('d_table_search'); + var snSearch = document.getElementById('d_sn_search'); + if (dSearch) dSearch.addEventListener('input', function() { renderTablePickerList(); }); + if (snSearch) snSearch.addEventListener('input', function() { renderSchemaNsPickerList(); }); + + var snSelAll = document.getElementById('d_sn_select_all'); + var snSelShown = document.getElementById('d_sn_select_shown'); + var snClr = document.getElementById('d_sn_clear'); + if (snSelAll) snSelAll.addEventListener('click', function() { + getSchemaChoices().forEach(function(s) { selectedSchemas.add(s); }); + renderSchemaNsPickerList(); + onDumpSchemasChanged(); + }); + if (snSelShown) snSelShown.addEventListener('click', function() { + visibleSchemaRows().forEach(function(s) { selectedSchemas.add(s); }); + renderSchemaNsPickerList(); + onDumpSchemasChanged(); + }); + if (snClr) snClr.addEventListener('click', function() { + selectedSchemas.clear(); + renderSchemaNsPickerList(); + onDumpSchemasChanged(); + }); + + (function setupScopePickerDropdowns() { + var snWrap = document.getElementById('d_sn_ms_wrap'); + var snBtn = document.getElementById('d_sn_btn'); + var tableWrap = document.getElementById('d_table_ms_wrap'); + var tableBtn = document.getElementById('d_table_btn'); + var allBtn = document.getElementById('d_sch_filter_all'); + + if (snBtn && snWrap) { + snBtn.addEventListener('click', function(e) { + e.stopPropagation(); + if (snBtn.disabled) return; + setTablePickerOpen(false); + setSnPickerOpen(!schemaNsPickerOpen); + if (schemaNsPickerOpen) renderSchemaNsPickerList(); + }); + } + + if (tableBtn && tableWrap) { + tableBtn.addEventListener('click', function(e) { + e.stopPropagation(); + setSnPickerOpen(false); + setTablePickerOpen(!tablePickerOpen); + if (tablePickerOpen) { + renderSchemaFilterPanel(); + renderTablePickerList(); + } + }); + } + + if (allBtn) { + allBtn.addEventListener('click', function(e) { + e.stopPropagation(); + tableSchemaExclude.clear(); + renderSchemaFilterPanel(); + renderTablePickerList(); + }); + } + + document.addEventListener('mousedown', function(e) { + if (snWrap && schemaNsPickerOpen && !snWrap.contains(e.target)) setSnPickerOpen(false); + if (tableWrap && tablePickerOpen && !tableWrap.contains(e.target)) setTablePickerOpen(false); + }); + document.addEventListener('keydown', function(e) { + if (e.key !== 'Escape') return; + if (schemaNsPickerOpen) setSnPickerOpen(false); + if (tablePickerOpen) setTablePickerOpen(false); + }); + })(); + + document.getElementById('d_table_select_all').addEventListener('click', function() { + tableChoicesForDump().forEach(function(row) { selectedTables.add(row.qualified); }); + renderTablePickerList(); + }); + var dTableSelShown = document.getElementById('d_table_select_shown'); + if (dTableSelShown) dTableSelShown.addEventListener('click', function() { + visibleTableRows().forEach(function(row) { selectedTables.add(row.qualified); }); + renderTablePickerList(); + }); + document.getElementById('d_table_clear').addEventListener('click', function() { + selectedTables.clear(); + renderTablePickerList(); + }); + + function readExtraCli(id) { + var el = document.getElementById(id); + return el && el.value ? el.value : ''; + } + + document.getElementById('d_run').onclick = () => { + lastLog = ''; + document.getElementById('log').textContent = ''; + refreshLogAssistVisibility(); + var selList = []; + selectedTables.forEach(function(q) { selList.push(q); }); + selList.sort(); + var schemaSel = []; + selectedSchemas.forEach(function(s) { schemaSel.push(s); }); + schemaSel.sort(); + vscode.postMessage({ type: 'runDump', payload: { + format: document.getElementById('d_format').value, + verbose: document.getElementById('d_verbose').checked, + schemaOnly: document.getElementById('d_schema').checked, + dataOnly: document.getElementById('d_data').checked, + blobs: document.getElementById('d_blobs').checked, + parallelJobs: parseInt(document.getElementById('d_jobs').value, 10) || 1, + compression: parseInt(document.getElementById('d_z').value, 10), + outputPath: document.getElementById('d_out').value, + database: document.getElementById('d_db').value, + tableQualifiedList: selList.length ? selList : undefined, + schemaNameList: schemaSel.length ? schemaSel : undefined, + extraCliArgs: readExtraCli('d_extra') + }}); + }; + + document.getElementById('r_run').onclick = () => { + const lines = []; + const boxes = document.querySelectorAll('#toc input[type=checkbox]'); + boxes.forEach(cb => { + if (cb.checked && cb.dataset.line) lines.push(cb.dataset.line); + }); + if (boxes.length > 0 && lines.length === 0) { + alert('Select at least one archive object, or run Dry-run again after clearing selections.'); + return; + } + lastLog = ''; + document.getElementById('log').textContent = ''; + refreshLogAssistVisibility(); + vscode.postMessage({ type: 'runRestore', payload: { + inputPath: document.getElementById('r_in').value, + targetDatabase: document.getElementById('r_target').value, + verbose: document.getElementById('r_verbose').checked, + jobs: parseInt(document.getElementById('r_jobs').value, 10) || 1, + selectedLines: lines.length ? lines : undefined, + extraCliArgs: readExtraCli('r_extra') + }}); + }; + + document.getElementById('r_list').onclick = () => { + vscode.postMessage({ type: 'listArchive', payload: { + path: document.getElementById('r_in').value, + extraCliArgs: readExtraCli('r_extra') + }}); + }; + + document.getElementById('a_run').onclick = () => { + lastLog = ''; + document.getElementById('log').textContent = ''; + refreshLogAssistVisibility(); + vscode.postMessage({ type: 'runDumpall', payload: { + verbose: document.getElementById('a_verbose').checked, + globalsOnly: document.getElementById('a_globals').checked, + rolesOnly: document.getElementById('a_roles').checked, + outputPath: document.getElementById('a_out').value, + extraCliArgs: readExtraCli('a_extra') + }}); + }; + + document.getElementById('nb_append').onclick = () => { + vscode.postMessage({ type: 'appendNotebook', payload: { title: 'Backup / restore log', log: lastLog } }); + }; + + var nbAssist = document.getElementById('nb_assist'); + if (nbAssist) { + nbAssist.addEventListener('click', function() { + vscode.postMessage({ type: 'backupToolsAssist', payload: { scenario: 'tool_log', logText: lastLog } }); + }); + } + + document.getElementById('nb_cancel').onclick = () => vscode.postMessage({ type: 'cancel' }); + + document.getElementById('d_task').onclick = () => vscode.postMessage({ type: 'generateTask', payload: { + format: document.getElementById('d_format').value, + database: document.getElementById('d_db').value, + outputPath: document.getElementById('d_out').value || ('\u0024{workspaceFolder}/backup.dump') + }}); + + (function setupBackupLogPanelChrome() { + var vscodeApi = vscode; + var wrap = document.getElementById('backup_log_wrap'); + var handle = document.getElementById('backup_log_resize'); + var toggleBtn = document.getElementById('backup_log_toggle'); + var header = document.getElementById('backup_log_header'); + var MIN_LOG_H = 120; + var DEFAULT_LOG_H = 240; + + function clampHeight(px) { + var maxH = Math.min(Math.floor(window.innerHeight * 0.72), Math.max(MIN_LOG_H, window.innerHeight - 96)); + return Math.max(MIN_LOG_H, Math.min(maxH, Math.round(px))); + } + + function applySavedLogLayout() { + if (!wrap) return; + var st = vscodeApi.getState() || {}; + var h = typeof st.logPanelHeight === 'number' ? st.logPanelHeight : DEFAULT_LOG_H; + wrap.style.setProperty('--backup-log-height', clampHeight(h) + 'px'); + if (st.logCollapsed) { + wrap.classList.add('is-collapsed'); + if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false'); + } + } + + applySavedLogLayout(); + + function toggleLogCollapsed() { + if (!wrap) return; + var collapsed = wrap.classList.toggle('is-collapsed'); + if (toggleBtn) toggleBtn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + vscodeApi.setState(Object.assign({}, vscodeApi.getState() || {}, { logCollapsed: collapsed })); + } + + if (toggleBtn) { + toggleBtn.addEventListener('click', function(e) { + e.stopPropagation(); + toggleLogCollapsed(); + }); + } + if (header) { + header.addEventListener('click', function() { + toggleLogCollapsed(); + }); + } + + var dragActive = false; + var startY = 0; + var startH = 0; + + function endDrag() { + if (!dragActive) return; + dragActive = false; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onDragMove); + document.removeEventListener('mouseup', endDrag); + if (wrap && !wrap.classList.contains('is-collapsed')) { + vscodeApi.setState(Object.assign({}, vscodeApi.getState() || {}, { + logPanelHeight: Math.round(wrap.getBoundingClientRect().height) + })); + } + } + + function onDragMove(e) { + if (!dragActive || !wrap) return; + var dy = startY - e.clientY; + var nh = clampHeight(startH + dy); + wrap.style.setProperty('--backup-log-height', nh + 'px'); + } + + if (handle && wrap) { + handle.addEventListener('mousedown', function(e) { + if (wrap.classList.contains('is-collapsed')) return; + e.preventDefault(); + dragActive = true; + startY = e.clientY; + startH = wrap.getBoundingClientRect().height; + document.body.style.cursor = 'ns-resize'; + document.addEventListener('mousemove', onDragMove); + document.addEventListener('mouseup', endDrag); + }); + handle.addEventListener('keydown', function(e) { + if (wrap.classList.contains('is-collapsed')) return; + var step = e.shiftKey ? 28 : 14; + var cur = wrap.getBoundingClientRect().height; + if (e.key === 'ArrowUp') { + e.preventDefault(); + var up = clampHeight(cur + step); + wrap.style.setProperty('--backup-log-height', up + 'px'); + vscodeApi.setState(Object.assign({}, vscodeApi.getState() || {}, { logPanelHeight: up })); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + var down = clampHeight(cur - step); + wrap.style.setProperty('--backup-log-height', down + 'px'); + vscodeApi.setState(Object.assign({}, vscodeApi.getState() || {}, { logPanelHeight: down })); + } + }); + } + + window.addEventListener('resize', function() { + if (!wrap || wrap.classList.contains('is-collapsed')) return; + var cur = wrap.getBoundingClientRect().height; + var capped = clampHeight(cur); + if (capped !== cur) { + wrap.style.setProperty('--backup-log-height', capped + 'px'); + } + }); + })(); diff --git a/templates/backup-restore/styles.css b/templates/backup-restore/styles.css new file mode 100644 index 0000000..4a1e884 --- /dev/null +++ b/templates/backup-restore/styles.css @@ -0,0 +1,676 @@ +/* Backup workspace — tokens aligned with SQL Assistant / notebook output chrome */ +:root { + --backup-r: 8px; + --backup-r-sm: 6px; + --picker-list-max-h: 220px; + --picker-list-min-h: 120px; + --ms-panel-max-h: 280px; + --ui-surface: color-mix(in srgb, var(--vscode-editor-background) 92%, transparent); + --ui-surface-raised: color-mix(in srgb, var(--vscode-input-background) 88%, var(--vscode-editor-background) 12%); + --ui-border: color-mix(in srgb, var(--vscode-widget-border) 65%, transparent); + --ui-border-strong: color-mix(in srgb, var(--vscode-focusBorder) 28%, var(--vscode-widget-border)); +} + +html, body { + height: 100%; + overflow: hidden; +} +body { + padding: var(--sp-4); + display: flex; + flex-direction: column; + gap: 0; +} + +.backup-root { + display: flex; + flex-direction: column; + gap: var(--sp-3); + height: 100%; + min-height: 0; +} + +.backup-header { + flex-shrink: 0; +} +.backup-title { + margin: 0; + font-size: 14px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--vscode-foreground); +} +.backup-subtitle { + margin: var(--sp-1) 0 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +#banner { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} +.pg-banner { + padding: 8px 10px; + border-radius: var(--backup-r-sm); + border-left: 3px solid var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 1.45; + color: var(--vscode-foreground); +} +.pg-banner--split { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: var(--sp-2); +} +.pg-banner-msg { + flex: 1 1 220px; + min-width: 0; +} +.pg-banner--split .btn { + flex-shrink: 0; + font-size: 11px; + padding: 5px 10px; +} +.pg-banner.info { + border-left-color: var(--vscode-inputValidation-infoBorder); + background: color-mix(in srgb, var(--vscode-inputValidation-infoBorder) 12%, transparent); +} +.pg-banner.warn { + border-left-color: var(--vscode-editorWarning-foreground); + background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 14%, transparent); +} + +.backup-tabs { + display: flex; + gap: 2px; + flex-shrink: 0; + padding: 3px; + border-radius: var(--backup-r); + background: var(--ui-surface); + border: 1px solid var(--ui-border); +} +.backup-tab { + flex: 1; + text-align: center; + padding: 8px 10px; + font-size: 12px; + font-weight: 500; + border: none; + border-radius: var(--backup-r-sm); + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} +.backup-tab:hover { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 70%, transparent); +} +.backup-tab.is-active { + color: var(--vscode-foreground); + background: color-mix(in srgb, var(--vscode-editor-background) 75%, var(--vscode-list-activeSelectionBackground) 25%); + box-shadow: 0 0 0 1px var(--ui-border-strong); +} + +.backup-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + display: flex; + flex-direction: column; + gap: var(--sp-3); +} + +.backup-panel { display: none; flex-direction: column; gap: var(--sp-3); } +.backup-panel.is-visible { display: flex; } + +.backup-card { + border: 1px solid var(--ui-border); + border-radius: var(--backup-r); + background: var(--ui-surface-raised); + padding: var(--sp-3) var(--sp-4); + box-shadow: 0 1px 0 color-mix(in srgb, var(--vscode-widget-shadow) 12%, transparent); +} +.backup-card-title { + margin: 0 0 var(--sp-3); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.field { margin-bottom: var(--sp-3); } +.field:last-child { margin-bottom: 0; } +.field--tight-top { margin-top: var(--sp-2); } +.field-hint code { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 10px; +} +.field-control--mono { + font-family: var(--vscode-editor-font-family, ui-monospace, monospace); + font-size: 12px; +} +.field-label { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + margin-bottom: var(--sp-1); +} +.field-hint { + font-size: 11px; + color: var(--vscode-descriptionForeground); + margin-top: var(--sp-1); + line-height: 1.35; +} + +.field-control, +.backup-card select.field-control { + width: 100%; + box-sizing: border-box; + padding: 7px 10px; + font-size: 12px; + border-radius: var(--backup-r-sm); + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + outline: none; +} +.field-control:focus { + border-color: var(--vscode-focusBorder); +} + +.chk-grid { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); +} +.chk-item { + display: inline-flex; + align-items: center; + gap: var(--sp-2); + padding: 6px 10px; + border-radius: var(--backup-r-sm); + border: 1px solid var(--ui-border); + background: color-mix(in srgb, var(--vscode-editor-background) 94%, transparent); + font-size: 12px; + cursor: pointer; + user-select: none; +} +.chk-item input { + margin: 0; + accent-color: var(--vscode-focusBorder); +} + +.field-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-3); +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: var(--sp-2); + margin-top: var(--sp-2); +} +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 14px; + font-size: 12px; + font-weight: 500; + border-radius: var(--backup-r-sm); + border: none; + cursor: pointer; + transition: filter 0.12s ease, background 0.12s ease; +} +.btn-primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); +} +.btn-primary:hover { + background: var(--vscode-button-hoverBackground); +} +.btn-secondary { + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); +} +.btn-secondary:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + +/* Output log: outer wrap height is user-resizable (see --backup-log-height) */ +.backup-log-wrap { + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + height: var(--backup-log-height, 240px); + max-height: min(70vh, calc(100vh - 120px)); +} +.backup-log-wrap.is-collapsed { + height: auto !important; + max-height: none; +} +.backup-log-wrap.is-collapsed .backup-log-resize-handle { + display: none; +} +.backup-log-wrap.is-collapsed .backup-log-collapsible { + display: none !important; +} +.backup-log-wrap.is-collapsed .backup-log-chevron { + transform: rotate(-90deg); +} + +.backup-log-resize-handle { + flex-shrink: 0; + height: 8px; + margin: 0 0 var(--sp-1); + border-radius: 4px; + cursor: ns-resize; + background: transparent; + position: relative; + touch-action: none; +} +.backup-log-resize-handle:hover, +.backup-log-resize-handle:focus-visible { + background: color-mix(in srgb, var(--vscode-focusBorder) 28%, transparent); +} +.backup-log-resize-handle::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 48px; + height: 3px; + border-radius: 2px; + background: var(--vscode-widget-border); + opacity: 0.85; + pointer-events: none; +} + +.backup-log-section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.backup-log-section.pg-panel { + overflow: hidden; +} +.backup-log-header-bar.pg-panel-header { + cursor: pointer; + display: flex; + align-items: center; + gap: var(--sp-2); + flex-shrink: 0; +} +.backup-log-collapse-btn { + background: transparent; + border: none; + color: var(--vscode-foreground); + padding: 4px 8px; + margin: -4px 0 -4px -6px; + cursor: pointer; + border-radius: var(--backup-r-sm); + line-height: 1; +} +.backup-log-collapse-btn:hover { + background: var(--vscode-toolbar-hoverBackground); +} +.backup-log-collapse-btn:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} +.backup-log-chevron { + display: inline-block; + font-size: 10px; + transition: transform 0.18s ease; +} + +.backup-log-collapsible { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.backup-log-section .pg-panel-body { + padding-top: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); + flex: 1; + min-height: 0; +} +.backup-log-label { + margin: 0; + font-size: 12px; + font-weight: 600; + flex: 1; +} +#log { + flex: 1; + min-height: 72px; + margin: 0; + padding: 10px 12px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + line-height: 1.45; + background: color-mix(in srgb, var(--vscode-textBlockQuote-background) 85%, var(--vscode-editor-background) 15%); + color: var(--vscode-editor-foreground); + border: 1px solid var(--ui-border); + border-radius: var(--backup-r-sm); + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.toc-card { + margin-top: var(--sp-2); +} +#toc { + max-height: 180px; + overflow: auto; + padding: var(--sp-2); + border-radius: var(--backup-r-sm); + border: 1px solid var(--ui-border); + background: color-mix(in srgb, var(--vscode-editor-background) 96%, transparent); +} +#toc label { + margin: 4px 0; + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 11px; + line-height: 1.35; + cursor: pointer; +} +#toc input { margin-top: 2px; flex-shrink: 0; } + +.schema-ns-disabled { + opacity: 0.58; +} + +/* Selected schema chips (-n) */ +.picker-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: var(--sp-2) 0 var(--sp-1); + min-height: 0; +} +.picker-chips[hidden] { + display: none !important; +} +.picker-chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 4px 6px 4px 10px; + font-size: 11px; + font-family: var(--vscode-editor-font-family); + line-height: 1.3; + border-radius: 999px; + border: 1px solid var(--ui-border-strong); + background: color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 18%, var(--vscode-editor-background)); + color: var(--vscode-foreground); +} +.picker-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin: 0 -2px 0 0; + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.picker-chip-remove:hover { + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} +.picker-chip-remove:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +/* Scope pickers: combobox + scrollable checkbox panel (Schema -n, Tables -t) */ +.ms-dropdown { + position: relative; + min-width: 0; +} +.ms-dropdown-trigger:disabled { + cursor: not-allowed; + opacity: 0.65; +} +.ms-dropdown-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + text-align: left; + cursor: pointer; + font-family: inherit; +} +.ms-dropdown-trigger:hover { + border-color: color-mix(in srgb, var(--vscode-focusBorder) 45%, var(--vscode-input-border)); +} +.ms-dropdown.is-open .ms-dropdown-trigger { + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--vscode-focusBorder) 35%, transparent); +} +.ms-dropdown-value { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.ms-dropdown-chevron { + flex-shrink: 0; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--vscode-descriptionForeground); + opacity: 0.85; + transition: transform 0.15s ease; +} +.ms-dropdown.is-open .ms-dropdown-chevron { + transform: rotate(180deg); +} +.ms-dropdown-panel { + position: absolute; + z-index: 50; + left: 0; + right: 0; + top: calc(100% + 4px); + max-height: var(--ms-panel-max-h); + display: flex; + flex-direction: column; + border-radius: var(--backup-r-sm); + border: 1px solid var(--ui-border-strong); + background: var(--vscode-editor-background); + box-shadow: 0 8px 24px color-mix(in srgb, var(--vscode-widget-shadow) 28%, transparent); +} +.ms-dropdown-panel--picker { + max-height: min(78vh, 520px); + overflow: hidden; +} +.ms-dropdown-panel--wide { + min-width: min(100%, 440px); +} +.ms-dropdown-section { + flex-shrink: 0; +} +.ms-dropdown-head--sub { + padding-bottom: 6px; + border-bottom: none; +} +.ms-dropdown-head--wrap .ms-dropdown-title { + flex-basis: 100%; +} +.ms-dropdown-divider { + height: 1px; + margin: 0 8px; + background: var(--ui-border); + flex-shrink: 0; +} +.ms-dropdown-search { + padding: 8px 10px 0; + flex-shrink: 0; +} +.ms-dropdown-search .field-control { + width: 100%; + box-sizing: border-box; +} +.ms-dropdown-actions { + padding: 8px 10px 0; + margin-top: 0; + flex-shrink: 0; +} +.ms-dropdown-empty { + margin: 0 10px 10px; + padding: 0 2px; +} +.ms-dropdown-list--filter { + max-height: 112px; + margin: 0 8px 8px; + border-radius: var(--backup-r-sm); + border: 1px solid var(--ui-border); + background: color-mix(in srgb, var(--vscode-editor-background) 96%, transparent); +} +.ms-dropdown-list--scroll { + flex: 1 1 auto; + min-height: var(--picker-list-min-h); + max-height: var(--picker-list-max-h); + margin: 0 8px 10px; + border-radius: var(--backup-r-sm); + border: 1px solid var(--ui-border); + background: color-mix(in srgb, var(--vscode-editor-background) 96%, transparent); +} +.ms-dropdown-panel--wide .ms-dropdown-list--scroll { + max-height: min(340px, 44vh); +} +.ms-dropdown-panel--picker:focus-within .ms-dropdown-list--scroll, +.ms-dropdown-panel--picker:focus-within .ms-dropdown-list--filter { + border-color: color-mix(in srgb, var(--vscode-focusBorder) 40%, var(--ui-border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--vscode-focusBorder) 18%, transparent); +} +.ms-dropdown-panel[hidden] { + display: none !important; +} +.ms-dropdown-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 10px; + padding: 8px 10px; + border-bottom: 1px solid var(--ui-border); + font-size: 11px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; +} +.ms-dropdown-title { + font-weight: 600; + color: var(--vscode-foreground); + margin-right: auto; +} +.ms-dropdown-link { + background: none; + border: none; + padding: 2px 4px; + font-size: 11px; + color: var(--vscode-textLink-foreground); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} +.ms-dropdown-link:hover { + color: var(--vscode-textLink-activeForeground); +} +.ms-dropdown-list { + overflow: auto; + padding: 6px; +} +.ms-dropdown-option { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 8px; + border-radius: var(--backup-r-sm); + margin: 2px 0; + font-size: 12px; + font-family: var(--vscode-editor-font-family); + cursor: pointer; + user-select: none; +} +.ms-dropdown-option:hover { + background: var(--vscode-list-hoverBackground); +} +.ms-dropdown-option:focus-within { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} +.ms-dropdown-option input { + margin-top: 3px; + flex-shrink: 0; + accent-color: var(--vscode-focusBorder); +} +.table-picker-actions { + margin-bottom: 0; +} +.table-picker-row { + display: flex; + align-items: flex-start; + gap: var(--sp-2); + padding: 6px 10px; + border-radius: var(--backup-r-sm); + margin: 2px 0; + font-size: 12px; + font-family: var(--vscode-editor-font-family); + cursor: pointer; + user-select: none; +} +.table-picker-row:hover { + background: var(--vscode-list-hoverBackground); +} +.table-picker-row.is-selected { + background: color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 22%, transparent); +} +.table-picker-row input { + margin-top: 3px; + flex-shrink: 0; + accent-color: var(--vscode-focusBorder); +} + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-thumb { + background: var(--vscode-scrollbarSlider-background); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--vscode-scrollbarSlider-hoverBackground); +} From 8cf6de7f0e1748b691e69537950abc47472155ca Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 3 May 2026 23:00:13 +0530 Subject: [PATCH 16/17] Bump version to 1.3.6 --- package.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d321f33..df690db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.2.3", + "version": "1.3.6", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, @@ -1961,7 +1961,11 @@ "taskDefinitions": [ { "type": "pgstudio-pgdump", - "required": ["connectionId", "databaseName", "outputPath"], + "required": [ + "connectionId", + "databaseName", + "outputPath" + ], "properties": { "connectionId": { "type": "string", @@ -1977,7 +1981,12 @@ }, "dumpFormat": { "type": "string", - "enum": ["custom", "plain", "directory", "tar"], + "enum": [ + "custom", + "plain", + "directory", + "tar" + ], "description": "pg_dump -F mapping", "default": "custom" }, From 6be4387be163d0ac1266627d0b76447717cadf6d Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 3 May 2026 23:31:13 +0530 Subject: [PATCH 17/17] Bump version to 1.2.4 --- CHANGELOG.md | 12 ++++++++++++ package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2abe0a1..f64adeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the PostgreSQL Explorer extension will be documented in t The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.4] - 2026-05-03 + +### Added + +- **Backup and restore** — Database backup and restore from the extension: connection/database selection, a dedicated backup/restore webview with guided options, `pg_dump` / `pg_restore` (and related) argument builders with safe identifier handling and extra CLI args parsing, task-provider integration for scheduled dumps from VS Code, and clearer logging and errors across the flow. Chat assistant gains backup-oriented tooling and prompts where relevant. +- **PostgreSQL server version awareness** — New server-version helper so the extension can adapt queries and metadata reads; SQL helpers and the database tree use the live server version for better behavior on PostgreSQL 10 and 11. SQL completions and the DDL viewer incorporate version-aware paths where capabilities differ by release. + +### Changed + +- **Database commands** — Refactored database command surface to align with the new backup/restore entry points and shared resolution of connections for external tools. +- **Chat webview** — Wiring updates to support backup-related assistant flows alongside existing chat behavior. + ## [1.2.3] - 2026-05-02 ### Added diff --git a/package.json b/package.json index df690db..a353e1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.3.6", + "version": "1.2.4", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false,