From 81981518e8d523b5d7370df47fb1d81318129a75 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Wed, 18 Feb 2026 22:53:33 +0530 Subject: [PATCH 1/6] docs: Introduce a comprehensive roadmap proposal and technical gap analysis, updating the existing roadmap with new technical health and future collaboration phases. --- docs/GAP_ANALYSIS.md | 61 ++++++++++++++++++++++++++++ docs/ROADMAP.md | 38 ++++++++++++++---- docs/ROADMAP_PROPOSAL.md | 85 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 docs/GAP_ANALYSIS.md create mode 100644 docs/ROADMAP_PROPOSAL.md diff --git a/docs/GAP_ANALYSIS.md b/docs/GAP_ANALYSIS.md new file mode 100644 index 0000000..3c1744c --- /dev/null +++ b/docs/GAP_ANALYSIS.md @@ -0,0 +1,61 @@ +# PgStudio Gap Analysis & Technical Review + +> **Date:** February 2026 +> **Scope:** Full Codebase Review (v0.8.2) + +## 1. Security & Stability (CRITICAL) + +### 🚨 SQL Injection Risks in Generic Handlers +**Location:** `src/providers/NotebookKernel.ts` (handleSaveChanges, handleDeleteRows) +**Issue:** +The current implementation of `handleSaveChanges` and `handleDeleteRows` constructs SQL queries using string interpolation for values in some cases, rather than consistently using parameterized queries. +- `handleSaveChanges` manually escapes strings (`replace(/'/g, "''")`) and injects them into the query string. +- This is error-prone and a classic SQL injection vector if the manual escaping is bypassed or flawed. +**Recommendation:** Refactor to use parameterized queries (`$1`, `$2`, etc.) for ALL value inputs in `UPDATE` and `DELETE` operations. + +### ⚠️ Missing Transaction Safety for Batch Operations +**Location:** `src/providers/NotebookKernel.ts` +**Issue:** +Batch updates and deletes are executed sequentially. If one fails, previous successes are committed (unless auto-commit is off, but that's connection-dependent). +**Recommendation:** Wrap all batch operations (e.g., "Save Changes" for 50 rows) in an explicit `BEGIN ... COMMIT/ROLLBACK` block to ensure atomicity. + +## 2. Architecture & Patterns + +### πŸ“‰ Missing "Why Slow?" Persistence +**Location:** `src/services/QueryAnalyzer.ts`, `src/services/QueryHistoryService.ts` +**Issue:** +The `QueryAnalyzer` has logic to compare against a baseline (`analyzePerformanceAgainstBaseline`), but there is no persistence layer to store these baselines across sessions. +- `QueryHistoryService` stores strictly chronological history, not aggregated performance stats per query hash. +**Gap:** The "Why is this slow?" feature cannot track performance degradation over time without a persistent baseline store. + +### 🍝 Message Handling Bloat +**Location:** `src/extension.ts` (activate function), `src/providers/NotebookKernel.ts` (handleMessage) +**Issue:** +The `activate` function in `extension.ts` contains a massive `rendererMessaging.onDidReceiveMessage` block with mixed responsibilities (UI logic, database calls, error handling). +- Similarly, `NotebookKernel.ts` has a growing `handleMessage` switch statement. +**Recommendation:** Refactor into a `MessageHandler` registry or Command Pattern to decouple message routing from the entry points. + +## 3. Product Features (Missing vs Market) + +### πŸ“Š Visualizations +- **Gap:** No "Explain Plan" visualizer. Currently shows JSON or text. +- **Goal:** Implement a React-based flowchart or tree visualizer for `EXPLAIN (FORMAT JSON)` results. + +### πŸ€– AI Capabilities +- **Gap:** Context window management is basic. Large schemas will truncate or overflow. +- **Goal:** Implement RAG (Retrieval-Augmented Generation) or smarter schema pruning to fit relevant table definitions into context. + +## 4. Testing & QA + +### πŸ§ͺ Test Coverage +- **Unit Tests:** Exist for `ConnectionManager`, `QueryAnalyzer`, etc. +- **Integration Tests:** Basic connection tests exist. +- **Gap:** No end-to-end (E2E) tests for the UI (Playwright/Selenium) to verify the Notebook -> Renderer interaction. + +## 5. Roadmap Updates (Draft) +Based on this analysis, the following items should be added to the roadmap: + +- **[P0] Security:** Parameterize all usage of `handleSaveChanges` / `execute_update`. +- **[P1] Architecture:** Extract `MessageHandler` pattern. +- **[P1] Feature:** Implement `QueryPerformanceService` for persistent baselines. +- **[P2] UX:** Visual Explain Plan. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4945149..faeb056 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -116,11 +116,11 @@ --- ## ⚑ Phase 7: Advanced Power User & AI - -### AI Upgrades - [x] **Inject schema + breadcrumb into AI context** -- [ ] **β€œExplain this result” / β€œWhy slow?”** - - Implementation: Feed query execution plan or result summary to AI for analysis. +- [ ] **β€œWhy slow?” Performance Tracking** + - Implementation: Persistence layer for query performance baselines. Compare current execution vs historical average. +- [ ] **Visual Explain Plan** + - Implementation: React-based tree/flowchart visualization for `EXPLAIN (FORMAT JSON)` results. - [ ] **Safer AI suggestions on prod connections** - Implementation: Prompt engineering to warn AI about production contexts. @@ -134,12 +134,34 @@ --- +## πŸ› οΈ Phase 8: Technical Health (Security & Refactoring) + +### Security Hardening +- [ ] **Parameterize SQL Generation** + - Fix: Refactor `handleSaveChanges` and `handleDeleteRows` to use generic parameterized queries (`$1`, `$2`) instead of string interpolation to prevent SQL injection risks. +- [ ] **Atomic Batch Operations** + - Fix: Wrap batch updates/deletes in explicit transactions to ensure all-or-nothing execution. + +### Architectural Improvements +- [ ] **Refactor Message Handling** + - Fix: Extract `rendererMessaging.onDidReceiveMessage` logic into a dedicated `MessageHandler` registry or Command Pattern to reduce bloat in `extension.ts` and `NotebookKernel.ts`. +- [ ] **End-to-End Testing** + - Fix: Setup Playwright/Selenium suite to verify Notebook UI interactions and Renderer communication. + +--- + +## πŸš€ Phase 9: Future & Collaboration + +- [ ] **Team Collaboration Features** (Shared queries, comments) +- [ ] **Visual Database Designer** (ERD manipulation) +- [ ] **Cloud Sync** (Settings/Connection profiles sync) + +--- + ## ❌ Intentionally Not Now -- [ ] Visual query builder -- [ ] ER diagrams -- [ ] Full plan visualizers -- [ ] Cloud sync / accounts +- [ ] Full Visual Query Builder (complex UI burden) +- [ ] User/Role Management UI (admin focus, low priority) --- diff --git a/docs/ROADMAP_PROPOSAL.md b/docs/ROADMAP_PROPOSAL.md new file mode 100644 index 0000000..4395700 --- /dev/null +++ b/docs/ROADMAP_PROPOSAL.md @@ -0,0 +1,85 @@ +# PgStudio Roadmap 2026: The Path to a Full-Fledged DBMS + +> **Mission:** Combine the depth of pgAdmin with the developer-centric speed of VS Code. +> **Philosophy:** "Reduce fear. Increase speed. Everything else waits." + +--- + +## 🎯 Strategic Pillars + +### 1. Visual Schema Builder (VSB) β€” *"Design without Code"* +**Goal:** Empower users to design and modify database schemas visually without manually writing `ALTER TABLE` statements, while maintaining safety. + +- **Visual Table Designer**: A GUI to add/remove columns, change types, and set constraints. +- **Index Manager**: Visually create, drop, and analyze indexes. +- **Constraint Manager**: Manage Foreign Keys, Unique constraints, and Checks with a simple UI. +- **Role/User Management**: Basic interface for creating users and granting permissions (essential for local dev setup). + +### 2. Data Fluidity β€” *"Move Data Instantly"* +**Goal:** Make getting data in and out of the database frictionless and immediate. + +- **Smart Paste**: Copy from Excel/Sheets, paste directly into a table grid to insert rows. +- **Visual Import Wizard**: Drag & drop CSV/JSON files, map columns, and import data. +- **Enhanced Export**: Configurable delimiters, encoding, and direct-to-file streaming for large datasets. +- **Quick Clone**: Right-click a table to "Duplicate Structure & Data". + +### 3. Operability Suite β€” *"Diagnose & Fix"* +**Goal:** Give users deep visibility and control over the running database instance to diagnose issues instantly. + +- **Session Manager**: View active queries, see who is blocking whom, and `KILL` stuck processes. +- **Lock Viewer**: Visualize blocking chains to resolve deadlocks. +- **Visual Explain Plan**: Graphical flowchart representation of query execution plans (to identify bottlenecks at a glance). +- **Server Dashboard**: Real-time CPU/RAM/IO usage (if compatible with provider). + +### 4. Developer Experience (DX) β€” *"Code Faster"* +**Goal:** Leverage the VS Code ecosystem to bridge the gap between database and application code. + +- **TypeScript Type Generation**: Right-click a table -> "Copy TypeScript Interface". +- **Global Object Search**: Integrate with VS Code's `Ctrl+P` (e.g., `#users` to jump to users table). +- **"Go to Definition"**: `F12` on a table name in SQL to jump to its DDL/Designer. +- **API Generator**: Right-click table -> "Generate CRUD API (Node/Python)". + +--- + +## πŸ—“οΈ Proposed Rollout Phases + +### πŸ—οΈ Phase 7: Visual Schema Design (The "Builder" Phase) +*Focus: making schema changes safe and easy.* +- [ ] **Visual Table Designer** (Add/Edit Columns & Types) +- [ ] **Constraint Manager** (FK, PK, Unique, Check) +- [ ] **Index Manager** (Create/Drop Indexes visually) +- [ ] **Schema Diff** (Compare local vs remote schema) + +### 🌊 Phase 8: Data Productivity (The "Mover" Phase) +*Focus: getting data into the right shape fast.* +- [ ] **Smart Paste** (Excel -> Table) +- [ ] **Visual Import Wizard** (CSV/JSON Drag & Drop) +- [ ] **Bulk Row Editing** (Spreadsheet-like experience) +- [ ] **Result Grid Aggregations** (Sum/Avg of selected cells) + +### 🩺 Phase 9: Diagnostics & Ops (The "Doctor" Phase) +*Focus: solving performance issues and locks.* +- [ ] **Visual Explain Plan** (Flowchart view) +- [ ] **Session Manager** (View & Kill queries) +- [ ] **Lock Viewer** (Blocking chains) +- [ ] **Log Viewer** (Live tail of Postgres logs if accessible) + +### πŸ’» Phase 10: Developer Integrations (The "Coder" Phase) +*Focus: bridging DB and App code.* +- [ ] **TypeScript / Zod Type Generator** +- [ ] **Global Object Search** (`Ctrl+P` integration) +- [ ] **Snippet Manager** (Team-shared query library) +- [ ] **"Generate Mock Data"** (AI-powered fake data population) + +--- + +## πŸ“Š Feature Comparison (Target State) + +| Feature | PgStudio (Future) | pgAdmin | DBeaver | VS Code SQLTools | +|---------|-------------------|---------|---------|------------------| +| **Visual Table Design** | βœ… Modern React UI | βœ… Legacy Dialogs | βœ… | ❌ | +| **Data Import/Export** | βœ… Drag & Drop | βœ… Wizard | βœ… Wizard | ❌ Basic | +| **Interactive Notebooks**| βœ… | ❌ | ❌ | ❌ | +| **TS Type Gen** | βœ… Built-in | ❌ | ❌ | ❌ | +| **AI Copilot** | βœ… Built-in | ❌ | ❌ | ❌ | +| **Session Manager** | βœ… | βœ… | βœ… | ❌ | diff --git a/package.json b/package.json index f834d13..718d2a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "0.8.2", + "version": "0.8.3", "description": "PostgreSQL database explorer for VS Code with notebook support", "publisher": "ric-v", "private": false, From 23c25b8002ed787f3059ba5d6b804ba88fb11f70 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Wed, 18 Feb 2026 23:18:46 +0530 Subject: [PATCH 2/6] feat: Add visual table designer for creating and altering PostgreSQL tables and introduce schema diff panel. --- package.json | 50 +- src/activation/commands.ts | 17 + src/commands/schemaDesigner.ts | 52 ++ src/extension.ts | 19 + src/schemaDesigner/SchemaDiffPanel.ts | 894 ++++++++++++++++++++ src/schemaDesigner/TableDesignerPanel.ts | 995 +++++++++++++++++++++++ src/schemaDesigner/connectionHelper.ts | 81 ++ 7 files changed, 2102 insertions(+), 6 deletions(-) create mode 100644 src/commands/schemaDesigner.ts create mode 100644 src/schemaDesigner/SchemaDiffPanel.ts create mode 100644 src/schemaDesigner/TableDesignerPanel.ts create mode 100644 src/schemaDesigner/connectionHelper.ts diff --git a/package.json b/package.json index 718d2a0..cb612be 100644 --- a/package.json +++ b/package.json @@ -864,7 +864,7 @@ }, { "command": "postgres-explorer.saveQueryToLibraryUI", - "title": "πŸ’Ύ Save Query (UI Form)", + "title": "\ud83d\udcbe Save Query (UI Form)", "icon": "$(save)", "category": "PgStudio: Queries" }, @@ -876,31 +876,31 @@ }, { "command": "postgres-explorer.loadSavedQueryUI", - "title": "πŸ“‚ Load Saved Query", + "title": "\ud83d\udcc2 Load Saved Query", "icon": "$(folder-opened)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.viewSavedQuery", - "title": "πŸ“Œ View Saved Query", + "title": "\ud83d\udccc View Saved Query", "icon": "$(preview)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.copySavedQuery", - "title": "πŸ“‹ Copy Query", + "title": "\ud83d\udccb Copy Query", "icon": "$(copy)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.editSavedQuery", - "title": "✏️ Edit Query", + "title": "\u270f\ufe0f Edit Query", "icon": "$(edit)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.openSavedQueryInNotebook", - "title": "πŸ“˜ Open in Notebook", + "title": "\ud83d\udcd8 Open in Notebook", "icon": "$(notebook)", "category": "PgStudio: Queries" }, @@ -933,6 +933,24 @@ "title": "Import Saved Queries", "icon": "$(import)", "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.openTableDesigner", + "title": "Open Table Designer", + "icon": "$(layout)", + "category": "PgStudio: Schema" + }, + { + "command": "postgres-explorer.createTableVisual", + "title": "Create Table (Visual)", + "icon": "$(add)", + "category": "PgStudio: Schema" + }, + { + "command": "postgres-explorer.openSchemaDiff", + "title": "Schema Diff", + "icon": "$(diff)", + "category": "PgStudio: Schema" } ], "submenus": [ @@ -1847,6 +1865,26 @@ "command": "postgres-explorer.deleteSavedQuery", "when": "view == postgresExplorer.savedQueries && viewItem == savedQuery", "group": "2_delete@1" + }, + { + "command": "postgres-explorer.openTableDesigner", + "when": "view == postgresExplorer && viewItem == table", + "group": "1_actions" + }, + { + "command": "postgres-explorer.openSchemaDiff", + "when": "view == postgresExplorer && viewItem == table", + "group": "1_actions" + }, + { + "command": "postgres-explorer.createTableVisual", + "when": "view == postgresExplorer && viewItem == schema", + "group": "1_actions" + }, + { + "command": "postgres-explorer.openSchemaDiff", + "when": "view == postgresExplorer && viewItem == schema", + "group": "1_actions" } ], "postgres-explorer.columnScriptsMenu": [ diff --git a/src/activation/commands.ts b/src/activation/commands.ts index fff56c5..245c012 100644 --- a/src/activation/commands.ts +++ b/src/activation/commands.ts @@ -48,6 +48,9 @@ import { } from '../commands/phase7'; import { SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; +// Visual Schema Design +import { cmdOpenTableDesigner, cmdCreateTableVisual, cmdOpenSchemaDiff } from '../commands/schemaDesigner'; + export function registerAllCommands( context: vscode.ExtensionContext, databaseTreeProvider: DatabaseTreeProvider, @@ -1156,6 +1159,20 @@ export function registerAllCommands( command: 'postgres-explorer.loadSavedQueryUI', callback: () => loadSavedQueryUI() }, + + // Visual Schema Design (Phase 7 Roadmap) + { + command: 'postgres-explorer.openTableDesigner', + callback: (item: DatabaseTreeItem) => cmdOpenTableDesigner(item, context) + }, + { + command: 'postgres-explorer.createTableVisual', + callback: (item: DatabaseTreeItem) => cmdCreateTableVisual(item, context) + }, + { + command: 'postgres-explorer.openSchemaDiff', + callback: (item: DatabaseTreeItem) => cmdOpenSchemaDiff(item, context) + }, ]; console.log('Starting command registration...'); diff --git a/src/commands/schemaDesigner.ts b/src/commands/schemaDesigner.ts new file mode 100644 index 0000000..1928732 --- /dev/null +++ b/src/commands/schemaDesigner.ts @@ -0,0 +1,52 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { TableDesignerPanel } from '../schemaDesigner/TableDesignerPanel'; +import { SchemaDiffPanel } from '../schemaDesigner/SchemaDiffPanel'; + +/** + * Open the Visual Table Designer for an existing table (Edit mode) + */ +export async function cmdOpenTableDesigner( + item: DatabaseTreeItem, + context: vscode.ExtensionContext +): Promise { + console.log('[SchemaDesigner] cmdOpenTableDesigner called with item:', JSON.stringify({ + label: item?.label, + type: item?.type, + connectionId: item?.connectionId, + databaseName: item?.databaseName, + schema: item?.schema, + tableName: item?.tableName, + contextValue: item?.contextValue, + })); + await TableDesignerPanel.openForTable(item, context); +} + +/** + * Open the Visual Table Designer in Create mode (new table) + */ +export async function cmdCreateTableVisual( + item: DatabaseTreeItem, + context: vscode.ExtensionContext +): Promise { + await TableDesignerPanel.openForCreate(item, context); +} + +/** + * Open the Schema Diff panel to compare two schemas + */ +export async function cmdOpenSchemaDiff( + item: DatabaseTreeItem, + context: vscode.ExtensionContext +): Promise { + console.log('[SchemaDesigner] cmdOpenSchemaDiff called with item:', JSON.stringify({ + label: item?.label, + type: item?.type, + connectionId: item?.connectionId, + databaseName: item?.databaseName, + schema: item?.schema, + tableName: item?.tableName, + contextValue: item?.contextValue, + })); + await SchemaDiffPanel.open(item, context); +} diff --git a/src/extension.ts b/src/extension.ts index ef45fff..bc8c493 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -44,6 +44,25 @@ export async function activate(context: vscode.ExtensionContext) { QueryHistoryService.initialize(context.workspaceState); QueryPerformanceService.initialize(context.globalState); + // Migration: Ensure all connections have an ID (legacy connections might not) + const config = vscode.workspace.getConfiguration(); + const connections = config.get('postgresExplorer.connections') || []; + let hasChanges = false; + + const migratedConnections = connections.map((conn, index) => { + if (!conn.id) { + hasChanges = true; + // Generate a stable-ish ID for legacy connections + return { ...conn, id: `${Date.now()}-${index}` }; + } + return conn; + }); + + if (hasChanges) { + await config.update('postgresExplorer.connections', migratedConnections, vscode.ConfigurationTarget.Global); + console.log('Migrated legacy connections to include IDs'); + } + // Phase 7: Initialize ProfileManager and SavedQueriesService ProfileManager.getInstance().initialize(context); SavedQueriesService.getInstance().initialize(context); diff --git a/src/schemaDesigner/SchemaDiffPanel.ts b/src/schemaDesigner/SchemaDiffPanel.ts new file mode 100644 index 0000000..115400e --- /dev/null +++ b/src/schemaDesigner/SchemaDiffPanel.ts @@ -0,0 +1,894 @@ +import * as vscode from 'vscode'; +import { ErrorHandlers } from '../commands/helper'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { resolveTreeItemConnection } from './connectionHelper'; +import { ConnectionManager } from '../services/ConnectionManager'; +import { SecretStorageService } from '../services/SecretStorageService'; + +interface SchemaSnapshot { + tables: TableSnapshot[]; +} + +interface TableSnapshot { + name: string; + schema: string; + columns: ColumnSnapshot[]; + constraints: ConstraintSnapshot[]; + indexes: IndexSnapshot[]; +} + +interface ColumnSnapshot { + column_name: string; + data_type: string; + not_null: boolean; + default_value: string | null; + ordinal: number; +} + +interface ConstraintSnapshot { + name: string; + type: string; + definition: string; +} + +interface IndexSnapshot { + name: string; + definition: string; + is_unique: boolean; + is_primary: boolean; +} + +type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged'; + +interface TableDiff { + name: string; + status: DiffStatus; + columnDiffs: ColumnDiff[]; + constraintDiffs: ConstraintDiff[]; + indexDiffs: IndexDiff[]; +} + +interface ColumnDiff { + name: string; + status: DiffStatus; + before?: ColumnSnapshot; + after?: ColumnSnapshot; +} + +interface ConstraintDiff { + name: string; + status: DiffStatus; + before?: ConstraintSnapshot; + after?: ConstraintSnapshot; +} + +interface IndexDiff { + name: string; + status: DiffStatus; + before?: IndexSnapshot; + after?: IndexSnapshot; +} + +/** + * Schema Diff Panel + * + * Compares two schemas (or the same schema at two points in time) and renders + * a color-coded diff. Generates a migration SQL script for review in a notebook. + */ +export class SchemaDiffPanel { + public static readonly viewType = 'pgStudio.schemaDiff'; + + private static _panels = new Map(); + private readonly _panel: vscode.WebviewPanel; + private _disposables: vscode.Disposable[] = []; + + private constructor(panel: vscode.WebviewPanel) { + this._panel = panel; + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + public static async open( + item: DatabaseTreeItem, + context: vscode.ExtensionContext + ): Promise { + let sourceConn; + let targetConn: any; + + try { + sourceConn = await resolveTreeItemConnection(item); + if (!sourceConn) return; // user cancelled + + const { client: sourceClient, metadata } = sourceConn; + + // Determine source schema + const labelStr = typeof item.label === 'string' ? item.label : (item.label as any)?.label ?? ''; + const sourceSchema = item.schema || labelStr || 'public'; + + // 1. Get schemas in current DB + const allSchemasResult = await sourceClient.query(` + SELECT nspname as schema_name + FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname + `); + + const currentDbSchemas = allSchemasResult.rows.map((r: any) => r.schema_name); + const otherSchemasInCurrentDb = currentDbSchemas.filter((s: string) => s !== sourceSchema); + + // 2. Build QuickPick items + const pickItems: vscode.QuickPickItem[] = [ + { + label: '$(server) Compare with another database...', + description: 'Select a schema from a different connection or database', + alwaysShow: true + }, + { + label: 'Current Database', + kind: vscode.QuickPickItemKind.Separator + }, + ...otherSchemasInCurrentDb.map(s => ({ label: s, description: 'Current Database' })) + ]; + + // 3. Ask user for target + const selection = await vscode.window.showQuickPick(pickItems, { + placeHolder: `Compare "${sourceSchema}" against...`, + title: 'Schema Diff: Select Target' + }); + + if (!selection) return; + + let targetSchema = selection.label; + let targetClient = sourceClient; // Default to same client + + // Handle "Compare with another database..." + if (selection.label === '$(server) Compare with another database...') { + // A. Pick Connection + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + if (connections.length === 0) { + vscode.window.showErrorMessage('No other connections found.'); + return; + } + + const connPick = await vscode.window.showQuickPick( + connections.map(c => ({ + label: c.name || `${c.host}:${c.port}`, + description: c.database || 'postgres', + original: c + })), + { title: 'Select Target Connection' } + ); + if (!connPick) return; + + // B. Pick Database (need to connect first to list DBs) + const selectedConn = connPick.original; + + // Connect to 'postgres' or default db to list databases + const password = await SecretStorageService.getInstance().getPassword(selectedConn.id); + if (!password) { + vscode.window.showErrorMessage('Password not found for selected connection.'); + return; + } + + const tempClient = await ConnectionManager.getInstance().getPooledClient({ + ...selectedConn, + password, + database: selectedConn.database || 'postgres' // Connect to default + }); + + try { + const dbsResult = await tempClient.query(` + SELECT datname FROM pg_database + WHERE datallowconn = true AND datname != 'postgres' AND datistemplate = false + ORDER BY datname + `); + const databases = dbsResult.rows.map((r: any) => r.datname); + + // Add the default db if it's not in the list (e.g. if we filtered 'postgres' but it was the default) + if (selectedConn.database && !databases.includes(selectedConn.database)) { + databases.unshift(selectedConn.database); + } + + const dbPick = await vscode.window.showQuickPick(databases, { title: 'Select Target Database' }); + if (!dbPick) return; // user cancelled + + // C. Connect to Target Database + targetConn = { + client: await ConnectionManager.getInstance().getPooledClient({ + ...selectedConn, + password, + database: dbPick + }), + release: () => targetConn?.client.release() + }; + targetClient = targetConn.client; + + // D. Pick Schema in Target Database + const targetSchemasResult = await targetClient.query(` + SELECT nspname as schema_name + FROM pg_namespace + WHERE nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND nspname NOT LIKE 'pg_%' + ORDER BY nspname + `); + const targetSchemas = targetSchemasResult.rows.map((r: any) => r.schema_name); + + const schemaPick = await vscode.window.showQuickPick(targetSchemas, { title: `Select Schema in ${dbPick}` }); + if (!schemaPick) return; + + targetSchema = schemaPick; + + } finally { + tempClient.release(); + } + } + + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: 'Computing schema diff...', cancellable: false }, + async () => { + // Serialize queries to avoid client interleaving issues (even if different clients, good practice) + // sourceClient and targetClient might be same or different + const sourceSnapshot = await SchemaDiffPanel._fetchSnapshot(sourceClient, sourceSchema); + const targetSnapshot = await SchemaDiffPanel._fetchSnapshot(targetClient, targetSchema); + + const diffs = SchemaDiffPanel._computeDiff(sourceSnapshot, targetSnapshot); + + // Key needs to include target connection info to be unique + const targetConnId = (targetClient === sourceClient) ? item.connectionId : 'external'; + const panelKey = `diff:${item.connectionId}:${item.databaseName}:${sourceSchema}:${targetConnId}:${targetSchema}`; + + if (SchemaDiffPanel._panels.has(panelKey)) { + SchemaDiffPanel._panels.get(panelKey)!._panel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + SchemaDiffPanel.viewType, + `πŸ” Diff: ${sourceSchema} ↔ ${targetSchema}`, + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true } + ); + + const diffPanel = new SchemaDiffPanel(panel); + SchemaDiffPanel._panels.set(panelKey, diffPanel); + + panel.onDidDispose(() => { + SchemaDiffPanel._panels.delete(panelKey); + }); + + panel.webview.html = SchemaDiffPanel._getHtml( + sourceSchema, + targetSchema, + diffs + ); + + panel.webview.onDidReceiveMessage(async (message) => { + if (message.type === 'generateMigration') { + await SchemaDiffPanel._generateMigration( + sourceSchema, + targetSchema, + diffs, + metadata + ); + } + }, null, diffPanel._disposables); + } + ); + + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'open schema diff'); + } finally { + if (sourceConn && sourceConn.release) sourceConn.release(); + if (targetConn && targetConn.client) targetConn.release(); + } + } + + private static async _fetchSnapshot(client: any, schema: string): Promise { + // Fetch tables + const tablesResult = await client.query(` + SELECT c.relname as table_name + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = 'r' + ORDER BY c.relname + `, [schema]); + + const tables: TableSnapshot[] = []; + + for (const tableRow of tablesResult.rows) { + const tableName = tableRow.table_name; + + // Columns + const colResult = await client.query(` + SELECT + a.attnum as ordinal, + a.attname as column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) as data_type, + a.attnotnull as not_null, + pg_catalog.pg_get_expr(d.adbin, d.adrelid) as default_value + FROM pg_catalog.pg_attribute a + LEFT JOIN pg_catalog.pg_attrdef d + ON d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef + WHERE a.attrelid = ($1 || '.' || $2)::regclass + AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + `, [schema, tableName]); + + // Constraints + const conResult = await client.query(` + SELECT + conname as name, + CASE contype + WHEN 'p' THEN 'PRIMARY KEY' + WHEN 'f' THEN 'FOREIGN KEY' + WHEN 'u' THEN 'UNIQUE' + WHEN 'c' THEN 'CHECK' + ELSE contype + END as type, + pg_get_constraintdef(oid) as definition + FROM pg_constraint + WHERE conrelid = ($1 || '.' || $2)::regclass + ORDER BY conname + `, [schema, tableName]); + + // Indexes + const idxResult = await client.query(` + SELECT + i.relname as name, + pg_get_indexdef(ix.indexrelid) as definition, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE t.relname = $1 AND n.nspname = $2 + ORDER BY i.relname + `, [tableName, schema]); + + tables.push({ + name: tableName, + schema, + columns: colResult.rows, + constraints: conResult.rows, + indexes: idxResult.rows + }); + } + + return { tables }; + } + + private static _computeDiff(source: SchemaSnapshot, target: SchemaSnapshot): TableDiff[] { + const diffs: TableDiff[] = []; + const sourceMap = new Map(source.tables.map(t => [t.name, t])); + const targetMap = new Map(target.tables.map(t => [t.name, t])); + + const allTableNames = new Set([...sourceMap.keys(), ...targetMap.keys()]); + + for (const tableName of allTableNames) { + const srcTable = sourceMap.get(tableName); + const tgtTable = targetMap.get(tableName); + + if (!srcTable) { + // Table added in target + diffs.push({ + name: tableName, + status: 'added', + columnDiffs: (tgtTable!.columns || []).map(c => ({ name: c.column_name, status: 'added', after: c })), + constraintDiffs: (tgtTable!.constraints || []).map(c => ({ name: c.name, status: 'added', after: c })), + indexDiffs: (tgtTable!.indexes || []).map(i => ({ name: i.name, status: 'added', after: i })) + }); + continue; + } + + if (!tgtTable) { + // Table removed in target + diffs.push({ + name: tableName, + status: 'removed', + columnDiffs: (srcTable.columns || []).map(c => ({ name: c.column_name, status: 'removed', before: c })), + constraintDiffs: (srcTable.constraints || []).map(c => ({ name: c.name, status: 'removed', before: c })), + indexDiffs: (srcTable.indexes || []).map(i => ({ name: i.name, status: 'removed', before: i })) + }); + continue; + } + + // Both exist β€” diff columns, constraints, indexes + const columnDiffs = SchemaDiffPanel._diffColumns(srcTable.columns, tgtTable.columns); + const constraintDiffs = SchemaDiffPanel._diffConstraints(srcTable.constraints, tgtTable.constraints); + const indexDiffs = SchemaDiffPanel._diffIndexes(srcTable.indexes, tgtTable.indexes); + + const hasChanges = columnDiffs.some(d => d.status !== 'unchanged') || + constraintDiffs.some(d => d.status !== 'unchanged') || + indexDiffs.some(d => d.status !== 'unchanged'); + + diffs.push({ + name: tableName, + status: hasChanges ? 'changed' : 'unchanged', + columnDiffs, + constraintDiffs, + indexDiffs + }); + } + + // Sort: changed first, then added/removed, then unchanged + const order: Record = { changed: 0, added: 1, removed: 2, unchanged: 3 }; + diffs.sort((a, b) => order[a.status] - order[b.status]); + + return diffs; + } + + private static _diffColumns(src: ColumnSnapshot[], tgt: ColumnSnapshot[]): ColumnDiff[] { + const srcMap = new Map(src.map(c => [c.column_name, c])); + const tgtMap = new Map(tgt.map(c => [c.column_name, c])); + const diffs: ColumnDiff[] = []; + + for (const [name, srcCol] of srcMap) { + const tgtCol = tgtMap.get(name); + if (!tgtCol) { + diffs.push({ name, status: 'removed', before: srcCol }); + } else { + const changed = srcCol.data_type !== tgtCol.data_type || + srcCol.not_null !== tgtCol.not_null || + (srcCol.default_value || '') !== (tgtCol.default_value || ''); + diffs.push({ name, status: changed ? 'changed' : 'unchanged', before: srcCol, after: tgtCol }); + } + } + for (const [name, tgtCol] of tgtMap) { + if (!srcMap.has(name)) { + diffs.push({ name, status: 'added', after: tgtCol }); + } + } + return diffs; + } + + private static _diffConstraints(src: ConstraintSnapshot[], tgt: ConstraintSnapshot[]): ConstraintDiff[] { + const srcMap = new Map(src.map(c => [c.name, c])); + const tgtMap = new Map(tgt.map(c => [c.name, c])); + const diffs: ConstraintDiff[] = []; + + for (const [name, srcCon] of srcMap) { + const tgtCon = tgtMap.get(name); + if (!tgtCon) { + diffs.push({ name, status: 'removed', before: srcCon }); + } else { + const changed = srcCon.definition !== tgtCon.definition; + diffs.push({ name, status: changed ? 'changed' : 'unchanged', before: srcCon, after: tgtCon }); + } + } + for (const [name, tgtCon] of tgtMap) { + if (!srcMap.has(name)) { + diffs.push({ name, status: 'added', after: tgtCon }); + } + } + return diffs; + } + + private static _diffIndexes(src: IndexSnapshot[], tgt: IndexSnapshot[]): IndexDiff[] { + const srcMap = new Map(src.map(i => [i.name, i])); + const tgtMap = new Map(tgt.map(i => [i.name, i])); + const diffs: IndexDiff[] = []; + + for (const [name, srcIdx] of srcMap) { + const tgtIdx = tgtMap.get(name); + if (!tgtIdx) { + diffs.push({ name, status: 'removed', before: srcIdx }); + } else { + const changed = srcIdx.definition !== tgtIdx.definition; + diffs.push({ name, status: changed ? 'changed' : 'unchanged', before: srcIdx, after: tgtIdx }); + } + } + for (const [name, tgtIdx] of tgtMap) { + if (!srcMap.has(name)) { + diffs.push({ name, status: 'added', after: tgtIdx }); + } + } + return diffs; + } + + private static async _generateMigration( + sourceSchema: string, + targetSchema: string, + diffs: TableDiff[], + metadata: any + ): Promise { + const stmts: string[] = []; + + for (const table of diffs) { + if (table.status === 'unchanged') continue; + + if (table.status === 'added') { + // Table exists in target but not source β€” generate CREATE TABLE + const cols = table.columnDiffs.filter(c => c.status === 'added' && c.after); + const colDefs = cols.map(c => { + const nn = c.after!.not_null ? ' NOT NULL' : ''; + const def = c.after!.default_value ? ` DEFAULT ${c.after!.default_value}` : ''; + return ` "${c.name}" ${c.after!.data_type}${nn}${def}`; + }); + stmts.push(`-- Table added in ${targetSchema}\nCREATE TABLE "${sourceSchema}"."${table.name}" (\n${colDefs.join(',\n')}\n);`); + continue; + } + + if (table.status === 'removed') { + stmts.push(`-- Table removed in ${targetSchema}\n-- DROP TABLE "${sourceSchema}"."${table.name}"; -- Uncomment to drop`); + continue; + } + + // Changed table + stmts.push(`-- Changes for table: ${table.name}`); + + for (const col of table.columnDiffs) { + if (col.status === 'added' && col.after) { + const nn = col.after.not_null ? ' NOT NULL' : ''; + const def = col.after.default_value ? ` DEFAULT ${col.after.default_value}` : ''; + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ADD COLUMN "${col.name}" ${col.after.data_type}${nn}${def};`); + } else if (col.status === 'removed') { + stmts.push(`-- ALTER TABLE "${sourceSchema}"."${table.name}"\n-- DROP COLUMN "${col.name}"; -- Uncomment to drop`); + } else if (col.status === 'changed' && col.before && col.after) { + if (col.before.data_type !== col.after.data_type) { + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ALTER COLUMN "${col.name}" TYPE ${col.after.data_type};`); + } + if (col.before.not_null !== col.after.not_null) { + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ALTER COLUMN "${col.name}" ${col.after.not_null ? 'SET' : 'DROP'} NOT NULL;`); + } + if ((col.before.default_value || '') !== (col.after.default_value || '')) { + if (col.after.default_value) { + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ALTER COLUMN "${col.name}" SET DEFAULT ${col.after.default_value};`); + } else { + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ALTER COLUMN "${col.name}" DROP DEFAULT;`); + } + } + } + } + + for (const con of table.constraintDiffs) { + if (con.status === 'added' && con.after) { + stmts.push(`ALTER TABLE "${sourceSchema}"."${table.name}"\n ADD CONSTRAINT "${con.name}" ${con.after.definition};`); + } else if (con.status === 'removed') { + stmts.push(`-- ALTER TABLE "${sourceSchema}"."${table.name}"\n-- DROP CONSTRAINT "${con.name}"; -- Uncomment to drop`); + } + } + + for (const idx of table.indexDiffs) { + if (idx.status === 'added' && idx.after) { + // Replace schema in definition + stmts.push(idx.after.definition.replace( + new RegExp(`ON ${targetSchema}\\.`, 'g'), + `ON ${sourceSchema}.` + ) + ';'); + } else if (idx.status === 'removed') { + stmts.push(`-- DROP INDEX "${idx.name}"; -- Uncomment to drop`); + } + } + } + + if (stmts.length === 0) { + vscode.window.showInformationMessage('No differences found between schemas.'); + return; + } + + const { createAndShowNotebook } = await import('../commands/connection'); + const cells: vscode.NotebookCellData[] = [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + `### πŸ” Schema Migration: \`${sourceSchema}\` β†’ \`${targetSchema}\`\n\n` + + `
` + + `⚠️ Warning: Review all statements carefully. DROP operations are commented out for safety.
\n\n` + + `Generated **${stmts.length}** migration statement(s).`, + 'markdown' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + `-- Schema Migration Script\n-- Source: ${sourceSchema} Target: ${targetSchema}\n-- Generated by PgStudio Schema Diff\n\nBEGIN;\n\n${stmts.join('\n\n')}\n\n-- COMMIT; -- Uncomment to apply\n-- ROLLBACK; -- Uncomment to cancel`, + 'sql' + ) + ]; + + await createAndShowNotebook(cells, metadata); + } + + private static _getHtml( + sourceSchema: string, + targetSchema: string, + diffs: TableDiff[] + ): string { + const totalTables = diffs.length; + const added = diffs.filter(d => d.status === 'added').length; + const removed = diffs.filter(d => d.status === 'removed').length; + const changed = diffs.filter(d => d.status === 'changed').length; + const unchanged = diffs.filter(d => d.status === 'unchanged').length; + + const statusIcon: Record = { + added: '🟒', + removed: 'πŸ”΄', + changed: '🟑', + unchanged: 'βšͺ' + }; + const statusLabel: Record = { + added: 'Added', + removed: 'Removed', + changed: 'Changed', + unchanged: 'Unchanged' + }; + const statusClass: Record = { + added: 'diff-added', + removed: 'diff-removed', + changed: 'diff-changed', + unchanged: 'diff-unchanged' + }; + + const renderColumnDiff = (col: ColumnDiff): string => { + if (col.status === 'unchanged') return ''; + const icon = statusIcon[col.status]; + let detail = ''; + if (col.status === 'changed' && col.before && col.after) { + const changes: string[] = []; + if (col.before.data_type !== col.after.data_type) { + changes.push(`type: ${col.before.data_type} β†’ ${col.after.data_type}`); + } + if (col.before.not_null !== col.after.not_null) { + changes.push(`not_null: ${col.before.not_null} β†’ ${col.after.not_null}`); + } + if ((col.before.default_value || '') !== (col.after.default_value || '')) { + changes.push(`default: ${col.before.default_value || 'NULL'} β†’ ${col.after.default_value || 'NULL'}`); + } + detail = changes.join(', '); + } else if (col.status === 'added' && col.after) { + detail = `${col.after.data_type}${col.after.not_null ? ' NOT NULL' : ''}`; + } else if (col.status === 'removed' && col.before) { + detail = `${col.before.data_type}`; + } + return `
${icon} ${col.name} ${detail}
`; + }; + + const renderTableDiff = (table: TableDiff): string => { + const changedCols = table.columnDiffs.filter(c => c.status !== 'unchanged'); + const changedCons = table.constraintDiffs.filter(c => c.status !== 'unchanged'); + const changedIdxs = table.indexDiffs.filter(c => c.status !== 'unchanged'); + + const colsHtml = changedCols.map(renderColumnDiff).join(''); + const consHtml = changedCons.map(c => { + const icon = statusIcon[c.status]; + const def = c.after?.definition || c.before?.definition || ''; + return `
${icon} ${c.name} ${def}
`; + }).join(''); + const idxsHtml = changedIdxs.map(i => { + const icon = statusIcon[i.status]; + return `
${icon} ${i.name}
`; + }).join(''); + + const hasDetails = colsHtml || consHtml || idxsHtml; + + return ` +
+
+ ${statusIcon[table.status]} + ${table.name} + ${statusLabel[table.status]} + ${hasDetails ? 'β–Ά' : ''} +
+ ${hasDetails ? ` + + ` : ''} +
+ `; + }; + + const tablesHtml = diffs.map(renderTableDiff).join(''); + + return ` + + + + Schema Diff + + + +
+

πŸ” Schema Diff

+

${sourceSchema} (source) vs ${targetSchema} (target) β€” ${totalTables} tables compared

+
+ +
+
🟑${changed}Changed
+
🟒${added}Added
+
πŸ”΄${removed}Removed
+
βšͺ${unchanged}Unchanged
+
+ +
+
+ +
+ Show: + + + + +
+ +
+ ${diffs.length === 0 + ? '
βœ…

Schemas are identical β€” no differences found.

' + : tablesHtml + } +
+ + + +`; + } + + public dispose(): void { + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) d.dispose(); + } + } +} diff --git a/src/schemaDesigner/TableDesignerPanel.ts b/src/schemaDesigner/TableDesignerPanel.ts new file mode 100644 index 0000000..df3b8d2 --- /dev/null +++ b/src/schemaDesigner/TableDesignerPanel.ts @@ -0,0 +1,995 @@ +import * as vscode from 'vscode'; +import { ErrorHandlers } from '../commands/helper'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { resolveTreeItemConnection } from './connectionHelper'; + +/** + * Visual Table Designer Panel + * + * Opens an interactive webview for designing/editing a PostgreSQL table. + * Supports both "Edit" mode (existing table) and "Create" mode (new table). + * Generates ALTER TABLE / CREATE TABLE DDL and opens it in a notebook for review. + */ +export class TableDesignerPanel { + public static readonly viewType = 'pgStudio.tableDesigner'; + + private static _panels = new Map(); + + private readonly _panel: vscode.WebviewPanel; + private _disposables: vscode.Disposable[] = []; + + private constructor( + panel: vscode.WebviewPanel, + private readonly _extensionUri: vscode.Uri, + private readonly _context: vscode.ExtensionContext + ) { + this._panel = panel; + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + /** + * Open Table Designer for an existing table (Edit mode) + */ + public static async openForTable( + item: DatabaseTreeItem, + context: vscode.ExtensionContext + ): Promise { + let dbConn; + try { + dbConn = await resolveTreeItemConnection(item); + if (!dbConn) return; // user cancelled + const { client, metadata, connection } = dbConn; + const schema = item.schema!; + const tableName = item.label; + + // Fetch columns + const colResult = await client.query(` + SELECT + a.attnum as ordinal, + a.attname as column_name, + pg_catalog.format_type(a.atttypid, a.atttypmod) as data_type, + a.attnotnull as not_null, + pg_catalog.pg_get_expr(d.adbin, d.adrelid) as default_value, + CASE WHEN pk.contype = 'p' THEN true ELSE false END as is_primary_key, + CASE WHEN uq.contype = 'u' THEN true ELSE false END as is_unique, + col_description(a.attrelid, a.attnum) as comment + FROM pg_catalog.pg_attribute a + LEFT JOIN pg_catalog.pg_attrdef d + ON d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef + LEFT JOIN pg_catalog.pg_constraint pk + ON pk.conrelid = a.attrelid AND a.attnum = ANY(pk.conkey) AND pk.contype = 'p' + LEFT JOIN pg_catalog.pg_constraint uq + ON uq.conrelid = a.attrelid AND a.attnum = ANY(uq.conkey) AND uq.contype = 'u' + WHERE a.attrelid = $1::regclass AND a.attnum > 0 AND NOT a.attisdropped + ORDER BY a.attnum + `, [`"${schema}"."${tableName}"`]); + + const columns = colResult.rows; + + // Fetch table comment + const commentResult = await client.query(` + SELECT obj_description(c.oid, 'pg_class') as comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = $1 AND n.nspname = $2 + `, [tableName, schema]); + const tableComment = commentResult.rows[0]?.comment || ''; + + const panelKey = `${item.connectionId}:${item.databaseName}:${schema}.${tableName}`; + + if (TableDesignerPanel._panels.has(panelKey)) { + TableDesignerPanel._panels.get(panelKey)!._panel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + TableDesignerPanel.viewType, + `🎨 ${schema}.${tableName}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'resources')] + } + ); + + const designer = new TableDesignerPanel(panel, context.extensionUri, context); + TableDesignerPanel._panels.set(panelKey, designer); + + panel.onDidDispose(() => { + TableDesignerPanel._panels.delete(panelKey); + }); + + panel.webview.html = TableDesignerPanel._getHtml( + panel.webview, + schema, + tableName, + columns, + tableComment, + false + ); + + // Handle messages from the webview + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case 'applyChanges': { + await TableDesignerPanel._applyChanges( + message.original, + message.modified, + schema, + tableName, + metadata + ); + break; + } + case 'copySQL': { + await vscode.env.clipboard.writeText(message.sql); + vscode.window.showInformationMessage('SQL copied to clipboard'); + break; + } + } + }, null, designer._disposables); + + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'open table designer'); + } finally { + if (dbConn && dbConn.release) dbConn.release(); + } + } + + /** + * Open Table Designer in Create mode (new table) + */ + public static async openForCreate( + item: DatabaseTreeItem, + context: vscode.ExtensionContext + ): Promise { + let dbConn; + try { + dbConn = await resolveTreeItemConnection(item); + if (!dbConn) return; // user cancelled + const { metadata } = dbConn; + const labelStr = typeof item.label === 'string' ? item.label : (item.label as any)?.label ?? ''; + const schema = item.schema || labelStr || 'public'; + + const panelKey = `create:${item.connectionId}:${item.databaseName}:${schema}`; + + if (TableDesignerPanel._panels.has(panelKey)) { + TableDesignerPanel._panels.get(panelKey)!._panel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + TableDesignerPanel.viewType, + `🎨 New Table in ${schema}`, + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + } + ); + + const designer = new TableDesignerPanel(panel, context.extensionUri, context); + TableDesignerPanel._panels.set(panelKey, designer); + + panel.onDidDispose(() => { + TableDesignerPanel._panels.delete(panelKey); + }); + + // Start with a default id column + const defaultColumns = [ + { + ordinal: 1, + column_name: 'id', + data_type: 'bigserial', + not_null: true, + default_value: null, + is_primary_key: true, + is_unique: false, + comment: '' + } + ]; + + panel.webview.html = TableDesignerPanel._getHtml( + panel.webview, + schema, + '', + defaultColumns, + '', + true + ); + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case 'applyChanges': { + await TableDesignerPanel._createTable( + message.tableName, + schema, + message.modified, + message.tableComment, + metadata + ); + break; + } + case 'copySQL': { + await vscode.env.clipboard.writeText(message.sql); + vscode.window.showInformationMessage('SQL copied to clipboard'); + break; + } + } + }, null, designer._disposables); + + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'open table designer (create)'); + } finally { + if (dbConn && dbConn.release) dbConn.release(); + } + } + + /** + * Generate ALTER TABLE SQL from diff and open in notebook + */ + private static async _applyChanges( + original: any[], + modified: any[], + schema: string, + tableName: string, + metadata: any + ): Promise { + const statements: string[] = []; + const originalMap = new Map(original.map((c: any) => [c.column_name, c])); + const modifiedMap = new Map(modified.map((c: any) => [c.column_name, c])); + + // Detect dropped columns + for (const [name, col] of originalMap) { + if (!modifiedMap.has(name) && !col._deleted) { + // column was removed from the list + } + if (col._deleted) { + statements.push(`-- Drop column\nALTER TABLE "${schema}"."${tableName}"\n DROP COLUMN "${name}";`); + } + } + + // Detect added columns + for (const col of modified) { + if (col._new) { + const notNull = col.not_null ? ' NOT NULL' : ''; + const defaultVal = col.default_value ? ` DEFAULT ${col.default_value}` : ''; + statements.push( + `-- Add column\nALTER TABLE "${schema}"."${tableName}"\n ADD COLUMN "${col.column_name}" ${col.data_type}${notNull}${defaultVal};` + ); + if (col.is_primary_key) { + statements.push( + `-- Add primary key\nALTER TABLE "${schema}"."${tableName}"\n ADD PRIMARY KEY ("${col.column_name}");` + ); + } + if (col.comment) { + statements.push( + `-- Add column comment\nCOMMENT ON COLUMN "${schema}"."${tableName}"."${col.column_name}" IS '${col.comment.replace(/'/g, "''")}';` + ); + } + } else { + // Detect modified columns + const orig = originalMap.get(col.column_name); + if (!orig) continue; + + if (orig.data_type !== col.data_type) { + statements.push( + `-- Change column type\nALTER TABLE "${schema}"."${tableName}"\n ALTER COLUMN "${col.column_name}" TYPE ${col.data_type};` + ); + } + if (orig.not_null !== col.not_null) { + if (col.not_null) { + statements.push( + `-- Set NOT NULL\nALTER TABLE "${schema}"."${tableName}"\n ALTER COLUMN "${col.column_name}" SET NOT NULL;` + ); + } else { + statements.push( + `-- Drop NOT NULL\nALTER TABLE "${schema}"."${tableName}"\n ALTER COLUMN "${col.column_name}" DROP NOT NULL;` + ); + } + } + if ((orig.default_value || '') !== (col.default_value || '')) { + if (col.default_value) { + statements.push( + `-- Set default\nALTER TABLE "${schema}"."${tableName}"\n ALTER COLUMN "${col.column_name}" SET DEFAULT ${col.default_value};` + ); + } else { + statements.push( + `-- Drop default\nALTER TABLE "${schema}"."${tableName}"\n ALTER COLUMN "${col.column_name}" DROP DEFAULT;` + ); + } + } + if ((orig.comment || '') !== (col.comment || '')) { + statements.push( + `-- Update column comment\nCOMMENT ON COLUMN "${schema}"."${tableName}"."${col.column_name}" IS '${(col.comment || '').replace(/'/g, "''")}';` + ); + } + } + } + + if (statements.length === 0) { + vscode.window.showInformationMessage('No changes detected.'); + return; + } + + const { createAndShowNotebook } = await import('../commands/connection'); + const cells: vscode.NotebookCellData[] = [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + `### 🎨 Table Designer: \`${schema}.${tableName}\`\n\n` + + `
` + + `ℹ️ Review: Review each statement carefully before executing. Run them in a transaction for safety.
\n\n` + + `Generated **${statements.length}** change(s).`, + 'markdown' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + `-- Generated by PgStudio Table Designer\n-- Review carefully before executing!\n\nBEGIN;\n\n${statements.join('\n\n')}\n\n-- COMMIT; -- Uncomment to apply changes\n-- ROLLBACK; -- Uncomment to cancel`, + 'sql' + ) + ]; + + await createAndShowNotebook(cells, metadata); + } + + /** + * Generate CREATE TABLE SQL and open in notebook + */ + private static async _createTable( + tableName: string, + schema: string, + columns: any[], + tableComment: string, + metadata: any + ): Promise { + if (!tableName || !tableName.trim()) { + vscode.window.showWarningMessage('Please enter a table name.'); + return; + } + + const pkCols = columns.filter(c => c.is_primary_key).map(c => `"${c.column_name}"`); + const colDefs = columns.map(c => { + const notNull = c.not_null ? ' NOT NULL' : ''; + const defaultVal = c.default_value ? ` DEFAULT ${c.default_value}` : ''; + return ` "${c.column_name}" ${c.data_type}${notNull}${defaultVal}`; + }); + + if (pkCols.length > 0) { + colDefs.push(` PRIMARY KEY (${pkCols.join(', ')})`); + } + + const createSQL = `CREATE TABLE "${schema}"."${tableName}" (\n${colDefs.join(',\n')}\n);`; + + const commentStatements: string[] = []; + if (tableComment) { + commentStatements.push(`COMMENT ON TABLE "${schema}"."${tableName}" IS '${tableComment.replace(/'/g, "''")}';`); + } + for (const col of columns) { + if (col.comment) { + commentStatements.push(`COMMENT ON COLUMN "${schema}"."${tableName}"."${col.column_name}" IS '${col.comment.replace(/'/g, "''")}';`); + } + } + + const { createAndShowNotebook } = await import('../commands/connection'); + const cells: vscode.NotebookCellData[] = [ + new vscode.NotebookCellData( + vscode.NotebookCellKind.Markup, + `### 🎨 Create Table: \`${schema}.${tableName}\`\n\n` + + `
` + + `πŸ’‘ Tip: Review the generated SQL, then execute to create the table.
`, + 'markdown' + ), + new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + `-- Generated by PgStudio Table Designer\n${createSQL}${commentStatements.length > 0 ? '\n\n' + commentStatements.join('\n') : ''}`, + 'sql' + ) + ]; + + await createAndShowNotebook(cells, metadata); + } + + private static _getHtml( + webview: vscode.Webview, + schema: string, + tableName: string, + columns: any[], + tableComment: string, + isCreate: boolean + ): string { + const columnsJson = JSON.stringify(columns); + const mode = isCreate ? 'create' : 'edit'; + + const pgTypes = [ + 'bigint', 'bigserial', 'boolean', 'bytea', 'char', 'character varying', + 'date', 'double precision', 'integer', 'interval', 'json', 'jsonb', + 'numeric', 'real', 'serial', 'smallint', 'smallserial', 'text', + 'time', 'timestamp', 'timestamptz', 'uuid', 'varchar' + ]; + const typeOptions = pgTypes.map(t => ``).join('\n'); + + return ` + + + + + Table Designer + + + +
+

🎨 Table Designer

+ ${isCreate ? 'CREATE MODE' : 'EDIT MODE'} + ${isCreate ? schema : `${schema}.${tableName}`} +
+ +
+
+ ${isCreate ? ` +

Table Properties

+
+
+ + +
+
+ + +
+
+ + +
+
+ ` : ` +
+ ℹ️ Edit mode: Modify columns below. Changes generate safe ALTER TABLE statements for review before execution. +
+ `} + +

Columns

+ + + + + + + + + + + + + + + + +
Column NameData TypeNot NullDefaultPKUQComment
+ + +
+ +
+
πŸ“‹ SQL Preview
+
+
Make changes to see SQL preview...
+
+
+ + +
+
+
+ + + +`; + } + + public dispose(): void { + this._panel.dispose(); + while (this._disposables.length) { + const d = this._disposables.pop(); + if (d) d.dispose(); + } + } +} diff --git a/src/schemaDesigner/connectionHelper.ts b/src/schemaDesigner/connectionHelper.ts new file mode 100644 index 0000000..25ba700 --- /dev/null +++ b/src/schemaDesigner/connectionHelper.ts @@ -0,0 +1,81 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { SecretStorageService } from '../services/SecretStorageService'; +import { ConnectionManager } from '../services/ConnectionManager'; +import { createMetadata } from '../commands/connection'; + +/** + * Resolves a database connection from a tree item. + * + * When commands are triggered from the tree's context menu, VS Code + * passes the original DatabaseTreeItem instance. However, `connectionId` + * can sometimes be `undefined` on items deep in the tree hierarchy. + * + * This helper uses multiple fallback strategies to find the right + * connection, making the schema-designer commands resilient. + */ +export async function resolveTreeItemConnection(item: DatabaseTreeItem) { + const connections = + vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + + if (connections.length === 0) { + throw new Error('No saved connections found. Please add a connection first.'); + } + + let connection: any; + + // Strategy 1: Use connectionId directly (ideal path) + if (item.connectionId) { + connection = connections.find(c => c.id === item.connectionId); + } + + // Strategy 2: If only one connection exists, use it + if (!connection && connections.length === 1) { + connection = connections[0]; + } + + // Strategy 3: Ask user to pick from all connections + if (!connection) { + const pick = await vscode.window.showQuickPick( + connections.map(c => ({ + label: c.name || `${c.host}:${c.port}`, + description: c.database || '', + id: c.id + })), + { placeHolder: 'Select the connection for this operation' } + ); + if (!pick) return undefined; // user cancelled + connection = connections.find(c => c.id === pick.id); + } + + if (!connection) { + throw new Error('Could not determine database connection.'); + } + + // Retrieve the password from secret storage + const password = await SecretStorageService.getInstance().getPassword(connection.id); + if (!password) { + throw new Error('Password not found in secure storage. Re-enter the connection password.'); + } + const fullConnection = { ...connection, password }; + + // Use the database from the tree item if available, else fall back to the connection default + const databaseName = item.databaseName || connection.database; + const client = await ConnectionManager.getInstance().getPooledClient({ + id: connection.id, + host: connection.host, + port: connection.port, + username: connection.username, + database: databaseName, + name: connection.name, + }); + + const metadata = createMetadata(fullConnection, databaseName); + + return { + connection: fullConnection, + client, + metadata, + release: () => client.release(), + }; +} From 1994ac290cb3cf3079800f2a49152f578070ae8c Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Wed, 18 Feb 2026 23:26:04 +0530 Subject: [PATCH 3/6] feat: Implement visual management for table constraints and indexes in the designer. --- package.json | 2 +- src/schemaDesigner/TableDesignerPanel.ts | 590 ++++++++++++++++++++++- 2 files changed, 588 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index cb612be..75076c6 100644 --- a/package.json +++ b/package.json @@ -936,7 +936,7 @@ }, { "command": "postgres-explorer.openTableDesigner", - "title": "Open Table Designer", + "title": "Open Table Designer (Visual)", "icon": "$(layout)", "category": "PgStudio: Schema" }, diff --git a/src/schemaDesigner/TableDesignerPanel.ts b/src/schemaDesigner/TableDesignerPanel.ts index df3b8d2..989ca0b 100644 --- a/src/schemaDesigner/TableDesignerPanel.ts +++ b/src/schemaDesigner/TableDesignerPanel.ts @@ -75,6 +75,40 @@ export class TableDesignerPanel { `, [tableName, schema]); const tableComment = commentResult.rows[0]?.comment || ''; + // Fetch constraints + const conResult = await client.query(` + SELECT + conname as name, + CASE contype + WHEN 'p' THEN 'PRIMARY KEY' + WHEN 'f' THEN 'FOREIGN KEY' + WHEN 'u' THEN 'UNIQUE' + WHEN 'c' THEN 'CHECK' + ELSE contype + END as type, + pg_get_constraintdef(oid) as definition + FROM pg_constraint + WHERE conrelid = $1::regclass + ORDER BY conname + `, [`"${schema}"."${tableName}"`]); + const constraints = conResult.rows; + + // Fetch indexes + const idxResult = await client.query(` + SELECT + i.relname as name, + pg_get_indexdef(ix.indexrelid) as definition, + ix.indisunique as is_unique, + ix.indisprimary as is_primary + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + WHERE t.relname = $1 AND n.nspname = $2 + ORDER BY i.relname + `, [tableName, schema]); + const indexes = idxResult.rows; + const panelKey = `${item.connectionId}:${item.databaseName}:${schema}.${tableName}`; if (TableDesignerPanel._panels.has(panelKey)) { @@ -105,6 +139,8 @@ export class TableDesignerPanel { schema, tableName, columns, + constraints, + indexes, tableComment, false ); @@ -116,6 +152,8 @@ export class TableDesignerPanel { await TableDesignerPanel._applyChanges( message.original, message.modified, + message.constraints || [], + message.indexes || [], schema, tableName, metadata @@ -195,6 +233,8 @@ export class TableDesignerPanel { schema, '', defaultColumns, + [], // constraints + [], // indexes '', true ); @@ -232,11 +272,34 @@ export class TableDesignerPanel { private static async _applyChanges( original: any[], modified: any[], + constraints: any[], + indexes: any[], schema: string, tableName: string, metadata: any ): Promise { const statements: string[] = []; + + // 1. Drop/Add Constraints + for (const con of constraints) { + if (con._deleted) { + statements.push(`-- Drop Constraint\nALTER TABLE "${schema}"."${tableName}"\n DROP CONSTRAINT IF EXISTS "${con.name}";`); + } else if (con._new) { + statements.push(`-- Add Constraint\nALTER TABLE "${schema}"."${tableName}"\n ADD CONSTRAINT "${con.name}" ${con.rawDef};`); + } + } + + // 2. Drop/Add Indexes + for (const idx of indexes) { + if (idx._deleted) { + statements.push(`-- Drop Index\nDROP INDEX IF EXISTS "${schema}"."${idx.name}";`); + } else if (idx._new) { + const unique = idx.is_unique ? 'UNIQUE ' : ''; + const cols = idx.columns.map((c: string) => '"' + c + '"').join(', '); + statements.push(`-- Create Index\nCREATE ${unique}INDEX "${idx.name}" ON "${schema}"."${tableName}" USING ${idx.method} (${cols});`); + } + } + const originalMap = new Map(original.map((c: any) => [c.column_name, c])); const modifiedMap = new Map(modified.map((c: any) => [c.column_name, c])); @@ -395,10 +458,14 @@ export class TableDesignerPanel { schema: string, tableName: string, columns: any[], + constraints: any[], + indexes: any[], tableComment: string, isCreate: boolean ): string { const columnsJson = JSON.stringify(columns); + const constraintsJson = JSON.stringify(constraints); + const indexesJson = JSON.stringify(indexes); const mode = isCreate ? 'create' : 'edit'; const pgTypes = [ @@ -433,6 +500,34 @@ export class TableDesignerPanel { align-items: center; gap: 12px; } + .header-tabs { + display: flex; + gap: 2px; + margin-left: 20px; + } + .tab-btn { + padding: 6px 16px; + background: transparent; + color: var(--vscode-foreground); + border: 1px solid transparent; + border-bottom: 2px solid transparent; + cursor: pointer; + font-size: 12px; + font-weight: 500; + opacity: 0.7; + } + .tab-btn:hover { + opacity: 1; + background: var(--vscode-toolbar-hoverBackground); + } + .tab-btn.active { + opacity: 1; + border-bottom-color: var(--vscode-panelTitle-activeBorder); + color: var(--vscode-panelTitle-activeForeground); + background: var(--vscode-editor-background); + } + .tab-content { display: none; padding: 0; height: 100%; overflow: hidden; } + .tab-content.active { display: block; } .header h1 { font-size: 15px; font-weight: 600; @@ -660,6 +755,21 @@ export class TableDesignerPanel { font-style: italic; padding: 8px; } + .modal-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); + z-index: 100; + display: flex; align-items: center; justify-content: center; + } + .modal { + background: var(--vscode-sideBar-background); + border: 1px solid var(--vscode-panel-border); + padding: 20px; + width: 400px; + border-radius: 5px; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); + } + .checkbox-list label { display: block; margin-bottom: 4px; font-size: 12px; } @@ -667,10 +777,16 @@ export class TableDesignerPanel {

🎨 Table Designer

${isCreate ? 'CREATE MODE' : 'EDIT MODE'} ${isCreate ? schema : `${schema}.${tableName}`} +
+ + + +
+
${isCreate ? `

Table Properties

@@ -713,6 +829,129 @@ export class TableDesignerPanel { +
+ +
+

Constraints

+
Manage Foreign Keys, Check constraints, etc. PK/Unique on single columns are managed in the Columns tab.
+ + + + + + + + + + +
NameTypeDefinition
+
+ + + +
+
+ +
+

Indexes

+ + + + + + + + + + + +
NameDefinitionUniquePrimary
+ +
+
+ + + + +
@@ -733,17 +972,54 @@ export class TableDesignerPanel { @@ -25,10 +25,10 @@

...

style="color: inherit; text-decoration: none; margin-right: 16px;" id="count-tables">... Tables ... Views - ... Funcs - Top SQL + ... Funcs + Top SQL
@@ -37,130 +37,149 @@

...

- -
- -
- DB Health -
- - Healthy + +
+
Overview
+
Locks & Blocking
+ +
+ + +
+ +
+ +
+ DB Health +
+ + Healthy +
+
- - -
- Active Load -
- ... + +
+ Blocking Locks +
+ 0 +
-
- No waits + + +
+ Throughput (TPS) +
+ 0 + +
+
+ +
-
- -
- Blocking Locks -
- ... + +
+ Issues +
+ +
- -
- Throughput (TPS) -
- 0 - + +
+
+
+ Connections + History +
+
-
- +
+
Rollback Spikes
+
-
- - -
- Issues -
- +
+
Cache Hit Ratio
+ +
+
+
Long Running (>5s)
+
-
- -
-
-
- Connections - - History + +
+
+
Checkpoints (Req/Timed)
+ +
+
+
Temp Files (Bytes)
+ +
+
+
Tuple Activity (Fetch/Ret)
+
- -
-
-
Rollback Spikes
- -
-
-
Cache Hit Ratio
- -
-
-
Long Running (>5s)
-
-
- -
-
-

Active Queries

- View All → -
+ +
+
+

Active Queries

+
+ +
+
-
- - - - - - - - - - - - - - -
PIDUserDurationStart TimeQueryActions
+
+ + + + + + + + + + + + + + +
PIDUserDurationStart TimeQueryActions
+
- -
-
-

Locks & Blocking

-
-
- - - - - - - - - - - - -
Blocker (PID)Waited By (PID)ObjectMode
+ +
+
+ +
+ +
@@ -173,7 +192,7 @@

- {{INLINE_SCRIPTS}} + /* INLINE_SCRIPTS */ diff --git a/templates/dashboard/scripts.js b/templates/dashboard/scripts.js index 756459a..5dda1cf 100644 --- a/templates/dashboard/scripts.js +++ b/templates/dashboard/scripts.js @@ -165,6 +165,69 @@ const longRunningChart = new Chart(document.getElementById('longRunningChart'), options: commonOptions }); +// 6. Checkpoints +let checkpointHistory = { req: new Array(maxHistory).fill(0), timed: new Array(maxHistory).fill(0) }; +const checkpointsChart = new Chart(document.getElementById('checkpointsChart'), { + type: 'line', + data: { + labels: new Array(maxHistory).fill(''), + datasets: [ + { label: 'Timed', data: checkpointHistory.timed, borderColor: colors.success, fill: false }, + { label: 'Requested', data: checkpointHistory.req, borderColor: colors.danger, fill: false } + ] + }, + options: commonOptions +}); + +// 7. Temp Files +let tempFilesHistory = new Array(maxHistory).fill(0); +const tempFilesChart = new Chart(document.getElementById('tempFilesChart'), { + type: 'line', + data: { + labels: new Array(maxHistory).fill(''), + datasets: [{ + label: 'Temp Bytes', + data: tempFilesHistory, + borderColor: colors.warning, + fill: true, + backgroundColor: 'rgba(250, 204, 21, 0.1)' + }] + }, + options: { + ...commonOptions, + scales: { + ...commonOptions.scales, + y: { + display: true, + grid: { color: colors.grid }, + ticks: { + callback: function (value) { + if (value === 0) return '0'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(Math.abs(value)) / Math.log(k)); + return parseFloat((value / Math.pow(k, i)).toFixed(0)) + ' ' + sizes[i]; + } + } + } + } + } +}); + +// 8. Tuples Activity +let tuplesHistory = { fetched: new Array(maxHistory).fill(0), returned: new Array(maxHistory).fill(0) }; +const tuplesChart = new Chart(document.getElementById('tuplesChart'), { + type: 'line', + data: { + labels: new Array(maxHistory).fill(''), + datasets: [ + { label: 'Fetched', data: tuplesHistory.fetched, borderColor: colors.text, borderDash: [2, 2], fill: false }, + { label: 'Returned', data: tuplesHistory.returned, borderColor: colors.accent, fill: false } + ] + }, + options: commonOptions +}); + let refreshIntervalId; function startAutoRefresh(interval) { @@ -283,11 +346,41 @@ function updateDashboard(stats) { if (longRunningHistory.length > maxHistory) longRunningHistory.shift(); longRunningChart.update('none'); + // 6. Update Checkpoints + const cpTimed = stats.metrics.checkpoints_timed - (lastMetrics.checkpoints_timed || 0); + const cpReq = stats.metrics.checkpoints_req - (lastMetrics.checkpoints_req || 0); + checkpointHistory.timed.push(cpTimed >= 0 ? cpTimed : 0); + checkpointHistory.req.push(cpReq >= 0 ? cpReq : 0); + if (checkpointHistory.timed.length > maxHistory) { + checkpointHistory.timed.shift(); + checkpointHistory.req.shift(); + } + checkpointsChart.update('none'); + + // 7. Update Temp Files + // Temp bytes is a cumulative counter in pg_stat_database? No, it's cumulative. + // So we want the delta (bytes used in this interval) + const tempBytes = stats.metrics.temp_bytes - (lastMetrics.temp_bytes || 0); + tempFilesHistory.push(tempBytes >= 0 ? tempBytes : 0); + if (tempFilesHistory.length > maxHistory) tempFilesHistory.shift(); + tempFilesChart.update('none'); + + // 8. Update Tuples + const tupFetched = stats.metrics.tuples_fetched - (lastMetrics.tuples_fetched || 0); + const tupReturned = stats.metrics.tuples_returned - (lastMetrics.tuples_returned || 0); + tuplesHistory.fetched.push(tupFetched >= 0 ? tupFetched : 0); + tuplesHistory.returned.push(tupReturned >= 0 ? tupReturned : 0); + if (tuplesHistory.fetched.length > maxHistory) { + tuplesHistory.fetched.shift(); + tuplesHistory.returned.shift(); + } + tuplesChart.update('none'); + // Update Health Indicator updateHealth(stats); // Update Locks FIRST (populates blockingPids for Active Queries) - updateLocks(stats.blockingLocks); + updateLocks(stats.blockingLocks); // Will also update Tree View if tab active // Update Active Queries Table (uses blockingPids for lock icons) updateActiveQueries(stats.activeQueries); @@ -304,7 +397,12 @@ function updateDashboard(stats) { xact_rollback: stats.metrics.xact_rollback, blks_read: stats.metrics.blks_read, blks_hit: stats.metrics.blks_hit, - tps: tps + tps: tps, + checkpoints_timed: stats.metrics.checkpoints_timed, + checkpoints_req: stats.metrics.checkpoints_req, + temp_bytes: stats.metrics.temp_bytes, + tuples_fetched: stats.metrics.tuples_fetched, + tuples_returned: stats.metrics.tuples_returned }; } } @@ -468,20 +566,32 @@ function updateLocks(locks) { }); } + // Render Tree View (regardless of tab, but could optimize) + renderLockTree(locks); + // If we have no locks, show empty state if (!locks || locks.length === 0) { if (headerTitle) { headerTitle.innerText = 'Locks & Blocking'; headerTitle.style.color = 'var(--fg-color)'; } - if (tableContainer) tableContainer.style.borderColor = 'var(--border-color)'; + // if (tableContainer) tableContainer.style.borderColor = 'var(--border-color)'; // Removed in new HTML if (container) { - container.style.display = 'none'; + // container.style.display = 'none'; // Removed in new HTML } + // Update Tile + const tileVal = document.getElementById('locks-tile-value'); + if (tileVal) tileVal.innerText = '0'; return; } + // Update Tile + const tileVal = document.getElementById('locks-tile-value'); + if (tileVal) { + tileVal.innerHTML = `${locks.length}`; + } + // Restore visibility if we have locks if (container) container.style.display = 'block'; @@ -489,28 +599,125 @@ function updateLocks(locks) { headerTitle.innerText = 'Blocking Locks Detected'; headerTitle.style.color = 'var(--danger-color)'; } - if (tableContainer) tableContainer.style.borderColor = 'var(--danger-color)'; - - if (container) { - const tbody = container.querySelector('tbody'); - if (tbody) { - tbody.innerHTML = locks.map(l => { - // Fix null object display - let objectDisplay = l.locked_object; - if (!objectDisplay || objectDisplay === 'null' || objectDisplay === null) { - objectDisplay = 'Session-level lock'; - } - return ` - - ${l.blocking_pid} - ${l.blocked_pid} - ${objectDisplay} - ${l.lock_mode} - - `; - }).join(''); - } +} + +function renderLockTree(locks) { + const container = document.getElementById('locks-tree-container'); + const emptyState = document.getElementById('locks-empty-state'); + + if (!locks || locks.length === 0) { + if (container) container.innerHTML = ''; + if (emptyState) emptyState.style.display = 'block'; + return; + } + + if (emptyState) emptyState.style.display = 'none'; + if (!container) return; + + container.innerHTML = ''; + + // Build Graph + const nodes = new Set(); + const relations = []; // { blocker, blocked, info } + + locks.forEach(l => { + nodes.add(l.blocking_pid); + nodes.add(l.blocked_pid); + relations.push({ + parent: l.blocking_pid, + child: l.blocked_pid, + info: l + }); + }); + + // Find Roots (Nodes that are parents but never children in this set) + // Note: In circular deadlock, there are no roots. We pick one arbitrarily or handle it. + // Actually, "blocking_pid" might itself be blocked by someone else outside this set? + // No, pg_locks join should cover the chain if we fetched enough. + // DashboardData fetches blocking locks. + + const children = new Set(relations.map(r => r.child)); + const roots = Array.from(nodes).filter(n => !children.has(n)); + + // If no roots and we have nodes -> Cycle. Pick one. + if (roots.length === 0 && nodes.size > 0) { + roots.push(Array.from(nodes)[0]); + // Visual indicator of cycle? } + + const createNode = (pid, visited) => { + const div = document.createElement('div'); + div.className = 'lock-node'; + + if (visited.has(pid)) { + div.innerHTML = `
πŸ”„ Cycle detected: PID ${pid}
`; + return div; + } + visited.add(pid); + + // Find relations where this pid is the blocker + const myRelations = relations.filter(r => r.parent === pid); + + // Node Content + // Try to find user/query info from the relations (either as blocker or blocked) + // We know 'l' has blocker info and blocked info. + let user = 'Unknown'; + let query = 'Unknown'; + let mode = ''; + let obj = ''; + + // If I am a child of someone, they have my info in 'blocked_*' + // If I am a parent, I have my info in 'blocking_*' + // We can just find *any* relation involving this PID to get some info + const asBlocker = relations.find(r => r.parent === pid); + const asBlocked = relations.find(r => r.child === pid); + + if (asBlocker) { + user = asBlocker.info.blocking_user; + query = asBlocker.info.blocking_query; + } else if (asBlocked) { + user = asBlocked.info.blocked_user; + query = asBlocked.info.blocked_query; + mode = asBlocked.info.lock_mode; + obj = asBlocked.info.locked_object; + } + + div.innerHTML = ` +
+
+ PID ${pid} + ${user} +
+
+ ${asBlocked ? `${asBlocked.info.lock_mode}` : 'Root'} +
+
+
+ ${query || '(No query info)'} +
+ ${asBlocked ? `
Waiting for: ${asBlocked.info.locked_object}
` : ''} +
+ + +
+ `; + + // Children + if (myRelations.length > 0) { + const childContainer = document.createElement('div'); + childContainer.className = 'lock-children'; + myRelations.forEach(r => { + childContainer.appendChild(createNode(r.child, new Set(visited))); + }); + div.appendChild(childContainer); + } + + return div; + }; + + roots.forEach(rootPid => { + container.appendChild(createNode(rootPid, new Set())); + }); } // Recommended Action helper @@ -643,6 +850,30 @@ window.addEventListener('message', event => { // Auto Refresh setInterval(manualRefresh, 5000); +// --- Tab Logic --- +document.querySelectorAll('.tab').forEach(t => { + t.onclick = () => { + document.querySelectorAll('.tab').forEach(x => x.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(x => x.classList.remove('active')); + + t.classList.add('active'); + const tabId = t.getAttribute('data-tab'); + document.getElementById('tab-' + tabId).classList.add('active'); + + // Trigger Resize for charts if they become visible + if (tabId === 'overview') { + tpsChart.resize(); + connChart.resize(); + rollbackChart.resize(); + cacheHitChart.resize(); + longRunningChart.resize(); + checkpointsChart.resize(); + tempFilesChart.resize(); + tuplesChart.resize(); + } + }; +}); + // Init initializeDashboard(initialStats); updateDashboard(initialStats); // Populate initial charts diff --git a/templates/dashboard/styles.css b/templates/dashboard/styles.css index 3a094e7..80854d9 100644 --- a/templates/dashboard/styles.css +++ b/templates/dashboard/styles.css @@ -108,10 +108,46 @@ h3 { font-size: 0.9rem; } +/* --- Tabs --- */ +.tabs-nav { + display: flex; + border-bottom: 2px solid var(--border-color); + margin-bottom: 20px; +} + +.tab { + padding: 8px 16px; + cursor: pointer; + opacity: 0.6; + font-weight: 500; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: all 0.2s; +} + +.tab:hover { + opacity: 1; + color: var(--fg-color); +} + +.tab.active { + opacity: 1; + border-bottom-color: var(--accent-color); + color: var(--accent-color); +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; +} + /* --- Charts Area --- */ .charts-row { display: grid; - grid-template-columns: 2fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 24px; } @@ -127,6 +163,7 @@ h3 { border: var(--card-border); border-radius: var(--card-radius); overflow: hidden; + overflow-x: auto; } table { @@ -142,6 +179,7 @@ th { font-weight: 500; color: var(--muted-color); font-size: 0.8rem; + white-space: nowrap; } td { @@ -151,51 +189,24 @@ td { tr.row-crit { background: rgba(248, 113, 113, 0.15); - /* Darker red */ } tr.row-warn { background: rgba(250, 204, 21, 0.1); } -.actions-cell { - text-align: right; -} - -.btn-action { - background: transparent; +/* --- Lock Tree Visualizer --- */ +.lock-node { border: 1px solid var(--border-color); - color: var(--muted-color); - padding: 4px 8px; + margin: 8px 0; + padding: 12px; border-radius: 4px; - cursor: pointer; - font-size: 0.75rem; -} - -.btn-action:hover { - color: var(--fg-color); - border-color: var(--accent-color); -} - -.btn-danger { - color: var(--danger-color); - border-color: rgba(248, 113, 113, 0.3); -} - -.btn-danger:hover { - background: var(--danger-color); - color: white; -} - -/* Duplicated rule in original, keeping single logic */ -.btn-warn { - color: var(--warning-color); - border-color: rgba(250, 204, 21, 0.3); } -.btn-warn:hover { - background: var(--warning-color); - color: black; +.lock-children { + margin-left: 24px; + border-left: 2px solid var(--border-color); + padding-left: 12px; } /* --- Detail View --- */ From 852d1805bdfa78acfac0c3c125c4495c680afa19 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Thu, 19 Feb 2026 23:51:22 +0530 Subject: [PATCH 5/6] feat: Introduce visual table designer, index and constraint manager, smart paste, and enhanced dashboard diagnostics. --- CHANGELOG.md | 16 ++++++++++++ README.md | 3 +++ docs/ROADMAP.md | 18 +++++++++++-- docs/VISUAL_TOOLS.md | 60 ++++++++++++++++++++++++++++++++++++++++++++ docs/index.html | 13 +++++++++- 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 docs/VISUAL_TOOLS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b9287..804d814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.4] - 2026-02-19 + +### Added +- **Visual Table Designer**: A robust, interactive UI for creating tables. Define columns, data types, constraints, and foreign keys visually without writing SQL. +- **Visual Index & Constraint Manager**: Manage indexes and constraints with a modern GUI. Analyze usage, drop unused indexes, and create new constraints with ease. +- **Smart Paste**: Intelligent clipboard handling that detects SQL, CSV, or JSON content and offers context-aware actions (e.g., "Insert as Rows", "Format SQL"). +- **Dashboard Improvements**: + - **Visual Lock Viewer**: Diagnostic tree view to identify and resolve blocking chains and deadlocks. + - **Enhanced Metrics**: Real-time charts for IO, Checkpoints, and System Load. + - **Active Query Management**: Kill/Cancel blocking queries directly from the dashboard. + +### Improved +- **Stability**: Fixed dashboard template loading issues to ensure reliable rendering on all platforms. + +--- + ## [0.8.3] - 2026-02-14 ### Added diff --git a/README.md b/README.md index 8c3dc97..f26b581 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ - πŸ’Ύ **Saved Queries** β€” Tag-based organization, connection context restoration, AI metadata generation, edit & reuse - 🌳 **Database Explorer** β€” Browse tables, views, functions, types, FDWs - πŸ› οΈ **Object Operations** β€” CRUD, scripts, VACUUM, ANALYZE, REINDEX +- πŸ—οΈ **Visual Table Designer** β€” Create/Edit tables with a robust GUI +- πŸ”‘ **Index & Constraint Manager** β€” Visual management of DB constraints +- πŸ“‹ **Smart Paste** β€” Context-aware clipboard actions (SQL/CSV/JSON) - πŸ“Š **Table Intelligence** β€” Profile, activity monitor, index usage, definition viewer - πŸ” **EXPLAIN CodeLens** β€” One-click query analysis directly in notebooks - πŸ›‘οΈ **Auto-LIMIT** β€” Intelligent query protection (configurable, default 1000 rows) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index faeb056..155f449 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -134,7 +134,21 @@ --- -## πŸ› οΈ Phase 8: Technical Health (Security & Refactoring) +## 🎨 Phase 8: Visual Editors & Intelligence βœ… COMPLETE + +- [x] **Visual Table Designer** + - Robust UI for creating/editing tables, columns, constraints, and foreign keys without SQL. +- [x] **Visual Index & Constraint Manager** + - Dedicated UI for managing indexes and constraints, seeing usage stats, and dropping unused objects. +- [x] **Smart Paste** + - Intelligent clipboard handling: detects CSV/JSON/SQL usage and offers context-aware actions (Insert Rows, Format). +- [x] **Dashboard Diagnostics** + - Visual Lock Viewer (Tree view of blocking chains). + - Enhanced metrics (IO/Checkpoints/Temp Files). + +--- + +## πŸ› οΈ Phase 9: Technical Health (Security & Refactoring) ### Security Hardening - [ ] **Parameterize SQL Generation** @@ -150,7 +164,7 @@ --- -## πŸš€ Phase 9: Future & Collaboration +## πŸš€ Phase 10: Future & Collaboration - [ ] **Team Collaboration Features** (Shared queries, comments) - [ ] **Visual Database Designer** (ERD manipulation) diff --git a/docs/VISUAL_TOOLS.md b/docs/VISUAL_TOOLS.md new file mode 100644 index 0000000..35f8d47 --- /dev/null +++ b/docs/VISUAL_TOOLS.md @@ -0,0 +1,60 @@ +# Visual Database Tools + +PgStudio v0.8.4 introduces powerful visual editors to manage your database schema without writing complex DDL manually. + +## πŸ—οΈ Visual Table Designer + +Create and edit tables with a robust, interactive UI. + +### Features +- **Column Management**: Add, remove, and reorder columns. +- **Data Types**: Full support for PostgreSQL data types (TEXT, INTEGER, JSONB, UUID, etc.) with length/precision options. +- **Constraints**: + - **Primary Keys**: Define single or composite primary keys. + - **Foreign Keys**: Visually link to other tables with ON DELETE/UPDATE rules. + - **Unique/Check**: Add custom constraints. +- **Preview SQL**: See the generated `CREATE TABLE` query in real-time before executing. + +### Usage +- **Create**: Right-click a Schema in the sidebar β†’ **Create Table**. +- **Edit**: Right-click an existing Table β†’ **Design Table**. + +--- + +## πŸ”‘ Index & Constraint Manager + +Optimize performance and ensure data integrity with a dedicated management interface. + +### Features +- **Usage Statistics**: See scan counts and read/fetch efficiency for every index. +- **Unused Index Detection**: Quickly identify indexes that are consuming space but not being used. +- **Visual Creation**: created indexes (B-Tree, Hash, GIN, GiST) on single or multiple columns. +- **Drop Safely**: Remove unused indexes with a single click. + +### Usage +- Right-click a Table β†’ **Manage Indexes & Constraints**. + +--- + +## πŸ“‹ Smart Paste + +Intelligently handle clipboard content based on context. + +### Features +- **SQL Detection**: Pasting SQL into a notebook automatically offers to format it. +- **CSV/JSON Detection**: Pasting data suggests: + - **Insert as Rows**: Convert raw data into `INSERT` statements for the current table context. + - **Create Table**: Generate a new table schema based on the data structure. + +### Usage +- Simply paste (`Ctrl+V` / `Cmd+V`) content into a notebook or SQL editor. PgStudio will analyze the content and show a "Smart Action" notification if applicable. + +--- + +## πŸ“Š Dashboard Diagnostics + +The Server Dashboard now includes deep diagnostic tools: + +- **Lock Viewer**: A tree visualization of blocking chains. Identify the root cause of stuck queries. +- **Kill/Cancel**: Terminate blocking sessions directly from the "Active Queries" list. +- **IO Metrics**: Real-time charts for Checkpoints, Temp File usage, and Tuple Fetch/Return ratios. diff --git a/docs/index.html b/docs/index.html index 92789f5..5b2712f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -54,7 +54,7 @@ @@ -290,6 +290,17 @@

Developer Tools

  • PSQL terminal access
  • +
    +
    πŸ—οΈ
    +

    Visual Designers

    +

    Create and manage schema objects without writing DDL.

    +
      +
    • Visual Table Designer
    • +
    • Index & Constraint Manager
    • +
    • Smart Paste (Data/SQL)
    • +
    • Schema Diagramming
    • +
    +
    From bdbff4af7c1bba60fde8b6a6808538ea441a6825 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Thu, 19 Feb 2026 23:51:32 +0530 Subject: [PATCH 6/6] Bump version to 0.8.4 --- package.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 13840c7..ecd57f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "0.8.3", + "version": "0.8.4", "description": "PostgreSQL database explorer for VS Code with notebook support", "publisher": "ric-v", "private": false, @@ -879,7 +879,7 @@ }, { "command": "postgres-explorer.saveQueryToLibraryUI", - "title": "\ud83d\udcbe Save Query (UI Form)", + "title": "πŸ’Ύ Save Query (UI Form)", "icon": "$(save)", "category": "PgStudio: Queries" }, @@ -891,31 +891,31 @@ }, { "command": "postgres-explorer.loadSavedQueryUI", - "title": "\ud83d\udcc2 Load Saved Query", + "title": "πŸ“‚ Load Saved Query", "icon": "$(folder-opened)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.viewSavedQuery", - "title": "\ud83d\udccc View Saved Query", + "title": "πŸ“Œ View Saved Query", "icon": "$(preview)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.copySavedQuery", - "title": "\ud83d\udccb Copy Query", + "title": "πŸ“‹ Copy Query", "icon": "$(copy)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.editSavedQuery", - "title": "\u270f\ufe0f Edit Query", + "title": "✏️ Edit Query", "icon": "$(edit)", "category": "PgStudio: Queries" }, { "command": "postgres-explorer.openSavedQueryInNotebook", - "title": "\ud83d\udcd8 Open in Notebook", + "title": "πŸ“˜ Open in Notebook", "icon": "$(notebook)", "category": "PgStudio: Queries" },