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/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..155f449 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,48 @@
---
+## π¨ 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**
+ - 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 10: 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/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
+
+
diff --git a/package.json b/package.json
index f834d13..ecd57f2 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.4",
"description": "PostgreSQL database explorer for VS Code with notebook support",
"publisher": "ric-v",
"private": false,
@@ -239,6 +239,16 @@
"title": "Update Data",
"icon": "$(edit)"
},
+ {
+ "command": "postgres-explorer.quickClone",
+ "title": "Quick Clone Table",
+ "icon": "$(files)"
+ },
+ {
+ "command": "postgres-explorer.exportTable",
+ "title": "Export Table Data",
+ "icon": "$(export)"
+ },
{
"command": "postgres-explorer.createInSchema",
"title": "Create Object",
@@ -254,6 +264,11 @@
"title": "Create Schema",
"icon": "$(add)"
},
+ {
+ "command": "postgres-explorer.pasteTable",
+ "title": "Smart Paste (create table from clipboard)",
+ "icon": "$(clippy)"
+ },
{
"command": "postgres-explorer.refreshMaterializedView",
"title": "Refresh Materialized View",
@@ -933,6 +948,24 @@
"title": "Import Saved Queries",
"icon": "$(import)",
"category": "PgStudio: Queries"
+ },
+ {
+ "command": "postgres-explorer.openTableDesigner",
+ "title": "Open Table Designer (Visual)",
+ "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": [
@@ -1415,11 +1448,21 @@
"when": "view == postgresExplorer && viewItem == table",
"group": "2_operations@1"
},
+ {
+ "command": "postgres-explorer.quickClone",
+ "when": "view == postgresExplorer && viewItem == table",
+ "group": "2_operations@0"
+ },
{
"command": "postgres-explorer.insertData",
"when": "view == postgresExplorer && viewItem == table",
"group": "2_operations@2"
},
+ {
+ "command": "postgres-explorer.exportTable",
+ "when": "view == postgresExplorer && viewItem == table",
+ "group": "2_operations@3"
+ },
{
"command": "postgres-explorer.updateData",
"when": "view == postgresExplorer && viewItem == table",
@@ -1428,10 +1471,20 @@
{
"command": "postgres-explorer.showSchemaProperties",
"when": "view == postgresExplorer && viewItem == schema",
- "group": "inline@0"
+ "group": "inline@2"
},
{
- "command": "postgres-explorer.createInSchema",
+ "command": "postgres-explorer.pasteTable",
+ "when": "view == postgresExplorer && viewItem == schema",
+ "group": "1_actions"
+ },
+ {
+ "command": "postgres-explorer.pasteTable",
+ "when": "view == postgresExplorer && viewItem == table",
+ "group": "1_actions"
+ },
+ {
+ "command": "postgres-explorer.schemaOperations",
"when": "view == postgresExplorer && viewItem == schema",
"group": "inline@1",
"icon": "$(add)"
@@ -1847,6 +1900,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": [
@@ -1994,7 +2067,8 @@
"vscode:prepublish": "npm run esbuild-base -- --minify && npm run esbuild-renderer -- --minify",
"esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --external:ssh2 --external:pg --format=cjs --platform=node --main-fields=main",
"esbuild-renderer": "esbuild ./src/renderer_v2.ts --bundle --outfile=dist/renderer_v2.js --format=esm --platform=browser",
- "compile": "tsc -p ./ && npm run esbuild-renderer",
+ "copy-templates": "cp -r templates dist/",
+ "compile": "tsc -p ./ && npm run esbuild-renderer && npm run copy-templates",
"watch": "tsc -watch -p ./",
"esbuild": "npm run esbuild-base -- --sourcemap && npm run esbuild-renderer -- --sourcemap",
"esbuild-watch": "npm run esbuild-base -- --sourcemap --watch & npm run esbuild-renderer -- --sourcemap --watch",
diff --git a/src/activation/commands.ts b/src/activation/commands.ts
index fff56c5..7e562f5 100644
--- a/src/activation/commands.ts
+++ b/src/activation/commands.ts
@@ -16,8 +16,10 @@ import { cmdForeignDataWrapperOperations, cmdShowForeignDataWrapperProperties, c
import { cmdCallFunction, cmdCreateFunction, cmdDropFunction, cmdEditFunction, cmdFunctionOperations, cmdRefreshFunction, cmdShowFunctionProperties } from '../commands/functions';
import { cmdCreateMaterializedView, cmdDropMatView, cmdEditMatView, cmdMatViewOperations, cmdRefreshMatView, cmdViewMatViewData, cmdViewMatViewProperties } from '../commands/materializedViews';
import { cmdNewNotebook, cmdExplainQuery } from '../commands/notebook';
-import { cmdCreateObjectInSchema, cmdCreateSchema, cmdSchemaOperations, cmdShowSchemaProperties } from '../commands/schema';
-import { cmdCreateTable, cmdDropTable, cmdEditTable, cmdInsertTable, cmdMaintenanceAnalyze, cmdMaintenanceReindex, cmdMaintenanceVacuum, cmdScriptCreate, cmdScriptDelete, cmdScriptInsert, cmdScriptSelect, cmdScriptUpdate, cmdShowTableProperties, cmdTableOperations, cmdTruncateTable, cmdUpdateTable, cmdViewTableData, cmdTableProfile, cmdTableActivity, cmdIndexUsage, cmdTableDefinition } from '../commands/tables';
+import { cmdCreateObjectInSchema, cmdCreateSchema, cmdSchemaOperations, cmdShowSchemaProperties, cmdPasteTable } from '../commands/schema';
+import {
+ cmdCreateTable, cmdDropTable, cmdEditTable, cmdInsertTable, cmdMaintenanceAnalyze, cmdMaintenanceReindex, cmdMaintenanceVacuum, cmdScriptCreate, cmdScriptDelete, cmdScriptInsert, cmdScriptSelect, cmdScriptUpdate, cmdShowTableProperties, cmdTableOperations, cmdTruncateTable, cmdUpdateTable, cmdViewTableData, cmdTableProfile, cmdTableActivity, cmdQuickCloneTable, cmdExportTable, cmdIndexUsage, cmdTableDefinition
+} from '../commands/tables';
import { cmdAllOperationsTypes, cmdCreateType, cmdDropType, cmdEditTypes, cmdRefreshType, cmdShowTypeProperties } from '../commands/types';
import { cmdAddRole, cmdAddUser, cmdDropRole, cmdEditRole, cmdGrantRevokeRole, cmdRefreshRole, cmdRoleOperations, cmdShowRoleProperties } from '../commands/usersRoles';
import { cmdCreateView, cmdDropView, cmdEditView, cmdRefreshView, cmdScriptCreate as cmdViewScriptCreate, cmdScriptSelect as cmdViewScriptSelect, cmdShowViewProperties, cmdViewData, cmdViewOperations } from '../commands/views';
@@ -48,6 +50,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,
@@ -446,10 +451,18 @@ export function registerAllCommands(
command: 'postgres-explorer.truncateTable',
callback: async (item: DatabaseTreeItem) => await cmdTruncateTable(item, context)
},
+ {
+ command: 'postgres-explorer.quickClone',
+ callback: async (item: DatabaseTreeItem) => await cmdQuickCloneTable(item, context)
+ },
{
command: 'postgres-explorer.insertData',
callback: async (item: DatabaseTreeItem) => await cmdInsertTable(item, context)
},
+ {
+ command: 'postgres-explorer.exportTable',
+ callback: async (item: DatabaseTreeItem) => await cmdExportTable(item, context)
+ },
{
command: 'postgres-explorer.updateData',
callback: async (item: DatabaseTreeItem) => await cmdUpdateTable(item, context)
@@ -1156,6 +1169,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...');
@@ -1179,7 +1206,9 @@ export function registerAllCommands(
if (savedQueriesTreeProvider) {
savedQueriesTreeProvider.refresh();
}
- })
+ }),
+
+ vscode.commands.registerCommand('postgres-explorer.pasteTable', (item) => cmdPasteTable(item, context))
);
outputChannel.appendLine('All commands registered successfully.');
diff --git a/src/commands/schema/index.ts b/src/commands/schema/index.ts
new file mode 100644
index 0000000..fa9b044
--- /dev/null
+++ b/src/commands/schema/index.ts
@@ -0,0 +1,2 @@
+export * from './operations';
+export * from './paste';
diff --git a/src/commands/schema.ts b/src/commands/schema/operations.ts
similarity index 99%
rename from src/commands/schema.ts
rename to src/commands/schema/operations.ts
index 3a6d0ed..fc152ca 100644
--- a/src/commands/schema.ts
+++ b/src/commands/schema/operations.ts
@@ -1,6 +1,6 @@
import { Client } from 'pg';
import * as vscode from 'vscode';
-import { DatabaseTreeItem, DatabaseTreeProvider } from '../providers/DatabaseTreeProvider';
+import { DatabaseTreeItem, DatabaseTreeProvider } from '../../providers/DatabaseTreeProvider';
import {
MarkdownUtils,
ErrorHandlers,
@@ -8,8 +8,8 @@ import {
NotebookBuilder,
QueryBuilder,
validateCategoryItem,
-} from './helper';
-import { SchemaSQL } from './sql';
+} from '../helper';
+import { SchemaSQL } from '../sql';
diff --git a/src/commands/schema/paste.ts b/src/commands/schema/paste.ts
new file mode 100644
index 0000000..9e5dce9
--- /dev/null
+++ b/src/commands/schema/paste.ts
@@ -0,0 +1,184 @@
+import * as vscode from 'vscode';
+import { DatabaseTreeItem } from '../../providers/DatabaseTreeProvider';
+import { NotebookBuilder, MarkdownUtils, getDatabaseConnection } from '../helper';
+
+export async function cmdPasteTable(item: DatabaseTreeItem, context: vscode.ExtensionContext) {
+ let release: (() => void) | undefined;
+ try {
+ const clipboardText = await vscode.env.clipboard.readText();
+ if (!clipboardText || clipboardText.trim().length === 0) {
+ vscode.window.showWarningMessage('Clipboard is empty.');
+ return;
+ }
+
+ // simplistic detection
+ const isJson = clipboardText.trim().startsWith('[') || clipboardText.trim().startsWith('{');
+ let data: any[] = [];
+ let format = 'CSV';
+
+ if (isJson) {
+ try {
+ const parsed = JSON.parse(clipboardText);
+ if (Array.isArray(parsed)) data = parsed;
+ else if (typeof parsed === 'object') data = [parsed];
+ format = 'JSON';
+ } catch (e) {
+ // Fallback to CSV if JSON parse fails?? No, likely invalid JSON.
+ vscode.window.showErrorMessage('Invalid JSON in clipboard');
+ return;
+ }
+ } else {
+ // CSV Parse
+ data = parseCSV(clipboardText);
+ format = 'CSV';
+ }
+
+ if (data.length === 0) {
+ vscode.window.showWarningMessage('No parseable data found in clipboard.');
+ return;
+ }
+
+ // Infer Schema
+ const columns = Object.keys(data[0]);
+ const inferredTypes: Record = {};
+
+ columns.forEach(col => {
+ inferredTypes[col] = inferType(data, col);
+ });
+
+ // Generate Notebook
+ const schema = item.schema || 'public';
+ const tableName = `imported_table_${Date.now().toString().slice(-4)}`;
+
+ const ddl = `CREATE TABLE ${schema}.${tableName} (\n` +
+ columns.map(col => ` "${col}" ${inferredTypes[col]}`).join(',\n') +
+ '\n);';
+
+ const insert = generateInsertScript(schema, tableName, columns, data);
+
+
+ // ... logic ...
+
+ let metadata: any;
+
+ try {
+ const conn = await getDatabaseConnection(item);
+ metadata = conn.metadata;
+ release = conn.release;
+ } catch (e) {
+ // Connection failed, maybe just show without metadata (will likely fail to execute but show notebook is fine?)
+ // Actually NotebookBuilder needs metadata to link connection.
+ // If connection fails, we can't really run the SQL.
+ // But we can still show the SQL.
+ // We will proceed with undefined metadata.
+ }
+
+ await new NotebookBuilder(metadata)
+ .addMarkdown(
+ MarkdownUtils.header(`π Smart Paste: New Table`) +
+ MarkdownUtils.infoBox(`Detected **${format}** data with **${data.length}** rows. Review the inferred schema and data below.`) +
+ `\n\n**Note:** Rename the table in the SQL script before running if desired.`
+ )
+ .addMarkdown('#### 1. Create Table')
+ .addSql(ddl)
+ .addMarkdown('#### 2. Insert Data')
+ .addSql(insert)
+ .show();
+
+ } catch (err: any) {
+ vscode.window.showErrorMessage(`Smart Paste failed: ${err.message}`);
+ } finally {
+ if (release) release();
+ }
+}
+
+function parseCSV(text: string): any[] {
+ const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
+ if (lines.length === 0) return [];
+
+ // Headers
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
+ const result = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ // Simple manual split with quote support
+ const row: any = {};
+ const values: string[] = [];
+ let inQuote = false;
+ let val = '';
+ for (const char of lines[i]) {
+ if (char === '"') inQuote = !inQuote;
+ else if (char === ',' && !inQuote) {
+ values.push(val.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
+ val = '';
+ } else val += char;
+ }
+ values.push(val.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
+
+ headers.forEach((h, idx) => {
+ row[h] = idx < values.length ? values[idx] : null;
+ });
+ result.push(row);
+ }
+ return result;
+}
+
+function inferType(data: any[], col: string): string {
+ let isInt = true;
+ let isFloat = true;
+ let isBool = true;
+ let isDate = true;
+ let hasData = false;
+
+ // Check first 50 rows
+ const sample = data.slice(0, 50);
+
+ for (const row of sample) {
+ const val = row[col];
+ if (val === null || val === undefined || val === '') continue;
+ hasData = true;
+ const s = String(val).trim();
+
+ if (isBool && !['true', 'false', 't', 'f', '0', '1', 'yes', 'no'].includes(s.toLowerCase())) isBool = false;
+ if (isInt && !/^-?\d+$/.test(s)) isInt = false;
+ if (isFloat && !/^-?\d+(\.\d+)?$/.test(s)) isFloat = false;
+ if (isDate && isNaN(Date.parse(s))) isDate = false; // crude date check
+ }
+
+ if (!hasData) return 'TEXT'; // Default to text if all null
+ if (isBool) return 'BOOLEAN';
+ if (isInt) return 'INTEGER';
+ if (isFloat) return 'NUMERIC';
+ if (isDate) return 'TIMESTAMP'; // or DATE
+ return 'TEXT';
+}
+
+function generateInsertScript(schema: string, table: string, columns: string[], data: any[]): string {
+ const quote = (v: any) => {
+ if (v === null || v === undefined) return 'NULL';
+ if (typeof v === 'number') return v;
+ if (typeof v === 'boolean') return v ? 'TRUE' : 'FALSE';
+ return `'${String(v).replace(/'/g, "''")}'`;
+ };
+
+ const rows = data.map(row => {
+ const values = columns.map(c => quote(row[c])).join(', ');
+ return `(${values})`;
+ });
+
+ // Batch insert?
+ const cols = columns.map(c => `"${c}"`).join(', ');
+ const header = `INSERT INTO ${schema}.${table} (${cols}) VALUES\n`;
+
+ // Split into chunks of 1000 to avoid huge statements?
+ // For notebook display, maybe just 100 rows preview + "..." if too large?
+ // "Insert Script" implying full data.
+ // If data is huge, we shouldn't dump 10MB into a notebook cell.
+
+ if (rows.length > 500) {
+ const chunk = rows.slice(0, 500).join(',\n');
+ return `${header} ${chunk};\n\n-- ... and ${rows.length - 500} more rows (truncated for preview)`;
+ }
+
+ return `${header} ${rows.join(',\n')};`;
+}
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/commands/tables/export.ts b/src/commands/tables/export.ts
new file mode 100644
index 0000000..45abb56
--- /dev/null
+++ b/src/commands/tables/export.ts
@@ -0,0 +1,183 @@
+import * as vscode from 'vscode';
+import * as fs from 'fs';
+import { DatabaseTreeItem } from '../../providers/DatabaseTreeProvider';
+import { CommandBase } from '../../common/commands/CommandBase';
+import { ConnectionManager } from '../../services/ConnectionManager';
+import Cursor from 'pg-cursor';
+
+export async function cmdExportTable(item: DatabaseTreeItem, context: vscode.ExtensionContext) {
+ if (!item.schema || !item.label) return;
+
+ const tableFull = `${item.schema}.${item.label}`;
+
+ // 1. Select Format
+ const format = await vscode.window.showQuickPick(['CSV', 'JSON', 'SQL INSERT'], {
+ placeHolder: 'Select Export Format'
+ });
+ if (!format) return;
+
+ // 2. Select Delimiter (if CSV)
+ let delimiter = ',';
+ if (format === 'CSV') {
+ const delimOptions = [
+ { label: 'Comma (,)', value: ',' },
+ { label: 'Semicolon (;)', value: ';' },
+ { label: 'Tab (\\t)', value: '\t' },
+ { label: 'Pipe (|)', value: '|' }
+ ];
+ const pickedDelim = await vscode.window.showQuickPick(delimOptions, { placeHolder: 'Select Delimiter' });
+ if (!pickedDelim) return;
+ delimiter = pickedDelim.value;
+ }
+
+ // 3. Select Encoding
+ const encodingOptions = [
+ { label: 'UTF-8', value: 'utf8' },
+ { label: 'UTF-16 LE', value: 'utf16le' },
+ { label: 'ASCII', value: 'ascii' }
+ ];
+ const pickedEncoding = await vscode.window.showQuickPick(encodingOptions, { placeHolder: 'Select Encoding' });
+ if (!pickedEncoding) return;
+ const encoding = pickedEncoding.value as BufferEncoding;
+
+ // 4. Save Dialog
+ const filters: { [key: string]: string[] } = {};
+ if (format === 'CSV') filters['CSV'] = ['csv'];
+ else if (format === 'JSON') filters['JSON'] = ['json'];
+ else filters['SQL'] = ['sql'];
+
+ const uri = await vscode.window.showSaveDialog({
+ filters,
+ saveLabel: 'Export',
+ title: `Export ${tableFull}`
+ });
+ if (!uri) return;
+
+ await CommandBase.run(context, item, 'export table', async (conn, client, metadata) => {
+ // We use a dedicated client for streaming to avoid blocking the main pool/session
+ // Actually CommandBase gives us a client. If it's from pool, we should be careful.
+ // But CommandBase uses `run` which likely gets a pooled client (ephemeral).
+ // Streaming might take time.
+ // Ideally we should use a progress indicator.
+
+ await vscode.window.withProgress({
+ location: vscode.ProgressLocation.Notification,
+ title: `Exporting ${tableFull}...`,
+ cancellable: true
+ }, async (progress, token) => {
+
+ const cursor = client.query(new Cursor(`SELECT * FROM "${item.schema}"."${item.label}"`));
+
+ // Open file stream
+ // vscode.workspace.fs does not support streaming write easily (it has writeFile which takes Buffer).
+ // For large files, we should use fs.createWriteStream if local, but we might be in web.
+ // However, the extension runs in Node host for local files.
+ // If we are in standard VS Code desktop, `fs` works.
+ // If `uri.scheme` is 'file', use `fs`.
+
+ if (uri.scheme !== 'file') {
+ throw new Error('Streaming export currently supports local file system only.');
+ }
+
+ const writeStream = fs.createWriteStream(uri.fsPath, { encoding });
+
+ try {
+ // Write Header
+ if (format === 'CSV') {
+ // We need column names. Fetch 1 row or use metadata?
+ // We can't fetch 1 row from cursor without consuming it?
+ // Wait, pg-cursor read(batchSize) returns rows.
+ // We can get columns from the first batch result fields (if available) or just row keys.
+ } else if (format === 'JSON') {
+ writeStream.write('[');
+ }
+
+ let isFirstBatch = true;
+ let rowCount = 0;
+ const BATCH_SIZE = 1000;
+ let cancelled = false;
+
+ token.onCancellationRequested(() => {
+ cancelled = true;
+ cursor.close(() => { });
+ });
+
+ // Loop
+ while (true) {
+ if (cancelled) break;
+
+ const rows = await new Promise((resolve, reject) => {
+ cursor.read(BATCH_SIZE, (err: Error, rows: any[]) => {
+ if (err) reject(err);
+ else resolve(rows);
+ });
+ });
+
+ if (rows.length === 0) break;
+
+ if (isFirstBatch) {
+ if (format === 'CSV') {
+ const columns = Object.keys(rows[0]);
+ writeStream.write(columns.map(c => `"${c}"`).join(delimiter) + '\n');
+ }
+ isFirstBatch = false;
+ }
+
+ let chunk = '';
+ if (format === 'CSV') {
+ const columns = Object.keys(rows[0]); // Assume consistent schema
+ chunk = rows.map(row => {
+ return columns.map(col => {
+ const val = row[col];
+ if (val === null || val === undefined) return '';
+ const str = String(val);
+ if (str.includes(delimiter) || str.includes('"') || str.includes('\n')) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+ }).join(delimiter);
+ }).join('\n') + '\n';
+ } else if (format === 'JSON') {
+ // Need comma between batches
+ const jsonStr = JSON.stringify(rows); // [obj, obj]
+ // We want ... obj, obj ...
+ // JSON.stringify(rows) returns "[obj,obj]"
+ // Remove brackets
+ let inner = jsonStr.substring(1, jsonStr.length - 1);
+ if (rowCount > 0 && inner.length > 0) chunk = ',' + inner;
+ else chunk = inner;
+ } else if (format === 'SQL INSERT') {
+ const columns = Object.keys(rows[0]);
+ const colsStr = columns.map(c => `"${c}"`).join(', ');
+ chunk = rows.map(row => {
+ const vals = columns.map(c => {
+ const v = row[c];
+ if (v === null) return 'NULL';
+ if (typeof v === 'string') return `'${v.replace(/'/g, "''")}'`;
+ return v;
+ }).join(', ');
+ return `INSERT INTO ${tableFull} (${colsStr}) VALUES (${vals});`;
+ }).join('\n') + '\n';
+ }
+
+ if (!writeStream.write(chunk)) {
+ await new Promise(fulfill => writeStream.once('drain', fulfill));
+ }
+
+ rowCount += rows.length;
+ progress.report({ message: `${rowCount} rows exported...` });
+ }
+
+ if (format === 'JSON') {
+ writeStream.write(']');
+ }
+
+ } finally {
+ writeStream.end();
+ cursor.close(() => { });
+ }
+ });
+
+ vscode.window.showInformationMessage(`Export complete for ${tableFull}`);
+ });
+}
diff --git a/src/commands/tables/index.ts b/src/commands/tables/index.ts
index 7058e18..36e05ac 100644
--- a/src/commands/tables/index.ts
+++ b/src/commands/tables/index.ts
@@ -3,3 +3,4 @@ export * from './scripts';
export * from './maintenance';
export * from './profile';
export * from './definition';
+export * from './export';
diff --git a/src/commands/tables/operations.ts b/src/commands/tables/operations.ts
index 8c96488..2ddc946 100644
--- a/src/commands/tables/operations.ts
+++ b/src/commands/tables/operations.ts
@@ -765,3 +765,33 @@ CREATE INDEX idx_logs_created_at ON ${schema}.logs(created_at);`)
.show();
});
}
+
+export async function cmdQuickCloneTable(item: DatabaseTreeItem, context: vscode.ExtensionContext) {
+ await CommandBase.run(context, item, 'create quick clone notebook', async (conn, client, metadata) => {
+ const schema = item.schema!;
+ const table = item.label!;
+ const newTableName = `${table}_copy`;
+
+ await new NotebookBuilder(metadata)
+ .addMarkdown(
+ MarkdownUtils.header(`π― Quick Clone: \`${schema}.${table}\``) +
+ MarkdownUtils.infoBox('This script creates a complete copy of the table structure (including indexes and constraints) and data.') +
+ `\n\n#### π Naming Collision\n\n` +
+ MarkdownUtils.warningBox(`The script uses \`${newTableName}\` as the new table name. If this name already exists, the script will fail. Change the name in the script if needed.`)
+ )
+ .addMarkdown('##### π Clone Structure & Data')
+ .addSql(`-- 1. Create new table with same structure (indexes, constraints, defaults)
+CREATE TABLE ${schema}.${newTableName} (LIKE ${schema}.${table} INCLUDING ALL);
+
+-- 2. Copy all data
+INSERT INTO ${schema}.${newTableName}
+SELECT * FROM ${schema}.${table};
+
+-- 3. (Optional) Reset sequences if needed
+-- If the original table used a sequence, the new table might point to the same sequence or need a new one.
+-- 'INCLUDING ALL' copies defaults, so if default is nextval('seq'), both tables share the sequence.
+-- You might want to create a new sequence for the copy.
+`)
+ .show();
+ });
+}
diff --git a/src/dashboard/DashboardData.ts b/src/dashboard/DashboardData.ts
index f24bfe0..67b7914 100644
--- a/src/dashboard/DashboardData.ts
+++ b/src/dashboard/DashboardData.ts
@@ -45,6 +45,12 @@ export interface DashboardStats {
blks_hit: number;
deadlocks: number;
conflicts: number;
+ temp_bytes: number;
+ temp_files: number;
+ checkpoints_timed: number;
+ checkpoints_req: number;
+ tuples_fetched: number;
+ tuples_returned: number;
};
pgStatStatements?: {
query: string;
@@ -147,8 +153,9 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P
`, [dbName]),
// Database Metrics (Throughput & I/O & Conflicts/Deadlocks)
+ // Select all columns to be robust against version differences (e.g. checkpoints_timed removed in PG 17)
client.query(`
- SELECT xact_commit, xact_rollback, blks_read, blks_hit, deadlocks, conflicts
+ SELECT *
FROM pg_stat_database
WHERE datname = $1
`, [dbName]),
@@ -156,7 +163,8 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P
// Settings (Max Connections)
client.query(`SHOW max_connections`),
- // pg_stat_statements (Top Queries)
+ // pg_stat_statements (Top Queries) - Safe selection that returns empty if extension missing
+ // We use a check to avoid error log spam if possible, or just let it fail gracefully via allSettled
client.query(`
SELECT query, calls, total_time, mean_time, rows
FROM pg_stat_statements
@@ -203,7 +211,11 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P
const extCount = getResult(extRes).rows[0]?.count || 0;
const activeQueriesRows = getResult(activeQueriesRes).rows;
const locksRows = getResult(locksRes).rows;
- const metricsRow = getResult(metricsRes).rows[0] || { xact_commit: 0, xact_rollback: 0, blks_read: 0, blks_hit: 0, deadlocks: 0, conflicts: 0 };
+ const metricsRow = getResult(metricsRes).rows[0] || {
+ xact_commit: 0, xact_rollback: 0, blks_read: 0, blks_hit: 0, deadlocks: 0, conflicts: 0,
+ temp_bytes: 0, temp_files: 0, checkpoints_timed: 0, checkpoints_req: 0,
+ tup_fetched: 0, tup_returned: 0
+ };
const maxConnRow = getResult(settingsRes).rows[0] || { max_connections: '100' };
const pgStatRows = getResult(pgStatRes).rows || [];
const waitEventsRows = getResult(waitsRes).rows;
@@ -273,7 +285,13 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P
blks_read: parseInt(metricsRow.blks_read || '0'),
blks_hit: parseInt(metricsRow.blks_hit || '0'),
deadlocks: parseInt(metricsRow.deadlocks || '0'),
- conflicts: parseInt(metricsRow.conflicts || '0')
+ conflicts: parseInt(metricsRow.conflicts || '0'),
+ temp_bytes: parseInt(metricsRow.temp_bytes || '0'),
+ temp_files: parseInt(metricsRow.temp_files || '0'),
+ checkpoints_timed: parseInt(metricsRow.checkpoints_timed || '0'),
+ checkpoints_req: parseInt(metricsRow.checkpoints_req || '0'),
+ tuples_fetched: parseInt(metricsRow.tup_fetched || '0'),
+ tuples_returned: parseInt(metricsRow.tup_returned || '0')
},
pgStatStatements: pgStatRows.map((r: any) => ({
query: r.query,
diff --git a/src/dashboard/DashboardHtml.ts b/src/dashboard/DashboardHtml.ts
index 61cb470..3c74ac0 100644
--- a/src/dashboard/DashboardHtml.ts
+++ b/src/dashboard/DashboardHtml.ts
@@ -23,13 +23,35 @@ export async function getHtmlForWebview(webview: vscode.Webview, extensionUri: v
// Inject Data safely
js = js.replace('null; // __STATS_JSON__', JSON.stringify(stats));
+ console.log('DashboardHtml: Loaded resources. HTML length:', html.length, 'CSS length:', css.length, 'JS length:', js.length);
+
// Inject content
// Use replacer function to avoid special replacement patterns (like $&) in the code/css
+ // Inject content with flexible Regex: matches {{NAME}}, { { NAME } }, or /* NAME */
+ const replacePlaceholder = (name: string, value: string) => {
+ // Regex explanation:
+ // 1. (?:\s*\{\s*\{\s*${name}\s*\}\s*\}\s*) -> Matches curly braces with optional whitespace/split
+ // 2. (?:\/\*\s*${name}\s*\*\/) -> Matches /* NAME */ comments
+ const regex = new RegExp(`(?:\\{\\s*\\{\\s*${name}\\s*\\}\\s*\\}|\\/\\*\\s*${name}\\s*\\*\\/)`);
+
+ if (regex.test(html)) {
+ html = html.replace(regex, () => value);
+ console.log(`DashboardHtml: Successfully replaced ${name}`);
+ } else {
+ console.error(`DashboardHtml: Placeholder ${name} NOT found! Regex: ${regex.source}`);
+ // Log start and end of HTML to debug
+ console.log('DashboardHtml Head (500 chars):', html.substring(0, 500));
+ console.log('DashboardHtml Tail (500 chars):', html.substring(html.length - 500));
+ }
+ };
+
html = html.replace('{{CSP}}', () => csp);
- html = html.replace('{{INLINE_STYLES}}', () => css);
- html = html.replace('{{INLINE_SCRIPTS}}', () => js);
+ replacePlaceholder('INLINE_STYLES', css);
+ replacePlaceholder('INLINE_SCRIPTS', js);
+
html = html.replace('{{NONCE}}', () => nonce);
+ console.log('DashboardHtml: Final HTML length:', html.length);
return html;
} catch (error) {
console.error('Failed to load dashboard templates:', error);
diff --git a/src/dashboard/DashboardPanel.ts b/src/dashboard/DashboardPanel.ts
index e8d2689..f3c84d4 100644
--- a/src/dashboard/DashboardPanel.ts
+++ b/src/dashboard/DashboardPanel.ts
@@ -136,6 +136,8 @@ export class DashboardPanel {
}
}
+
+
private async _update() {
let client;
try {
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/notebookProvider.ts b/src/notebookProvider.ts
index ed81ab4..d87951a 100644
--- a/src/notebookProvider.ts
+++ b/src/notebookProvider.ts
@@ -2,178 +2,178 @@ import { Client } from 'pg';
import * as vscode from 'vscode';
interface PostgresCell {
- kind: 'query';
- value: string;
+ kind: 'query';
+ value: string;
}
interface NotebookMetadata {
- connectionId: string;
- databaseName: string;
- host: string;
- port: number;
- username: string;
- password: string;
+ connectionId: string;
+ databaseName: string;
+ host: string;
+ port: number;
+ username: string;
+ password: string;
}
interface Cell {
- value: string;
- kind?: 'markdown' | 'sql';
- language?: 'markdown' | 'sql';
+ value: string;
+ kind?: 'markdown' | 'sql';
+ language?: 'markdown' | 'sql';
}
export class PostgresNotebookProvider implements vscode.NotebookSerializer {
- async deserializeNotebook(
- content: Uint8Array,
- _token: vscode.CancellationToken
- ): Promise {
- let metadata: NotebookMetadata | undefined;
- let cells: vscode.NotebookCellData[] = [];
-
- if (content.byteLength > 0) {
- try {
- const data = JSON.parse(Buffer.from(content).toString());
- if (data.metadata) {
- metadata = data.metadata;
- }
- if (Array.isArray(data.cells)) {
- cells = data.cells.map((cell: Cell) => {
- const isMarkdown = cell.kind === 'markdown';
- return new vscode.NotebookCellData(
- isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code,
- cell.value,
- isMarkdown ? 'markdown' : 'sql'
- );
- });
- }
- } catch {
- cells = [
- new vscode.NotebookCellData(
- vscode.NotebookCellKind.Code,
- '-- Write your SQL query here\nSELECT NOW();',
- 'sql'
- )
- ];
- }
- } else {
- cells = [
- new vscode.NotebookCellData(
- vscode.NotebookCellKind.Code,
- '-- Write your SQL query here\nSELECT NOW();',
- 'sql'
- )
- ];
+ async deserializeNotebook(
+ content: Uint8Array,
+ _token: vscode.CancellationToken
+ ): Promise {
+ let metadata: NotebookMetadata | undefined;
+ let cells: vscode.NotebookCellData[] = [];
+
+ if (content.byteLength > 0) {
+ try {
+ const data = JSON.parse(Buffer.from(content).toString());
+ if (data.metadata) {
+ metadata = data.metadata;
}
-
- const notebookData = new vscode.NotebookData(cells);
- if (metadata) {
- notebookData.metadata = {
- ...metadata,
- custom: {
- cells: [],
- metadata: {
- ...metadata,
- enableScripts: true
- }
- }
- };
+ if (Array.isArray(data.cells)) {
+ cells = data.cells.map((cell: Cell) => {
+ const isMarkdown = cell.kind === 'markdown';
+ return new vscode.NotebookCellData(
+ isMarkdown ? vscode.NotebookCellKind.Markup : vscode.NotebookCellKind.Code,
+ cell.value,
+ isMarkdown ? 'markdown' : 'sql'
+ );
+ });
}
- return notebookData;
+ } catch {
+ cells = [
+ new vscode.NotebookCellData(
+ vscode.NotebookCellKind.Code,
+ '-- Write your SQL query here\nSELECT NOW();',
+ 'sql'
+ )
+ ];
+ }
+ } else {
+ cells = [
+ new vscode.NotebookCellData(
+ vscode.NotebookCellKind.Code,
+ '-- Write your SQL query here\nSELECT NOW();',
+ 'sql'
+ )
+ ];
}
- async serializeNotebook(
- data: vscode.NotebookData,
- _token: vscode.CancellationToken
- ): Promise {
- const cells: Cell[] = data.cells.map(cell => ({
- value: cell.value,
- kind: cell.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql',
- language: cell.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql'
- }));
-
- const metadata = {
- ...data.metadata,
- custom: {
- cells: cells,
- metadata: {
- ...data.metadata,
- enableScripts: true
- }
- }
- };
-
- return Buffer.from(JSON.stringify({
- cells,
- metadata
- }));
+ const notebookData = new vscode.NotebookData(cells);
+ if (metadata) {
+ notebookData.metadata = {
+ ...metadata,
+ custom: {
+ cells: [],
+ metadata: {
+ ...metadata,
+ enableScripts: true
+ }
+ }
+ };
}
+ return notebookData;
+ }
+
+ async serializeNotebook(
+ data: vscode.NotebookData,
+ _token: vscode.CancellationToken
+ ): Promise {
+ const cells: Cell[] = data.cells.map(cell => ({
+ value: cell.value,
+ kind: cell.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql',
+ language: cell.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql'
+ }));
+
+ const metadata = {
+ ...data.metadata,
+ custom: {
+ cells: cells,
+ metadata: {
+ ...data.metadata,
+ enableScripts: true
+ }
+ }
+ };
+
+ return Buffer.from(JSON.stringify({
+ cells,
+ metadata
+ }));
+ }
}
export class PostgresNotebookController {
- readonly controllerId = 'postgres-notebook-controller';
- readonly notebookType = 'postgres-notebook';
- readonly label = 'PostgreSQL Notebook';
- readonly supportedLanguages = ['sql'];
-
- private readonly _controller: vscode.NotebookController;
- private _executionOrder = 0;
-
- constructor(private client: () => Client | undefined) {
- this._controller = vscode.notebooks.createNotebookController(
- this.controllerId,
- this.notebookType,
- this.label
- );
-
- this._controller.supportedLanguages = this.supportedLanguages;
- this._controller.supportsExecutionOrder = true;
- this._controller.executeHandler = this._execute.bind(this);
+ readonly controllerId = 'postgres-notebook-controller';
+ readonly notebookType = 'postgres-notebook';
+ readonly label = 'PostgreSQL Notebook';
+ readonly supportedLanguages = ['sql'];
+
+ private readonly _controller: vscode.NotebookController;
+ private _executionOrder = 0;
+
+ constructor(private client: () => Client | undefined) {
+ this._controller = vscode.notebooks.createNotebookController(
+ this.controllerId,
+ this.notebookType,
+ this.label
+ );
+
+ this._controller.supportedLanguages = this.supportedLanguages;
+ this._controller.supportsExecutionOrder = true;
+ this._controller.executeHandler = this._execute.bind(this);
+ }
+
+ dispose() {
+ this._controller.dispose();
+ }
+
+ private async _execute(
+ cells: vscode.NotebookCell[],
+ _notebook: vscode.NotebookDocument,
+ _controller: vscode.NotebookController
+ ): Promise {
+ const client = this.client();
+ if (!client) {
+ vscode.window.showErrorMessage('Please connect to a PostgreSQL database first');
+ return;
}
- dispose() {
- this._controller.dispose();
- }
+ for (const cell of cells) {
+ const execution = this._controller.createNotebookCellExecution(cell);
+ execution.executionOrder = ++this._executionOrder;
+ execution.start(Date.now());
- private async _execute(
- cells: vscode.NotebookCell[],
- _notebook: vscode.NotebookDocument,
- _controller: vscode.NotebookController
- ): Promise {
- const client = this.client();
- if (!client) {
- vscode.window.showErrorMessage('Please connect to a PostgreSQL database first');
- return;
- }
+ try {
+ const result = await client.query(cell.document.getText());
- for (const cell of cells) {
- const execution = this._controller.createNotebookCellExecution(cell);
- execution.executionOrder = ++this._executionOrder;
- execution.start(Date.now());
-
- try {
- const result = await client.query(cell.document.getText());
-
- // Create a JSON output for the custom renderer
- const outputData = {
- columns: result.fields.map(field => field.name),
- rows: result.rows,
- rowCount: result.rowCount,
- command: result.command
- };
-
- execution.replaceOutput([
- new vscode.NotebookCellOutput([
- vscode.NotebookCellOutputItem.json(outputData, 'application/x-postgres-result')
- ])
- ]);
- execution.end(true, Date.now());
- } catch (err) {
- execution.replaceOutput([
- new vscode.NotebookCellOutput([
- vscode.NotebookCellOutputItem.error(err as Error)
- ])
- ]);
- execution.end(false, Date.now());
- }
- }
+ // Create a JSON output for the custom renderer
+ const outputData = {
+ columns: result.fields.map(field => field.name),
+ rows: result.rows,
+ rowCount: result.rowCount,
+ command: result.command
+ };
+
+ execution.replaceOutput([
+ new vscode.NotebookCellOutput([
+ vscode.NotebookCellOutputItem.json(outputData, 'application/x-postgres-result')
+ ])
+ ]);
+ execution.end(true, Date.now());
+ } catch (err) {
+ execution.replaceOutput([
+ new vscode.NotebookCellOutput([
+ vscode.NotebookCellOutputItem.error(err as Error)
+ ])
+ ]);
+ execution.end(false, Date.now());
+ }
}
+ }
}
diff --git a/src/providers/NotebookKernel.ts b/src/providers/NotebookKernel.ts
index e460966..aac6188 100644
--- a/src/providers/NotebookKernel.ts
+++ b/src/providers/NotebookKernel.ts
@@ -11,7 +11,7 @@ import {
ExecuteUpdateBackgroundHandler, ScriptDeleteHandler, ExecuteUpdateHandler,
CancelQueryHandler, DeleteRowsHandler, SaveChangesHandler
} from '../services/handlers/QueryHandlers';
-import { ExportRequestHandler, ShowErrorMessageHandler } from '../services/handlers/CoreHandlers';
+import { ExportRequestHandler, ShowErrorMessageHandler, ImportRequestHandler } from '../services/handlers/CoreHandlers';
import { SendToChatHandler } from '../services/handlers/ExplainHandlers';
export class PostgresKernel implements vscode.Disposable {
@@ -34,7 +34,7 @@ export class PostgresKernel implements vscode.Disposable {
this.label
);
- this._controller.supportedLanguages = this.supportedLanguages;
+ // this._controller.supportedLanguages = this.supportedLanguages; // Support all languages to avoid issues with detection
this._controller.supportsExecutionOrder = true;
this._controller.executeHandler = this._executeAll.bind(this);
@@ -66,6 +66,7 @@ export class PostgresKernel implements vscode.Disposable {
registry.register('script_delete', new ScriptDeleteHandler());
registry.register('execute_update', new ExecuteUpdateHandler());
registry.register('export_request', new ExportRequestHandler());
+ registry.register('import_request', new ImportRequestHandler());
registry.register('delete_row', new DeleteRowsHandler());
registry.register('delete_rows', new DeleteRowsHandler());
registry.register('sendToChat', new SendToChatHandler(undefined));
diff --git a/src/renderer/components/table/TableRenderer.ts b/src/renderer/components/table/TableRenderer.ts
index 7b9ae8e..d781cd0 100644
--- a/src/renderer/components/table/TableRenderer.ts
+++ b/src/renderer/components/table/TableRenderer.ts
@@ -439,6 +439,8 @@ export class TableRenderer {
this.rerenderTable();
});
+ checkbox.addEventListener('paste', (e) => this.handlePaste(e, index, col));
+
td.appendChild(checkbox);
checkbox.focus();
} else if (isJsonType) {
@@ -513,6 +515,8 @@ export class TableRenderer {
}
});
+ textarea.addEventListener('paste', (e) => this.handlePaste(e, index, col));
+
} else {
const input = document.createElement('input');
input.type = 'text';
@@ -555,6 +559,7 @@ export class TableRenderer {
if (e.key === 'Enter') { e.preventDefault(); saveEdit(); }
else if (e.key === 'Escape') { e.preventDefault(); cancelEdit(); }
});
+ input.addEventListener('paste', (e) => this.handlePaste(e, index, col));
}
}
@@ -586,16 +591,77 @@ export class TableRenderer {
});
}
+ private handlePaste(e: ClipboardEvent, startIndex: number, startCol: string) {
+ const clipboardData = e.clipboardData?.getData('text');
+ if (!clipboardData) return;
+
+ // If simple single-line string without tabs, let default paste happen
+ if (!clipboardData.includes('\t') && !clipboardData.includes('\n') && !clipboardData.includes('\r')) return;
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Parse CSV/TSV
+ // Simple parser: split by newline then by tab (common for Excel/Sheets copy)
+ const rows = clipboardData.trim().split(/\r?\n/).map(r => r.split('\t'));
+
+ const colNames = this.columns;
+ const startColIdx = colNames.indexOf(startCol);
+ if (startColIdx === -1) return;
+
+ let rowsAdded = false;
+
+ rows.forEach((rowValues, rOffset) => {
+ const targetRowIdx = startIndex + rOffset;
+
+ // Expand rows if needed
+ if (targetRowIdx >= this.rows.length) {
+ this.rows.push({});
+ this.originalRows.push({}); // Maintain parallel array for diffs
+ rowsAdded = true;
+ }
+
+ rowValues.forEach((val, cOffset) => {
+ const targetColIdx = startColIdx + cOffset;
+ if (targetColIdx < colNames.length) {
+ const colName = colNames[targetColIdx];
+
+ // Handle "NULL" string as null value if preferred, or keep as string
+ // For now, keep as string/value.
+ // Trim quotes if Excel added them (Excel sometimes adds quotes for multiline/special chars)
+ let newValue: any = val;
+ if (newValue.startsWith('"') && newValue.endsWith('"')) {
+ newValue = newValue.slice(1, -1).replace(/""/g, '"');
+ }
+ if (newValue === '') newValue = null; // Empty string -> null often useful
+
+ const originalValue = this.originalRows[targetRowIdx][colName];
+ const cellKey = `${targetRowIdx}-${colName}`;
+
+ if (newValue != originalValue) {
+ this.modifiedCells.set(cellKey, { originalValue, newValue });
+ this.events.onDataChange?.(targetRowIdx, colName, newValue, originalValue);
+ }
+
+ this.rows[targetRowIdx][colName] = newValue;
+ }
+ });
+ });
+
+ this.currentlyEditingCell = null;
+ this.rerenderTable();
+ }
+
public dispose() {
// Cleanup IntersectionObserver
if (this.loadMoreObserver) {
this.loadMoreObserver.disconnect();
this.loadMoreObserver = null;
}
-
+
// Clear sentinel reference
this.loadMoreSentinel = null;
-
+
// Clear DOM references
this.tableBody = null;
this.currentlyEditingCell = null;
diff --git a/src/renderer/features/import.ts b/src/renderer/features/import.ts
new file mode 100644
index 0000000..b4d7233
--- /dev/null
+++ b/src/renderer/features/import.ts
@@ -0,0 +1,282 @@
+import { createButton } from '../components/ui';
+
+export const createImportButton = (
+ columns: string[],
+ tableInfo: any | undefined,
+ context?: { postMessage?: (msg: any) => void }
+) => {
+ const importBtn = createButton('Import', true);
+ importBtn.style.position = 'relative';
+
+ if (!tableInfo) {
+ importBtn.style.display = 'none'; // Completely hide if not a table
+ return importBtn;
+ }
+
+ importBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ showImportModal(columns, tableInfo, context);
+ });
+
+ return importBtn;
+};
+
+function showImportModal(tableColumns: string[], tableInfo: any, context?: { postMessage?: (msg: any) => void }) {
+ // Create Modal Overlay
+ const overlay = document.createElement('div');
+ overlay.style.cssText = `
+ position: fixed; top: 0; left: 0; width: 100%; height: 100%;
+ background: rgba(0, 0, 0, 0.5); z-index: 1000;
+ display: flex; justify-content: center; align-items: center;
+ `;
+
+ // Create Modal Content
+ const modal = document.createElement('div');
+ modal.style.cssText = `
+ background: var(--vscode-editor-background);
+ border: 1px solid var(--vscode-widget-border);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
+ border-radius: 6px;
+ width: 600px;
+ max-height: 80vh;
+ display: flex; flex-direction: column;
+ color: var(--vscode-editor-foreground);
+ font-family: var(--vscode-font-family);
+ `;
+
+ // Header
+ const header = document.createElement('div');
+ header.style.cssText = 'padding: 12px 16px; border-bottom: 1px solid var(--vscode-widget-border); font-weight: 600; display: flex; justify-content: space-between; align-items: center;';
+ header.innerHTML = `Import Data into ${tableInfo.schema}.${tableInfo.table}`;
+
+ const closeBtn = document.createElement('span');
+ closeBtn.innerHTML = '×';
+ closeBtn.style.cssText = 'cursor: pointer; font-size: 20px;';
+ closeBtn.onclick = () => overlay.remove();
+ header.appendChild(closeBtn);
+ modal.appendChild(header);
+
+ // Body
+ const body = document.createElement('div');
+ body.style.cssText = 'padding: 16px; overflow-y: auto; flex: 1;';
+ modal.appendChild(body);
+
+ // Step 1: File Selection
+ const dropZone = document.createElement('div');
+ dropZone.style.cssText = `
+ border: 2px dashed var(--vscode-widget-border);
+ padding: 32px; text-align: center;
+ border-radius: 4px; cursor: pointer;
+ transition: background 0.2s;
+ `;
+ dropZone.innerHTML = `
+ π
+ Click or Drag & Drop CSV/JSON file here
+ Max size recommended: 10MB
+ `;
+
+ const fileInput = document.createElement('input');
+ fileInput.type = 'file';
+ fileInput.accept = '.csv,.json,.txt';
+ fileInput.style.display = 'none';
+ body.appendChild(fileInput);
+
+ dropZone.onclick = () => fileInput.click();
+
+ dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.background = 'var(--vscode-list-hoverBackground)'; };
+ dropZone.ondragleave = () => { dropZone.style.background = 'transparent'; };
+ dropZone.ondrop = (e) => {
+ e.preventDefault();
+ dropZone.style.background = 'transparent';
+ if (e.dataTransfer?.files?.length) handleFile(e.dataTransfer.files[0]);
+ };
+
+ fileInput.onchange = (e: any) => {
+ if (e.target.files?.length) handleFile(e.target.files[0]);
+ };
+
+ body.appendChild(dropZone);
+
+ // Preview Container (Initially Hidden)
+ const previewContainer = document.createElement('div');
+ previewContainer.style.display = 'none';
+ previewContainer.style.marginTop = '16px';
+ body.appendChild(previewContainer);
+
+ // Footer (Actions)
+ const footer = document.createElement('div');
+ footer.style.cssText = 'padding: 12px 16px; border-top: 1px solid var(--vscode-widget-border); display: flex; justify-content: flex-end; gap: 8px;';
+
+ const cancelBtn = createButton('Cancel', false);
+ cancelBtn.onclick = () => overlay.remove();
+
+ const importBtn = createButton('Import', true) as HTMLButtonElement;
+ importBtn.disabled = true;
+ importBtn.style.opacity = '0.5';
+
+ footer.appendChild(cancelBtn);
+ footer.appendChild(importBtn);
+ modal.appendChild(footer);
+
+ overlay.appendChild(modal);
+ document.body.appendChild(overlay);
+
+ // Logic
+ let parsedData: any[] = [];
+ let fileColumns: string[] = [];
+ const columnMapping: Record = {}; // FileCol -> TableCol
+
+ const handleFile = (file: File) => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const content = e.target?.result as string;
+ if (!content) return;
+
+ if (file.name.endsWith('.json')) {
+ try {
+ const json = JSON.parse(content);
+ if (Array.isArray(json)) {
+ parsedData = json;
+ if (parsedData.length > 0) fileColumns = Object.keys(parsedData[0]);
+ showMappingUI();
+ } else {
+ alert('JSON file must contain an array of objects');
+ }
+ } catch (err) { alert('Invalid JSON file'); }
+ } else {
+ parsedData = parseCSV(content);
+ if (parsedData.length > 0) {
+ fileColumns = Object.keys(parsedData[0]);
+ showMappingUI();
+ }
+ }
+ };
+ reader.readAsText(file);
+ };
+
+ const showMappingUI = () => {
+ dropZone.style.display = 'none';
+ previewContainer.style.display = 'block';
+
+ // Auto-map based on name match (case-insensitive)
+ fileColumns.forEach(fCol => {
+ const match = tableColumns.find(tCol => tCol.toLowerCase() === fCol.toLowerCase());
+ if (match) columnMapping[fCol] = match;
+ });
+
+ const rowsHtml = fileColumns.map(fCol => {
+ const options = [``]
+ .concat(tableColumns.map(tCol =>
+ ``
+ )).join('');
+
+ return `
+
+ | ${fCol} |
+ β |
+
+
+ |
+
+ `;
+ }).join('');
+
+ previewContainer.innerHTML = `
+ Map Columns
+
+ Matched ${parsedData.length} rows. Map file columns to table columns.
+
+
+
+ | File Header |
+ |
+ Table Column |
+
+ ${rowsHtml}
+
+ `;
+
+ // Bind events
+ previewContainer.querySelectorAll('select').forEach((select: any) => {
+ select.onchange = (e: any) => {
+ const fCol = select.getAttribute('data-file-col');
+ const val = e.target.value;
+ if (val) columnMapping[fCol] = val;
+ else delete columnMapping[fCol];
+ };
+ });
+
+ importBtn.disabled = false;
+ importBtn.style.opacity = '1';
+ importBtn.onclick = () => {
+ const mappedData = parsedData.map(row => {
+ const newRow: any = {};
+ Object.keys(row).forEach(fCol => {
+ if (columnMapping[fCol]) {
+ newRow[columnMapping[fCol]] = row[fCol];
+ }
+ });
+ return newRow;
+ });
+
+ // Filter out empty rows (if any) or rows with no mapped keys
+ const validData = mappedData.filter(r => Object.keys(r).length > 0);
+
+ if (validData.length === 0) {
+ alert('No data to import (check column mappings)');
+ return;
+ }
+
+ importBtn.innerText = 'Importing...';
+ importBtn.disabled = true;
+
+ context?.postMessage?.({
+ type: 'import_request',
+ table: tableInfo.table,
+ schema: tableInfo.schema,
+ data: validData
+ });
+
+ setTimeout(() => overlay.remove(), 500);
+ };
+ };
+
+ function parseCSV(text: string): any[] {
+ const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
+ if (lines.length < 2) return [];
+
+ const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
+ const result = [];
+
+ for (let i = 1; i < lines.length; i++) {
+ // Simple regex-based splitter that respects quotes
+ const line = lines[i];
+ const values: string[] = [];
+ let inQuote = false;
+ let val = '';
+ for (let j = 0; j < line.length; j++) {
+ const c = line[j];
+ if (c === '"') {
+ inQuote = !inQuote;
+ } else if (c === ',' && !inQuote) {
+ values.push(val.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
+ val = '';
+ } else {
+ val += c;
+ }
+ }
+ values.push(val.trim().replace(/^"|"$/g, '').replace(/""/g, '"'));
+
+ const row: any = {};
+ headers.forEach((h, idx) => {
+ if (idx < values.length) {
+ row[h] = values[idx];
+ }
+ });
+ result.push(row);
+ }
+ return result;
+ }
+}
diff --git a/src/renderer_v2.ts b/src/renderer_v2.ts
index 7ac3c19..0dff442 100644
--- a/src/renderer_v2.ts
+++ b/src/renderer_v2.ts
@@ -2,6 +2,7 @@ import type { ActivationFunction } from 'vscode-notebook-renderer';
import { Chart, registerables } from 'chart.js';
import { createButton, createTab, createBreadcrumb, BreadcrumbSegment } from './renderer/components/ui';
import { createExportButton } from './renderer/features/export';
+import { createImportButton } from './renderer/features/import';
import { createAiButtons } from './renderer/features/ai';
import { TableRenderer, TableEvents } from './renderer/components/table/TableRenderer';
import { ChartRenderer } from './renderer/components/chart/ChartRenderer';
@@ -230,6 +231,7 @@ export const activate: ActivationFunction = context => {
deleteBtn.style.cssText = 'display: none; background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); margin-left: 8px;';
const exportBtn = createExportButton(columns, currentRows, tableInfo, context, query);
+ const importBtn = createImportButton(columns, tableInfo, context);
// Left Group
const leftActions = document.createElement('div');
@@ -237,6 +239,7 @@ export const activate: ActivationFunction = context => {
leftActions.appendChild(selectAllBtn);
leftActions.appendChild(copyBtn);
leftActions.appendChild(deleteBtn);
+ leftActions.appendChild(importBtn);
leftActions.appendChild(exportBtn);
// Right Group
@@ -531,12 +534,14 @@ export const activate: ActivationFunction = context => {
selectAllBtn.style.display = 'inline-block';
copyBtn.style.display = 'inline-block';
exportBtn.style.display = 'inline-block';
+ importBtn.style.display = tableInfo ? 'inline-block' : 'none';
exportChartBtn.style.display = 'none';
} else {
// Chart Mode: Hide Table Buttons, Show Chart Button
selectAllBtn.style.display = 'none';
copyBtn.style.display = 'none';
exportBtn.style.display = 'none'; // Hide Data Export in Chart Mode
+ importBtn.style.display = 'none';
exportChartBtn.style.display = 'inline-block';
}
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 `
+
+
+ ${hasDetails ? `
+
+ ${colsHtml ? `
` : ''}
+ ${consHtml ? `
` : ''}
+ ${idxsHtml ? `
` : ''}
+
+ ` : ''}
+
+ `;
+ };
+
+ const tablesHtml = diffs.map(renderTableDiff).join('');
+
+ return `
+
+
+
+ Schema Diff
+
+
+
+
+
+
+
π‘${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..989ca0b
--- /dev/null
+++ b/src/schemaDesigner/TableDesignerPanel.ts
@@ -0,0 +1,1579 @@
+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 || '';
+
+ // 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)) {
+ 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,
+ constraints,
+ indexes,
+ tableComment,
+ false
+ );
+
+ // Handle messages from the webview
+ panel.webview.onDidReceiveMessage(async (message) => {
+ switch (message.type) {
+ case 'applyChanges': {
+ await TableDesignerPanel._applyChanges(
+ message.original,
+ message.modified,
+ message.constraints || [],
+ message.indexes || [],
+ 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,
+ [], // constraints
+ [], // indexes
+ '',
+ 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[],
+ 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]));
+
+ // 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[],
+ 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 = [
+ '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
+
+
+
+
+
+
+
+
+ ${isCreate ? `
+
Table Properties
+
+ ` : `
+
+ βΉοΈ Edit mode: Modify columns below. Changes generate safe ALTER TABLE statements for review before execution.
+
+ `}
+
+
Columns
+
+
+
+ |
+ Column Name |
+ Data Type |
+ Not Null |
+ Default |
+ PK |
+ UQ |
+ Comment |
+ |
+
+
+
+
+
+
+
+
+
+
+
Constraints
+
Manage Foreign Keys, Check constraints, etc. PK/Unique on single columns are managed in the Columns tab.
+
+
+
+ | Name |
+ Type |
+ Definition |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
Indexes
+
+
+
+ | Name |
+ Definition |
+ Unique |
+ Primary |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
Add Index
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Add Constraint
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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(),
+ };
+}
diff --git a/src/services/handlers/CoreHandlers.ts b/src/services/handlers/CoreHandlers.ts
index 6b2d9a7..e65de50 100644
--- a/src/services/handlers/CoreHandlers.ts
+++ b/src/services/handlers/CoreHandlers.ts
@@ -51,6 +51,85 @@ export class ShowDatabaseSwitcherHandler implements IMessageHandler {
}
}
+export class ImportRequestHandler implements IMessageHandler {
+ async handle(message: any, context: { editor: vscode.NotebookEditor }) {
+ if (!context.editor) return;
+
+ const metadata = context.editor.notebook.metadata;
+ const connectionId = metadata?.connectionId;
+ if (!connectionId) {
+ vscode.window.showErrorMessage('No active connection found for this notebook.');
+ return;
+ }
+
+ const connection = ConnectionUtils.findConnection(connectionId);
+ if (!connection) {
+ vscode.window.showErrorMessage('Connection configuration not found.');
+ return;
+ }
+
+ const { table, schema, data } = message;
+ if (!data || !Array.isArray(data) || data.length === 0) {
+ vscode.window.showWarningMessage('No data received for import.');
+ return;
+ }
+
+ const client = await import('../../services/ConnectionManager').then(m => m.ConnectionManager.getInstance().getPooledClient(connection));
+
+ try {
+ await client.query('BEGIN');
+
+ // Batch insert logic
+ const BATCH_SIZE = 100;
+ const columns = Object.keys(data[0]);
+ const quotedColumns = columns.map(c => `"${c}"`).join(', ');
+ const tableName = `"${schema}"."${table}"`;
+
+ let insertedCount = 0;
+
+ for (let i = 0; i < data.length; i += BATCH_SIZE) {
+ const batch = data.slice(i, i + BATCH_SIZE);
+ const values: any[] = [];
+ const placeholders: string[] = [];
+
+ batch.forEach((row, rowIndex) => {
+ const rowPlaceholders: string[] = [];
+ columns.forEach((col, colIndex) => {
+ rowPlaceholders.push(`$${values.length + 1}`);
+ values.push(row[col] ?? null);
+ });
+ placeholders.push(`(${rowPlaceholders.join(', ')})`);
+ });
+
+ const query = `INSERT INTO ${tableName} (${quotedColumns}) VALUES ${placeholders.join(', ')}`;
+ await client.query(query, values);
+ insertedCount += batch.length;
+ }
+
+ await client.query('COMMIT');
+ vscode.window.showInformationMessage(`Successfully imported ${insertedCount} rows into ${schema}.${table}.`);
+
+ // Notify renderer to refresh?
+ // Actually we just show message. The user might need to re-run query to see data.
+ // We could trigger a re-run if we had access to the cell, but we are in a handler.
+
+ } catch (err: any) {
+ await client.query('ROLLBACK');
+ vscode.window.showErrorMessage(`Import failed: ${err.message}`);
+ console.error('Import error:', err);
+ } finally {
+ client.release();
+ }
+ }
+
+ private rowsToCsv(rows: any[], columns: string[]): string {
+ // ... imported from somewhere else? or duplicate?
+ // Accessing private method from another class is impossible.
+ // ExportRequestHandler has rowsToCsv. ImportRequestHandler doesn't need it.
+ return '';
+ }
+}
+
export class ExportRequestHandler implements IMessageHandler {
async handle(message: any) {
// This logic was in NotebookKernel previously
diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html
index 62dc4c6..3752f17 100644
--- a/templates/dashboard/index.html
+++ b/templates/dashboard/index.html
@@ -8,7 +8,7 @@
Dashboard
@@ -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
+
Settings
+
+
+
+
+
+
+
+
+
DB Health
+
+
+ Healthy
+
+
+
-
+
+
+
+
Active Load
+
+ ...
+
+
+ No waits
+
-
-
-
-
Active Load
-
- ...
+
+
-
- No waits
+
+
+
+
Throughput (TPS)
+
+ 0
+
+
+
+
+
-
-
-
-
Blocking Locks
-
-
-
-
Throughput (TPS)
-
-
0
-
+
+
+
+
+ Connections
+ History
+
+
-
-
-
-
-
-
-
-
-
Connections
-
-
History
+
+
+
+
Checkpoints (Req/Timed)
+
+
+
+
+
Tuple Activity (Fetch/Ret)
+
-
-
-
-
-
-
-
-
-
-
Active Queries
- View All →
-
+
+
+
-
-
-
-
- | PID |
- User |
- Duration |
- Start Time |
- Query |
- Actions |
-
-
-
-
-
-
+
+
+
+
+ | PID |
+ User |
+ Duration |
+ Start Time |
+ Query |
+ Actions |
+
+
+
+
+
+
+
-
-
-
-
Locks & Blocking
-
-
-
-
-
- | Blocker (PID) |
- Waited By (PID) |
- Object |
- Mode |
-
-
-
-
-
-
+
+
+
+
+
No blocking locks detected
+
Your database is running smoothly with no transaction conflicts.
+
+
+
+
@@ -173,7 +192,7 @@
- {{INLINE_SCRIPTS}}
+ /* INLINE_SCRIPTS */