From 916a37e4fe8fb4038579dab2379a8db4dcc2baaa Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sat, 18 Apr 2026 18:14:17 +0530 Subject: [PATCH 1/8] feat: add support for prefers-reduced-motion in UI - Introduced `prefersReducedMotion` utility function to handle reduced motion preferences. - Updated CSS across various templates to respect user motion preferences. - Added new database URL utilities for parsing and extracting PostgreSQL connection information from environment variables. - Implemented WAL & Replication tab in the dashboard with relevant tables and data. - Enhanced accessibility by adding ARIA roles to message usage elements. - Updated TypeScript configuration for module path aliases. - Added walkthroughs for connection management, database exploration, and notebook usage. - Refactored Webpack configuration to point to the correct entry file for the renderer. --- .gitignore | 2 + .../rebranding-sql-support-expansion.md | 190 +++ eslint.config.js | 4 +- package.json | 208 ++- src/activation/commandRegistry.ts | 51 + src/activation/commandSpecs.ts | 1461 +++++++++++++++++ src/activation/commands.ts | 1431 +--------------- src/activation/providers.ts | 103 +- src/activation/statusBar.ts | 52 +- src/commands/connection.ts | 42 + .../importConnectionFromDatabaseUrl.ts | 122 ++ src/commands/listenNotify.ts | 96 ++ src/commands/notebookExport.ts | 175 ++ src/commands/pgCron.ts | 105 ++ src/commands/phase7.ts | 8 +- src/commands/rlsPolicies.ts | 41 + src/commands/schemaDesigner.ts | 116 ++ src/commands/schemaSearch.ts | 325 +++- src/commands/sql/index.ts | 2 + src/commands/sql/pgCron.ts | 59 + src/commands/sql/policies.ts | 11 + src/commands/workspaceConnection.ts | 51 + src/common/types.ts | 4 + src/core/connection/cloudAuth/types.ts | 8 + src/core/types/handlerMessages.ts | 16 + src/core/types/index.ts | 1 + src/dashboard/DashboardData.ts | 239 ++- src/extension.ts | 216 ++- .../aiAssistant/settings}/aiSettingsPanel.ts | 2 +- src/features/analyst/coerceNumeric.ts | 20 + src/features/analyst/columnAggregates.ts | 135 ++ src/features/analyst/constants.ts | 8 + src/features/analyst/histogram.ts | 102 ++ src/features/analyst/index.ts | 6 + src/features/analyst/pgNumeric.ts | 30 + src/features/analyst/pivot.ts | 159 ++ .../connections}/ProfileManager.ts | 4 +- .../connections}/connectionForm.ts | 110 +- .../connections}/connectionManagement.ts | 2 +- src/features/migrations/detectFramework.ts | 24 + src/features/notebook/notebookExportHtml.ts | 204 +++ .../notebook}/notebookProvider.ts | 0 .../notebook}/postgresNotebook.ts | 0 .../savedQueries}/SaveQueryPanel.ts | 13 +- .../savedQueries}/SavedQueriesService.ts | 2 + .../savedQueries}/SavedQueryDetailsPanel.ts | 2 +- src/features/schemaDiff/SchemaDiffEngine.ts | 240 +++ src/features/schemaDiff/schemaDiffTypes.ts | 65 + .../tables/properties}/tableProperties.ts | 0 src/providers/DatabaseTreeProvider.ts | 212 ++- src/providers/ListenNotifyPanel.ts | 23 +- src/providers/Phase7TreeProviders.ts | 4 +- src/providers/chat/AiService.ts | 85 +- src/providers/kernel/SqlExecutor.ts | 196 ++- src/providers/kernel/SqlParser.ts | 331 ++++ .../components/analyst/AnalystPanel.ts | 307 ++++ .../components/table/TableRenderer.ts | 260 ++- src/schemaDesigner/ErdPanel.ts | 77 +- src/schemaDesigner/SchemaDiffPanel.ts | 266 +-- src/services/DdlViewerService.ts | 6 +- src/services/SecretStorageService.ts | 13 + src/services/WorkspaceStateService.ts | 66 + src/services/handlers/CoreHandlers.ts | 6 + src/test/unit/AnalystTools.test.ts | 87 + src/test/unit/NotebookStatusBar.test.ts | 12 +- src/test/unit/ProfileManager.test.ts | 2 +- src/test/unit/SavedQueriesService.test.ts | 2 +- src/test/unit/SchemaDiffEngine.test.ts | 133 ++ src/test/unit/SqlParser.test.ts | 142 ++ src/test/unit/SqlTemplates.test.ts | 18 + src/test/unit/TableRenderer.test.ts | 39 + src/test/unit/databaseUrl.test.ts | 57 + .../unit/handlers/FkLookupHandler.test.ts | 104 ++ src/test/unit/handlers/messaging.test.ts | 47 +- src/test/unit/notebookExportHtml.test.ts | 14 + src/test/unit/schemaSearch.test.ts | 17 + src/ui/renderer/rendererConstants.ts | 5 + src/{ => ui/renderer}/renderer_v2.ts | 71 +- src/ui/theme/motion.ts | 11 + src/utils/databaseUrl.ts | 67 + src/utils/envFileDatabaseUrls.ts | 51 + templates/ai-settings/styles.css | 11 + templates/chat/scripts.js | 4 + templates/chat/styles.css | 22 +- templates/connection-form/styles.css | 11 + templates/dashboard/index.html | 77 + templates/dashboard/scripts.js | 140 ++ templates/dashboard/styles.css | 11 + tsconfig.json | 7 + walkthroughs/step-connection.md | 7 + walkthroughs/step-explorer.md | 7 + walkthroughs/step-notebook.md | 5 + webpack.config.js | 2 +- 93 files changed, 7260 insertions(+), 2042 deletions(-) create mode 100644 docs/roadmap/rebranding-sql-support-expansion.md create mode 100644 src/activation/commandRegistry.ts create mode 100644 src/activation/commandSpecs.ts create mode 100644 src/commands/importConnectionFromDatabaseUrl.ts create mode 100644 src/commands/listenNotify.ts create mode 100644 src/commands/notebookExport.ts create mode 100644 src/commands/pgCron.ts create mode 100644 src/commands/rlsPolicies.ts create mode 100644 src/commands/sql/pgCron.ts create mode 100644 src/commands/sql/policies.ts create mode 100644 src/commands/workspaceConnection.ts create mode 100644 src/core/connection/cloudAuth/types.ts create mode 100644 src/core/types/handlerMessages.ts create mode 100644 src/core/types/index.ts rename src/{ => features/aiAssistant/settings}/aiSettingsPanel.ts (99%) create mode 100644 src/features/analyst/coerceNumeric.ts create mode 100644 src/features/analyst/columnAggregates.ts create mode 100644 src/features/analyst/constants.ts create mode 100644 src/features/analyst/histogram.ts create mode 100644 src/features/analyst/index.ts create mode 100644 src/features/analyst/pgNumeric.ts create mode 100644 src/features/analyst/pivot.ts rename src/{services => features/connections}/ProfileManager.ts (98%) rename src/{ => features/connections}/connectionForm.ts (91%) rename src/{ => features/connections}/connectionManagement.ts (99%) create mode 100644 src/features/migrations/detectFramework.ts create mode 100644 src/features/notebook/notebookExportHtml.ts rename src/{ => features/notebook}/notebookProvider.ts (100%) rename src/{ => features/notebook}/postgresNotebook.ts (100%) rename src/{ => features/savedQueries}/SaveQueryPanel.ts (98%) rename src/{services => features/savedQueries}/SavedQueriesService.ts (98%) rename src/{ => features/savedQueries}/SavedQueryDetailsPanel.ts (99%) create mode 100644 src/features/schemaDiff/SchemaDiffEngine.ts create mode 100644 src/features/schemaDiff/schemaDiffTypes.ts rename src/{ => features/tables/properties}/tableProperties.ts (100%) create mode 100644 src/renderer/components/analyst/AnalystPanel.ts create mode 100644 src/services/WorkspaceStateService.ts create mode 100644 src/test/unit/AnalystTools.test.ts create mode 100644 src/test/unit/SchemaDiffEngine.test.ts create mode 100644 src/test/unit/databaseUrl.test.ts create mode 100644 src/test/unit/handlers/FkLookupHandler.test.ts create mode 100644 src/test/unit/notebookExportHtml.test.ts create mode 100644 src/test/unit/schemaSearch.test.ts create mode 100644 src/ui/renderer/rendererConstants.ts rename src/{ => ui/renderer}/renderer_v2.ts (94%) create mode 100644 src/ui/theme/motion.ts create mode 100644 src/utils/databaseUrl.ts create mode 100644 src/utils/envFileDatabaseUrls.ts create mode 100644 walkthroughs/step-connection.md create mode 100644 walkthroughs/step-explorer.md create mode 100644 walkthroughs/step-notebook.md diff --git a/.gitignore b/.gitignore index bcbbe6a..72446f9 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,5 @@ docs/.next/ .nycrc roadmap.md FIXES_APPLIED.md +.cursor +review-the-current-codebase-shimmering-bee.md diff --git a/docs/roadmap/rebranding-sql-support-expansion.md b/docs/roadmap/rebranding-sql-support-expansion.md new file mode 100644 index 0000000..7a2ff7a --- /dev/null +++ b/docs/roadmap/rebranding-sql-support-expansion.md @@ -0,0 +1,190 @@ +# DbStudio: Expanding PgStudio to a Multi-Database Extension + +## Context + +PgStudio today is a single-database VS Code extension (`ric-v.postgres-explorer`) built end-to-end around PostgreSQL: the `pg` driver is imported in 23 files, all 28 SQL templates use PG dialect (mix of `information_schema` and `pg_catalog`), the tree provider exposes ~40 object types (~10 of which are PG-only: publications, subscriptions, tablespaces, event-triggers, pgcron, FDWs), and `package.json` ships ~494 command IDs, 25 config keys, 2 notebook types, a `.pgsql` file extension, a `postgres` language ID, and a `postgres-explorer` activity-bar container — all hard-branded PostgreSQL. + +The goal: rebrand to **DbStudio**, a single extension where the user picks a database type per connection. MySQL/MariaDB lands first, followed by SQLite, MSSQL, Oracle, and other common SQL engines, each targeting near-full feature parity. Per-DB features that have no equivalent (LISTEN/NOTIFY, publications, tablespaces, pgcron) stay gated to their engine. + +The surprise finding from exploration: **~70% of the code is already near-DB-agnostic** (UI/webviews, AI assistant, saved queries, notebook serializer, secret storage, activation scaffolding, test utils). The coupling is concentrated in four surfaces — driver calls, SQL templates, tree introspection queries, and the `package.json` manifest. + +--- + +## Recommended Approach: Driver-Adapter Architecture + +Introduce a `DbDriver` abstraction and a `DbDialect` abstraction. All call sites that today import `pg` go through the driver; all code that writes SQL goes through the dialect. Drivers/dialects are registered per `DbEngine` (`postgres | mysql | sqlite | mssql | oracle | …`). + +### Core interfaces (new: `src/core/db/`) + +```ts +// src/core/db/DbEngine.ts +export type DbEngine = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'oracle'; + +// src/core/db/DbDriver.ts +export interface DbDriver { + readonly engine: DbEngine; + connect(config: ConnectionConfig): Promise; + releaseAll(): Promise; +} +export interface DbClient { + query(sql: string, params?: any[]): Promise>; + stream?(sql: string, params?: any[]): AsyncIterable; + close(): Promise; +} + +// src/core/db/DbDialect.ts — all dialect-variable SQL lives behind this +export interface DbDialect { + readonly engine: DbEngine; + identifier(name: string): string; // quoting + limitClause(n: number): string; // LIMIT / TOP / ROWNUM + introspect: IntrospectionProvider; // lists schemas/tables/cols/idx/fks + explain(sql: string): string; // per-engine EXPLAIN + capabilities: FeatureFlags; // supportsSchemas, supportsListenNotify, … +} + +// src/core/db/registry.ts +export function getDriver(engine: DbEngine): DbDriver; +export function getDialect(engine: DbEngine): DbDialect; +``` + +`ConnectionConfig` gains an `engine: DbEngine` field. `SecretStorageService` and `ProfileManager` already work with a generic config — minimal change. + +### Phased migration + +**Phase 0 — Abstraction skeleton (1 week)** +- Create `src/core/db/` with interfaces above. +- Build a thin default registry with only the `postgres` driver/dialect registered. +- Wire `ConnectionManager` to delegate to `getDriver(config.engine)` instead of calling `new Pool(...)` directly. Keep existing PG behavior identical. +- Move pg-specific helpers (`resolvePgPassPassword`, SSL fallback for `ECONNRESET`/`EPROTO` at `ConnectionManager.ts:93-112`, `SET default_transaction_read_only` at line 131) into `PostgresDriver`. +- **Exit criterion:** full test suite still green; no behavior change; all `import { Pool, Client } from 'pg'` removed from everywhere except `PostgresDriver.ts`. + +**Phase 1 — Rebrand manifest (1 week, parallelizable with Phase 0)** +- Rename extension: `postgres-explorer` → `dbstudio`, display name `PgStudio (PostgreSQL Explorer)` → `DbStudio`, publisher stays `ric-v`. +- Command namespace migration: `postgres-explorer.*` → `dbstudio.*` for all 494 commands. Keep old IDs as deprecated aliases for one release cycle (users have keybindings). +- Config keys: `postgresExplorer.*` → `dbstudio.*` with a one-shot migration on activation that copies old settings forward. Register both namespaces as readable; only write to the new one. +- Activity bar container: `postgres-explorer` → `dbstudio`; keep the view IDs stable where possible to avoid breaking layouts. +- Language registration: keep `postgres` language for `.pgsql` files; add new generic `sql` fallback and future per-engine languages (`mysql`, `tsql`) as the drivers land. +- Notebooks: keep `postgres-notebook` / `postgres-query` working. Add a new generic `dbstudio-notebook` that carries the engine in notebook metadata. Old notebook files keep opening via the legacy controllers, routed to the postgres engine by default. +- Update icon/keywords/README; leave the PostgreSQL elephant in place for the PG-specific assets and add a neutral DbStudio mark. +- **Exit criterion:** extension installs under new ID, existing users get silent config/notebook migration, no regression on PG workflows. + +**Phase 2 — Dialect extraction for PostgreSQL (2 weeks)** +- Move all 28 files under `src/commands/sql/` behind `PostgresDialect`. The public API that `src/commands/{domain}.ts` consumes becomes dialect-agnostic: instead of `import { TableSQL } from './sql'`, call `getDialect(engine).tables.dropTable(schema, table)`. +- Convert `DatabaseTreeProvider.ts` introspection queries (lines 697, 715, 1031, 1072, 1195-1204 are the worst offenders, plus the 40+ object-type switch at line 1402) to go through `dialect.introspect.*`. Object types gated by `dialect.capabilities` (e.g. `publication` node only renders when `capabilities.supportsLogicalReplication === true`). +- `src/commands/schemaSearch.ts`: replace hard-coded `information_schema.*` + `pg_sequences`/`pg_triggers` queries with `dialect.introspect.search(term)`. +- AI assistant (`src/providers/chat/AiService.ts` lines 86-100): parameterize the "PostgreSQL database assistant" prompt to `buildSystemPrompt(engine)` and have each dialect contribute the engine-specific addendum (SQL flavor hints, EXPLAIN syntax, system-catalog names). `DbObjectService.ts` routes through `dialect.introspect` for @-mentions. +- `src/providers/kernel/SqlExecutor.ts`: the auto-LIMIT at lines 95-100 becomes `dialect.limitClause(n)`. EXPLAIN handlers in `src/services/handlers/ExplainHandlers.ts` delegate to `dialect.explain(sql)`. +- **Exit criterion:** PG test matrix (unit + integration against PG 12-17 via `make test-full`) stays green. No file outside `src/core/db/drivers/postgres/` imports from `pg`. + +**Phase 3 — MySQL driver + dialect (3 weeks)** +- Add `mysql2/promise` to dependencies. Implement `MySqlDriver` (pool, streaming via `.stream()`, SSH tunnel reuses the existing generic tunnel code in `ConnectionConfig`). +- Implement `MySqlDialect`: + - introspection via `information_schema` (schemas = databases, no `pg_catalog` equivalent), + - `LIMIT` / backtick quoting / no `RETURNING` clause (rewrite INSERT/UPDATE/DELETE templates in `tables.ts` to fetch-after-write), + - `EXPLAIN FORMAT=JSON` for `ExplainVisualizer`, + - capability flags: `supportsSchemas=false` (MySQL conflates schema/database), `supportsListenNotify=false`, `supportsPublications=false`. +- Connection form (`src/features/connections/connectionForm.ts`) gets an engine picker; PG-only fields (`pgpass` file) hidden when `engine !== 'postgres'`. +- Tree provider hides nodes whose capabilities aren't supported (publications, tablespaces, event-triggers, pgcron, FDWs, rules). +- Schema designer (`src/schemaDesigner/*`) already delegates through `ConnectionManager` — only type-picker widget and ERD column-type list need a per-dialect type catalog (`dialect.types.list()`). +- Notebook controller label reads engine from connection metadata; result MIME type generalizes to `application/x-dbstudio-result`. +- Integration tests: add `docker-compose` services for MySQL 5.7, 8.0, 8.4 mirroring the existing PG 12-17 matrix in `Makefile`. Credentials `testuser`/`testpass`, DB `testdb`. +- **Exit criterion:** MySQL connection + browse + notebook + saved queries + AI assistant + export + schema designer working; `make test-full` green for PG and MySQL. + +**Phase 4 — SQLite (2 weeks)** +- `better-sqlite3` (sync, simpler) or `sqlite3` (async). Choose `better-sqlite3` — VS Code ships compatible Node, simpler API, negligible perf penalty at extension scale. +- `SqliteDialect` introspection uses `sqlite_master` / `PRAGMA table_info(...)`, `PRAGMA foreign_key_list(...)`, `PRAGMA index_list(...)`. No schemas, no roles, no users. +- Connection form: file-picker for `.db`/`.sqlite`/`.sqlite3` paths instead of host/port. `ConnectionConfig.host` becomes optional. +- Capabilities gate most of the left-tree (no roles, no tablespaces, no extensions, no FDWs, no publications, no subscriptions, no event triggers, no cron jobs). +- Single-file ergonomics: right-click `.sqlite` in Explorer → "Open with DbStudio" command. +- **Exit criterion:** SQLite CRUD + tree + notebook + AI + export green in integration tests. + +**Phase 5 — MSSQL (3 weeks)** +- `mssql` (tedious) dependency. Windows / Kerberos / SQL auth support. +- `MssqlDialect`: `sys.*` catalog (`sys.tables`, `sys.columns`, `sys.indexes`, `sys.foreign_keys`), T-SQL quirks (`TOP n` vs `LIMIT`, `[bracketed]` identifiers, `sp_help`, `EXEC` for procs). +- `EXPLAIN` → `SET SHOWPLAN_XML ON` + parse XML for the visualizer. +- Capabilities: supports schemas + linked servers (surface as FDW-equivalent?) + SQL Agent jobs (surface as pgcron-equivalent?). Defer those to a "parity sweep" milestone. +- **Exit criterion:** same bar as MySQL. + +**Phase 6 — Oracle (4 weeks)** +- `oracledb` (instant client native bindings — note native-dep packaging impact on `.vsix` size and platform matrix). Document install requirements in README. +- `OracleDialect`: `ALL_*` / `USER_*` / `DBA_*` data dictionary views, PL/SQL, `VARCHAR2` / `NUMBER` / `CLOB` type system, schemas-as-users, no booleans. +- EXPLAIN via `EXPLAIN PLAN FOR ... ; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY())`. +- Per-engine notebook cell hint: `/` terminator for PL/SQL blocks. +- **Exit criterion:** same bar as MySQL. + +**Phase 7 — Parity sweep + additional engines (open-ended)** +- CockroachDB, Redshift, Snowflake, DB2, etc. plug in as new `DbDialect + DbDriver` pairs with capability flags tuning which UI affordances light up. +- At this point adding a new engine should be a ~1-2 week effort, since all integration points are abstracted. + +--- + +## Critical files to modify + +**New files (core abstraction):** +- `src/core/db/DbEngine.ts`, `DbDriver.ts`, `DbDialect.ts`, `registry.ts`, `capabilities.ts`, `introspection/IntrospectionProvider.ts` +- `src/core/db/drivers/postgres/` — relocate PG-specific helpers (pgpass, SSL fallback, readonly-set SQL) +- `src/core/db/drivers/mysql/`, `sqlite/`, `mssql/`, `oracle/` — one per phase + +**Modified in Phase 0-2 (PG still works, just goes through abstraction):** +- `src/services/ConnectionManager.ts` — delegate to `getDriver(engine)`, remove direct `pg` imports +- `src/services/TransactionManager.ts`, `src/services/StreamingQueryService.ts` — accept `DbClient` instead of `PoolClient` +- `src/providers/DatabaseTreeProvider.ts` (1,749 lines) — introspection through `dialect.introspect`, object-type switch at line 1402 gated by capabilities +- `src/providers/Phase7TreeProviders.ts` — same pattern +- `src/providers/kernel/SqlExecutor.ts` — limit clause via dialect, review prompt unchanged +- `src/commands/sql/*.ts` (28 files) — become `PostgresDialect` internals +- `src/commands/*.ts` (~13 files importing `pg`) — use `DbClient` from connection, remove direct driver types +- `src/commands/schemaSearch.ts` — via `dialect.introspect.search` +- `src/providers/chat/AiService.ts` (lines 86-100 system prompt), `DbObjectService.ts` — parameterize by engine +- `src/services/handlers/ExplainHandlers.ts`, `src/providers/ExplainProvider.ts`, `src/providers/ExplainVisualizer.ts` — dialect-driven EXPLAIN +- `src/providers/ListenNotifyPanel.ts` — stays PG-only, gated by `capabilities.supportsListenNotify` +- `src/features/connections/connectionForm.ts` — engine picker, conditional fields +- `src/features/notebook/notebookProvider.ts` (line 113 controller ID, line 115 label, line 144 error), `postgresNotebook.ts` (line 15 language), `notebookExportHtml.ts` (lines 47-48 MIME) — generalize +- `package.json` — rename extension identity, command namespace, config keys, activity bar, add new drivers +- `README.md`, `CHANGELOG.md`, marketplace icons — rebrand + +**Reusable as-is (no changes expected):** +- `src/features/savedQueries/*` — already engine-agnostic +- `src/services/SecretStorageService.ts`, `src/features/connections/ProfileManager.ts` — generic +- `src/ui/renderer/*`, `src/renderer/components/table/TableRenderer.ts`, `templates/dashboard/*` — consume `QueryResults` which is already driver-agnostic +- `src/services/handlers/CoreHandlers.ts`, `QueryHandlers.ts`, `MessageHandlerRegistry`, `src/common/htmlStyles.ts`, `notebookTemplates.ts` +- `src/test/unit/mocks/vscode.ts`, `src/test/setup.ts` + +--- + +## Effort estimate + +| Phase | Scope | Engineer-weeks | +|---|---|---| +| 0 | Abstraction skeleton, PG passthrough | 1 | +| 1 | Manifest rebrand + migration shims | 1 | +| 2 | PG dialect extraction | 2 | +| 3 | MySQL driver + dialect + integration tests | 3 | +| 4 | SQLite | 2 | +| 5 | MSSQL | 3 | +| 6 | Oracle | 4 | +| **Total to Oracle** | | **~16 weeks** | +| Each additional engine after Phase 6 | | ~1-2 weeks | + +Parallelizable if more than one engineer: Phases 0+1 can run concurrently; Phases 3-6 can each run in parallel once Phase 2 lands. + +**Answer to "can it be one extension or separate?":** One extension. The 70% reuse number makes separate extensions actively wasteful — every UI, AI, notebook, and saved-query change would have to be ported N ways. The one-time rebrand cost (~2 weeks in Phases 0-1) is much smaller than the ongoing divergence cost of N forks. Per-engine features stay healthy via capability flags, not code duplication. + +--- + +## Verification + +**Per phase:** +- `npm run test:unit` — no regression in mocked tests +- `npm run test:integration` with matching Docker services (`make docker-up` extended for MySQL/MSSQL/Oracle/SQLite-in-memory) +- `npm run test:renderer` — jsdom suite unchanged (UI is engine-agnostic) +- Manual smoke in Extension Development Host (F5): connect to each supported engine, browse tree, run notebook cell, save query, invoke AI assistant, export result grid + +**Phase 1 specifically — migration safety:** +- Install old PgStudio, create connections + saved queries + notebooks, upgrade to DbStudio build, verify connections/saves/notebooks still appear and work +- Verify old command IDs still invoke (deprecated-alias path) for one release +- Verify old `postgresExplorer.*` config values are read and copied to `dbstudio.*` + +**Phase 3+ per new engine:** +- Integration test suite mirroring the PG matrix: CRUD, tree navigation, schema introspection parity, notebook execution, EXPLAIN visualization, saved queries, AI-generated SQL round-trip, export CSV/JSON +- Capability-gated features: assert PG-only features (publications, LISTEN/NOTIFY, tablespaces, event triggers, pgcron) don't render in the tree for non-PG connections +- Docker Compose: extend `Makefile`'s `docker-up` to include MySQL 5.7/8.0/8.4, MSSQL 2019/2022, Oracle XE 21c, and use `:memory:` for SQLite diff --git a/eslint.config.js b/eslint.config.js index 1f6d8a4..cb1116d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,8 +27,8 @@ module.exports = [ }, rules: { ...prettierConfig.rules, - '@typescript-eslint/no-empty-function': 'off', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', 'no-unused-expressions': 'off', }, }, diff --git a/package.json b/package.json index cc2cf00..14cc660 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,11 @@ "title": "Add PostgreSQL Connection", "icon": "$(add)" }, + { + "command": "postgres-explorer.importConnectionFromDatabaseUrl", + "title": "Import Connection from DATABASE_URL (.env)", + "icon": "$(file-symlink-file)" + }, { "command": "postgres-explorer.refreshConnections", "title": "Refresh Connections", @@ -73,6 +78,11 @@ "title": "Edit Connection", "icon": "$(edit)" }, + { + "command": "postgres-explorer.duplicateConnection", + "title": "Duplicate Connection", + "icon": "$(copy)" + }, { "command": "postgres-explorer.addToFavorites", "title": "Add to Favorites", @@ -109,6 +119,12 @@ "category": "PgStudio", "icon": "$(list-unordered)" }, + { + "command": "postgres-explorer.exportNotebook", + "title": "Export Notebook (HTML / Gist)", + "category": "PgStudio", + "icon": "$(export)" + }, { "command": "postgres-explorer.notebooks.open", "title": "Open Notebook", @@ -594,6 +610,16 @@ "title": "Show Database Dashboard", "icon": "$(graph)" }, + { + "command": "postgres-explorer.openListenNotify", + "title": "LISTEN/NOTIFY Monitor", + "icon": "$(broadcast)" + }, + { + "command": "postgres-explorer.openListenNotifyFromPalette", + "title": "LISTEN/NOTIFY Monitor: Choose Connection & Database…", + "icon": "$(broadcast)" + }, { "command": "postgres-explorer.showSchemaProperties", "title": "Properties", @@ -956,6 +982,12 @@ "icon": "$(person)", "category": "PgStudio: Profiles" }, + { + "command": "postgres-explorer.switchWorkspaceDefaultConnection", + "title": "Set Workspace Default PostgreSQL Connection", + "icon": "$(root-folder)", + "category": "PgStudio" + }, { "command": "postgres-explorer.createConnectionProfile", "title": "Create Connection Profile", @@ -1064,6 +1096,12 @@ "icon": "$(diff)", "category": "PgStudio: Schema" }, + { + "command": "postgres-explorer.openSchemaDiffFromPalette", + "title": "Schema Diff: Choose Connection & Schema…", + "icon": "$(diff)", + "category": "PgStudio: Schema" + }, { "command": "postgres-explorer.openErd", "title": "View ERD (Entity-Relationship Diagram)", @@ -1111,6 +1149,12 @@ "icon": "$(trash)", "category": "PgStudio" }, + { + "command": "postgres-explorer.dropPolicy", + "title": "Drop RLS Policy", + "icon": "$(trash)", + "category": "PgStudio" + }, { "command": "postgres-explorer.enableTrigger", "title": "Enable Trigger", @@ -1280,6 +1324,36 @@ "icon": "$(notebook)", "category": "PgStudio" }, + { + "command": "postgres-explorer.listCronJobs", + "title": "List pg_cron Jobs", + "icon": "$(list-unordered)", + "category": "PgStudio" + }, + { + "command": "postgres-explorer.installPgCron", + "title": "Install pg_cron Extension", + "icon": "$(cloud-download)", + "category": "PgStudio" + }, + { + "command": "postgres-explorer.scheduleCronJob", + "title": "Schedule New pg_cron Job", + "icon": "$(add)", + "category": "PgStudio" + }, + { + "command": "postgres-explorer.showCronJobProperties", + "title": "Cron Job Details", + "icon": "$(info)", + "category": "PgStudio" + }, + { + "command": "postgres-explorer.unscheduleCronJob", + "title": "Unschedule Cron Job", + "icon": "$(trash)", + "category": "PgStudio" + }, { "command": "postgres-explorer.listRules", "title": "List Rules", @@ -1461,6 +1535,51 @@ } ] }, + "walkthroughs": [ + { + "id": "pgstudioWelcome", + "title": "PgStudio: PostgreSQL in VS Code", + "description": "Connect to a database, explore objects, and run SQL in notebooks.", + "steps": [ + { + "id": "pgstudioWelcome.addConnection", + "title": "Add a database connection", + "description": "Save your server and credentials securely, then connect.\n[Add PostgreSQL Connection](command:postgres-explorer.addConnection)", + "media": { + "markdown": "walkthroughs/step-connection.md", + "altText": "Add a PostgreSQL connection" + }, + "completionEvents": [ + "onCommand:postgres-explorer.addConnection" + ] + }, + { + "id": "pgstudioWelcome.openExplorer", + "title": "Open the PG Studio explorer", + "description": "Browse databases, schemas, and objects from the sidebar.\n[Show PG Studio](command:workbench.view.extension.postgres-explorer)", + "media": { + "markdown": "walkthroughs/step-explorer.md", + "altText": "Database explorer tree" + }, + "completionEvents": [ + "onView:postgresExplorer" + ] + }, + { + "id": "pgstudioWelcome.newNotebook", + "title": "Create a SQL notebook", + "description": "Use interactive `.pgsql` notebooks for queries and results.\n[New SQL Notebook](command:postgres-explorer.newNotebook)", + "media": { + "markdown": "walkthroughs/step-notebook.md", + "altText": "PostgreSQL SQL notebook" + }, + "completionEvents": [ + "onCommand:postgres-explorer.newNotebook" + ] + } + ] + } + ], "notebooks": [ { "type": "postgres-notebook", @@ -1625,6 +1744,16 @@ "default": true, "description": "Automatically append LIMIT clause to SELECT queries that don't have one. Always enabled in read-only mode." }, + "postgresExplorer.parameters.cacheLastValues": { + "type": "boolean", + "default": true, + "description": "Remember the last entered SQL parameter values in this workspace." + }, + "postgresExplorer.parameters.nullSentinel": { + "type": "string", + "default": "NULL", + "description": "Exact input text that is sent as SQL NULL for prompted parameters. Set empty string to disable." + }, "postgresExplorer.autoRefresh.enabled": { "type": "boolean", "default": true, @@ -1708,6 +1837,11 @@ "command": "postgres-explorer.jumpToSection", "when": "notebookType == 'postgres-notebook'", "group": "navigation@99" + }, + { + "command": "postgres-explorer.exportNotebook", + "when": "notebookType == 'postgres-notebook' || notebookType == 'postgres-query'", + "group": "navigation@98" } ], "view/title": [ @@ -1721,6 +1855,11 @@ "when": "view == postgresExplorer", "group": "navigation" }, + { + "command": "postgres-explorer.importConnectionFromDatabaseUrl", + "when": "view == postgresExplorer", + "group": "navigation" + }, { "command": "postgres-explorer.refreshConnections", "when": "view == postgresExplorer", @@ -1827,7 +1966,7 @@ }, { "command": "postgres-explorer.openDefinition", - "when": "view == postgresExplorer && viewItem =~ /^(table|partition|view|function|procedure|index|materialized-view|sequence|type|trigger|extension|role|foreign-table|foreign-data-wrapper|foreign-server)$/", + "when": "view == postgresExplorer && viewItem =~ /^(table|partition|view|function|procedure|index|materialized-view|sequence|type|trigger|policy|extension|role|foreign-table|foreign-data-wrapper|foreign-server)$/", "group": "inline@1" }, { @@ -1870,6 +2009,11 @@ "when": "view == postgresExplorer && (viewItem == connection || viewItem == connection-disconnected)", "group": "inline@2" }, + { + "command": "postgres-explorer.duplicateConnection", + "when": "view == postgresExplorer && (viewItem == connection || viewItem == connection-disconnected)", + "group": "inline@3" + }, { "command": "postgres-explorer.createDatabase", "when": "view == postgresExplorer && viewItem == connection", @@ -2298,6 +2442,11 @@ "when": "view == postgresExplorer && viewItem == database", "group": "1_operations@1" }, + { + "command": "postgres-explorer.openListenNotify", + "when": "view == postgresExplorer && viewItem == database", + "group": "1_operations@8" + }, { "command": "postgres-explorer.databaseOperations", "when": "view == postgresExplorer && viewItem == database", @@ -2533,6 +2682,11 @@ "when": "view == postgresExplorer && viewItem == trigger", "group": "9_destructive" }, + { + "command": "postgres-explorer.dropPolicy", + "when": "view == postgresExplorer && viewItem == policy", + "group": "9_destructive" + }, { "command": "postgres-explorer.createTrigger", "when": "view == postgresExplorer && viewItem == category-triggers", @@ -2668,6 +2822,41 @@ "when": "view == postgresExplorer && viewItem == category-event-triggers", "group": "1_actions" }, + { + "command": "postgres-explorer.listCronJobs", + "when": "view == postgresExplorer && viewItem == category-cron-jobs", + "group": "1_actions" + }, + { + "command": "postgres-explorer.installPgCron", + "when": "view == postgresExplorer && viewItem == category-cron-jobs", + "group": "1_actions@1" + }, + { + "command": "postgres-explorer.scheduleCronJob", + "when": "view == postgresExplorer && viewItem == category-cron-jobs", + "group": "1_actions@2" + }, + { + "command": "postgres-explorer.installPgCron", + "when": "view == postgresExplorer && viewItem == cron-setup", + "group": "inline@1" + }, + { + "command": "postgres-explorer.listCronJobs", + "when": "view == postgresExplorer && viewItem == cron-setup", + "group": "1_actions@1" + }, + { + "command": "postgres-explorer.showCronJobProperties", + "when": "view == postgresExplorer && viewItem == cron-job", + "group": "inline@1" + }, + { + "command": "postgres-explorer.unscheduleCronJob", + "when": "view == postgresExplorer && viewItem == cron-job", + "group": "9_destructive" + }, { "command": "postgres-explorer.showRuleProperties", "when": "view == postgresExplorer && viewItem == rule", @@ -2899,7 +3088,20 @@ ] }, "activationEvents": [ - "onStartupFinished" + "onView:postgresExplorer", + "onView:postgresExplorer.notebooks", + "onView:postgresExplorer.savedQueries", + "onView:postgresExplorer.chatView", + "onNotebook:postgres-notebook", + "onNotebook:postgres-query", + "onCommand:postgres-explorer.connect", + "onCommand:postgres-explorer.newNotebook", + "onCommand:postgres-explorer.addConnection", + "onCommand:postgres-explorer.importConnectionFromDatabaseUrl", + "onCommand:postgres-explorer.switchWorkspaceDefaultConnection", + "onCommand:postgres-explorer.exportNotebook", + "onCommand:postgres-explorer.openListenNotify", + "onCommand:postgres-explorer.openListenNotifyFromPalette" ], "main": "./dist/extension.js", "scripts": { @@ -2908,7 +3110,7 @@ "package:prerelease": "npx @vscode/vsce package --pre-release", "package:openvsx:nightly": "npx @vscode/vsce package", "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", + "esbuild-renderer": "esbuild ./src/ui/renderer/renderer_v2.ts --bundle --outfile=dist/renderer_v2.js --format=esm --platform=browser", "copy-templates": "cp -r templates dist/", "compile": "tsc -p ./ && npm run esbuild-renderer && npm run copy-templates", "watch": "tsc -watch -p ./", diff --git a/src/activation/commandRegistry.ts b/src/activation/commandRegistry.ts new file mode 100644 index 0000000..4e64b84 --- /dev/null +++ b/src/activation/commandRegistry.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; +import { ChatViewProvider } from '../providers/ChatViewProvider'; +import { SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; +import { NotebooksTreeProvider } from '../providers/NotebooksTreeProvider'; +import { cmdPasteTable } from '../commands/schema'; +import { getCommandSpecs } from './commandSpecs'; + +/** + * Aggregates command specs and registers VS Code commands. Command IDs must stay stable (docs/API_STABILITY.md). + */ +export function registerAllCommands( + context: vscode.ExtensionContext, + databaseTreeProvider: DatabaseTreeProvider, + chatViewProviderInstance: ChatViewProvider | undefined, + outputChannel: vscode.OutputChannel, + savedQueriesTreeProvider?: SavedQueriesTreeProvider, + notebooksTreeProvider?: NotebooksTreeProvider +): void { + const commands = getCommandSpecs( + context, + databaseTreeProvider, + chatViewProviderInstance, + outputChannel, + savedQueriesTreeProvider, + notebooksTreeProvider + ); + + outputChannel.appendLine('Starting command registration...'); + + commands.forEach(({ command, callback }) => { + try { + context.subscriptions.push(vscode.commands.registerCommand(command, callback as (...args: unknown[]) => void)); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + outputChannel.appendLine(`Failed to register command ${command}: ${err}`); + } + }); + + context.subscriptions.push( + vscode.commands.registerCommand('postgresExplorer.savedQueries.refresh', () => { + if (savedQueriesTreeProvider) { + savedQueriesTreeProvider.refresh(); + } + }), + vscode.commands.registerCommand('postgres-explorer.pasteTable', (item: DatabaseTreeItem) => cmdPasteTable(item, context)) + ); + + outputChannel.appendLine('All commands registered successfully.'); +} diff --git a/src/activation/commandSpecs.ts b/src/activation/commandSpecs.ts new file mode 100644 index 0000000..fce7605 --- /dev/null +++ b/src/activation/commandSpecs.ts @@ -0,0 +1,1461 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; +import { QueryHistoryService } from '../services/QueryHistoryService'; +import { ChatViewProvider } from '../providers/ChatViewProvider'; + +import { cmdAiAssist } from '../commands/aiAssist'; +import { showColumnProperties, copyColumnName, copyColumnNameQuoted, generateSelectStatement, generateWhereClause, generateAlterColumnScript, generateDropColumnScript, generateRenameColumnScript, addColumnComment, generateIndexOnColumn, viewColumnStatistics, cmdAddColumn } from '../commands/columns'; +import { showConstraintProperties, copyConstraintName, generateDropConstraintScript, generateAlterConstraintScript, validateConstraint, generateAddConstraintScript, viewConstraintDependencies, cmdConstraintOperations, cmdAddConstraint } from '../commands/constraints'; +import { cmdConnectDatabase, cmdDisconnectConnection, cmdDisconnectDatabase, cmdReconnectConnection, cmdDuplicateConnection, showConnectionSafety, revealInExplorer } from '../commands/connection'; +import { cmdImportConnectionFromDatabaseUrl } from '../commands/importConnectionFromDatabaseUrl'; +import { showIndexProperties, copyIndexName, generateDropIndexScript, generateReindexScript, generateScriptCreate, analyzeIndexUsage, generateAlterIndexScript, addIndexComment, cmdIndexOperations, cmdAddIndex } from '../commands/indexes'; +import { cmdAddObjectInDatabase, cmdBackupDatabase, cmdCreateDatabase, cmdDatabaseDashboard, cmdDatabaseOperations, cmdDeleteDatabase, cmdDisconnectDatabase as cmdDisconnectDatabaseLegacy, cmdGenerateCreateScript, cmdMaintenanceDatabase, cmdPsqlTool, cmdQueryTool, cmdRestoreDatabase, cmdScriptAlterDatabase, cmdShowConfiguration } from '../commands/database'; +import { cmdDropExtension, cmdEnableExtension, cmdExtensionOperations, cmdRefreshExtension } from '../commands/extensions'; +import { cmdCreateForeignTable, cmdDropForeignTable, cmdEditForeignTable, cmdForeignTableOperations, cmdRefreshForeignTable, cmdShowForeignTableProperties, cmdViewForeignTableData } from '../commands/foreignTables'; +import { cmdForeignDataWrapperOperations, cmdShowForeignDataWrapperProperties, cmdCreateForeignServer, cmdForeignServerOperations, cmdShowForeignServerProperties, cmdDropForeignServer, cmdCreateUserMapping, cmdUserMappingOperations, cmdShowUserMappingProperties, cmdDropUserMapping, cmdRefreshForeignDataWrapper, cmdRefreshForeignServer, cmdRefreshUserMapping } from '../commands/foreignDataWrappers'; +import { cmdCallFunction, cmdCreateFunction, cmdDropFunction, cmdEditFunction, cmdFunctionOperations, cmdRefreshFunction, cmdShowFunctionProperties } from '../commands/functions'; +import { cmdCallProcedure, cmdCreateProcedure, cmdDropProcedure, cmdEditProcedure, cmdProcedureOperations, cmdRefreshProcedure, cmdShowProcedureProperties } from '../commands/procedures'; +import { cmdCreateMaterializedView, cmdDropMatView, cmdEditMatView, cmdMatViewOperations, cmdRefreshMatView, cmdViewMatViewData, cmdViewMatViewProperties } from '../commands/materializedViews'; +import { cmdNewNotebook, cmdExplainQuery, cmdJumpToSection } from '../commands/notebook'; +import { cmdExportNotebook } from '../commands/notebookExport'; +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'; + +import { AiSettingsPanel } from '../features/aiAssistant/settings/aiSettingsPanel'; +import { ConnectionFormPanel } from '../features/connections/connectionForm'; +import { ConnectionManagementPanel } from '../features/connections/connectionManagement'; +import { ConnectionUtils } from '../utils/connectionUtils'; + +// Phase 7: Advanced Power User & AI features +import { + switchConnectionProfile, + createConnectionProfile, + deleteConnectionProfile, + saveQueryToLibrary, + saveQueryToLibraryUI, + loadSavedQuery, + loadSavedQueryUI, + viewSavedQuery, + deleteSavedQuery, + copySavedQuery, + editSavedQuery, + openSavedQueryInNotebook, + exportSavedQueries, + importSavedQueries, + searchSavedQueries, + showQueryRecommendations, + exportConnectionProfiles, + importConnectionProfiles, +} from '../commands/phase7'; +import { SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; +import { pickQueryHistory } from '../commands/pickQueryHistory'; + +// Visual Schema Design +import { + cmdOpenTableDesigner, + cmdCreateTableVisual, + cmdOpenSchemaDiff, + cmdOpenSchemaDiffFromPalette, + cmdOpenErd, + cmdImportData, +} from '../commands/schemaDesigner'; +import { NotebookTreeItem, NotebooksTreeProvider } from '../providers/NotebooksTreeProvider'; + +// Phase 2: New object types +import { cmdListTriggers, cmdCreateTrigger, cmdDropTrigger, cmdEnableTrigger, cmdDisableTrigger, cmdShowTriggerProperties, cmdTriggerOperations } from '../commands/triggers'; +import { cmdListSequences, cmdCreateSequence, cmdDropSequence, cmdSequenceNextValue, cmdShowSequenceProperties, cmdSequenceOperations } from '../commands/sequences'; +import { cmdListPartitions, cmdDetachPartition, cmdShowPartitionProperties, cmdCreatePartition } from '../commands/partitions'; +import { cmdListDomains, cmdCreateDomain, cmdDropDomain, cmdShowDomainProperties } from '../commands/domains'; +import { cmdListAggregates, cmdDropAggregate, cmdShowAggregateProperties, cmdCreateAggregate } from '../commands/aggregates'; +import { cmdListEventTriggers, cmdCreateEventTrigger, cmdDropEventTrigger, cmdEnableEventTrigger, cmdDisableEventTrigger, cmdShowEventTriggerProperties, cmdEventTriggerOperations } from '../commands/eventTriggers'; +import { + cmdListCronJobs, + cmdInstallPgCron, + cmdScheduleCronJob, + cmdShowCronJobProperties, + cmdUnscheduleCronJob, +} from '../commands/pgCron'; +import { cmdListRules, cmdDropRule, cmdShowRuleProperties, cmdRuleOperations } from '../commands/rules'; +import { cmdListTablespaces, cmdShowTablespaceProperties, cmdTablespaceOperations } from '../commands/tablespaces'; +import { cmdListPublications, cmdCreatePublication, cmdDropPublication, cmdShowPublicationProperties, cmdListSubscriptions, cmdDropSubscription, cmdShowSubscriptionProperties, cmdPublicationOperations } from '../commands/publications'; +import { cmdDropPolicy } from '../commands/rlsPolicies'; +import { cmdOpenListenNotify, cmdOpenListenNotifyFromPalette } from '../commands/listenNotify'; +import { cmdSearchSchema } from '../commands/schemaSearch'; +import { WorkspaceStateService } from '../services/WorkspaceStateService'; +import { switchWorkspaceDefaultConnection } from '../commands/workspaceConnection'; + +export function getCommandSpecs( + context: vscode.ExtensionContext, + databaseTreeProvider: DatabaseTreeProvider, + chatViewProviderInstance: ChatViewProvider | undefined, + outputChannel: vscode.OutputChannel, + savedQueriesTreeProvider?: SavedQueriesTreeProvider, + notebooksTreeProvider?: NotebooksTreeProvider +): Array<{ command: string; callback: (...args: any[]) => any }> { + const commands = [ + { + command: 'postgres-explorer.addConnection', + callback: () => { + // Explicitly pass undefined to force "Add" mode, ignoring any arguments VS Code might pass + ConnectionFormPanel.show(context.extensionUri, context, undefined); + } + }, + { + command: 'postgres-explorer.importConnectionFromDatabaseUrl', + callback: () => cmdImportConnectionFromDatabaseUrl(context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.editConnection', + callback: (item: DatabaseTreeItem) => { + if (!item || !item.connectionId) return; + const connection = ConnectionUtils.findConnection(item.connectionId); + if (connection) { + ConnectionFormPanel.show(context.extensionUri, context, connection); + } + } + }, + { + command: 'postgres-explorer.duplicateConnection', + callback: async (item: DatabaseTreeItem) => { + await cmdDuplicateConnection(item, context, databaseTreeProvider); + } + }, + { + command: 'postgres-explorer.refreshConnections', + callback: () => { + databaseTreeProvider.refresh(); + } + }, + { + command: 'postgres-explorer.clearHistory', + callback: async () => { + await QueryHistoryService.getInstance().clear(); + vscode.window.showInformationMessage('Query history cleared'); + } + }, + { + command: 'postgres-explorer.pickQueryHistory', + callback: () => pickQueryHistory() + }, + { + command: 'postgres-explorer.copyQuery', + callback: async (item: any) => { + // Handle both direct string (from context menu if configured that way) or TreeItem + const query = typeof item === 'string' ? item : item?.query; + if (query) { + await vscode.env.clipboard.writeText(query); + vscode.window.showInformationMessage('Query copied to clipboard'); + } + } + }, + { + command: 'postgres-explorer.deleteHistoryItem', + callback: async (item: any) => { + if (item && item.id) { + await QueryHistoryService.getInstance().delete(item.id); + } + } + }, + { + command: 'postgres-explorer.openQuery', + callback: async (item: any) => { + const query = typeof item === 'string' ? item : item?.query; + if (query) { + const doc = await vscode.workspace.openTextDocument({ + content: query, + language: 'sql' + }); + await vscode.window.showTextDocument(doc); + } + } + }, + { + command: 'postgres-explorer.explainQuery', + callback: async (cellUri: vscode.Uri, analyze: boolean) => { + await cmdExplainQuery(cellUri, analyze); + } + }, + { + command: 'postgres-explorer.tableProfile', + callback: async (item: DatabaseTreeItem) => await cmdTableProfile(item, context) + }, + { + command: 'postgres-explorer.tableActivity', + callback: async (item: DatabaseTreeItem) => await cmdTableActivity(item, context) + }, + { + command: 'postgres-explorer.indexUsage', + callback: async (item: DatabaseTreeItem) => await cmdIndexUsage(item, context) + }, + { + command: 'postgres-explorer.tableDefinition', + callback: async (item: DatabaseTreeItem) => await cmdTableDefinition(item, context) + }, + { + command: 'postgres-explorer.generateQuery', + callback: async () => { + if (!chatViewProviderInstance) { + vscode.window.showErrorMessage('AI Chat is not initialized'); + return; + } + + // Step 1: Get all connections + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + + if (connections.length === 0) { + vscode.window.showErrorMessage('No database connections found. Please add a connection first.'); + return; + } + + // Step 2: Let user select connection + const connectionItems = connections.map(conn => ({ + label: conn.name, + description: `${conn.host}:${conn.port}/${conn.database}`, + connection: conn + })); + + const selectedConnection = await vscode.window.showQuickPick(connectionItems, { + placeHolder: 'Select a database connection', + title: 'Generate Query - Select Database' + }); + + if (!selectedConnection) { + return; + } + + // Step 3: Fetch database objects (tables, views, functions) + try { + const dbObjects = await databaseTreeProvider.getDbObjectsForConnection(selectedConnection.connection); + + if (!dbObjects || dbObjects.length === 0) { + vscode.window.showWarningMessage('No tables, views, or functions found in this database.'); + // Continue anyway, let user describe query without schema + const input = await vscode.window.showInputBox({ + prompt: 'Describe the SQL query you want to generate', + placeHolder: 'e.g., Show me top 10 users by order count' + }); + + if (input) { + vscode.commands.executeCommand('postgres-explorer.chatView.focus'); + await chatViewProviderInstance.handleGenerateQuery(input); + } + return; + } + + // Step 4: Let user select relevant objects + const objectItems = dbObjects.map(obj => ({ + label: `${obj.type === 'table' ? '📋' : obj.type === 'view' ? '👁️' : '⚙️'} ${obj.schema}.${obj.name}`, + description: obj.type, + picked: false, + object: obj + })); + + const selectedObjects = await vscode.window.showQuickPick(objectItems, { + placeHolder: 'Select tables, views, or functions (multi-select)', + title: 'Generate Query - Select Database Objects', + canPickMany: true + }); + + if (!selectedObjects || selectedObjects.length === 0) { + const proceed = await vscode.window.showWarningMessage( + 'No objects selected. Generate query without schema context?', + 'Yes', 'No' + ); + + if (proceed !== 'Yes') { + return; + } + } + + // Step 5: Get query description + const input = await vscode.window.showInputBox({ + prompt: 'Describe the SQL query you want to generate', + placeHolder: 'e.g., Show me top 10 users by order count in the last month' + }); + + if (input) { + // Focus the chat view + vscode.commands.executeCommand('postgres-explorer.chatView.focus'); + + // Send to AI with schema context + const schemaContext = selectedObjects ? selectedObjects.map(item => item.object) : undefined; + await chatViewProviderInstance.handleGenerateQuery(input, schemaContext); + } + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to fetch database objects: ${error.message}`); + } + } + }, + { + command: 'postgres-explorer.optimizeQuery', + callback: async () => { + if (!chatViewProviderInstance) { + vscode.window.showErrorMessage('AI Chat is not initialized'); + return; + } + + let query = ''; + const activeNotebookEditor = vscode.window.activeNotebookEditor; + if (activeNotebookEditor && activeNotebookEditor.selections.length > 0) { + const cellIndex = activeNotebookEditor.selections[0].start; + const cell = activeNotebookEditor.notebook.cellAt(cellIndex); + query = cell.document.getText().trim(); + } + + if (!query) { + query = (await vscode.window.showInputBox({ + prompt: 'Paste or type the SQL query you want to optimize', + placeHolder: 'SELECT ...' + }))?.trim() || ''; + } + + if (!query) { + vscode.window.showWarningMessage('No query provided for optimization.'); + return; + } + + await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); + await chatViewProviderInstance.handleOptimizeQuery(query); + } + }, + { + command: 'postgres-explorer.addToFavorites', + callback: async (item: DatabaseTreeItem) => { + if (item) { + await databaseTreeProvider.addToFavorites(item); + } + } + }, + { + command: 'postgres-explorer.removeFromFavorites', + callback: async (item: DatabaseTreeItem) => { + if (item) { + await databaseTreeProvider.removeFromFavorites(item); + } + } + }, + { + command: 'postgres-explorer.manageConnections', + callback: () => { + ConnectionManagementPanel.show(context.extensionUri, context); + } + }, + { + command: 'postgres-explorer.aiSettings', + callback: () => { + AiSettingsPanel.show(context.extensionUri, context); + } + }, + { + command: 'postgres-explorer.connect', + callback: async (item: any) => await cmdConnectDatabase(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.disconnect', + callback: async () => { + databaseTreeProvider.refresh(); + vscode.window.showInformationMessage('Disconnected from PostgreSQL database'); + } + }, + { + command: 'postgres-explorer.queryTable', + callback: async (item: any) => { + if (!item || !item.schema) { + return; + } + + const query = `SELECT * FROM ${item.schema}.${item.label} LIMIT 100;`; + const notebook = await vscode.workspace.openNotebookDocument('postgres-notebook', new vscode.NotebookData([ + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, query, 'sql') + ])); + await vscode.window.showNotebookDocument(notebook); + } + }, + { + command: 'postgres-explorer.newNotebook', + callback: async (item: any) => await cmdNewNotebook(item, context) + }, + { + command: 'postgres-explorer.jumpToSection', + callback: async () => await cmdJumpToSection() + }, + { + command: 'postgres-explorer.exportNotebook', + callback: () => cmdExportNotebook() + }, + { + command: 'postgres-explorer.notebooks.refresh', + callback: () => notebooksTreeProvider?.refresh() + }, + { + command: 'postgres-explorer.notebooks.open', + callback: async (item: NotebookTreeItem) => { + if (item?.uri) { + const doc = await vscode.workspace.openNotebookDocument(item.uri); + await vscode.window.showNotebookDocument(doc, { preserveFocus: false }); + } + } + }, + { + command: 'postgres-explorer.notebooks.rename', + callback: async (item: NotebookTreeItem) => { + if (!item?.uri) { return; } + const oldName = (item.label as string); + const newName = await vscode.window.showInputBox({ + prompt: 'New notebook name', + value: oldName, + validateInput: v => v && /^[a-zA-Z0-9_-]+$/.test(v) ? null : 'Use only letters, numbers, hyphens, underscores' + }); + if (!newName || newName === oldName) { return; } + const newUri = vscode.Uri.joinPath(item.uri, '..', `${newName}.pgsql`); + await vscode.workspace.fs.rename(item.uri, newUri, { overwrite: false }); + notebooksTreeProvider?.refresh(); + } + }, + { + command: 'postgres-explorer.notebooks.delete', + callback: async (item: NotebookTreeItem) => { + if (!item?.uri) { return; } + const confirm = await vscode.window.showWarningMessage( + `Delete "${item.label as string}.pgsql"? This cannot be undone.`, + { modal: true }, 'Delete' + ); + if (confirm !== 'Delete') { return; } + await vscode.workspace.fs.delete(item.uri, { recursive: false }); + notebooksTreeProvider?.refresh(); + } + }, + { + command: 'postgres-explorer.notebooks.deleteFolder', + callback: async (item: NotebookTreeItem) => { + if (!item?.uri) { return; } + const folderName = item.label as string; + const confirm = await vscode.window.showWarningMessage( + `Delete folder "${folderName}" and all notebooks inside it? This cannot be undone.`, + { modal: true }, + 'Delete Folder' + ); + if (confirm !== 'Delete Folder') { return; } + await vscode.workspace.fs.delete(item.uri, { recursive: true, useTrash: false }); + notebooksTreeProvider?.refresh(); + } + }, + { + command: 'postgres-explorer.refresh', + callback: () => databaseTreeProvider.refresh() + }, + // Add database commands + { + command: 'postgres-explorer.createInDatabase', + callback: async (item: DatabaseTreeItem) => await cmdAddObjectInDatabase(item, context) + }, + { + command: 'postgres-explorer.createDatabase', + callback: async (item: DatabaseTreeItem) => await cmdCreateDatabase(item, context) + }, + { + command: 'postgres-explorer.dropDatabase', + callback: async (item: DatabaseTreeItem) => await cmdDeleteDatabase(item, context) + }, + { + command: 'postgres-explorer.scriptAlterDatabase', + callback: async (item: DatabaseTreeItem) => await cmdScriptAlterDatabase(item, context) + }, + { + command: 'postgres-explorer.databaseOperations', + callback: async (item: DatabaseTreeItem) => await cmdDatabaseOperations(item, context) + }, + { + command: 'postgres-explorer.showDashboard', + callback: async (item: DatabaseTreeItem) => await cmdDatabaseDashboard(item, context) + }, + { + command: 'postgres-explorer.openListenNotify', + callback: async (item: DatabaseTreeItem) => await cmdOpenListenNotify(item, context) + }, + { + command: 'postgres-explorer.openListenNotifyFromPalette', + callback: () => cmdOpenListenNotifyFromPalette(context) + }, + { + command: 'postgres-explorer.backupDatabase', + callback: async (item: DatabaseTreeItem) => await cmdBackupDatabase(item, context) + }, + { + command: 'postgres-explorer.restoreDatabase', + callback: async (item: DatabaseTreeItem) => await cmdRestoreDatabase(item, context) + }, + { + command: 'postgres-explorer.generateCreateScript', + callback: async (item: DatabaseTreeItem) => await cmdGenerateCreateScript(item, context) + }, + { + command: 'postgres-explorer.disconnectDatabase', + callback: async (item: DatabaseTreeItem) => await cmdDisconnectDatabaseLegacy(item, context) + }, + { + command: 'postgres-explorer.maintenanceDatabase', + callback: async (item: DatabaseTreeItem) => await cmdMaintenanceDatabase(item, context) + }, + { + command: 'postgres-explorer.queryTool', + callback: async (item: DatabaseTreeItem) => await cmdQueryTool(item, context) + }, + { + command: 'postgres-explorer.psqlTool', + callback: async (item: DatabaseTreeItem) => await cmdPsqlTool(item, context) + }, + { + command: 'postgres-explorer.showConfiguration', + callback: async (item: DatabaseTreeItem) => await cmdShowConfiguration(item, context) + }, + // Add schema commands + { + command: 'postgres-explorer.createSchema', + callback: async (item: DatabaseTreeItem) => await cmdCreateSchema(item, context) + }, + { + command: 'postgres-explorer.createInSchema', + callback: async (item: DatabaseTreeItem) => await cmdCreateObjectInSchema(item, context) + }, + { + command: 'postgres-explorer.schemaOperations', + callback: async (item: DatabaseTreeItem) => await cmdSchemaOperations(item, context) + }, + { + command: 'postgres-explorer.showSchemaProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowSchemaProperties(item, context) + }, + // Add table commands + { + command: 'postgres-explorer.editTable', + callback: async (item: DatabaseTreeItem) => await cmdEditTable(item, context) + }, + { + command: 'postgres-explorer.viewTableData', + callback: async (item: DatabaseTreeItem) => { + await databaseTreeProvider.addToRecent(item); + await cmdViewTableData(item, context); + } + }, + { + command: 'postgres-explorer.dropTable', + callback: async (item: DatabaseTreeItem) => await cmdDropTable(item, context) + }, + { + command: 'postgres-explorer.tableOperations', + callback: async (item: DatabaseTreeItem) => await cmdTableOperations(item, context) + }, + { + 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) + }, + { + command: 'postgres-explorer.showTableProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowTableProperties(item, context) + }, + // Add script commands + { + command: 'postgres-explorer.scriptSelect', + callback: async (item: DatabaseTreeItem) => await cmdScriptSelect(item, context) + }, + { + command: 'postgres-explorer.scriptInsert', + callback: async (item: DatabaseTreeItem) => await cmdScriptInsert(item, context) + }, + { + command: 'postgres-explorer.scriptUpdate', + callback: async (item: DatabaseTreeItem) => await cmdScriptUpdate(item, context) + }, + { + command: 'postgres-explorer.scriptDelete', + callback: async (item: DatabaseTreeItem) => await cmdScriptDelete(item, context) + }, + { + command: 'postgres-explorer.scriptCreate', + callback: async (item: DatabaseTreeItem) => await cmdScriptCreate(item, context) + }, + // Add maintenance commands + { + command: 'postgres-explorer.maintenanceVacuum', + callback: async (item: DatabaseTreeItem) => await cmdMaintenanceVacuum(item, context) + }, + { + command: 'postgres-explorer.maintenanceAnalyze', + callback: async (item: DatabaseTreeItem) => await cmdMaintenanceAnalyze(item, context) + }, + { + command: 'postgres-explorer.maintenanceReindex', + callback: async (item: DatabaseTreeItem) => await cmdMaintenanceReindex(item, context) + }, + + // Add view commands + { + command: 'postgres-explorer.refreshView', + callback: async (item: DatabaseTreeItem) => await cmdRefreshView(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.editViewDefinition', + callback: async (item: DatabaseTreeItem) => await cmdEditView(item, context) + }, + { + command: 'postgres-explorer.viewViewData', + callback: async (item: DatabaseTreeItem) => { + await databaseTreeProvider.addToRecent(item); + await cmdViewData(item, context); + } + }, + { + command: 'postgres-explorer.dropView', + callback: async (item: DatabaseTreeItem) => await cmdDropView(item, context) + }, + { + command: 'postgres-explorer.viewOperations', + callback: async (item: DatabaseTreeItem) => await cmdViewOperations(item, context) + }, + { + command: 'postgres-explorer.showViewProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowViewProperties(item, context) + }, + { + command: 'postgres-explorer.viewScriptSelect', + callback: async (item: DatabaseTreeItem) => await cmdViewScriptSelect(item, context) + }, + { + command: 'postgres-explorer.viewScriptCreate', + callback: async (item: DatabaseTreeItem) => await cmdViewScriptCreate(item, context) + }, + // Add function commands + { + command: 'postgres-explorer.refreshFunction', + callback: async (item: DatabaseTreeItem) => await cmdRefreshFunction(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.showFunctionProperties', + callback: async (item: DatabaseTreeItem) => { + await databaseTreeProvider.addToRecent(item); + await cmdShowFunctionProperties(item, context); + } + }, + { + command: 'postgres-explorer.functionOperations', + callback: async (item: DatabaseTreeItem) => await cmdFunctionOperations(item, context) + }, + { + command: 'postgres-explorer.createReplaceFunction', + callback: async (item: DatabaseTreeItem) => await cmdEditFunction(item, context) + }, + { + command: 'postgres-explorer.callFunction', + callback: async (item: DatabaseTreeItem) => await cmdCallFunction(item, context) + }, + { + command: 'postgres-explorer.dropFunction', + callback: async (item: DatabaseTreeItem) => await cmdDropFunction(item, context) + }, + // Add procedure commands + { + command: 'postgres-explorer.refreshProcedure', + callback: async (item: DatabaseTreeItem) => await cmdRefreshProcedure(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.showProcedureProperties', + callback: async (item: DatabaseTreeItem) => { + await databaseTreeProvider.addToRecent(item); + await cmdShowProcedureProperties(item, context); + } + }, + { + command: 'postgres-explorer.procedureOperations', + callback: async (item: DatabaseTreeItem) => await cmdProcedureOperations(item, context) + }, + { + command: 'postgres-explorer.createReplaceProcedure', + callback: async (item: DatabaseTreeItem) => await cmdEditProcedure(item, context) + }, + { + command: 'postgres-explorer.callProcedure', + callback: async (item: DatabaseTreeItem) => await cmdCallProcedure(item, context) + }, + { + command: 'postgres-explorer.dropProcedure', + callback: async (item: DatabaseTreeItem) => await cmdDropProcedure(item, context) + }, + { + command: 'postgres-explorer.createProcedure', + callback: async (item: DatabaseTreeItem) => await cmdCreateProcedure(item, context) + }, + // Add materialized view commands + { + command: 'postgres-explorer.refreshMaterializedView', + callback: async (item: DatabaseTreeItem) => await cmdRefreshMatView(item, context) + }, + { + command: 'postgres-explorer.editMatView', + callback: async (item: DatabaseTreeItem) => await cmdEditMatView(item, context) + }, + { + command: 'postgres-explorer.editMaterializedView', + callback: async (item: DatabaseTreeItem) => await cmdEditMatView(item, context) + }, + { + command: 'postgres-explorer.viewMaterializedViewData', + callback: async (item: DatabaseTreeItem) => await cmdViewMatViewData(item, context) + }, + { + command: 'postgres-explorer.showMaterializedViewProperties', + callback: async (item: DatabaseTreeItem) => await cmdViewMatViewProperties(item, context) + }, + { + command: 'postgres-explorer.dropMatView', + callback: async (item: DatabaseTreeItem) => await cmdDropMatView(item, context) + }, + { + command: 'postgres-explorer.materializedViewOperations', + callback: async (item: DatabaseTreeItem) => await cmdMatViewOperations(item, context) + }, + // Add type commands + { + command: 'postgres-explorer.refreshType', + callback: async (item: DatabaseTreeItem) => await cmdRefreshType(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.typeOperations', + callback: async (item: DatabaseTreeItem) => await cmdAllOperationsTypes(item, context) + }, + { + command: 'postgres-explorer.editType', + callback: async (item: DatabaseTreeItem) => await cmdEditTypes(item, context) + }, + { + command: 'postgres-explorer.showTypeProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowTypeProperties(item, context) + }, + { + command: 'postgres-explorer.dropType', + callback: async (item: DatabaseTreeItem) => await cmdDropType(item, context) + }, + // Add foreign table commands + { + command: 'postgres-explorer.refreshForeignTable', + callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignTable(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.foreignTableOperations', + callback: async (item: DatabaseTreeItem) => await cmdForeignTableOperations(item, context) + }, + { + command: 'postgres-explorer.editForeignTable', + callback: async (item: DatabaseTreeItem) => await cmdEditForeignTable(item, context) + }, + { + command: 'postgres-explorer.showForeignTableProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowForeignTableProperties(item, context) + }, + { + command: 'postgres-explorer.viewForeignTableData', + callback: async (item: DatabaseTreeItem) => await cmdViewForeignTableData(item, context) + }, + { + command: 'postgres-explorer.dropForeignTable', + callback: async (item: DatabaseTreeItem) => await cmdDropForeignTable(item, context) + }, + // Add role/user commands + { + command: 'postgres-explorer.refreshRole', + callback: async (item: DatabaseTreeItem) => await cmdRefreshRole(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.createUser', + callback: async (item: DatabaseTreeItem) => await cmdAddUser(item, context) + }, + { + command: 'postgres-explorer.createRole', + callback: async (item: DatabaseTreeItem) => await cmdAddRole(item, context) + }, + { + command: 'postgres-explorer.editRole', + callback: async (item: DatabaseTreeItem) => await cmdEditRole(item, context) + }, + { + command: 'postgres-explorer.grantRevoke', + callback: async (item: DatabaseTreeItem) => await cmdGrantRevokeRole(item, context) + }, + { + command: 'postgres-explorer.dropRole', + callback: async (item: DatabaseTreeItem) => await cmdDropRole(item, context) + }, + { + command: 'postgres-explorer.roleOperations', + callback: async (item: DatabaseTreeItem) => await cmdRoleOperations(item, context) + }, + { + command: 'postgres-explorer.showRoleProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowRoleProperties(item, context) + }, + // Add extension commands + { + command: 'postgres-explorer.refreshExtension', + callback: async (item: DatabaseTreeItem) => await cmdRefreshExtension(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.enableExtension', + callback: async (item: DatabaseTreeItem) => await cmdEnableExtension(item, context) + }, + { + command: 'postgres-explorer.extensionOperations', + callback: async (item: DatabaseTreeItem) => await cmdExtensionOperations(item, context) + }, + { + command: 'postgres-explorer.dropExtension', + callback: async (item: DatabaseTreeItem) => await cmdDropExtension(item, context) + }, + // Add connection commands + { + command: 'postgres-explorer.disconnectConnection', + callback: async (item: DatabaseTreeItem) => await cmdDisconnectConnection(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.reconnectConnection', + callback: async (item: DatabaseTreeItem) => await cmdReconnectConnection(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.deleteConnection', + callback: async (item: DatabaseTreeItem) => await cmdDisconnectDatabase(item, context, databaseTreeProvider) + }, + + { + command: 'postgres-explorer.createTable', + callback: async (item: DatabaseTreeItem) => await cmdCreateTable(item, context) + }, + { + command: 'postgres-explorer.createView', + callback: async (item: DatabaseTreeItem) => await cmdCreateView(item, context) + }, + { + command: 'postgres-explorer.createFunction', + callback: async (item: DatabaseTreeItem) => await cmdCreateFunction(item, context) + }, + { + command: 'postgres-explorer.createMaterializedView', + callback: async (item: DatabaseTreeItem) => await cmdCreateMaterializedView(item, context) + }, + { + command: 'postgres-explorer.createType', + callback: async (item: DatabaseTreeItem) => await cmdCreateType(item, context) + }, + { + command: 'postgres-explorer.createForeignTable', + callback: async (item: DatabaseTreeItem) => await cmdCreateForeignTable(item, context) + }, + // Foreign Data Wrapper commands + { + command: 'postgres-explorer.foreignDataWrapperOperations', + callback: async (item: DatabaseTreeItem) => await cmdForeignDataWrapperOperations(item, context) + }, + { + command: 'postgres-explorer.showForeignDataWrapperProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowForeignDataWrapperProperties(item, context) + }, + { + command: 'postgres-explorer.refreshForeignDataWrapper', + callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignDataWrapper(item, context, databaseTreeProvider) + }, + // Foreign Server commands + { + command: 'postgres-explorer.createForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdCreateForeignServer(item, context) + }, + { + command: 'postgres-explorer.foreignServerOperations', + callback: async (item: DatabaseTreeItem) => await cmdForeignServerOperations(item, context) + }, + { + command: 'postgres-explorer.showForeignServerProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowForeignServerProperties(item, context) + }, + { + command: 'postgres-explorer.dropForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdDropForeignServer(item, context) + }, + { + command: 'postgres-explorer.refreshForeignServer', + callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignServer(item, context, databaseTreeProvider) + }, + // User Mapping commands + { + command: 'postgres-explorer.createUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdCreateUserMapping(item, context) + }, + { + command: 'postgres-explorer.userMappingOperations', + callback: async (item: DatabaseTreeItem) => await cmdUserMappingOperations(item, context) + }, + { + command: 'postgres-explorer.showUserMappingProperties', + callback: async (item: DatabaseTreeItem) => await cmdShowUserMappingProperties(item, context) + }, + { + command: 'postgres-explorer.dropUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdDropUserMapping(item, context) + }, + { + command: 'postgres-explorer.refreshUserMapping', + callback: async (item: DatabaseTreeItem) => await cmdRefreshUserMapping(item, context, databaseTreeProvider) + }, + { + command: 'postgres-explorer.createRole', + callback: async (item: DatabaseTreeItem) => await cmdAddRole(item, context) + }, + { + command: 'postgres-explorer.enableExtension', + callback: async (item: DatabaseTreeItem) => await cmdEnableExtension(item, context) + }, + + { + command: 'postgres-explorer.aiAssist', + callback: async (cell: vscode.NotebookCell) => await cmdAiAssist(cell, context, outputChannel) + }, + + { + command: 'postgres-explorer.chatWithQuery', + callback: async (cell: vscode.NotebookCell) => { + // Get the query from the active cell + let query = ''; + let results = ''; + + if (cell) { + query = cell.document.getText(); + // Check if there are outputs from previous execution + if (cell.outputs && cell.outputs.length > 0) { + const output = cell.outputs[0]; + for (const item of output.items) { + if (item.mime === 'application/x-postgres-result' || item.mime === 'application/json') { + try { + const data = JSON.parse(new TextDecoder().decode(item.data)); + if (data.rows && data.rows.length > 0) { + results = `\nResults (${data.rows.length} rows): ${JSON.stringify(data.rows.slice(0, 5), null, 2)}${data.rows.length > 5 ? '\n... and more' : ''}`; + } + } catch (e) { + // Ignore parse errors + } + } + } + } + } else { + // Fallback to active notebook editor + const activeEditor = vscode.window.activeNotebookEditor; + if (activeEditor) { + const selections = activeEditor.selections; + if (selections && selections.length > 0) { + const cellIndex = selections[0].start; + const activeCell = activeEditor.notebook.cellAt(cellIndex); + query = activeCell.document.getText(); + } + } + } + + if (!query) { + vscode.window.showWarningMessage('No query found in the active cell.'); + return; + } + + // Focus the chat view and send the query + await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); + + // Send message to chat view with query context + const message = `Help me with this SQL query:\n\`\`\`sql\n${query}\n\`\`\`${results}`; + + // Use the chat view provider to send the message + if (chatViewProviderInstance) { + chatViewProviderInstance.sendToChat({ query, results, message }); + } + } + }, + + { + command: 'postgres-explorer.sendToChat', + callback: async (data: { query: string; results?: string; message: string }) => { + if (chatViewProviderInstance) { + await chatViewProviderInstance.sendToChat(data); + } + } + }, + + { + command: 'postgres-explorer.attachToChat', + callback: async (item: DatabaseTreeItem) => { + if (!chatViewProviderInstance) { + vscode.window.showWarningMessage('SQL Assistant is not available'); + return; + } + if (!item || !item.connectionId || !item.databaseName) { + vscode.window.showErrorMessage('Invalid database object'); + return; + } + + // Resolve connection name from config + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const conn = connections.find(c => c.id === item.connectionId); + const connectionName = conn?.name || conn?.host || 'Unknown'; + + // Convert DatabaseTreeItem to DbObject + const dbObject: any = { + name: item.label, + type: item.type, + schema: item.schema || '', + database: item.databaseName, + connectionId: item.connectionId, + connectionName: connectionName, + breadcrumb: [connectionName, item.databaseName, item.schema, item.label].filter(Boolean).join(' > ') + }; + + await chatViewProviderInstance.attachDbObject(dbObject); + } + }, + + // Column commands + { + command: 'postgres-explorer.showColumnProperties', + callback: async (item: DatabaseTreeItem) => await showColumnProperties(item) + }, + { + command: 'postgres-explorer.copyColumnName', + callback: async (item: DatabaseTreeItem) => await copyColumnName(item) + }, + { + command: 'postgres-explorer.copyColumnNameQuoted', + callback: async (item: DatabaseTreeItem) => await copyColumnNameQuoted(item) + }, + { + command: 'postgres-explorer.generateSelectStatement', + callback: async (item: DatabaseTreeItem) => await generateSelectStatement(item) + }, + { + command: 'postgresExplorer.openColumnNotebook', + callback: async (item: DatabaseTreeItem) => await showColumnProperties(item) + }, + { + command: 'postgres-explorer.generateWhereClause', + callback: async (item: DatabaseTreeItem) => await generateWhereClause(item) + }, + { + command: 'postgres-explorer.generateAlterColumnScript', + callback: async (item: DatabaseTreeItem) => await generateAlterColumnScript(item) + }, + { + command: 'postgres-explorer.generateDropColumnScript', + callback: async (item: DatabaseTreeItem) => await generateDropColumnScript(item) + }, + { + command: 'postgres-explorer.generateRenameColumnScript', + callback: async (item: DatabaseTreeItem) => await generateRenameColumnScript(item) + }, + { + command: 'postgres-explorer.addColumnComment', + callback: async (item: DatabaseTreeItem) => await addColumnComment(item) + }, + { + command: 'postgres-explorer.generateIndexOnColumn', + callback: async (item: DatabaseTreeItem) => await generateIndexOnColumn(item) + }, + { + command: 'postgres-explorer.viewColumnStatistics', + callback: async (item: DatabaseTreeItem) => await viewColumnStatistics(item) + }, + + // Constraint commands + { + command: 'postgres-explorer.showConstraintProperties', + callback: async (item: DatabaseTreeItem) => await showConstraintProperties(item) + }, + { + command: 'postgres-explorer.copyConstraintName', + callback: async (item: DatabaseTreeItem) => await copyConstraintName(item) + }, + { + command: 'postgres-explorer.generateDropConstraintScript', + callback: async (item: DatabaseTreeItem) => await generateDropConstraintScript(item) + }, + { + command: 'postgres-explorer.generateAlterConstraintScript', + callback: async (item: DatabaseTreeItem) => await generateAlterConstraintScript(item) + }, + { + command: 'postgres-explorer.validateConstraint', + callback: async (item: DatabaseTreeItem) => await validateConstraint(item) + }, + { + command: 'postgres-explorer.generateAddConstraintScript', + callback: async (item: DatabaseTreeItem) => await generateAddConstraintScript(item) + }, + { + command: 'postgres-explorer.viewConstraintDependencies', + callback: async (item: DatabaseTreeItem) => await viewConstraintDependencies(item) + }, + { + command: 'postgres-explorer.constraintOperations', + callback: async (item: DatabaseTreeItem) => await cmdConstraintOperations(item, context) + }, + + // Index commands + { + command: 'postgres-explorer.showIndexProperties', + callback: async (item: DatabaseTreeItem) => await showIndexProperties(item) + }, + { + command: 'postgres-explorer.copyIndexName', + callback: async (item: DatabaseTreeItem) => await copyIndexName(item) + }, + { + command: 'postgres-explorer.generateDropIndexScript', + callback: async (item: DatabaseTreeItem) => await generateDropIndexScript(item) + }, + { + command: 'postgres-explorer.generateReindexScript', + callback: async (item: DatabaseTreeItem) => await generateReindexScript(item) + }, + { + command: 'postgres-explorer.generateScriptCreate', + callback: async (item: DatabaseTreeItem) => await generateScriptCreate(item) + }, + { + command: 'postgres-explorer.analyzeIndexUsage', + callback: async (item: DatabaseTreeItem) => await analyzeIndexUsage(item) + }, + { + command: 'postgres-explorer.generateAlterIndexScript', + callback: async (item: DatabaseTreeItem) => await generateAlterIndexScript(item) + }, + { + command: 'postgres-explorer.addIndexComment', + callback: async (item: DatabaseTreeItem) => await addIndexComment(item) + }, + { + command: 'postgres-explorer.indexOperations', + callback: async (item: DatabaseTreeItem) => await cmdIndexOperations(item, context) + }, + { + command: 'postgres-explorer.addColumn', + callback: async (item: DatabaseTreeItem) => await cmdAddColumn(item) + }, + { + command: 'postgres-explorer.addConstraint', + callback: async (item: DatabaseTreeItem) => await cmdAddConstraint(item) + }, + { + command: 'postgres-explorer.addIndex', + callback: async (item: DatabaseTreeItem) => await cmdAddIndex(item) + }, + + // Breadcrumb navigation commands + { + command: 'postgres-explorer.switchConnection', + callback: async () => { + const editor = ConnectionUtils.getActivePostgresNotebook(); + if (!editor) { + vscode.window.showWarningMessage('No active PostgreSQL notebook.'); + return; + } + + const metadata = editor.notebook.metadata as any; + const selected = await ConnectionUtils.showConnectionPicker(metadata?.connectionId); + + if (selected) { + await ConnectionUtils.updateNotebookMetadata(editor.notebook, { + connectionId: selected.id, + databaseName: selected.database, + host: selected.host, + port: selected.port, + username: selected.username + }); + await WorkspaceStateService.getInstance().recordConnectionSwitch(selected.id, selected.database); + vscode.window.showInformationMessage(`Switched to: ${selected.name || selected.host}`); + } + } + }, + { + command: 'postgres-explorer.showConnectionSafety', + callback: showConnectionSafety + }, + { + command: 'postgres-explorer.revealInExplorer', + callback: () => revealInExplorer(databaseTreeProvider) + }, + { + command: 'postgres-explorer.navigateBreadcrumb', + callback: async (args: { type: string; connectionId?: string; database?: string; schema?: string; object?: string }) => { + // Reveal the item in the database tree based on breadcrumb segment + if (args?.type === 'connection' && args.connectionId) { + // Focus database explorer and reveal connection + await vscode.commands.executeCommand('postgresExplorer.focus'); + } + // Future: could expand tree to specific schema/table + } + }, + { + command: 'postgres-explorer.copyBreadcrumbPath', + callback: async (args: { connectionName?: string; database?: string; schema?: string; object?: string }) => { + const parts = [ + args?.connectionName, + args?.database, + args?.schema, + args?.object + ].filter(Boolean); + + if (parts.length > 0) { + const path = parts.join(' ▸ '); + await vscode.env.clipboard.writeText(path); + vscode.window.showInformationMessage('Breadcrumb path copied to clipboard'); + } + } + }, + { + command: 'postgres-explorer.switchDatabase', + callback: async () => { + const editor = ConnectionUtils.getActivePostgresNotebook(); + if (!editor) { + vscode.window.showWarningMessage('No active PostgreSQL notebook.'); + return; + } + + const metadata = editor.notebook.metadata as any; + if (!metadata?.connectionId) { + vscode.window.showWarningMessage('No connection configured for this notebook.'); + return; + } + + const connection = ConnectionUtils.findConnection(metadata.connectionId); + if (!connection) { + vscode.window.showErrorMessage('Connection not found.'); + return; + } + + const selectedDb = await ConnectionUtils.showDatabasePicker(connection, metadata.databaseName); + + if (selectedDb && selectedDb !== metadata.databaseName) { + await ConnectionUtils.updateNotebookMetadata(editor.notebook, { databaseName: selectedDb }); + await WorkspaceStateService.getInstance().recordDatabaseSwitch(metadata.connectionId, selectedDb); + vscode.window.showInformationMessage(`Switched to database: ${selectedDb}`); + } + } + }, + { + command: 'postgres-explorer.switchWorkspaceDefaultConnection', + callback: () => switchWorkspaceDefaultConnection() + }, + // Phase 7: Connection Profiles + { + command: 'postgres-explorer.switchConnectionProfile', + callback: () => switchConnectionProfile() + }, + { + command: 'postgres-explorer.createConnectionProfile', + callback: () => createConnectionProfile() + }, + { + command: 'postgres-explorer.deleteConnectionProfile', + callback: () => deleteConnectionProfile() + }, + // Phase 7: Saved Queries + { + command: 'postgres-explorer.saveQueryToLibrary', + callback: () => saveQueryToLibrary() + }, + { + command: 'postgres-explorer.loadSavedQuery', + callback: () => loadSavedQuery() + }, + { + command: 'postgres-explorer.exportSavedQueries', + callback: () => exportSavedQueries() + }, + { + command: 'postgres-explorer.importSavedQueries', + callback: () => importSavedQueries() + }, + { + command: 'postgres-explorer.searchSavedQueries', + callback: () => searchSavedQueries() + }, + { + command: 'postgres-explorer.showQueryRecommendations', + callback: () => showQueryRecommendations() + }, + { + command: 'postgres-explorer.saveQueryToLibraryUI', + callback: () => saveQueryToLibraryUI() + }, + { + command: 'postgres-explorer.viewSavedQuery', + callback: (query: any) => viewSavedQuery(query) + }, + { + command: 'postgres-explorer.copySavedQuery', + callback: (query: any) => copySavedQuery(query) + }, + { + command: 'postgres-explorer.editSavedQuery', + callback: (query: any) => editSavedQuery(query) + }, + { + command: 'postgres-explorer.openSavedQueryInNotebook', + callback: (query: any) => openSavedQueryInNotebook(query) + }, + { + command: 'postgres-explorer.deleteSavedQuery', + callback: (query: any) => deleteSavedQuery(query) + }, + { + 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) + }, + { + command: 'postgres-explorer.openSchemaDiffFromPalette', + callback: () => cmdOpenSchemaDiffFromPalette(context) + }, + // D2: ERD + { + command: 'postgres-explorer.openErd', + callback: (item: DatabaseTreeItem) => cmdOpenErd(item, context) + }, + // Import Data + { + command: 'postgres-explorer.importData', + callback: (item: DatabaseTreeItem) => cmdImportData(item, context) + }, + // D3: Profile export/import + { + command: 'postgres-explorer.exportConnectionProfiles', + callback: () => exportConnectionProfiles() + }, + { + command: 'postgres-explorer.importConnectionProfiles', + callback: () => importConnectionProfiles() + }, + { + command: 'postgres-explorer.viewMaintenanceVacuum', + callback: async () => { + vscode.window.showInformationMessage('VACUUM is not applicable to regular views. Use this on tables or materialized views.'); + } + }, + { + command: 'postgres-explorer.viewMaintenanceAnalyze', + callback: async () => { + vscode.window.showInformationMessage('ANALYZE is not applicable to regular views. Use this on tables or materialized views.'); + } + }, + + // Phase 2: Triggers + { command: 'postgres-explorer.listTriggers', callback: async (item: DatabaseTreeItem) => await cmdListTriggers(item, context) }, + { command: 'postgres-explorer.createTrigger', callback: async (item: DatabaseTreeItem) => await cmdCreateTrigger(item, context) }, + { command: 'postgres-explorer.dropTrigger', callback: async (item: DatabaseTreeItem) => await cmdDropTrigger(item, context) }, + { command: 'postgres-explorer.enableTrigger', callback: async (item: DatabaseTreeItem) => await cmdEnableTrigger(item, context) }, + { command: 'postgres-explorer.disableTrigger', callback: async (item: DatabaseTreeItem) => await cmdDisableTrigger(item, context) }, + { command: 'postgres-explorer.showTriggerProperties', callback: async (item: DatabaseTreeItem) => await cmdShowTriggerProperties(item, context) }, + { command: 'postgres-explorer.triggerOperations', callback: async (item: DatabaseTreeItem) => await cmdTriggerOperations(item, context) }, + + // Phase 2: Sequences + { command: 'postgres-explorer.listSequences', callback: async (item: DatabaseTreeItem) => await cmdListSequences(item, context) }, + { command: 'postgres-explorer.createSequence', callback: async (item: DatabaseTreeItem) => await cmdCreateSequence(item, context) }, + { command: 'postgres-explorer.dropSequence', callback: async (item: DatabaseTreeItem) => await cmdDropSequence(item, context) }, + { command: 'postgres-explorer.sequenceNextValue', callback: async (item: DatabaseTreeItem) => await cmdSequenceNextValue(item, context) }, + { command: 'postgres-explorer.showSequenceProperties', callback: async (item: DatabaseTreeItem) => await cmdShowSequenceProperties(item, context) }, + { command: 'postgres-explorer.sequenceOperations', callback: async (item: DatabaseTreeItem) => await cmdSequenceOperations(item, context) }, + + // Phase 2: Partitions + { command: 'postgres-explorer.listPartitions', callback: async (item: DatabaseTreeItem) => await cmdListPartitions(item, context) }, + { command: 'postgres-explorer.detachPartition', callback: async (item: DatabaseTreeItem) => await cmdDetachPartition(item, context) }, + { command: 'postgres-explorer.showPartitionProperties', callback: async (item: DatabaseTreeItem) => await cmdShowPartitionProperties(item, context) }, + { command: 'postgres-explorer.createPartition', callback: async (item: DatabaseTreeItem) => await cmdCreatePartition(item, context) }, + + // Phase 2: Domains + { command: 'postgres-explorer.listDomains', callback: async (item: DatabaseTreeItem) => await cmdListDomains(item, context) }, + { command: 'postgres-explorer.createDomain', callback: async (item: DatabaseTreeItem) => await cmdCreateDomain(item, context) }, + { command: 'postgres-explorer.dropDomain', callback: async (item: DatabaseTreeItem) => await cmdDropDomain(item, context) }, + { command: 'postgres-explorer.showDomainProperties', callback: async (item: DatabaseTreeItem) => await cmdShowDomainProperties(item, context) }, + + // Phase 2: Aggregates + { command: 'postgres-explorer.listAggregates', callback: async (item: DatabaseTreeItem) => await cmdListAggregates(item, context) }, + { command: 'postgres-explorer.createAggregate', callback: async (item: DatabaseTreeItem) => await cmdCreateAggregate(item, context) }, + { command: 'postgres-explorer.dropAggregate', callback: async (item: DatabaseTreeItem) => await cmdDropAggregate(item, context) }, + { command: 'postgres-explorer.showAggregateProperties', callback: async (item: DatabaseTreeItem) => await cmdShowAggregateProperties(item, context) }, + + // Phase 2: Event Triggers + { command: 'postgres-explorer.listEventTriggers', callback: async (item: DatabaseTreeItem) => await cmdListEventTriggers(item, context) }, + { command: 'postgres-explorer.createEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdCreateEventTrigger(item, context) }, + { command: 'postgres-explorer.dropEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdDropEventTrigger(item, context) }, + { command: 'postgres-explorer.enableEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdEnableEventTrigger(item, context) }, + { command: 'postgres-explorer.disableEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdDisableEventTrigger(item, context) }, + { command: 'postgres-explorer.showEventTriggerProperties', callback: async (item: DatabaseTreeItem) => await cmdShowEventTriggerProperties(item, context) }, + { command: 'postgres-explorer.eventTriggerOperations', callback: async (item: DatabaseTreeItem) => await cmdEventTriggerOperations(item, context) }, + { command: 'postgres-explorer.listCronJobs', callback: async (item: DatabaseTreeItem) => await cmdListCronJobs(item, context) }, + { command: 'postgres-explorer.installPgCron', callback: async (item: DatabaseTreeItem) => await cmdInstallPgCron(item, context) }, + { command: 'postgres-explorer.scheduleCronJob', callback: async (item: DatabaseTreeItem) => await cmdScheduleCronJob(item, context) }, + { command: 'postgres-explorer.showCronJobProperties', callback: async (item: DatabaseTreeItem) => await cmdShowCronJobProperties(item, context) }, + { command: 'postgres-explorer.unscheduleCronJob', callback: async (item: DatabaseTreeItem) => await cmdUnscheduleCronJob(item, context) }, + + // Phase 2: Rules + { command: 'postgres-explorer.listRules', callback: async (item: DatabaseTreeItem) => await cmdListRules(item, context) }, + { command: 'postgres-explorer.dropRule', callback: async (item: DatabaseTreeItem) => await cmdDropRule(item, context) }, + { command: 'postgres-explorer.showRuleProperties', callback: async (item: DatabaseTreeItem) => await cmdShowRuleProperties(item, context) }, + { command: 'postgres-explorer.ruleOperations', callback: async (item: DatabaseTreeItem) => await cmdRuleOperations(item, context) }, + + // Phase 2: Tablespaces + { command: 'postgres-explorer.listTablespaces', callback: async (item: DatabaseTreeItem) => await cmdListTablespaces(item, context) }, + { command: 'postgres-explorer.showTablespaceProperties', callback: async (item: DatabaseTreeItem) => await cmdShowTablespaceProperties(item, context) }, + { command: 'postgres-explorer.tablespaceOperations', callback: async (item: DatabaseTreeItem) => await cmdTablespaceOperations(item, context) }, + + // Phase 2: Publications & Subscriptions + { command: 'postgres-explorer.listPublications', callback: async (item: DatabaseTreeItem) => await cmdListPublications(item, context) }, + { command: 'postgres-explorer.createPublication', callback: async (item: DatabaseTreeItem) => await cmdCreatePublication(item, context) }, + { command: 'postgres-explorer.dropPublication', callback: async (item: DatabaseTreeItem) => await cmdDropPublication(item, context) }, + { command: 'postgres-explorer.showPublicationProperties', callback: async (item: DatabaseTreeItem) => await cmdShowPublicationProperties(item, context) }, + { command: 'postgres-explorer.publicationOperations', callback: async (item: DatabaseTreeItem) => await cmdPublicationOperations(item, context) }, + { command: 'postgres-explorer.listSubscriptions', callback: async (item: DatabaseTreeItem) => await cmdListSubscriptions(item, context) }, + { command: 'postgres-explorer.dropSubscription', callback: async (item: DatabaseTreeItem) => await cmdDropSubscription(item, context) }, + { command: 'postgres-explorer.dropPolicy', callback: async (item: DatabaseTreeItem) => await cmdDropPolicy(item, context) }, + { command: 'postgres-explorer.showSubscriptionProperties', callback: async (item: DatabaseTreeItem) => await cmdShowSubscriptionProperties(item, context) }, + + // Phase 2: Schema Search + { command: 'postgres-explorer.searchSchema', callback: async () => await cmdSearchSchema() }, + ]; + + return commands; +} diff --git a/src/activation/commands.ts b/src/activation/commands.ts index 49fa732..ee33d7a 100644 --- a/src/activation/commands.ts +++ b/src/activation/commands.ts @@ -1,1429 +1,2 @@ -import * as vscode from 'vscode'; -import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; -import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; -import { QueryHistoryService } from '../services/QueryHistoryService'; -import { ChatViewProvider } from '../providers/ChatViewProvider'; - -import { cmdAiAssist } from '../commands/aiAssist'; -import { showColumnProperties, copyColumnName, copyColumnNameQuoted, generateSelectStatement, generateWhereClause, generateAlterColumnScript, generateDropColumnScript, generateRenameColumnScript, addColumnComment, generateIndexOnColumn, viewColumnStatistics, cmdAddColumn } from '../commands/columns'; -import { showConstraintProperties, copyConstraintName, generateDropConstraintScript, generateAlterConstraintScript, validateConstraint, generateAddConstraintScript, viewConstraintDependencies, cmdConstraintOperations, cmdAddConstraint } from '../commands/constraints'; -import { cmdConnectDatabase, cmdDisconnectConnection, cmdDisconnectDatabase, cmdReconnectConnection, showConnectionSafety, revealInExplorer } from '../commands/connection'; -import { showIndexProperties, copyIndexName, generateDropIndexScript, generateReindexScript, generateScriptCreate, analyzeIndexUsage, generateAlterIndexScript, addIndexComment, cmdIndexOperations, cmdAddIndex } from '../commands/indexes'; -import { cmdAddObjectInDatabase, cmdBackupDatabase, cmdCreateDatabase, cmdDatabaseDashboard, cmdDatabaseOperations, cmdDeleteDatabase, cmdDisconnectDatabase as cmdDisconnectDatabaseLegacy, cmdGenerateCreateScript, cmdMaintenanceDatabase, cmdPsqlTool, cmdQueryTool, cmdRestoreDatabase, cmdScriptAlterDatabase, cmdShowConfiguration } from '../commands/database'; -import { cmdDropExtension, cmdEnableExtension, cmdExtensionOperations, cmdRefreshExtension } from '../commands/extensions'; -import { cmdCreateForeignTable, cmdDropForeignTable, cmdEditForeignTable, cmdForeignTableOperations, cmdRefreshForeignTable, cmdShowForeignTableProperties, cmdViewForeignTableData } from '../commands/foreignTables'; -import { cmdForeignDataWrapperOperations, cmdShowForeignDataWrapperProperties, cmdCreateForeignServer, cmdForeignServerOperations, cmdShowForeignServerProperties, cmdDropForeignServer, cmdCreateUserMapping, cmdUserMappingOperations, cmdShowUserMappingProperties, cmdDropUserMapping, cmdRefreshForeignDataWrapper, cmdRefreshForeignServer, cmdRefreshUserMapping } from '../commands/foreignDataWrappers'; -import { cmdCallFunction, cmdCreateFunction, cmdDropFunction, cmdEditFunction, cmdFunctionOperations, cmdRefreshFunction, cmdShowFunctionProperties } from '../commands/functions'; -import { cmdCallProcedure, cmdCreateProcedure, cmdDropProcedure, cmdEditProcedure, cmdProcedureOperations, cmdRefreshProcedure, cmdShowProcedureProperties } from '../commands/procedures'; -import { cmdCreateMaterializedView, cmdDropMatView, cmdEditMatView, cmdMatViewOperations, cmdRefreshMatView, cmdViewMatViewData, cmdViewMatViewProperties } from '../commands/materializedViews'; -import { cmdNewNotebook, cmdExplainQuery, cmdJumpToSection } from '../commands/notebook'; -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'; - -import { AiSettingsPanel } from '../aiSettingsPanel'; -import { ConnectionFormPanel } from '../connectionForm'; -import { ConnectionManagementPanel } from '../connectionManagement'; -import { ConnectionUtils } from '../utils/connectionUtils'; - -// Phase 7: Advanced Power User & AI features -import { - switchConnectionProfile, - createConnectionProfile, - deleteConnectionProfile, - saveQueryToLibrary, - saveQueryToLibraryUI, - loadSavedQuery, - loadSavedQueryUI, - viewSavedQuery, - deleteSavedQuery, - copySavedQuery, - editSavedQuery, - openSavedQueryInNotebook, - exportSavedQueries, - importSavedQueries, - searchSavedQueries, - showQueryRecommendations, - exportConnectionProfiles, - importConnectionProfiles, -} from '../commands/phase7'; -import { SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; -import { pickQueryHistory } from '../commands/pickQueryHistory'; - -// Visual Schema Design -import { cmdOpenTableDesigner, cmdCreateTableVisual, cmdOpenSchemaDiff, cmdOpenErd, cmdImportData } from '../commands/schemaDesigner'; -import { NotebookTreeItem, NotebooksTreeProvider } from '../providers/NotebooksTreeProvider'; - -// Phase 2: New object types -import { cmdListTriggers, cmdCreateTrigger, cmdDropTrigger, cmdEnableTrigger, cmdDisableTrigger, cmdShowTriggerProperties, cmdTriggerOperations } from '../commands/triggers'; -import { cmdListSequences, cmdCreateSequence, cmdDropSequence, cmdSequenceNextValue, cmdShowSequenceProperties, cmdSequenceOperations } from '../commands/sequences'; -import { cmdListPartitions, cmdDetachPartition, cmdShowPartitionProperties, cmdCreatePartition } from '../commands/partitions'; -import { cmdListDomains, cmdCreateDomain, cmdDropDomain, cmdShowDomainProperties } from '../commands/domains'; -import { cmdListAggregates, cmdDropAggregate, cmdShowAggregateProperties, cmdCreateAggregate } from '../commands/aggregates'; -import { cmdListEventTriggers, cmdCreateEventTrigger, cmdDropEventTrigger, cmdEnableEventTrigger, cmdDisableEventTrigger, cmdShowEventTriggerProperties, cmdEventTriggerOperations } from '../commands/eventTriggers'; -import { cmdListRules, cmdDropRule, cmdShowRuleProperties, cmdRuleOperations } from '../commands/rules'; -import { cmdListTablespaces, cmdShowTablespaceProperties, cmdTablespaceOperations } from '../commands/tablespaces'; -import { cmdListPublications, cmdCreatePublication, cmdDropPublication, cmdShowPublicationProperties, cmdListSubscriptions, cmdDropSubscription, cmdShowSubscriptionProperties, cmdPublicationOperations } from '../commands/publications'; -import { cmdSearchSchema } from '../commands/schemaSearch'; - -export function registerAllCommands( - context: vscode.ExtensionContext, - databaseTreeProvider: DatabaseTreeProvider, - chatViewProviderInstance: ChatViewProvider | undefined, - outputChannel: vscode.OutputChannel, - savedQueriesTreeProvider?: SavedQueriesTreeProvider, - notebooksTreeProvider?: NotebooksTreeProvider -) { - const commands = [ - { - command: 'postgres-explorer.addConnection', - callback: () => { - // Explicitly pass undefined to force "Add" mode, ignoring any arguments VS Code might pass - ConnectionFormPanel.show(context.extensionUri, context, undefined); - } - }, - { - command: 'postgres-explorer.editConnection', - callback: (item: DatabaseTreeItem) => { - if (!item || !item.connectionId) return; - const connection = ConnectionUtils.findConnection(item.connectionId); - if (connection) { - ConnectionFormPanel.show(context.extensionUri, context, connection); - } - } - }, - { - command: 'postgres-explorer.refreshConnections', - callback: () => { - databaseTreeProvider.refresh(); - } - }, - { - command: 'postgres-explorer.clearHistory', - callback: async () => { - await QueryHistoryService.getInstance().clear(); - vscode.window.showInformationMessage('Query history cleared'); - } - }, - { - command: 'postgres-explorer.pickQueryHistory', - callback: () => pickQueryHistory() - }, - { - command: 'postgres-explorer.copyQuery', - callback: async (item: any) => { - // Handle both direct string (from context menu if configured that way) or TreeItem - const query = typeof item === 'string' ? item : item?.query; - if (query) { - await vscode.env.clipboard.writeText(query); - vscode.window.showInformationMessage('Query copied to clipboard'); - } - } - }, - { - command: 'postgres-explorer.deleteHistoryItem', - callback: async (item: any) => { - if (item && item.id) { - await QueryHistoryService.getInstance().delete(item.id); - } - } - }, - { - command: 'postgres-explorer.openQuery', - callback: async (item: any) => { - const query = typeof item === 'string' ? item : item?.query; - if (query) { - const doc = await vscode.workspace.openTextDocument({ - content: query, - language: 'sql' - }); - await vscode.window.showTextDocument(doc); - } - } - }, - { - command: 'postgres-explorer.explainQuery', - callback: async (cellUri: vscode.Uri, analyze: boolean) => { - await cmdExplainQuery(cellUri, analyze); - } - }, - { - command: 'postgres-explorer.tableProfile', - callback: async (item: DatabaseTreeItem) => await cmdTableProfile(item, context) - }, - { - command: 'postgres-explorer.tableActivity', - callback: async (item: DatabaseTreeItem) => await cmdTableActivity(item, context) - }, - { - command: 'postgres-explorer.indexUsage', - callback: async (item: DatabaseTreeItem) => await cmdIndexUsage(item, context) - }, - { - command: 'postgres-explorer.tableDefinition', - callback: async (item: DatabaseTreeItem) => await cmdTableDefinition(item, context) - }, - { - command: 'postgres-explorer.generateQuery', - callback: async () => { - if (!chatViewProviderInstance) { - vscode.window.showErrorMessage('AI Chat is not initialized'); - return; - } - - // Step 1: Get all connections - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - - if (connections.length === 0) { - vscode.window.showErrorMessage('No database connections found. Please add a connection first.'); - return; - } - - // Step 2: Let user select connection - const connectionItems = connections.map(conn => ({ - label: conn.name, - description: `${conn.host}:${conn.port}/${conn.database}`, - connection: conn - })); - - const selectedConnection = await vscode.window.showQuickPick(connectionItems, { - placeHolder: 'Select a database connection', - title: 'Generate Query - Select Database' - }); - - if (!selectedConnection) { - return; - } - - // Step 3: Fetch database objects (tables, views, functions) - try { - const dbObjects = await databaseTreeProvider.getDbObjectsForConnection(selectedConnection.connection); - - if (!dbObjects || dbObjects.length === 0) { - vscode.window.showWarningMessage('No tables, views, or functions found in this database.'); - // Continue anyway, let user describe query without schema - const input = await vscode.window.showInputBox({ - prompt: 'Describe the SQL query you want to generate', - placeHolder: 'e.g., Show me top 10 users by order count' - }); - - if (input) { - vscode.commands.executeCommand('postgres-explorer.chatView.focus'); - await chatViewProviderInstance.handleGenerateQuery(input); - } - return; - } - - // Step 4: Let user select relevant objects - const objectItems = dbObjects.map(obj => ({ - label: `${obj.type === 'table' ? '📋' : obj.type === 'view' ? '👁️' : '⚙️'} ${obj.schema}.${obj.name}`, - description: obj.type, - picked: false, - object: obj - })); - - const selectedObjects = await vscode.window.showQuickPick(objectItems, { - placeHolder: 'Select tables, views, or functions (multi-select)', - title: 'Generate Query - Select Database Objects', - canPickMany: true - }); - - if (!selectedObjects || selectedObjects.length === 0) { - const proceed = await vscode.window.showWarningMessage( - 'No objects selected. Generate query without schema context?', - 'Yes', 'No' - ); - - if (proceed !== 'Yes') { - return; - } - } - - // Step 5: Get query description - const input = await vscode.window.showInputBox({ - prompt: 'Describe the SQL query you want to generate', - placeHolder: 'e.g., Show me top 10 users by order count in the last month' - }); - - if (input) { - // Focus the chat view - vscode.commands.executeCommand('postgres-explorer.chatView.focus'); - - // Send to AI with schema context - const schemaContext = selectedObjects ? selectedObjects.map(item => item.object) : undefined; - await chatViewProviderInstance.handleGenerateQuery(input, schemaContext); - } - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to fetch database objects: ${error.message}`); - } - } - }, - { - command: 'postgres-explorer.optimizeQuery', - callback: async () => { - if (!chatViewProviderInstance) { - vscode.window.showErrorMessage('AI Chat is not initialized'); - return; - } - - let query = ''; - const activeNotebookEditor = vscode.window.activeNotebookEditor; - if (activeNotebookEditor && activeNotebookEditor.selections.length > 0) { - const cellIndex = activeNotebookEditor.selections[0].start; - const cell = activeNotebookEditor.notebook.cellAt(cellIndex); - query = cell.document.getText().trim(); - } - - if (!query) { - query = (await vscode.window.showInputBox({ - prompt: 'Paste or type the SQL query you want to optimize', - placeHolder: 'SELECT ...' - }))?.trim() || ''; - } - - if (!query) { - vscode.window.showWarningMessage('No query provided for optimization.'); - return; - } - - await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); - await chatViewProviderInstance.handleOptimizeQuery(query); - } - }, - { - command: 'postgres-explorer.addToFavorites', - callback: async (item: DatabaseTreeItem) => { - if (item) { - await databaseTreeProvider.addToFavorites(item); - } - } - }, - { - command: 'postgres-explorer.removeFromFavorites', - callback: async (item: DatabaseTreeItem) => { - if (item) { - await databaseTreeProvider.removeFromFavorites(item); - } - } - }, - { - command: 'postgres-explorer.manageConnections', - callback: () => { - ConnectionManagementPanel.show(context.extensionUri, context); - } - }, - { - command: 'postgres-explorer.aiSettings', - callback: () => { - AiSettingsPanel.show(context.extensionUri, context); - } - }, - { - command: 'postgres-explorer.connect', - callback: async (item: any) => await cmdConnectDatabase(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.disconnect', - callback: async () => { - databaseTreeProvider.refresh(); - vscode.window.showInformationMessage('Disconnected from PostgreSQL database'); - } - }, - { - command: 'postgres-explorer.queryTable', - callback: async (item: any) => { - if (!item || !item.schema) { - return; - } - - const query = `SELECT * FROM ${item.schema}.${item.label} LIMIT 100;`; - const notebook = await vscode.workspace.openNotebookDocument('postgres-notebook', new vscode.NotebookData([ - new vscode.NotebookCellData(vscode.NotebookCellKind.Code, query, 'sql') - ])); - await vscode.window.showNotebookDocument(notebook); - } - }, - { - command: 'postgres-explorer.newNotebook', - callback: async (item: any) => await cmdNewNotebook(item, context) - }, - { - command: 'postgres-explorer.jumpToSection', - callback: async () => await cmdJumpToSection() - }, - { - command: 'postgres-explorer.notebooks.refresh', - callback: () => notebooksTreeProvider?.refresh() - }, - { - command: 'postgres-explorer.notebooks.open', - callback: async (item: NotebookTreeItem) => { - if (item?.uri) { - const doc = await vscode.workspace.openNotebookDocument(item.uri); - await vscode.window.showNotebookDocument(doc, { preserveFocus: false }); - } - } - }, - { - command: 'postgres-explorer.notebooks.rename', - callback: async (item: NotebookTreeItem) => { - if (!item?.uri) { return; } - const oldName = (item.label as string); - const newName = await vscode.window.showInputBox({ - prompt: 'New notebook name', - value: oldName, - validateInput: v => v && /^[a-zA-Z0-9_-]+$/.test(v) ? null : 'Use only letters, numbers, hyphens, underscores' - }); - if (!newName || newName === oldName) { return; } - const newUri = vscode.Uri.joinPath(item.uri, '..', `${newName}.pgsql`); - await vscode.workspace.fs.rename(item.uri, newUri, { overwrite: false }); - notebooksTreeProvider?.refresh(); - } - }, - { - command: 'postgres-explorer.notebooks.delete', - callback: async (item: NotebookTreeItem) => { - if (!item?.uri) { return; } - const confirm = await vscode.window.showWarningMessage( - `Delete "${item.label as string}.pgsql"? This cannot be undone.`, - { modal: true }, 'Delete' - ); - if (confirm !== 'Delete') { return; } - await vscode.workspace.fs.delete(item.uri, { recursive: false }); - notebooksTreeProvider?.refresh(); - } - }, - { - command: 'postgres-explorer.notebooks.deleteFolder', - callback: async (item: NotebookTreeItem) => { - if (!item?.uri) { return; } - const folderName = item.label as string; - const confirm = await vscode.window.showWarningMessage( - `Delete folder "${folderName}" and all notebooks inside it? This cannot be undone.`, - { modal: true }, - 'Delete Folder' - ); - if (confirm !== 'Delete Folder') { return; } - await vscode.workspace.fs.delete(item.uri, { recursive: true, useTrash: false }); - notebooksTreeProvider?.refresh(); - } - }, - { - command: 'postgres-explorer.refresh', - callback: () => databaseTreeProvider.refresh() - }, - // Add database commands - { - command: 'postgres-explorer.createInDatabase', - callback: async (item: DatabaseTreeItem) => await cmdAddObjectInDatabase(item, context) - }, - { - command: 'postgres-explorer.createDatabase', - callback: async (item: DatabaseTreeItem) => await cmdCreateDatabase(item, context) - }, - { - command: 'postgres-explorer.dropDatabase', - callback: async (item: DatabaseTreeItem) => await cmdDeleteDatabase(item, context) - }, - { - command: 'postgres-explorer.scriptAlterDatabase', - callback: async (item: DatabaseTreeItem) => await cmdScriptAlterDatabase(item, context) - }, - { - command: 'postgres-explorer.databaseOperations', - callback: async (item: DatabaseTreeItem) => await cmdDatabaseOperations(item, context) - }, - { - command: 'postgres-explorer.showDashboard', - callback: async (item: DatabaseTreeItem) => await cmdDatabaseDashboard(item, context) - }, - { - command: 'postgres-explorer.backupDatabase', - callback: async (item: DatabaseTreeItem) => await cmdBackupDatabase(item, context) - }, - { - command: 'postgres-explorer.restoreDatabase', - callback: async (item: DatabaseTreeItem) => await cmdRestoreDatabase(item, context) - }, - { - command: 'postgres-explorer.generateCreateScript', - callback: async (item: DatabaseTreeItem) => await cmdGenerateCreateScript(item, context) - }, - { - command: 'postgres-explorer.disconnectDatabase', - callback: async (item: DatabaseTreeItem) => await cmdDisconnectDatabaseLegacy(item, context) - }, - { - command: 'postgres-explorer.maintenanceDatabase', - callback: async (item: DatabaseTreeItem) => await cmdMaintenanceDatabase(item, context) - }, - { - command: 'postgres-explorer.queryTool', - callback: async (item: DatabaseTreeItem) => await cmdQueryTool(item, context) - }, - { - command: 'postgres-explorer.psqlTool', - callback: async (item: DatabaseTreeItem) => await cmdPsqlTool(item, context) - }, - { - command: 'postgres-explorer.showConfiguration', - callback: async (item: DatabaseTreeItem) => await cmdShowConfiguration(item, context) - }, - // Add schema commands - { - command: 'postgres-explorer.createSchema', - callback: async (item: DatabaseTreeItem) => await cmdCreateSchema(item, context) - }, - { - command: 'postgres-explorer.createInSchema', - callback: async (item: DatabaseTreeItem) => await cmdCreateObjectInSchema(item, context) - }, - { - command: 'postgres-explorer.schemaOperations', - callback: async (item: DatabaseTreeItem) => await cmdSchemaOperations(item, context) - }, - { - command: 'postgres-explorer.showSchemaProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowSchemaProperties(item, context) - }, - // Add table commands - { - command: 'postgres-explorer.editTable', - callback: async (item: DatabaseTreeItem) => await cmdEditTable(item, context) - }, - { - command: 'postgres-explorer.viewTableData', - callback: async (item: DatabaseTreeItem) => { - await databaseTreeProvider.addToRecent(item); - await cmdViewTableData(item, context); - } - }, - { - command: 'postgres-explorer.dropTable', - callback: async (item: DatabaseTreeItem) => await cmdDropTable(item, context) - }, - { - command: 'postgres-explorer.tableOperations', - callback: async (item: DatabaseTreeItem) => await cmdTableOperations(item, context) - }, - { - 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) - }, - { - command: 'postgres-explorer.showTableProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowTableProperties(item, context) - }, - // Add script commands - { - command: 'postgres-explorer.scriptSelect', - callback: async (item: DatabaseTreeItem) => await cmdScriptSelect(item, context) - }, - { - command: 'postgres-explorer.scriptInsert', - callback: async (item: DatabaseTreeItem) => await cmdScriptInsert(item, context) - }, - { - command: 'postgres-explorer.scriptUpdate', - callback: async (item: DatabaseTreeItem) => await cmdScriptUpdate(item, context) - }, - { - command: 'postgres-explorer.scriptDelete', - callback: async (item: DatabaseTreeItem) => await cmdScriptDelete(item, context) - }, - { - command: 'postgres-explorer.scriptCreate', - callback: async (item: DatabaseTreeItem) => await cmdScriptCreate(item, context) - }, - // Add maintenance commands - { - command: 'postgres-explorer.maintenanceVacuum', - callback: async (item: DatabaseTreeItem) => await cmdMaintenanceVacuum(item, context) - }, - { - command: 'postgres-explorer.maintenanceAnalyze', - callback: async (item: DatabaseTreeItem) => await cmdMaintenanceAnalyze(item, context) - }, - { - command: 'postgres-explorer.maintenanceReindex', - callback: async (item: DatabaseTreeItem) => await cmdMaintenanceReindex(item, context) - }, - - // Add view commands - { - command: 'postgres-explorer.refreshView', - callback: async (item: DatabaseTreeItem) => await cmdRefreshView(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.editViewDefinition', - callback: async (item: DatabaseTreeItem) => await cmdEditView(item, context) - }, - { - command: 'postgres-explorer.viewViewData', - callback: async (item: DatabaseTreeItem) => { - await databaseTreeProvider.addToRecent(item); - await cmdViewData(item, context); - } - }, - { - command: 'postgres-explorer.dropView', - callback: async (item: DatabaseTreeItem) => await cmdDropView(item, context) - }, - { - command: 'postgres-explorer.viewOperations', - callback: async (item: DatabaseTreeItem) => await cmdViewOperations(item, context) - }, - { - command: 'postgres-explorer.showViewProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowViewProperties(item, context) - }, - { - command: 'postgres-explorer.viewScriptSelect', - callback: async (item: DatabaseTreeItem) => await cmdViewScriptSelect(item, context) - }, - { - command: 'postgres-explorer.viewScriptCreate', - callback: async (item: DatabaseTreeItem) => await cmdViewScriptCreate(item, context) - }, - // Add function commands - { - command: 'postgres-explorer.refreshFunction', - callback: async (item: DatabaseTreeItem) => await cmdRefreshFunction(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.showFunctionProperties', - callback: async (item: DatabaseTreeItem) => { - await databaseTreeProvider.addToRecent(item); - await cmdShowFunctionProperties(item, context); - } - }, - { - command: 'postgres-explorer.functionOperations', - callback: async (item: DatabaseTreeItem) => await cmdFunctionOperations(item, context) - }, - { - command: 'postgres-explorer.createReplaceFunction', - callback: async (item: DatabaseTreeItem) => await cmdEditFunction(item, context) - }, - { - command: 'postgres-explorer.callFunction', - callback: async (item: DatabaseTreeItem) => await cmdCallFunction(item, context) - }, - { - command: 'postgres-explorer.dropFunction', - callback: async (item: DatabaseTreeItem) => await cmdDropFunction(item, context) - }, - // Add procedure commands - { - command: 'postgres-explorer.refreshProcedure', - callback: async (item: DatabaseTreeItem) => await cmdRefreshProcedure(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.showProcedureProperties', - callback: async (item: DatabaseTreeItem) => { - await databaseTreeProvider.addToRecent(item); - await cmdShowProcedureProperties(item, context); - } - }, - { - command: 'postgres-explorer.procedureOperations', - callback: async (item: DatabaseTreeItem) => await cmdProcedureOperations(item, context) - }, - { - command: 'postgres-explorer.createReplaceProcedure', - callback: async (item: DatabaseTreeItem) => await cmdEditProcedure(item, context) - }, - { - command: 'postgres-explorer.callProcedure', - callback: async (item: DatabaseTreeItem) => await cmdCallProcedure(item, context) - }, - { - command: 'postgres-explorer.dropProcedure', - callback: async (item: DatabaseTreeItem) => await cmdDropProcedure(item, context) - }, - { - command: 'postgres-explorer.createProcedure', - callback: async (item: DatabaseTreeItem) => await cmdCreateProcedure(item, context) - }, - // Add materialized view commands - { - command: 'postgres-explorer.refreshMaterializedView', - callback: async (item: DatabaseTreeItem) => await cmdRefreshMatView(item, context) - }, - { - command: 'postgres-explorer.editMatView', - callback: async (item: DatabaseTreeItem) => await cmdEditMatView(item, context) - }, - { - command: 'postgres-explorer.editMaterializedView', - callback: async (item: DatabaseTreeItem) => await cmdEditMatView(item, context) - }, - { - command: 'postgres-explorer.viewMaterializedViewData', - callback: async (item: DatabaseTreeItem) => await cmdViewMatViewData(item, context) - }, - { - command: 'postgres-explorer.showMaterializedViewProperties', - callback: async (item: DatabaseTreeItem) => await cmdViewMatViewProperties(item, context) - }, - { - command: 'postgres-explorer.dropMatView', - callback: async (item: DatabaseTreeItem) => await cmdDropMatView(item, context) - }, - { - command: 'postgres-explorer.materializedViewOperations', - callback: async (item: DatabaseTreeItem) => await cmdMatViewOperations(item, context) - }, - // Add type commands - { - command: 'postgres-explorer.refreshType', - callback: async (item: DatabaseTreeItem) => await cmdRefreshType(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.typeOperations', - callback: async (item: DatabaseTreeItem) => await cmdAllOperationsTypes(item, context) - }, - { - command: 'postgres-explorer.editType', - callback: async (item: DatabaseTreeItem) => await cmdEditTypes(item, context) - }, - { - command: 'postgres-explorer.showTypeProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowTypeProperties(item, context) - }, - { - command: 'postgres-explorer.dropType', - callback: async (item: DatabaseTreeItem) => await cmdDropType(item, context) - }, - // Add foreign table commands - { - command: 'postgres-explorer.refreshForeignTable', - callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignTable(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.foreignTableOperations', - callback: async (item: DatabaseTreeItem) => await cmdForeignTableOperations(item, context) - }, - { - command: 'postgres-explorer.editForeignTable', - callback: async (item: DatabaseTreeItem) => await cmdEditForeignTable(item, context) - }, - { - command: 'postgres-explorer.showForeignTableProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowForeignTableProperties(item, context) - }, - { - command: 'postgres-explorer.viewForeignTableData', - callback: async (item: DatabaseTreeItem) => await cmdViewForeignTableData(item, context) - }, - { - command: 'postgres-explorer.dropForeignTable', - callback: async (item: DatabaseTreeItem) => await cmdDropForeignTable(item, context) - }, - // Add role/user commands - { - command: 'postgres-explorer.refreshRole', - callback: async (item: DatabaseTreeItem) => await cmdRefreshRole(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.createUser', - callback: async (item: DatabaseTreeItem) => await cmdAddUser(item, context) - }, - { - command: 'postgres-explorer.createRole', - callback: async (item: DatabaseTreeItem) => await cmdAddRole(item, context) - }, - { - command: 'postgres-explorer.editRole', - callback: async (item: DatabaseTreeItem) => await cmdEditRole(item, context) - }, - { - command: 'postgres-explorer.grantRevoke', - callback: async (item: DatabaseTreeItem) => await cmdGrantRevokeRole(item, context) - }, - { - command: 'postgres-explorer.dropRole', - callback: async (item: DatabaseTreeItem) => await cmdDropRole(item, context) - }, - { - command: 'postgres-explorer.roleOperations', - callback: async (item: DatabaseTreeItem) => await cmdRoleOperations(item, context) - }, - { - command: 'postgres-explorer.showRoleProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowRoleProperties(item, context) - }, - // Add extension commands - { - command: 'postgres-explorer.refreshExtension', - callback: async (item: DatabaseTreeItem) => await cmdRefreshExtension(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.enableExtension', - callback: async (item: DatabaseTreeItem) => await cmdEnableExtension(item, context) - }, - { - command: 'postgres-explorer.extensionOperations', - callback: async (item: DatabaseTreeItem) => await cmdExtensionOperations(item, context) - }, - { - command: 'postgres-explorer.dropExtension', - callback: async (item: DatabaseTreeItem) => await cmdDropExtension(item, context) - }, - // Add connection commands - { - command: 'postgres-explorer.disconnectConnection', - callback: async (item: DatabaseTreeItem) => await cmdDisconnectConnection(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.reconnectConnection', - callback: async (item: DatabaseTreeItem) => await cmdReconnectConnection(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.deleteConnection', - callback: async (item: DatabaseTreeItem) => await cmdDisconnectDatabase(item, context, databaseTreeProvider) - }, - - { - command: 'postgres-explorer.createTable', - callback: async (item: DatabaseTreeItem) => await cmdCreateTable(item, context) - }, - { - command: 'postgres-explorer.createView', - callback: async (item: DatabaseTreeItem) => await cmdCreateView(item, context) - }, - { - command: 'postgres-explorer.createFunction', - callback: async (item: DatabaseTreeItem) => await cmdCreateFunction(item, context) - }, - { - command: 'postgres-explorer.createMaterializedView', - callback: async (item: DatabaseTreeItem) => await cmdCreateMaterializedView(item, context) - }, - { - command: 'postgres-explorer.createType', - callback: async (item: DatabaseTreeItem) => await cmdCreateType(item, context) - }, - { - command: 'postgres-explorer.createForeignTable', - callback: async (item: DatabaseTreeItem) => await cmdCreateForeignTable(item, context) - }, - // Foreign Data Wrapper commands - { - command: 'postgres-explorer.foreignDataWrapperOperations', - callback: async (item: DatabaseTreeItem) => await cmdForeignDataWrapperOperations(item, context) - }, - { - command: 'postgres-explorer.showForeignDataWrapperProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowForeignDataWrapperProperties(item, context) - }, - { - command: 'postgres-explorer.refreshForeignDataWrapper', - callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignDataWrapper(item, context, databaseTreeProvider) - }, - // Foreign Server commands - { - command: 'postgres-explorer.createForeignServer', - callback: async (item: DatabaseTreeItem) => await cmdCreateForeignServer(item, context) - }, - { - command: 'postgres-explorer.foreignServerOperations', - callback: async (item: DatabaseTreeItem) => await cmdForeignServerOperations(item, context) - }, - { - command: 'postgres-explorer.showForeignServerProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowForeignServerProperties(item, context) - }, - { - command: 'postgres-explorer.dropForeignServer', - callback: async (item: DatabaseTreeItem) => await cmdDropForeignServer(item, context) - }, - { - command: 'postgres-explorer.refreshForeignServer', - callback: async (item: DatabaseTreeItem) => await cmdRefreshForeignServer(item, context, databaseTreeProvider) - }, - // User Mapping commands - { - command: 'postgres-explorer.createUserMapping', - callback: async (item: DatabaseTreeItem) => await cmdCreateUserMapping(item, context) - }, - { - command: 'postgres-explorer.userMappingOperations', - callback: async (item: DatabaseTreeItem) => await cmdUserMappingOperations(item, context) - }, - { - command: 'postgres-explorer.showUserMappingProperties', - callback: async (item: DatabaseTreeItem) => await cmdShowUserMappingProperties(item, context) - }, - { - command: 'postgres-explorer.dropUserMapping', - callback: async (item: DatabaseTreeItem) => await cmdDropUserMapping(item, context) - }, - { - command: 'postgres-explorer.refreshUserMapping', - callback: async (item: DatabaseTreeItem) => await cmdRefreshUserMapping(item, context, databaseTreeProvider) - }, - { - command: 'postgres-explorer.createRole', - callback: async (item: DatabaseTreeItem) => await cmdAddRole(item, context) - }, - { - command: 'postgres-explorer.enableExtension', - callback: async (item: DatabaseTreeItem) => await cmdEnableExtension(item, context) - }, - - { - command: 'postgres-explorer.aiAssist', - callback: async (cell: vscode.NotebookCell) => await cmdAiAssist(cell, context, outputChannel) - }, - - { - command: 'postgres-explorer.chatWithQuery', - callback: async (cell: vscode.NotebookCell) => { - // Get the query from the active cell - let query = ''; - let results = ''; - - if (cell) { - query = cell.document.getText(); - // Check if there are outputs from previous execution - if (cell.outputs && cell.outputs.length > 0) { - const output = cell.outputs[0]; - for (const item of output.items) { - if (item.mime === 'application/x-postgres-result' || item.mime === 'application/json') { - try { - const data = JSON.parse(new TextDecoder().decode(item.data)); - if (data.rows && data.rows.length > 0) { - results = `\nResults (${data.rows.length} rows): ${JSON.stringify(data.rows.slice(0, 5), null, 2)}${data.rows.length > 5 ? '\n... and more' : ''}`; - } - } catch (e) { - // Ignore parse errors - } - } - } - } - } else { - // Fallback to active notebook editor - const activeEditor = vscode.window.activeNotebookEditor; - if (activeEditor) { - const selections = activeEditor.selections; - if (selections && selections.length > 0) { - const cellIndex = selections[0].start; - const activeCell = activeEditor.notebook.cellAt(cellIndex); - query = activeCell.document.getText(); - } - } - } - - if (!query) { - vscode.window.showWarningMessage('No query found in the active cell.'); - return; - } - - // Focus the chat view and send the query - await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); - - // Send message to chat view with query context - const message = `Help me with this SQL query:\n\`\`\`sql\n${query}\n\`\`\`${results}`; - - // Use the chat view provider to send the message - if (chatViewProviderInstance) { - chatViewProviderInstance.sendToChat({ query, results, message }); - } - } - }, - - { - command: 'postgres-explorer.sendToChat', - callback: async (data: { query: string; results?: string; message: string }) => { - if (chatViewProviderInstance) { - await chatViewProviderInstance.sendToChat(data); - } - } - }, - - { - command: 'postgres-explorer.attachToChat', - callback: async (item: DatabaseTreeItem) => { - if (!chatViewProviderInstance) { - vscode.window.showWarningMessage('SQL Assistant is not available'); - return; - } - if (!item || !item.connectionId || !item.databaseName) { - vscode.window.showErrorMessage('Invalid database object'); - return; - } - - // Resolve connection name from config - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - const conn = connections.find(c => c.id === item.connectionId); - const connectionName = conn?.name || conn?.host || 'Unknown'; - - // Convert DatabaseTreeItem to DbObject - const dbObject: any = { - name: item.label, - type: item.type, - schema: item.schema || '', - database: item.databaseName, - connectionId: item.connectionId, - connectionName: connectionName, - breadcrumb: [connectionName, item.databaseName, item.schema, item.label].filter(Boolean).join(' > ') - }; - - await chatViewProviderInstance.attachDbObject(dbObject); - } - }, - - // Column commands - { - command: 'postgres-explorer.showColumnProperties', - callback: async (item: DatabaseTreeItem) => await showColumnProperties(item) - }, - { - command: 'postgres-explorer.copyColumnName', - callback: async (item: DatabaseTreeItem) => await copyColumnName(item) - }, - { - command: 'postgres-explorer.copyColumnNameQuoted', - callback: async (item: DatabaseTreeItem) => await copyColumnNameQuoted(item) - }, - { - command: 'postgres-explorer.generateSelectStatement', - callback: async (item: DatabaseTreeItem) => await generateSelectStatement(item) - }, - { - command: 'postgresExplorer.openColumnNotebook', - callback: async (item: DatabaseTreeItem) => await showColumnProperties(item) - }, - { - command: 'postgres-explorer.generateWhereClause', - callback: async (item: DatabaseTreeItem) => await generateWhereClause(item) - }, - { - command: 'postgres-explorer.generateAlterColumnScript', - callback: async (item: DatabaseTreeItem) => await generateAlterColumnScript(item) - }, - { - command: 'postgres-explorer.generateDropColumnScript', - callback: async (item: DatabaseTreeItem) => await generateDropColumnScript(item) - }, - { - command: 'postgres-explorer.generateRenameColumnScript', - callback: async (item: DatabaseTreeItem) => await generateRenameColumnScript(item) - }, - { - command: 'postgres-explorer.addColumnComment', - callback: async (item: DatabaseTreeItem) => await addColumnComment(item) - }, - { - command: 'postgres-explorer.generateIndexOnColumn', - callback: async (item: DatabaseTreeItem) => await generateIndexOnColumn(item) - }, - { - command: 'postgres-explorer.viewColumnStatistics', - callback: async (item: DatabaseTreeItem) => await viewColumnStatistics(item) - }, - - // Constraint commands - { - command: 'postgres-explorer.showConstraintProperties', - callback: async (item: DatabaseTreeItem) => await showConstraintProperties(item) - }, - { - command: 'postgres-explorer.copyConstraintName', - callback: async (item: DatabaseTreeItem) => await copyConstraintName(item) - }, - { - command: 'postgres-explorer.generateDropConstraintScript', - callback: async (item: DatabaseTreeItem) => await generateDropConstraintScript(item) - }, - { - command: 'postgres-explorer.generateAlterConstraintScript', - callback: async (item: DatabaseTreeItem) => await generateAlterConstraintScript(item) - }, - { - command: 'postgres-explorer.validateConstraint', - callback: async (item: DatabaseTreeItem) => await validateConstraint(item) - }, - { - command: 'postgres-explorer.generateAddConstraintScript', - callback: async (item: DatabaseTreeItem) => await generateAddConstraintScript(item) - }, - { - command: 'postgres-explorer.viewConstraintDependencies', - callback: async (item: DatabaseTreeItem) => await viewConstraintDependencies(item) - }, - { - command: 'postgres-explorer.constraintOperations', - callback: async (item: DatabaseTreeItem) => await cmdConstraintOperations(item, context) - }, - - // Index commands - { - command: 'postgres-explorer.showIndexProperties', - callback: async (item: DatabaseTreeItem) => await showIndexProperties(item) - }, - { - command: 'postgres-explorer.copyIndexName', - callback: async (item: DatabaseTreeItem) => await copyIndexName(item) - }, - { - command: 'postgres-explorer.generateDropIndexScript', - callback: async (item: DatabaseTreeItem) => await generateDropIndexScript(item) - }, - { - command: 'postgres-explorer.generateReindexScript', - callback: async (item: DatabaseTreeItem) => await generateReindexScript(item) - }, - { - command: 'postgres-explorer.generateScriptCreate', - callback: async (item: DatabaseTreeItem) => await generateScriptCreate(item) - }, - { - command: 'postgres-explorer.analyzeIndexUsage', - callback: async (item: DatabaseTreeItem) => await analyzeIndexUsage(item) - }, - { - command: 'postgres-explorer.generateAlterIndexScript', - callback: async (item: DatabaseTreeItem) => await generateAlterIndexScript(item) - }, - { - command: 'postgres-explorer.addIndexComment', - callback: async (item: DatabaseTreeItem) => await addIndexComment(item) - }, - { - command: 'postgres-explorer.indexOperations', - callback: async (item: DatabaseTreeItem) => await cmdIndexOperations(item, context) - }, - { - command: 'postgres-explorer.addColumn', - callback: async (item: DatabaseTreeItem) => await cmdAddColumn(item) - }, - { - command: 'postgres-explorer.addConstraint', - callback: async (item: DatabaseTreeItem) => await cmdAddConstraint(item) - }, - { - command: 'postgres-explorer.addIndex', - callback: async (item: DatabaseTreeItem) => await cmdAddIndex(item) - }, - - // Breadcrumb navigation commands - { - command: 'postgres-explorer.switchConnection', - callback: async () => { - const editor = ConnectionUtils.getActivePostgresNotebook(); - if (!editor) { - vscode.window.showWarningMessage('No active PostgreSQL notebook.'); - return; - } - - const metadata = editor.notebook.metadata as any; - const selected = await ConnectionUtils.showConnectionPicker(metadata?.connectionId); - - if (selected) { - await ConnectionUtils.updateNotebookMetadata(editor.notebook, { - connectionId: selected.id, - databaseName: selected.database, - host: selected.host, - port: selected.port, - username: selected.username - }); - vscode.window.showInformationMessage(`Switched to: ${selected.name || selected.host}`); - } - } - }, - { - command: 'postgres-explorer.showConnectionSafety', - callback: showConnectionSafety - }, - { - command: 'postgres-explorer.revealInExplorer', - callback: () => revealInExplorer(databaseTreeProvider) - }, - { - command: 'postgres-explorer.navigateBreadcrumb', - callback: async (args: { type: string; connectionId?: string; database?: string; schema?: string; object?: string }) => { - // Reveal the item in the database tree based on breadcrumb segment - if (args?.type === 'connection' && args.connectionId) { - // Focus database explorer and reveal connection - await vscode.commands.executeCommand('postgresExplorer.focus'); - } - // Future: could expand tree to specific schema/table - } - }, - { - command: 'postgres-explorer.copyBreadcrumbPath', - callback: async (args: { connectionName?: string; database?: string; schema?: string; object?: string }) => { - const parts = [ - args?.connectionName, - args?.database, - args?.schema, - args?.object - ].filter(Boolean); - - if (parts.length > 0) { - const path = parts.join(' ▸ '); - await vscode.env.clipboard.writeText(path); - vscode.window.showInformationMessage('Breadcrumb path copied to clipboard'); - } - } - }, - { - command: 'postgres-explorer.switchDatabase', - callback: async () => { - const editor = ConnectionUtils.getActivePostgresNotebook(); - if (!editor) { - vscode.window.showWarningMessage('No active PostgreSQL notebook.'); - return; - } - - const metadata = editor.notebook.metadata as any; - if (!metadata?.connectionId) { - vscode.window.showWarningMessage('No connection configured for this notebook.'); - return; - } - - const connection = ConnectionUtils.findConnection(metadata.connectionId); - if (!connection) { - vscode.window.showErrorMessage('Connection not found.'); - return; - } - - const selectedDb = await ConnectionUtils.showDatabasePicker(connection, metadata.databaseName); - - if (selectedDb && selectedDb !== metadata.databaseName) { - await ConnectionUtils.updateNotebookMetadata(editor.notebook, { databaseName: selectedDb }); - vscode.window.showInformationMessage(`Switched to database: ${selectedDb}`); - } - } - }, - // Phase 7: Connection Profiles - { - command: 'postgres-explorer.switchConnectionProfile', - callback: () => switchConnectionProfile() - }, - { - command: 'postgres-explorer.createConnectionProfile', - callback: () => createConnectionProfile() - }, - { - command: 'postgres-explorer.deleteConnectionProfile', - callback: () => deleteConnectionProfile() - }, - // Phase 7: Saved Queries - { - command: 'postgres-explorer.saveQueryToLibrary', - callback: () => saveQueryToLibrary() - }, - { - command: 'postgres-explorer.loadSavedQuery', - callback: () => loadSavedQuery() - }, - { - command: 'postgres-explorer.exportSavedQueries', - callback: () => exportSavedQueries() - }, - { - command: 'postgres-explorer.importSavedQueries', - callback: () => importSavedQueries() - }, - { - command: 'postgres-explorer.searchSavedQueries', - callback: () => searchSavedQueries() - }, - { - command: 'postgres-explorer.showQueryRecommendations', - callback: () => showQueryRecommendations() - }, - { - command: 'postgres-explorer.saveQueryToLibraryUI', - callback: () => saveQueryToLibraryUI() - }, - { - command: 'postgres-explorer.viewSavedQuery', - callback: (query: any) => viewSavedQuery(query) - }, - { - command: 'postgres-explorer.copySavedQuery', - callback: (query: any) => copySavedQuery(query) - }, - { - command: 'postgres-explorer.editSavedQuery', - callback: (query: any) => editSavedQuery(query) - }, - { - command: 'postgres-explorer.openSavedQueryInNotebook', - callback: (query: any) => openSavedQueryInNotebook(query) - }, - { - command: 'postgres-explorer.deleteSavedQuery', - callback: (query: any) => deleteSavedQuery(query) - }, - { - 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) - }, - // D2: ERD - { - command: 'postgres-explorer.openErd', - callback: (item: DatabaseTreeItem) => cmdOpenErd(item, context) - }, - // Import Data - { - command: 'postgres-explorer.importData', - callback: (item: DatabaseTreeItem) => cmdImportData(item, context) - }, - // D3: Profile export/import - { - command: 'postgres-explorer.exportConnectionProfiles', - callback: () => exportConnectionProfiles() - }, - { - command: 'postgres-explorer.importConnectionProfiles', - callback: () => importConnectionProfiles() - }, - { - command: 'postgres-explorer.viewMaintenanceVacuum', - callback: async () => { - vscode.window.showInformationMessage('VACUUM is not applicable to regular views. Use this on tables or materialized views.'); - } - }, - { - command: 'postgres-explorer.viewMaintenanceAnalyze', - callback: async () => { - vscode.window.showInformationMessage('ANALYZE is not applicable to regular views. Use this on tables or materialized views.'); - } - }, - - // Phase 2: Triggers - { command: 'postgres-explorer.listTriggers', callback: async (item: DatabaseTreeItem) => await cmdListTriggers(item, context) }, - { command: 'postgres-explorer.createTrigger', callback: async (item: DatabaseTreeItem) => await cmdCreateTrigger(item, context) }, - { command: 'postgres-explorer.dropTrigger', callback: async (item: DatabaseTreeItem) => await cmdDropTrigger(item, context) }, - { command: 'postgres-explorer.enableTrigger', callback: async (item: DatabaseTreeItem) => await cmdEnableTrigger(item, context) }, - { command: 'postgres-explorer.disableTrigger', callback: async (item: DatabaseTreeItem) => await cmdDisableTrigger(item, context) }, - { command: 'postgres-explorer.showTriggerProperties', callback: async (item: DatabaseTreeItem) => await cmdShowTriggerProperties(item, context) }, - { command: 'postgres-explorer.triggerOperations', callback: async (item: DatabaseTreeItem) => await cmdTriggerOperations(item, context) }, - - // Phase 2: Sequences - { command: 'postgres-explorer.listSequences', callback: async (item: DatabaseTreeItem) => await cmdListSequences(item, context) }, - { command: 'postgres-explorer.createSequence', callback: async (item: DatabaseTreeItem) => await cmdCreateSequence(item, context) }, - { command: 'postgres-explorer.dropSequence', callback: async (item: DatabaseTreeItem) => await cmdDropSequence(item, context) }, - { command: 'postgres-explorer.sequenceNextValue', callback: async (item: DatabaseTreeItem) => await cmdSequenceNextValue(item, context) }, - { command: 'postgres-explorer.showSequenceProperties', callback: async (item: DatabaseTreeItem) => await cmdShowSequenceProperties(item, context) }, - { command: 'postgres-explorer.sequenceOperations', callback: async (item: DatabaseTreeItem) => await cmdSequenceOperations(item, context) }, - - // Phase 2: Partitions - { command: 'postgres-explorer.listPartitions', callback: async (item: DatabaseTreeItem) => await cmdListPartitions(item, context) }, - { command: 'postgres-explorer.detachPartition', callback: async (item: DatabaseTreeItem) => await cmdDetachPartition(item, context) }, - { command: 'postgres-explorer.showPartitionProperties', callback: async (item: DatabaseTreeItem) => await cmdShowPartitionProperties(item, context) }, - { command: 'postgres-explorer.createPartition', callback: async (item: DatabaseTreeItem) => await cmdCreatePartition(item, context) }, - - // Phase 2: Domains - { command: 'postgres-explorer.listDomains', callback: async (item: DatabaseTreeItem) => await cmdListDomains(item, context) }, - { command: 'postgres-explorer.createDomain', callback: async (item: DatabaseTreeItem) => await cmdCreateDomain(item, context) }, - { command: 'postgres-explorer.dropDomain', callback: async (item: DatabaseTreeItem) => await cmdDropDomain(item, context) }, - { command: 'postgres-explorer.showDomainProperties', callback: async (item: DatabaseTreeItem) => await cmdShowDomainProperties(item, context) }, - - // Phase 2: Aggregates - { command: 'postgres-explorer.listAggregates', callback: async (item: DatabaseTreeItem) => await cmdListAggregates(item, context) }, - { command: 'postgres-explorer.createAggregate', callback: async (item: DatabaseTreeItem) => await cmdCreateAggregate(item, context) }, - { command: 'postgres-explorer.dropAggregate', callback: async (item: DatabaseTreeItem) => await cmdDropAggregate(item, context) }, - { command: 'postgres-explorer.showAggregateProperties', callback: async (item: DatabaseTreeItem) => await cmdShowAggregateProperties(item, context) }, - - // Phase 2: Event Triggers - { command: 'postgres-explorer.listEventTriggers', callback: async (item: DatabaseTreeItem) => await cmdListEventTriggers(item, context) }, - { command: 'postgres-explorer.createEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdCreateEventTrigger(item, context) }, - { command: 'postgres-explorer.dropEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdDropEventTrigger(item, context) }, - { command: 'postgres-explorer.enableEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdEnableEventTrigger(item, context) }, - { command: 'postgres-explorer.disableEventTrigger', callback: async (item: DatabaseTreeItem) => await cmdDisableEventTrigger(item, context) }, - { command: 'postgres-explorer.showEventTriggerProperties', callback: async (item: DatabaseTreeItem) => await cmdShowEventTriggerProperties(item, context) }, - { command: 'postgres-explorer.eventTriggerOperations', callback: async (item: DatabaseTreeItem) => await cmdEventTriggerOperations(item, context) }, - - // Phase 2: Rules - { command: 'postgres-explorer.listRules', callback: async (item: DatabaseTreeItem) => await cmdListRules(item, context) }, - { command: 'postgres-explorer.dropRule', callback: async (item: DatabaseTreeItem) => await cmdDropRule(item, context) }, - { command: 'postgres-explorer.showRuleProperties', callback: async (item: DatabaseTreeItem) => await cmdShowRuleProperties(item, context) }, - { command: 'postgres-explorer.ruleOperations', callback: async (item: DatabaseTreeItem) => await cmdRuleOperations(item, context) }, - - // Phase 2: Tablespaces - { command: 'postgres-explorer.listTablespaces', callback: async (item: DatabaseTreeItem) => await cmdListTablespaces(item, context) }, - { command: 'postgres-explorer.showTablespaceProperties', callback: async (item: DatabaseTreeItem) => await cmdShowTablespaceProperties(item, context) }, - { command: 'postgres-explorer.tablespaceOperations', callback: async (item: DatabaseTreeItem) => await cmdTablespaceOperations(item, context) }, - - // Phase 2: Publications & Subscriptions - { command: 'postgres-explorer.listPublications', callback: async (item: DatabaseTreeItem) => await cmdListPublications(item, context) }, - { command: 'postgres-explorer.createPublication', callback: async (item: DatabaseTreeItem) => await cmdCreatePublication(item, context) }, - { command: 'postgres-explorer.dropPublication', callback: async (item: DatabaseTreeItem) => await cmdDropPublication(item, context) }, - { command: 'postgres-explorer.showPublicationProperties', callback: async (item: DatabaseTreeItem) => await cmdShowPublicationProperties(item, context) }, - { command: 'postgres-explorer.publicationOperations', callback: async (item: DatabaseTreeItem) => await cmdPublicationOperations(item, context) }, - { command: 'postgres-explorer.listSubscriptions', callback: async (item: DatabaseTreeItem) => await cmdListSubscriptions(item, context) }, - { command: 'postgres-explorer.dropSubscription', callback: async (item: DatabaseTreeItem) => await cmdDropSubscription(item, context) }, - { command: 'postgres-explorer.showSubscriptionProperties', callback: async (item: DatabaseTreeItem) => await cmdShowSubscriptionProperties(item, context) }, - - // Phase 2: Schema Search - { command: 'postgres-explorer.searchSchema', callback: async () => await cmdSearchSchema() }, - ]; - - console.log('Starting command registration...'); - outputChannel.appendLine('Starting command registration...'); - - commands.forEach(({ command, callback }) => { - try { - console.log(`Registering command: ${command}`); - context.subscriptions.push( - vscode.commands.registerCommand(command, callback) - ); - } catch (e) { - console.error(`Failed to register command ${command}:`, e); - outputChannel.appendLine(`Failed to register command ${command}: ${e}`); - } - }); - - // Phase 7: Register refresh commands for tree views - context.subscriptions.push( - vscode.commands.registerCommand('postgresExplorer.savedQueries.refresh', () => { - if (savedQueriesTreeProvider) { - savedQueriesTreeProvider.refresh(); - } - }), - - vscode.commands.registerCommand('postgres-explorer.pasteTable', (item) => cmdPasteTable(item, context)) - ); - - outputChannel.appendLine('All commands registered successfully.'); -} +/** Re-export — command implementations live in commandSpecs.ts; registration in commandRegistry.ts */ +export { registerAllCommands } from './commandRegistry'; diff --git a/src/activation/providers.ts b/src/activation/providers.ts index d1b6985..a66a7cd 100644 --- a/src/activation/providers.ts +++ b/src/activation/providers.ts @@ -1,16 +1,28 @@ import * as vscode from 'vscode'; import { ChatViewProvider } from '../providers/ChatViewProvider'; import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; -import { PostgresNotebookProvider } from '../notebookProvider'; -import { PostgresNotebookSerializer } from '../postgresNotebook'; +import { PostgresNotebookProvider } from '../features/notebook/notebookProvider'; +import { PostgresNotebookSerializer } from '../features/notebook/postgresNotebook'; -import { QueryCodeLensProvider } from '../providers/QueryCodeLensProvider'; -import { QueryHistoryProvider } from '../providers/QueryHistoryProvider'; import { ProfilesTreeProvider, SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; import { NotebooksTreeProvider } from '../providers/NotebooksTreeProvider'; import { AutoRefreshService } from '../services/AutoRefreshService'; import { DdlViewerService } from '../services/DdlViewerService'; +function runDeferredProviderTask(outputChannel: vscode.OutputChannel, taskName: string, task: () => Promise) { + setTimeout(() => { + void (async () => { + const start = Date.now(); + try { + await task(); + outputChannel.appendLine(`[startup/deferred-provider] ${taskName} completed in ${Date.now() - start}ms`); + } catch (error) { + outputChannel.appendLine(`[startup/deferred-provider] ${taskName} failed: ${error}`); + } + })(); + }, 0); +} + export function registerProviders(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel) { // Create database tree provider instance const databaseTreeProvider = new DatabaseTreeProvider(context); @@ -51,47 +63,54 @@ export function registerProviders(context: vscode.ExtensionContext, outputChanne vscode.workspace.registerNotebookSerializer('postgres-query', new PostgresNotebookSerializer()) ); - // Register SQL completion provider - const { SqlCompletionProvider } = require('../providers/SqlCompletionProvider'); - const sqlCompletionProvider = new SqlCompletionProvider(); - context.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - { language: 'sql' }, - sqlCompletionProvider, - '.' // Trigger on dot for schema.table suggestions - ), - vscode.languages.registerCompletionItemProvider( - { scheme: 'vscode-notebook-cell', language: 'sql' }, - sqlCompletionProvider, - '.' - ) - ); - + // Register SQL completion provider, CodeLens, and query history lazily. + runDeferredProviderTask(outputChannel, 'registerSqlCompletionProvider', async () => { + const sqlCompletionModule = await import('../providers/SqlCompletionProvider'); + const sqlCompletionProvider = new sqlCompletionModule.SqlCompletionProvider(); + + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: 'sql' }, + sqlCompletionProvider, + '.' // Trigger on dot for schema.table suggestions + ), + vscode.languages.registerCompletionItemProvider( + { scheme: 'vscode-notebook-cell', language: 'sql' }, + sqlCompletionProvider, + '.' + ) + ); + }); + runDeferredProviderTask(outputChannel, 'registerQueryCodeLensProvider', async () => { + const queryCodeLensModule = await import('../providers/QueryCodeLensProvider'); + const queryCodeLensProvider = new queryCodeLensModule.QueryCodeLensProvider(); + queryCodeLensModule.QueryCodeLensProvider.setInstance(queryCodeLensProvider); + + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: 'postgres', scheme: 'vscode-notebook-cell' }, + queryCodeLensProvider + ), + vscode.languages.registerCodeLensProvider( + { language: 'sql', scheme: 'vscode-notebook-cell' }, + queryCodeLensProvider + ) + ); + outputChannel.appendLine('QueryCodeLensProvider registered for EXPLAIN actions.'); + }); - // Register Query CodeLens Provider for EXPLAIN actions - const queryCodeLensProvider = new QueryCodeLensProvider(); - QueryCodeLensProvider.setInstance(queryCodeLensProvider); - context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - { language: 'postgres', scheme: 'vscode-notebook-cell' }, - queryCodeLensProvider - ), - vscode.languages.registerCodeLensProvider( - { language: 'sql', scheme: 'vscode-notebook-cell' }, - queryCodeLensProvider - ) - ); - outputChannel.appendLine('QueryCodeLensProvider registered for EXPLAIN actions.'); + runDeferredProviderTask(outputChannel, 'registerQueryHistoryProvider', async () => { + const queryHistoryModule = await import('../providers/QueryHistoryProvider'); + const queryHistoryProvider = new queryHistoryModule.QueryHistoryProvider(); - // Register Query History Provider - const queryHistoryProvider = new QueryHistoryProvider(); - context.subscriptions.push( - vscode.window.registerTreeDataProvider('postgresExplorer.history', queryHistoryProvider) - ); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('postgresExplorer.history', queryHistoryProvider) + ); - // Store query history provider instance for command access - context.workspaceState.update('queryHistoryProviderInstance', queryHistoryProvider); + // Store query history provider instance for command access + await context.workspaceState.update('queryHistoryProviderInstance', queryHistoryProvider); + }); // Phase 7: Register Saved Queries Tree Provider const savedQueriesTreeProvider = new SavedQueriesTreeProvider(); @@ -122,7 +141,7 @@ export function registerProviders(context: vscode.ExtensionContext, outputChanne treeView, ddlViewerService, chatViewProviderInstance, - queryHistoryProvider, + queryHistoryProvider: undefined, savedQueriesTreeProvider, notebooksTreeProvider, autoRefreshService diff --git a/src/activation/statusBar.ts b/src/activation/statusBar.ts index b8c015e..aadece9 100644 --- a/src/activation/statusBar.ts +++ b/src/activation/statusBar.ts @@ -1,8 +1,10 @@ import * as vscode from 'vscode'; import { PostgresMetadata } from '../common/types'; import { extensionContext } from '../extension'; -import { ProfileManager } from '../services/ProfileManager'; +import { ProfileManager } from '../features/connections/ProfileManager'; import { getTransactionManager } from '../services/TransactionManager'; +import { ConnectionUtils } from '../utils/connectionUtils'; +import { WorkspaceStateService } from '../services/WorkspaceStateService'; /** * Manages the notebook status bar items that display connection and database info. @@ -14,6 +16,8 @@ export class NotebookStatusBar implements vscode.Disposable { private readonly riskIndicatorItem: vscode.StatusBarItem; private readonly profileItem: vscode.StatusBarItem; private readonly transactionItem: vscode.StatusBarItem; + /** Shown when no PostgreSQL notebook is active: workspace default connection (per-folder state). */ + private readonly workspaceDefaultItem: vscode.StatusBarItem; private readonly disposables: vscode.Disposable[] = []; constructor() { @@ -36,12 +40,16 @@ export class NotebookStatusBar implements vscode.Disposable { this.transactionItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 96); this.transactionItem.tooltip = 'Transaction is open — click to view transaction details'; + this.workspaceDefaultItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 95); + this.workspaceDefaultItem.command = 'postgres-explorer.switchWorkspaceDefaultConnection'; + this.disposables.push( this.connectionItem, this.databaseItem, this.riskIndicatorItem, this.profileItem, this.transactionItem, + this.workspaceDefaultItem, vscode.window.onDidChangeActiveNotebookEditor(() => this.update()), vscode.workspace.onDidChangeNotebookDocument((e) => { if (vscode.window.activeNotebookEditor?.notebook === e.notebook) { @@ -58,10 +66,13 @@ export class NotebookStatusBar implements vscode.Disposable { const editor = vscode.window.activeNotebookEditor; if (!this.isPostgresNotebook(editor)) { - this.hide(); + this.hideNotebookItems(); + this.updateWorkspaceDefaultItem(); return; } + this.workspaceDefaultItem.hide(); + const metadata = editor!.notebook.metadata as PostgresMetadata; const connection = this.getConnection(metadata?.connectionId); @@ -86,7 +97,7 @@ export class NotebookStatusBar implements vscode.Disposable { return connections.find(c => c.id === connectionId); } - private hide(): void { + private hideNotebookItems(): void { this.connectionItem.hide(); this.databaseItem.hide(); this.riskIndicatorItem.hide(); @@ -94,6 +105,41 @@ export class NotebookStatusBar implements vscode.Disposable { this.transactionItem.hide(); } + private hide(): void { + this.hideNotebookItems(); + this.workspaceDefaultItem.hide(); + } + + private updateWorkspaceDefaultItem(): void { + if (!vscode.workspace.workspaceFolders?.length) { + this.workspaceDefaultItem.hide(); + return; + } + + const defaults = WorkspaceStateService.getInstance().getDefaults(); + const conn = defaults.lastConnectionId + ? ConnectionUtils.findConnection(defaults.lastConnectionId) + : undefined; + const connLabel = conn?.name || conn?.host; + const dbLabel = defaults.lastDatabaseName || conn?.database; + + if (!connLabel && !dbLabel) { + this.workspaceDefaultItem.text = '$(folder) PgStudio: set workspace DB'; + this.workspaceDefaultItem.tooltip = + 'Choose a default PostgreSQL connection for this workspace (used when no .pgsql notebook is focused).'; + this.workspaceDefaultItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); + this.workspaceDefaultItem.show(); + return; + } + + const hostPart = conn ? `${conn.name || conn.host}` : 'Unknown connection'; + const dbPart = dbLabel || '—'; + this.workspaceDefaultItem.text = `$(root-folder) ${hostPart} · $(database) ${dbPart}`; + this.workspaceDefaultItem.tooltip = 'Workspace default connection. Click to change.'; + this.workspaceDefaultItem.backgroundColor = new vscode.ThemeColor('statusBarItem.prominentBackground'); + this.workspaceDefaultItem.show(); + } + private showNoConnection(): void { this.connectionItem.text = '$(plug) Click to Connect'; this.connectionItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); diff --git a/src/commands/connection.ts b/src/commands/connection.ts index e356d28..a6a073f 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -299,6 +299,48 @@ export async function showConnectionSafety(): Promise { } } +/** + * Clone a connection entry (new id, new name) and copy stored password when present. + */ +export async function cmdDuplicateConnection( + item: DatabaseTreeItem, + _context: vscode.ExtensionContext, + databaseTreeProvider: DatabaseTreeProvider +): Promise { + try { + if (!item?.connectionId) { + return; + } + const config = vscode.workspace.getConfiguration(); + const connections = config.get('postgresExplorer.connections') || []; + const conn = connections.find((c) => c.id === item.connectionId); + if (!conn) { + vscode.window.showErrorMessage('Connection not found'); + return; + } + const newId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const dup = { + ...conn, + id: newId, + name: `${conn.name} (copy)`, + }; + await config.update( + 'postgresExplorer.connections', + [...connections, dup], + vscode.ConfigurationTarget.Global + ); + const secrets = SecretStorageService.getInstance(); + const pw = await secrets.getPassword(conn.id); + if (pw) { + await secrets.setPassword(newId, pw); + } + databaseTreeProvider.refresh(); + vscode.window.showInformationMessage(`Duplicated connection as "${dup.name}"`); + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'duplicate connection'); + } +} + /** * Reveal connection in explorer - shows and selects the connection in the tree view */ diff --git a/src/commands/importConnectionFromDatabaseUrl.ts b/src/commands/importConnectionFromDatabaseUrl.ts new file mode 100644 index 0000000..ceca02f --- /dev/null +++ b/src/commands/importConnectionFromDatabaseUrl.ts @@ -0,0 +1,122 @@ +import * as vscode from 'vscode'; +import { appendWorkspaceConnection, ConnectionInfo } from '../features/connections/connectionForm'; +import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; +import { connectionInfoFromDatabaseUrl, previewDatabaseUrl } from '../utils/databaseUrl'; +import { DATABASE_URL_ENV_KEYS, extractDatabaseUrlsFromEnvText } from '../utils/envFileDatabaseUrls'; +import { ErrorHandlers } from './helper'; + +interface EnvUrlCandidate { + relativePath: string; + key: string; + value: string; +} + +export async function cmdImportConnectionFromDatabaseUrl( + context: vscode.ExtensionContext, + databaseTreeProvider: DatabaseTreeProvider, +): Promise { + try { + const folders = vscode.workspace.workspaceFolders; + if (!folders?.length) { + vscode.window.showWarningMessage('Open a workspace folder to scan .env files.'); + return; + } + + const keySet = new Set(DATABASE_URL_ENV_KEYS); + const acceptKey = (k: string): boolean => keySet.has(k); + const candidates = await scanWorkspaceEnvFiles(folders, acceptKey); + + let chosenUrl: string | undefined; + let sourceLabel: string | undefined; + + if (candidates.length === 0) { + const pasted = await vscode.window.showInputBox({ + title: 'Import PostgreSQL connection', + prompt: + 'No DATABASE_URL-style keys found in .env files. Paste a postgres:// or postgresql:// URL.', + ignoreFocusOut: true, + }); + if (!pasted?.trim()) { + return; + } + chosenUrl = pasted.trim(); + sourceLabel = 'pasted URL'; + } else if (candidates.length === 1) { + chosenUrl = candidates[0].value; + sourceLabel = `${candidates[0].relativePath} (${candidates[0].key})`; + } else { + const pick = await vscode.window.showQuickPick( + candidates.map((c) => ({ + label: `${c.relativePath} — ${c.key}`, + description: previewDatabaseUrl(c.value), + candidate: c, + })), + { placeHolder: 'Choose a DATABASE_URL from the workspace' }, + ); + if (!pick) { + return; + } + chosenUrl = pick.candidate.value; + sourceLabel = `${pick.candidate.relativePath} (${pick.candidate.key})`; + } + + const id = `env-${Date.now()}`; + let info: ConnectionInfo; + try { + info = connectionInfoFromDatabaseUrl(chosenUrl, id); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + vscode.window.showErrorMessage(msg); + return; + } + + const name = await vscode.window.showInputBox({ + title: 'Connection name', + prompt: `Imported from ${sourceLabel}`, + value: info.name, + ignoreFocusOut: true, + }); + if (name === undefined) { + return; + } + if (!name.trim()) { + vscode.window.showWarningMessage('Connection name is required.'); + return; + } + + await appendWorkspaceConnection(context, { ...info, name: name.trim() }); + databaseTreeProvider.refresh(); + vscode.window.showInformationMessage(`Saved connection "${name.trim()}"`); + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'import connection from DATABASE_URL'); + } +} + +async function scanWorkspaceEnvFiles( + folders: readonly vscode.WorkspaceFolder[], + acceptKey: (k: string) => boolean, +): Promise { + const out: EnvUrlCandidate[] = []; + + for (const folder of folders) { + const pattern = new vscode.RelativePattern( + folder, + '{**/.env,**/.env.local,**/.env.development,**/.env.production}', + ); + const files = await vscode.workspace.findFiles(pattern, '**/node_modules/**', 80); + for (const uri of files) { + let text: string; + try { + const doc = await vscode.workspace.fs.readFile(uri); + text = Buffer.from(doc).toString('utf8'); + } catch { + continue; + } + const rel = vscode.workspace.asRelativePath(uri, false); + for (const { key, value } of extractDatabaseUrlsFromEnvText(text, acceptKey)) { + out.push({ relativePath: rel, key, value }); + } + } + } + return out; +} diff --git a/src/commands/listenNotify.ts b/src/commands/listenNotify.ts new file mode 100644 index 0000000..421676c --- /dev/null +++ b/src/commands/listenNotify.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { ListenNotifyPanel } from '../providers/ListenNotifyPanel'; +import { ConnectionManager } from '../services/ConnectionManager'; +import { ErrorHandlers } from './helper'; + +export async function cmdOpenListenNotify( + item: DatabaseTreeItem, + context: vscode.ExtensionContext, +): Promise { + try { + if (item.type !== 'database' || !item.connectionId || !item.databaseName) { + await vscode.window.showErrorMessage( + 'Open LISTEN/NOTIFY from a database node in the PG Studio tree.', + ); + return; + } + await ListenNotifyPanel.open(item.connectionId, item.databaseName, context); + } catch (err) { + await ErrorHandlers.handleCommandError(err, 'open LISTEN/NOTIFY monitor'); + } +} + +/** + * Command Palette: pick saved connection → database, then open the monitor panel. + */ +export async function cmdOpenListenNotifyFromPalette( + context: vscode.ExtensionContext, +): Promise { + const connections = + vscode.workspace.getConfiguration().get>>('postgresExplorer.connections') || + []; + if (connections.length === 0) { + await vscode.window.showErrorMessage('No saved connections. Add one in settings.'); + return; + } + + const connPick = await vscode.window.showQuickPick( + connections.map((c: any) => ({ + label: (c.name as string) || `${c.host}:${c.port}`, + description: (c.database as string) || 'postgres', + conn: c, + })), + { title: 'LISTEN/NOTIFY: Connection', placeHolder: 'Select a saved connection' }, + ); + if (!connPick) { + return; + } + + const connection = connPick.conn as Record & { + id: string; + host: string; + port: number; + database?: string; + }; + const bootstrapDb = connection.database || 'postgres'; + + let tempClient; + try { + tempClient = await ConnectionManager.getInstance().getPooledClient({ + ...(connection as any), + database: bootstrapDb, + }); + } catch (err: any) { + await vscode.window.showErrorMessage( + `Could not connect: ${err?.message || String(err)}. Check credentials and network.`, + ); + return; + } + + let dbName: string; + try { + const dbsResult = await tempClient.query(` + SELECT datname FROM pg_database + WHERE datallowconn = true AND datistemplate = false + ORDER BY datname + `); + const databases = dbsResult.rows.map((r: { datname: string }) => r.datname); + const dbChoice = await vscode.window.showQuickPick(databases, { + title: 'LISTEN/NOTIFY: Database', + placeHolder: 'Database to open a dedicated LISTEN session on', + }); + if (!dbChoice) { + return; + } + dbName = dbChoice; + } finally { + tempClient.release(); + } + + try { + await ListenNotifyPanel.open(connection.id, dbName, context); + } catch (err) { + await ErrorHandlers.handleCommandError(err, 'open LISTEN/NOTIFY monitor'); + } +} diff --git a/src/commands/notebookExport.ts b/src/commands/notebookExport.ts new file mode 100644 index 0000000..a1b4fbb --- /dev/null +++ b/src/commands/notebookExport.ts @@ -0,0 +1,175 @@ +import * as vscode from 'vscode'; +import { + buildNotebookHtmlDocument, + serializeNotebookForGist, +} from '../features/notebook/notebookExportHtml'; +import { SecretStorageService } from '../services/SecretStorageService'; +import { ErrorHandlers } from './helper'; + +function isPostgresNotebookDoc(doc: vscode.NotebookDocument): boolean { + return doc.notebookType === 'postgres-notebook' || doc.notebookType === 'postgres-query'; +} + +function sanitizeFilenameBase(name: string): string { + return name.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120) || 'notebook'; +} + +async function ensureGithubGistToken(): Promise { + const secrets = SecretStorageService.getInstance(); + const existing = await secrets.getGithubGistToken(); + if (existing) { + return existing; + } + const input = await vscode.window.showInputBox({ + title: 'GitHub personal access token', + prompt: 'Needs the gist scope. Create at https://github.com/settings/tokens — stored in VS Code Secret Storage.', + password: true, + ignoreFocusOut: true, + }); + if (!input?.trim()) { + return undefined; + } + await secrets.setGithubGistToken(input.trim()); + return input.trim(); +} + +async function createGithubGist(params: { + description: string; + public: boolean; + files: Record; + token: string; +}): Promise { + const res = await fetch('https://api.github.com/gists', { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'PgStudio-VSCode-Extension', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ + description: params.description, + public: params.public, + files: params.files, + }), + }); + + if (res.status === 401) { + await SecretStorageService.getInstance().deleteGithubGistToken(); + throw new Error('GitHub rejected the token (401). Run export again to set a new token with gist scope.'); + } + + if (!res.ok) { + const body = await res.text(); + throw new Error(`GitHub API ${res.status}: ${body.slice(0, 500)}`); + } + + const data = (await res.json()) as { html_url?: string }; + if (!data.html_url) { + throw new Error('GitHub response missing html_url'); + } + return data.html_url; +} + +/** + * Export the active PostgreSQL notebook to HTML (print to PDF from the browser) and optionally publish a Gist. + */ +export async function cmdExportNotebook(): Promise { + const editor = vscode.window.activeNotebookEditor; + if (!editor || !isPostgresNotebookDoc(editor.notebook)) { + vscode.window.showWarningMessage('Open a PostgreSQL notebook (.pgsql) first.'); + return; + } + + const doc = editor.notebook; + const pick = await vscode.window.showQuickPick( + [ + { + label: '$(file-code) Save as HTML file', + description: 'Standalone page with SQL, markdown, and result tables', + id: 'html' as const, + }, + { + label: '$(browser) Save HTML and open in browser', + description: 'Use the browser Print dialog → Save as PDF', + id: 'html-open' as const, + }, + { + label: '$(github) Publish to GitHub Gist', + description: 'Upload .pgsql source + HTML render (needs GitHub token)', + id: 'gist' as const, + }, + ], + { title: 'Export PostgreSQL notebook', placeHolder: 'Choose how to export' }, + ); + if (!pick) { + return; + } + + const meta = doc.metadata as Record | undefined; + const title = + (meta?.title as string) || + doc.uri.path.split('/').pop()?.replace(/\.pgsql$/i, '') || + 'notebook'; + const safeBase = sanitizeFilenameBase(title); + + try { + const html = buildNotebookHtmlDocument(doc, title); + + if (pick.id === 'gist') { + const token = await ensureGithubGistToken(); + if (!token) { + return; + } + + const vis = await vscode.window.showQuickPick( + [ + { label: 'Secret gist', id: 'secret' as const }, + { label: 'Public gist', id: 'public' as const }, + ], + { placeHolder: 'Gist visibility' }, + ); + if (!vis) { + return; + } + + const { filename, json } = serializeNotebookForGist(doc); + const htmlName = filename.replace(/\.pgsql$/i, '.html'); + + const url = await createGithubGist({ + description: `PgStudio: ${title}`, + public: vis.id === 'public', + token, + files: { + [filename]: { content: json }, + [htmlName]: { content: html }, + }, + }); + + const open = await vscode.window.showInformationMessage(`Gist created: ${url}`, 'Open in browser'); + if (open === 'Open in browser') { + await vscode.env.openExternal(vscode.Uri.parse(url)); + } + return; + } + + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`${safeBase}.html`), + filters: { HTML: ['html'] }, + saveLabel: 'Save HTML', + }); + if (!uri) { + return; + } + + await vscode.workspace.fs.writeFile(uri, Buffer.from(html, 'utf8')); + vscode.window.showInformationMessage(`Exported notebook to ${uri.fsPath}`); + + if (pick.id === 'html-open') { + await vscode.env.openExternal(uri); + } + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'export notebook'); + } +} diff --git a/src/commands/pgCron.ts b/src/commands/pgCron.ts new file mode 100644 index 0000000..51b3dca --- /dev/null +++ b/src/commands/pgCron.ts @@ -0,0 +1,105 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { getDatabaseConnection, NotebookBuilder, MarkdownUtils } from './helper'; +import { validateCategoryItem } from './connection'; +import { PgCronSQL } from './sql/pgCron'; + +function assertCronJob(item: DatabaseTreeItem): asserts item is DatabaseTreeItem & { cronJobId: number } { + if (item.cronJobId === undefined || item.cronJobId === null) { + throw new Error('Select a scheduled job'); + } +} + +export async function cmdListCronJobs(item: DatabaseTreeItem, _context: vscode.ExtensionContext) { + const { metadata, release } = await getDatabaseConnection(item, validateCategoryItem); + try { + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header('pg_cron jobs') + + MarkdownUtils.infoBox( + 'Jobs are stored in cron.job. Requires the pg_cron extension. Some hosts need pg_cron in shared_preload_libraries and a PostgreSQL restart.', + ), + ) + .addSql(PgCronSQL.listJobs()) + .show(); + } finally { + release(); + } +} + +export async function cmdInstallPgCron(item: DatabaseTreeItem, _context: vscode.ExtensionContext) { + const { metadata, release } = await getDatabaseConnection(item, validateCategoryItem); + try { + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header('Install pg_cron') + + MarkdownUtils.warningBox( + 'Creating the extension may require superuser. The database cluster may need shared_preload_libraries = \'pg_cron\' and a restart before CREATE EXTENSION succeeds.', + ), + ) + .addSql(PgCronSQL.installExtension()) + .show(); + } finally { + release(); + } +} + +export async function cmdScheduleCronJob(item: DatabaseTreeItem, _context: vscode.ExtensionContext) { + const { metadata, release } = await getDatabaseConnection(item, validateCategoryItem); + try { + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header('Schedule a new pg_cron job') + + MarkdownUtils.infoBox('Edit the cron expression and SQL body, then execute the cell.'), + ) + .addSql(PgCronSQL.scheduleNewJob()) + .show(); + } finally { + release(); + } +} + +export async function cmdShowCronJobProperties(item: DatabaseTreeItem, _context: vscode.ExtensionContext) { + assertCronJob(item); + const { metadata, release } = await getDatabaseConnection(item, validateCategoryItem); + try { + const label = item.label; + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`Cron job: ${label}`) + + MarkdownUtils.infoBox('Job definition from cron.job. Use Unschedule to remove the schedule.'), + ) + .addSql(PgCronSQL.jobDetail(item.cronJobId)) + .addMarkdown('##### Recent runs (if job_run_details exists)') + .addSql(PgCronSQL.jobRunHistory(item.cronJobId)) + .addMarkdown('##### Alter / advanced') + .addSql(PgCronSQL.alterJobNote()) + .show(); + } finally { + release(); + } +} + +export async function cmdUnscheduleCronJob(item: DatabaseTreeItem, _context: vscode.ExtensionContext) { + assertCronJob(item); + const confirm = await vscode.window.showWarningMessage( + `Unschedule cron job "${item.label}" (job id ${item.cronJobId})?`, + { modal: true }, + 'Unschedule', + ); + if (confirm !== 'Unschedule') { + return; + } + const { metadata, release } = await getDatabaseConnection(item, validateCategoryItem); + try { + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`Unschedule job ${item.cronJobId}`) + + MarkdownUtils.dangerBox('This removes the job from the schedule. It cannot be undone except by creating a new job.'), + ) + .addSql(PgCronSQL.unschedule(item.cronJobId)) + .show(); + } finally { + release(); + } +} diff --git a/src/commands/phase7.ts b/src/commands/phase7.ts index 9dae1ec..cea0dc0 100644 --- a/src/commands/phase7.ts +++ b/src/commands/phase7.ts @@ -1,12 +1,12 @@ import * as vscode from 'vscode'; import { ChatViewProvider } from '../providers/ChatViewProvider'; -import { ProfileManager, ConnectionProfile } from '../services/ProfileManager'; -import { SavedQueriesService, SavedQuery } from '../services/SavedQueriesService'; +import { ProfileManager, ConnectionProfile } from '../features/connections/ProfileManager'; +import { SavedQueriesService, SavedQuery } from '../features/savedQueries/SavedQueriesService'; import { QueryAnalyzer } from '../services/QueryAnalyzer'; import { ErrorService } from '../services/ErrorService'; import { extensionContext, statusBar } from '../extension'; -import { SaveQueryPanel } from '../SaveQueryPanel'; -import { SavedQueryDetailsPanel } from '../SavedQueryDetailsPanel'; +import { SaveQueryPanel } from '../features/savedQueries/SaveQueryPanel'; +import { SavedQueryDetailsPanel } from '../features/savedQueries/SavedQueryDetailsPanel'; import { ConnectionUtils } from '../utils/connectionUtils'; import { SecretStorageService } from '../services/SecretStorageService'; diff --git a/src/commands/rlsPolicies.ts b/src/commands/rlsPolicies.ts new file mode 100644 index 0000000..6648ddf --- /dev/null +++ b/src/commands/rlsPolicies.ts @@ -0,0 +1,41 @@ +import * as vscode from 'vscode'; +import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; +import { getDatabaseConnection, NotebookBuilder, MarkdownUtils } from './helper'; +import { PolicySQL } from './sql/policies'; + +export async function cmdDropPolicy(item: DatabaseTreeItem, _context: vscode.ExtensionContext): Promise { + if (item.type !== 'policy' || !item.schema || !item.tableName) { + await vscode.window.showErrorMessage('Select an RLS policy under a table to drop it.'); + return; + } + + if (item.label.startsWith('Cannot read')) { + await vscode.window.showErrorMessage('Policies could not be loaded. Fix permissions first.'); + return; + } + + const policyName = item.label; + const confirm = await vscode.window.showWarningMessage( + `Drop policy "${policyName}" on "${item.schema}"."${item.tableName}"? Row-level access rules may change immediately.`, + { modal: true }, + 'Drop', + ); + if (confirm !== 'Drop') { + return; + } + + const { metadata, release } = await getDatabaseConnection(item); + try { + await new NotebookBuilder(metadata) + .addMarkdown( + MarkdownUtils.header(`Drop policy: ${policyName}`) + + MarkdownUtils.dangerBox( + `Drops policy "${policyName}" on "${item.schema}"."${item.tableName}". Review and execute in the SQL cell when ready.`, + ), + ) + .addSql(PolicySQL.drop(item.schema, item.tableName, policyName)) + .show(); + } finally { + release(); + } +} diff --git a/src/commands/schemaDesigner.ts b/src/commands/schemaDesigner.ts index 106bbf0..73b25f4 100644 --- a/src/commands/schemaDesigner.ts +++ b/src/commands/schemaDesigner.ts @@ -4,6 +4,7 @@ import { TableDesignerPanel } from '../schemaDesigner/TableDesignerPanel'; import { SchemaDiffPanel } from '../schemaDesigner/SchemaDiffPanel'; import { ErdPanel } from '../schemaDesigner/ErdPanel'; import { ImportDataPanel } from '../schemaDesigner/ImportDataPanel'; +import { ConnectionManager } from '../services/ConnectionManager'; /** * Open the Visual Table Designer for an existing table (Edit mode) @@ -53,6 +54,121 @@ export async function cmdOpenSchemaDiff( await SchemaDiffPanel.open(item, context); } +/** + * Schema diff from the Command Palette: pick connection → database → source schema, + * then the usual target-schema flow from {@link SchemaDiffPanel.open}. + */ +export async function cmdOpenSchemaDiffFromPalette( + context: vscode.ExtensionContext +): Promise { + const connections = + vscode.workspace.getConfiguration().get>>('postgresExplorer.connections') || + []; + if (connections.length === 0) { + await vscode.window.showErrorMessage('No saved connections. Add one in settings.'); + return; + } + + const connPick = await vscode.window.showQuickPick( + connections.map((c: any) => ({ + label: (c.name as string) || `${c.host}:${c.port}`, + description: (c.database as string) || 'postgres', + conn: c, + })), + { title: 'Schema Diff: Connection', placeHolder: 'Select a saved connection' }, + ); + if (!connPick) { + return; + } + + const connection = connPick.conn as Record & { + id: string; + host: string; + port: number; + database?: string; + }; + const bootstrapDb = connection.database || 'postgres'; + + let tempClient; + try { + tempClient = await ConnectionManager.getInstance().getPooledClient({ + ...(connection as any), + database: bootstrapDb, + }); + } catch (err: any) { + await vscode.window.showErrorMessage( + `Could not connect: ${err?.message || String(err)}. Check credentials and network.`, + ); + return; + } + + let dbName: string; + try { + const dbsResult = await tempClient.query(` + SELECT datname FROM pg_database + WHERE datallowconn = true AND datistemplate = false + ORDER BY datname + `); + const databases = dbsResult.rows.map((r: { datname: string }) => r.datname); + const dbChoice = await vscode.window.showQuickPick(databases, { + title: 'Schema Diff: Database', + placeHolder: 'Database containing the source schema', + }); + if (!dbChoice) { + return; + } + dbName = dbChoice; + } finally { + tempClient.release(); + } + + let client; + try { + client = await ConnectionManager.getInstance().getPooledClient({ + ...(connection as any), + database: dbName, + }); + } catch (err: any) { + await vscode.window.showErrorMessage( + `Could not connect to database "${dbName}": ${err?.message || String(err)}`, + ); + return; + } + + let schemaName: string; + try { + const sch = await client.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 names = sch.rows.map((r: { schema_name: string }) => r.schema_name); + const schemaChoice = await vscode.window.showQuickPick(names, { + title: `Schema Diff: Source schema (${dbName})`, + placeHolder: 'Schema to treat as the migration source', + }); + if (!schemaChoice) { + return; + } + schemaName = schemaChoice; + } finally { + client.release(); + } + + const synthetic = new DatabaseTreeItem( + schemaName, + vscode.TreeItemCollapsibleState.Collapsed, + 'schema', + connection.id, + dbName, + schemaName, + ); + + await SchemaDiffPanel.open(synthetic, context); +} + /** * Open the ERD (Entity-Relationship Diagram) for a schema */ diff --git a/src/commands/schemaSearch.ts b/src/commands/schemaSearch.ts index 77c66dc..bc09da9 100644 --- a/src/commands/schemaSearch.ts +++ b/src/commands/schemaSearch.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import type { PoolClient } from 'pg'; import { ConnectionManager } from '../services/ConnectionManager'; import { NotebookBuilder, MarkdownUtils } from './helper'; import { getConnectionWithPassword } from './connection'; @@ -8,6 +9,9 @@ interface SearchRow { schema: string; name: string; definition: string | null; + connectionId: string; + databaseName: string; + connectionLabel: string; } const TYPE_ICONS: Record = { @@ -21,48 +25,19 @@ const TYPE_ICONS: Record = { 'materialized-view': '$(symbol-structure)', }; -export async function cmdSearchSchema() { - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - if (connections.length === 0) { - vscode.window.showInformationMessage('No connections configured. Please add a PostgreSQL connection first.'); - return; - } +/** Escape `%`, `_`, `\` for use in PostgreSQL LIKE / ILIKE with ESCAPE '\\'. */ +export function escapeLikePattern(input: string): string { + return input.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_'); +} - // Ask user to pick a connection if multiple - let selectedConn = connections[0]; - if (connections.length > 1) { - const picked = await vscode.window.showQuickPick( - connections.map(c => ({ - label: c.name || `${c.host}:${c.port}`, - description: `${c.host}:${c.port}/${c.database}`, - connection: c - })), - { placeHolder: 'Select connection to search' } - ); - if (!picked) { return; } - selectedConn = picked.connection; - } +const DEBOUNCE_MS = 280; +const LIMIT_BROWSE = 300; +const LIMIT_FILTER = 2500; - const qp = vscode.window.createQuickPick(); - qp.placeholder = 'Search schema objects... (tables, views, functions, triggers, sequences, domains...)'; - qp.matchOnDescription = true; - qp.matchOnDetail = true; - qp.busy = true; - qp.show(); - - let client: any; - try { - const connection = await getConnectionWithPassword(selectedConn.id, selectedConn.database); - client = await ConnectionManager.getInstance().getPooledClient({ - id: connection.id, - host: connection.host, - port: connection.port, - username: connection.username, - database: selectedConn.database || connection.database, - name: connection.name - }); - - const searchQuery = ` +/** + * Single UNION for catalog objects; wrapped with server-side filter + LIMIT for scalable search. + */ +const SEARCH_UNION_BODY = ` SELECT 'table' AS type, table_schema AS schema, table_name AS name, NULL::text AS definition FROM information_schema.tables WHERE table_schema NOT IN ('pg_catalog', 'information_schema') AND table_type = 'BASE TABLE' @@ -96,33 +71,237 @@ export async function cmdSearchSchema() { SELECT 'materialized-view', schemaname, matviewname, definition FROM pg_matviews WHERE schemaname NOT IN ('pg_catalog', 'information_schema') - ORDER BY type, schema, name - `; +`; + +const SEARCH_QUERY = ` +SELECT * FROM ( +${SEARCH_UNION_BODY} +) AS u +WHERE ( + $1::text IS NULL OR btrim($1) = '' OR + u.name ILIKE '%' || $1 || '%' ESCAPE '\\' OR + u.schema ILIKE '%' || $1 || '%' ESCAPE '\\' +) +ORDER BY u.type, u.schema, u.name +LIMIT $2 +`; + +async function querySearchRows(client: PoolClient, rawFilter: string): Promise { + const trimmed = rawFilter.trim(); + const pattern = trimmed.length === 0 ? null : escapeLikePattern(trimmed); + const limit = pattern === null ? LIMIT_BROWSE : LIMIT_FILTER; + const result = await client.query(SEARCH_QUERY, [pattern, limit]); + return result.rows as SearchRow[]; +} + +async function acquireClientForConnection(conn: any): Promise { + const connection = await getConnectionWithPassword(conn.id, conn.database); + return ConnectionManager.getInstance().getPooledClient({ + id: connection.id, + host: connection.host, + port: connection.port, + username: connection.username, + database: conn.database || connection.database, + name: connection.name, + }); +} + +function rowToQuickPickItems( + rows: SearchRow[], + multiConnection: boolean, +): (vscode.QuickPickItem & { row: SearchRow })[] { + return rows.map((row) => ({ + label: `${TYPE_ICONS[row.type] || '$(symbol-misc)'} ${row.name}`, + description: multiConnection + ? `${row.connectionLabel} · ${row.databaseName} · ${row.schema} · ${row.type}` + : `${row.schema} · ${row.type}`, + detail: row.definition + ? row.definition.slice(0, 120).replace(/\s+/g, ' ').trim() + : undefined, + row, + })); +} + +async function pickSearchConnections( + connections: any[], +): Promise { + if (connections.length === 0) { + return undefined; + } + if (connections.length === 1) { + return [connections[0]]; + } + + type ScopePick = vscode.QuickPickItem & { connections?: any[] }; + const items: ScopePick[] = [ + { + label: '$(globe) All connections', + description: 'Search every saved connection (parallel queries)', + connections, + }, + ...connections.map((c) => ({ + label: c.name || `${c.host}:${c.port}`, + description: `${c.host}:${c.port}/${c.database}`, + connections: [c], + })), + ]; + + const picked = await vscode.window.showQuickPick(items, { + placeHolder: 'Search scope: one connection or all', + title: 'Schema search', + }); + return picked?.connections; +} + +export async function cmdSearchSchema(): Promise { + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + if (connections.length === 0) { + vscode.window.showInformationMessage('No connections configured. Please add a PostgreSQL connection first.'); + return; + } - const result = await client.query(searchQuery); + const scoped = await pickSearchConnections(connections); + if (!scoped?.length) { + return; + } - const items = result.rows.map((row: SearchRow) => ({ - label: `${TYPE_ICONS[row.type] || '$(symbol-misc)'} ${row.name}`, - description: `${row.schema} · ${row.type}`, - detail: row.definition - ? row.definition.slice(0, 120).replace(/\s+/g, ' ').trim() - : undefined, - row, - })); + const multiConnection = scoped.length > 1; - qp.items = items; - qp.busy = false; + const qp = vscode.window.createQuickPick(); + qp.title = 'Schema search — browse catalog'; + qp.placeholder = multiConnection + ? 'Filter by name or schema (server-side, all connections)…' + : 'Filter by name or schema (server-side)…'; + qp.matchOnDescription = true; + qp.matchOnDetail = true; + qp.busy = true; + qp.show(); + + const clientByConnectionId = new Map(); + const releaseAll = async (): Promise => { + for (const c of clientByConnectionId.values()) { + try { + c.release(); + } catch { + /* ignore */ + } + } + clientByConnectionId.clear(); + }; - qp.onDidAccept(async () => { - const selected = qp.selectedItems[0] as any; - qp.hide(); - if (!selected?.row) { return; } + let debounceTimer: ReturnType | undefined; + let disposed = false; - const { row } = selected; + const runSearch = async (filter: string): Promise => { + if (disposed) { + return; + } + qp.busy = true; + try { + const rows: SearchRow[] = []; + + if (!multiConnection) { + const conn = scoped[0]; + const label = conn.name || conn.host || conn.id; + let client = clientByConnectionId.get(conn.id); + if (!client) { + client = await acquireClientForConnection(conn); + clientByConnectionId.set(conn.id, client); + } + const raw = await querySearchRows(client, filter); + for (const r of raw) { + rows.push({ + ...r, + connectionId: conn.id, + databaseName: conn.database || 'postgres', + connectionLabel: label, + }); + } + } else { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Window, + title: 'PgStudio: schema search', + }, + async () => { + const tasks = scoped.map(async (conn) => { + const label = conn.name || conn.host || conn.id; + try { + let client = clientByConnectionId.get(conn.id); + if (!client) { + client = await acquireClientForConnection(conn); + clientByConnectionId.set(conn.id, client); + } + const raw = await querySearchRows(client, filter); + for (const r of raw) { + rows.push({ + ...r, + connectionId: conn.id, + databaseName: conn.database || 'postgres', + connectionLabel: label, + }); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + void vscode.window.showWarningMessage(`Schema search skipped for ${label}: ${msg}`); + } + }); + await Promise.all(tasks); + }, + ); + rows.sort((a, b) => { + const t = a.type.localeCompare(b.type); + if (t !== 0) { + return t; + } + const s = a.schema.localeCompare(b.schema); + if (s !== 0) { + return s; + } + return a.name.localeCompare(b.name); + }); + } + qp.items = rowToQuickPickItems(rows, multiConnection); + qp.title = + rows.length > 0 + ? `Schema search (${rows.length} object${rows.length === 1 ? '' : 's'})` + : filter.trim() + ? 'Schema search — no matches' + : 'Schema search — browse catalog'; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Schema search failed: ${msg}`); + qp.items = []; + } finally { + qp.busy = false; + } + }; + + await runSearch(''); + + qp.onDidChangeValue((value) => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + void runSearch(value); + }, DEBOUNCE_MS); + }); + + qp.onDidAccept(async () => { + const selected = qp.selectedItems[0]; + qp.hide(); + if (!selected?.row) { + return; + } + + const row = selected.row; + try { + const connection = await getConnectionWithPassword(row.connectionId, row.databaseName); const metadata = { connectionId: connection.id, - databaseName: selectedConn.database || connection.database, + databaseName: row.databaseName || connection.database, host: connection.host, port: connection.port, username: connection.username, @@ -190,24 +369,22 @@ WHERE p.prokind = 'a' AND n.nspname = '${row.schema}' AND p.proname = '${row.nam await new NotebookBuilder(metadata as any) .addMarkdown( MarkdownUtils.header(`${row.type}: ${row.schema}.${row.name}`) + - MarkdownUtils.infoBox(`Object type: ${row.type} | Schema: ${row.schema}`) + MarkdownUtils.infoBox(`Object type: ${row.type} | Schema: ${row.schema}`), ) .addSql(sql) .show(); - }); - - qp.onDidHide(() => { - qp.dispose(); - }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + void vscode.window.showErrorMessage(`Could not open object: ${msg}`); + } + }); - } catch (err: any) { - qp.busy = false; - qp.hide(); - qp.dispose(); - vscode.window.showErrorMessage(`Schema search failed: ${err.message}`); - } finally { - if (client) { - try { client.release(); } catch { /* ignore */ } + qp.onDidHide(async () => { + disposed = true; + if (debounceTimer) { + clearTimeout(debounceTimer); } - } + await releaseAll(); + qp.dispose(); + }); } diff --git a/src/commands/sql/index.ts b/src/commands/sql/index.ts index 05d043d..12740e7 100644 --- a/src/commands/sql/index.ts +++ b/src/commands/sql/index.ts @@ -15,6 +15,8 @@ export { ProcedureSQL } from './procedures'; export { IndexSQL } from './indexes'; export { MaterializedViewSQL } from './materializedViews'; export { PartitionSQL } from './partitions'; +export { PolicySQL } from './policies'; +export { PgCronSQL } from './pgCron'; export { SchemaSQL } from './schema'; export { SequenceSQL } from './sequences'; export { TableSQL } from './tables'; diff --git a/src/commands/sql/pgCron.ts b/src/commands/sql/pgCron.ts new file mode 100644 index 0000000..4ecde10 --- /dev/null +++ b/src/commands/sql/pgCron.ts @@ -0,0 +1,59 @@ +/** + * pg_cron — job scheduling inside PostgreSQL (extension: pg_cron). + * See https://github.com/citusdata/pg_cron + */ + +export const PgCronSQL = { + listJobs: (): string => + `SELECT jobid, schedule, command, nodename, nodeport, database, username, active, jobname +FROM cron.job +ORDER BY jobname NULLS LAST, jobid;`, + + installExtension: (): string => + `-- pg_cron runs periodic SQL as a background worker. +-- On managed services, pg_cron may be pre-installed; on self-hosted, ensure shared_preload_libraries includes 'pg_cron' and restart PostgreSQL before CREATE EXTENSION. + +CREATE EXTENSION IF NOT EXISTS pg_cron; + +-- List scheduled jobs +SELECT * FROM cron.job ORDER BY jobid;`, + + jobDetail: (jobid: number): string => + `SELECT * FROM cron.job WHERE jobid = ${jobid};`, + + jobRunHistory: (jobid: number): string => + `-- Recent runs (requires cron.job_run_details; pg_cron 1.3+) +SELECT jobid, runid, job_pid, database, username, command, status, return_message, + start_time, end_time +FROM cron.job_run_details +WHERE jobid = ${jobid} +ORDER BY start_time DESC +LIMIT 100;`, + + unschedule: (jobid: number): string => + `-- Remove job ${jobid} from the schedule +SELECT cron.unschedule(${jobid}::bigint);`, + + scheduleNewJob: (): string => + `-- Schedule SQL (cron expression: minute hour day-of-month month day-of-week) +-- Name + schedule + command (use dollar-quoting for multi-line SQL) + +SELECT cron.schedule( + 'my_nightly_job', -- job name (optional in some versions) + '0 2 * * *', -- daily at 02:00 + $$VACUUM ANALYZE public.my_table;$$ +); + +-- One-argument form (schedule + command only): +-- SELECT cron.schedule('*/15 * * * *', $$SELECT refresh_materialized_view('public.stats_mv');$$); + +SELECT * FROM cron.job ORDER BY jobid DESC LIMIT 5;`, + + alterJobNote: (): string => + `-- pg_cron 1.4+: alter schedule, command, database, username, active +-- SELECT cron.alter_job(job_id := 1, schedule := '0 * * * *', command := $$SELECT 1$$); + +SELECT jobid, jobname, schedule, active, database, username +FROM cron.job +ORDER BY jobid;`, +}; diff --git a/src/commands/sql/policies.ts b/src/commands/sql/policies.ts new file mode 100644 index 0000000..f8d45d8 --- /dev/null +++ b/src/commands/sql/policies.ts @@ -0,0 +1,11 @@ +/** RLS policy DDL templates (identifiers quoted; no dynamic SQL concatenation at runtime). */ + +function quoteIdent(value: string): string { + return `"${value.replace(/"/g, '""')}"`; +} + +export const PolicySQL = { + drop: (schema: string, table: string, policyName: string): string => + `-- Drop row-level security policy +DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${quoteIdent(schema)}.${quoteIdent(table)};`, +}; diff --git a/src/commands/workspaceConnection.ts b/src/commands/workspaceConnection.ts new file mode 100644 index 0000000..05e7fd4 --- /dev/null +++ b/src/commands/workspaceConnection.ts @@ -0,0 +1,51 @@ +import * as vscode from 'vscode'; +import { ConnectionUtils } from '../utils/connectionUtils'; +import { WorkspaceStateService } from '../services/WorkspaceStateService'; +import { ErrorHandlers } from './helper'; +import { statusBar } from '../extension'; + +/** + * Pick connection + database and store as this workspace’s default. + * If a PostgreSQL notebook is active, its metadata is updated to match. + */ +export async function switchWorkspaceDefaultConnection(): Promise { + try { + if (!vscode.workspace.workspaceFolders?.length) { + vscode.window.showWarningMessage('Open a folder or workspace to set a workspace default connection.'); + return; + } + + const ws = WorkspaceStateService.getInstance(); + const defaults = ws.getDefaults(); + + const selected = await ConnectionUtils.showConnectionPicker(defaults.lastConnectionId); + if (!selected) { + return; + } + + const selectedDb = await ConnectionUtils.showDatabasePicker(selected, defaults.lastDatabaseName); + if (!selectedDb) { + return; + } + + await ws.recordDatabaseSwitch(selected.id, selectedDb); + + const editor = ConnectionUtils.getActivePostgresNotebook(); + if (editor) { + await ConnectionUtils.updateNotebookMetadata(editor.notebook, { + connectionId: selected.id, + databaseName: selectedDb, + host: selected.host, + port: selected.port, + username: selected.username, + }); + } + + vscode.window.showInformationMessage( + `Workspace default: ${selected.name || selected.host} → ${selectedDb}`, + ); + statusBar?.update(); + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'set workspace default connection'); + } +} diff --git a/src/common/types.ts b/src/common/types.ts index c03b6a2..e1dca30 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -109,6 +109,10 @@ export interface QueryResults { statementCount: number; // Statements executed in current transaction }; pendingCommit?: boolean; // True when result was produced inside a transaction + /** True when SqlExecutor appended LIMIT (auto-limit / profile / read-only). */ + autoLimitApplied?: boolean; + /** Effective LIMIT value when autoLimitApplied is true. */ + autoLimitValue?: number; } export interface TableRenderOptions { diff --git a/src/core/connection/cloudAuth/types.ts b/src/core/connection/cloudAuth/types.ts new file mode 100644 index 0000000..8ff3926 --- /dev/null +++ b/src/core/connection/cloudAuth/types.ts @@ -0,0 +1,8 @@ +/** + * Cloud IAM auth adapters (v1.5+). Implementations store tokens via SecretStorage only. + */ +export type CloudAuthKind = 'aws-iam' | 'azure-ad' | 'gcp-iam' | 'none'; + +export interface CloudAuthContext { + kind: CloudAuthKind; +} diff --git a/src/core/types/handlerMessages.ts b/src/core/types/handlerMessages.ts new file mode 100644 index 0000000..b7efa11 --- /dev/null +++ b/src/core/types/handlerMessages.ts @@ -0,0 +1,16 @@ +/** + * Discriminated union for webview ↔ extension notebook messages (extend as handlers grow). + * Prefer narrowing on `type` at call sites. + */ +export type HandlerMessageType = + | 'breadcrumbNavigate' + | 'saveColumnWidths' + | 'getColumnWidths' + | 'exportRequest' + | 'retryCell' + | 'explainError' + | 'fixQuery'; + +export interface HandlerMessageBase { + type: string; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts new file mode 100644 index 0000000..1f2fb0d --- /dev/null +++ b/src/core/types/index.ts @@ -0,0 +1 @@ +export type { HandlerMessageType, HandlerMessageBase } from './handlerMessages'; diff --git a/src/dashboard/DashboardData.ts b/src/dashboard/DashboardData.ts index 75c781f..91a1707 100644 --- a/src/dashboard/DashboardData.ts +++ b/src/dashboard/DashboardData.ts @@ -67,13 +67,81 @@ export interface DashboardStats { indexHitRatio: number; oldestTransactionAgeSeconds: number; vacuumTablesNeedingAttention: number; + + /** WAL / replication (may be partially empty if views are inaccessible). */ + walReplication: WalReplicationStats; +} + +export interface WalReplicationStats { + inRecovery: boolean; + currentWalLsn: string | null; + receiveLsn: string | null; + replayLsn: string | null; + /** Bytes standby is behind receive on replay (standby only). */ + replayLagBytes: number | null; + replicas: Array<{ + application_name: string | null; + client_addr: string | null; + state: string | null; + sent_lsn: string | null; + write_lsn: string | null; + flush_lsn: string | null; + replay_lsn: string | null; + write_lag: string | null; + flush_lag: string | null; + replay_lag: string | null; + sync_state: string | null; + backend_start: string | null; + }>; + walReceiver: { + status: string | null; + received_lsn: string | null; + latest_end_lsn: string | null; + slot_name: string | null; + sender_host: string | null; + sender_port: number | null; + last_msg_receipt_time: string | null; + } | null; + settings: Record; + pgStatWal: Record | null; + replicationSlots: Array<{ + slot_name: string; + plugin: string | null; + slot_type: string | null; + active: boolean; + wal_status: string | null; + restart_lsn: string | null; + confirmed_flush_lsn: string | null; + }>; } import { Client, PoolClient } from 'pg'; export async function fetchStats(client: Client | PoolClient, dbName: string): Promise { // Fetch data with error handling for each query to prevent one failure from breaking the entire dashboard - const [dbInfoRes, connRes, tableRes, extRes, countsRes, activeQueriesRes, locksRes, metricsRes, settingsRes, pgStatRes, waitsRes, longQueriesRes, indexHitRes, oldestTxRes, vacuumHealthRes] = await Promise.allSettled([ + const [ + dbInfoRes, + connRes, + tableRes, + extRes, + countsRes, + activeQueriesRes, + locksRes, + metricsRes, + settingsRes, + pgStatRes, + waitsRes, + longQueriesRes, + indexHitRes, + oldestTxRes, + vacuumHealthRes, + walSnapshotRes, + walReplRes, + walSettingsRes, + walReceiverRes, + pgStatWalRes, + replSlotsRes, + ] = await Promise.allSettled([ // DB Info client.query(` SELECT pg_catalog.pg_get_userbyid(d.datdba) as owner, @@ -228,7 +296,94 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P SELECT COUNT(*)::int AS tables_needing_attention FROM pg_stat_user_tables WHERE n_dead_tup > GREATEST((n_live_tup * 0.2)::bigint, 1000) - `) + `), + + // WAL / replication snapshot (safe on primary and standby) + client.query(` + SELECT + pg_is_in_recovery() AS in_recovery, + CASE WHEN NOT pg_is_in_recovery() THEN pg_current_wal_lsn()::text ELSE NULL END AS current_wal_lsn, + CASE WHEN pg_is_in_recovery() THEN pg_last_wal_receive_lsn()::text ELSE NULL END AS receive_lsn, + CASE WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn()::text ELSE NULL END AS replay_lsn, + CASE + WHEN pg_is_in_recovery() + AND pg_last_wal_receive_lsn() IS NOT NULL + AND pg_last_wal_replay_lsn() IS NOT NULL + THEN pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) + ELSE NULL + END AS replay_lag_bytes + `), + + client.query(` + SELECT + application_name, + client_addr::text AS client_addr, + state, + sent_lsn::text AS sent_lsn, + write_lsn::text AS write_lsn, + flush_lsn::text AS flush_lsn, + replay_lsn::text AS replay_lsn, + write_lag::text AS write_lag, + flush_lag::text AS flush_lag, + replay_lag::text AS replay_lag, + sync_state, + backend_start::text AS backend_start + FROM pg_stat_replication + ORDER BY application_name NULLS LAST, pid + `), + + client.query(` + SELECT name, setting, unit + FROM pg_settings + WHERE name IN ( + 'wal_level', + 'max_wal_size', + 'min_wal_size', + 'archive_mode', + 'synchronous_standby_names', + 'archive_command' + ) + `), + + client.query(` + SELECT + status, + received_lsn::text AS received_lsn, + latest_end_lsn::text AS latest_end_lsn, + slot_name, + sender_host::text AS sender_host, + sender_port, + last_msg_receipt_time::text AS last_msg_receipt_time + FROM pg_stat_wal_receiver + LIMIT 1 + `), + + client.query(` + SELECT + wal_records, + wal_fpi, + wal_bytes, + wal_buffers_full, + wal_write, + wal_sync, + wal_write_time, + wal_sync_time, + stats_reset::text AS stats_reset + FROM pg_stat_wal + `), + + client.query(` + SELECT + slot_name, + plugin::text AS plugin, + slot_type, + active, + wal_status::text AS wal_status, + restart_lsn::text AS restart_lsn, + confirmed_flush_lsn::text AS confirmed_flush_lsn + FROM pg_replication_slots + ORDER BY slot_name + `), ]); // Helper to safely extract result or return empty default @@ -261,6 +416,83 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P const oldestTxRow = getResult(oldestTxRes).rows[0] || { oldest_tx_age_seconds: 0 }; const vacuumHealthRow = getResult(vacuumHealthRes).rows[0] || { tables_needing_attention: 0 }; + const walSnapRow = getResult(walSnapshotRes).rows[0]; + const replRows = getResult(walReplRes).rows; + const walSettingRows = getResult(walSettingsRes).rows; + const walRecvRow = getResult(walReceiverRes).rows[0]; + const pgWalRow = getResult(pgStatWalRes).rows[0]; + const slotRows = getResult(replSlotsRes).rows; + + const walSettingsMap: Record = {}; + for (const r of walSettingRows) { + const u = r.unit && String(r.unit).trim() !== '' ? ` ${r.unit}` : ''; + walSettingsMap[r.name] = `${r.setting ?? ''}${u}`; + } + + const walReceiver: WalReplicationStats['walReceiver'] = walRecvRow + ? { + status: walRecvRow.status ?? null, + received_lsn: walRecvRow.received_lsn ?? null, + latest_end_lsn: walRecvRow.latest_end_lsn ?? null, + slot_name: walRecvRow.slot_name ?? null, + sender_host: walRecvRow.sender_host ?? null, + sender_port: walRecvRow.sender_port != null ? Number(walRecvRow.sender_port) : null, + last_msg_receipt_time: walRecvRow.last_msg_receipt_time ?? null, + } + : null; + + let pgStatWalOut: WalReplicationStats['pgStatWal'] = null; + if (pgWalRow) { + pgStatWalOut = { + wal_records: Number(pgWalRow.wal_records || 0), + wal_fpi: Number(pgWalRow.wal_fpi || 0), + wal_bytes: Number(pgWalRow.wal_bytes || 0), + wal_buffers_full: Number(pgWalRow.wal_buffers_full || 0), + wal_write: Number(pgWalRow.wal_write || 0), + wal_sync: Number(pgWalRow.wal_sync || 0), + wal_write_time: Number(pgWalRow.wal_write_time || 0), + wal_sync_time: Number(pgWalRow.wal_sync_time || 0), + stats_reset: String(pgWalRow.stats_reset || ''), + }; + } + + const walReplication: WalReplicationStats = { + inRecovery: Boolean(walSnapRow?.in_recovery), + currentWalLsn: walSnapRow?.current_wal_lsn ?? null, + receiveLsn: walSnapRow?.receive_lsn ?? null, + replayLsn: walSnapRow?.replay_lsn ?? null, + replayLagBytes: + walSnapRow?.replay_lag_bytes != null && walSnapRow.replay_lag_bytes !== '' + ? Number(walSnapRow.replay_lag_bytes) + : null, + replicas: replRows.map((r: any) => ({ + application_name: r.application_name ?? null, + client_addr: r.client_addr ?? null, + state: r.state ?? null, + sent_lsn: r.sent_lsn ?? null, + write_lsn: r.write_lsn ?? null, + flush_lsn: r.flush_lsn ?? null, + replay_lsn: r.replay_lsn ?? null, + write_lag: r.write_lag ?? null, + flush_lag: r.flush_lag ?? null, + replay_lag: r.replay_lag ?? null, + sync_state: r.sync_state ?? null, + backend_start: r.backend_start ?? null, + })), + walReceiver, + settings: walSettingsMap, + pgStatWal: pgStatWalOut, + replicationSlots: slotRows.map((r: any) => ({ + slot_name: String(r.slot_name ?? ''), + plugin: r.plugin ?? null, + slot_type: r.slot_type ?? null, + active: Boolean(r.active), + wal_status: r.wal_status ?? null, + restart_lsn: r.restart_lsn ?? null, + confirmed_flush_lsn: r.confirmed_flush_lsn ?? null, + })), + }; + let active = 0; let idle = 0; let waiting = 0; @@ -350,7 +582,8 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P longRunningQueries: parseInt(longQueriesRow.count), indexHitRatio: Math.max(0, Math.min(100, Number(indexHitRow.index_hit_ratio || 100))), oldestTransactionAgeSeconds: parseInt(oldestTxRow.oldest_tx_age_seconds || '0'), - vacuumTablesNeedingAttention: parseInt(vacuumHealthRow.tables_needing_attention || '0') + vacuumTablesNeedingAttention: parseInt(vacuumHealthRow.tables_needing_attention || '0'), + walReplication, }; } diff --git a/src/extension.ts b/src/extension.ts index 9406bfe..b01ac70 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,30 +1,16 @@ -import { Client } from 'pg'; import * as vscode from 'vscode'; -import { PostgresMetadata } from './common/types'; -import { PostgresKernel } from './providers/NotebookKernel'; import { ConnectionManager } from './services/ConnectionManager'; import { SecretStorageService } from './services/SecretStorageService'; -import { ProfileManager } from './services/ProfileManager'; -import { SavedQueriesService } from './services/SavedQueriesService'; -import { ErrorHandlers, NotebookBuilder } from './commands/helper'; +import { ProfileManager } from './features/connections/ProfileManager'; +import { SavedQueriesService } from './features/savedQueries/SavedQueriesService'; +import { NotebookBuilder } from './commands/helper'; import { SessionRegistry } from './services/SessionRegistry'; -import { registerProviders } from './activation/providers'; -import { registerAllCommands } from './activation/commands'; -import { NotebookStatusBar } from './activation/statusBar'; -import { WhatsNewManager } from './activation/WhatsNewManager'; -import { ChatViewProvider } from './providers/ChatViewProvider'; +import type { NotebookStatusBar } from './activation/statusBar'; +import type { ChatViewProvider } from './providers/ChatViewProvider'; import { QueryHistoryService } from './services/QueryHistoryService'; import { QueryPerformanceService } from './services/QueryPerformanceService'; -import { ConnectionUtils } from './utils/connectionUtils'; -import { ExplainProvider } from './providers/ExplainProvider'; +import { WorkspaceStateService } from './services/WorkspaceStateService'; import { MessageHandlerRegistry } from './services/MessageHandler'; -import { - ExplainErrorHandler, FixQueryHandler, AnalyzeDataHandler, OptimizeQueryHandler, - SendToChatHandler, ShowExplainPlanHandler, ConvertExplainHandler -} from './services/handlers/ExplainHandlers'; -import { ShowConnectionSwitcherHandler, ShowDatabaseSwitcherHandler, ShowErrorMessageHandler, ExportRequestHandler, RetryCellHandler, ShowConnectionInfoHandler } from './services/handlers/CoreHandlers'; -import { ExecuteUpdateBackgroundHandler, ScriptDeleteHandler, SaveChangesHandler } from './services/handlers/QueryHandlers'; -import { formatSqlCommand, createFormatOnSaveListener } from './commands/formatSql'; export let outputChannel: vscode.OutputChannel; export let extensionContext: vscode.ExtensionContext; @@ -32,6 +18,18 @@ export let statusBar: NotebookStatusBar; let chatViewProvider: ChatViewProvider | undefined; +function runDeferredStartupTask(taskName: string, task: () => Promise): void { + void (async () => { + const start = Date.now(); + try { + await task(); + outputChannel?.appendLine(`[startup/deferred] ${taskName} completed in ${Date.now() - start}ms`); + } catch (error) { + outputChannel?.appendLine(`[startup/deferred] ${taskName} failed: ${error}`); + } + })(); +} + function isAzurePostgresHost(host?: string): boolean { if (!host) { return false; @@ -61,7 +59,47 @@ export function getChatViewProvider(): ChatViewProvider | undefined { return chatViewProvider; } +async function ensureRendererMessageHandlers( + registry: MessageHandlerRegistry, + chatView: ChatViewProvider, + statusBarInstance: NotebookStatusBar, + context: vscode.ExtensionContext +): Promise { + const [ + explainHandlersModule, + coreHandlersModule, + queryHandlersModule, + ] = await Promise.all([ + import('./services/handlers/ExplainHandlers'), + import('./services/handlers/CoreHandlers'), + import('./services/handlers/QueryHandlers'), + ]); + + // Explain & Chat Handlers + registry.register('explainError', new explainHandlersModule.ExplainErrorHandler(chatView)); + registry.register('fixQuery', new explainHandlersModule.FixQueryHandler(chatView)); + registry.register('analyzeData', new explainHandlersModule.AnalyzeDataHandler(chatView)); + registry.register('optimizeQuery', new explainHandlersModule.OptimizeQueryHandler(chatView)); + registry.register('sendToChat', new explainHandlersModule.SendToChatHandler(chatView)); + registry.register('showExplainPlan', new explainHandlersModule.ShowExplainPlanHandler(context.extensionUri)); + registry.register('convertExplainToJson', new explainHandlersModule.ConvertExplainHandler(context)); + + // Core Handlers + registry.register('showConnectionSwitcher', new coreHandlersModule.ShowConnectionSwitcherHandler(statusBarInstance)); + registry.register('showDatabaseSwitcher', new coreHandlersModule.ShowDatabaseSwitcherHandler(statusBarInstance)); + registry.register('showErrorMessage', new coreHandlersModule.ShowErrorMessageHandler()); + registry.register('export_request', new coreHandlersModule.ExportRequestHandler()); + registry.register('retryCell', new coreHandlersModule.RetryCellHandler()); + registry.register('showConnectionInfo', new coreHandlersModule.ShowConnectionInfoHandler()); + + // Query Execution Handlers + registry.register('execute_update_background', new queryHandlersModule.ExecuteUpdateBackgroundHandler()); + registry.register('script_delete', new queryHandlersModule.ScriptDeleteHandler()); + registry.register('saveChanges', new queryHandlersModule.SaveChangesHandler()); +} + export async function activate(context: vscode.ExtensionContext) { + const activationStart = Date.now(); extensionContext = context; // Provide extension context to NotebookBuilder for persistent session support (Req 5.4) @@ -88,6 +126,9 @@ export async function activate(context: vscode.ExtensionContext) { QueryHistoryService.initialize(context.workspaceState); QueryPerformanceService.initialize(context.globalState); + WorkspaceStateService.getInstance().initialize(context); + context.subscriptions.push({ dispose: () => WorkspaceStateService.getInstance().dispose() }); + // Migration: Ensure all connections have an ID (legacy connections might not) const config = vscode.workspace.getConfiguration(); const connections = config.get('postgresExplorer.connections') || []; @@ -123,7 +164,11 @@ export async function activate(context: vscode.ExtensionContext) { // Phase 7: Initialize ProfileManager and SavedQueriesService ProfileManager.getInstance().initialize(context); SavedQueriesService.getInstance().initialize(context); - await ProfileManager.getInstance().initializeDefaultProfiles(); + + // Non-blocking startup: default profile seeding can happen after activation completes. + runDeferredStartupTask('initializeDefaultProfiles', async () => { + await ProfileManager.getInstance().initializeDefaultProfiles(); + }); // D3: Opt profile and favorites data into VS Code Settings Sync so users can // share their connection profiles and query library across machines. @@ -132,36 +177,73 @@ export async function activate(context: vscode.ExtensionContext) { 'postgresExplorer.favorites', ]); - const { databaseTreeProvider, treeView, chatViewProviderInstance: chatView, savedQueriesTreeProvider, notebooksTreeProvider, autoRefreshService } = registerProviders(context, outputChannel); + const [providersModule, commandsModule, notebookKernelModule, whatsNewModule, statusBarModule] = await Promise.all([ + import('./activation/providers'), + import('./activation/commands'), + import('./providers/NotebookKernel'), + import('./activation/WhatsNewManager'), + import('./activation/statusBar'), + ]); + + const { databaseTreeProvider, treeView, chatViewProviderInstance: chatView, savedQueriesTreeProvider, notebooksTreeProvider, autoRefreshService } = providersModule.registerProviders(context, outputChannel); context.subscriptions.push(autoRefreshService); chatViewProvider = chatView; // Store tree view instance for reveal functionality (databaseTreeProvider as any).setTreeView(treeView); - registerAllCommands(context, databaseTreeProvider, chatView, outputChannel, savedQueriesTreeProvider, notebooksTreeProvider); + commandsModule.registerAllCommands(context, databaseTreeProvider, chatView, outputChannel, savedQueriesTreeProvider, notebooksTreeProvider); - // Kernel initialization const rendererMessaging = vscode.notebooks.createRendererMessaging('postgres-query-renderer'); - const kernel = new PostgresKernel(context, rendererMessaging, 'postgres-notebook', async (msg: { type: string; command: string; format?: string; content?: string; filename?: string }) => { - if (msg.type === 'custom' && msg.command === 'export') { - vscode.commands.executeCommand('postgres-explorer.exportData', { - format: msg.format, - content: msg.content, - filename: msg.filename - }); + let kernelsInitialized = false; + const ensureNotebookKernels = () => { + if (kernelsInitialized) { + return; } - }); - context.subscriptions.push(kernel); + + const notebookKernel = new notebookKernelModule.PostgresKernel(context, rendererMessaging, 'postgres-notebook', async (msg: { type: string; command: string; format?: string; content?: string; filename?: string }) => { + if (msg.type === 'custom' && msg.command === 'export') { + vscode.commands.executeCommand('postgres-explorer.exportData', { + format: msg.format, + content: msg.content, + filename: msg.filename + }); + } + }); + + const queryKernel = new notebookKernelModule.PostgresKernel(context, rendererMessaging, 'postgres-query'); + context.subscriptions.push(notebookKernel, queryKernel); + kernelsInitialized = true; + outputChannel.appendLine('[startup] notebook kernels initialized lazily'); + }; + + context.subscriptions.push( + vscode.workspace.onDidOpenNotebookDocument((notebook) => { + if (notebook.notebookType === 'postgres-notebook' || notebook.notebookType === 'postgres-query') { + ensureNotebookKernels(); + } + }) + ); + + if (vscode.workspace.notebookDocuments.some((notebook) => notebook.notebookType === 'postgres-notebook' || notebook.notebookType === 'postgres-query')) { + ensureNotebookKernels(); + } // What's New / Welcome Screen - const whatsNewManager = new WhatsNewManager(context, context.extensionUri); + const whatsNewManager = new whatsNewModule.WhatsNewManager(context, context.extensionUri); // SQL Formatter command + format-on-save listener context.subscriptions.push( - vscode.commands.registerCommand('postgres-explorer.formatSql', formatSqlCommand) + vscode.commands.registerCommand('postgres-explorer.formatSql', async () => { + const { formatSqlCommand } = await import('./commands/formatSql'); + await formatSqlCommand(); + }) ); - context.subscriptions.push(createFormatOnSaveListener()); + + runDeferredStartupTask('registerFormatOnSaveListener', async () => { + const { createFormatOnSaveListener } = await import('./commands/formatSql'); + context.subscriptions.push(createFormatOnSaveListener()); + }); context.subscriptions.push( vscode.commands.registerCommand('postgres-explorer.showWhatsNew', () => { @@ -169,40 +251,24 @@ export async function activate(context: vscode.ExtensionContext) { }) ); // Auto-open once on install/update; manager tracks the last shown version in global state. - void whatsNewManager.checkAndShow(false); - - const queryKernel = new PostgresKernel(context, rendererMessaging, 'postgres-query'); + runDeferredStartupTask('showWhatsNew', async () => { + await whatsNewManager.checkAndShow(false); + }); // Status bar for connection/database display - statusBar = new NotebookStatusBar(); + statusBar = new statusBarModule.NotebookStatusBar(); context.subscriptions.push(statusBar); // Register Message Handlers const registry = MessageHandlerRegistry.getInstance(); - - // Explain & Chat Handlers - registry.register('explainError', new ExplainErrorHandler(chatView)); - registry.register('fixQuery', new FixQueryHandler(chatView)); - registry.register('analyzeData', new AnalyzeDataHandler(chatView)); - registry.register('optimizeQuery', new OptimizeQueryHandler(chatView)); - registry.register('sendToChat', new SendToChatHandler(chatView)); - registry.register('showExplainPlan', new ShowExplainPlanHandler(context.extensionUri)); - registry.register('convertExplainToJson', new ConvertExplainHandler(context)); - - // Core Handlers - registry.register('showConnectionSwitcher', new ShowConnectionSwitcherHandler(statusBar)); - registry.register('showDatabaseSwitcher', new ShowDatabaseSwitcherHandler(statusBar)); - registry.register('showErrorMessage', new ShowErrorMessageHandler()); - registry.register('export_request', new ExportRequestHandler()); - registry.register('retryCell', new RetryCellHandler()); - registry.register('showConnectionInfo', new ShowConnectionInfoHandler()); - - // Query Execution Handlers - registry.register('execute_update_background', new ExecuteUpdateBackgroundHandler()); - registry.register('script_delete', new ScriptDeleteHandler()); - registry.register('saveChanges', new SaveChangesHandler()); + let handlersInitialized = false; rendererMessaging.onDidReceiveMessage(async (event) => { + if (!handlersInitialized) { + await ensureRendererMessageHandlers(registry, chatView, statusBar!, context); + handlersInitialized = true; + } + await registry.handleMessage(event.message, { editor: event.editor, postMessage: (msg) => rendererMessaging.postMessage(msg, event.editor) @@ -210,17 +276,23 @@ export async function activate(context: vscode.ExtensionContext) { }); // Auto-generate notebook title on open - const { updateNotebookTitle } = await import('./utils/notebookTitle'); - context.subscriptions.push( - vscode.workspace.onDidOpenNotebookDocument(async (notebook) => { - if (notebook.notebookType === 'postgres-notebook' || notebook.notebookType === 'postgres-query') { - await updateNotebookTitle(notebook); - } - }) - ); + runDeferredStartupTask('registerNotebookTitleUpdater', async () => { + const { updateNotebookTitle } = await import('./utils/notebookTitle'); + context.subscriptions.push( + vscode.workspace.onDidOpenNotebookDocument(async (notebook) => { + if (notebook.notebookType === 'postgres-notebook' || notebook.notebookType === 'postgres-query') { + await updateNotebookTitle(notebook); + } + }) + ); + }); + + runDeferredStartupTask('migrateExistingPasswords', async () => { + const { migrateExistingPasswords } = await import('./services/SecretStorageService'); + await migrateExistingPasswords(context); + }); - const { migrateExistingPasswords } = await import('./services/SecretStorageService'); - await migrateExistingPasswords(context); + outputChannel.appendLine(`PgStudio activation completed in ${Date.now() - activationStart}ms`); } export async function deactivate() { diff --git a/src/aiSettingsPanel.ts b/src/features/aiAssistant/settings/aiSettingsPanel.ts similarity index 99% rename from src/aiSettingsPanel.ts rename to src/features/aiAssistant/settings/aiSettingsPanel.ts index 22e317a..d62cc23 100644 --- a/src/aiSettingsPanel.ts +++ b/src/features/aiAssistant/settings/aiSettingsPanel.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import * as https from 'https'; import * as http from 'http'; -import { getChatViewProvider } from './extension'; +import { getChatViewProvider } from '../../../extension'; export interface AiSettings { provider: string; diff --git a/src/features/analyst/coerceNumeric.ts b/src/features/analyst/coerceNumeric.ts new file mode 100644 index 0000000..ff75c10 --- /dev/null +++ b/src/features/analyst/coerceNumeric.ts @@ -0,0 +1,20 @@ +/** + * Parse a cell value to a finite number, or null if not numeric. + */ +export function coerceNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + if (typeof value === 'bigint') { + return Number(value); + } + const s = String(value).trim(); + if (s === '') { + return null; + } + const n = Number(s); + return Number.isFinite(n) ? n : null; +} diff --git a/src/features/analyst/columnAggregates.ts b/src/features/analyst/columnAggregates.ts new file mode 100644 index 0000000..4f142b9 --- /dev/null +++ b/src/features/analyst/columnAggregates.ts @@ -0,0 +1,135 @@ +import { DISTINCT_COUNT_CAP } from './constants'; +import { coerceNumber } from './coerceNumeric'; +import { isPgNumericType } from './pgNumeric'; + +export interface ColumnStatSummary { + column: string; + pgType?: string; + rowCount: number; + nonNullCount: number; + nullCount: number; + /** Distinct non-null values; may be capped at DISTINCT_COUNT_CAP. */ + distinctCount: number; + distinctCapped: boolean; + numeric?: { + min: number; + max: number; + sum: number; + avg: number; + }; +} + +function countDistinctNonNull(sample: Iterable): { count: number; capped: boolean } { + const s = new Set(); + for (const v of sample) { + if (v === null || v === undefined) { + continue; + } + s.add(v); + if (s.size >= DISTINCT_COUNT_CAP) { + return { count: DISTINCT_COUNT_CAP, capped: true }; + } + } + return { count: s.size, capped: false }; +} + +/** + * Per-column summary for the current result set (client-side). + */ +export function computeColumnStats( + rows: Record[], + columns: string[], + columnTypes?: Record, +): ColumnStatSummary[] { + const rowCount = rows.length; + return columns.map((col) => { + const pgType = columnTypes?.[col]; + const values = rows.map((r) => r[col]); + let nonNullCount = 0; + for (const v of values) { + if (v !== null && v !== undefined) { + nonNullCount++; + } + } + const nullCount = rowCount - nonNullCount; + + const { count: distinctCount, capped: distinctCapped } = countDistinctNonNull(values); + + const treatAsNumeric = isPgNumericType(pgType) || shouldTreatAsNumericBySampling(values); + + if (!treatAsNumeric) { + return { + column: col, + pgType, + rowCount, + nonNullCount, + nullCount, + distinctCount, + distinctCapped, + }; + } + + const nums: number[] = []; + for (const v of values) { + const n = coerceNumber(v); + if (n !== null) { + nums.push(n); + } + } + + if (nums.length === 0) { + return { + column: col, + pgType, + rowCount, + nonNullCount, + nullCount, + distinctCount, + distinctCapped, + }; + } + + let min = nums[0]; + let max = nums[0]; + let sum = 0; + for (const n of nums) { + if (n < min) { + min = n; + } + if (n > max) { + max = n; + } + sum += n; + } + const avg = sum / nums.length; + + return { + column: col, + pgType, + rowCount, + nonNullCount, + nullCount, + distinctCount, + distinctCapped, + numeric: { min, max, sum, avg }, + }; + }); +} + +/** When pg type is unknown, infer numeric from sample (first 50 non-null rows). */ +function shouldTreatAsNumericBySampling(values: unknown[]): boolean { + let checked = 0; + for (const v of values) { + if (v === null || v === undefined) { + continue; + } + if (coerceNumber(v) === null) { + return false; + } + checked++; + if (checked >= 50) { + break; + } + } + return checked > 0; +} diff --git a/src/features/analyst/constants.ts b/src/features/analyst/constants.ts new file mode 100644 index 0000000..2ae781a --- /dev/null +++ b/src/features/analyst/constants.ts @@ -0,0 +1,8 @@ +/** Default number of histogram buckets (matches ColumnProfilePanel convention). */ +export const HISTOGRAM_BUCKET_COUNT = 10; + +/** Max distinct values per pivot dimension (row or column axis). */ +export const MAX_PIVOT_DISTINCT = 40; + +/** Cap for distinct counting in summary stats (performance guard). */ +export const DISTINCT_COUNT_CAP = 10001; diff --git a/src/features/analyst/histogram.ts b/src/features/analyst/histogram.ts new file mode 100644 index 0000000..e883a84 --- /dev/null +++ b/src/features/analyst/histogram.ts @@ -0,0 +1,102 @@ +import { HISTOGRAM_BUCKET_COUNT } from './constants'; +import { coerceNumber } from './coerceNumeric'; + +export interface HistogramResult { + bucketLabels: string[]; + counts: number[]; + min: number; + max: number; + validCount: number; + error?: string; +} + +export interface HistogramOptions { + bucketCount?: number; +} + +/** + * Equal-width histogram over numeric values in `column` (client-side). + */ +export function buildHistogram( + rows: Record[], + column: string, + options: HistogramOptions = {}, +): HistogramResult { + const bucketCount = options.bucketCount ?? HISTOGRAM_BUCKET_COUNT; + const nums: number[] = []; + + for (const row of rows) { + const n = coerceNumber(row[column]); + if (n !== null) { + nums.push(n); + } + } + + if (nums.length === 0) { + return { + bucketLabels: [], + counts: [], + min: 0, + max: 0, + validCount: 0, + error: 'No numeric values in this column.', + }; + } + + let min = nums[0]; + let max = nums[0]; + for (const n of nums) { + if (n < min) { + min = n; + } + if (n > max) { + max = n; + } + } + + const validCount = nums.length; + + if (min === max) { + return { + bucketLabels: [`${formatNum(min)}`], + counts: [validCount], + min, + max, + validCount, + }; + } + + const buckets = new Array(bucketCount).fill(0); + const span = max - min; + const n = nums.length; + for (let i = 0; i < n; i++) { + const v = nums[i]; + const idx = Math.min( + bucketCount - 1, + Math.floor(((v - min) / span) * bucketCount), + ); + buckets[idx]++; + } + + const bucketLabels: string[] = []; + for (let b = 0; b < bucketCount; b++) { + const lo = min + (span * b) / bucketCount; + const hi = min + (span * (b + 1)) / bucketCount; + bucketLabels.push(`${formatNum(lo)} – ${formatNum(hi)}`); + } + + return { + bucketLabels, + counts: buckets, + min, + max, + validCount, + }; +} + +function formatNum(x: number): string { + if (Number.isInteger(x) && Math.abs(x) < 1e12) { + return String(x); + } + return x.toPrecision(4); +} diff --git a/src/features/analyst/index.ts b/src/features/analyst/index.ts new file mode 100644 index 0000000..685409d --- /dev/null +++ b/src/features/analyst/index.ts @@ -0,0 +1,6 @@ +export * from './constants'; +export * from './coerceNumeric'; +export * from './pgNumeric'; +export * from './columnAggregates'; +export * from './histogram'; +export * from './pivot'; diff --git a/src/features/analyst/pgNumeric.ts b/src/features/analyst/pgNumeric.ts new file mode 100644 index 0000000..98ed0a9 --- /dev/null +++ b/src/features/analyst/pgNumeric.ts @@ -0,0 +1,30 @@ +/** + * PostgreSQL type names treated as numeric for analyst features. + * Kept in sync with ChartControls NUMERIC_PG_TYPES. + */ +const NUMERIC_PG_TYPES = new Set([ + 'int2', + 'int4', + 'int8', + 'float4', + 'float8', + 'numeric', + 'decimal', + 'money', + 'real', + 'double precision', + 'bigint', + 'integer', + 'smallint', +]); + +export function isPgNumericType(typeName: string | undefined): boolean { + if (!typeName) { + return false; + } + const t = typeName.toLowerCase().trim(); + if (NUMERIC_PG_TYPES.has(t)) { + return true; + } + return t.startsWith('int') || t.startsWith('float') || t.startsWith('numeric'); +} diff --git a/src/features/analyst/pivot.ts b/src/features/analyst/pivot.ts new file mode 100644 index 0000000..720d36a --- /dev/null +++ b/src/features/analyst/pivot.ts @@ -0,0 +1,159 @@ +import { MAX_PIVOT_DISTINCT } from './constants'; +import { coerceNumber } from './coerceNumeric'; + +export type PivotAgg = 'sum' | 'count' | 'avg' | 'min' | 'max'; + +export interface PivotResult { + rowLabels: string[]; + colLabels: string[]; + /** cells[i][j] is value at rowLabels[i], colLabels[j] */ + cells: (number | null)[][]; +} + +interface CellAcc { + sum: number; + count: number; + min: number; + max: number; +} + +function cardinality(rows: Record[], key: string): number { + const s = new Set(); + for (const row of rows) { + s.add(String(row[key] ?? '')); + if (s.size > MAX_PIVOT_DISTINCT) { + return MAX_PIVOT_DISTINCT + 1; + } + } + return s.size; +} + +/** + * Two-dimensional pivot over in-memory result rows (client-side). + * Row/column dimensions are stringified cell values; capped distinct cardinality per axis. + */ +export function computePivot( + rows: Record[], + rowDim: string, + colDim: string, + valueKey: string | undefined, + agg: PivotAgg, +): PivotResult | { error: string } { + if (!rowDim || !colDim) { + return { error: 'Choose both row and column dimensions.' }; + } + if (rowDim === colDim) { + return { error: 'Row and column dimensions must be different columns.' }; + } + + const cardR = cardinality(rows, rowDim); + const cardC = cardinality(rows, colDim); + if (cardR > MAX_PIVOT_DISTINCT) { + return { + error: `Row dimension has too many distinct values (>${MAX_PIVOT_DISTINCT}). Choose a lower-cardinality column.`, + }; + } + if (cardC > MAX_PIVOT_DISTINCT) { + return { + error: `Column dimension has too many distinct values (>${MAX_PIVOT_DISTINCT}). Choose a lower-cardinality column.`, + }; + } + + if (agg !== 'count' && !valueKey) { + return { error: 'Choose a value column for this aggregation.' }; + } + if (valueKey && (valueKey === rowDim || valueKey === colDim)) { + return { error: 'Value column must differ from row and column dimensions.' }; + } + + const acc = new Map>(); + + for (const row of rows) { + const rk = String(row[rowDim] ?? ''); + const ck = String(row[colDim] ?? ''); + + if (agg === 'count') { + let rowMap = acc.get(rk); + if (!rowMap) { + rowMap = new Map(); + acc.set(rk, rowMap); + } + const cell = rowMap.get(ck) ?? { sum: 0, count: 0, min: Infinity, max: -Infinity }; + cell.count += 1; + rowMap.set(ck, cell); + continue; + } + + const vk = valueKey as string; + const n = coerceNumber(row[vk]); + if (n === null) { + continue; + } + + let rowMap = acc.get(rk); + if (!rowMap) { + rowMap = new Map(); + acc.set(rk, rowMap); + } + const cell = rowMap.get(ck) ?? { sum: 0, count: 0, min: Infinity, max: -Infinity }; + cell.count += 1; + cell.sum += n; + if (n < cell.min) { + cell.min = n; + } + if (n > cell.max) { + cell.max = n; + } + rowMap.set(ck, cell); + } + + const rowLabels = Array.from(acc.keys()).sort((a, b) => a.localeCompare(b)); + const colSet = new Set(); + for (const rowMap of acc.values()) { + for (const ck of rowMap.keys()) { + colSet.add(ck); + } + } + const colLabels = Array.from(colSet).sort((a, b) => a.localeCompare(b)); + + const cells: (number | null)[][] = rowLabels.map(() => + colLabels.map(() => null), + ); + + for (let i = 0; i < rowLabels.length; i++) { + const rk = rowLabels[i]; + const rowMap = acc.get(rk); + if (!rowMap) { + continue; + } + for (let j = 0; j < colLabels.length; j++) { + const ck = colLabels[j]; + const cell = rowMap.get(ck); + if (!cell || cell.count === 0) { + cells[i][j] = null; + continue; + } + switch (agg) { + case 'count': + cells[i][j] = cell.count; + break; + case 'sum': + cells[i][j] = cell.sum; + break; + case 'avg': + cells[i][j] = cell.sum / cell.count; + break; + case 'min': + cells[i][j] = cell.min === Infinity ? null : cell.min; + break; + case 'max': + cells[i][j] = cell.max === -Infinity ? null : cell.max; + break; + default: + cells[i][j] = null; + } + } + } + + return { rowLabels, colLabels, cells }; +} diff --git a/src/services/ProfileManager.ts b/src/features/connections/ProfileManager.ts similarity index 98% rename from src/services/ProfileManager.ts rename to src/features/connections/ProfileManager.ts index 9ae8299..a64ef0a 100644 --- a/src/services/ProfileManager.ts +++ b/src/features/connections/ProfileManager.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import { ConnectionConfig } from '../common/types'; -import { ConnectionManager } from './ConnectionManager'; +import { ConnectionConfig } from '../../common/types'; +import { ConnectionManager } from '../../services/ConnectionManager'; /** * Connection profile with preset safety and performance settings. diff --git a/src/connectionForm.ts b/src/features/connections/connectionForm.ts similarity index 91% rename from src/connectionForm.ts rename to src/features/connections/connectionForm.ts index 3624f70..dab57a9 100644 --- a/src/connectionForm.ts +++ b/src/features/connections/connectionForm.ts @@ -1,12 +1,12 @@ import { Client } from "pg"; import * as vscode from "vscode"; import * as fs from "fs"; -import { SSHService } from "./services/SSHService"; -import { ConnectionManager } from "./services/ConnectionManager"; +import { SSHService } from "../../services/SSHService"; +import { ConnectionManager } from "../../services/ConnectionManager"; import { resolvePgPassPasswordAsync, pgPassFileDescription, -} from "./utils/pgPassUtils"; +} from "../../utils/pgPassUtils"; export interface ConnectionInfo { id: string; @@ -44,6 +44,64 @@ export interface ConnectionInfo { }; } +async function writeConnectionsToWorkspace( + extensionContext: vscode.ExtensionContext, + connections: ConnectionInfo[], +): Promise { + try { + const connectionsForSettings = connections.map( + ({ password, ...connWithoutPassword }) => connWithoutPassword, + ); + await vscode.workspace + .getConfiguration() + .update( + "postgresExplorer.connections", + connectionsForSettings, + vscode.ConfigurationTarget.Global, + ); + + const secretsStorage = extensionContext.secrets; + for (const conn of connections) { + if (conn.password) { + await secretsStorage.store( + `postgres-password-${conn.id}`, + conn.password, + ); + } + } + } catch (error) { + console.error("Failed to store connections:", error); + const existingConnections = + vscode.workspace + .getConfiguration() + .get("postgresExplorer.connections") || []; + const sanitizedConnections = existingConnections.map( + ({ password, ...connWithoutPassword }) => connWithoutPassword, + ); + await vscode.workspace + .getConfiguration() + .update( + "postgresExplorer.connections", + sanitizedConnections, + vscode.ConfigurationTarget.Global, + ); + throw error; + } +} + +/** Append or replace a connection by id (password stored in SecretStorage). */ +export async function appendWorkspaceConnection( + extensionContext: vscode.ExtensionContext, + connection: ConnectionInfo, +): Promise { + const existing = + vscode.workspace + .getConfiguration() + .get("postgresExplorer.connections") || []; + const merged = [...existing.filter((c) => c.id !== connection.id), connection]; + await writeConnectionsToWorkspace(extensionContext, merged); +} + export class ConnectionFormPanel { public static currentPanel: ConnectionFormPanel | undefined; private readonly _panel: vscode.WebviewPanel; @@ -305,7 +363,7 @@ export class ConnectionFormPanel { ) ) { const { pgPassFileDescription } = - await import("./utils/pgPassUtils"); + await import("../../utils/pgPassUtils"); const location = pgPassFileDescription(); throw new Error( `No password found for this connection.\n\n` + @@ -580,49 +638,7 @@ export class ConnectionFormPanel { } private async storeConnections(connections: ConnectionInfo[]): Promise { - try { - // First store the connections without passwords in settings - const connectionsForSettings = connections.map( - ({ password, ...connWithoutPassword }) => connWithoutPassword, - ); - await vscode.workspace - .getConfiguration() - .update( - "postgresExplorer.connections", - connectionsForSettings, - vscode.ConfigurationTarget.Global, - ); - - // Then store passwords in SecretStorage - const secretsStorage = this._extensionContext.secrets; - for (const conn of connections) { - if (conn.password) { - // Removed logging of sensitive connection information for security. - await secretsStorage.store( - `postgres-password-${conn.id}`, - conn.password, - ); - } - } - } catch (error) { - console.error("Failed to store connections:", error); - // If anything fails, make sure we don't leave passwords in settings - const existingConnections = - vscode.workspace - .getConfiguration() - .get("postgresExplorer.connections") || []; - const sanitizedConnections = existingConnections.map( - ({ password, ...connWithoutPassword }) => connWithoutPassword, - ); - await vscode.workspace - .getConfiguration() - .update( - "postgresExplorer.connections", - sanitizedConnections, - vscode.ConfigurationTarget.Global, - ); - throw error; - } + await writeConnectionsToWorkspace(this._extensionContext, connections); } private dispose() { diff --git a/src/connectionManagement.ts b/src/features/connections/connectionManagement.ts similarity index 99% rename from src/connectionManagement.ts rename to src/features/connections/connectionManagement.ts index b1fe0f9..9c18fd8 100644 --- a/src/connectionManagement.ts +++ b/src/features/connections/connectionManagement.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { ConnectionInfo, ConnectionFormPanel } from './connectionForm'; -import { SecretStorageService } from './services/SecretStorageService'; +import { SecretStorageService } from '../../services/SecretStorageService'; export class ConnectionManagementPanel { public static currentPanel: ConnectionManagementPanel | undefined; diff --git a/src/features/migrations/detectFramework.ts b/src/features/migrations/detectFramework.ts new file mode 100644 index 0000000..c98e8b3 --- /dev/null +++ b/src/features/migrations/detectFramework.ts @@ -0,0 +1,24 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Best-effort detection of migration tool layout (v1.3+). Returns null when unknown. + */ +export function detectMigrationFramework(workspaceRoot: string): string | null { + const candidates = [ + ['migrations'], + ['db', 'migrations'], + ['prisma', 'migrations'], + ]; + for (const parts of candidates) { + const p = path.join(workspaceRoot, ...parts); + try { + if (fs.existsSync(p) && fs.statSync(p).isDirectory()) { + return parts.join('/'); + } + } catch { + /* ignore */ + } + } + return null; +} diff --git a/src/features/notebook/notebookExportHtml.ts b/src/features/notebook/notebookExportHtml.ts new file mode 100644 index 0000000..500e250 --- /dev/null +++ b/src/features/notebook/notebookExportHtml.ts @@ -0,0 +1,204 @@ +import * as vscode from 'vscode'; +import type { QueryResults } from '../../common/types'; + +export function escapeHtml(raw: string): string { + return raw + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** Minimal markdown → HTML for export (headers + paragraphs). */ +export function simpleMarkdownToHtml(md: string): string { + const lines = md.split(/\n/); + const out: string[] = []; + for (const line of lines) { + const t = line.trimEnd(); + if (t === '') { + out.push('

'); + continue; + } + let m = /^###\s+(.+)$/.exec(t); + if (m) { + out.push(`

${escapeHtml(m[1])}

`); + continue; + } + m = /^##\s+(.+)$/.exec(t); + if (m) { + out.push(`

${escapeHtml(m[1])}

`); + continue; + } + m = /^#\s+(.+)$/.exec(t); + if (m) { + out.push(`

${escapeHtml(m[1])}

`); + continue; + } + out.push(`

${escapeHtml(t)}

`); + } + return out.join('\n'); +} + +function tryParseQueryResultFromOutput(cell: vscode.NotebookCell): QueryResults | null { + for (const output of cell.outputs) { + for (const item of output.items) { + if ( + item.mime === 'application/vnd.postgres-notebook.result' || + item.mime === 'application/x-postgres-result' + ) { + try { + const text = new TextDecoder().decode(item.data as Uint8Array); + return JSON.parse(text) as QueryResults; + } catch { + return null; + } + } + } + } + return null; +} + +function tryParseErrorFromOutput(cell: vscode.NotebookCell): string | null { + for (const output of cell.outputs) { + for (const item of output.items) { + if (item.mime === 'application/vnd.postgres-notebook.error') { + try { + const text = new TextDecoder().decode(item.data as Uint8Array); + const j = JSON.parse(text) as { error?: string }; + return j.error ?? text; + } catch { + return new TextDecoder().decode(item.data as Uint8Array); + } + } + } + } + return null; +} + +function renderResultTable(data: QueryResults): string { + const cols = data.columns ?? []; + const rows = data.rows ?? []; + if (cols.length === 0) { + return `

No columns in result.

`; + } + const thead = `${cols.map((c) => `${escapeHtml(c)}`).join('')}`; + const tbody = rows + .map((row) => { + const cells = cols.map((c) => { + const v = row[c]; + const s = v === null || v === undefined ? '' : String(v); + return `${escapeHtml(s)}`; + }); + return `${cells.join('')}`; + }) + .join('\n'); + const meta = [ + data.rowCount != null ? `${data.rowCount} row(s)` : '', + data.executionTime != null ? `${data.executionTime.toFixed(3)}s` : '', + data.command ?? '', + ] + .filter(Boolean) + .join(' · '); + return ` +
${escapeHtml(meta)}
+${thead}${tbody}
`; +} + +export function serializeNotebookForGist(doc: vscode.NotebookDocument): { filename: string; json: string } { + const rawName = doc.uri.path.split('/').pop() || 'notebook.pgsql'; + const filename = rawName.endsWith('.pgsql') ? rawName : `${rawName}.pgsql`; + const cells = doc.getCells().map((c) => ({ + value: c.document.getText(), + kind: c.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql', + language: c.kind === vscode.NotebookCellKind.Markup ? 'markdown' : 'sql', + })); + const meta = { ...(doc.metadata as Record) }; + delete meta.password; + delete meta.custom; + const json = JSON.stringify({ cells, metadata: meta }, null, 2); + return { filename, json }; +} + +const EXPORT_CSS = ` +:root { color-scheme: light dark; } +body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; padding: 1rem 1.5rem; max-width: 56rem; margin-inline: auto; line-height: 1.45; } +h1 { font-size: 1.35rem; } +h2 { font-size: 1.15rem; } +h3 { font-size: 1.05rem; } +pre.sql { background: color-mix(in srgb, Canvas 92%, CanvasText 8%); padding: 0.75rem 1rem; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; } +.md-spacer { margin: 0.25rem 0; } +.section { margin-bottom: 1.75rem; border-bottom: 1px solid color-mix(in srgb, CanvasText 12%, transparent); padding-bottom: 1rem; } +.cell-label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: color-mix(in srgb, CanvasText 55%, transparent); margin-bottom: 0.35rem; } +.result-meta { font-size: 0.8rem; color: color-mix(in srgb, CanvasText 60%, transparent); margin: 0.5rem 0; } +.result-grid { width: 100%; border-collapse: collapse; font-size: 0.9rem; } +.result-grid th, .result-grid td { border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); padding: 0.35rem 0.5rem; text-align: left; vertical-align: top; } +.result-grid thead { background: color-mix(in srgb, Canvas 94%, CanvasText 6%); } +.error-box { background: color-mix(in srgb, #c00 12%, transparent); padding: 0.75rem; border-radius: 6px; white-space: pre-wrap; } +.muted { color: color-mix(in srgb, CanvasText 50%, transparent); } +header.doc-title { margin-bottom: 1.5rem; } +header.doc-title h1 { margin: 0 0 0.25rem 0; } +@media print { + body { padding: 0; max-width: none; } + .section { break-inside: avoid; } +} +`; + +/** + * Builds a standalone HTML document from the current notebook cells and outputs. + */ +export function buildNotebookHtmlDocument(doc: vscode.NotebookDocument, title: string): string { + const parts: string[] = []; + let i = 0; + for (const cell of doc.getCells()) { + i++; + if (cell.kind === vscode.NotebookCellKind.Markup) { + const html = simpleMarkdownToHtml(cell.document.getText()); + parts.push( + `
Markdown · cell ${i}
${html}
`, + ); + continue; + } + const sql = cell.document.getText(); + const sqlInner: string[] = [ + `
SQL · cell ${i}
${escapeHtml(sql)}
`, + ]; + const err = tryParseErrorFromOutput(cell); + if (err) { + sqlInner.push(`
${escapeHtml(err)}
`); + parts.push(`
${sqlInner.join('\n')}
`); + continue; + } + const result = tryParseQueryResultFromOutput(cell); + if (result && result.success !== false && result.columns?.length) { + sqlInner.push(`
${renderResultTable(result)}
`); + } else if (result && result.success === false) { + sqlInner.push( + `
${escapeHtml(String((result as any).error ?? 'Query failed'))}
`, + ); + } else if (result) { + const cmd = result.command ?? 'statement'; + const rc = result.rowCount != null ? `${result.rowCount} row(s)` : ''; + sqlInner.push(`

${escapeHtml([cmd, rc].filter(Boolean).join(' · '))}

`); + } + parts.push(`
${sqlInner.join('\n')}
`); + } + + const safeTitle = escapeHtml(title); + return ` + + + + + ${safeTitle} + + + +
+

${safeTitle}

+

Exported from PgStudio · Use your browser’s Print dialog to save as PDF.

+
+ ${parts.join('\n')} + +`; +} diff --git a/src/notebookProvider.ts b/src/features/notebook/notebookProvider.ts similarity index 100% rename from src/notebookProvider.ts rename to src/features/notebook/notebookProvider.ts diff --git a/src/postgresNotebook.ts b/src/features/notebook/postgresNotebook.ts similarity index 100% rename from src/postgresNotebook.ts rename to src/features/notebook/postgresNotebook.ts diff --git a/src/SaveQueryPanel.ts b/src/features/savedQueries/SaveQueryPanel.ts similarity index 98% rename from src/SaveQueryPanel.ts rename to src/features/savedQueries/SaveQueryPanel.ts index 791abe4..c321d74 100644 --- a/src/SaveQueryPanel.ts +++ b/src/features/savedQueries/SaveQueryPanel.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; -import { SavedQueriesService, SavedQuery } from './services/SavedQueriesService'; -import { QueryAnalyzer } from './services/QueryAnalyzer'; -import { AiService } from './providers/chat/AiService'; +import { SqlParser } from '../../providers/kernel/SqlParser'; +import { SavedQueriesService, SavedQuery } from './SavedQueriesService'; +import { QueryAnalyzer } from '../../services/QueryAnalyzer'; +import { AiService } from '../../providers/chat/AiService'; export class SaveQueryPanel { public static currentPanel: SaveQueryPanel | undefined; @@ -288,7 +289,8 @@ export class SaveQueryPanel { lastUsed: Date.now(), connectionId, databaseName, - schemaName + schemaName, + isTemplate: SqlParser.hasNamedParameters(query) }; await service.updateQuery(updatedQuery); @@ -307,7 +309,8 @@ export class SaveQueryPanel { lastUsed: now, connectionId, databaseName, - schemaName + schemaName, + isTemplate: SqlParser.hasNamedParameters(query) }; await service.saveQuery(savedQuery); diff --git a/src/services/SavedQueriesService.ts b/src/features/savedQueries/SavedQueriesService.ts similarity index 98% rename from src/services/SavedQueriesService.ts rename to src/features/savedQueries/SavedQueriesService.ts index da47bbf..f7ad745 100644 --- a/src/services/SavedQueriesService.ts +++ b/src/features/savedQueries/SavedQueriesService.ts @@ -28,6 +28,8 @@ export interface SavedQuery { databaseName?: string; /** Schema name for context */ schemaName?: string; + /** Set when the query uses `:name` placeholders (detected on save). */ + isTemplate?: boolean; } /** diff --git a/src/SavedQueryDetailsPanel.ts b/src/features/savedQueries/SavedQueryDetailsPanel.ts similarity index 99% rename from src/SavedQueryDetailsPanel.ts rename to src/features/savedQueries/SavedQueryDetailsPanel.ts index bba498d..fb8c7aa 100644 --- a/src/SavedQueryDetailsPanel.ts +++ b/src/features/savedQueries/SavedQueryDetailsPanel.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import { SavedQueriesService } from './services/SavedQueriesService'; +import { SavedQueriesService } from './SavedQueriesService'; export class SavedQueryDetailsPanel { public static currentPanel: SavedQueryDetailsPanel | undefined; diff --git a/src/features/schemaDiff/SchemaDiffEngine.ts b/src/features/schemaDiff/SchemaDiffEngine.ts new file mode 100644 index 0000000..a5c0045 --- /dev/null +++ b/src/features/schemaDiff/SchemaDiffEngine.ts @@ -0,0 +1,240 @@ +import type { + ColumnDiff, + ColumnSnapshot, + ConstraintDiff, + ConstraintSnapshot, + DiffStatus, + IndexDiff, + IndexSnapshot, + SchemaSnapshot, + TableDiff, +} from './schemaDiffTypes'; + +/** + * Pure diff + migration statement generation (no VS Code / I/O). + * Used by {@link SchemaDiffPanel} and unit tests. + */ +export function computeSchemaDiff(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) { + 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) { + 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; + } + + const columnDiffs = diffColumns(srcTable.columns, tgtTable.columns); + const constraintDiffs = diffConstraints(srcTable.constraints, tgtTable.constraints); + const indexDiffs = 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, + }); + } + + const order: Record = { changed: 0, added: 1, removed: 2, unchanged: 3 }; + diffs.sort((a, b) => order[a.status] - order[b.status]); + + return diffs; +} + +export function 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; +} + +export function 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; +} + +export function 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; +} + +/** + * SQL statements to migrate **source** schema toward **target** (source = current, target = desired). + */ +export function buildMigrationStatements( + sourceSchema: string, + targetSchema: string, + diffs: TableDiff[], +): string[] { + const stmts: string[] = []; + + for (const table of diffs) { + if (table.status === 'unchanged') { + continue; + } + + if (table.status === 'added') { + 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; + } + + 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) { + 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`); + } + } + } + + return stmts; +} diff --git a/src/features/schemaDiff/schemaDiffTypes.ts b/src/features/schemaDiff/schemaDiffTypes.ts new file mode 100644 index 0000000..cda6841 --- /dev/null +++ b/src/features/schemaDiff/schemaDiffTypes.ts @@ -0,0 +1,65 @@ +/** Snapshot of a single schema for comparison (tables, columns, constraints, indexes). */ + +export interface SchemaSnapshot { + tables: TableSnapshot[]; +} + +export interface TableSnapshot { + name: string; + schema: string; + columns: ColumnSnapshot[]; + constraints: ConstraintSnapshot[]; + indexes: IndexSnapshot[]; +} + +export interface ColumnSnapshot { + column_name: string; + data_type: string; + not_null: boolean; + default_value: string | null; + ordinal: number; +} + +export interface ConstraintSnapshot { + name: string; + type: string; + definition: string; +} + +export interface IndexSnapshot { + name: string; + definition: string; + is_unique: boolean; + is_primary: boolean; +} + +export type DiffStatus = 'added' | 'removed' | 'changed' | 'unchanged'; + +export interface TableDiff { + name: string; + status: DiffStatus; + columnDiffs: ColumnDiff[]; + constraintDiffs: ConstraintDiff[]; + indexDiffs: IndexDiff[]; +} + +export interface ColumnDiff { + name: string; + status: DiffStatus; + before?: ColumnSnapshot; + after?: ColumnSnapshot; +} + +export interface ConstraintDiff { + name: string; + status: DiffStatus; + before?: ConstraintSnapshot; + after?: ConstraintSnapshot; +} + +export interface IndexDiff { + name: string; + status: DiffStatus; + before?: IndexSnapshot; + after?: IndexSnapshot; +} diff --git a/src/tableProperties.ts b/src/features/tables/properties/tableProperties.ts similarity index 100% rename from src/tableProperties.ts rename to src/features/tables/properties/tableProperties.ts diff --git a/src/providers/DatabaseTreeProvider.ts b/src/providers/DatabaseTreeProvider.ts index d0eec47..4159965 100644 --- a/src/providers/DatabaseTreeProvider.ts +++ b/src/providers/DatabaseTreeProvider.ts @@ -652,6 +652,20 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider 0) { + const cj = await client.query(`SELECT COUNT(*)::int AS n FROM cron.job`); + cronJobCount = Number(cj.rows[0].n); + } + } catch { + cronJobCount = 0; + } + const fdwCountResult = await client.query('SELECT COUNT(*) FROM pg_foreign_data_wrapper'); const eventTriggerCountResult = await client.query('SELECT COUNT(*) FROM pg_event_trigger'); const publicationCountResult = await client.query('SELECT COUNT(*) FROM pg_publication'); @@ -667,6 +681,7 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider + new DatabaseTreeItem( + row.policyname, + vscode.TreeItemCollapsibleState.None, + 'policy', + element.connectionId, + element.databaseName, + element.schema, + element.tableName, + undefined, + `${row.cmd} · ${row.permissive === 'PERMISSIVE' ? 'permissive' : 'restrictive'}`, + ), + ); + } catch { + return [ + new DatabaseTreeItem( + 'Cannot read pg_policies (permissions?)', + vscode.TreeItemCollapsibleState.None, + 'policy', + element.connectionId, + element.databaseName, + element.schema, + element.tableName, + undefined, + 'Your role may lack SELECT on pg_policies.', + ), + ]; + } + } + case 'Rules': const tableRuleResult = await client.query( `SELECT rulename FROM pg_rules WHERE schemaname = $1 AND tablename = $2 ORDER BY rulename`, @@ -897,6 +952,110 @@ i.relname as index_name, row.installed_version )); + case 'Cron Jobs': { + const hasCron = await client.query( + `SELECT 1 FROM pg_catalog.pg_extension WHERE extname = 'pg_cron' LIMIT 1`, + ); + if (hasCron.rows.length === 0) { + return [ + new DatabaseTreeItem( + 'Install pg_cron extension', + vscode.TreeItemCollapsibleState.None, + 'cron-job', + element.connectionId, + element.databaseName, + undefined, + undefined, + undefined, + 'pg_cron is not installed in this database. Use List / Install commands or run CREATE EXTENSION pg_cron (may require superuser).', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + ]; + } + let cronResult: { rows: any[] }; + try { + cronResult = await client.query( + `SELECT jobid, jobname, schedule, command, active, database, username, nodename, nodeport + FROM cron.job + ORDER BY jobname NULLS LAST, jobid`, + ); + } catch { + return [ + new DatabaseTreeItem( + 'Cannot read cron.job (permissions?)', + vscode.TreeItemCollapsibleState.None, + 'cron-job', + element.connectionId, + element.databaseName, + undefined, + undefined, + undefined, + 'Your role may lack USAGE on schema cron or SELECT on cron.job.', + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + ]; + } + return cronResult.rows.map((row: any) => { + const name = + row.jobname && String(row.jobname).trim() !== '' + ? String(row.jobname) + : `job ${row.jobid}`; + const comment = [ + `Schedule: ${row.schedule}`, + row.active ? 'Status: active' : 'Status: paused', + `Database: ${row.database}`, + `Run as: ${row.username}`, + `Command:\n${row.command}`, + ].join('\n'); + return new DatabaseTreeItem( + name, + vscode.TreeItemCollapsibleState.None, + 'cron-job', + element.connectionId, + element.databaseName, + 'cron', + undefined, + undefined, + comment, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + Number(row.jobid), + String(row.schedule), + Boolean(row.active), + ); + }); + } + // Existing category cases for schema level items case 'Tables': // Fetch tables with size and row count @@ -1380,16 +1539,44 @@ i.relname as index_name, return schemaItems; - case 'table': - // Show hierarchical structure for tables + case 'table': { + let rlsPolicyCount = 0; + try { + const pc = await client.query( + `SELECT COUNT(*)::int AS n FROM pg_policies WHERE schemaname = $1 AND tablename = $2`, + [element.schema, element.label], + ); + rlsPolicyCount = Number(pc.rows[0]?.n ?? 0); + } catch { + rlsPolicyCount = 0; + } return [ new DatabaseTreeItem('Columns', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), new DatabaseTreeItem('Constraints', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), new DatabaseTreeItem('Indexes', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), new DatabaseTreeItem('Triggers', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), + new DatabaseTreeItem( + 'RLS Policies', + vscode.TreeItemCollapsibleState.Collapsed, + 'category', + element.connectionId, + element.databaseName, + element.schema, + element.label, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + rlsPolicyCount, + ), new DatabaseTreeItem('Rules', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), - new DatabaseTreeItem('Partitions', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label) + new DatabaseTreeItem('Partitions', vscode.TreeItemCollapsibleState.Collapsed, 'category', element.connectionId, element.databaseName, element.schema, element.label), ]; + } case 'view': // Views only have columns @@ -1468,7 +1655,7 @@ export class DatabaseTreeItem extends vscode.TreeItem { constructor( public readonly label: string, public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly type: 'connection' | 'database' | 'schema' | 'table' | 'view' | 'function' | 'procedure' | 'column' | 'category' | 'materialized-view' | 'type' | 'foreign-table' | 'extension' | 'role' | 'databases-group' | 'system-databases-group' | 'favorites-group' | 'recent-group' | 'constraint' | 'index' | 'foreign-data-wrapper' | 'foreign-server' | 'user-mapping' | 'connection-group' | 'trigger' | 'sequence' | 'partition' | 'domain' | 'aggregate' | 'event-trigger' | 'rule' | 'tablespace' | 'publication' | 'subscription', + public readonly type: 'connection' | 'database' | 'schema' | 'table' | 'view' | 'function' | 'procedure' | 'column' | 'category' | 'materialized-view' | 'type' | 'foreign-table' | 'extension' | 'role' | 'databases-group' | 'system-databases-group' | 'favorites-group' | 'recent-group' | 'constraint' | 'index' | 'foreign-data-wrapper' | 'foreign-server' | 'user-mapping' | 'connection-group' | 'trigger' | 'sequence' | 'partition' | 'domain' | 'aggregate' | 'event-trigger' | 'rule' | 'tablespace' | 'publication' | 'subscription' | 'cron-job' | 'policy', public readonly connectionId?: string, public readonly databaseName?: string, public readonly schema?: string, @@ -1484,7 +1671,10 @@ export class DatabaseTreeItem extends vscode.TreeItem { public readonly rowCount?: string | number, // Data row count public readonly size?: string, // Data size public readonly environment?: 'production' | 'staging' | 'development', // Environment tag - public readonly readOnlyMode?: boolean // Read-only mode flag + public readonly readOnlyMode?: boolean, // Read-only mode flag + public readonly cronJobId?: number, + public readonly cronSchedule?: string, + public readonly cronJobActive?: boolean, ) { super(label, collapsibleState); if (type === 'category' && label) { @@ -1493,6 +1683,8 @@ export class DatabaseTreeItem extends vscode.TreeItem { this.contextValue = `category-${suffix}`; } else if (type === 'connection' && isDisconnected) { this.contextValue = 'connection-disconnected'; + } else if (type === 'cron-job' && cronJobId === undefined) { + this.contextValue = 'cron-setup'; } else { // Keep original contextValue - isFavorite flag is stored separately for star indicator // For favorites menu detection, we use description containing ★ @@ -1534,7 +1726,9 @@ export class DatabaseTreeItem extends vscode.TreeItem { 'rule': new vscode.ThemeIcon('law', new vscode.ThemeColor('charts.yellow')), 'tablespace': new vscode.ThemeIcon('folder-library', new vscode.ThemeColor('charts.blue')), 'publication': new vscode.ThemeIcon('rss', new vscode.ThemeColor('charts.green')), - 'subscription': new vscode.ThemeIcon('inbox', new vscode.ThemeColor('charts.purple')) + 'subscription': new vscode.ThemeIcon('inbox', new vscode.ThemeColor('charts.purple')), + 'cron-job': new vscode.ThemeIcon('clock', new vscode.ThemeColor('charts.orange')), + policy: new vscode.ThemeIcon('shield', new vscode.ThemeColor('charts.green')), }[type]; } @@ -1605,8 +1799,14 @@ export class DatabaseTreeItem extends vscode.TreeItem { desc = size; } else if (type === 'category' && count !== undefined && this.label === 'Extensions') { desc = `• ${count} installed`; + } else if (type === 'category' && count !== undefined && this.label === 'Cron Jobs') { + desc = `• ${count} job${Number(count) === 1 ? '' : 's'}`; } else if ((type === 'category' || type === 'databases-group') && count !== undefined) { desc = `• ${count}`; + } else if (type === 'cron-job' && this.cronSchedule) { + desc = `${this.cronSchedule} · ${this.cronJobActive === false ? 'paused' : 'active'}`; + } else if (type === 'cron-job' && !this.cronSchedule) { + desc = 'not installed'; } // Append muted star for favorites (★ is more subtle than ⭐) diff --git a/src/providers/ListenNotifyPanel.ts b/src/providers/ListenNotifyPanel.ts index 7010aba..6a37535 100644 --- a/src/providers/ListenNotifyPanel.ts +++ b/src/providers/ListenNotifyPanel.ts @@ -594,6 +594,12 @@ export class ListenNotifyPanel { } .error-bar.visible { display: block; } + + .error-bar.toast-success { + background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 16%, var(--vscode-editor-background)); + border-top-color: var(--vscode-testing-iconPassed, #73c991); + color: var(--vscode-editor-foreground); + } @@ -787,10 +793,18 @@ export class ListenNotifyPanel { // ---------- Error bar ---------- function showError(msg) { errorBar.textContent = msg; + errorBar.classList.remove('toast-success'); errorBar.classList.add('visible'); setTimeout(() => errorBar.classList.remove('visible'), 5000); } + function showSuccess(msg) { + errorBar.textContent = msg; + errorBar.classList.remove('toast-success'); + errorBar.classList.add('visible', 'toast-success'); + setTimeout(() => errorBar.classList.remove('visible', 'toast-success'), 3200); + } + // ---------- Button handlers ---------- subscribeBtn.addEventListener('click', () => { const ch = subscribeInput.value.trim(); @@ -837,9 +851,14 @@ export class ListenNotifyPanel { case 'error': showError(msg.message); break; - case 'notifySent': - // Optional: could show a brief confirmation + case 'notifySent': { + const ch = (msg.channel || '').replace(//g, '>'); + const pl = msg.payload != null && String(msg.payload).length > 0 + ? ' · ' + String(msg.payload).slice(0, 120).replace(/ { + const direct = AiService._usageFromLmResponseObject(chatRequest); + if (direct) { + return direct; + } + const r = chatRequest?.result; + if (r && typeof r.then === 'function') { + try { + const resolved = await r; + return AiService._usageFromLmResponseObject(resolved); + } catch { + return undefined; + } + } + return undefined; + } + + private static _usageFromLmResponseObject(obj: any): string | undefined { + if (!obj || typeof obj !== 'object') { + return undefined; + } + const u = obj.usage; + if (!u || typeof u !== 'object') { + return undefined; + } + if (typeof u.totalTokens === 'number') { + return `${u.totalTokens} tokens`; + } + if (typeof u.inputTokens === 'number' && typeof u.outputTokens === 'number') { + return `${u.inputTokens} in + ${u.outputTokens} out`; + } + if (typeof u.promptTokens === 'number' && typeof u.completionTokens === 'number') { + return `${u.promptTokens} in + ${u.completionTokens} out`; + } + return undefined; + } + + private static _approxCharsFromLmMessages(lmMessages: any[]): number { + let n = 0; + for (const msg of lmMessages) { + const c = (msg as any)?.content; + if (typeof c === 'string') { + n += c.length; + } else if (Array.isArray(c)) { + for (const part of c) { + if (typeof part === 'string') { + n += part.length; + } else if (part && typeof (part as any).text === 'string') { + n += (part as any).text.length; + } else if (part && typeof (part as any).value === 'string') { + n += (part as any).value.length; + } + } + } + } + return n; + } + + /** Rough token hint when the LM host does not report usage (not billing-grade). */ + private static _roughTokenEstimateLabel(promptChars: number, completionChars: number): string { + const inTok = Math.max(1, Math.round(promptChars / ROUGH_CHARS_PER_TOKEN)); + const outTok = Math.max(1, Math.round(completionChars / ROUGH_CHARS_PER_TOKEN)); + const total = inTok + outTok; + return `~${total} tokens (est. · ${inTok} in + ${outTok} out)`; + } + private async _findAlternateModel(currentModelId: string): Promise { const allModels = await this._selectChatModelsWithTimeout({}); if (allModels.length === 0) { @@ -916,6 +995,10 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; content = JSON.stringify(response); // Fallback } + if (usage && body?.model) { + usage = `${body.model} · ${usage}`; + } + resolve({ text: content, usage }); } catch (e) { // If response is not JSON, we might want to log it diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index 0e8fec8..e39811d 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -19,6 +19,10 @@ export class SqlExecutor { private static readonly REVIEW_COUNT_KEY = 'postgresExplorer.reviewPrompt.successCount'; private static readonly REVIEW_SHOWN_KEY = 'postgresExplorer.reviewPrompt.shown'; private static readonly REVIEW_THRESHOLD = 3; + /** Workspace memento: last-used values for `:name` SQL parameters (keyed by parameter name). */ + private static readonly NAMED_PARAM_DEFAULTS_KEY = 'pgstudio.namedParamDefaults.v1'; + /** Workspace memento: last-used values for `$N` SQL parameters (keyed by sqlHash -> parameter index). */ + private static readonly POSITIONAL_PARAM_DEFAULTS_KEY = 'pgstudio.positionalParamDefaults.v1'; constructor(private readonly _controller: vscode.NotebookController) { } @@ -55,6 +59,142 @@ export class SqlExecutor { } } + /** + * Prompts for each `:name` value in order; persists last-used values per workspace. + * Returns `undefined` if the user cancels any prompt. + */ + private async promptForNamedParameterValues(paramNames: string[]): Promise { + const paramsConfig = vscode.workspace.getConfiguration('postgresExplorer.parameters'); + const cacheLastValues = paramsConfig.get('cacheLastValues', true); + const nullSentinel = paramsConfig.get('nullSentinel', 'NULL'); + const cache = + cacheLastValues + ? extensionContext?.workspaceState.get>(SqlExecutor.NAMED_PARAM_DEFAULTS_KEY, {}) ?? {} + : {}; + const next: Record = { ...cache }; + const values: unknown[] = []; + + for (const name of paramNames) { + const existing = next[name] ?? ''; + const input = await vscode.window.showInputBox({ + title: `SQL parameter :${name}`, + prompt: `Value for :${name} (${nullSentinel ? `type ${nullSentinel} to send SQL NULL` : 'sent to PostgreSQL as text; casts in SQL still apply'})`, + value: existing, + ignoreFocusOut: true + }); + if (input === undefined) { + return undefined; + } + values.push(nullSentinel && input === nullSentinel ? null : input); + if (cacheLastValues) { + next[name] = input; + } + } + + if (cacheLastValues && extensionContext) { + await extensionContext.workspaceState.update(SqlExecutor.NAMED_PARAM_DEFAULTS_KEY, next); + } + return values; + } + + private getSqlParameterContextSnippet(sql: string, parameterIndex: number): string | undefined { + const token = `$${parameterIndex}`; + const pattern = new RegExp(`\\$${parameterIndex}(?!\\d)`); + const match = pattern.exec(sql); + if (!match || match.index < 0) { + return undefined; + } + + const around = 20; + const start = Math.max(0, match.index - around); + const end = Math.min(sql.length, match.index + token.length + around); + const snippet = sql.slice(start, end).replace(/\s+/g, ' ').trim(); + return snippet || undefined; + } + + private async promptForPositionalParameterValues( + indices: number[], + sqlHash: string, + sql: string + ): Promise { + const paramsConfig = vscode.workspace.getConfiguration('postgresExplorer.parameters'); + const cacheLastValues = paramsConfig.get('cacheLastValues', true); + const nullSentinel = paramsConfig.get('nullSentinel', 'NULL'); + const cache = + cacheLastValues + ? extensionContext?.workspaceState.get>>( + SqlExecutor.POSITIONAL_PARAM_DEFAULTS_KEY, + {} + ) ?? {} + : {}; + + const statementDefaults = { ...(cache[sqlHash] ?? {}) }; + const values: unknown[] = []; + + for (const parameterIndex of indices) { + const key = String(parameterIndex); + const contextSnippet = this.getSqlParameterContextSnippet(sql, parameterIndex); + const input = await vscode.window.showInputBox({ + title: `SQL parameter $${parameterIndex}`, + prompt: `Value for $${parameterIndex}${nullSentinel ? ` (type ${nullSentinel} to send SQL NULL)` : ''}`, + placeHolder: contextSnippet, + value: statementDefaults[key] ?? '', + ignoreFocusOut: true + }); + if (input === undefined) { + return undefined; + } + + values.push(nullSentinel && input === nullSentinel ? null : input); + if (cacheLastValues) { + statementDefaults[key] = input; + } + } + + if (cacheLastValues && extensionContext) { + await extensionContext.workspaceState.update(SqlExecutor.POSITIONAL_PARAM_DEFAULTS_KEY, { + ...cache, + [sqlHash]: statementDefaults + }); + } + + return values; + } + + private async promptForQuotedPsqlValues( + tokens: { name: string; kind: 'literal' | 'identifier' }[] + ): Promise | undefined> { + const cache = + extensionContext?.workspaceState.get>(SqlExecutor.NAMED_PARAM_DEFAULTS_KEY, {}) ?? {}; + const next: Record = { ...cache }; + const values: Record = {}; + + for (const token of tokens) { + if (Object.prototype.hasOwnProperty.call(values, token.name)) { + continue; + } + const tokenLabel = token.kind === 'literal' ? `:'${token.name}'` : `:"${token.name}"`; + const input = await vscode.window.showInputBox({ + title: `SQL variable ${tokenLabel}`, + prompt: `Value for ${tokenLabel}`, + value: next[token.name] ?? '', + ignoreFocusOut: true + }); + if (input === undefined) { + return undefined; + } + + values[token.name] = input; + next[token.name] = input; + } + + if (extensionContext) { + await extensionContext.workspaceState.update(SqlExecutor.NAMED_PARAM_DEFAULTS_KEY, next); + } + + return values; + } + /** * Apply auto-LIMIT to SELECT queries that don't already have one * Respects both global settings and profile-level autoLimitSelectResults @@ -220,6 +360,51 @@ export class SqlExecutor { let query = statements[stmtIndex]; const stmtStartTime = Date.now(); + const params = SqlParser.detectParameters(query); + const hasPositional = params.positional.length > 0; + const hasNamed = params.named.length > 0; + + if (hasPositional && hasNamed) { + throw new Error('Mixing $N and :name parameters in the same statement is not supported. Use one style per query.'); + } + + let pgParamValues: unknown[] | undefined; + + if (params.quoted.length > 0) { + const quotedVals = await this.promptForQuotedPsqlValues(params.quoted); + if (!quotedVals) { + client.removeListener('notice', noticeListener); + execution.end(false, Date.now()); + return; + } + query = SqlParser.substituteQuotedPsqlVariables(query, quotedVals).text; + } + + if (hasNamed) { + const named = SqlParser.substituteNamedParametersWithPgPlaceholders(query); + const vals = await this.promptForNamedParameterValues(named.paramNames); + if (vals === undefined) { + client.removeListener('notice', noticeListener); + execution.end(false, Date.now()); + return; + } + query = named.text; + pgParamValues = vals; + } else if (hasPositional) { + const maxN = Math.max(...params.positional); + const vals = await this.promptForPositionalParameterValues( + Array.from({ length: maxN }, (_, i) => i + 1), + QueryAnalyzer.getInstance().getQueryHash(query), + query + ); + if (vals === undefined) { + client.removeListener('notice', noticeListener); + execution.end(false, Date.now()); + return; + } + pgParamValues = vals; + } + // Apply auto-LIMIT if applicable (pass notebook metadata and profile context for settings) const originalQuery = query; query = this.applyAutoLimit(query, connection, metadata, activeProfileContext); @@ -235,7 +420,8 @@ export class SqlExecutor { statementCount: statements.length }); - result = await client.query(query); + result = + pgParamValues !== undefined ? await client.query(query, pgParamValues) : await client.query(query); const stmtEndTime = Date.now(); @@ -306,6 +492,12 @@ export class SqlExecutor { // Build output data const tableInfo = await this.getTableInfo(client, result, query); + let autoLimitValue: number | undefined; + if (autoLimitApplied) { + const lim = query.match(/\bLIMIT\s+(\d+)/i); + autoLimitValue = lim ? parseInt(lim[1], 10) : undefined; + } + const outputData: QueryResults = { success, rowCount: result.rowCount, @@ -325,6 +517,8 @@ export class SqlExecutor { explainPlan, performanceAnalysis, // Pass analysis to frontend slowQuery: isSlow, + autoLimitApplied, + autoLimitValue, breadcrumb: { connectionId: connection.id, connectionName: connection.name || connection.host, diff --git a/src/providers/kernel/SqlParser.ts b/src/providers/kernel/SqlParser.ts index 1ff3cb5..41c41d7 100644 --- a/src/providers/kernel/SqlParser.ts +++ b/src/providers/kernel/SqlParser.ts @@ -3,6 +3,185 @@ * Service for parsing and analyzing SQL statements */ export class SqlParser { + private static readonly DOLLAR_TAG_REGEX = /^(\$[a-zA-Z0-9_]*\$)/; + + private static isIdentifierStart(ch: string): boolean { + return /^[a-zA-Z_]$/.test(ch); + } + + private static isIdentifierPart(ch: string): boolean { + return /^[a-zA-Z0-9_]$/.test(ch); + } + + /** + * Iterates SQL and invokes `onCode` only for characters outside quoted strings and comments. + * If `emitOriginal` is true, original text is copied through unless overridden by `onCode`. + */ + private static scanOutsideSpecial( + sql: string, + onCode: (sql: string, index: number) => { length: number; replacement?: string } | undefined, + emitOriginal: boolean + ): string { + let out = ''; + let i = 0; + let inSingleQuote = false; + let inDollarQuote = false; + let dollarQuoteTag = ''; + let inBlockComment = false; + + while (i < sql.length) { + const char = sql[i]; + const nextChar = i + 1 < sql.length ? sql[i + 1] : ''; + const peek = sql.substring(i, i + 32); + + if (!inSingleQuote && !inDollarQuote && char === '/' && nextChar === '*') { + inBlockComment = true; + if (emitOriginal) { + out += '/*'; + } + i += 2; + continue; + } + + if (inBlockComment && char === '*' && nextChar === '/') { + inBlockComment = false; + if (emitOriginal) { + out += '*/'; + } + i += 2; + continue; + } + + if (inBlockComment) { + if (emitOriginal) { + out += char; + } + i++; + continue; + } + + if (!inSingleQuote && !inDollarQuote && char === '-' && nextChar === '-') { + const lineEnd = sql.indexOf('\n', i); + if (lineEnd === -1) { + if (emitOriginal) { + out += sql.substring(i); + } + break; + } + if (emitOriginal) { + out += sql.substring(i, lineEnd + 1); + } + i = lineEnd + 1; + continue; + } + + if (!inSingleQuote) { + const dollarMatch = peek.match(SqlParser.DOLLAR_TAG_REGEX); + if (dollarMatch) { + const tag = dollarMatch[1]; + if (!inDollarQuote) { + inDollarQuote = true; + dollarQuoteTag = tag; + if (emitOriginal) { + out += tag; + } + i += tag.length; + continue; + } + if (tag === dollarQuoteTag) { + inDollarQuote = false; + dollarQuoteTag = ''; + if (emitOriginal) { + out += tag; + } + i += tag.length; + continue; + } + } + } + + if (!inDollarQuote && char === "'") { + if (inSingleQuote && nextChar === "'") { + if (emitOriginal) { + out += "''"; + } + i += 2; + continue; + } + inSingleQuote = !inSingleQuote; + if (emitOriginal) { + out += char; + } + i++; + continue; + } + + if (!inSingleQuote && !inDollarQuote) { + const decision = onCode(sql, i); + if (decision && decision.length > 0) { + if (emitOriginal) { + out += decision.replacement ?? sql.slice(i, i + decision.length); + } + i += decision.length; + continue; + } + } + + if (emitOriginal) { + out += char; + } + i++; + } + + return out; + } + + private static tryReadIdentifier(sql: string, start: number): string | undefined { + const first = sql[start] ?? ''; + if (!SqlParser.isIdentifierStart(first)) { + return undefined; + } + + let i = start + 1; + while (i < sql.length && SqlParser.isIdentifierPart(sql[i])) { + i++; + } + return sql.slice(start, i); + } + + private static tryReadQuotedVariableToken( + sql: string, + index: number + ): { name: string; kind: 'literal' | 'identifier'; length: number } | undefined { + if (sql[index] !== ':' || (sql[index + 1] !== "'" && sql[index + 1] !== '"')) { + return undefined; + } + + const quote = sql[index + 1]; + const name = SqlParser.tryReadIdentifier(sql, index + 2); + if (!name) { + return undefined; + } + + const end = index + 2 + name.length; + if (sql[end] !== quote) { + return undefined; + } + + return { + name, + kind: quote === "'" ? 'literal' : 'identifier', + length: 3 + name.length + }; + } + + private static escapePgLiteral(value: string): string { + return `'${value.replace(/'/g, "''")}'`; + } + + private static escapePgIdentifier(value: string): string { + return `"${value.replace(/"/g, '""')}"`; + } /** * Split SQL text into individual statements, respecting semicolons but ignoring them inside: * - String literals (single quotes) @@ -107,4 +286,156 @@ export class SqlParser { return statements.filter(s => s.length > 0); } + + /** + * Whether the SQL uses at least one `:paramName` placeholder (outside literals/comments; `::` casts excluded). + */ + public static hasNamedParameters(sql: string): boolean { + return SqlParser.substituteNamedParametersWithPgPlaceholders(sql).paramNames.length > 0; + } + + /** + * Detects PostgreSQL positional parameters (`$N`) outside literals/comments. + */ + public static detectPositionalParameters(sql: string): number[] { + const found = new Set(); + + SqlParser.scanOutsideSpecial( + sql, + (input, index) => { + if (input[index] !== '$') { + return undefined; + } + const rest = input.slice(index + 1); + const match = rest.match(/^([1-9][0-9]*)/); + if (!match) { + return undefined; + } + + const raw = match[1]; + found.add(Number(raw)); + return { length: 1 + raw.length }; + }, + false + ); + + return [...found].sort((a, b) => a - b); + } + + /** + * Substitute psql-style quoted variables `:'name'` and `:"name"`. + */ + public static substituteQuotedPsqlVariables( + sql: string, + values: Record + ): { text: string; paramNames: string[] } { + const ordered: string[] = []; + const seen = new Set(); + + const text = SqlParser.scanOutsideSpecial( + sql, + (input, index) => { + const token = SqlParser.tryReadQuotedVariableToken(input, index); + if (!token) { + return undefined; + } + + if (!seen.has(token.name)) { + seen.add(token.name); + ordered.push(token.name); + } + + const rawValue = values[token.name] ?? ''; + const replacement = + token.kind === 'literal' + ? SqlParser.escapePgLiteral(rawValue) + : SqlParser.escapePgIdentifier(rawValue); + + return { length: token.length, replacement }; + }, + true + ); + + return { text, paramNames: ordered }; + } + + /** + * Detect positional (`$N`), named (`:name`), and psql-quoted (`:'x'`, `:"x"`) parameters. + */ + public static detectParameters(sql: string): { + positional: number[]; + named: string[]; + quoted: { name: string; kind: 'literal' | 'identifier' }[]; + } { + const positional = SqlParser.detectPositionalParameters(sql); + const named = SqlParser.substituteNamedParametersWithPgPlaceholders(sql).paramNames; + + const quoted: { name: string; kind: 'literal' | 'identifier' }[] = []; + const seen = new Set(); + SqlParser.scanOutsideSpecial( + sql, + (input, index) => { + const token = SqlParser.tryReadQuotedVariableToken(input, index); + if (!token) { + return undefined; + } + + const key = `${token.kind}:${token.name}`; + if (!seen.has(key)) { + seen.add(key); + quoted.push({ name: token.name, kind: token.kind }); + } + + return { length: token.length }; + }, + false + ); + + return { positional, named, quoted }; + } + + /** + * Replaces `:paramName` tokens with PostgreSQL positional placeholders `$1`, `$2`, … in first-seen name order. + * Does not replace inside strings, dollar-quotes, or comments. Skips PostgreSQL `::type` casts. + */ + public static substituteNamedParametersWithPgPlaceholders(sql: string): { text: string; paramNames: string[] } { + const ordered: string[] = []; + const seen = new Map(); + + const pushParam = (name: string): number => { + const existing = seen.get(name); + if (existing !== undefined) { + return existing; + } + const idx = ordered.length + 1; + seen.set(name, idx); + ordered.push(name); + return idx; + }; + + const out = SqlParser.scanOutsideSpecial( + sql, + (input, index) => { + const char = input[index]; + const nextChar = index + 1 < input.length ? input[index + 1] : ''; + + if (char === ':' && nextChar === ':') { + return { length: 2, replacement: '::' }; + } + + if (char === ':') { + const name = SqlParser.tryReadIdentifier(input, index + 1); + if (name) { + const idx = pushParam(name); + return { length: 1 + name.length, replacement: `$${idx}` }; + } + } + + return undefined; + }, + true + ); + + return { text: out, paramNames: ordered }; + } } diff --git a/src/renderer/components/analyst/AnalystPanel.ts b/src/renderer/components/analyst/AnalystPanel.ts new file mode 100644 index 0000000..22f8967 --- /dev/null +++ b/src/renderer/components/analyst/AnalystPanel.ts @@ -0,0 +1,307 @@ +/** + * In-result analyst tools: column summaries, histogram, pivot (client-side). + */ + +import { ChartRenderOptions } from '../../../common/types'; +import { buildHistogram } from '../../../features/analyst/histogram'; +import { computeColumnStats } from '../../../features/analyst/columnAggregates'; +import { DISTINCT_COUNT_CAP } from '../../../features/analyst/constants'; +import { computePivot, type PivotAgg } from '../../../features/analyst/pivot'; +import { ChartRenderer } from '../chart/ChartRenderer'; +import { detectNumericColumns } from '../chart/ChartControls'; + +const HIST_COL_BUCKET = '__pg_hist_bucket'; +const HIST_COL_COUNT = '__pg_hist_count'; + +export interface AnalystPanelProps { + columns: string[]; + rows: Record[]; + columnTypes?: Record; +} + +function makeSectionTitle(text: string): HTMLElement { + const h = document.createElement('h3'); + h.textContent = text; + h.style.cssText = + 'font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;margin:12px 0 8px 0;color:var(--vscode-descriptionForeground);'; + return h; +} + +function makeTable(): HTMLTableElement { + const table = document.createElement('table'); + table.style.cssText = ` + border-collapse:collapse; + font-size:12px; + width:100%; + font-variant-numeric:tabular-nums; + `; + return table; +} + +function makeSelect( + label: string, + options: { value: string; text: string }[], + value: string, +): HTMLDivElement { + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;flex-direction:column;gap:4px;margin-bottom:8px;'; + const lbl = document.createElement('label'); + lbl.textContent = label; + lbl.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);'; + const sel = document.createElement('select'); + sel.setAttribute('aria-label', label); + sel.style.cssText = + 'padding:4px 6px;border:1px solid var(--vscode-input-border);background:var(--vscode-input-background);color:var(--vscode-input-foreground);border-radius:3px;font-size:12px;max-width:100%;'; + options.forEach((o) => { + const opt = document.createElement('option'); + opt.value = o.value; + opt.textContent = o.text; + if (o.value === value) { + opt.selected = true; + } + sel.appendChild(opt); + }); + wrap.appendChild(lbl); + wrap.appendChild(sel); + return wrap; +} + +function formatStatCell(v: number | undefined): string { + if (v === undefined) { + return '—'; + } + if (Number.isInteger(v) && Math.abs(v) < 1e15) { + return v.toLocaleString(); + } + return v.toPrecision(6); +} + +export function renderAnalystPanel(props: AnalystPanelProps): HTMLElement { + const { columns, rows, columnTypes } = props; + const wrapper = document.createElement('div'); + wrapper.style.cssText = + 'flex:1;overflow:auto;display:flex;flex-direction:column;padding:8px 12px;gap:4px;max-height:70vh;'; + + if (columns.length === 0 || rows.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'padding:12px;color:var(--vscode-descriptionForeground);font-size:12px;'; + empty.textContent = 'No rows to analyze.'; + wrapper.appendChild(empty); + return wrapper; + } + + // ── Summary ───────────────────────────────────────────────────── + wrapper.appendChild(makeSectionTitle('Column summary')); + const stats = computeColumnStats(rows, columns, columnTypes); + const summaryTable = makeTable(); + const thead = document.createElement('thead'); + const hr = document.createElement('tr'); + const headers = ['Column', 'Non-null', 'Nulls', 'Distinct', 'Min', 'Max', 'Sum', 'Avg']; + headers.forEach((h) => { + const th = document.createElement('th'); + th.textContent = h; + th.style.cssText = + 'padding:4px 8px;text-align:left;border-bottom:1px solid var(--vscode-widget-border);font-size:11px;color:var(--vscode-descriptionForeground);'; + hr.appendChild(th); + }); + thead.appendChild(hr); + summaryTable.appendChild(thead); + const tb = document.createElement('tbody'); + for (const s of stats) { + const tr = document.createElement('tr'); + const distinctStr = s.distinctCapped ? `${DISTINCT_COUNT_CAP - 1}+` : String(s.distinctCount); + const cells = [ + s.column, + String(s.nonNullCount), + String(s.nullCount), + distinctStr, + s.numeric ? formatStatCell(s.numeric.min) : '—', + s.numeric ? formatStatCell(s.numeric.max) : '—', + s.numeric ? formatStatCell(s.numeric.sum) : '—', + s.numeric ? formatStatCell(s.numeric.avg) : '—', + ]; + cells.forEach((text, i) => { + const td = document.createElement('td'); + td.textContent = text; + td.style.cssText = `padding:4px 8px;border-bottom:1px solid var(--vscode-widget-border);font-size:12px;${i === 0 ? 'font-weight:500;' : ''}`; + tr.appendChild(td); + }); + tb.appendChild(tr); + } + summaryTable.appendChild(tb); + wrapper.appendChild(summaryTable); + + // ── Histogram ──────────────────────────────────────────────────── + wrapper.appendChild(makeSectionTitle('Histogram')); + const numericCols = detectNumericColumns(columns, rows, columnTypes); + const histSection = document.createElement('div'); + histSection.style.cssText = 'display:flex;flex-direction:column;gap:8px;'; + + if (numericCols.length === 0) { + const msg = document.createElement('div'); + msg.style.cssText = 'font-size:12px;color:var(--vscode-descriptionForeground);'; + msg.textContent = 'No numeric columns detected for histogram.'; + histSection.appendChild(msg); + } else { + const initialCol = numericCols[0]; + const histSelectWrap = makeSelect( + 'Value column', + numericCols.map((c) => ({ value: c, text: c })), + initialCol, + ); + const sel = histSelectWrap.querySelector('select') as HTMLSelectElement; + + const canvasWrap = document.createElement('div'); + canvasWrap.style.cssText = 'position:relative;height:220px;min-height:180px;'; + const canvas = document.createElement('canvas'); + canvas.setAttribute('aria-label', 'Histogram chart'); + canvasWrap.appendChild(canvas); + const chartRenderer = new ChartRenderer(canvas); + + const renderHist = () => { + const col = sel.value; + const h = buildHistogram(rows, col, {}); + chartRenderer.destroy(); + if (h.error) { + const note = document.createElement('div'); + note.style.cssText = 'font-size:12px;color:var(--vscode-descriptionForeground);padding:8px 0;'; + note.textContent = h.error; + canvasWrap.replaceChildren(note); + return; + } + if (h.bucketLabels.length === 0) { + canvasWrap.replaceChildren(canvas); + return; + } + canvasWrap.replaceChildren(canvas); + const fakeRows = h.bucketLabels.map((label, i) => ({ + [HIST_COL_BUCKET]: label, + [HIST_COL_COUNT]: h.counts[i], + })); + const config: ChartRenderOptions = { + type: 'bar', + xAxisCol: HIST_COL_BUCKET, + yAxisCols: [HIST_COL_COUNT], + numericCols: [HIST_COL_COUNT], + chartTitle: `Histogram · ${col} (${h.validCount} values)`, + sortBy: 'none', + showGridX: true, + showGridY: true, + showLabels: true, + legendPosition: 'hidden', + textColor: '#ccc', + }; + chartRenderer.render(fakeRows, config); + }; + + sel.addEventListener('change', renderHist); + histSection.appendChild(histSelectWrap); + histSection.appendChild(canvasWrap); + renderHist(); + } + wrapper.appendChild(histSection); + + // ── Pivot ──────────────────────────────────────────────────────── + wrapper.appendChild(makeSectionTitle('Pivot')); + const pivotWrap = document.createElement('div'); + pivotWrap.style.cssText = 'display:flex;flex-direction:column;gap:8px;'; + + if (columns.length < 2) { + const needCols = document.createElement('div'); + needCols.style.cssText = 'font-size:12px;color:var(--vscode-descriptionForeground);'; + needCols.textContent = 'Pivot needs at least two columns in the result.'; + pivotWrap.appendChild(needCols); + wrapper.appendChild(pivotWrap); + return wrapper; + } + + const colOpts = columns.map((c) => ({ value: c, text: c })); + const rowSelWrap = makeSelect('Rows', colOpts, columns[0]); + const colSelWrap = makeSelect('Columns', colOpts, columns[1]); + const valSelWrap = makeSelect('Value (for sum/avg/min/max)', [{ value: '', text: '—' }, ...numericCols.map((c) => ({ value: c, text: c }))], numericCols[0] ?? ''); + const aggSelWrap = makeSelect('Aggregation', [ + { value: 'count', text: 'Count rows' }, + { value: 'sum', text: 'Sum' }, + { value: 'avg', text: 'Average' }, + { value: 'min', text: 'Min' }, + { value: 'max', text: 'Max' }, + ], 'count'); + + const pivotBtn = document.createElement('button'); + pivotBtn.type = 'button'; + pivotBtn.textContent = 'Build pivot'; + pivotBtn.style.cssText = + 'align-self:flex-start;padding:4px 12px;font-size:12px;cursor:pointer;background:var(--vscode-button-background);color:var(--vscode-button-foreground);border:1px solid var(--vscode-contrastBorder, transparent);border-radius:3px;'; + pivotBtn.setAttribute('aria-label', 'Build pivot table'); + + const pivotOut = document.createElement('div'); + pivotOut.style.cssText = 'overflow:auto;max-height:280px;'; + + const runPivot = () => { + pivotOut.innerHTML = ''; + const rowDim = (rowSelWrap.querySelector('select') as HTMLSelectElement).value; + const colDim = (colSelWrap.querySelector('select') as HTMLSelectElement).value; + const valRaw = (valSelWrap.querySelector('select') as HTMLSelectElement).value; + const agg = (aggSelWrap.querySelector('select') as HTMLSelectElement).value as PivotAgg; + + const result = computePivot(rows, rowDim, colDim, valRaw || undefined, agg); + if ('error' in result) { + const err = document.createElement('div'); + err.style.cssText = 'font-size:12px;color:var(--vscode-errorForeground);'; + err.textContent = result.error; + pivotOut.appendChild(err); + return; + } + + const table = makeTable(); + const ptr = document.createElement('thead'); + const hRow = document.createElement('tr'); + const corner = document.createElement('th'); + corner.textContent = `${rowDim} \\ ${colDim}`; + corner.style.cssText = + 'padding:4px 8px;text-align:left;border-bottom:1px solid var(--vscode-widget-border);position:sticky;left:0;background:var(--vscode-editor-background);z-index:1;'; + hRow.appendChild(corner); + for (const cl of result.colLabels) { + const th = document.createElement('th'); + th.textContent = cl; + th.style.cssText = + 'padding:4px 8px;text-align:right;border-bottom:1px solid var(--vscode-widget-border);font-size:11px;max-width:120px;overflow:hidden;text-overflow:ellipsis;'; + hRow.appendChild(th); + } + ptr.appendChild(hRow); + table.appendChild(ptr); + + const pbody = document.createElement('tbody'); + for (let i = 0; i < result.rowLabels.length; i++) { + const tr = document.createElement('tr'); + const rowH = document.createElement('th'); + rowH.textContent = result.rowLabels[i]; + rowH.style.cssText = + 'padding:4px 8px;text-align:left;border-bottom:1px solid var(--vscode-widget-border);font-size:11px;max-width:140px;overflow:hidden;text-overflow:ellipsis;'; + tr.appendChild(rowH); + for (let j = 0; j < result.colLabels.length; j++) { + const td = document.createElement('td'); + const v = result.cells[i][j]; + td.textContent = v === null || v === undefined ? '' : formatStatCell(v); + td.style.cssText = + 'padding:4px 8px;text-align:right;border-bottom:1px solid var(--vscode-widget-border);font-size:12px;'; + tr.appendChild(td); + } + pbody.appendChild(tr); + } + table.appendChild(pbody); + pivotOut.appendChild(table); + }; + + pivotBtn.addEventListener('click', runPivot); + + pivotWrap.appendChild(rowSelWrap); + pivotWrap.appendChild(colSelWrap); + pivotWrap.appendChild(aggSelWrap); + pivotWrap.appendChild(valSelWrap); + pivotWrap.appendChild(pivotBtn); + pivotWrap.appendChild(pivotOut); + wrapper.appendChild(pivotWrap); + + return wrapper; +} diff --git a/src/renderer/components/table/TableRenderer.ts b/src/renderer/components/table/TableRenderer.ts index b5c759a..28122fe 100644 --- a/src/renderer/components/table/TableRenderer.ts +++ b/src/renderer/components/table/TableRenderer.ts @@ -70,6 +70,19 @@ export class TableRenderer { private readonly CHUNK_SIZE = 50; private currentlyEditingCell: HTMLElement | null = null; + /** Below this many combined data + pending rows, use chunk + IntersectionObserver (legacy). */ + private static readonly VIRTUAL_ROW_THRESHOLD = 100; + /** Extra rows above/below viewport as a multiple of visible rows (2× viewport total buffer). */ + private static readonly VIRTUAL_VIEWPORT_BUFFER_MULTIPLIER = 2; + private static readonly DEFAULT_DATA_ROW_HEIGHT_PX = 30; + private static readonly DEFAULT_PENDING_ROW_HEIGHT_PX = 40; + + private virtualScrollEnabled = false; + private scrollListenerAttached = false; + private virtualScrollRaf: number | null = null; + private dataRowHeightEstimate = TableRenderer.DEFAULT_DATA_ROW_HEIGHT_PX; + private lastVirtualRange: { start: number; end: number } | null = null; + // Events private events: TableEvents = {}; @@ -116,6 +129,9 @@ export class TableRenderer { // Apply sort + filter to produce displayRows this.applyTransforms(); + this.teardownVirtualScroll(); + this.dataRowHeightEstimate = TableRenderer.DEFAULT_DATA_ROW_HEIGHT_PX; + // Reset DOM this.tableContainer.innerHTML = ''; this.renderedCount = 0; @@ -159,13 +175,19 @@ export class TableRenderer { this.mainContainer.insertBefore(this.filterBar.getElement(), this.tableContainer); if (this.displayRows.length === 0 && this.rows.length === 0) { - this.renderEmptyState(); + this.renderEmptyState(this.columns.length === 0 ? 'no-columns' : 'no-rows'); return; } this.createTableStructure(); - this.renderNextChunk(); - this.setupInfiniteScroll(); + if (this.shouldVirtualize()) { + this.virtualScrollEnabled = true; + this.renderVirtualInitial(); + } else { + this.renderNextChunk(); + this.setupInfiniteScroll(); + } + this.maybeAppendFilterEmptyHint(); } private addPendingRow() { @@ -201,6 +223,8 @@ export class TableRenderer { } private refreshTableContent() { + this.teardownVirtualScroll(); + this.dataRowHeightEstimate = TableRenderer.DEFAULT_DATA_ROW_HEIGHT_PX; this.tableContainer.innerHTML = ''; this.renderedCount = 0; this.tableBody = null; @@ -217,8 +241,14 @@ export class TableRenderer { this.statsTooltip = new ColumnStatsTooltip(); this.createTableStructure(); - this.renderNextChunk(); - this.setupInfiniteScroll(); + if (this.shouldVirtualize()) { + this.virtualScrollEnabled = true; + this.renderVirtualInitial(); + } else { + this.renderNextChunk(); + this.setupInfiniteScroll(); + } + this.maybeAppendFilterEmptyHint(); } /** Apply current sort + filter, storing result in displayRows */ @@ -277,14 +307,60 @@ export class TableRenderer { } } - private renderEmptyState() { - const empty = document.createElement('div'); - empty.textContent = 'No results found'; - empty.style.fontStyle = 'italic'; - empty.style.opacity = '0.7'; - empty.style.padding = '20px'; - empty.style.textAlign = 'center'; - this.tableContainer.appendChild(empty); + private renderEmptyState(kind: 'no-rows' | 'no-columns') { + const wrap = document.createElement('div'); + wrap.setAttribute('role', 'status'); + wrap.setAttribute('aria-live', 'polite'); + wrap.style.cssText = + 'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:32px 24px;text-align:center;max-width:440px;margin:0 auto;'; + + const title = document.createElement('div'); + title.style.cssText = + 'font-weight:600;font-size:14px;color:var(--vscode-editor-foreground);letter-spacing:0.02em;'; + title.textContent = kind === 'no-rows' ? 'No rows returned' : 'No columns in result'; + + const sub = document.createElement('div'); + sub.style.cssText = + 'font-size:12px;line-height:1.5;color:var(--vscode-descriptionForeground);'; + sub.textContent = + kind === 'no-rows' + ? 'Empty result set. Adjust the query or filters if unexpected.' + : 'No column metadata. Check Messages for errors.'; + + wrap.appendChild(title); + wrap.appendChild(sub); + this.tableContainer.appendChild(wrap); + } + + /** When filters hide every row but data exists, show a single hint row (headers stay visible). */ + private maybeAppendFilterEmptyHint() { + if (!this.tableBody) { + return; + } + if (this.pendingInserts.length > 0) { + return; + } + if (this.displayRows.length !== 0 || this.rows.length === 0) { + return; + } + const tr = document.createElement('tr'); + tr.setAttribute('data-filter-empty', '1'); + const td = document.createElement('td'); + td.colSpan = Math.max(1, this.columns.length + 1); + td.style.cssText = + 'padding:28px 16px;text-align:center;color:var(--vscode-descriptionForeground);font-size:13px;border-top:1px solid var(--vscode-widget-border);'; + td.setAttribute('role', 'status'); + td.setAttribute('aria-live', 'polite'); + const strong = document.createElement('div'); + strong.style.cssText = 'font-weight:600;color:var(--vscode-editor-foreground);margin-bottom:6px;'; + strong.textContent = 'No rows match the current filter'; + const hint = document.createElement('div'); + hint.style.fontSize = '12px'; + hint.textContent = 'Clear the search box or column filters to see data again.'; + td.appendChild(strong); + td.appendChild(hint); + tr.appendChild(td); + this.tableBody.appendChild(tr); } private createTableStructure() { @@ -300,6 +376,8 @@ export class TableRenderer { // Row number header const selectTh = document.createElement('th'); selectTh.textContent = '#'; + selectTh.setAttribute('scope', 'col'); + selectTh.setAttribute('aria-label', 'Row number'); selectTh.style.cssText = ` width:32px;min-width:32px;position:sticky;top:0;left:0; background:var(--vscode-editor-background); @@ -322,6 +400,8 @@ export class TableRenderer { private createHeaderCell(col: string): HTMLElement { const th = document.createElement('th'); + th.setAttribute('data-sortable', 'true'); + th.setAttribute('scope', 'col'); th.style.cssText = ` text-align:left;padding:8px 12px; border-bottom:1px solid var(--vscode-widget-border); @@ -449,6 +529,12 @@ export class TableRenderer { attachColumnStatsTooltip(th, col, () => this.displayRows, this.statsTooltip, 600); } + if (this.sortColumn === col && this.sortDirection !== 'none') { + th.setAttribute('aria-sort', this.sortDirection === 'asc' ? 'ascending' : 'descending'); + } else { + th.setAttribute('aria-sort', 'none'); + } + this.addResizeHandle(th); return th; } @@ -889,6 +975,153 @@ export class TableRenderer { this.loadMoreObserver.observe(this.loadMoreSentinel); } + private shouldVirtualize(): boolean { + return ( + this.displayRows.length + this.pendingInserts.length > + TableRenderer.VIRTUAL_ROW_THRESHOLD + ); + } + + private createSpacerRow(heightPx: number): HTMLTableRowElement { + const tr = document.createElement('tr'); + tr.setAttribute('data-virtual-spacer', '1'); + tr.setAttribute('aria-hidden', 'true'); + const td = document.createElement('td'); + td.colSpan = Math.max(1, this.columns.length + 1); + td.style.padding = '0'; + td.style.border = 'none'; + td.style.height = `${heightPx}px`; + td.style.lineHeight = '0'; + td.style.fontSize = '0'; + tr.appendChild(td); + return tr; + } + + private getPendingBlockHeight(): number { + if (!this.tableBody) return 0; + const pendingCount = this.pendingInserts.length; + let h = 0; + for (let i = 0; i < pendingCount; i++) { + const row = this.tableBody.children[i] as HTMLElement | undefined; + if (row) { + const oh = row.offsetHeight; + h += oh > 0 ? oh : TableRenderer.DEFAULT_PENDING_ROW_HEIGHT_PX; + } else { + h += TableRenderer.DEFAULT_PENDING_ROW_HEIGHT_PX; + } + } + return h; + } + + private renderVirtualInitial() { + if (!this.tableBody) return; + const pendingCount = this.pendingInserts.length; + for (let i = 0; i < pendingCount; i++) { + const pending = this.pendingInserts[i]; + if (pending) { + this.tableBody.appendChild(this.createPendingInsertRow(pending)); + } + } + this.lastVirtualRange = null; + this.syncVirtualWindow(); + this.attachVirtualScrollListener(); + } + + private onVirtualScroll = () => { + if (!this.virtualScrollEnabled) return; + if (this.virtualScrollRaf !== null) { + cancelAnimationFrame(this.virtualScrollRaf); + } + this.virtualScrollRaf = requestAnimationFrame(() => { + this.virtualScrollRaf = null; + this.syncVirtualWindow(); + }); + }; + + private attachVirtualScrollListener() { + if (this.scrollListenerAttached) return; + this.tableContainer.addEventListener('scroll', this.onVirtualScroll, { passive: true }); + this.scrollListenerAttached = true; + } + + private teardownVirtualScroll() { + if (this.scrollListenerAttached) { + this.tableContainer.removeEventListener('scroll', this.onVirtualScroll); + this.scrollListenerAttached = false; + } + if (this.virtualScrollRaf !== null) { + cancelAnimationFrame(this.virtualScrollRaf); + this.virtualScrollRaf = null; + } + this.virtualScrollEnabled = false; + this.lastVirtualRange = null; + } + + private syncVirtualWindow() { + if (!this.tableBody || !this.virtualScrollEnabled) return; + + const totalDisplay = this.displayRows.length; + if (totalDisplay === 0) { + this.lastVirtualRange = null; + return; + } + + const pendingCount = this.pendingInserts.length; + const pendingHeight = this.getPendingBlockHeight(); + + const scrollTop = this.tableContainer.scrollTop; + const clientH = this.tableContainer.clientHeight; + const rowH = Math.max(12, this.dataRowHeightEstimate); + + const scrollPastPending = Math.max(0, scrollTop - pendingHeight); + const firstIdx = Math.min( + totalDisplay - 1, + Math.max(0, Math.floor(scrollPastPending / rowH)), + ); + const visibleCount = Math.max(1, Math.ceil(clientH / rowH)); + const bufferRows = Math.ceil( + visibleCount * TableRenderer.VIRTUAL_VIEWPORT_BUFFER_MULTIPLIER, + ); + const start = Math.max(0, firstIdx - bufferRows); + const end = Math.min(totalDisplay, firstIdx + visibleCount + bufferRows); + + if ( + this.lastVirtualRange && + this.lastVirtualRange.start === start && + this.lastVirtualRange.end === end + ) { + return; + } + this.lastVirtualRange = { start, end }; + + while (this.tableBody.children.length > pendingCount) { + this.tableBody.removeChild(this.tableBody.lastChild!); + } + + const topSpacerHeight = start * rowH; + const bottomSpacerHeight = (totalDisplay - end) * rowH; + + if (topSpacerHeight > 0) { + this.tableBody.appendChild(this.createSpacerRow(topSpacerHeight)); + } + for (let i = start; i < end; i++) { + const displayIndex = i; + const sourceIndex = this.displayRowSourceIndices[displayIndex] ?? displayIndex; + const tr = this.createRow(this.displayRows[displayIndex], displayIndex, sourceIndex); + this.tableBody.appendChild(tr); + } + if (bottomSpacerHeight > 0) { + this.tableBody.appendChild(this.createSpacerRow(bottomSpacerHeight)); + } + + const firstDataTr = this.tableBody.querySelector( + 'tr[data-source-index]:not([data-virtual-spacer])', + ) as HTMLElement | null; + if (firstDataTr && firstDataTr.offsetHeight > 0) { + this.dataRowHeightEstimate = firstDataTr.offsetHeight; + } + } + private rerenderTable() { this.render({ columns: this.columns, @@ -966,6 +1199,7 @@ export class TableRenderer { } public dispose() { + this.teardownVirtualScroll(); if (this.loadMoreObserver) { this.loadMoreObserver.disconnect(); this.loadMoreObserver = null; diff --git a/src/schemaDesigner/ErdPanel.ts b/src/schemaDesigner/ErdPanel.ts index 0820ff7..c70958b 100644 --- a/src/schemaDesigner/ErdPanel.ts +++ b/src/schemaDesigner/ErdPanel.ts @@ -22,6 +22,8 @@ interface ErdForeignKey { interface ErdTable { name: string; schema: string; + /** Approximate row count from pg_class.reltuples (ANALYZE refreshes). */ + estRows?: number; columns: ErdColumn[]; } @@ -114,7 +116,8 @@ export class ErdPanel { private static async _fetchTables(client: any, schema: string): Promise { const tablesResult = await client.query( - `SELECT c.relname AS table_name + `SELECT c.relname AS table_name, + CASE WHEN c.reltuples < 0 THEN NULL ELSE c.reltuples::bigint END AS est_rows FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = $1 AND c.relkind = 'r' @@ -159,6 +162,11 @@ export class ErdPanel { const tables: ErdTable[] = []; for (const tableRow of tablesResult.rows) { const tableName = tableRow.table_name; + const rawEst = tableRow.est_rows; + const estRows = + rawEst !== null && rawEst !== undefined && !Number.isNaN(Number(rawEst)) + ? Number(rawEst) + : undefined; const colResult = await client.query( `SELECT a.attname AS column_name, pg_catalog.format_type(a.atttypid, a.atttypmod) AS data_type, @@ -176,6 +184,7 @@ export class ErdPanel { tables.push({ name: tableName, schema, + ...(estRows !== undefined ? { estRows } : {}), columns: colResult.rows.map((r: any) => ({ name: r.column_name, type: r.data_type, @@ -318,15 +327,29 @@ export class ErdPanel { } .erd-table-header { display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; + flex-direction: column; + align-items: stretch; + gap: 2px; + padding: 6px 10px 5px; background: var(--vscode-button-background); color: var(--vscode-button-foreground); font-weight: 600; font-size: 12px; } + .erd-table-header .hdr-top { + display: flex; + align-items: center; + gap: 6px; + } .erd-table-header .icon { font-size: 13px; } + .erd-table-header .hdr-meta { + font-size: 10px; + font-weight: 400; + opacity: 0.88; + padding-left: 19px; + line-height: 1.2; + color: var(--vscode-button-foreground); + } .erd-table-body { padding: 4px 0; } .erd-col { display: flex; @@ -392,6 +415,15 @@ export class ErdPanel { gap: 8px; } #empty .icon { font-size: 48px; } + + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } @@ -436,7 +468,7 @@ export class ErdPanel { // ── State ───────────────────────────────────────────────────────────────── const TABLE_W = 210; const COL_H = 22; - const HEADER_H = 30; + const HEADER_H = 40; let scale = 1; let panX = 0, panY = 0; @@ -500,7 +532,17 @@ export class ErdPanel { const header = document.createElement('div'); header.className = 'erd-table-header'; - header.innerHTML = '' + escHtml(t.name) + ''; + const meta = + t.estRows !== undefined && t.estRows !== null && !Number.isNaN(Number(t.estRows)) + ? '
' + + escHtml(formatEstRows(t.estRows)) + + '
' + : ''; + header.innerHTML = + '
' + + escHtml(t.name) + + '
' + + meta; el.appendChild(header); const body = document.createElement('div'); @@ -770,7 +812,17 @@ export class ErdPanel { svg += ''; svg += ''; svg += ''; - svg += '' + escHtml(t.name) + ''; + svg += '' + escHtml(t.name) + ''; + if (t.estRows !== undefined && t.estRows !== null && !Number.isNaN(Number(t.estRows))) { + svg += + '' + + escHtml(formatEstRows(t.estRows)) + + ''; + } t.columns.forEach((c, i) => { const cy2 = ty + HEADER_H + i * COL_H + 15; @@ -785,6 +837,17 @@ export class ErdPanel { } // ── Helpers ─────────────────────────────────────────────────────────────── + function formatEstRows(n) { + const x = Number(n); + if (!Number.isFinite(x) || x < 0) { return ''; } + if (x >= 1e9) { return '~' + trimTrailingZero((x / 1e9).toFixed(1)) + 'B rows (est.)'; } + if (x >= 1e6) { return '~' + trimTrailingZero((x / 1e6).toFixed(1)) + 'M rows (est.)'; } + if (x >= 1e3) { return '~' + trimTrailingZero((x / 1e3).toFixed(1)) + 'k rows (est.)'; } + return '~' + x + ' rows (est.)'; + } + function trimTrailingZero(s) { + return s.replace(/\.0$/, ''); + } function escHtml(s) { return String(s) .replace(/&/g, '&') diff --git a/src/schemaDesigner/SchemaDiffPanel.ts b/src/schemaDesigner/SchemaDiffPanel.ts index cad792c..73f1bd1 100644 --- a/src/schemaDesigner/SchemaDiffPanel.ts +++ b/src/schemaDesigner/SchemaDiffPanel.ts @@ -4,70 +4,8 @@ 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; -} +import { buildMigrationStatements, computeSchemaDiff } from '../features/schemaDiff/SchemaDiffEngine'; +import type { ColumnDiff, DiffStatus, SchemaSnapshot, TableDiff, TableSnapshot } from '../features/schemaDiff/schemaDiffTypes'; /** * Schema Diff Panel @@ -231,7 +169,7 @@ export class SchemaDiffPanel { const sourceSnapshot = await SchemaDiffPanel._fetchSnapshot(sourceClient, sourceSchema); const targetSnapshot = await SchemaDiffPanel._fetchSnapshot(targetClient, targetSchema); - const diffs = SchemaDiffPanel._computeDiff(sourceSnapshot, targetSnapshot); + const diffs = computeSchemaDiff(sourceSnapshot, targetSnapshot); // Key needs to include target connection info to be unique const targetConnId = (targetClient === sourceClient) ? item.connectionId : 'external'; @@ -358,209 +296,13 @@ export class SchemaDiffPanel { 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`); - } - } - } + const stmts = buildMigrationStatements(sourceSchema, targetSchema, diffs); if (stmts.length === 0) { vscode.window.showInformationMessage('No differences found between schemas.'); diff --git a/src/services/DdlViewerService.ts b/src/services/DdlViewerService.ts index 53a87a8..7425401 100644 --- a/src/services/DdlViewerService.ts +++ b/src/services/DdlViewerService.ts @@ -101,6 +101,9 @@ function toObjectDisplayName(target: DdlViewerTarget): string { if (!target.objectName) { return '(selection)'; } + if (target.objectType === 'policy' && target.tableName && target.schema) { + return `${target.schema}.${target.tableName}.${target.objectName}`; + } if (target.schema) { return `${target.schema}.${target.objectName}`; } @@ -1651,7 +1654,8 @@ export class DdlViewerService implements vscode.Disposable { 'foreign-table', 'foreign-data-wrapper', 'foreign-server', - 'partition' + 'partition', + 'policy' ]); if (!supportedTypes.has(item.type as DdlObjectType)) { diff --git a/src/services/SecretStorageService.ts b/src/services/SecretStorageService.ts index 26df724..5901997 100644 --- a/src/services/SecretStorageService.ts +++ b/src/services/SecretStorageService.ts @@ -37,6 +37,19 @@ export class SecretStorageService { public async deleteAiApiKey(): Promise { await this.context.secrets.delete('postgresExplorer.aiApiKey'); } + + /** GitHub PAT with `gist` scope — used only for “Publish notebook to Gist”. */ + public async getGithubGistToken(): Promise { + return await this.context.secrets.get('postgresExplorer.githubGistToken'); + } + + public async setGithubGistToken(token: string): Promise { + await this.context.secrets.store('postgresExplorer.githubGistToken', token); + } + + public async deleteGithubGistToken(): Promise { + await this.context.secrets.delete('postgresExplorer.githubGistToken'); + } } /** diff --git a/src/services/WorkspaceStateService.ts b/src/services/WorkspaceStateService.ts new file mode 100644 index 0000000..80de66a --- /dev/null +++ b/src/services/WorkspaceStateService.ts @@ -0,0 +1,66 @@ +import * as vscode from 'vscode'; + +/** Workspace-scoped defaults for PgStudio (per VS Code workspace folder). */ +export interface PgStudioWorkspaceDefaults { + lastConnectionId?: string; + lastDatabaseName?: string; +} + +const WORKSPACE_DEFAULTS_KEY = 'pgstudio.workspaceDefaults.v1'; + +/** + * Centralizes reads/writes to {@link vscode.ExtensionContext.workspaceState} for PgStudio. + * Used for last-used connection/database when switching from the status bar and for workspace-level UI. + */ +export class WorkspaceStateService implements vscode.Disposable { + private static instance: WorkspaceStateService; + private context: vscode.ExtensionContext | null = null; + + private constructor() {} + + static getInstance(): WorkspaceStateService { + if (!WorkspaceStateService.instance) { + WorkspaceStateService.instance = new WorkspaceStateService(); + } + return WorkspaceStateService.instance; + } + + initialize(context: vscode.ExtensionContext): void { + this.context = context; + } + + getDefaults(): PgStudioWorkspaceDefaults { + if (!this.context) { + return {}; + } + return this.context.workspaceState.get(WORKSPACE_DEFAULTS_KEY, {}) ?? {}; + } + + async setDefaults(partial: Partial): Promise { + if (!this.context) { + return; + } + const next: PgStudioWorkspaceDefaults = { ...this.getDefaults(), ...partial }; + await this.context.workspaceState.update(WORKSPACE_DEFAULTS_KEY, next); + } + + /** Record after user switches connection (and optional database) from a notebook. */ + async recordConnectionSwitch(connectionId: string, databaseName?: string): Promise { + await this.setDefaults({ + lastConnectionId: connectionId, + ...(databaseName !== undefined ? { lastDatabaseName: databaseName } : {}), + }); + } + + /** Record after user switches database for the current connection. */ + async recordDatabaseSwitch(connectionId: string, databaseName: string): Promise { + await this.setDefaults({ + lastConnectionId: connectionId, + lastDatabaseName: databaseName, + }); + } + + dispose(): void { + this.context = null; + } +} diff --git a/src/services/handlers/CoreHandlers.ts b/src/services/handlers/CoreHandlers.ts index 33aec6f..6b7ab6c 100644 --- a/src/services/handlers/CoreHandlers.ts +++ b/src/services/handlers/CoreHandlers.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { IMessageHandler } from '../MessageHandler'; import { ConnectionUtils } from '../../utils/connectionUtils'; +import { WorkspaceStateService } from '../WorkspaceStateService'; import { PostgresMetadata } from '../../common/types'; import { DatabaseTreeItem } from '../../providers/DatabaseTreeProvider'; @@ -24,6 +25,7 @@ export class ShowConnectionSwitcherHandler implements IMessageHandler { port: selected.port, username: selected.username }); + await WorkspaceStateService.getInstance().recordConnectionSwitch(selected.id, selected.database); vscode.window.showInformationMessage(`Switched to: ${selected.name || selected.host}`); this.statusBar.update(); } @@ -46,6 +48,10 @@ export class ShowDatabaseSwitcherHandler implements IMessageHandler { if (selectedDb && selectedDb !== message.currentDatabase) { await ConnectionUtils.updateNotebookMetadata(context.editor.notebook, { databaseName: selectedDb }); + const connectionId = (context.editor.notebook.metadata as PostgresMetadata | undefined)?.connectionId; + if (connectionId) { + await WorkspaceStateService.getInstance().recordDatabaseSwitch(connectionId, selectedDb); + } vscode.window.showInformationMessage(`Switched to database: ${selectedDb}`); this.statusBar.update(); } diff --git a/src/test/unit/AnalystTools.test.ts b/src/test/unit/AnalystTools.test.ts new file mode 100644 index 0000000..e358808 --- /dev/null +++ b/src/test/unit/AnalystTools.test.ts @@ -0,0 +1,87 @@ +import { expect } from 'chai'; +import { computeColumnStats } from '../../features/analyst/columnAggregates'; +import { buildHistogram } from '../../features/analyst/histogram'; +import { computePivot } from '../../features/analyst/pivot'; +import { coerceNumber } from '../../features/analyst/coerceNumeric'; + +describe('AnalystTools', () => { + describe('coerceNumber', () => { + it('parses numbers and numeric strings', () => { + expect(coerceNumber(42)).to.equal(42); + expect(coerceNumber('3.5')).to.equal(3.5); + expect(coerceNumber(null)).to.equal(null); + expect(coerceNumber('x')).to.equal(null); + }); + }); + + describe('computeColumnStats', () => { + it('summarizes numeric and text columns', () => { + const rows = [ + { a: 1, b: 'x' }, + { a: 2, b: 'y' }, + { a: null, b: null }, + ]; + const stats = computeColumnStats(rows, ['a', 'b'], { a: 'int4', b: 'text' }); + expect(stats).to.have.length(2); + const a = stats.find((s) => s.column === 'a'); + expect(a?.nonNullCount).to.equal(2); + expect(a?.nullCount).to.equal(1); + expect(a?.numeric?.sum).to.equal(3); + expect(a?.numeric?.avg).to.equal(1.5); + const b = stats.find((s) => s.column === 'b'); + expect(b?.numeric).to.equal(undefined); + expect(b?.distinctCount).to.equal(2); + }); + }); + + describe('buildHistogram', () => { + it('builds buckets for a numeric series', () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ v: i })); + const h = buildHistogram(rows, 'v', { bucketCount: 4 }); + expect(h.error).to.equal(undefined); + expect(h.bucketLabels).to.have.length(4); + expect(h.counts.reduce((s, c) => s + c, 0)).to.equal(20); + }); + + it('returns error when no numeric values', () => { + const h = buildHistogram([{ v: 'a' }], 'v', {}); + expect(h.error).to.match(/numeric/i); + }); + }); + + describe('computePivot', () => { + it('pivots with count aggregation', () => { + const rows = [ + { region: 'EU', quarter: 'Q1', amount: 10 }, + { region: 'EU', quarter: 'Q1', amount: 20 }, + { region: 'US', quarter: 'Q1', amount: 5 }, + ]; + const p = computePivot(rows, 'region', 'quarter', undefined, 'count'); + if ('error' in p) { + expect.fail(p.error); + } + expect(p.rowLabels).to.deep.equal(['EU', 'US']); + expect(p.colLabels).to.deep.equal(['Q1']); + expect(p.cells[0][0]).to.equal(2); + expect(p.cells[1][0]).to.equal(1); + }); + + it('rejects high-cardinality dimensions', () => { + const rows = Array.from({ length: 50 }, (_, i) => ({ a: i, b: 0, c: 1 })); + const p = computePivot(rows, 'a', 'b', 'c', 'sum'); + expect('error' in p).to.equal(true); + }); + + it('sums values per cell', () => { + const rows = [ + { region: 'EU', quarter: 'Q1', amount: 10 }, + { region: 'EU', quarter: 'Q1', amount: 20 }, + ]; + const p = computePivot(rows, 'region', 'quarter', 'amount', 'sum'); + if ('error' in p) { + expect.fail(p.error); + } + expect(p.cells[0][0]).to.equal(30); + }); + }); +}); diff --git a/src/test/unit/NotebookStatusBar.test.ts b/src/test/unit/NotebookStatusBar.test.ts index ac839a3..98c3ba0 100644 --- a/src/test/unit/NotebookStatusBar.test.ts +++ b/src/test/unit/NotebookStatusBar.test.ts @@ -3,7 +3,7 @@ import * as sinon from 'sinon'; import * as vscode from 'vscode'; import { NotebookStatusBar } from '../../activation/statusBar'; -import { ProfileManager } from '../../services/ProfileManager'; +import { ProfileManager } from '../../features/connections/ProfileManager'; import * as TransactionModule from '../../services/TransactionManager'; const extensionModule = require('../../extension'); @@ -52,6 +52,7 @@ describe('NotebookStatusBar', () => { sandbox = sinon.createSandbox(); (ProfileManager as any).instance = undefined; (vscode.window as any).activeNotebookEditor = undefined; + sandbox.stub(vscode.workspace, 'workspaceFolders').value(undefined); }); afterEach(() => { @@ -97,13 +98,14 @@ describe('NotebookStatusBar', () => { (vscode.window as any).activeNotebookEditor = createNotebookEditor('postgres-query', {}); statusBar.update(); - const [connectionItem, databaseItem, riskItem, profileItem, transactionItem] = items; + const [connectionItem, databaseItem, riskItem, profileItem, transactionItem, workspaceItem] = items; expect(connectionItem.text).to.equal('$(plug) Click to Connect'); expect(connectionItem.backgroundColor.id).to.equal('statusBarItem.warningBackground'); expect(connectionItem.show.called).to.be.true; expect(databaseItem.hide.called).to.be.true; expect(riskItem.hide.called).to.be.true; expect(profileItem.hide.called).to.be.true; + expect(workspaceItem.hide.called).to.be.true; statusBar.updateTransactionState(); expect(transactionItem.hide.called).to.be.true; @@ -111,6 +113,7 @@ describe('NotebookStatusBar', () => { statusBar.dispose(); expect(connectionItem.dispose.called).to.be.true; expect(databaseItem.dispose.called).to.be.true; + expect(workspaceItem.dispose.called).to.be.true; }); it('shows connection, profile, risk, and transaction indicators for an active notebook', () => { @@ -154,7 +157,7 @@ describe('NotebookStatusBar', () => { }, 'nb:active'); const statusBar = new NotebookStatusBar(); - const [connectionItem, databaseItem, riskItem, profileItem, transactionItem] = items; + const [connectionItem, databaseItem, riskItem, profileItem, transactionItem, workspaceItem] = items; expect(connectionItem.text).to.equal('$(server) Primary'); expect(databaseItem.text).to.equal('$(database) appdb'); @@ -176,11 +179,14 @@ describe('NotebookStatusBar', () => { expect(transactionItem.backgroundColor.id).to.equal('statusBarItem.warningBackground'); expect(transactionItem.show.called).to.be.true; + expect(workspaceItem.hide.called).to.be.true; + statusBar.dispose(); expect(connectionItem.dispose.called).to.be.true; expect(databaseItem.dispose.called).to.be.true; expect(riskItem.dispose.called).to.be.true; expect(profileItem.dispose.called).to.be.true; expect(transactionItem.dispose.called).to.be.true; + expect(workspaceItem.dispose.called).to.be.true; }); }); \ No newline at end of file diff --git a/src/test/unit/ProfileManager.test.ts b/src/test/unit/ProfileManager.test.ts index 156a034..3012842 100644 --- a/src/test/unit/ProfileManager.test.ts +++ b/src/test/unit/ProfileManager.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { ProfileManager } from '../../services/ProfileManager'; +import { ProfileManager } from '../../features/connections/ProfileManager'; function createContext(initialProfiles: any[] = []) { let profiles = [...initialProfiles]; diff --git a/src/test/unit/SavedQueriesService.test.ts b/src/test/unit/SavedQueriesService.test.ts index 80344b0..0679610 100644 --- a/src/test/unit/SavedQueriesService.test.ts +++ b/src/test/unit/SavedQueriesService.test.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; -import { SavedQueriesService, SavedQuery } from '../../services/SavedQueriesService'; +import { SavedQueriesService, SavedQuery } from '../../features/savedQueries/SavedQueriesService'; function createContext(initialQueries: SavedQuery[] = []) { const state = new Map([['postgres-explorer.savedQueries', initialQueries]]); diff --git a/src/test/unit/SchemaDiffEngine.test.ts b/src/test/unit/SchemaDiffEngine.test.ts new file mode 100644 index 0000000..0b1d552 --- /dev/null +++ b/src/test/unit/SchemaDiffEngine.test.ts @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import { buildMigrationStatements, computeSchemaDiff } from '../../features/schemaDiff/SchemaDiffEngine'; +import type { SchemaSnapshot, TableSnapshot } from '../../features/schemaDiff/schemaDiffTypes'; + +function table( + name: string, + schema: string, + columns: TableSnapshot['columns'], + constraints: TableSnapshot['constraints'] = [], + indexes: TableSnapshot['indexes'] = [], +): TableSnapshot { + return { name, schema, columns, constraints, indexes }; +} + +describe('SchemaDiffEngine', () => { + describe('computeSchemaDiff', () => { + it('returns empty when both snapshots have no tables', () => { + const a: SchemaSnapshot = { tables: [] }; + const b: SchemaSnapshot = { tables: [] }; + expect(computeSchemaDiff(a, b)).to.deep.equal([]); + }); + + it('detects table added only in target', () => { + const source: SchemaSnapshot = { tables: [] }; + const target: SchemaSnapshot = { + tables: [ + table('t1', 'public', [ + { + column_name: 'id', + data_type: 'integer', + not_null: true, + default_value: null, + ordinal: 1, + }, + ]), + ], + }; + const diffs = computeSchemaDiff(source, target); + expect(diffs).to.have.lengthOf(1); + expect(diffs[0].name).to.equal('t1'); + expect(diffs[0].status).to.equal('added'); + expect(diffs[0].columnDiffs[0].status).to.equal('added'); + }); + + it('detects column type change', () => { + const colA = { + column_name: 'x', + data_type: 'integer', + not_null: false, + default_value: null, + ordinal: 1, + }; + const colB = { ...colA, data_type: 'bigint' }; + const source: SchemaSnapshot = { + tables: [table('t1', 'public', [colA])], + }; + const target: SchemaSnapshot = { + tables: [table('t1', 'public', [colB])], + }; + const diffs = computeSchemaDiff(source, target); + expect(diffs[0].status).to.equal('changed'); + const cd = diffs[0].columnDiffs.find((c) => c.name === 'x'); + expect(cd?.status).to.equal('changed'); + }); + }); + + describe('buildMigrationStatements', () => { + const cases: Array<{ + title: string; + sourceSchema: string; + targetSchema: string; + build: () => ReturnType; + expectSnippet: string; + }> = [ + { + title: 'ADD COLUMN in changed table', + sourceSchema: 'public', + targetSchema: 'public', + build: () => + computeSchemaDiff( + { tables: [table('t1', 'public', [])] }, + { + tables: [ + table('t1', 'public', [ + { + column_name: 'n', + data_type: 'text', + not_null: false, + default_value: null, + ordinal: 1, + }, + ]), + ], + }, + ), + expectSnippet: 'ADD COLUMN "n"', + }, + { + title: 'CREATE TABLE for new table in target', + sourceSchema: 'app', + targetSchema: 'app', + build: () => + computeSchemaDiff({ tables: [] }, { + tables: [ + table( + 'new_t', + 'app', + [ + { + column_name: 'id', + data_type: 'uuid', + not_null: true, + default_value: null, + ordinal: 1, + }, + ], + [], + [], + ), + ], + }), + expectSnippet: 'CREATE TABLE "app"."new_t"', + }, + ]; + + for (const { title, sourceSchema, targetSchema, build, expectSnippet } of cases) { + it(title, () => { + const sql = buildMigrationStatements(sourceSchema, targetSchema, build()).join('\n'); + expect(sql).to.include(expectSnippet); + }); + } + }); +}); diff --git a/src/test/unit/SqlParser.test.ts b/src/test/unit/SqlParser.test.ts index 129793e..8a571b0 100644 --- a/src/test/unit/SqlParser.test.ts +++ b/src/test/unit/SqlParser.test.ts @@ -95,4 +95,146 @@ describe('SqlParser', () => { expect(statements[0]).to.equal('SELECT 1'); }); }); + + describe('substituteNamedParametersWithPgPlaceholders', () => { + it('rewrites :name to $n in first-seen order', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + 'SELECT * FROM t WHERE a = :b AND c = :a' + ); + expect(paramNames).to.deep.equal(['b', 'a']); + expect(text).to.equal('SELECT * FROM t WHERE a = $1 AND c = $2'); + }); + + it('reuses the same $n when the same name appears twice', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + 'SELECT * FROM t WHERE id = :id OR parent_id = :id' + ); + expect(paramNames).to.deep.equal(['id']); + expect(text).to.equal('SELECT * FROM t WHERE id = $1 OR parent_id = $1'); + }); + + it('does not treat PostgreSQL ::type casts as parameters', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + "SELECT '2020-01-01'::date, col::text FROM t WHERE x = :x" + ); + expect(paramNames).to.deep.equal(['x']); + expect(text).to.equal("SELECT '2020-01-01'::date, col::text FROM t WHERE x = $1"); + }); + + it('ignores :name inside single-quoted literals', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + "SELECT ':foo', :bar FROM t" + ); + expect(paramNames).to.deep.equal(['bar']); + expect(text).to.equal("SELECT ':foo', $1 FROM t"); + }); + + it('ignores :name in line comments', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + 'SELECT 1 -- :nope\n, :yes' + ); + expect(paramNames).to.deep.equal(['yes']); + expect(text).to.include('-- :nope'); + expect(text).to.include('$1'); + }); + + it('ignores :name in block comments', () => { + const { text, paramNames } = SqlParser.substituteNamedParametersWithPgPlaceholders( + 'SELECT /* :nope */ :yes' + ); + expect(paramNames).to.deep.equal(['yes']); + expect(text).to.equal('SELECT /* :nope */ $1'); + }); + }); + + describe('detectPositionalParameters', () => { + it('detects simple positional placeholders', () => { + const found = SqlParser.detectPositionalParameters('SELECT $1, $2, $3'); + expect(found).to.deep.equal([1, 2, 3]); + }); + + it('keeps gaps and deduplicates placeholders', () => { + const found = SqlParser.detectPositionalParameters('SELECT $1, $3, $1'); + expect(found).to.deep.equal([1, 3]); + }); + + it('ignores placeholders inside single-quoted strings', () => { + const found = SqlParser.detectPositionalParameters("SELECT '$1 unread'"); + expect(found).to.deep.equal([]); + }); + + it('ignores placeholders inside dollar-quoted blocks', () => { + const sql = 'CREATE FUNCTION f(a int) RETURNS int AS $$ BEGIN RETURN $1; END $$ LANGUAGE plpgsql'; + const found = SqlParser.detectPositionalParameters(sql); + expect(found).to.deep.equal([]); + }); + + it('ignores placeholders in comments and dollar quote markers', () => { + const sql = 'SELECT $$not a $1 placeholder$$, $tag$abc$tag$, 1 -- $4\n/* $5 */\n, $2'; + const found = SqlParser.detectPositionalParameters(sql); + expect(found).to.deep.equal([2]); + }); + }); + + describe('substituteQuotedPsqlVariables', () => { + it('substitutes literal and identifier variables with proper escaping', () => { + const { text, paramNames } = SqlParser.substituteQuotedPsqlVariables( + "SELECT :'name' AS n, :\"col\" FROM :\"tbl\"", + { name: "O'Brien", col: 'my"col', tbl: 'my"tbl' } + ); + + expect(paramNames).to.deep.equal(['name', 'col', 'tbl']); + expect(text).to.equal("SELECT 'O''Brien' AS n, \"my\"\"col\" FROM \"my\"\"tbl\""); + }); + + it('ignores quoted psql variables inside strings, comments, and dollar quotes', () => { + const { text, paramNames } = SqlParser.substituteQuotedPsqlVariables( + "SELECT ':\"ignored\"', :'ok' -- :'ignored'\n, $$ :'ignored' $$, /* :'ignored' */ :'ok'", + { ok: 'x' } + ); + + expect(paramNames).to.deep.equal(['ok']); + expect(text).to.equal("SELECT ':\"ignored\"', 'x' -- :'ignored'\n, $$ :'ignored' $$, /* :'ignored' */ 'x'"); + }); + + it('uses one prompted name for repeated tokens', () => { + const { text, paramNames } = SqlParser.substituteQuotedPsqlVariables( + "SELECT :'v', :'v'", + { v: 'a' } + ); + expect(paramNames).to.deep.equal(['v']); + expect(text).to.equal("SELECT 'a', 'a'"); + }); + }); + + describe('detectParameters', () => { + it('returns positional, named, and quoted buckets', () => { + const params = SqlParser.detectParameters( + "SELECT $1, :name, :'literalVar', :\"identifierVar\", col::text" + ); + + expect(params.positional).to.deep.equal([1]); + expect(params.named).to.deep.equal(['name']); + expect(params.quoted).to.deep.equal([ + { name: 'literalVar', kind: 'literal' }, + { name: 'identifierVar', kind: 'identifier' } + ]); + }); + + it('keeps casts out of named parameters', () => { + const params = SqlParser.detectParameters("SELECT '2020-01-01'::date, col::text"); + expect(params.named).to.deep.equal([]); + }); + }); + + describe('hasNamedParameters', () => { + it('is true when a bind placeholder exists outside literals', () => { + expect(SqlParser.hasNamedParameters('SELECT :a')).to.be.true; + }); + + it('is false when only casts or literals contain colons', () => { + expect(SqlParser.hasNamedParameters("SELECT '::'::text")).to.be.false; + expect(SqlParser.hasNamedParameters('SELECT 1::int')).to.be.false; + }); + }); }); diff --git a/src/test/unit/SqlTemplates.test.ts b/src/test/unit/SqlTemplates.test.ts index 4791d83..67e6933 100644 --- a/src/test/unit/SqlTemplates.test.ts +++ b/src/test/unit/SqlTemplates.test.ts @@ -12,6 +12,8 @@ import { IndexSQL, MaintenanceTemplates, MaterializedViewSQL, + PgCronSQL, + PolicySQL, QueryBuilder, PartitionSQL, SQL_TEMPLATES, @@ -126,6 +128,22 @@ describe('SQL template modules', () => { expect(MaintenanceTemplates.reindexDatabase('app_db')).to.contain('REINDEX DATABASE "app_db";'); }); + it('covers pg_cron SQL templates', () => { + expectSqlContains(PgCronSQL.listJobs(), ['FROM cron.job', 'jobid']); + expectSqlContains(PgCronSQL.installExtension(), ['CREATE EXTENSION IF NOT EXISTS pg_cron']); + expectSqlContains(PgCronSQL.jobDetail(3), ['FROM cron.job', 'WHERE jobid = 3']); + expectSqlContains(PgCronSQL.unschedule(9), ['cron.unschedule', '9']); + expectSqlContains(PgCronSQL.scheduleNewJob(), ['cron.schedule']); + }); + + it('covers RLS policy SQL templates', () => { + expectSqlContains(PolicySQL.drop('public', 'accounts', 'tenant_isolation'), [ + 'DROP POLICY IF EXISTS', + '"tenant_isolation"', + 'ON "public"."accounts"', + ]); + }); + it('covers aggregate, domain, partition, sequence and trigger SQL builders', () => { expectSqlContains(AggregateSQL.list(schema), ['FROM pg_proc p', "p.prokind = 'a'"]); expectSqlContains(AggregateSQL.getDefinition(schema, 'sum_salary'), ['JOIN pg_aggregate a', 'transition_function']); diff --git a/src/test/unit/TableRenderer.test.ts b/src/test/unit/TableRenderer.test.ts index f597de0..6454edc 100644 --- a/src/test/unit/TableRenderer.test.ts +++ b/src/test/unit/TableRenderer.test.ts @@ -94,4 +94,43 @@ describe('TableRenderer', () => { expect(modifiedCells.has('1-name')).to.be.true; expect(modifiedCells.has('0-name')).to.be.false; }); + + it('virtualizes large result sets so most rows are not in the DOM', () => { + const rows = Array.from({ length: 120 }, (_, i) => ({ id: i, v: `row-${i}` })); + const originalRows = JSON.parse(JSON.stringify(rows)); + + const renderer = new TableRenderer(container, {}); + renderer.render({ + columns: ['id', 'v'], + rows, + originalRows, + columnTypes: { id: 'int4', v: 'text' }, + tableInfo: { schema: 'public', table: 't', primaryKeys: ['id'] } as any, + foreignKeys: [], + }); + + const dataRows = container.querySelectorAll('tbody tr[data-source-index]'); + expect(dataRows.length).to.be.lessThan(rows.length); + expect(dataRows.length).to.be.at.least(1); + + renderer.dispose(); + }); + + it('sets aria-sort on the active sort column header', () => { + const rows = [{ id: 1, n: 'a' }]; + const renderer = new TableRenderer(container, {}); + renderer.render({ + columns: ['id', 'n'], + rows, + originalRows: JSON.parse(JSON.stringify(rows)), + columnTypes: { id: 'int4', n: 'text' }, + sortState: { column: 'n', direction: 'asc' }, + foreignKeys: [], + }); + + const sortedHeader = container.querySelector('thead th[aria-sort="ascending"]'); + expect(sortedHeader?.textContent).to.include('n'); + + renderer.dispose(); + }); }); diff --git a/src/test/unit/databaseUrl.test.ts b/src/test/unit/databaseUrl.test.ts new file mode 100644 index 0000000..e2ed444 --- /dev/null +++ b/src/test/unit/databaseUrl.test.ts @@ -0,0 +1,57 @@ +import { expect } from 'chai'; +import { connectionInfoFromDatabaseUrl, previewDatabaseUrl } from '../../utils/databaseUrl'; +import { DATABASE_URL_ENV_KEYS, extractDatabaseUrlsFromEnvText } from '../../utils/envFileDatabaseUrls'; + +describe('databaseUrl / env DATABASE_URL helpers', () => { + const acceptStandardKeys = (k: string): boolean => + new Set(DATABASE_URL_ENV_KEYS).has(k); + + describe('extractDatabaseUrlsFromEnvText', () => { + const cases: Array<{ title: string; text: string; expected: Array<{ key: string; value: string }> }> = [ + { + title: 'parses quoted DATABASE_URL', + text: `DATABASE_URL="postgresql://u:p@db.example.com:5432/mydb"`, + expected: [{ key: 'DATABASE_URL', value: 'postgresql://u:p@db.example.com:5432/mydb' }], + }, + { + title: 'ignores comments and unrelated keys', + text: `# DATABASE_URL=x\nFOO=1\nDATABASE_URL=postgres://a@h/db\n`, + expected: [{ key: 'DATABASE_URL', value: 'postgres://a@h/db' }], + }, + { + title: 'skips non-postgres URLs', + text: 'DATABASE_URL=mysql://x', + expected: [], + }, + ]; + + for (const { title, text, expected } of cases) { + it(title, () => { + const got = extractDatabaseUrlsFromEnvText(text, acceptStandardKeys); + expect(got).to.deep.equal(expected); + }); + } + }); + + describe('connectionInfoFromDatabaseUrl', () => { + it('maps URL fields and sslmode query param', () => { + const info = connectionInfoFromDatabaseUrl( + 'postgresql://user:secret@localhost:5433/appdb?sslmode=require', + 'id-1', + ); + expect(info.id).to.equal('id-1'); + expect(info.host).to.equal('localhost'); + expect(info.port).to.equal(5433); + expect(info.username).to.equal('user'); + expect(info.password).to.equal('secret'); + expect(info.database).to.equal('appdb'); + expect(info.sslmode).to.equal('require'); + }); + }); + + describe('previewDatabaseUrl', () => { + it('shows host:port/db without password', () => { + expect(previewDatabaseUrl('postgresql://u:p@hostz:5432/dbname')).to.equal('hostz:5432/dbname'); + }); + }); +}); diff --git a/src/test/unit/handlers/FkLookupHandler.test.ts b/src/test/unit/handlers/FkLookupHandler.test.ts new file mode 100644 index 0000000..81b0d1b --- /dev/null +++ b/src/test/unit/handlers/FkLookupHandler.test.ts @@ -0,0 +1,104 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import * as vscode from 'vscode'; +import { ConnectionManager } from '../../../services/ConnectionManager'; +import { ConnectionUtils } from '../../../utils/connectionUtils'; +import { FkLookupHandler } from '../../../services/handlers/FkLookupHandler'; + +describe('FkLookupHandler', () => { + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('returns FK rows and columns on success', async () => { + const query = sandbox.stub().resolves({ + rows: [{ id: 1 }, { id: 2 }], + fields: [{ name: 'id' }], + }); + const release = sandbox.stub(); + sandbox.stub(ConnectionManager, 'getInstance').returns({ + getPooledClient: sandbox.stub().resolves({ query, release }), + } as any); + sandbox.stub(ConnectionUtils, 'findConnection').returns({ + id: 'conn-1', + host: 'localhost', + port: 5432, + username: 'u', + database: 'db', + } as any); + + const postMessage = sandbox.stub().resolves(true); + await new FkLookupHandler().handle( + { + type: 'fkLookup', + requestId: 'req-1', + fkSchema: 'public', + fkTable: 'ref', + fkColumn: 'id', + searchText: '', + limit: 50, + } as any, + { + editor: { + notebook: { + metadata: { connectionId: 'conn-1' }, + }, + }, + postMessage, + } as any, + ); + + expect(query.calledOnce).to.be.true; + expect( + postMessage.calledOnceWithMatch({ + type: 'fkLookupResponse', + requestId: 'req-1', + rows: [{ id: 1 }, { id: 2 }], + columns: ['id'], + }), + ).to.be.true; + expect(release.calledOnce).to.be.true; + }); + + it('posts empty result when connection is missing', async () => { + sandbox.stub(ConnectionUtils, 'findConnection').returns(undefined as any); + const postMessage = sandbox.stub().resolves(true); + const showError = sandbox.stub(vscode.window, 'showErrorMessage').resolves(undefined); + + await new FkLookupHandler().handle( + { + type: 'fkLookup', + requestId: 'req-2', + fkSchema: 'public', + fkTable: 'ref', + fkColumn: 'id', + searchText: '', + limit: 50, + } as any, + { + editor: { + notebook: { + metadata: { connectionId: 'missing' }, + }, + }, + postMessage, + } as any, + ); + + expect(showError.calledOnce).to.be.true; + expect( + postMessage.calledOnceWithMatch({ + type: 'fkLookupResponse', + requestId: 'req-2', + rows: [], + columns: [], + }), + ).to.be.true; + }); +}); diff --git a/src/test/unit/handlers/messaging.test.ts b/src/test/unit/handlers/messaging.test.ts index f6f20be..ff35d72 100644 --- a/src/test/unit/handlers/messaging.test.ts +++ b/src/test/unit/handlers/messaging.test.ts @@ -1,7 +1,6 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import * as vscode from 'vscode'; - import { safelyPostMessage } from '../../../services/handlers/messaging'; describe('safelyPostMessage', () => { @@ -9,7 +8,6 @@ describe('safelyPostMessage', () => { beforeEach(() => { sandbox = sinon.createSandbox(); - sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined as any); }); afterEach(() => { @@ -17,41 +15,30 @@ describe('safelyPostMessage', () => { }); it('returns false when postMessage is undefined', async () => { - const result = await safelyPostMessage(undefined, { type: 'x' }, { contextLabel: 'test' }); - expect(result).to.equal(false); + const r = await safelyPostMessage(undefined, { x: 1 }, { contextLabel: 'Test' }); + expect(r).to.be.false; }); - it('returns true when message is delivered', async () => { + it('returns true when delivery succeeds', async () => { const postMessage = sandbox.stub().resolves(true); - - const result = await safelyPostMessage(postMessage, { type: 'ok' }, { contextLabel: 'test' }); - - expect(result).to.equal(true); - expect(postMessage.calledOnceWithExactly({ type: 'ok' })).to.equal(true); - expect((vscode.window.showWarningMessage as any).called).to.equal(false); + const r = await safelyPostMessage(postMessage, { type: 'a' }, { contextLabel: 'Test' }); + expect(r).to.be.true; + expect(postMessage.calledOnceWithExactly({ type: 'a' })).to.be.true; }); - it('returns false and warns when delivery returns false', async () => { + it('returns false when postMessage returns false', async () => { const postMessage = sandbox.stub().resolves(false); - - const result = await safelyPostMessage(postMessage, { type: 'nope' }, { - contextLabel: 'test', - notifyOnFailure: true, - }); - - expect(result).to.equal(false); - expect((vscode.window.showWarningMessage as any).calledOnce).to.equal(true); + const warn = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const r = await safelyPostMessage(postMessage, { type: 'a' }, { contextLabel: 'Test', notifyOnFailure: true }); + expect(r).to.be.false; + expect(warn.calledOnce).to.be.true; }); - it('returns false and warns when postMessage throws', async () => { - const postMessage = sandbox.stub().rejects(new Error('panel closed')); - - const result = await safelyPostMessage(postMessage, { type: 'boom' }, { - contextLabel: 'test', - notifyOnFailure: true, - }); - - expect(result).to.equal(false); - expect((vscode.window.showWarningMessage as any).calledOnce).to.equal(true); + it('returns false on thrown error', async () => { + const postMessage = sandbox.stub().rejects(new Error('closed')); + const warn = sandbox.stub(vscode.window, 'showWarningMessage').resolves(undefined); + const r = await safelyPostMessage(postMessage, { type: 'a' }, { contextLabel: 'Test', notifyOnFailure: true }); + expect(r).to.be.false; + expect(warn.calledOnce).to.be.true; }); }); diff --git a/src/test/unit/notebookExportHtml.test.ts b/src/test/unit/notebookExportHtml.test.ts new file mode 100644 index 0000000..e0ef528 --- /dev/null +++ b/src/test/unit/notebookExportHtml.test.ts @@ -0,0 +1,14 @@ +import { expect } from 'chai'; +import { escapeHtml, simpleMarkdownToHtml } from '../../features/notebook/notebookExportHtml'; + +describe('notebookExportHtml', () => { + it('escapeHtml escapes special characters', () => { + expect(escapeHtml(`<&>"'`)).to.equal('&<>"''); + }); + + it('simpleMarkdownToHtml handles headers and paragraphs', () => { + const html = simpleMarkdownToHtml('# Title\n\nHello'); + expect(html).to.contain('

Title

'); + expect(html).to.contain('

Hello

'); + }); +}); diff --git a/src/test/unit/schemaSearch.test.ts b/src/test/unit/schemaSearch.test.ts new file mode 100644 index 0000000..9f54f27 --- /dev/null +++ b/src/test/unit/schemaSearch.test.ts @@ -0,0 +1,17 @@ +import { expect } from 'chai'; +import { escapeLikePattern } from '../../commands/schemaSearch'; + +describe('schemaSearch escapeLikePattern', () => { + const cases: Array<{ input: string; expected: string }> = [ + { input: 'plain', expected: 'plain' }, + { input: '50%', expected: '50\\%' }, + { input: 'a_b', expected: 'a\\_b' }, + { input: 'x\\y', expected: 'x\\\\y' }, + ]; + + for (const { input, expected } of cases) { + it(`escapes ${JSON.stringify(input)}`, () => { + expect(escapeLikePattern(input)).to.equal(expected); + }); + } +}); diff --git a/src/ui/renderer/rendererConstants.ts b/src/ui/renderer/rendererConstants.ts new file mode 100644 index 0000000..954ecdd --- /dev/null +++ b/src/ui/renderer/rendererConstants.ts @@ -0,0 +1,5 @@ +/** Shared notebook renderer branding (CSS variables). */ +export const BRAND_ACCENT = 'var(--vscode-textLink-foreground)'; +export const BRAND_ACCENT_MUTED = 'color-mix(in srgb, var(--vscode-textLink-foreground) 20%, transparent)'; + +export const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; diff --git a/src/renderer_v2.ts b/src/ui/renderer/renderer_v2.ts similarity index 94% rename from src/renderer_v2.ts rename to src/ui/renderer/renderer_v2.ts index e62a6c4..05a5b31 100644 --- a/src/renderer_v2.ts +++ b/src/ui/renderer/renderer_v2.ts @@ -5,23 +5,25 @@ import { createTab, createBreadcrumb, BreadcrumbSegment, -} from './renderer/components/ui'; -import { createExportButton } from './renderer/features/export'; -import { TableRenderer, TableEvents } from './renderer/components/table/TableRenderer'; -import { ChartRenderer } from './renderer/components/chart/ChartRenderer'; -import { ChartControls } from './renderer/components/chart/ChartControls'; -import { ExplainVisualizer } from './renderer/components/ExplainVisualizer'; -import { createErrorPanel } from './renderer/components/ErrorPanel'; -import { createActionBar } from './renderer/components/ActionBar'; -import { showImportModal } from './renderer/features/import'; -import { createTransactionBanner } from './renderer/components/TransactionBanner'; -import { parseBreadcrumbFromSql } from './renderer/utils/sqlParsing'; +} from '../../renderer/components/ui'; +import { createExportButton } from '../../renderer/features/export'; +import { TableRenderer, TableEvents } from '../../renderer/components/table/TableRenderer'; +import { ChartRenderer } from '../../renderer/components/chart/ChartRenderer'; +import { ChartControls } from '../../renderer/components/chart/ChartControls'; +import { ExplainVisualizer } from '../../renderer/components/ExplainVisualizer'; +import { createErrorPanel } from '../../renderer/components/ErrorPanel'; +import { createActionBar } from '../../renderer/components/ActionBar'; +import { showImportModal } from '../../renderer/features/import'; +import { createTransactionBanner } from '../../renderer/components/TransactionBanner'; +import { parseBreadcrumbFromSql } from '../../renderer/utils/sqlParsing'; import { addResultToHistory, getResultHistory, renderTabStrip, -} from './renderer/components/ResultTabStrip'; -import { renderTransposeTable } from './renderer/components/TransposeView'; +} from '../../renderer/components/ResultTabStrip'; +import { renderTransposeTable } from '../../renderer/components/TransposeView'; +import { renderAnalystPanel } from '../../renderer/components/analyst/AnalystPanel'; +import { BRAND_ACCENT, BRAND_ACCENT_MUTED, SPINNER_FRAMES } from './rendererConstants'; // Register Chart.js components Chart.register(...registerables); @@ -29,10 +31,6 @@ Chart.register(...registerables); // Track renderer instances and their containers per output element for cleanup const chartInstances = new WeakMap(); const tableInstances = new WeakMap(); -const BRAND_ACCENT = 'var(--vscode-textLink-foreground)'; -const BRAND_ACCENT_MUTED = 'color-mix(in srgb, var(--vscode-textLink-foreground) 20%, transparent)'; - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; /** * Puts a button into a loading state with an animated braille spinner. @@ -110,6 +108,8 @@ export const activate: ActivationFunction = (context) => { columnTypes, backendPid, breadcrumb, + autoLimitApplied, + autoLimitValue, } = json; // Transaction state from payload @@ -196,6 +196,26 @@ export const activate: ActivationFunction = (context) => { header.appendChild(summary); // Pending commit badge — shown when result was produced inside an open transaction + if (autoLimitApplied) { + const limitBadge = document.createElement('span'); + limitBadge.textContent = + autoLimitValue !== undefined ? `LIMIT ${autoLimitValue} applied` : 'LIMIT applied'; + limitBadge.title = 'A row limit was appended to this SELECT by settings (auto-limit).'; + limitBadge.style.cssText = ` + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; + background: color-mix(in srgb, var(--vscode-textLink-foreground) 15%, transparent); + color: var(--vscode-textLink-foreground); + border: 1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 40%, transparent); + margin-left: 8px; + text-transform: uppercase; + letter-spacing: 0.04em; + `; + header.appendChild(limitBadge); + } + if (pendingCommit) { const pendingBadge = document.createElement('span'); pendingBadge.textContent = 'pending commit'; @@ -842,6 +862,7 @@ export const activate: ActivationFunction = (context) => { const tableTab = createTab('Table', 'table', true, () => switchTab('table')); const chartTab = createTab('Chart', 'chart', false, () => switchTab('chart')); + const analystTab = createTab('Analyst', 'analyst', false, () => switchTab('analyst')); let explainTab: HTMLElement | null = null; if (json.explainPlan) { @@ -854,6 +875,7 @@ export const activate: ActivationFunction = (context) => { tabs.appendChild(tableTab); tabs.appendChild(chartTab); + tabs.appendChild(analystTab); if (explainTab) tabs.appendChild(explainTab); tabs.appendChild(transposeTab); if (!json.error) { @@ -983,7 +1005,10 @@ export const activate: ActivationFunction = (context) => { // Switch Tab Logic let currentMode = 'table'; - const allTabs = () => [tableTab, chartTab, transposeTab, ...(explainTab ? [explainTab] : [])]; + const allTabs = () => + explainTab + ? [tableTab, chartTab, analystTab, explainTab, transposeTab] + : [tableTab, chartTab, analystTab, transposeTab]; const setActiveTab = (activeTab: HTMLElement) => { allTabs().forEach((t) => { t.style.borderBottom = '2px solid transparent'; @@ -1040,6 +1065,16 @@ export const activate: ActivationFunction = (context) => { explainWrapper.textContent = 'No explain plan data available. Run EXPLAIN (ANALYZE, FORMAT JSON) to get a visual plan.'; } + } else if (mode === 'analyst') { + setActiveTab(analystTab); + updateActionsVisibility(); + viewContainer.appendChild( + renderAnalystPanel({ + columns, + rows: currentRows, + columnTypes, + }), + ); } else { setActiveTab(chartTab); updateActionsVisibility(); diff --git a/src/ui/theme/motion.ts b/src/ui/theme/motion.ts new file mode 100644 index 0000000..1351468 --- /dev/null +++ b/src/ui/theme/motion.ts @@ -0,0 +1,11 @@ +/** + * Branch when JS must skip motion (e.g. imperative scroll animations). + * Webviews should also use a prefers-reduced-motion media query in CSS + * (see template styles under templates/). + */ +export function prefersReducedMotion(): boolean { + if (typeof window === 'undefined' || !window.matchMedia) { + return false; + } + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} diff --git a/src/utils/databaseUrl.ts b/src/utils/databaseUrl.ts new file mode 100644 index 0000000..653b63a --- /dev/null +++ b/src/utils/databaseUrl.ts @@ -0,0 +1,67 @@ +import { parse } from 'pg-connection-string'; +import type { ConnectionInfo } from '../features/connections/connectionForm'; + +const VALID_SSL = new Set([ + 'disable', + 'allow', + 'prefer', + 'require', + 'verify-ca', + 'verify-full', +]); + +/** + * Maps a `postgres://` / `postgresql://` URL into a {@link ConnectionInfo} row (password kept only in memory until persisted). + */ +export function connectionInfoFromDatabaseUrl(rawUrl: string, id: string): ConnectionInfo { + const trimmed = rawUrl.trim(); + const parsed = parse(trimmed); + const host = parsed.host || 'localhost'; + const portRaw = parsed.port; + const port = + portRaw !== undefined && portRaw !== null && String(portRaw) !== '' + ? parseInt(String(portRaw), 10) + : 5432; + if (Number.isNaN(port)) { + throw new Error('Invalid port in database URL'); + } + const database = parsed.database || 'postgres'; + const username = parsed.user || undefined; + const password = parsed.password || undefined; + + let sslmode: ConnectionInfo['sslmode'] = 'prefer'; + const sm = typeof parsed.sslmode === 'string' ? parsed.sslmode : undefined; + if (sm && VALID_SSL.has(sm)) { + sslmode = sm as ConnectionInfo['sslmode']; + } else if (parsed.ssl === false) { + sslmode = 'disable'; + } + + const name = `${host}:${port}/${database}`; + + return { + id, + name, + host, + port, + username, + password, + database, + sslmode, + environment: 'development', + readOnlyMode: false, + }; +} + +/** Host:port/db preview for UI (no password). */ +export function previewDatabaseUrl(rawUrl: string): string { + try { + const p = parse(rawUrl.trim()); + const host = p.host || '?'; + const port = p.port ? String(p.port) : '5432'; + const db = p.database || '?'; + return `${host}:${port}/${db}`; + } catch { + return '(unparseable URL)'; + } +} diff --git a/src/utils/envFileDatabaseUrls.ts b/src/utils/envFileDatabaseUrls.ts new file mode 100644 index 0000000..b0d2ca9 --- /dev/null +++ b/src/utils/envFileDatabaseUrls.ts @@ -0,0 +1,51 @@ +/** Common env keys that hold a PostgreSQL URL. */ +export const DATABASE_URL_ENV_KEYS = [ + 'DATABASE_URL', + 'POSTGRES_URL', + 'POSTGRESQL_URL', + 'DATABASE_URL_UNPOOLED', +] as const; + +const POSTGRES_URL_PREFIX = /^postgres(ql)?:\/\//i; + +function stripQuotes(value: string): string { + let v = value.trim(); + if (v.length >= 2) { + const q = v[0]; + if ((q === '"' || q === "'") && v[v.length - 1] === q) { + v = v.slice(1, -1); + } + } + return v.trim(); +} + +/** + * Parses simple `KEY=value` lines from an .env-style file; only keys accepted by `acceptKey` + * with values that look like postgres URLs are returned. + */ +export function extractDatabaseUrlsFromEnvText( + text: string, + acceptKey: (k: string) => boolean, +): Array<{ key: string; value: string }> { + const out: Array<{ key: string; value: string }> = []; + for (const line of text.split(/\r?\n/)) { + const t = line.trim(); + if (!t || t.startsWith('#')) { + continue; + } + const eq = t.indexOf('='); + if (eq <= 0) { + continue; + } + const key = t.slice(0, eq).trim(); + if (!acceptKey(key)) { + continue; + } + const value = stripQuotes(t.slice(eq + 1)); + if (!value || !POSTGRES_URL_PREFIX.test(value)) { + continue; + } + out.push({ key, value }); + } + return out; +} diff --git a/templates/ai-settings/styles.css b/templates/ai-settings/styles.css index 654bcd6..c23b850 100644 --- a/templates/ai-settings/styles.css +++ b/templates/ai-settings/styles.css @@ -435,3 +435,14 @@ button[title] { cursor: pointer; } gap: var(--sp-2); align-items: center; } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/templates/chat/scripts.js b/templates/chat/scripts.js index 3746a08..b9a306b 100644 --- a/templates/chat/scripts.js +++ b/templates/chat/scripts.js @@ -1925,6 +1925,8 @@ function renderMessages(messages, animate = false) { if (msg.usage) { const usageDiv = document.createElement('div'); usageDiv.className = 'message-usage'; + usageDiv.setAttribute('role', 'status'); + usageDiv.setAttribute('aria-live', 'polite'); usageDiv.textContent = msg.usage; messageDiv.appendChild(usageDiv); messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); @@ -1955,6 +1957,8 @@ function renderMessages(messages, animate = false) { if (msg.usage) { const usageDiv = document.createElement('div'); usageDiv.className = 'message-usage'; + usageDiv.setAttribute('role', 'status'); + usageDiv.setAttribute('aria-live', 'polite'); usageDiv.textContent = msg.usage; messageDiv.appendChild(usageDiv); } diff --git a/templates/chat/styles.css b/templates/chat/styles.css index df84abf..af87c46 100644 --- a/templates/chat/styles.css +++ b/templates/chat/styles.css @@ -434,12 +434,14 @@ } .message-usage { - font-size: 10px; + font-size: 11px; + font-variant-numeric: tabular-nums; color: var(--vscode-descriptionForeground); - margin-top: 4px; + margin-top: 6px; text-align: right; - opacity: 0.7; - padding: 0 4px; + opacity: 0.88; + padding: 2px 4px 0; + letter-spacing: 0.01em; } .message-content pre { @@ -2064,3 +2066,15 @@ /* ── Tooltip affordance: all icon-only buttons must have title ── */ button[title] { cursor: pointer; } + + /* WCAG: respect system reduced-motion */ + @media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + } diff --git a/templates/connection-form/styles.css b/templates/connection-form/styles.css index 4ad729e..6269f04 100644 --- a/templates/connection-form/styles.css +++ b/templates/connection-form/styles.css @@ -465,3 +465,14 @@ button[title] { cursor: pointer; } cursor: pointer; } .empty-state .empty-cta:hover { background: var(--button-hover); } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html index 6147d4a..dd9cc72 100644 --- a/templates/dashboard/index.html +++ b/templates/dashboard/index.html @@ -54,6 +54,7 @@

...

Overview
Activity
Performance
+
WAL & Replication
Locks & Blocking
@@ -249,6 +250,82 @@

Session Activity

+ +
+
+ + +
+
+ +
+
+
Streaming replicas (pg_stat_replication)
+
WAL sender connections to standbys. Empty on a standby or when no replicas connect.
+
+ + + + + + + + + + + + +
ApplicationClientStateReplay lagSyncReplay LSN
+
+
+
+ +
+
+
WAL receiver (standby)
+
From pg_stat_wal_receiver — primary shows no rows.
+
+ + + + + + + + + + +
StatusReceived LSNSenderLast message
+
+
+
+
Replication slots
+
Logical decoding and physical replication slots.
+
+ + + + + + + + + + + +
SlotTypeActiveWAL statusRestart LSN
+
+
+
+ +
+
pg_stat_wal (PostgreSQL 15+)
+
Cluster-wide WAL activity since stats_reset.
+

+      
+
+
diff --git a/templates/dashboard/scripts.js b/templates/dashboard/scripts.js index 45f7c60..c3db33b 100644 --- a/templates/dashboard/scripts.js +++ b/templates/dashboard/scripts.js @@ -538,6 +538,7 @@ function updateDashboard(stats) { updateIdleInTransactionTable(stats.activeQueries || []); updateOverviewSignals(stats); updatePerformanceInsights(stats); + updateWalReplication(stats); updateKpiDelta('locks', (stats.blockingLocks || []).length, 'locks-delta'); @@ -732,6 +733,145 @@ function updateHealth(stats) { updateRecommendedAction(stats, hasBlocks); } +function updateWalReplication(stats) { + const w = stats.walReplication; + const roleEl = document.getElementById('wal-role-chip'); + const lsnEl = document.getElementById('wal-lsn-summary'); + const chipBox = document.getElementById('wal-settings-chips'); + const replBody = document.querySelector('#wal-repl-table tbody'); + const recvBody = document.querySelector('#wal-receiver-table tbody'); + const slotsBody = document.querySelector('#wal-slots-table tbody'); + const pgNote = document.getElementById('wal-pgstat-note'); + const pgPre = document.getElementById('wal-pgstat-pre'); + + if (!w) { + if (roleEl) roleEl.textContent = 'WAL: —'; + return; + } + + if (roleEl) { + roleEl.textContent = w.inRecovery ? 'Role: standby' : 'Role: primary'; + roleEl.classList.toggle('warn', w.inRecovery); + } + + if (lsnEl) { + if (!w.inRecovery && w.currentWalLsn) { + lsnEl.textContent = `Current WAL: ${w.currentWalLsn}`; + } else if (w.inRecovery) { + const lag = + w.replayLagBytes != null && Number.isFinite(w.replayLagBytes) + ? ` · replay delta ${bytesTick(w.replayLagBytes)}` + : ''; + lsnEl.textContent = `Receive ${w.receiveLsn || '—'} · Replay ${w.replayLsn || '—'}${lag}`; + } else { + lsnEl.textContent = 'WAL LSN: —'; + } + } + + if (chipBox) { + chipBox.innerHTML = ''; + ['wal_level', 'max_wal_size', 'min_wal_size', 'archive_mode', 'synchronous_standby_names'].forEach((k) => { + const val = w.settings && w.settings[k]; + if (val === undefined || val === '') return; + const span = document.createElement('span'); + span.className = 'signal-chip'; + const display = String(val).length > 80 ? `${String(val).slice(0, 77)}…` : String(val); + span.textContent = `${k}: ${display}`; + span.title = `${k}: ${val}`; + chipBox.appendChild(span); + }); + } + + if (replBody) { + replBody.innerHTML = ''; + const rows = Array.isArray(w.replicas) ? w.replicas : []; + if (rows.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 6; + td.style.padding = '16px'; + td.style.color = 'var(--muted-color)'; + td.textContent = w.inRecovery + ? 'Not available on standby (pg_stat_replication is empty here).' + : 'No active replication connections.'; + tr.appendChild(td); + replBody.appendChild(tr); + } else { + rows.forEach((r) => { + const tr = document.createElement('tr'); + [r.application_name || '—', r.client_addr || '—', r.state || '—', r.replay_lag || '—', r.sync_state || '—', r.replay_lsn || '—'].forEach((cell) => { + const td = document.createElement('td'); + td.textContent = cell; + tr.appendChild(td); + }); + replBody.appendChild(tr); + }); + } + } + + if (recvBody) { + recvBody.innerHTML = ''; + const wr = w.walReceiver; + if (!wr || (!wr.status && !wr.received_lsn)) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 4; + td.style.padding = '16px'; + td.style.color = 'var(--muted-color)'; + td.textContent = w.inRecovery ? 'No walreceiver row (check replication link).' : 'WAL receiver is only populated on standbys.'; + tr.appendChild(td); + recvBody.appendChild(tr); + } else { + const tr = document.createElement('tr'); + [wr.status || '—', wr.received_lsn || '—', `${wr.sender_host || '—'}:${wr.sender_port ?? '—'}`, wr.last_msg_receipt_time || '—'].forEach((cell) => { + const td = document.createElement('td'); + td.textContent = cell; + tr.appendChild(td); + }); + recvBody.appendChild(tr); + } + } + + if (slotsBody) { + slotsBody.innerHTML = ''; + const slots = Array.isArray(w.replicationSlots) ? w.replicationSlots : []; + if (slots.length === 0) { + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 5; + td.style.padding = '16px'; + td.style.color = 'var(--muted-color)'; + td.textContent = 'No replication slots.'; + tr.appendChild(td); + slotsBody.appendChild(tr); + } else { + slots.forEach((s) => { + const tr = document.createElement('tr'); + [s.slot_name, s.slot_type || '—', s.active ? 'yes' : 'no', s.wal_status || '—', s.restart_lsn || '—'].forEach((cell) => { + const td = document.createElement('td'); + td.textContent = cell; + tr.appendChild(td); + }); + slotsBody.appendChild(tr); + }); + } + } + + if (pgPre && pgNote) { + if (w.pgStatWal && typeof w.pgStatWal === 'object') { + pgNote.textContent = 'Cluster-wide WAL generator stats (since stats_reset).'; + const o = w.pgStatWal; + const lines = Object.keys(o) + .sort() + .map((k) => `${k}: ${o[k]}`); + pgPre.textContent = lines.join('\n'); + } else { + pgNote.textContent = 'pg_stat_wal not available (requires PostgreSQL 15+, or insufficient privileges).'; + pgPre.textContent = ''; + } + } +} + function updateOverviewSignals(stats) { const indexChip = document.getElementById('signal-index-hit'); const txChip = document.getElementById('signal-oldest-tx'); diff --git a/templates/dashboard/styles.css b/templates/dashboard/styles.css index 436ea0d..068ef72 100644 --- a/templates/dashboard/styles.css +++ b/templates/dashboard/styles.css @@ -539,4 +539,15 @@ tr.row-focus { .header-actions { flex-wrap: wrap; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f06dbc0..9d0b7c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,11 @@ { "compilerOptions": { + "baseUrl": "src", + "paths": { + "@features/*": ["features/*"], + "@core/*": ["core/*"], + "@ui/*": ["ui/*"] + }, "jsx": "react", "module": "commonjs", "target": "ES2020", @@ -12,6 +18,7 @@ "rootDir": "src", "strict": true, "skipLibCheck": true, + "ignoreDeprecations": "6.0", "types": [ "node", "vscode", diff --git a/walkthroughs/step-connection.md b/walkthroughs/step-connection.md new file mode 100644 index 0000000..c8bf104 --- /dev/null +++ b/walkthroughs/step-connection.md @@ -0,0 +1,7 @@ +# Add a connection + +PgStudio stores host, port, database name, and your password in **VS Code Secret Storage** (encrypted on disk). + +Use **Add PostgreSQL Connection** from the palette or the PG Studio sidebar. Test the connection before saving. + +You can also import a `DATABASE_URL` from a `.env` file with the dedicated command. diff --git a/walkthroughs/step-explorer.md b/walkthroughs/step-explorer.md new file mode 100644 index 0000000..58493db --- /dev/null +++ b/walkthroughs/step-explorer.md @@ -0,0 +1,7 @@ +# Explore the database + +The **Connections** tree lists databases, schemas, tables, views, and more. + +Right-click objects for DDL, data, designer tools, and context actions. Use **Refresh** if the tree looks stale. + +Open **SQL Assistant** from the activity bar when you want AI help with queries. diff --git a/walkthroughs/step-notebook.md b/walkthroughs/step-notebook.md new file mode 100644 index 0000000..0bc8006 --- /dev/null +++ b/walkthroughs/step-notebook.md @@ -0,0 +1,5 @@ +# Run SQL in notebooks + +Create a **`.pgsql`** notebook to run cells against a chosen connection, use parameters, view result grids, and export output. + +Use the connection picker in the notebook toolbar to switch databases without leaving the editor. diff --git a/webpack.config.js b/webpack.config.js index 9aaea76..538fcc8 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -33,7 +33,7 @@ module.exports = [ { name: 'renderer', target: 'web', - entry: './src/renderer_v2.ts', + entry: './src/ui/renderer/renderer_v2.ts', output: { path: path.resolve(__dirname, 'dist'), filename: 'renderer_v2.js', From 8921729a8dec01e34639b6bc9182284e65655913 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 11:55:21 +0530 Subject: [PATCH 2/8] feat: Enhance PostgreSQL dashboard and editor functionality - Added new command for opening a live dashboard from the command palette. - Implemented validation for notebook context items to ensure valid database selections. - Enhanced SQL execution to capture and display PostgreSQL NOTICE messages with timestamps. - Updated table cell editors to support various PostgreSQL data types and improve user experience. - Introduced inline editing capabilities for long text and JSON data types. - Improved error handling and user feedback for SQL execution results. - Updated TypeScript types and interfaces to support new features and improve type safety. --- .vscodeignore | 15 + eslint.config.js | 19 ++ package.json | 9 +- src/activation/commandSpecs.ts | 6 +- src/commands/connection.ts | 10 + src/commands/database.ts | 85 +++++ src/commands/helper.ts | 4 +- src/commands/notebook.ts | 47 ++- src/common/pgDataTypeNames.ts | 20 ++ src/common/types.ts | 15 +- src/core/connection/cloudAuth/index.ts | 16 + src/features/connections/connectionForm.ts | 10 + src/features/notebook/notebookProvider.ts | 9 +- src/providers/ChatViewProvider.ts | 55 ++- src/providers/chat/AiService.ts | 191 +++++++++-- src/providers/kernel/SqlExecutor.ts | 125 ++++--- .../components/notices/NoticesPanel.ts | 321 ++++++++++++++++++ src/renderer/components/table/CellEditors.ts | 305 +++++++++++++---- .../components/table/TableRenderer.ts | 44 ++- src/services/DdlViewerService.ts | 121 +++++-- src/test/unit/CellEditors.test.ts | 32 ++ src/test/unit/ChatViewProvider.test.ts | 20 ++ src/test/unit/CommandHelper.test.ts | 5 + src/test/unit/NotebookCommands.test.ts | 25 +- src/test/unit/NoticesPanel.test.ts | 51 +++ src/test/unit/cloudAuth.test.ts | 17 + src/test/unit/pgDataTypeNames.test.ts | 14 + src/ui/renderer/renderer_v2.ts | 107 ++++-- src/utils/connectionUtils.ts | 19 +- templates/connection-form/index.html | 11 + templates/connection-form/scripts.js | 10 +- 31 files changed, 1511 insertions(+), 227 deletions(-) create mode 100644 src/common/pgDataTypeNames.ts create mode 100644 src/core/connection/cloudAuth/index.ts create mode 100644 src/renderer/components/notices/NoticesPanel.ts create mode 100644 src/test/unit/CellEditors.test.ts create mode 100644 src/test/unit/NoticesPanel.test.ts create mode 100644 src/test/unit/cloudAuth.test.ts create mode 100644 src/test/unit/pgDataTypeNames.test.ts diff --git a/.vscodeignore b/.vscodeignore index 1e81a23..7c1744a 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -55,3 +55,18 @@ docs/** .nycrc index.js venv/** +.claude/ +.cursor/ +.nightly/ +.kiro/ +.c8rc.phase-handlers.json +.c8rc.phase-utils.json +.c8rc.phase-report.json +.c8rc.phase-*.json +.nycrc +roadmap.md +FIXES_APPLIED.md +review-the-current-codebase-shimmering-bee.md +.github/ +.coverage/ +.coverage-unit/ \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index cb1116d..f190768 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -27,11 +27,30 @@ module.exports = [ }, rules: { ...prettierConfig.rules, + 'no-debugger': 'error', '@typescript-eslint/no-empty-function': 'warn', '@typescript-eslint/no-explicit-any': 'warn', 'no-unused-expressions': 'off', }, }, + { + files: ['src/test/**/*.ts'], + languageOptions: { + parser: tsParser, + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...prettierConfig.rules, + 'no-debugger': 'error', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', + 'no-unused-expressions': 'off', + }, + }, { files: ['scripts/**/*.js', '*.js'], languageOptions: { diff --git a/package.json b/package.json index 14cc660..79b98bb 100644 --- a/package.json +++ b/package.json @@ -610,6 +610,11 @@ "title": "Show Database Dashboard", "icon": "$(graph)" }, + { + "command": "postgres-explorer.showDashboardFromPalette", + "title": "Live Dashboard: Choose Connection & Database…", + "icon": "$(graph)" + }, { "command": "postgres-explorer.openListenNotify", "title": "LISTEN/NOTIFY Monitor", @@ -1612,6 +1617,7 @@ "mimeTypes": [ "application/x-postgres-result", "application/vnd.postgres-notebook.result", + "application/vnd.postgres-notebook.notices-live", "application/vnd.postgres-notebook.error", "application/x-postgres-notebook-header+json" ], @@ -3101,7 +3107,8 @@ "onCommand:postgres-explorer.switchWorkspaceDefaultConnection", "onCommand:postgres-explorer.exportNotebook", "onCommand:postgres-explorer.openListenNotify", - "onCommand:postgres-explorer.openListenNotifyFromPalette" + "onCommand:postgres-explorer.openListenNotifyFromPalette", + "onCommand:postgres-explorer.showDashboardFromPalette" ], "main": "./dist/extension.js", "scripts": { diff --git a/src/activation/commandSpecs.ts b/src/activation/commandSpecs.ts index fce7605..0caa5f3 100644 --- a/src/activation/commandSpecs.ts +++ b/src/activation/commandSpecs.ts @@ -10,7 +10,7 @@ import { showConstraintProperties, copyConstraintName, generateDropConstraintScr import { cmdConnectDatabase, cmdDisconnectConnection, cmdDisconnectDatabase, cmdReconnectConnection, cmdDuplicateConnection, showConnectionSafety, revealInExplorer } from '../commands/connection'; import { cmdImportConnectionFromDatabaseUrl } from '../commands/importConnectionFromDatabaseUrl'; import { showIndexProperties, copyIndexName, generateDropIndexScript, generateReindexScript, generateScriptCreate, analyzeIndexUsage, generateAlterIndexScript, addIndexComment, cmdIndexOperations, cmdAddIndex } from '../commands/indexes'; -import { cmdAddObjectInDatabase, cmdBackupDatabase, cmdCreateDatabase, cmdDatabaseDashboard, cmdDatabaseOperations, cmdDeleteDatabase, cmdDisconnectDatabase as cmdDisconnectDatabaseLegacy, cmdGenerateCreateScript, cmdMaintenanceDatabase, cmdPsqlTool, cmdQueryTool, cmdRestoreDatabase, cmdScriptAlterDatabase, cmdShowConfiguration } from '../commands/database'; +import { cmdAddObjectInDatabase, cmdBackupDatabase, cmdCreateDatabase, cmdDatabaseDashboard, cmdDatabaseDashboardFromPalette, cmdDatabaseOperations, cmdDeleteDatabase, cmdDisconnectDatabase as cmdDisconnectDatabaseLegacy, cmdGenerateCreateScript, cmdMaintenanceDatabase, cmdPsqlTool, cmdQueryTool, cmdRestoreDatabase, cmdScriptAlterDatabase, cmdShowConfiguration } from '../commands/database'; import { cmdDropExtension, cmdEnableExtension, cmdExtensionOperations, cmdRefreshExtension } from '../commands/extensions'; import { cmdCreateForeignTable, cmdDropForeignTable, cmdEditForeignTable, cmdForeignTableOperations, cmdRefreshForeignTable, cmdShowForeignTableProperties, cmdViewForeignTableData } from '../commands/foreignTables'; import { cmdForeignDataWrapperOperations, cmdShowForeignDataWrapperProperties, cmdCreateForeignServer, cmdForeignServerOperations, cmdShowForeignServerProperties, cmdDropForeignServer, cmdCreateUserMapping, cmdUserMappingOperations, cmdShowUserMappingProperties, cmdDropUserMapping, cmdRefreshForeignDataWrapper, cmdRefreshForeignServer, cmdRefreshUserMapping } from '../commands/foreignDataWrappers'; @@ -475,6 +475,10 @@ export function getCommandSpecs( command: 'postgres-explorer.showDashboard', callback: async (item: DatabaseTreeItem) => await cmdDatabaseDashboard(item, context) }, + { + command: 'postgres-explorer.showDashboardFromPalette', + callback: () => cmdDatabaseDashboardFromPalette(context) + }, { command: 'postgres-explorer.openListenNotify', callback: async (item: DatabaseTreeItem) => await cmdOpenListenNotify(item, context) diff --git a/src/commands/connection.ts b/src/commands/connection.ts index a6a073f..cc97283 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -71,6 +71,16 @@ export function validateCategoryItem(item: DatabaseTreeItem): asserts item is Da } } +/** + * Validates tree context for SQL notebook commands that only need a pooled DB connection + * (e.g. new notebook from a database node or after picking connection + database from the palette). + */ +export function validateNotebookContextItem(item: DatabaseTreeItem): asserts item is DatabaseTreeItem & { connectionId: string; databaseName: string } { + if (!item?.connectionId || !item?.databaseName) { + throw new Error('Invalid selection'); + } +} + /** * getConnectionWithPassword - Retrieves the connection details and password for the specified connection ID. */ diff --git a/src/commands/database.ts b/src/commands/database.ts index a9096e8..51f79f2 100644 --- a/src/commands/database.ts +++ b/src/commands/database.ts @@ -1,6 +1,7 @@ import { Client } from 'pg'; import * as vscode from 'vscode'; import { createMetadata, getConnectionWithPassword } from '../commands/connection'; +import { ConnectionConfig } from '../common/types'; import { DashboardPanel } from '../dashboard/DashboardPanel'; import { DatabaseTreeItem, DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; import { ConnectionManager } from '../services/ConnectionManager'; @@ -45,6 +46,90 @@ export async function cmdDatabaseDashboard(item: DatabaseTreeItem, context: vsco } } +/** + * Command Palette: pick saved connection → database, then open the live dashboard webview. + */ +export async function cmdDatabaseDashboardFromPalette( + context: vscode.ExtensionContext, +): Promise { + const connections = + vscode.workspace + .getConfiguration() + .get>>('postgresExplorer.connections') || []; + if (connections.length === 0) { + await vscode.window.showErrorMessage( + 'No saved connections. Add a connection first.', + ); + return; + } + + const connPick = await vscode.window.showQuickPick( + connections.map((c: Record) => ({ + label: (c.name as string) || `${c.host}:${c.port}`, + description: (c.database as string) || 'postgres', + conn: c, + })), + { + title: 'Live Dashboard: Connection', + placeHolder: 'Select a saved connection', + }, + ); + if (!connPick) { + return; + } + + const connection = connPick.conn as Record & { + id: string; + host: string; + port: number; + database?: string; + }; + const bootstrapDb = connection.database || 'postgres'; + + let tempClient; + try { + tempClient = await ConnectionManager.getInstance().getPooledClient({ + ...(connection as ConnectionConfig), + database: bootstrapDb, + }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + await vscode.window.showErrorMessage(`Could not connect: ${msg}`); + return; + } + + let dbName: string; + try { + const dbsResult = await tempClient.query(` + SELECT datname FROM pg_database + WHERE datallowconn = true AND datistemplate = false + ORDER BY datname + `); + const databases = dbsResult.rows.map((r: { datname: string }) => r.datname); + const dbChoice = await vscode.window.showQuickPick(databases, { + title: 'Live Dashboard: Database', + placeHolder: 'Database to open the dashboard for', + }); + if (!dbChoice) { + return; + } + dbName = dbChoice; + } finally { + tempClient.release(); + } + + try { + await DashboardPanel.show( + context.extensionUri, + connection as ConnectionConfig, + dbName, + connection.id, + ); + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'show dashboard'); + } +} + /** * cmdAddInDatabase - Command to create a new object in the database. * Prompts the user to select the type of object to create (schema, user, role, or extension). diff --git a/src/commands/helper.ts b/src/commands/helper.ts index 9a6309e..cc12815 100644 --- a/src/commands/helper.ts +++ b/src/commands/helper.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; -import { createAndShowNotebook, createMetadata, getConnectionWithPassword, validateItem, validateCategoryItem, validateRoleItem } from './connection'; +import { createAndShowNotebook, createMetadata, getConnectionWithPassword, validateItem, validateCategoryItem, validateRoleItem, validateNotebookContextItem } from './connection'; import { ConnectionManager } from '../services/ConnectionManager'; import { ErrorService } from '../services/ErrorService'; import { SessionRegistry } from '../services/SessionRegistry'; @@ -11,7 +11,7 @@ let _extensionContext: vscode.ExtensionContext | undefined; // Re-export SQL templates from sql/helper.ts for backward compatibility export { SQL_TEMPLATES, QueryBuilder, MaintenanceTemplates } from './sql/helper'; -export { validateItem, validateCategoryItem, validateRoleItem }; +export { validateItem, validateCategoryItem, validateRoleItem, validateNotebookContextItem }; /** Get database connection and metadata for tree item operations */ export async function getDatabaseConnection(item: DatabaseTreeItem, validateFn: (item: DatabaseTreeItem) => void = validateItem) { diff --git a/src/commands/notebook.ts b/src/commands/notebook.ts index d2940df..d589af7 100644 --- a/src/commands/notebook.ts +++ b/src/commands/notebook.ts @@ -1,7 +1,8 @@ import * as vscode from 'vscode'; import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; -import { getDatabaseConnection, NotebookBuilder, MarkdownUtils, ErrorHandlers } from './helper'; +import { getDatabaseConnection, NotebookBuilder, MarkdownUtils, ErrorHandlers, validateNotebookContextItem } from './helper'; import { PostgresMetadata } from '../common/types'; +import { ConnectionUtils } from '../utils/connectionUtils'; type NotebookCellSeed = { kind: 'markdown' | 'sql'; @@ -279,9 +280,49 @@ async function createAndOpenRandomNotebook(metadata: any, cells: NotebookCellSee await builder.showNew(); } +function hasNotebookDbContext(item: DatabaseTreeItem | undefined): boolean { + return item !== undefined && !!item.connectionId && !!item.databaseName; +} + +/** + * When the command runs from a keybinding there is no tree selection; some tree nodes + * also omit connectionId/databaseName. Prompt for connection and database in that case. + */ +async function resolveNotebookTreeItem(item: DatabaseTreeItem | undefined): Promise { + if (hasNotebookDbContext(item)) { + return item; + } + const connection = await ConnectionUtils.showConnectionPicker(undefined, { + title: 'New SQL Notebook', + placeHolder: 'Select a connection for this notebook', + }); + if (!connection) { + return undefined; + } + const databaseName = await ConnectionUtils.showDatabasePicker(connection, undefined, { + title: 'New SQL Notebook', + placeHolder: 'Select a database to connect the notebook to', + }); + if (!databaseName) { + return undefined; + } + return new DatabaseTreeItem( + databaseName, + vscode.TreeItemCollapsibleState.None, + 'database', + connection.id, + databaseName + ); +} + export async function cmdNewNotebook(item: DatabaseTreeItem, context?: vscode.ExtensionContext) { try { - const dbConn = await getDatabaseConnection(item); + const treeItem = await resolveNotebookTreeItem(item); + if (!treeItem) { + return; + } + + const dbConn = await getDatabaseConnection(treeItem, validateNotebookContextItem); const { metadata } = dbConn; if (dbConn.release) dbConn.release(); @@ -296,7 +337,7 @@ export async function cmdNewNotebook(item: DatabaseTreeItem, context?: vscode.Ex kind: 'sql', value: `-- Connected to database: ${metadata.databaseName} -- Write your SQL query here -SELECT * FROM ${item.schema ? `${item.schema}.${item.label}` : 'your_table'} +SELECT * FROM ${treeItem.schema ? `${treeItem.schema}.${treeItem.label}` : 'your_table'} LIMIT 100;`, }, ]; diff --git a/src/common/pgDataTypeNames.ts b/src/common/pgDataTypeNames.ts new file mode 100644 index 0000000..07299bc --- /dev/null +++ b/src/common/pgDataTypeNames.ts @@ -0,0 +1,20 @@ +/** + * Maps PostgreSQL type OIDs (from pg Field.dataTypeID) to stable typnames for UI and editors. + * Built from pg-types builtins (pg_catalog base types); unknown OIDs get `oid:` — never generic "string". + */ +import * as pgTypes from 'pg-types'; + +const BUILTIN_OID_TO_TYPNAME: Record = (() => { + const m: Record = {}; + const builtins = pgTypes.builtins as Record; + for (const [name, oid] of Object.entries(builtins)) { + if (typeof oid === 'number') { + m[oid] = name.toLowerCase(); + } + } + return m; +})(); + +export function getPgDataTypeName(dataTypeID: number): string { + return BUILTIN_OID_TO_TYPNAME[dataTypeID] ?? `oid:${dataTypeID}`; +} diff --git a/src/common/types.ts b/src/common/types.ts index e1dca30..e266098 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -1,3 +1,5 @@ +import type { CloudAuthContext } from '../core/connection/cloudAuth/types'; + export interface ConnectionConfig { id: string; name?: string; @@ -25,6 +27,8 @@ export interface ConnectionConfig { username: string; privateKeyPath?: string; }; + /** Optional tag for future IAM token auth (password/pgpass still used until implemented). */ + cloudAuth?: CloudAuthContext; } export interface PostgresMetadata { @@ -86,13 +90,20 @@ export interface BreadcrumbContext { }; } +/** PostgreSQL notice with client receive time (log-style UI) */ +export interface NoticeLogEntry { + message: string; + /** ISO 8601 when the client received the notice */ + receivedAt: string; +} + export interface QueryResults { rows: any[]; columns: string[]; rowCount?: number | null; command?: string; query?: string; - notices?: string[]; + notices?: NoticeLogEntry[]; executionTime?: number; tableInfo?: TableInfo; columnTypes?: Record; @@ -282,5 +293,7 @@ export interface ResultHistoryEntry { rowCount?: number | null; executionTime?: number; query?: string; + /** PostgreSQL notices (RAISE NOTICE, etc.) for this result */ + notices?: NoticeLogEntry[]; timestamp: number; } diff --git a/src/core/connection/cloudAuth/index.ts b/src/core/connection/cloudAuth/index.ts new file mode 100644 index 0000000..b4edb59 --- /dev/null +++ b/src/core/connection/cloudAuth/index.ts @@ -0,0 +1,16 @@ +export type { CloudAuthKind, CloudAuthContext } from './types'; +import type { CloudAuthContext, CloudAuthKind } from './types'; + +/** + * Normalise persisted or form JSON into a {@link CloudAuthContext}. + * Unknown values default to `none` (password / standard auth). + */ +export function parseCloudAuth(raw: unknown): CloudAuthContext { + if (raw && typeof raw === 'object' && 'kind' in raw) { + const k = (raw as { kind?: string }).kind; + if (k === 'aws-iam' || k === 'azure-ad' || k === 'gcp-iam') { + return { kind: k as CloudAuthKind }; + } + } + return { kind: 'none' }; +} diff --git a/src/features/connections/connectionForm.ts b/src/features/connections/connectionForm.ts index dab57a9..f0658cc 100644 --- a/src/features/connections/connectionForm.ts +++ b/src/features/connections/connectionForm.ts @@ -7,6 +7,8 @@ import { resolvePgPassPasswordAsync, pgPassFileDescription, } from "../../utils/pgPassUtils"; +import type { CloudAuthContext } from "../../core/connection/cloudAuth/types"; +import { parseCloudAuth } from "../../core/connection/cloudAuth"; export interface ConnectionInfo { id: string; @@ -42,6 +44,8 @@ export interface ConnectionInfo { username: string; privateKeyPath?: string; }; + /** Planned IAM flows; connections still use password or pgpass today. */ + cloudAuth?: CloudAuthContext; } async function writeConnectionsToWorkspace( @@ -400,6 +404,9 @@ export class ConnectionFormPanel { await runTest(message.connection, true); const connections = this.getStoredConnections(); + const cloudAuthParsed = parseCloudAuth( + message.connection.cloudAuth, + ); const newConnection: ConnectionInfo = { id: this._connectionToEdit ? this._connectionToEdit.id @@ -427,6 +434,9 @@ export class ConnectionFormPanel { message.connection.applicationName || undefined, options: message.connection.options || undefined, ssh: message.connection.ssh, + ...(cloudAuthParsed.kind !== "none" + ? { cloudAuth: cloudAuthParsed } + : {}), }; if (this._connectionToEdit) { diff --git a/src/features/notebook/notebookProvider.ts b/src/features/notebook/notebookProvider.ts index 901bae4..7d263cb 100644 --- a/src/features/notebook/notebookProvider.ts +++ b/src/features/notebook/notebookProvider.ts @@ -1,5 +1,6 @@ import { Client } from 'pg'; import * as vscode from 'vscode'; +import { getPgDataTypeName } from '../../common/pgDataTypeNames'; interface PostgresCell { kind: 'query'; @@ -155,10 +156,14 @@ export class PostgresNotebookController { // Create a JSON output for the custom renderer const outputData = { - columns: result.fields.map(field => field.name), + columns: result.fields.map((field) => field.name), rows: result.rows, rowCount: result.rowCount, - command: result.command + command: result.command, + columnTypes: result.fields.reduce>((acc, f) => { + acc[f.name] = getPgDataTypeName(f.dataTypeID); + return acc; + }, {}), }; execution.replaceOutput([ diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index cfd616f..3501af1 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -22,6 +22,7 @@ import { getWebviewHtml } from './chat'; import { ErrorService } from '../services/ErrorService'; +import type { NoticeLogEntry } from '../common/types'; export class ChatViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'postgresExplorer.chatView'; @@ -100,7 +101,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { * Called from the "Chat" CodeLens button or "Send to Chat" result button * Does NOT auto-send - waits for user to add their context */ - public async sendToChat(data: { query: string; results?: string; message: string }): Promise { + public async sendToChat(data: { + query: string; + results?: string; + message: string; + /** PostgreSQL RAISE NOTICE / server messages — attached as a .txt file */ + notices?: Array; + }): Promise { // Wait a bit for the view to be ready after focus await new Promise(resolve => setTimeout(resolve, 300)); @@ -131,6 +138,36 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { }); } + // Optional notices file (numbered, execution order) + if (data.notices && data.notices.length > 0) { + const noticeLines = data.notices + .map((n, i) => { + if (typeof n === 'string') { + return `${i + 1}. ${n}`; + } + const msg = n.message ?? ''; + const iso = n.receivedAt?.trim(); + if (iso) { + return `${i + 1}. [${iso}] ${msg}`; + } + return `${i + 1}. ${msg}`; + }) + .join('\n\n'); + const noticeFileName = `notices_${Date.now()}.txt`; + const noticeFilePath = path.join(tempDir, noticeFileName); + await fs.promises.writeFile(noticeFilePath, noticeLines, 'utf8'); + + this._view.webview.postMessage({ + type: 'fileAttached', + file: { + name: noticeFileName, + content: noticeLines, + type: 'txt', + path: noticeFilePath, + }, + }); + } + // Create results file if we have results - convert to CSV like Analyze Data does if (data.results) { try { @@ -189,8 +226,20 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } - // Show toast in chat to let user know files are attached - vscode.window.showInformationMessage('Query and results attached to chat. Add your question and send!'); + const attached: string[] = []; + if (data.query?.trim()) { + attached.push('query'); + } + if (data.results) { + attached.push('results'); + } + if (data.notices?.length) { + attached.push('notices'); + } + const summary = attached.length ? attached.join(' & ') : 'Content'; + vscode.window.showInformationMessage( + `${summary} attached to SQL Assistant. Add your question and send!`, + ); } catch (error) { console.error('[ChatViewProvider] Failed to create temp files:', error); diff --git a/src/providers/chat/AiService.ts b/src/providers/chat/AiService.ts index 191486a..33049ee 100644 --- a/src/providers/chat/AiService.ts +++ b/src/providers/chat/AiService.ts @@ -17,6 +17,22 @@ const DEFAULT_GITHUB_MODEL = 'openai/gpt-4.1'; /** Heuristic for VS Code LM when the host does not report token usage (UI hint only). */ const ROUGH_CHARS_PER_TOKEN = 4; +/** Transient HTTP failures for direct API providers (OpenAI-compatible, Anthropic, etc.). */ +const HTTP_RETRY_MAX_ATTEMPTS = 3; +const HTTP_RETRY_BASE_MS = 400; +const HTTP_RETRY_CAP_MS = 8000; + +/** Carries HTTP status for non-200 / parse failures so retries can target 5xx and 429. */ +class AiProviderHttpError extends Error { + constructor( + message: string, + readonly httpStatus?: number, + ) { + super(message); + this.name = 'AiProviderHttpError'; + } +} + export class AiService { private _messages: ChatMessage[] = []; private _cancellationTokenSource: vscode.CancellationTokenSource | null = null; @@ -918,7 +934,81 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; throw new Error(`Unsupported provider: ${provider}`); } - return this._makeHttpRequest(endpoint, headers, body, provider); + return this._makeHttpRequestWithRetry(endpoint, headers, body, provider); + } + + private async _makeHttpRequestWithRetry( + endpoint: string, + headers: any, + body: any, + provider: string, + ): Promise<{ text: string; usage?: string }> { + let lastErr: unknown; + for (let attempt = 0; attempt < HTTP_RETRY_MAX_ATTEMPTS; attempt++) { + try { + return await this._makeHttpRequest(endpoint, headers, body, provider); + } catch (err) { + lastErr = err; + if ( + attempt === HTTP_RETRY_MAX_ATTEMPTS - 1 || + !this._isTransientProviderHttpError(err) || + this._shouldSkipRetryForLocalConnectionRefused(endpoint, err) + ) { + throw err; + } + const delay = Math.min( + HTTP_RETRY_BASE_MS * 2 ** attempt, + HTTP_RETRY_CAP_MS, + ); + await new Promise((r) => setTimeout(r, delay)); + } + } + throw lastErr; + } + + /** + * Ollama/LM Studio on localhost: ECONNREFUSED means the daemon is not running. + * Retrying does not help and only adds latency. + */ + private _shouldSkipRetryForLocalConnectionRefused( + endpoint: string, + err: unknown, + ): boolean { + const msg = err instanceof Error ? err.message : String(err); + if (!/ECONNREFUSED/i.test(msg)) { + return false; + } + try { + const host = new URL(endpoint).hostname.toLowerCase(); + return ( + host === 'localhost' || + host === '127.0.0.1' || + host === '::1' || + host === '[::1]' + ); + } catch { + return false; + } + } + + private _isTransientProviderHttpError(err: unknown): boolean { + if (err instanceof AiProviderHttpError && err.httpStatus !== undefined) { + const s = err.httpStatus; + if (s === 429) { + return true; + } + if (s >= 500 && s < 600) { + return true; + } + return false; + } + const msg = err instanceof Error ? err.message : String(err); + if (/status (429|502|503|504)\b/.test(msg)) { + return true; + } + return /ECONNRESET|ETIMEDOUT|EAI_AGAIN|ECONNREFUSED|socket hang up|ENOTFOUND/i.test( + msg, + ); } private async _getDirectApiKey(config: vscode.WorkspaceConfiguration): Promise { @@ -962,48 +1052,79 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { - try { - const response = JSON.parse(data); - - if (res.statusCode !== 200) { - reject(new Error(response.error?.message || `API request failed with status ${res.statusCode}`)); - return; - } - - let content = ''; - let usage = ''; - - if (provider === 'anthropic') { - content = response.content?.[0]?.text || ''; - if (response.usage) { - usage = `${response.usage.input_tokens} input, ${response.usage.output_tokens} output`; - } - } else if (provider === 'gemini') { - content = response.candidates?.[0]?.content?.parts?.[0]?.text || ''; - if (response.usageMetadata) { - usage = `${response.usageMetadata.totalTokenCount} tokens`; + const statusCode = res.statusCode ?? 0; + + if (statusCode !== 200) { + let detail = `API request failed with status ${statusCode}`; + try { + const errBody = JSON.parse(data) as { error?: { message?: string } }; + if (errBody.error?.message) { + detail = String(errBody.error.message); } - } else { - // OpenAI or compatible - content = response.choices?.[0]?.message?.content || ''; - if (response.usage) { - usage = `${response.usage.total_tokens} tokens (P:${response.usage.prompt_tokens}, C:${response.usage.completion_tokens})`; + } catch { + const snippet = data.replace(/\s+/g, ' ').trim().slice(0, 200); + if (snippet) { + detail = `${detail} — ${snippet}`; } } + reject(new AiProviderHttpError(detail, statusCode)); + return; + } - if (!content && provider === 'custom') { - content = JSON.stringify(response); // Fallback - } + let response: Record; + try { + response = JSON.parse(data) as Record; + } catch (e) { + reject( + new AiProviderHttpError( + `Failed to parse API response: ${e instanceof Error ? e.message : String(e)}`, + statusCode, + ), + ); + return; + } + + let content = ''; + let usage = ''; - if (usage && body?.model) { - usage = `${body.model} · ${usage}`; + if (provider === 'anthropic') { + const contentArr = response.content as Array<{ text?: string }> | undefined; + content = contentArr?.[0]?.text || ''; + const usageObj = response.usage as { input_tokens?: number; output_tokens?: number } | undefined; + if (usageObj) { + usage = `${usageObj.input_tokens} input, ${usageObj.output_tokens} output`; + } + } else if (provider === 'gemini') { + const candidates = response.candidates as Array<{ + content?: { parts?: Array<{ text?: string }> }; + }> | undefined; + content = candidates?.[0]?.content?.parts?.[0]?.text || ''; + const usageObj = response.usageMetadata as { totalTokenCount?: number } | undefined; + if (usageObj) { + usage = `${usageObj.totalTokenCount} tokens`; + } + } else { + const choices = response.choices as Array<{ message?: { content?: string } }> | undefined; + content = choices?.[0]?.message?.content || ''; + const usageObj = response.usage as { + total_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + } | undefined; + if (usageObj) { + usage = `${usageObj.total_tokens} tokens (P:${usageObj.prompt_tokens}, C:${usageObj.completion_tokens})`; } + } - resolve({ text: content, usage }); - } catch (e) { - // If response is not JSON, we might want to log it - reject(new Error(`Failed to parse API response: ${e instanceof Error ? e.message : String(e)}`)); + if (!content && provider === 'custom') { + content = JSON.stringify(response); } + + if (usage && body?.model) { + usage = `${body.model} · ${usage}`; + } + + resolve({ text: content, usage }); }); }); diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index e39811d..4323083 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -3,7 +3,8 @@ import * as vscode from 'vscode'; import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; import { ConnectionManager } from '../../services/ConnectionManager'; import { TelemetryService, SpanNames } from '../../services/TelemetryService'; -import { PostgresMetadata, QueryResults } from '../../common/types'; +import { NoticeLogEntry, PostgresMetadata, QueryResults } from '../../common/types'; +import { getPgDataTypeName } from '../../common/pgDataTypeNames'; import { SqlParser } from './SqlParser'; import { SecretStorageService } from '../../services/SecretStorageService'; import { ErrorService, getErrorExplanation } from '../../services/ErrorService'; @@ -15,6 +16,9 @@ import { extensionContext } from '../../extension'; import { QueryCodeLensProvider } from '../QueryCodeLensProvider'; import { updateNotebookTitle } from '../../utils/notebookTitle'; +/** Streaming NOTICE feed during a single-statement cell run (replaced by final result output). */ +const MIME_NOTICES_LIVE = 'application/vnd.postgres-notebook.notices-live'; + export class SqlExecutor { private static readonly REVIEW_COUNT_KEY = 'postgresExplorer.reviewPrompt.successCount'; private static readonly REVIEW_SHOWN_KEY = 'postgresExplorer.reviewPrompt.shown'; @@ -305,17 +309,44 @@ export class SqlExecutor { console.warn('Failed to get backend PID:', err); } - // Capture PostgreSQL NOTICE messages - const notices: string[] = []; + const queryText = cell.document.getText(); + const statements = SqlParser.splitSqlStatements(queryText); + const allowLiveNotices = statements.length === 1; + + // Capture PostgreSQL NOTICE messages (timestamp = client receive time, log-style) + const notices: NoticeLogEntry[] = []; + let liveNoticesActive = false; + const pushNotice = (message: string) => { + notices.push({ message, receivedAt: new Date().toISOString() }); + }; + const emitLiveNoticesIfNeeded = () => { + if (!allowLiveNotices || notices.length === 0) { + return; + } + liveNoticesActive = true; + void (async () => { + try { + const payload = { streaming: true as const, notices: [...notices] }; + await execution.replaceOutput([ + new NotebookCellOutput([ + new NotebookCellOutputItem( + Buffer.from(JSON.stringify(payload), 'utf8'), + MIME_NOTICES_LIVE, + ), + ]), + ]); + } catch (e) { + console.error('SqlExecutor: live notice output failed', e); + } + })(); + }; const noticeListener = (msg: any) => { const message = msg.message || msg.toString(); - notices.push(message); + pushNotice(message); + emitLiveNoticesIfNeeded(); }; client.on('notice', noticeListener); - const queryText = cell.document.getText(); - const statements = SqlParser.splitSqlStatements(queryText); - console.log('SqlExecutor: Executing', statements.length, 'statement(s)'); // Safety check: Analyze queries for dangerous operations @@ -349,7 +380,10 @@ export class SqlExecutor { if (!txInfo) { txManager.initializeSession(sessionId, true); } - notices.push('Transaction started automatically for safety. Run COMMIT or ROLLBACK when done.'); + pushNotice( + 'Transaction started automatically for safety. Run COMMIT or ROLLBACK when done.', + ); + emitLiveNoticesIfNeeded(); } } } @@ -357,6 +391,7 @@ export class SqlExecutor { // Execute each statement for (let stmtIndex = 0; stmtIndex < statements.length; stmtIndex++) { + liveNoticesActive = false; let query = statements[stmtIndex]; const stmtStartTime = Date.now(); @@ -498,16 +533,29 @@ export class SqlExecutor { autoLimitValue = lim ? parseInt(lim[1], 10) : undefined; } + const rows = result.rows || []; + let columns = result.fields?.map((f: any) => f.name) || []; + if (columns.length === 0 && rows.length > 0) { + columns = Object.keys(rows[0]); + } + const columnTypes: Record = { + ...(result.fields?.reduce((acc: any, f: any) => { + acc[f.name] = getPgDataTypeName(f.dataTypeID); + return acc; + }, {}) || {}), + }; + for (const c of columns) { + if (columnTypes[c] === undefined) { + columnTypes[c] = 'text'; + } + } + const outputData: QueryResults = { success, rowCount: result.rowCount, - rows: result.rows, - columns: result.fields?.map((f: any) => f.name) || [], - columnTypes: result.fields?.reduce((acc: any, f: any) => { - // Approximate type mapping or use OID if available - acc[f.name] = this.getTypeName(f.dataTypeID); - return acc; - }, {}), + rows, + columns, + columnTypes, command: result.command, query: query, notices: [...notices], // Copy current notices @@ -533,9 +581,17 @@ export class SqlExecutor { // Clear notices for next statement notices.length = 0; - await execution.appendOutput(new NotebookCellOutput([ - new NotebookCellOutputItem(Buffer.from(JSON.stringify(outputData), 'utf8'), 'application/vnd.postgres-notebook.result') - ])); + const successOutput = new NotebookCellOutput([ + new NotebookCellOutputItem( + Buffer.from(JSON.stringify(outputData), 'utf8'), + 'application/vnd.postgres-notebook.result', + ), + ]); + if (allowLiveNotices && liveNoticesActive) { + await execution.replaceOutput(successOutput); + } else { + await execution.appendOutput(successOutput); + } // Update execution time pill in CodeLens bar QueryCodeLensProvider.getInstance()?.updatePill(cell.document.uri.toString(), { @@ -588,9 +644,17 @@ export class SqlExecutor { errorExplanation: pgErrorCode ? getErrorExplanation(pgErrorCode) : undefined }; - await execution.appendOutput(new NotebookCellOutput([ - new NotebookCellOutputItem(Buffer.from(JSON.stringify(errorData), 'utf8'), 'application/vnd.postgres-notebook.error') - ])); + const errorOutput = new NotebookCellOutput([ + new NotebookCellOutputItem( + Buffer.from(JSON.stringify(errorData), 'utf8'), + 'application/vnd.postgres-notebook.error', + ), + ]); + if (allowLiveNotices && liveNoticesActive) { + await execution.replaceOutput(errorOutput); + } else { + await execution.appendOutput(errorOutput); + } // Update execution time pill in CodeLens bar (failure) QueryCodeLensProvider.getInstance()?.updatePill(cell.document.uri.toString(), { @@ -630,25 +694,6 @@ export class SqlExecutor { // --- Helpers --- - private getTypeName(oid: number): string { - // Basic mapping, in a real app this would use a proper TypeRegistry - const types: Record = { - 16: 'bool', - 17: 'bytea', - 20: 'int8', - 21: 'int2', - 23: 'int4', - 25: 'text', - 114: 'json', - 1043: 'varchar', - 1082: 'date', - 1114: 'timestamp', - 1184: 'timestamptz', - 1700: 'numeric' - }; - return types[oid] || 'string'; // Default to string - } - private async getTableInfo(client: any, result: any, query: string): Promise { // Attempt to deduce table from query for basic primary key support // This is a heuristic. For better support, we'd parse the query structure. diff --git a/src/renderer/components/notices/NoticesPanel.ts b/src/renderer/components/notices/NoticesPanel.ts new file mode 100644 index 0000000..9687053 --- /dev/null +++ b/src/renderer/components/notices/NoticesPanel.ts @@ -0,0 +1,321 @@ +/** + * PostgreSQL NOTICE and server messages from query execution, with search. + */ + +import type { NoticeLogEntry } from '../../../common/types'; +import { createButton } from '../ui'; + +export interface NoticeEntry { + /** 1-based order as received from the server */ + order: number; + text: string; + /** ISO 8601; empty when loading legacy notebook output (string-only notices) */ + receivedAt: string; +} + +/** Coerce notebook JSON (legacy `string[]` or structured entries) to `NoticeLogEntry`. */ +export function normalizeNoticesPayload(raw: unknown): NoticeLogEntry[] { + if (!Array.isArray(raw)) { + return []; + } + const out: NoticeLogEntry[] = []; + for (const item of raw) { + if (typeof item === 'string') { + out.push({ message: item, receivedAt: '' }); + } else if (item && typeof item === 'object' && 'message' in item) { + const m = String((item as { message: unknown }).message); + const at = (item as { receivedAt?: unknown }).receivedAt; + out.push({ + message: m, + receivedAt: typeof at === 'string' ? at : '', + }); + } + } + return out; +} + +const NOTICE_LOG_TIME = new Intl.DateTimeFormat(undefined, { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}); + +export function formatNoticeLogTime(iso: string): string { + if (!iso.trim()) { + return '—'; + } + const t = Date.parse(iso); + if (Number.isNaN(t)) { + return '—'; + } + return NOTICE_LOG_TIME.format(new Date(t)); +} + +/** Newest notice first (same labels as execution order #). */ +function newestFirstNoticeEntries(entries: readonly NoticeEntry[]): NoticeEntry[] { + return [...entries].reverse(); +} + +/** Newest first for live payload (array is oldest → newest). */ +function newestFirstLiveRows( + entries: readonly NoticeLogEntry[], +): { order: number; entry: NoticeLogEntry }[] { + const tagged = entries.map((e, i) => ({ order: i + 1, entry: e })); + return tagged.reverse(); +} + +/** Filter notices by case-insensitive substring on message; preserves original order. */ +export function filterNoticeEntries( + messages: readonly NoticeLogEntry[], + searchQuery: string, +): NoticeEntry[] { + const q = searchQuery.trim().toLowerCase(); + const indexed: NoticeEntry[] = messages.map((n, i) => ({ + order: i + 1, + text: n.message, + receivedAt: n.receivedAt, + })); + if (!q) { + return indexed; + } + return indexed.filter((e) => e.text.toLowerCase().includes(q)); +} + +const COPY_FEEDBACK_MS = 2000; +const LIVE_FEED_MAX_HEIGHT_PX = 240; + +/** Shown while a single-statement query runs; updated as each NOTICE arrives. */ +export function renderNoticesLiveStream(entries: readonly NoticeLogEntry[]): HTMLElement { + const root = document.createElement('div'); + root.setAttribute('data-pg-notices-live', '1'); + root.style.cssText = ` + font-family: var(--vscode-editor-font-family); + font-size: 12px; + padding: 8px 12px; + margin-bottom: 4px; + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-editor-background) 92%, var(--vscode-textBlockQuote-background)); + border-top: 2px solid var(--vscode-textLink-foreground); + `; + const title = document.createElement('div'); + title.textContent = 'Notices (live)'; + title.style.cssText = + 'font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;color:var(--vscode-descriptionForeground);margin-bottom:8px;'; + root.appendChild(title); + + const scroll = document.createElement('div'); + scroll.style.cssText = `max-height:${LIVE_FEED_MAX_HEIGHT_PX}px;overflow:auto;`; + + const rows = newestFirstLiveRows(entries); + rows.forEach(({ order, entry: e }) => { + const row = document.createElement('div'); + row.style.cssText = + 'display:flex;gap:10px;padding:6px 0;border-bottom:1px solid color-mix(in srgb, var(--vscode-panel-border) 55%, transparent);align-items:flex-start;'; + const idx = document.createElement('span'); + idx.textContent = String(order); + idx.style.cssText = + 'flex-shrink:0;min-width:2em;font-variant-numeric:tabular-nums;color:var(--vscode-descriptionForeground);'; + const timeEl = document.createElement('span'); + timeEl.textContent = formatNoticeLogTime(e.receivedAt); + timeEl.style.cssText = + 'flex-shrink:0;min-width:11.5em;font-variant-numeric:tabular-nums;color:color-mix(in srgb, var(--vscode-descriptionForeground) 88%, var(--vscode-editor-foreground));'; + timeEl.title = e.receivedAt || ''; + const body = document.createElement('div'); + body.textContent = e.message; + body.style.cssText = 'white-space:pre-wrap;word-break:break-word;flex:1;min-width:0;'; + row.appendChild(idx); + row.appendChild(timeEl); + row.appendChild(body); + scroll.appendChild(row); + }); + root.appendChild(scroll); + scroll.scrollTop = 0; + return root; +} + +export interface NoticesPanelOptions { + /** Attach query + notices to SQL Assistant (notebook result → extension host) */ + onAskAssistant?: () => void; +} + +export function renderNoticesPanel( + messages: readonly NoticeLogEntry[], + options?: NoticesPanelOptions, +): HTMLElement { + const wrapper = document.createElement('div'); + wrapper.style.cssText = + 'flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden;background:var(--vscode-editor-background);'; + + let lastFiltered: NoticeEntry[] = []; + + const searchRow = document.createElement('div'); + searchRow.style.cssText = + 'flex-shrink:0;padding:8px 12px;border-bottom:1px solid var(--vscode-panel-border);display:flex;align-items:center;gap:8px;flex-wrap:wrap;'; + + const searchInput = document.createElement('input'); + searchInput.type = 'search'; + searchInput.placeholder = 'Filter notices…'; + searchInput.setAttribute('aria-label', 'Filter notices'); + searchInput.autocomplete = 'off'; + searchInput.style.cssText = ` + flex:1; + min-width:0; + padding:6px 10px; + font-size:12px; + font-family:var(--vscode-font-family); + border:1px solid var(--vscode-input-border); + background:var(--vscode-input-background); + color:var(--vscode-input-foreground); + border-radius:4px; + `; + + const countBadge = document.createElement('span'); + countBadge.style.cssText = + 'font-size:11px;color:var(--vscode-descriptionForeground);white-space:nowrap;'; + + const copyBtn = createButton('⎘ Copy', true, 'neutral'); + copyBtn.title = 'Copy filtered notices as text'; + const copyDefaultLabel = '⎘ Copy'; + + const aiBtn = createButton('✦ Ask AI', true, 'ai'); + aiBtn.title = 'Attach query and notices to SQL Assistant'; + if (!options?.onAskAssistant) { + aiBtn.style.display = 'none'; + } + aiBtn.addEventListener('click', () => { + options?.onAskAssistant?.(); + }); + + copyBtn.addEventListener('click', () => { + if (lastFiltered.length === 0) { + return; + } + const text = lastFiltered + .map((e) => { + const ts = formatNoticeLogTime(e.receivedAt); + const timePart = ts === '—' ? '' : `[${ts}] `; + return `${e.order}. ${timePart}${e.text}`; + }) + .join('\n\n'); + void navigator.clipboard.writeText(text).then( + () => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.textContent = copyDefaultLabel; + }, COPY_FEEDBACK_MS); + }, + (err: unknown) => { + console.error('[NoticesPanel] Copy to clipboard failed', err); + }, + ); + }); + + searchRow.appendChild(searchInput); + searchRow.appendChild(countBadge); + searchRow.appendChild(copyBtn); + searchRow.appendChild(aiBtn); + wrapper.appendChild(searchRow); + + const listRegion = document.createElement('div'); + listRegion.style.cssText = + 'flex:1;overflow:auto;padding:0;min-height:0;font-family:var(--vscode-editor-font-family);font-size:12px;'; + listRegion.setAttribute('role', 'region'); + listRegion.setAttribute('aria-label', 'Notices'); + + const renderList = (query: string) => { + listRegion.innerHTML = ''; + const filtered = filterNoticeEntries(messages, query); + const displayRows = newestFirstNoticeEntries(filtered); + lastFiltered = displayRows; + + const canCopy = filtered.length > 0; + (copyBtn as HTMLButtonElement).disabled = !canCopy; + copyBtn.title = canCopy ? 'Copy filtered notices as text' : 'Nothing to copy'; + + const canAskAi = Boolean(options?.onAskAssistant && messages.length > 0); + (aiBtn as HTMLButtonElement).disabled = !canAskAi; + aiBtn.title = canAskAi + ? 'Attach query and notices to SQL Assistant' + : 'No notices to send'; + + countBadge.textContent = + messages.length === 0 + ? '' + : filtered.length === messages.length + ? `${messages.length} notice${messages.length === 1 ? '' : 's'}` + : `${filtered.length} of ${messages.length}`; + + if (messages.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'padding:16px 12px;color:var(--vscode-descriptionForeground);'; + empty.textContent = + 'No notices for this execution. Use PL/pgSQL RAISE NOTICE to emit notices.'; + listRegion.appendChild(empty); + return; + } + + if (filtered.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = 'padding:16px 12px;color:var(--vscode-descriptionForeground);'; + empty.textContent = 'No notices match your search.'; + listRegion.appendChild(empty); + return; + } + + const ol = document.createElement('ol'); + ol.style.cssText = 'margin:0;padding:0;list-style:none;'; + + displayRows.forEach((entry) => { + const li = document.createElement('li'); + li.style.cssText = ` + display:flex; + gap:10px; + padding:8px 12px; + border-bottom:1px solid color-mix(in srgb, var(--vscode-panel-border) 60%, transparent); + align-items:flex-start; + `; + const idx = document.createElement('span'); + idx.textContent = String(entry.order); + idx.style.cssText = ` + flex-shrink:0; + min-width:2.5em; + font-variant-numeric:tabular-nums; + color:var(--vscode-descriptionForeground); + opacity:0.85; + `; + const timeEl = document.createElement('span'); + timeEl.textContent = formatNoticeLogTime(entry.receivedAt); + timeEl.style.cssText = ` + flex-shrink:0; + min-width:11.5em; + font-variant-numeric:tabular-nums; + color:color-mix(in srgb, var(--vscode-descriptionForeground) 88%, var(--vscode-editor-foreground)); + opacity:0.92; + `; + timeEl.title = entry.receivedAt || ''; + const body = document.createElement('div'); + body.textContent = entry.text; + body.style.cssText = 'white-space:pre-wrap;word-break:break-word;flex:1;min-width:0;'; + li.appendChild(idx); + li.appendChild(timeEl); + li.appendChild(body); + ol.appendChild(li); + }); + + listRegion.appendChild(ol); + }; + + searchInput.addEventListener('input', () => { + renderList(searchInput.value); + }); + + renderList(''); + wrapper.appendChild(listRegion); + + return wrapper; +} diff --git a/src/renderer/components/table/CellEditors.ts b/src/renderer/components/table/CellEditors.ts index cdae5b5..57acdd6 100644 --- a/src/renderer/components/table/CellEditors.ts +++ b/src/renderer/components/table/CellEditors.ts @@ -13,14 +13,120 @@ export interface CellEditorOptions { onCancel: () => void; onFkLookup?: (searchText: string, callback: (rows: any[], columns: string[]) => void) => void; isFkColumn?: boolean; + /** Ignored — kept for interface compat; inline editor finds its own mount point. */ + modalMount?: HTMLElement; + /** The table cell element — used to locate the output container for inline editor injection. */ + anchorEl?: HTMLElement; } export type EditorType = 'text' | 'number' | 'boolean' | 'date' | 'time' | 'datetime' | 'json' | 'array' | 'fk' | 'longtext'; +/** + * Types where a one-line input is a poor fit; use the same anchored modal as long-text / JSON (without JSON validation). + * Names are lowercase PostgreSQL typnames / common aliases (see pg_catalog / pg-types builtins). + */ +const PG_TYPES_WITH_MODAL_TEXT_EDITOR = new Set([ + 'text', + 'varchar', + 'character varying', + 'bpchar', + 'char', + 'character', + 'name', + 'xml', + 'interval', + 'point', + 'line', + 'lseg', + 'box', + 'path', + 'polygon', + 'circle', + 'bit', + 'varbit', + 'bit varying', + 'tsvector', + 'tsquery', + 'inet', + 'cidr', + 'macaddr', + 'macaddr8', + 'geometry', + 'geography', + 'bytea', + 'uuid', + 'money', + 'int4range', + 'int8range', + 'numrange', + 'daterange', + 'tsrange', + 'tstzrange', + 'int4multirange', + 'int8multirange', + 'nummultirange', + 'datemultirange', + 'tsmultirange', + 'tstzmultirange', +]); + +function pgTypeUsesModalTextEditor(columnType: string): boolean { + const t = columnType.trim().toLowerCase(); + if (!t) { + return false; + } + if (t.startsWith('oid:')) { + return true; + } + if (PG_TYPES_WITH_MODAL_TEXT_EDITOR.has(t)) { + return true; + } + if (/^(varchar|bpchar|char|bit|varbit|numeric|decimal)\s*\(/.test(t)) { + return true; + } + return false; +} + +/** String for inline inputs — never use String(object) (yields "[object Object]"). */ +function cellValueToEditString(val: any): string { + if (val === null || val === undefined) return ''; + // node-pg bytea → Buffer; JSON round-trip uses { type: "Buffer", data: [...] } + if (typeof val === 'object' && val !== null && (val as { type?: string }).type === 'Buffer' && Array.isArray((val as { data?: number[] }).data)) { + const bytes = new Uint8Array((val as { data: number[] }).data); + return '\\x' + Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + } + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(val)) { + return '\\x' + (val as Buffer).toString('hex'); + } + if (typeof val === 'object' && !(val instanceof Date)) { + try { + return JSON.stringify(val); + } catch { + return String(val); + } + } + return String(val); +} + +/** + * node-pg parses json/jsonb to JS objects; if OID mapping is missing or legacy "string" slipped through, + * still open the JSON editor when the cell value is structured data. + */ +function coercedColumnTypeForEditor(columnType: string, currentValue: any): string { + const t = (columnType || '').trim().toLowerCase(); + if (t === '' || t === 'string') { + if (currentValue !== null && typeof currentValue === 'object' && !(currentValue instanceof Date)) { + return 'jsonb'; + } + } + return columnType; +} + /** * Determine editor type from PostgreSQL type string */ export function getEditorType(columnType: string, currentValue: any): EditorType { + columnType = coercedColumnTypeForEditor(columnType, currentValue); const type = (columnType || '').toLowerCase(); // Array types start with underscore in pg OID naming @@ -29,15 +135,18 @@ export function getEditorType(columnType: string, currentValue: any): EditorType // Boolean if (type === 'bool' || type === 'boolean') { return 'boolean'; } - // Numeric - if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric', 'decimal', 'money', + // Numeric (money uses modal text — locale/formatting is easier in the expanded editor) + if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric', 'decimal', 'smallint', 'integer', 'bigint', 'real', 'double precision'].includes(type)) { return 'number'; } // Date/time if (type === 'date') { return 'date'; } - if (type === 'time' || type === 'timetz') { return 'time'; } + if (type === 'time' || type === 'timetz' || + type === 'time without time zone' || type === 'time with time zone') { + return 'time'; + } if (type === 'timestamp' || type === 'timestamptz' || type === 'timestamp without time zone' || type === 'timestamp with time zone') { return 'datetime'; @@ -46,7 +155,12 @@ export function getEditorType(columnType: string, currentValue: any): EditorType // JSON if (type === 'json' || type === 'jsonb') { return 'json'; } - // Long text detection + // XML, interval, geometry, full-width text types, etc. — anchored modal (same UX as long text) + if (pgTypeUsesModalTextEditor(type)) { + return 'longtext'; + } + + // Long text detection (unknown OID label or legacy "string" + very long value) if (typeof currentValue === 'string' && currentValue.length > 200) { return 'longtext'; } return 'text'; @@ -158,8 +272,10 @@ function createNumberEditor(opts: CellEditorOptions): HTMLElement { function createTextEditor(opts: CellEditorOptions): HTMLElement { const input = document.createElement('input'); input.type = 'text'; - input.value = opts.currentValue !== null && opts.currentValue !== undefined - ? String(opts.currentValue) : ''; + input.value = + opts.currentValue !== null && opts.currentValue !== undefined + ? cellValueToEditString(opts.currentValue) + : ''; applyEditorBaseStyle(input); const save = () => opts.onSave(input.value === '' && opts.isNullable ? null : input.value); @@ -171,14 +287,24 @@ function createTextEditor(opts: CellEditorOptions): HTMLElement { // ─── Long Text (modal overlay) ─────────────────────────────────────── +function modalPlainEditorTitle(opts: CellEditorOptions): string { + const t = (opts.columnType || '').trim(); + if (!t) { + return opts.columnName; + } + return `${opts.columnName} (${t})`; +} + function createLongTextEditor(opts: CellEditorOptions): HTMLElement { return createModalEditor({ - title: opts.columnName, - initialContent: opts.currentValue != null ? String(opts.currentValue) : '', + title: modalPlainEditorTitle(opts), + initialContent: opts.currentValue != null ? cellValueToEditString(opts.currentValue) : '', isCode: false, validate: () => null, onSave: opts.onSave, onCancel: opts.onCancel, + modalMount: opts.modalMount, + anchorEl: opts.anchorEl, }); } @@ -282,7 +408,7 @@ function createJsonEditor(opts: CellEditorOptions): HTMLElement { : opts.currentValue; formatted = JSON.stringify(parsed, null, 2); } catch { - formatted = opts.currentValue != null ? String(opts.currentValue) : ''; + formatted = opts.currentValue != null ? cellValueToEditString(opts.currentValue) : ''; } return createModalEditor({ @@ -298,6 +424,8 @@ function createJsonEditor(opts: CellEditorOptions): HTMLElement { catch { opts.onSave(content); } // fallback: save as string }, onCancel: opts.onCancel, + modalMount: opts.modalMount, + anchorEl: opts.anchorEl, }); } @@ -526,7 +654,12 @@ function createFkEditor(opts: CellEditorOptions): HTMLElement { return wrapper; } -// ─── Modal Editor (shared by JSON and LongText) ─────────────────────── +// ─── Inline Expanding Editor (replaces clipped fixed-position modal) ── +// +// Notebook output runs inside an iframe whose ancestors apply overflow:hidden. +// No fixed/absolute overlay can escape this clipping. Instead we inject an +// inline panel into the output's own DOM flow — it pushes the table down, +// stays fully visible, and scrolls into view automatically. interface ModalEditorOptions { title: string; @@ -535,10 +668,32 @@ interface ModalEditorOptions { validate: (content: string) => string | null; onSave: (content: string) => void; onCancel: () => void; + /** Ignored (kept for interface compat). */ + modalMount?: HTMLElement; + /** The cell — used to find the output container and scroll into view. */ + anchorEl?: HTMLElement; +} + +/** + * Walk up from `start` looking for the output-level container that the + * TableRenderer lives in (the `viewContainer` created in renderer_v2). + * Falls back to the closest scrollable ancestor or document.body. + */ +function findOutputContainer(start: HTMLElement): HTMLElement { + let el: HTMLElement | null = start; + while (el) { + if (el.style.position === 'relative' && el.style.overflow === 'hidden') { + return el.parentElement ?? el; + } + const tag = el.tagName.toLowerCase(); + if (tag === 'body' || tag === 'html') break; + el = el.parentElement; + } + return document.body; } function createModalEditor(opts: ModalEditorOptions): HTMLElement { - // The inline placeholder that triggers the modal + // The inline placeholder returned to the caller (sits inside the ) const placeholder = document.createElement('div'); placeholder.style.cssText = ` padding:2px 6px; @@ -557,34 +712,41 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { placeholder.textContent = preview || '(empty)'; placeholder.title = 'Click to open editor'; - // Create and show the modal immediately - const showModal = () => { - const overlay = document.createElement('div'); - overlay.style.cssText = ` - position:fixed;inset:0; - background:rgba(0,0,0,0.6); - z-index:10000; - display:flex;align-items:center;justify-content:center; - `; + const showEditor = () => { + const container = opts.anchorEl ? findOutputContainer(opts.anchorEl) : document.body; - const modal = document.createElement('div'); - modal.style.cssText = ` + // ── Wrapper: inline block in normal DOM flow ── + const wrapper = document.createElement('div'); + wrapper.setAttribute('data-inline-editor', 'true'); + wrapper.style.cssText = ` + position:relative; + z-index:100; + width:100%; + box-sizing:border-box; + padding:12px; + margin:0; background:var(--vscode-editor-background); - border:1px solid var(--vscode-focusBorder); + border:2px solid var(--vscode-focusBorder); border-radius:4px; - padding:16px; - width:600px;max-width:90vw; - display:flex;flex-direction:column;gap:10px; - box-shadow:0 8px 32px rgba(0,0,0,0.5); + box-shadow:0 4px 16px rgba(0,0,0,0.35); + display:flex; + flex-direction:column; + gap:8px; `; + // ── Title bar ── const titleBar = document.createElement('div'); - titleBar.style.cssText = 'display:flex;justify-content:space-between;align-items:center;'; - titleBar.innerHTML = ` - ${opts.title} - Ctrl+Enter to save • Escape to cancel - `; - + titleBar.style.cssText = 'display:flex;justify-content:space-between;align-items:center;gap:8px;flex-shrink:0;'; + const titleMain = document.createElement('span'); + titleMain.style.cssText = 'font-size:13px;font-weight:600;color:var(--vscode-editor-foreground);'; + titleMain.textContent = opts.title; + const titleHint = document.createElement('span'); + titleHint.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);white-space:nowrap;'; + titleHint.textContent = 'Ctrl+Enter to save · Escape to cancel'; + titleBar.appendChild(titleMain); + titleBar.appendChild(titleHint); + + // ── Textarea ── const textarea = document.createElement('textarea'); textarea.value = opts.initialContent; textarea.style.cssText = ` @@ -596,21 +758,23 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { font-family:var(--vscode-editor-font-family,monospace); font-size:12px; resize:vertical; - min-height:200px; - max-height:60vh; + min-height:120px; + max-height:300px; outline:none; width:100%; box-sizing:border-box; `; + // ── Error display ── const errorDiv = document.createElement('div'); - errorDiv.style.cssText = 'color:var(--vscode-errorForeground);font-size:11px;min-height:16px;'; + errorDiv.style.cssText = 'color:var(--vscode-errorForeground);font-size:11px;min-height:14px;flex-shrink:0;'; + // ── Button row ── const btnRow = document.createElement('div'); - btnRow.style.cssText = 'display:flex;gap:6px;justify-content:flex-end;'; + btnRow.style.cssText = 'display:flex;gap:6px;justify-content:flex-end;flex-shrink:0;'; - const formatBtn = opts.isCode ? document.createElement('button') : null; - if (formatBtn) { + if (opts.isCode) { + const formatBtn = document.createElement('button'); formatBtn.textContent = 'Format JSON'; formatBtn.style.cssText = ` background:none;border:1px solid var(--vscode-button-border,#555); @@ -643,41 +807,68 @@ function createModalEditor(opts: ModalEditorOptions): HTMLElement { padding:4px 10px;cursor:pointer;font-size:12px; `; + btnRow.appendChild(saveBtn); + btnRow.appendChild(cancelBtn); + + // ── Lifecycle ── + const keyboardTrapAbort = new AbortController(); + const { signal } = keyboardTrapAbort; + + const stopKeysEscaping = (e: Event) => { e.stopPropagation(); }; + + const teardown = () => { + keyboardTrapAbort.abort(); + if (wrapper.parentNode) { + wrapper.parentNode.removeChild(wrapper); + } + }; + const doSave = () => { const err = opts.validate(textarea.value); if (err) { errorDiv.textContent = err; return; } - document.body.removeChild(overlay); + teardown(); opts.onSave(textarea.value); }; const doCancel = () => { - document.body.removeChild(overlay); + teardown(); opts.onCancel(); }; - textarea.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && e.ctrlKey) { doSave(); } - if (e.key === 'Escape') { doCancel(); } - }); + // Keyboard trap: stop host keybindings from stealing focus + wrapper.addEventListener( + 'keydown', + (e: KeyboardEvent) => { + if (e.key === 'Enter' && e.ctrlKey) { + e.preventDefault(); e.stopPropagation(); doSave(); return; + } + if (e.key === 'Escape') { + e.preventDefault(); e.stopPropagation(); doCancel(); return; + } + e.stopPropagation(); + }, + { signal }, + ); + wrapper.addEventListener('keyup', stopKeysEscaping, { signal }); + wrapper.addEventListener('beforeinput', stopKeysEscaping, { signal }); + wrapper.addEventListener('compositionend', stopKeysEscaping, { signal }); saveBtn.addEventListener('click', doSave); cancelBtn.addEventListener('click', doCancel); - overlay.addEventListener('click', (e) => { if (e.target === overlay) { doCancel(); } }); - btnRow.appendChild(saveBtn); - btnRow.appendChild(cancelBtn); + // ── Assemble & mount ── + wrapper.appendChild(titleBar); + wrapper.appendChild(textarea); + wrapper.appendChild(errorDiv); + wrapper.appendChild(btnRow); - modal.appendChild(titleBar); - modal.appendChild(textarea); - modal.appendChild(errorDiv); - modal.appendChild(btnRow); - overlay.appendChild(modal); - document.body.appendChild(overlay); + // Insert at the top of the output container (above the table) so the + // editor is always visible and never hidden beneath scrolled-away rows. + container.insertBefore(wrapper, container.firstChild); + wrapper.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); setTimeout(() => { textarea.focus(); textarea.setSelectionRange(0, 0); }, 0); }; - // Show immediately since we're creating this in response to a user action - showModal(); - + showEditor(); return placeholder; } diff --git a/src/renderer/components/table/TableRenderer.ts b/src/renderer/components/table/TableRenderer.ts index 28122fe..3211f32 100644 --- a/src/renderer/components/table/TableRenderer.ts +++ b/src/renderer/components/table/TableRenderer.ts @@ -72,7 +72,9 @@ export class TableRenderer { /** Below this many combined data + pending rows, use chunk + IntersectionObserver (legacy). */ private static readonly VIRTUAL_ROW_THRESHOLD = 100; - /** Extra rows above/below viewport as a multiple of visible rows (2× viewport total buffer). */ + /** Minimum rows to keep in the virtual window (avoids tiny first paint when clientHeight is still small). */ + private static readonly VIRTUAL_MIN_VIEWPORT_ROWS = 50; + /** Extra rows above/below viewport as a multiple of measured viewport rows (2× viewport buffer). */ private static readonly VIRTUAL_VIEWPORT_BUFFER_MULTIPLIER = 2; private static readonly DEFAULT_DATA_ROW_HEIGHT_PX = 30; private static readonly DEFAULT_PENDING_ROW_HEIGHT_PX = 40; @@ -105,6 +107,9 @@ export class TableRenderer { this.mainContainer.appendChild(this.tableContainer); } + // Remove any leftover inline editor panels from previous edits + this.cleanupInlineEditors(); + this.columns = options.columns; this.rows = options.rows; this.originalRows = options.originalRows; @@ -814,18 +819,20 @@ export class TableRenderer { // DOUBLE-CLICK to edit (not single-click) td.addEventListener('dblclick', (e) => { e.stopPropagation(); - this.handleCellEdit(e, td, sourceIndex, col, type); + // Pass PostgreSQL type for editors — not formatValue()'s semantic type (e.g. "object"). + this.handleCellEdit(e, td, sourceIndex, col); }); } + this.applyCellStyle(td, sourceIndex, displayIndex, 'data'); + const cellKey = `${sourceIndex}-${col}`; if (this.modifiedCells.has(cellKey)) { td.style.backgroundColor = 'rgba(245,158,11,0.15)'; td.style.borderLeft = '3px solid #f59e0b'; + td.title = 'Unsaved edit — double-click to edit'; } - this.applyCellStyle(td, sourceIndex, displayIndex, 'data'); - if (val === null || val === undefined) { const nullSpan = document.createElement('span'); nullSpan.textContent = 'NULL'; @@ -884,13 +891,7 @@ export class TableRenderer { }); } - private handleCellEdit( - e: MouseEvent, - td: HTMLElement, - sourceIndex: number, - col: string, - type: string, - ) { + private handleCellEdit(e: MouseEvent, td: HTMLElement, sourceIndex: number, col: string) { if (this.currentlyEditingCell === td) return; // Blur any existing editor @@ -900,9 +901,10 @@ export class TableRenderer { } this.currentlyEditingCell = td; + td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); const currentValue = this.rows[sourceIndex]?.[col]; const originalValue = this.originalRows[sourceIndex]?.[col] ?? currentValue; - const colType = this.columnTypes[col] || type; + const colType = this.columnTypes[col] ?? ''; const cellKey = `${sourceIndex}-${col}`; // Find FK info for this column @@ -953,6 +955,7 @@ export class TableRenderer { onFkLookup, onSave, onCancel, + anchorEl: td, }); td.appendChild(editorEl); @@ -1078,9 +1081,13 @@ export class TableRenderer { totalDisplay - 1, Math.max(0, Math.floor(scrollPastPending / rowH)), ); - const visibleCount = Math.max(1, Math.ceil(clientH / rowH)); + const viewportRows = Math.max(1, Math.ceil(clientH / rowH)); + const visibleCount = Math.max( + TableRenderer.VIRTUAL_MIN_VIEWPORT_ROWS, + viewportRows, + ); const bufferRows = Math.ceil( - visibleCount * TableRenderer.VIRTUAL_VIEWPORT_BUFFER_MULTIPLIER, + viewportRows * TableRenderer.VIRTUAL_VIEWPORT_BUFFER_MULTIPLIER, ); const start = Math.max(0, firstIdx - bufferRows); const end = Math.min(totalDisplay, firstIdx + visibleCount + bufferRows); @@ -1122,6 +1129,15 @@ export class TableRenderer { } } + /** Remove inline editor panels injected by the cell editor into ancestor containers. */ + private cleanupInlineEditors() { + let el: HTMLElement | null = this.mainContainer; + while (el && el !== document.body) { + el.querySelectorAll('[data-inline-editor]').forEach((e) => e.remove()); + el = el.parentElement; + } + } + private rerenderTable() { this.render({ columns: this.columns, diff --git a/src/services/DdlViewerService.ts b/src/services/DdlViewerService.ts index 7425401..f7bba93 100644 --- a/src/services/DdlViewerService.ts +++ b/src/services/DdlViewerService.ts @@ -36,6 +36,32 @@ type DdlObjectType = | 'policy' | 'placeholder'; +/** Tree item kinds that map to real DDL in `toTarget` (not placeholder). */ +const DDL_PREVIEW_TREE_TYPES = new Set([ + 'database', + 'schema', + 'table', + 'column', + 'view', + 'rule', + 'function', + 'procedure', + 'constraint', + 'index', + 'materialized-view', + 'sequence', + 'type', + 'domain', + 'trigger', + 'extension', + 'role', + 'foreign-table', + 'foreign-data-wrapper', + 'foreign-server', + 'partition', + 'policy' +]); + interface DdlViewerTarget { connectionId: string; databaseName: string; @@ -1535,6 +1561,25 @@ export class DdlViewerService implements vscode.Disposable { vscode.window.showInformationMessage('Select an object in Database Explorer to view its definition.'); return; } + const target = this.toTarget(selected); + const uri = this.createUri(target); + if (isDdlViewerEnabled()) { + const existingTabs = this.collectTabsForDdlUri(uri); + if (existingTabs.length > 0) { + await vscode.window.tabGroups.close(existingTabs); + await vscode.workspace.getConfiguration().update(DDL_VIEWER_ENABLED_CONFIG, false, vscode.ConfigurationTarget.Global); + this.codeLensProvider.refresh(); + this.refreshOpenPreviewDocuments(); + this.lastPreviewTarget = undefined; + vscode.window.showInformationMessage('SQL Preview disabled.'); + return; + } + } + if (!isDdlViewerEnabled()) { + await vscode.workspace.getConfiguration().update(DDL_VIEWER_ENABLED_CONFIG, true, vscode.ConfigurationTarget.Global); + this.codeLensProvider.refresh(); + this.refreshOpenPreviewDocuments(); + } await this.openForItem(selected, false); }), vscode.commands.registerCommand('postgres-explorer.ddlViewer.openEditableCopy', async (uri?: vscode.Uri) => { @@ -1559,14 +1604,35 @@ export class DdlViewerService implements vscode.Disposable { vscode.commands.registerCommand('postgres-explorer.ddlViewer.toggleEnabled', async (forceState?: boolean) => { const current = isDdlViewerEnabled(); const nextState = typeof forceState === 'boolean' ? forceState : !current; + if (!nextState) { + const ddlTabs = this.collectAllDdlViewerTabs(); + if (ddlTabs.length > 0) { + await vscode.window.tabGroups.close(ddlTabs); + } + this.lastPreviewTarget = undefined; + } await vscode.workspace.getConfiguration().update(DDL_VIEWER_ENABLED_CONFIG, nextState, vscode.ConfigurationTarget.Global); this.codeLensProvider.refresh(); this.refreshOpenPreviewDocuments(); + if (nextState) { + const selected = this.treeView.selection[0]; + if (selected && this.treeItemHasDdlPreview(selected)) { + await this.openForItem(selected, false); + } + } vscode.window.showInformationMessage(`SQL Preview ${nextState ? 'enabled' : 'disabled'}.`); }) ); } + /** Whether the explorer item maps to generated DDL (not a folder/category placeholder). */ + private treeItemHasDdlPreview(item: DatabaseTreeItem): boolean { + if (!item.connectionId || !item.databaseName) { + return false; + } + return DDL_PREVIEW_TREE_TYPES.has(item.type as DdlObjectType); + } + public async openForItem(item: DatabaseTreeItem, fromSelection: boolean): Promise { if (!isDdlViewerEnabled()) { if (!fromSelection) { @@ -1587,8 +1653,9 @@ export class DdlViewerService implements vscode.Disposable { await vscode.languages.setTextDocumentLanguage(doc, 'sql'); } + const alreadyOpen = this.collectTabsForDdlUri(uri).length > 0; await vscode.window.showTextDocument(doc, { - viewColumn: vscode.ViewColumn.Beside, + viewColumn: alreadyOpen ? undefined : vscode.ViewColumn.Beside, preserveFocus: fromSelection, preview: fromSelection }); @@ -1605,6 +1672,31 @@ export class DdlViewerService implements vscode.Disposable { this.provider.refreshAllOpenDdlDocuments(); } + private collectTabsForDdlUri(uri: vscode.Uri): vscode.Tab[] { + const want = uri.toString(); + const out: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === want) { + out.push(tab); + } + } + } + return out; + } + + private collectAllDdlViewerTabs(): vscode.Tab[] { + const out: vscode.Tab[] = []; + for (const group of vscode.window.tabGroups.all) { + for (const tab of group.tabs) { + if (tab.input instanceof vscode.TabInputText && tab.input.uri.scheme === DDL_VIEWER_SCHEME) { + out.push(tab); + } + } + } + return out; + } + private resolveDdlUri(uri?: vscode.Uri): vscode.Uri | undefined { if (uri?.scheme === DDL_VIEWER_SCHEME) { return uri; @@ -1633,32 +1725,7 @@ export class DdlViewerService implements vscode.Disposable { objectName: item.label }; - const supportedTypes = new Set([ - 'database', - 'schema', - 'table', - 'column', - 'view', - 'rule', - 'function', - 'procedure', - 'constraint', - 'index', - 'materialized-view', - 'sequence', - 'type', - 'domain', - 'trigger', - 'extension', - 'role', - 'foreign-table', - 'foreign-data-wrapper', - 'foreign-server', - 'partition', - 'policy' - ]); - - if (!supportedTypes.has(item.type as DdlObjectType)) { + if (!DDL_PREVIEW_TREE_TYPES.has(item.type as DdlObjectType)) { return unsupportedTarget; } diff --git a/src/test/unit/CellEditors.test.ts b/src/test/unit/CellEditors.test.ts new file mode 100644 index 0000000..8a8b4cb --- /dev/null +++ b/src/test/unit/CellEditors.test.ts @@ -0,0 +1,32 @@ +import { expect } from 'chai'; +import { getEditorType } from '../../renderer/components/table/CellEditors'; + +describe('getEditorType', () => { + const cases: { type: string; value: unknown; expected: ReturnType }[] = [ + { type: 'jsonb', value: {}, expected: 'json' }, + { type: 'varchar', value: 'hi', expected: 'longtext' }, + { type: 'text', value: '', expected: 'longtext' }, + { type: 'xml', value: '', expected: 'longtext' }, + { type: 'interval', value: '1 day', expected: 'longtext' }, + { type: 'path', value: '[(0,0),(1,1)]', expected: 'longtext' }, + { type: 'polygon', value: '((0,0),(1,1),(1,0))', expected: 'longtext' }, + { type: 'box', value: '(1,1),(0,0)', expected: 'longtext' }, + { type: 'line', value: '{1,2,3}', expected: 'longtext' }, + { type: 'varchar(64)', value: 'x', expected: 'longtext' }, + { type: 'numeric(10,2)', value: '1.00', expected: 'longtext' }, + { type: 'int4range', value: '[1,10)', expected: 'longtext' }, + { type: 'bytea', value: '\\xdead', expected: 'longtext' }, + { type: 'uuid', value: '00000000-0000-0000-0000-000000000000', expected: 'longtext' }, + { type: 'money', value: '12.34', expected: 'longtext' }, + { type: 'oid:12345', value: 'enum_val', expected: 'longtext' }, + { type: 'int4', value: 1, expected: 'number' }, + { type: 'unknown', value: 'x'.repeat(201), expected: 'longtext' }, + { type: '', value: 'short', expected: 'text' }, + ]; + + cases.forEach(({ type, value, expected }) => { + it(`maps ${JSON.stringify(type)} to ${expected}`, () => { + expect(getEditorType(type, value)).to.equal(expected); + }); + }); +}); diff --git a/src/test/unit/ChatViewProvider.test.ts b/src/test/unit/ChatViewProvider.test.ts index ac62188..6aa69b4 100644 --- a/src/test/unit/ChatViewProvider.test.ts +++ b/src/test/unit/ChatViewProvider.test.ts @@ -401,5 +401,25 @@ describe('ChatViewProvider', () => { content: '{not valid json' } })).to.be.true; + + const noticesSendPromise = provider.sendToChat({ + query: 'DO $$ BEGIN RAISE NOTICE \'a\'; RAISE NOTICE \'b\'; END $$;', + message: 'Notices help', + notices: [ + { message: 'first notice', receivedAt: '2020-01-01T00:00:00.000Z' }, + { message: 'second notice', receivedAt: '2020-01-01T00:00:01.000Z' }, + ], + }); + await clock.tickAsync(300); + await noticesSendPromise; + + expect(postMessage.calledWithMatch({ + type: 'fileAttached', + file: { + type: 'txt', + content: + '1. [2020-01-01T00:00:00.000Z] first notice\n\n2. [2020-01-01T00:00:01.000Z] second notice', + }, + })).to.be.true; }); }); \ No newline at end of file diff --git a/src/test/unit/CommandHelper.test.ts b/src/test/unit/CommandHelper.test.ts index 3ab9966..77b5568 100644 --- a/src/test/unit/CommandHelper.test.ts +++ b/src/test/unit/CommandHelper.test.ts @@ -14,6 +14,7 @@ import { getDatabaseConnection, validateCategoryItem, validateItem, + validateNotebookContextItem, validateRoleItem, } from '../../commands/helper'; import { ConnectionManager } from '../../services/ConnectionManager'; @@ -39,6 +40,10 @@ describe('CommandHelper utilities', () => { expect(() => validateItem({ connectionId: 'c1', schema: 'public' } as any)).not.to.throw(); expect(() => validateCategoryItem({ connectionId: 'c1' } as any)).not.to.throw(); expect(() => validateRoleItem({ connectionId: 'c1' } as any)).not.to.throw(); + + expect(() => validateNotebookContextItem({} as any)).to.throw('Invalid selection'); + expect(() => validateNotebookContextItem({ connectionId: 'c1' } as any)).to.throw('Invalid selection'); + expect(() => validateNotebookContextItem({ connectionId: 'c1', databaseName: 'db1' } as any)).not.to.throw(); }); it('covers markdown, formatting, validation, and string helpers', () => { diff --git a/src/test/unit/NotebookCommands.test.ts b/src/test/unit/NotebookCommands.test.ts index 8b13541..83ff67b 100644 --- a/src/test/unit/NotebookCommands.test.ts +++ b/src/test/unit/NotebookCommands.test.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import * as helper from '../../commands/helper'; import * as notebookCommands from '../../commands/notebook'; import { NotebookBuilder } from '../../commands/helper'; +import { ConnectionUtils } from '../../utils/connectionUtils'; function createBuilderStub(sandbox: sinon.SinonSandbox) { const builder: any = {}; @@ -58,7 +59,7 @@ describe('notebook commands', () => { release } as any); - const item = { connectionId: 'c1', schema: 'public', label: 'users' } as any; + const item = { connectionId: 'c1', databaseName: 'appdb', schema: 'public', label: 'users' } as any; await notebookCommands.cmdNewNotebook(item); expect(builder.addMarkdown.calledOnce).to.be.true; @@ -67,6 +68,28 @@ describe('notebook commands', () => { expect(release.calledOnce).to.be.true; }); + it('prompts for connection and database when invoked without tree context', async () => { + const builder = createBuilderStub(sandbox); + const release = sandbox.stub(); + const getDb = sandbox.stub(helper, 'getDatabaseConnection').resolves({ + metadata: { connectionId: 'c1', databaseName: 'appdb' }, + release + } as any); + sandbox.stub(ConnectionUtils, 'showConnectionPicker').resolves({ id: 'c1', name: 'Local', host: 'h', port: 5432 }); + sandbox.stub(ConnectionUtils, 'showDatabasePicker').resolves('appdb'); + + await notebookCommands.cmdNewNotebook(undefined as any); + + expect(ConnectionUtils.showConnectionPicker.calledOnce).to.be.true; + expect(ConnectionUtils.showDatabasePicker.calledOnce).to.be.true; + expect(getDb.calledOnce).to.be.true; + const firstArg = getDb.firstCall.args[0]; + expect(firstArg.connectionId).to.equal('c1'); + expect(firstArg.databaseName).to.equal('appdb'); + expect(builder.showNew.calledOnce).to.be.true; + expect(release.calledOnce).to.be.true; + }); + it('jumps to headings in the active notebook and handles empty states', async () => { const showInfoStub = sandbox.stub(vscode.window, 'showInformationMessage').resolves(undefined); const showQuickPickStub = sandbox.stub(vscode.window, 'showQuickPick'); diff --git a/src/test/unit/NoticesPanel.test.ts b/src/test/unit/NoticesPanel.test.ts new file mode 100644 index 0000000..28785c7 --- /dev/null +++ b/src/test/unit/NoticesPanel.test.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import type { NoticeLogEntry } from '../../common/types'; +import { filterNoticeEntries } from '../../renderer/components/notices/NoticesPanel'; + +const n = (message: string, receivedAt = ''): NoticeLogEntry => ({ message, receivedAt }); + +describe('filterNoticeEntries', () => { + const cases: { + name: string; + messages: NoticeLogEntry[]; + query: string; + want: { order: number; text: string; receivedAt: string }[]; + }[] = [ + { + name: 'empty query returns all in order', + messages: [n('alpha'), n('beta')], + query: '', + want: [ + { order: 1, text: 'alpha', receivedAt: '' }, + { order: 2, text: 'beta', receivedAt: '' }, + ], + }, + { + name: 'case-insensitive substring', + messages: [n('Hello World'), n('no match')], + query: 'world', + want: [{ order: 1, text: 'Hello World', receivedAt: '' }], + }, + { + name: 'whitespace-only query acts as no filter', + messages: [n('x')], + query: ' ', + want: [{ order: 1, text: 'x', receivedAt: '' }], + }, + { + name: 'preserves original indices when filtered', + messages: [n('a'), n('b'), n('alpha')], + query: 'a', + want: [ + { order: 1, text: 'a', receivedAt: '' }, + { order: 3, text: 'alpha', receivedAt: '' }, + ], + }, + ]; + + cases.forEach(({ name, messages, query, want }) => { + it(name, () => { + expect(filterNoticeEntries(messages, query)).to.deep.equal(want); + }); + }); +}); diff --git a/src/test/unit/cloudAuth.test.ts b/src/test/unit/cloudAuth.test.ts new file mode 100644 index 0000000..5e7c8e0 --- /dev/null +++ b/src/test/unit/cloudAuth.test.ts @@ -0,0 +1,17 @@ +import { expect } from 'chai'; +import { parseCloudAuth } from '../../core/connection/cloudAuth'; + +describe('parseCloudAuth', () => { + it('returns none for undefined and invalid', () => { + expect(parseCloudAuth(undefined).kind).to.equal('none'); + expect(parseCloudAuth(null).kind).to.equal('none'); + expect(parseCloudAuth({}).kind).to.equal('none'); + expect(parseCloudAuth({ kind: 'other' }).kind).to.equal('none'); + }); + + it('accepts known IAM kinds', () => { + expect(parseCloudAuth({ kind: 'aws-iam' }).kind).to.equal('aws-iam'); + expect(parseCloudAuth({ kind: 'azure-ad' }).kind).to.equal('azure-ad'); + expect(parseCloudAuth({ kind: 'gcp-iam' }).kind).to.equal('gcp-iam'); + }); +}); diff --git a/src/test/unit/pgDataTypeNames.test.ts b/src/test/unit/pgDataTypeNames.test.ts new file mode 100644 index 0000000..1b8e19f --- /dev/null +++ b/src/test/unit/pgDataTypeNames.test.ts @@ -0,0 +1,14 @@ +import { expect } from 'chai'; +import * as pgTypes from 'pg-types'; +import { getPgDataTypeName } from '../../common/pgDataTypeNames'; + +describe('getPgDataTypeName', () => { + it('maps builtin OIDs to pg typnames (json / jsonb)', () => { + expect(getPgDataTypeName(pgTypes.builtins.JSON)).to.equal('json'); + expect(getPgDataTypeName(pgTypes.builtins.JSONB)).to.equal('jsonb'); + }); + + it('returns oid: for unknown types instead of a misleading generic label', () => { + expect(getPgDataTypeName(999_001)).to.equal('oid:999001'); + }); +}); diff --git a/src/ui/renderer/renderer_v2.ts b/src/ui/renderer/renderer_v2.ts index 05a5b31..eea54f0 100644 --- a/src/ui/renderer/renderer_v2.ts +++ b/src/ui/renderer/renderer_v2.ts @@ -23,7 +23,14 @@ import { } from '../../renderer/components/ResultTabStrip'; import { renderTransposeTable } from '../../renderer/components/TransposeView'; import { renderAnalystPanel } from '../../renderer/components/analyst/AnalystPanel'; +import type { NoticeLogEntry } from '../../common/types'; +import { + normalizeNoticesPayload, + renderNoticesLiveStream, + renderNoticesPanel, +} from '../../renderer/components/notices/NoticesPanel'; import { BRAND_ACCENT, BRAND_ACCENT_MUTED, SPINNER_FRAMES } from './rendererConstants'; +import { prefersReducedMotion } from '../theme/motion'; // Register Chart.js components Chart.register(...registerables); @@ -34,6 +41,7 @@ const tableInstances = new WeakMap(); /** * Puts a button into a loading state with an animated braille spinner. + * When `prefers-reduced-motion` is set, uses a static label instead of animation. * Returns a cleanup function that restores the original label and re-enables the button. */ function startButtonLoading(btn: HTMLElement, loadingLabel: string): () => void { @@ -43,6 +51,18 @@ function startButtonLoading(btn: HTMLElement, loadingLabel: string): () => void btn.style.opacity = '0.7'; btn.style.cursor = 'not-allowed'; + const restore = () => { + btn.innerText = originalText; + (btn as HTMLButtonElement).disabled = originalDisabled; + btn.style.opacity = ''; + btn.style.cursor = ''; + }; + + if (prefersReducedMotion()) { + btn.innerText = `… ${loadingLabel}`; + return restore; + } + let frame = 0; btn.innerText = `${SPINNER_FRAMES[frame]} ${loadingLabel}`; const interval = setInterval(() => { @@ -52,10 +72,7 @@ function startButtonLoading(btn: HTMLElement, loadingLabel: string): () => void return () => { clearInterval(interval); - btn.innerText = originalText; - (btn as HTMLButtonElement).disabled = originalDisabled; - btn.style.opacity = ''; - btn.style.cursor = ''; + restore(); }; } @@ -88,6 +105,13 @@ export const activate: ActivationFunction = (context) => { return; } + if (data.mime === 'application/vnd.postgres-notebook.notices-live') { + const live = data.json() as { notices?: NoticeLogEntry[] }; + const entries = Array.isArray(live?.notices) ? live.notices : []; + element.replaceChildren(renderNoticesLiveStream(entries)); + return; + } + const json = data.json(); if (!json) { @@ -112,6 +136,8 @@ export const activate: ActivationFunction = (context) => { autoLimitValue, } = json; + const noticeItems = normalizeNoticesPayload(notices); + // Transaction state from payload const transactionState: { isActive: boolean; statementCount: number } | undefined = json.transactionState; @@ -137,6 +163,7 @@ export const activate: ActivationFunction = (context) => { rowCount, executionTime, query, + notices: noticeItems.length ? [...noticeItems] : undefined, timestamp: Date.now(), }; const resultHistory = addResultToHistory(element, historyEntry); @@ -169,7 +196,10 @@ export const activate: ActivationFunction = (context) => { const chevron = document.createElement('span'); chevron.textContent = '▼'; - chevron.style.cssText = 'font-size: 10px; transition: transform 0.2s; display: inline-block;'; + const chevronBase = 'font-size: 10px; display: inline-block;'; + chevron.style.cssText = prefersReducedMotion() + ? chevronBase + : `${chevronBase} transition: transform 0.2s;`; const title = document.createElement('span'); title.textContent = command || 'QUERY'; @@ -182,8 +212,10 @@ export const activate: ActivationFunction = (context) => { let summaryText = ''; if (rowCount !== undefined && rowCount !== null) summaryText += `${rowCount} rows`; - if (notices?.length) - summaryText += summaryText ? `, ${notices.length} messages` : `${notices.length} messages`; + if (noticeItems.length) + summaryText += summaryText + ? `, ${noticeItems.length} notices` + : `${noticeItems.length} notices`; if (executionTime !== undefined) summaryText += summaryText ? `, ${executionTime.toFixed(3)}s` @@ -369,28 +401,6 @@ export const activate: ActivationFunction = (context) => { contentContainer.appendChild(errorPanel); } - // Messages Section - if (notices?.length) { - const msgContainer = document.createElement('div'); - msgContainer.style.cssText = ` - padding: 8px 12px; background: var(--vscode-textBlockQuote-background); - border-left: 4px solid var(--vscode-textBlockQuote-border); margin: 8px 12px 0 12px; - font-family: var(--vscode-editor-font-family); white-space: pre-wrap; font-size: 12px; - `; - const msgTitle = document.createElement('div'); - msgTitle.textContent = 'Messages'; - msgTitle.style.cssText = 'font-weight: 600; margin-bottom: 4px; opacity: 0.8;'; - msgContainer.appendChild(msgTitle); - - notices.forEach((msg: string) => { - const d = document.createElement('div'); - d.textContent = msg; - d.style.marginBottom = '2px'; - msgContainer.appendChild(d); - }); - contentContainer.appendChild(msgContainer); - } - // Build the hidden export button to reuse its existing dropdown flow const exportBtn = createExportButton(columns, currentRows, tableInfo, context, query); exportBtn.style.display = 'none'; @@ -864,6 +874,10 @@ export const activate: ActivationFunction = (context) => { const chartTab = createTab('Chart', 'chart', false, () => switchTab('chart')); const analystTab = createTab('Analyst', 'analyst', false, () => switchTab('analyst')); + const noticesTabLabel = + noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; + const noticesTab = createTab(noticesTabLabel, 'notices', false, () => switchTab('notices')); + let explainTab: HTMLElement | null = null; if (json.explainPlan) { explainTab = createTab('Explain Plan', 'explain', false, () => switchTab('explain')); @@ -876,6 +890,7 @@ export const activate: ActivationFunction = (context) => { tabs.appendChild(tableTab); tabs.appendChild(chartTab); tabs.appendChild(analystTab); + tabs.appendChild(noticesTab); if (explainTab) tabs.appendChild(explainTab); tabs.appendChild(transposeTab); if (!json.error) { @@ -1007,8 +1022,8 @@ export const activate: ActivationFunction = (context) => { let currentMode = 'table'; const allTabs = () => explainTab - ? [tableTab, chartTab, analystTab, explainTab, transposeTab] - : [tableTab, chartTab, analystTab, transposeTab]; + ? [tableTab, chartTab, analystTab, noticesTab, explainTab, transposeTab] + : [tableTab, chartTab, analystTab, noticesTab, transposeTab]; const setActiveTab = (activeTab: HTMLElement) => { allTabs().forEach((t) => { t.style.borderBottom = '2px solid transparent'; @@ -1035,6 +1050,24 @@ export const activate: ActivationFunction = (context) => { initialSelectedIndices: selectedIndices, modifiedCells, }); + } else if (mode === 'notices') { + setActiveTab(noticesTab); + updateActionsVisibility(); + viewContainer.appendChild( + renderNoticesPanel(noticeItems, { + onAskAssistant: () => { + context.postMessage?.({ + type: 'sendToChat', + data: { + query: query || '', + message: + 'I ran this query and received the following PostgreSQL notices (RAISE NOTICE / server messages). Please help me interpret them or suggest improvements.', + notices: noticeItems, + }, + }); + }, + }), + ); } else if (mode === 'transpose') { setActiveTab(transposeTab); updateActionsVisibility(); @@ -1113,9 +1146,17 @@ export const activate: ActivationFunction = (context) => { // Initial Render if (columns.length > 0) { switchTab('table'); + } else if (noticeItems.length > 0) { + switchTab('notices'); } else { - if (rowCount === 0) - mainContainer.innerHTML += '
Query returned no data
'; + const filler = document.createElement('div'); + filler.style.cssText = + 'padding:12px;color:var(--vscode-descriptionForeground);font-size:12px;'; + filler.textContent = + (rowCount ?? 0) === 0 && (currentRows?.length ?? 0) === 0 + ? 'Query returned no data' + : 'Unable to display this result (no column metadata). Re-run the query after updating the extension.'; + viewContainer.appendChild(filler); } // Result history tab strip — rendered above mainContainer when >1 result exists diff --git a/src/utils/connectionUtils.ts b/src/utils/connectionUtils.ts index 1d6a306..f42e670 100644 --- a/src/utils/connectionUtils.ts +++ b/src/utils/connectionUtils.ts @@ -64,7 +64,10 @@ export class ConnectionUtils { } /** Show connection quick pick and return selected connection */ - static async showConnectionPicker(currentConnectionId?: string): Promise { + static async showConnectionPicker( + currentConnectionId?: string, + quickPick?: { title?: string; placeHolder?: string } + ): Promise { const connections = this.getConnections(); if (connections.length === 0) { @@ -80,15 +83,19 @@ export class ConnectionUtils { })); const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select connection', - title: 'Switch Database Connection' + placeHolder: quickPick?.placeHolder ?? 'Select connection', + title: quickPick?.title ?? 'Switch Database Connection' }); return selected?.connection; } /** Show database quick pick and return selected database name */ - static async showDatabasePicker(connection: any, currentDatabase?: string): Promise { + static async showDatabasePicker( + connection: any, + currentDatabase?: string, + quickPick?: { title?: string; placeHolder?: string } + ): Promise { try { const databases = await this.listDatabases(connection); @@ -99,8 +106,8 @@ export class ConnectionUtils { })); const selected = await vscode.window.showQuickPick(items, { - placeHolder: 'Select database', - title: 'Switch Database' + placeHolder: quickPick?.placeHolder ?? 'Select database', + title: quickPick?.title ?? 'Switch Database' }); return selected?.database; diff --git a/templates/connection-form/index.html b/templates/connection-form/index.html index bad6f5c..f6658d7 100644 --- a/templates/connection-form/index.html +++ b/templates/connection-form/index.html @@ -82,6 +82,17 @@

{{HEADER_TITLE}}

Stored securely in VS Code secret storage
+ +
+ + Tag for AWS RDS IAM, Entra ID, or GCP IAM. PgStudio still uses password or pgpass; token-based IAM is planned. + +
diff --git a/templates/connection-form/scripts.js b/templates/connection-form/scripts.js index ab64274..b3556d8 100644 --- a/templates/connection-form/scripts.js +++ b/templates/connection-form/scripts.js @@ -12,7 +12,7 @@ const testBtn = document.getElementById('testConnection'); const addBtn = document.getElementById('addConnection'); const addBtnLabel = addBtn.querySelector('span:last-child').textContent; const form = document.getElementById('connectionForm'); -const inputs = form.querySelectorAll('input'); +const inputs = form.querySelectorAll('input, select'); // Injected connection data (replaced at runtime by the extension) const connectionData = {{ CONNECTION_DATA }}; @@ -41,6 +41,9 @@ if (connectionData) { if (connectionData.options) { document.getElementById('options').value = connectionData.options; } if (connectionData.environment) { document.getElementById('environment').value = connectionData.environment; } if (connectionData.readOnlyMode) { document.getElementById('readOnlyMode').checked = connectionData.readOnlyMode; } + if (connectionData.cloudAuth && connectionData.cloudAuth.kind) { + document.getElementById('cloudAuthKind').value = connectionData.cloudAuth.kind; + } const hasAdvancedOptions = connectionData.sslmode || connectionData.statementTimeout || connectionData.connectTimeout || connectionData.applicationName || connectionData.options; @@ -176,6 +179,11 @@ function getFormData() { options: document.getElementById('options').value || undefined }; + const authKind = document.getElementById('cloudAuthKind').value; + if (authKind && authKind !== 'none') { + data.cloudAuth = { kind: authKind }; + } + if (sshEnabled) { data.ssh = { enabled: true, From b7561b79500ffdc6d350f61b23ba30b8b15275a9 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 12:37:37 +0530 Subject: [PATCH 3/8] feat: Implement deduplication for SQL completion items and enhance review changes UI --- src/activation/providers.ts | 5 - src/providers/SqlCompletionProvider.ts | 36 +++- .../components/table/TableRenderer.ts | 24 ++- src/test/unit/SqlCompletionProvider.test.ts | 34 ++++ src/ui/renderer/renderer_v2.ts | 184 +++++++++++++++++- templates/dashboard/index.html | 2 +- templates/dashboard/styles.css | 11 ++ 7 files changed, 274 insertions(+), 22 deletions(-) diff --git a/src/activation/providers.ts b/src/activation/providers.ts index a66a7cd..223e644 100644 --- a/src/activation/providers.ts +++ b/src/activation/providers.ts @@ -69,11 +69,6 @@ export function registerProviders(context: vscode.ExtensionContext, outputChanne const sqlCompletionProvider = new sqlCompletionModule.SqlCompletionProvider(); context.subscriptions.push( - vscode.languages.registerCompletionItemProvider( - { language: 'sql' }, - sqlCompletionProvider, - '.' // Trigger on dot for schema.table suggestions - ), vscode.languages.registerCompletionItemProvider( { scheme: 'vscode-notebook-cell', language: 'sql' }, sqlCompletionProvider, diff --git a/src/providers/SqlCompletionProvider.ts b/src/providers/SqlCompletionProvider.ts index f5a2208..8b86c0e 100644 --- a/src/providers/SqlCompletionProvider.ts +++ b/src/providers/SqlCompletionProvider.ts @@ -127,10 +127,10 @@ export class SqlCompletionProvider implements vscode.CompletionItemProvider { ORDER BY schemaname, tablename `; const tablesResult = await client.query(tablesQuery); - const tables: TableInfo[] = tablesResult.rows.map(row => ({ + const tables: TableInfo[] = this._dedupeTables(tablesResult.rows.map(row => ({ schema: row.schema, tableName: row.table_name - })); + }))); // Fetch columns const columnsQuery = ` @@ -144,12 +144,12 @@ export class SqlCompletionProvider implements vscode.CompletionItemProvider { ORDER BY table_schema, table_name, ordinal_position `; const columnsResult = await client.query(columnsQuery); - const columns: ColumnInfo[] = columnsResult.rows.map(row => ({ + const columns: ColumnInfo[] = this._dedupeColumns(columnsResult.rows.map(row => ({ schema: row.schema, tableName: row.table_name, columnName: row.column_name, dataType: row.data_type - })); + }))); this.tableCache.set(cacheKey, tables); this.columnCache.set(cacheKey, columns); @@ -267,4 +267,32 @@ export class SqlCompletionProvider implements vscode.CompletionItemProvider { return completions; } + + private _dedupeTables(tables: TableInfo[]): TableInfo[] { + const seen = new Set(); + + return tables.filter(table => { + const key = `${table.schema}.${table.tableName}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } + + private _dedupeColumns(columns: ColumnInfo[]): ColumnInfo[] { + const seen = new Set(); + + return columns.filter(column => { + const key = `${column.schema}.${column.tableName}.${column.columnName}`; + if (seen.has(key)) { + return false; + } + + seen.add(key); + return true; + }); + } } diff --git a/src/renderer/components/table/TableRenderer.ts b/src/renderer/components/table/TableRenderer.ts index 3211f32..ae825a3 100644 --- a/src/renderer/components/table/TableRenderer.ts +++ b/src/renderer/components/table/TableRenderer.ts @@ -637,6 +637,17 @@ export class TableRenderer { cell.style.cssText = `${cell.style.cssText};${style}`; } + private applyModifiedCellDecoration(cell: HTMLElement, sourceIndex: number, col: string): void { + const cellKey = `${sourceIndex}-${col}`; + if (!this.modifiedCells.has(cellKey)) { + return; + } + + cell.style.backgroundColor = 'rgba(245,158,11,0.15)'; + cell.style.borderLeft = '3px solid #f59e0b'; + cell.title = 'Unsaved edit — double-click to edit'; + } + private createRow(row: any, displayIndex: number, sourceIndex: number): HTMLElement { const tr = document.createElement('tr'); tr.dataset.index = String(displayIndex); @@ -826,12 +837,7 @@ export class TableRenderer { this.applyCellStyle(td, sourceIndex, displayIndex, 'data'); - const cellKey = `${sourceIndex}-${col}`; - if (this.modifiedCells.has(cellKey)) { - td.style.backgroundColor = 'rgba(245,158,11,0.15)'; - td.style.borderLeft = '3px solid #f59e0b'; - td.title = 'Unsaved edit — double-click to edit'; - } + this.applyModifiedCellDecoration(td, sourceIndex, col); if (val === null || val === undefined) { const nullSpan = document.createElement('span'); @@ -886,6 +892,12 @@ export class TableRenderer { rowCells.forEach((cell, cellIndex) => { const cellKind: 'row-number' | 'data' = cellIndex === 0 ? 'row-number' : 'data'; this.applyCellStyle(cell, sourceIndex, isNaN(displayIndex) ? sourceIndex : displayIndex, cellKind); + if (cellKind === 'data') { + const col = this.columns[cellIndex - 1]; + if (col) { + this.applyModifiedCellDecoration(cell, sourceIndex, col); + } + } }); } }); diff --git a/src/test/unit/SqlCompletionProvider.test.ts b/src/test/unit/SqlCompletionProvider.test.ts index bb91b42..7184030 100644 --- a/src/test/unit/SqlCompletionProvider.test.ts +++ b/src/test/unit/SqlCompletionProvider.test.ts @@ -132,4 +132,38 @@ describe('SqlCompletionProvider', () => { expect(fallbackItems.length).to.be.greaterThan(0); expect(getPooledClientStub.calledOnce).to.be.true; }); + + it('deduplicates repeated database objects before returning completions', async () => { + (getConfigurationStub as sinon.SinonStub).returns({ + get: (key: string) => (key === 'postgresExplorer.connections' + ? [{ id: 'conn-1', name: 'Main', host: 'localhost', port: 5432, username: 'postgres' }] + : undefined) + } as any); + + queryStub.onFirstCall().resolves({ + rows: [ + { schema: 'public', table_name: 'users' }, + { schema: 'public', table_name: 'users' }, + { schema: 'sales', table_name: 'orders' } + ] + }); + queryStub.onSecondCall().resolves({ + rows: [ + { schema: 'public', table_name: 'users', column_name: 'email', data_type: 'text' }, + { schema: 'public', table_name: 'users', column_name: 'email', data_type: 'text' }, + { schema: 'sales', table_name: 'orders', column_name: 'order_total', data_type: 'numeric' } + ] + }); + + const provider = new SqlCompletionProvider(); + const document = createNotebookCellDocument('SELECT * FROM public.users;'); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, document.text.length), {} as any, {} as any); + const labels = items.map(item => item.label); + + expect(labels.filter(label => label === 'users')).to.have.length(1); + expect(labels.filter(label => label === 'email')).to.have.length(1); + expect(labels.filter(label => label === 'orders')).to.have.length(1); + }); }); \ No newline at end of file diff --git a/src/ui/renderer/renderer_v2.ts b/src/ui/renderer/renderer_v2.ts index eea54f0..14caa97 100644 --- a/src/ui/renderer/renderer_v2.ts +++ b/src/ui/renderer/renderer_v2.ts @@ -693,12 +693,170 @@ export const activate: ActivationFunction = (context) => { } // Save Changes Logic + const parseCellKey = (key: string): { rowIndex: number; colName: string } | null => { + const sep = key.indexOf('-'); + if (sep === -1) return null; + const rowIndex = Number.parseInt(key.slice(0, sep), 10); + if (Number.isNaN(rowIndex)) return null; + return { rowIndex, colName: key.slice(sep + 1) }; + }; + + const formatDiffValue = (value: any): string => { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); + }; + + const buildEditDiffRows = (): Array<{ + rowIndex: number; + rowLabel: string; + colName: string; + oldValue: string; + newValue: string; + }> => { + const rowsForDiff: Array<{ + rowIndex: number; + rowLabel: string; + colName: string; + oldValue: string; + newValue: string; + }> = []; + + modifiedCells.forEach((diff, key) => { + const parsed = parseCellKey(key); + if (!parsed) return; + + const { rowIndex, colName } = parsed; + const pkLabel = tableInfo?.primaryKeys?.length + ? tableInfo.primaryKeys + .map((pk: string) => `${pk}=${formatDiffValue(originalRows[rowIndex]?.[pk])}`) + .join(', ') + : `row #${rowIndex + 1}`; + + rowsForDiff.push({ + rowIndex, + rowLabel: pkLabel, + colName, + oldValue: formatDiffValue(diff.originalValue), + newValue: formatDiffValue(diff.newValue), + }); + }); + + rowsForDiff.sort((a, b) => { + if (a.rowIndex !== b.rowIndex) return a.rowIndex - b.rowIndex; + return a.colName.localeCompare(b.colName); + }); + return rowsForDiff; + }; + + const renderReviewChangesView = (): HTMLElement => { + const diffRows = buildEditDiffRows(); + const wrap = document.createElement('div'); + wrap.style.cssText = 'height:100%;overflow:auto;'; + + const header = document.createElement('div'); + header.style.cssText = + 'padding:10px 12px;border-bottom:1px solid var(--vscode-widget-border);display:flex;flex-direction:column;gap:2px;'; + const titleEl = document.createElement('div'); + titleEl.textContent = 'Review Changes'; + titleEl.style.cssText = 'font-size:13px;font-weight:700;'; + const subtitleEl = document.createElement('div'); + const editedRowCount = new Set(diffRows.map((r) => r.rowIndex)).size; + subtitleEl.textContent = `${editedRowCount} row${editedRowCount !== 1 ? 's' : ''}, ${diffRows.length} edited cell${diffRows.length !== 1 ? 's' : ''}`; + subtitleEl.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);'; + header.appendChild(titleEl); + header.appendChild(subtitleEl); + wrap.appendChild(header); + + if (diffRows.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = + 'padding:20px 16px;color:var(--vscode-descriptionForeground);font-size:12px;'; + empty.textContent = 'No edited cells to review yet.'; + wrap.appendChild(empty); + return wrap; + } + + const table = document.createElement('table'); + table.style.cssText = + 'width:100%;border-collapse:separate;border-spacing:0;font-size:12px;line-height:1.45;'; + + const thead = document.createElement('thead'); + const htr = document.createElement('tr'); + ['Row', 'Column', 'Old Value', 'New Value'].forEach((label) => { + const th = document.createElement('th'); + th.textContent = label; + th.style.cssText = + 'position:sticky;top:0;z-index:1;text-align:left;padding:8px 10px;background:var(--vscode-editor-background);border-bottom:1px solid var(--vscode-widget-border);font-weight:600;'; + htr.appendChild(th); + }); + thead.appendChild(htr); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + diffRows.forEach((row, idx) => { + const tr = document.createElement('tr'); + const stripe = idx % 2 === 0 ? 'transparent' : 'var(--vscode-keybindingTable-rowsBackground)'; + tr.style.background = stripe; + + const rowTd = document.createElement('td'); + rowTd.textContent = row.rowLabel; + rowTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;white-space:nowrap;'; + + const colTd = document.createElement('td'); + colTd.textContent = row.colName; + colTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;'; + + const oldTd = document.createElement('td'); + oldTd.textContent = row.oldValue; + oldTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + oldTd.title = row.oldValue; + + const newTd = document.createElement('td'); + newTd.textContent = row.newValue; + newTd.style.cssText = + 'padding:7px 10px;border-bottom:1px solid var(--vscode-widget-border);font-family:var(--vscode-editor-font-family),monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:color-mix(in srgb, #f59e0b 12%, transparent);'; + newTd.title = row.newValue; + + tr.appendChild(rowTd); + tr.appendChild(colTd); + tr.appendChild(oldTd); + tr.appendChild(newTd); + tbody.appendChild(tr); + }); + + table.appendChild(tbody); + wrap.appendChild(table); + return wrap; + }; + const saveBtn = createButton('Save Changes', true, 'success'); saveBtn.style.marginRight = '8px'; + let reviewTab: HTMLElement | null = null; + const updateReviewTabLabel = () => { + if (!reviewTab) return; + const editedRows = new Set( + Array.from(modifiedCells.keys()) + .map((key) => parseCellKey(key)?.rowIndex) + .filter((idx): idx is number => typeof idx === 'number'), + ).size; + reviewTab.textContent = editedRows > 0 ? `Review Changes (${editedRows})` : 'Review Changes'; + }; + const updateSaveButtonVisibility = () => { // Show save button if there are edits OR deletions const hasChanges = modifiedCells.size > 0 || rowsMarkedForDeletion.size > 0; + updateReviewTabLabel(); if (hasChanges) { if (!rightActions.contains(saveBtn)) rightActions.prepend(saveBtn); @@ -724,8 +882,9 @@ export const activate: ActivationFunction = (context) => { const updates: any[] = []; modifiedCells.forEach((diff, key) => { - const [rowIndexStr, colName] = key.split('-'); - const rowIndex = parseInt(rowIndexStr); + const parsed = parseCellKey(key); + if (!parsed) return; + const { rowIndex, colName } = parsed; console.log(`Renderer: Processing diff for row ${rowIndex}, col ${colName}`); @@ -823,8 +982,9 @@ export const activate: ActivationFunction = (context) => { // The renderer now tracks edits by stable source index, so applying // edits first keeps those indices aligned for the remaining rows. modifiedCells.forEach((diff, key) => { - const [rowIndexStr, colName] = key.split('-'); - const rowIndex = parseInt(rowIndexStr); + const parsed = parseCellKey(key); + if (!parsed) return; + const { rowIndex, colName } = parsed; if (rowIndex >= 0 && rowIndex < originalRows.length) { originalRows[rowIndex][colName] = diff.newValue; } @@ -877,6 +1037,8 @@ export const activate: ActivationFunction = (context) => { const noticesTabLabel = noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; const noticesTab = createTab(noticesTabLabel, 'notices', false, () => switchTab('notices')); + reviewTab = createTab('Review Changes', 'review', false, () => switchTab('review')); + updateReviewTabLabel(); let explainTab: HTMLElement | null = null; if (json.explainPlan) { @@ -891,6 +1053,7 @@ export const activate: ActivationFunction = (context) => { tabs.appendChild(chartTab); tabs.appendChild(analystTab); tabs.appendChild(noticesTab); + tabs.appendChild(reviewTab); if (explainTab) tabs.appendChild(explainTab); tabs.appendChild(transposeTab); if (!json.error) { @@ -915,6 +1078,9 @@ export const activate: ActivationFunction = (context) => { onDataChange: (_rowIndex, _col, _newVal, _originalVal) => { updateSaveButtonVisibility(); updateActionsVisibility(); + if (currentMode === 'review') { + switchTab('review'); + } }, onInsertRow: (values, tempId) => { context.postMessage?.({ type: 'insertRow', tableInfo, values, tempId }); @@ -978,6 +1144,8 @@ export const activate: ActivationFunction = (context) => { } else { deleteBtn.style.display = 'none'; } + } else { + deleteBtn.style.display = 'none'; } }; @@ -1022,8 +1190,8 @@ export const activate: ActivationFunction = (context) => { let currentMode = 'table'; const allTabs = () => explainTab - ? [tableTab, chartTab, analystTab, noticesTab, explainTab, transposeTab] - : [tableTab, chartTab, analystTab, noticesTab, transposeTab]; + ? [tableTab, chartTab, analystTab, noticesTab, reviewTab!, explainTab, transposeTab] + : [tableTab, chartTab, analystTab, noticesTab, reviewTab!, transposeTab]; const setActiveTab = (activeTab: HTMLElement) => { allTabs().forEach((t) => { t.style.borderBottom = '2px solid transparent'; @@ -1077,6 +1245,10 @@ export const activate: ActivationFunction = (context) => { return String(v); }); viewContainer.appendChild(transposeEl); + } else if (mode === 'review') { + setActiveTab(reviewTab || tableTab); + updateActionsVisibility(); + viewContainer.appendChild(renderReviewChangesView()); } else if (mode === 'explain') { // Explain Mode setActiveTab(explainTab || tableTab); diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html index dd9cc72..48e556a 100644 --- a/templates/dashboard/index.html +++ b/templates/dashboard/index.html @@ -210,7 +210,7 @@

Session Activity

-
+
Cache Hit Ratio
Good: >95%. 90-95% is warning. Below 90% may indicate memory pressure.
diff --git a/templates/dashboard/styles.css b/templates/dashboard/styles.css index 068ef72..6f6dcf7 100644 --- a/templates/dashboard/styles.css +++ b/templates/dashboard/styles.css @@ -160,6 +160,17 @@ h1, h2, h3 { margin: 0; font-weight: 500; } gap: var(--sp-6); } +#tab-performance .performance-charts-grid { + grid-template-columns: repeat(2, minmax(320px, 1fr)); + gap: var(--sp-6); +} + +@media (max-width: 980px) { + #tab-performance .performance-charts-grid { + grid-template-columns: 1fr; + } +} + .chart-note { font-size: 11px; color: var(--muted-color); From 9aabf0aadf60f36c612dd52f8dd7b09514238161 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 13:56:21 +0530 Subject: [PATCH 4/8] Bump version to 1.2.0 --- package.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 79b98bb..460c909 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.0.0", + "version": "1.2.0", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, @@ -165,6 +165,12 @@ "title": "AI: Optimize Query", "category": "PgStudio" }, + { + "command": "postgres-explorer.openSqlAssistantTab", + "title": "SQL Assistant: Open in Editor Tab", + "category": "PgStudio", + "icon": "$(go-to-file)" + }, { "command": "postgres-explorer.connect", "title": "Connect to PostgreSQL Database" @@ -1910,6 +1916,11 @@ "command": "postgres-explorer.loadSavedQuery", "when": "view == postgresExplorer", "group": "2_phase7" + }, + { + "command": "postgres-explorer.openSqlAssistantTab", + "when": "view == postgresExplorer.chatView", + "group": "navigation" } ], "editor/title": [ @@ -3108,7 +3119,8 @@ "onCommand:postgres-explorer.exportNotebook", "onCommand:postgres-explorer.openListenNotify", "onCommand:postgres-explorer.openListenNotifyFromPalette", - "onCommand:postgres-explorer.showDashboardFromPalette" + "onCommand:postgres-explorer.showDashboardFromPalette", + "onCommand:postgres-explorer.openSqlAssistantTab" ], "main": "./dist/extension.js", "scripts": { From 051e5df105ef0e84a5b88793f514ad799a1e32f6 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 13:57:26 +0530 Subject: [PATCH 5/8] feat: add AI Insights panel styles and components for enhanced user interaction --- src/activation/commandSpecs.ts | 11 + src/dashboard/DashboardData.ts | 147 ++++++++ src/dashboard/DashboardPanel.ts | 161 +++++++- src/providers/ChatViewProvider.ts | 337 +++++++++-------- templates/dashboard/index.html | 179 ++++++++- templates/dashboard/scripts.js | 590 ++++++++++++++++++++++++++++++ templates/dashboard/styles.css | 529 +++++++++++++++++++++++++++ 7 files changed, 1805 insertions(+), 149 deletions(-) diff --git a/src/activation/commandSpecs.ts b/src/activation/commandSpecs.ts index 0caa5f3..61438b9 100644 --- a/src/activation/commandSpecs.ts +++ b/src/activation/commandSpecs.ts @@ -324,6 +324,17 @@ export function getCommandSpecs( await chatViewProviderInstance.handleOptimizeQuery(query); } }, + { + command: 'postgres-explorer.openSqlAssistantTab', + callback: async () => { + if (!chatViewProviderInstance) { + vscode.window.showWarningMessage('SQL Assistant is not available'); + return; + } + + await chatViewProviderInstance.openInEditor(vscode.ViewColumn.Beside); + } + }, { command: 'postgres-explorer.addToFavorites', callback: async (item: DatabaseTreeItem) => { diff --git a/src/dashboard/DashboardData.ts b/src/dashboard/DashboardData.ts index 91a1707..6cc109a 100644 --- a/src/dashboard/DashboardData.ts +++ b/src/dashboard/DashboardData.ts @@ -70,6 +70,19 @@ export interface DashboardStats { /** WAL / replication (may be partially empty if views are inaccessible). */ walReplication: WalReplicationStats; + + /** Schema health: indexes that have never been scanned. */ + unusedIndexes: { index_name: string; table_name: string; index_size: string; raw_size: number }[]; + /** Tables where sequential scans dominate over index scans. */ + highSeqScanTables: { table_name: string; seq_scan: number; idx_scan: number; seq_scan_pct: number; row_count: number }[]; + /** Tables with significant dead-tuple bloat. */ + tableBloat: { table_name: string; n_live_tup: number; n_dead_tup: number; bloat_pct: number; table_size: string }[]; + /** Currently running autovacuum workers. */ + autovacuumProgress: { pid: number; table_name: string; phase: string; heap_blks_scanned: number; heap_blks_total: number }[]; + /** Tables with notable dead tuples that need vacuum. */ + tablesNeedingVacuum: { table_name: string; n_dead_tup: number; n_live_tup: number; last_autovacuum: string | null; last_autoanalyze: string | null }[]; + /** Active connections grouped by application_name and state. */ + connectionsByApp: { application_name: string; state: string; count: number }[]; } export interface WalReplicationStats { @@ -141,6 +154,12 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P walReceiverRes, pgStatWalRes, replSlotsRes, + unusedIndexesRes, + highSeqScanRes, + tableBloatRes, + autovacuumProgressRes, + tablesNeedingVacuumRes, + connectionsByAppRes, ] = await Promise.allSettled([ // DB Info client.query(` @@ -384,6 +403,88 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P FROM pg_replication_slots ORDER BY slot_name `), + + // Unused indexes (never scanned) + client.query(` + SELECT schemaname || '.' || indexrelname AS index_name, + tablename AS table_name, + pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, + pg_relation_size(indexrelid) AS raw_size + FROM pg_stat_user_indexes + WHERE idx_scan = 0 + AND schemaname NOT IN ('pg_catalog', 'information_schema') + ORDER BY raw_size DESC + LIMIT 20 + `), + + // Tables with high sequential scan ratio + client.query(` + SELECT schemaname || '.' || relname AS table_name, + seq_scan, + COALESCE(idx_scan, 0) AS idx_scan, + CASE WHEN seq_scan + COALESCE(idx_scan, 0) > 0 + THEN ROUND(100.0 * seq_scan / (seq_scan + COALESCE(idx_scan, 0)), 1) + ELSE 0 END AS seq_scan_pct, + n_live_tup AS row_count + FROM pg_stat_user_tables + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + AND seq_scan + COALESCE(idx_scan, 0) > 100 + ORDER BY seq_scan_pct DESC, seq_scan DESC + LIMIT 20 + `), + + // Table bloat via dead tuple ratio + client.query(` + SELECT schemaname || '.' || relname AS table_name, + n_live_tup, + n_dead_tup, + CASE WHEN n_live_tup + n_dead_tup > 0 + THEN ROUND(100.0 * n_dead_tup / (n_live_tup + n_dead_tup), 1) + ELSE 0 END AS bloat_pct, + pg_size_pretty(pg_relation_size(relid)) AS table_size + FROM pg_stat_user_tables + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + AND n_dead_tup > 1000 + ORDER BY bloat_pct DESC + LIMIT 20 + `), + + // Currently running autovacuum workers + client.query(` + SELECT pid, + datname, + relid::regclass::text AS table_name, + phase, + COALESCE(heap_blks_scanned, 0) AS heap_blks_scanned, + COALESCE(heap_blks_total, 0) AS heap_blks_total + FROM pg_stat_progress_vacuum + `), + + // Tables needing vacuum (notable dead tuples) + client.query(` + SELECT schemaname || '.' || relname AS table_name, + n_dead_tup, + n_live_tup, + last_autovacuum::text, + last_autoanalyze::text + FROM pg_stat_user_tables + WHERE n_dead_tup > 500 + AND schemaname NOT IN ('pg_catalog', 'information_schema') + ORDER BY n_dead_tup DESC + LIMIT 20 + `), + + // Connections grouped by application_name and state + client.query(` + SELECT COALESCE(NULLIF(application_name, ''), 'unknown') AS application_name, + COALESCE(state, 'unknown') AS state, + COUNT(*)::int AS count + FROM pg_stat_activity + WHERE pid <> pg_backend_pid() + GROUP BY application_name, state + ORDER BY count DESC + LIMIT 20 + `), ]); // Helper to safely extract result or return empty default @@ -423,6 +524,13 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P const pgWalRow = getResult(pgStatWalRes).rows[0]; const slotRows = getResult(replSlotsRes).rows; + const unusedIndexRows = getResult(unusedIndexesRes).rows; + const highSeqScanRows = getResult(highSeqScanRes).rows; + const tableBloatRows = getResult(tableBloatRes).rows; + const autovacuumProgressRows = getResult(autovacuumProgressRes).rows; + const tablesNeedingVacuumRows = getResult(tablesNeedingVacuumRes).rows; + const connectionsByAppRows = getResult(connectionsByAppRes).rows; + const walSettingsMap: Record = {}; for (const r of walSettingRows) { const u = r.unit && String(r.unit).trim() !== '' ? ` ${r.unit}` : ''; @@ -584,6 +692,45 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P oldestTransactionAgeSeconds: parseInt(oldestTxRow.oldest_tx_age_seconds || '0'), vacuumTablesNeedingAttention: parseInt(vacuumHealthRow.tables_needing_attention || '0'), walReplication, + unusedIndexes: unusedIndexRows.map((r: any) => ({ + index_name: r.index_name, + table_name: r.table_name, + index_size: r.index_size, + raw_size: parseInt(r.raw_size || '0'), + })), + highSeqScanTables: highSeqScanRows.map((r: any) => ({ + table_name: r.table_name, + seq_scan: parseInt(r.seq_scan || '0'), + idx_scan: parseInt(r.idx_scan || '0'), + seq_scan_pct: parseFloat(r.seq_scan_pct || '0'), + row_count: parseInt(r.row_count || '0'), + })), + tableBloat: tableBloatRows.map((r: any) => ({ + table_name: r.table_name, + n_live_tup: parseInt(r.n_live_tup || '0'), + n_dead_tup: parseInt(r.n_dead_tup || '0'), + bloat_pct: parseFloat(r.bloat_pct || '0'), + table_size: r.table_size, + })), + autovacuumProgress: autovacuumProgressRows.map((r: any) => ({ + pid: parseInt(r.pid || '0'), + table_name: r.table_name, + phase: r.phase, + heap_blks_scanned: parseInt(r.heap_blks_scanned || '0'), + heap_blks_total: parseInt(r.heap_blks_total || '0'), + })), + tablesNeedingVacuum: tablesNeedingVacuumRows.map((r: any) => ({ + table_name: r.table_name, + n_dead_tup: parseInt(r.n_dead_tup || '0'), + n_live_tup: parseInt(r.n_live_tup || '0'), + last_autovacuum: r.last_autovacuum ?? null, + last_autoanalyze: r.last_autoanalyze ?? null, + })), + connectionsByApp: connectionsByAppRows.map((r: any) => ({ + application_name: r.application_name, + state: r.state, + count: parseInt(r.count || '0'), + })), }; } diff --git a/src/dashboard/DashboardPanel.ts b/src/dashboard/DashboardPanel.ts index f3c84d4..e08cfa0 100644 --- a/src/dashboard/DashboardPanel.ts +++ b/src/dashboard/DashboardPanel.ts @@ -1,16 +1,21 @@ import { Client, PoolClient } from 'pg'; import * as vscode from 'vscode'; -import { fetchStats } from './DashboardData'; +import { fetchStats, DashboardStats } from './DashboardData'; import { getErrorHtml, getHtmlForWebview, getLoadingHtml } from './DashboardHtml'; import { ConnectionManager } from '../services/ConnectionManager'; import { ConnectionConfig } from '../common/types'; import { createMetadata, createAndShowNotebook } from '../commands/connection'; +import { AiService } from '../providers/chat/AiService'; export class DashboardPanel { private static panels: Map = new Map(); private readonly _panel: vscode.WebviewPanel; private readonly _disposables: vscode.Disposable[] = []; private readonly _panelKey: string; + private _aiService: AiService | null = null; + private _lastStats: DashboardStats | null = null; + private _autoNotifyEnabled = false; + private _lastHealthCritical = false; private constructor(panel: vscode.WebviewPanel, private readonly config: ConnectionConfig, private readonly dbName: string, panelKey: string, private readonly extensionUri: vscode.Uri) { this._panel = panel; @@ -59,6 +64,18 @@ export class DashboardPanel { await this._cancelQuery(message.pid); } break; + case 'askAI': + await this._handleAskAI(message.question, message.context); + break; + case 'executeQueryForAI': + await this._executeQueryForAI(message.sql, message.question); + break; + case 'toggleAutoNotify': + this._autoNotifyEnabled = Boolean(message.enabled); + break; + case 'downloadCsv': + await this._downloadCsv(message.csv, message.filename); + break; } }, null, @@ -138,16 +155,147 @@ export class DashboardPanel { + private async _handleAskAI(question: string, context: string) { + if (!this._aiService) { + this._aiService = new AiService(); + } + this._panel.webview.postMessage({ command: 'aiLoading', loading: true }); + try { + const config = vscode.workspace.getConfiguration('postgresExplorer'); + const provider = config.get('aiProvider') ?? 'vscode-lm'; + const systemPrompt = this._buildDashboardSystemPrompt(context); + let result: { text: string }; + if (provider === 'vscode-lm') { + result = await this._aiService.callVsCodeLm(question, config, systemPrompt); + } else { + result = await this._aiService.callDirectApi(provider, question, config, systemPrompt); + } + this._panel.webview.postMessage({ command: 'aiResponse', text: result.text }); + } catch (err: any) { + this._panel.webview.postMessage({ command: 'aiResponse', text: `**Error:** ${err.message}` }); + } finally { + this._panel.webview.postMessage({ command: 'aiLoading', loading: false }); + } + } + + private async _executeQueryForAI(sql: string, question: string) { + const trimmed = sql.trim(); + const upper = trimmed.toUpperCase().replace(/\s+/g, ' '); + const isReadOnly = /^(SELECT|WITH|EXPLAIN)\b/.test(upper); + if (!isReadOnly) { + this._panel.webview.postMessage({ + command: 'queryForAIResult', + error: 'Only SELECT, WITH, or EXPLAIN queries are allowed.' + }); + return; + } + let client; + try { + client = await this.getClient(); + await client.query('SET statement_timeout = 10000'); + const result = await client.query(trimmed); + this._panel.webview.postMessage({ + command: 'queryForAIResult', + sql: trimmed, + question, + columns: result.fields.map((f: any) => f.name), + rows: result.rows.slice(0, 100), + rowCount: result.rowCount ?? result.rows.length, + }); + } catch (err: any) { + this._panel.webview.postMessage({ + command: 'queryForAIResult', + sql: trimmed, + question, + error: err.message, + }); + } finally { + if (client) client.release(); + } + } + + private _buildDashboardSystemPrompt(contextSummary: string): string { + return `You are an expert PostgreSQL DBA assistant embedded in a live monitoring dashboard. + +${contextSummary ? `## Current Database State\n${contextSummary}\n` : ''} + +## Your Role +- Answer questions about database health, performance, locks, and schema directly and concisely +- When you need live data, write a SELECT query in a \`\`\`sql block — the UI will present it to the user for approval and execution, then return the results to you automatically +- When query results are provided in the message, interpret them directly and answer the original question — do NOT ask to run another query +- Keep answers short and to the point + +## Rules for SQL Generation +- ONLY write SELECT, WITH (read-only CTE), or EXPLAIN queries — NEVER INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, or TRUNCATE +- Always wrap SQL in \`\`\`sql fenced code blocks +- Always add a LIMIT clause (max 100 rows unless the user asks for more) +- Do NOT ask "shall I run this?" or "would you like me to execute?" — the UI handles that automatically +- Do NOT invent or assume data values — query for them + +## Response Format +- Answer in plain sentences — no bullet points for simple answers +- Use bullets only for lists of 3+ items +- Skip preamble — answer directly`; + } + + private _buildContextSummary(stats: DashboardStats): string { + const health = stats.blockingLocks.length > 0 ? 'Degraded (blocking locks)' : + stats.waitingConnections > 0 ? 'Degraded (waiting sessions)' : 'OK'; + const lines = [ + `Database: ${stats.dbName} | Size: ${stats.size} | Owner: ${stats.owner}`, + `Health: ${health}`, + `Connections: ${stats.activeConnections} active / ${stats.idleConnections} idle / ${stats.waitingConnections} waiting / ${stats.maxConnections} max`, + `Blocking locks: ${stats.blockingLocks.length}`, + `Long-running queries (>5s): ${stats.longRunningQueries}`, + `Wait events: ${stats.waitEvents.map(w => `${w.type}=${w.count}`).join(', ') || 'none'}`, + `Index hit ratio: ${stats.indexHitRatio.toFixed(1)}%`, + `Oldest transaction age: ${stats.oldestTransactionAgeSeconds}s`, + `Tables needing vacuum attention: ${stats.vacuumTablesNeedingAttention}`, + `Unused indexes: ${stats.unusedIndexes.length}`, + `Bloated tables (>1000 dead tuples): ${stats.tableBloat.length}`, + `Tables needing vacuum: ${stats.tablesNeedingVacuum.length}`, + ]; + if (stats.blockingLocks.length > 0) { + lines.push(`Blocking lock details: ${stats.blockingLocks.slice(0, 3).map(l => + `PID ${l.blocking_pid} blocks PID ${l.blocked_pid} on ${l.locked_object} (${l.lock_mode})` + ).join('; ')}`); + } + if (stats.pgStatStatements && stats.pgStatStatements.length > 0) { + const top = stats.pgStatStatements[0]; + lines.push(`Top SQL by total time: ${top.total_time.toFixed(0)}ms total, ${top.calls} calls, avg ${top.mean_time.toFixed(1)}ms`); + } + return lines.join('\n'); + } + + private _isHealthCritical(stats: DashboardStats): boolean { + return stats.blockingLocks.length > 0 || + stats.waitingConnections > 5 || + stats.longRunningQueries > 3 || + (stats.totalConnections / stats.maxConnections) > 0.9; + } + private async _update() { let client; try { client = await this.getClient(); const stats = await fetchStats(client as unknown as Client, this.dbName); + this._lastStats = stats; this._panel.webview.postMessage({ command: 'updateStats', stats }); // If it's the first load, set the HTML if (this._panel.webview.html.includes('Loading Dashboard...')) { this._panel.webview.html = await getHtmlForWebview(this._panel.webview, this.extensionUri, stats); } + // Auto-notify if enabled and health newly turned critical + if (this._autoNotifyEnabled) { + const nowCritical = this._isHealthCritical(stats); + if (nowCritical && !this._lastHealthCritical) { + await this._handleAskAI( + 'Database health has degraded. Explain what is happening and what I should do immediately.', + this._buildContextSummary(stats) + ); + } + this._lastHealthCritical = nowCritical; + } } catch (error: any) { // Only show error if we haven't loaded the UI yet, otherwise send error message if (this._panel.webview.html.includes('Loading Dashboard...')) { @@ -161,6 +309,17 @@ export class DashboardPanel { } } + private async _downloadCsv(csv: string, filename: string) { + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(filename), + filters: { 'CSV Files': ['csv'], 'All Files': ['*'] } + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(csv, 'utf-8')); + vscode.window.showInformationMessage(`Saved: ${uri.fsPath}`); + } + } + private async _showDetails(type: string) { let client; try { diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 3501af1..5ef453a 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -26,8 +26,11 @@ import type { NoticeLogEntry } from '../common/types'; export class ChatViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'postgresExplorer.chatView'; + public static readonly panelViewType = 'postgresExplorer.chatViewPanel'; private _view?: vscode.WebviewView; + private _panels = new Set(); + private _activeWebview?: vscode.Webview; private _messages: ChatMessage[] = []; private _isProcessing = false; @@ -61,20 +64,150 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { this._updateModelInfo(); } + public async openInEditor(column: vscode.ViewColumn = vscode.ViewColumn.Beside): Promise { + const panel = vscode.window.createWebviewPanel( + ChatViewProvider.panelViewType, + 'SQL Assistant', + column, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [this._extensionUri], + } + ); + + this._panels.add(panel); + this._activeWebview = panel.webview; + + panel.onDidDispose(() => { + this._panels.delete(panel); + if (this._activeWebview === panel.webview) { + this._activeWebview = this._view?.webview; + } + }); + + await this._initializeWebview(panel.webview); + this._registerWebviewMessageHandler(panel.webview); + + this._sendHistoryToWebview(); + this._updateChatHistory(); + this._sendContextUpdate(); + await this._updateModelInfo(); + } + + private _getTargetWebview(): vscode.Webview | undefined { + return this._activeWebview ?? this._view?.webview; + } + + private async _ensureChatWebview(): Promise { + const target = this._getTargetWebview(); + if (target) { + return target; + } + + await this.openInEditor(vscode.ViewColumn.Beside); + return this._getTargetWebview(); + } + + private async _initializeWebview(webview: vscode.Webview): Promise { + webview.options = { + enableScripts: true, + localResourceRoots: [this._extensionUri] + }; + + const markedUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'marked.min.js')); + const highlightJsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.min.js')); + const highlightCssUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.css')); + + webview.html = await getWebviewHtml(webview, markedUri, highlightJsUri, highlightCssUri, this._extensionUri); + } + + private _registerWebviewMessageHandler(webview: vscode.Webview): void { + webview.onDidReceiveMessage(async (data) => { + this._activeWebview = webview; + switch (data.type) { + case 'sendMessage': + await this._handleUserMessage(data.message, data.attachments, data.mentions); + break; + case 'clearChat': + this._messages = []; + this._sessionService.clearCurrentSession(); + this._updateChatHistory(); + break; + case 'newChat': + await this._saveCurrentSession(); + this._messages = []; + this._sessionService.clearCurrentSession(); + this._updateChatHistory(); + this._sendHistoryToWebview(); + break; + case 'pickFile': + await this._handleFilePick(); + break; + case 'loadSession': + await this._loadSession(data.sessionId); + break; + case 'deleteSession': + console.log('[ChatView] Received deleteSession request for:', data.sessionId); + await this._deleteSession(data.sessionId); + break; + case 'explainError': + await this.handleExplainError(data.error, data.query); + break; + case 'fixQuery': + await this.handleFixQuery(data.error, data.query); + break; + case 'analyzeData': + await this.handleAnalyzeData(data.data, data.query, data.rowCount); + break; + case 'optimizeQuery': + await this.handleOptimizeQuery(data.query, data.executionTime); + break; + case 'cancelRequest': + this._aiService.cancel(); + this._setTypingIndicator(false); + this._isProcessing = false; + vscode.window.showInformationMessage('AI request cancelled.'); + break; + case 'getHistory': + this._sendHistoryToWebview(); + break; + case 'searchDbObjects': + await this._handleSearchDbObjects(data.query); + break; + case 'getDbObjectDetails': + await this._handleGetDbObjectDetails(data.object); + break; + case 'getDbObjects': + await this._handleGetAllDbObjects(); + break; + case 'getDbHierarchy': + await this._handleGetDbHierarchy(data.path); + break; + case 'openAiSettings': + vscode.commands.executeCommand('postgres-explorer.aiSettings'); + break; + case 'openInNotebook': + await this._handleOpenInNotebook(data.code); + break; + case 'previewFile': + await this._handlePreviewFile(data.path, data.name); + break; + } + }); + } + /** * Attach a database object to the chat * Called from the @ inline button on tree items */ public async attachDbObject(obj: DbObject): Promise { - // Focus the chat view - if (this._view) { - this._view.show(true); - } + const targetWebview = await this._ensureChatWebview(); // Wait a bit for the view to be ready await new Promise(resolve => setTimeout(resolve, 200)); - if (!this._view) { + if (!targetWebview) { vscode.window.showWarningMessage('Chat view not available'); return; } @@ -85,7 +218,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const objWithDetails = { ...obj, details }; // Send to webview - this._view.webview.postMessage({ + targetWebview.postMessage({ type: 'addMentionFromTree', object: objWithDetails }); @@ -108,10 +241,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { /** PostgreSQL RAISE NOTICE / server messages — attached as a .txt file */ notices?: Array; }): Promise { + const targetWebview = await this._ensureChatWebview(); + // Wait a bit for the view to be ready after focus await new Promise(resolve => setTimeout(resolve, 300)); - if (!this._view) { + if (!targetWebview) { vscode.window.showWarningMessage('Chat view not available. Please open the SQL Assistant panel first.'); return; } @@ -127,7 +262,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const queryFilePath = path.join(tempDir, queryFileName); await fs.promises.writeFile(queryFilePath, data.query, 'utf8'); - this._view.webview.postMessage({ + targetWebview.postMessage({ type: 'fileAttached', file: { name: queryFileName, @@ -157,7 +292,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const noticeFilePath = path.join(tempDir, noticeFileName); await fs.promises.writeFile(noticeFilePath, noticeLines, 'utf8'); - this._view.webview.postMessage({ + targetWebview.postMessage({ type: 'fileAttached', file: { name: noticeFileName, @@ -199,7 +334,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const resultsFilePath = path.join(tempDir, resultsFileName); await fs.promises.writeFile(resultsFilePath, csvContent, 'utf8'); - this._view.webview.postMessage({ + targetWebview.postMessage({ type: 'fileAttached', file: { name: resultsFileName, @@ -214,7 +349,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const resultsFilePath = path.join(tempDir, resultsFileName); await fs.promises.writeFile(resultsFilePath, data.results, 'utf8'); - this._view.webview.postMessage({ + targetWebview.postMessage({ type: 'fileAttached', file: { name: resultsFileName, @@ -253,97 +388,18 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { _token: vscode.CancellationToken ): Promise { this._view = webviewView; + this._activeWebview = webviewView.webview; - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [this._extensionUri] - }; - - // Send URI for marked.js and highlight.js - const markedUri = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'marked.min.js')); - const highlightJsUri = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.min.js')); - const highlightCssUri = webviewView.webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.css')); - - webviewView.webview.html = await getWebviewHtml(webviewView.webview, markedUri, highlightJsUri, highlightCssUri, this._extensionUri); + await this._initializeWebview(webviewView.webview); + this._registerWebviewMessageHandler(webviewView.webview); // Send initial history and model info setTimeout(() => { this._sendHistoryToWebview(); + this._updateChatHistory(); + this._sendContextUpdate(); this._updateModelInfo(); }, 100); - - webviewView.webview.onDidReceiveMessage(async (data) => { - switch (data.type) { - case 'sendMessage': - await this._handleUserMessage(data.message, data.attachments, data.mentions); - break; - case 'clearChat': - this._messages = []; - this._sessionService.clearCurrentSession(); - this._updateChatHistory(); - break; - case 'newChat': - await this._saveCurrentSession(); - this._messages = []; - this._sessionService.clearCurrentSession(); - this._updateChatHistory(); - this._sendHistoryToWebview(); - break; - case 'pickFile': - await this._handleFilePick(); - break; - case 'loadSession': - await this._loadSession(data.sessionId); - break; - case 'deleteSession': - console.log('[ChatView] Received deleteSession request for:', data.sessionId); - await this._deleteSession(data.sessionId); - break; - case 'explainError': - await this.handleExplainError(data.error, data.query); - break; - case 'fixQuery': - await this.handleFixQuery(data.error, data.query); - break; - case 'analyzeData': - await this.handleAnalyzeData(data.data, data.query, data.rowCount); - break; - case 'optimizeQuery': - await this.handleOptimizeQuery(data.query, data.executionTime); - break; - case 'cancelRequest': - this._aiService.cancel(); - this._setTypingIndicator(false); - this._isProcessing = false; - vscode.window.showInformationMessage('AI request cancelled.'); - break; - - case 'getHistory': - this._sendHistoryToWebview(); - break; - case 'searchDbObjects': - await this._handleSearchDbObjects(data.query); - break; - case 'getDbObjectDetails': - await this._handleGetDbObjectDetails(data.object); - break; - case 'getDbObjects': - await this._handleGetAllDbObjects(); - break; - case 'getDbHierarchy': - await this._handleGetDbHierarchy(data.path); - break; - case 'openAiSettings': - vscode.commands.executeCommand('postgres-explorer.aiSettings'); - break; - case 'openInNotebook': - await this._handleOpenInNotebook(data.code); - break; - case 'previewFile': - await this._handlePreviewFile(data.path, data.name); - break; - } - }); } // ==================== Message Handling ==================== @@ -450,7 +506,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { console.error('[ChatView] Failed to get schema for mention:', mention.name, e); // Notify user about the error - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'schemaError', object: `${mention.schema}.${mention.name}`, error: errorMsg @@ -560,12 +616,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { try { const filtered = await this._dbObjectService.searchObjectsAsync(query); - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbObjectsResult', objects: filtered }); } catch (error) { - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbObjectsResult', objects: [], error: 'Failed to fetch database objects' @@ -577,7 +633,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { try { const details = await this._dbObjectService.getObjectSchema(object); const objWithDetails = { ...object, details }; - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbObjectDetails', object: objWithDetails }); @@ -590,12 +646,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private async _handleGetAllDbObjects(): Promise { try { const objects = await this._dbObjectService.getInitialObjects(); - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbObjectsResult', objects: objects }); } catch (error) { - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbObjectsResult', objects: [], error: 'No database connections available' @@ -617,7 +673,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { items = await this._dbObjectService.getSchemaObjects(path.connectionId, path.database, path.schema); } - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbHierarchyData', path: path, items: items @@ -625,7 +681,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } catch (error) { console.error('Error fetching hierarchy:', error); - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'dbHierarchyData', path: path, items: [], @@ -658,7 +714,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { ? content.substring(0, maxSize) + '\n... (truncated)' : content; - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'fileAttached', file: { name: fileName, @@ -716,13 +772,13 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await vscode.workspace.applyEdit(edit); // Send success back to webview - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'notebookResult', success: true }); } else { // No active notebook - send error back to webview - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'notebookResult', success: false, error: 'Open notebook first' @@ -730,7 +786,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - this._view?.webview.postMessage({ + this._getTargetWebview()?.postMessage({ type: 'notebookResult', success: false, error: errorMessage @@ -779,58 +835,53 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } private _sendHistoryToWebview(): void { - if (this._view) { - this._view.webview.postMessage({ - type: 'updateHistory', - sessions: this._sessionService.getSessionSummaries() - }); - } + this._getTargetWebview()?.postMessage({ + type: 'updateHistory', + sessions: this._sessionService.getSessionSummaries() + }); } // Phase C: Send context bar update to webview private _sendContextUpdate(): void { - if (this._view) { - this._view.webview.postMessage({ - type: 'contextUpdate', - connectionName: this._currentConnectionName || null, - database: this._currentDatabase || null, - environment: this._currentEnvironment || null, - readOnlyMode: this._currentReadOnlyMode || false - }); - } + this._getTargetWebview()?.postMessage({ + type: 'contextUpdate', + connectionName: this._currentConnectionName || null, + database: this._currentDatabase || null, + environment: this._currentEnvironment || null, + readOnlyMode: this._currentReadOnlyMode || false + }); } // ==================== UI Helpers ==================== private _updateChatHistory(): void { - if (this._view) { - this._view.webview.postMessage({ - type: 'updateMessages', - messages: this._messages - }); - } + this._getTargetWebview()?.postMessage({ + type: 'updateMessages', + messages: this._messages + }); } private _setTypingIndicator(isTyping: boolean): void { - if (this._view) { - this._view.webview.postMessage({ - type: 'setTyping', - isTyping - }); - } + this._getTargetWebview()?.postMessage({ + type: 'setTyping', + isTyping + }); } private async _updateModelInfo(): Promise { - if (this._view) { - const config = vscode.workspace.getConfiguration('postgresExplorer'); - const provider = config.get('aiProvider') || 'vscode-lm'; - const modelInfo = await this._aiService.getModelInfo(provider, config); - - this._view.webview.postMessage({ - type: 'updateModelInfo', - modelName: modelInfo - }); + const webview = this._getTargetWebview(); + if (!webview) { + return; } + + const config = vscode.workspace.getConfiguration('postgresExplorer'); + const provider = config.get('aiProvider') || 'vscode-lm'; + const modelInfo = await this._aiService.getModelInfo(provider, config); + + webview.postMessage({ + type: 'updateModelInfo', + modelName: modelInfo + }); } public async handleExplainError(error: string, query: string): Promise { diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html index 48e556a..1616bb1 100644 --- a/templates/dashboard/index.html +++ b/templates/dashboard/index.html @@ -7,6 +7,7 @@ Dashboard + @@ -46,6 +47,7 @@

...

+
@@ -55,7 +57,8 @@

...

Activity
Performance
WAL & Replication
-
Locks & Blocking
+
Locks & Blocking
+
Schema Health
@@ -65,7 +68,7 @@

...

- DB Health + DB Health
Healthy @@ -79,7 +82,7 @@

...

- Active Load + Active Load
...
@@ -91,7 +94,7 @@

...

- Blocking Locks + Blocking Locks
0 @@ -113,7 +116,7 @@

...

- Issues + Issues
@@ -138,6 +141,29 @@

...

+ + +
+

Connections by Application

+
Breakdown of active sessions by client application name and state.
+
+
+ + + + + + + + + + + + +
ApplicationActiveIdleWaitingTotalDistribution
+
+
+
@@ -342,6 +368,149 @@

No blocking locks detected

+ +
+ + +

Index Health

+ +
+
+
Unused Indexes
+
Indexes that have never been scanned. They waste write overhead and storage — consider dropping them.
+
+ + + + + + + + + + +
IndexTableSizeSeverity
+
+
+ +
+
High Sequential Scan Tables
+
Tables where most reads use sequential scans. A high seq% with many rows may indicate a missing index.
+
+ + + + + + + + + + + +
TableSeq ScansIdx ScansSeq%Rows
+
+
+
+ + +

Table Bloat

+ +
+
+
Bloated Tables (Dead Tuple Ratio)
+
Tables with significant dead tuples. High bloat% slows queries and wastes disk. Run VACUUM to reclaim space.
+
+ + + + + + + + + + + + +
TableLive RowsDead RowsBloat%SizeAction
+
+
+
+ + +

Autovacuum Status

+ +
+
+
Running Vacuums
+
Currently active autovacuum workers from pg_stat_progress_vacuum.
+
+ + + + + + + + + + +
PIDTablePhaseProgress
+
+
+ +
+
Tables Needing Vacuum
+
Tables with notable dead tuples that autovacuum should process soon.
+
+ + + + + + + + + + +
TableDead TuplesLast AutovacuumLast Autoanalyze
+
+
+
+ +
+ + +
+
+ 🧠 AI Insights + + +
+
+ + + + + + +
+
+
+

Ask about any metric, or click a quick-action above.

+

Context from the current dashboard snapshot is sent with each question.

+
+
+
+ + +
+
+
diff --git a/templates/dashboard/scripts.js b/templates/dashboard/scripts.js index c3db33b..a9a7eb3 100644 --- a/templates/dashboard/scripts.js +++ b/templates/dashboard/scripts.js @@ -539,6 +539,8 @@ function updateDashboard(stats) { updateOverviewSignals(stats); updatePerformanceInsights(stats); updateWalReplication(stats); + updateConnectionsByApp(stats.connectionsByApp || []); + updateSchemaHealth(stats); updateKpiDelta('locks', (stats.blockingLocks || []).length, 'locks-delta'); @@ -1595,6 +1597,15 @@ window.addEventListener('message', event => { case 'showDetails': renderDetailsView(message.type, message.data, message.columns); break; + case 'aiLoading': + renderAiLoading(message.loading); + break; + case 'aiResponse': + renderAiResponse(message.text); + break; + case 'queryForAIResult': + _handleQueryResult(message); + break; } }); @@ -1638,3 +1649,582 @@ initializeDashboard(initialStats); if (initialStats) { updateDashboard(initialStats); } + +// ── Shared helpers ─────────────────────────────────────────────────── + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ── Connection Analytics ───────────────────────────────────────────── + +function updateConnectionsByApp(connectionsByApp) { + const tbody = document.querySelector('#connections-by-app-table tbody'); + if (!tbody) return; + + if (!connectionsByApp.length) { + tbody.innerHTML = 'No connection data.'; + return; + } + + const byApp = {}; + for (const row of connectionsByApp) { + if (!byApp[row.application_name]) { + byApp[row.application_name] = { active: 0, idle: 0, waiting: 0, other: 0 }; + } + const st = (row.state || '').toLowerCase(); + if (st === 'active') byApp[row.application_name].active += row.count; + else if (st === 'idle') byApp[row.application_name].idle += row.count; + else if (st === 'waiting' || st === 'idle in transaction (aborted)') byApp[row.application_name].waiting += row.count; + else byApp[row.application_name].other += row.count; + } + + const entries = Object.entries(byApp).sort((a, b) => { + const totalA = a[1].active + a[1].idle + a[1].waiting + a[1].other; + const totalB = b[1].active + b[1].idle + b[1].waiting + b[1].other; + return totalB - totalA; + }); + + tbody.innerHTML = entries.map(([app, counts]) => { + const total = counts.active + counts.idle + counts.waiting + counts.other; + const activeW = total > 0 ? Math.round((counts.active / total) * 100) : 0; + const idleW = total > 0 ? Math.round((counts.idle / total) * 100) : 0; + const waitW = 100 - activeW - idleW; + return ` + ${escHtml(app)} + ${counts.active || 0} + ${counts.idle || 0} + ${counts.waiting || 0} + ${total} + +
+ ${activeW > 0 ? `
` : ''} + ${idleW > 0 ? `
` : ''} + ${waitW > 0 ? `
` : ''} +
+ + `; + }).join(''); +} + +// ── Schema Health Tab ──────────────────────────────────────────────── + +function updateSchemaHealth(stats) { + renderUnusedIndexes(stats.unusedIndexes || []); + renderHighSeqScan(stats.highSeqScanTables || []); + renderTableBloat(stats.tableBloat || []); + renderAutovacuumProgress(stats.autovacuumProgress || []); + renderTablesNeedingVacuum(stats.tablesNeedingVacuum || []); +} + +function renderUnusedIndexes(indexes) { + const tbody = document.querySelector('#unused-indexes-table tbody'); + if (!tbody) return; + if (!indexes.length) { + tbody.innerHTML = 'No unused indexes detected.'; + return; + } + tbody.innerHTML = indexes.map(idx => { + const rawMb = idx.raw_size / (1024 * 1024); + const sev = rawMb > 50 ? 'crit' : rawMb > 10 ? 'warn' : 'ok'; + const badge = `${sev === 'crit' ? 'Large' : sev === 'warn' ? 'Medium' : 'Small'}`; + return ` + ${escHtml(idx.index_name)} + ${escHtml(idx.table_name)} + ${escHtml(idx.index_size)} + ${badge} + `; + }).join(''); +} + +function renderHighSeqScan(tables) { + const tbody = document.querySelector('#high-seq-scan-table tbody'); + if (!tbody) return; + if (!tables.length) { + tbody.innerHTML = 'No high sequential scan tables detected.'; + return; + } + tbody.innerHTML = tables.map(t => { + const pct = Number(t.seq_scan_pct || 0); + const cls = pct > 80 ? 'row-crit' : pct > 50 ? 'row-warn' : ''; + const color = pct > 80 ? 'var(--danger-color)' : pct > 50 ? 'var(--warning-color)' : 'var(--fg-color)'; + return ` + ${escHtml(t.table_name)} + ${(t.seq_scan||0).toLocaleString()} + ${(t.idx_scan||0).toLocaleString()} + ${pct.toFixed(1)}% + ${(t.row_count||0).toLocaleString()} + `; + }).join(''); +} + +function renderTableBloat(tables) { + const tbody = document.querySelector('#table-bloat-table tbody'); + if (!tbody) return; + if (!tables.length) { + tbody.innerHTML = 'No significant table bloat detected.'; + return; + } + tbody.innerHTML = tables.map(t => { + const pct = Number(t.bloat_pct || 0); + const cls = pct > 20 ? 'crit' : pct > 10 ? 'warn' : ''; + const rowCls = cls ? 'row-' + cls : ''; + const barW = Math.min(80, Math.round(pct)); + const barCls = cls ? ' ' + cls : ''; + return ` + ${escHtml(t.table_name)} + ${(t.n_live_tup||0).toLocaleString()} + ${(t.n_dead_tup||0).toLocaleString()} + + ${pct.toFixed(1)}% +
+ + ${escHtml(t.table_size || '-')} + + ${cls ? `VACUUM` : 'OK'} + + `; + }).join(''); +} + +function renderAutovacuumProgress(workers) { + const tbody = document.querySelector('#autovacuum-progress-table tbody'); + if (!tbody) return; + if (!workers.length) { + tbody.innerHTML = 'No autovacuum workers currently running.'; + return; + } + tbody.innerHTML = workers.map(w => { + const total = w.heap_blks_total || 1; + const scanned = w.heap_blks_scanned || 0; + const pct = Math.min(100, Math.round((scanned / total) * 100)); + return ` + ${w.pid} + ${escHtml(w.table_name || '-')} + ${escHtml(w.phase || '-')} + +
+
+
+ ${pct}% (${scanned.toLocaleString()}/${total.toLocaleString()} blocks) + + `; + }).join(''); +} + +function renderTablesNeedingVacuum(tables) { + const tbody = document.querySelector('#tables-needing-vacuum-table tbody'); + if (!tbody) return; + if (!tables.length) { + tbody.innerHTML = 'No tables need immediate vacuum.'; + return; + } + tbody.innerHTML = tables.map(t => { + const dead = t.n_dead_tup || 0; + const cls = dead > 10000 ? 'row-crit' : dead > 2000 ? 'row-warn' : ''; + const lastVac = t.last_autovacuum ? new Date(t.last_autovacuum).toLocaleString() : 'Never'; + const lastAna = t.last_autoanalyze ? new Date(t.last_autoanalyze).toLocaleString() : 'Never'; + return ` + ${escHtml(t.table_name)} + ${dead.toLocaleString()} + ${lastVac} + ${lastAna} + `; + }).join(''); +} + +// ── AI Panel ────────────────────────────────────────────────────────── + +let currentStats = null; + +const aiPanel = document.getElementById('ai-panel'); +const aiToggleBtn = document.getElementById('ai-toggle-btn'); +const aiCloseBtn = document.getElementById('ai-panel-close'); +const aiSendBtn = document.getElementById('ai-send-btn'); +const aiQuestionInput = document.getElementById('ai-question'); +const aiResponseArea = document.getElementById('ai-response-area'); +const aiAutoNotify = document.getElementById('ai-auto-notify'); + +function openAiPanel() { + if (!aiPanel) return; + aiPanel.classList.add('open'); + document.body.classList.add('ai-panel-open'); +} + +function closeAiPanel() { + if (!aiPanel) return; + aiPanel.classList.remove('open'); + document.body.classList.remove('ai-panel-open'); +} + +function buildContextSummary() { + if (!currentStats) return ''; + const s = currentStats; + const lines = [ + `Database: ${s.dbName || '-'} | Size: ${s.size || '-'}`, + `Connections: ${s.activeConnections || 0} active, ${s.idleConnections || 0} idle, ${s.waitingConnections || 0} waiting / ${s.maxConnections || 0} max`, + `Blocking locks: ${(s.blockingLocks || []).length}`, + `Long-running queries (>5s): ${s.longRunningQueries || 0}`, + `Wait events: ${(s.waitEvents || []).map(w => `${w.type}=${w.count}`).join(', ') || 'none'}`, + `Index hit ratio: ${(s.indexHitRatio || 0).toFixed(1)}%`, + `Oldest transaction age: ${s.oldestTransactionAgeSeconds || 0}s`, + `Tables needing vacuum: ${(s.tablesNeedingVacuum || []).length}`, + `Unused indexes: ${(s.unusedIndexes || []).length}`, + `Bloated tables: ${(s.tableBloat || []).length}`, + ]; + if ((s.blockingLocks || []).length > 0) { + lines.push(`Blocking lock: PID ${s.blockingLocks[0].blocking_pid} blocks PID ${s.blockingLocks[0].blocked_pid} on ${s.blockingLocks[0].locked_object}`); + } + if ((s.pgStatStatements || []).length > 0) { + const top = s.pgStatStatements[0]; + lines.push(`Top SQL: ${Number(top.total_time).toFixed(0)}ms total, ${top.calls} calls`); + } + return lines.join('\n'); +} + +const quickPromptMap = { + 'analyze-health': 'Analyze the current database health status and explain what is causing any issues.', + 'explain-locks': 'Explain the blocking lock situation in detail and provide steps to resolve it.', + 'slow-queries': 'What are the long-running queries indicating and how should I address them?', + 'top-sql': 'Review the top SQL statements by total time and suggest specific optimizations.', + 'schema-health': 'Analyze the schema health — unused indexes, table bloat, and sequential scan patterns. What should I fix first?', + 'vacuum-advice': 'Review the vacuum status and dead tuple counts. What vacuum actions should I take?', +}; + +const metricPromptMap = { + 'db-health': 'Explain the current DB health status and what is causing any degradation.', + 'active-load': 'Analyze the active connection load. Is it healthy? What should I watch for?', + 'blocking-locks': 'Explain the blocking locks in detail. What transactions are involved and how do I resolve them?', + 'wait-events': 'Explain the current wait events. What are they indicating about the workload?', + 'unused-indexes': 'Analyze the unused indexes. Which ones should I consider dropping and why?', + 'high-seq-scan': 'Analyze the tables with high sequential scan rates. Which might benefit from new indexes?', + 'table-bloat': 'Analyze the table bloat. What is the impact and what VACUUM strategy should I use?', + 'autovacuum': 'Analyze the autovacuum status. Is autovacuum keeping up? Should I tune any settings?', +}; + +let _lastAiQuestion = ''; + +function _appendAiMessage(role, htmlContent) { + if (!aiResponseArea) return; + const welcome = aiResponseArea.querySelector('.ai-welcome'); + if (welcome) welcome.remove(); + + const msgDiv = document.createElement('div'); + msgDiv.className = 'ai-message ' + role; + + const roleLabel = document.createElement('div'); + roleLabel.className = 'ai-message-role'; + roleLabel.textContent = role === 'user' ? 'You' : 'AI'; + + const bubble = document.createElement('div'); + bubble.className = 'ai-message-bubble'; + + const content = document.createElement('div'); + content.className = 'ai-message-content'; + content.innerHTML = htmlContent; + + bubble.appendChild(content); + msgDiv.appendChild(roleLabel); + msgDiv.appendChild(bubble); + aiResponseArea.appendChild(msgDiv); + + if (role === 'assistant') { + _addSqlRunButtons(msgDiv); + } + + aiResponseArea.scrollTop = aiResponseArea.scrollHeight; +} + +function _addSqlRunButtons(msgDiv) { + msgDiv.querySelectorAll('.ai-code-block').forEach(block => { + const langEl = block.querySelector('.ai-code-lang'); + const lang = langEl ? langEl.textContent.trim().toUpperCase() : ''; + if (!['SQL', 'PGSQL', 'POSTGRESQL', 'PLPGSQL'].includes(lang)) return; + + const codeEl = block.querySelector('.ai-code-content'); + if (!codeEl) return; + + const confirmRow = document.createElement('div'); + confirmRow.className = 'ai-run-confirm'; + confirmRow.innerHTML = ` + Run this query on ${escHtml(currentStats ? currentStats.dbName : 'the database')}? +
+ + +
`; + block.appendChild(confirmRow); + + confirmRow.querySelector('.ai-run-ok-btn').addEventListener('click', () => { + const sql = codeEl.textContent.trim(); + confirmRow.innerHTML = ' Executing query…'; + vscode.postMessage({ command: 'executeQueryForAI', sql, question: _lastAiQuestion }); + }); + + confirmRow.querySelector('.ai-run-skip-btn').addEventListener('click', () => { + confirmRow.remove(); + }); + }); +} + +function _handleQueryResult(data) { + if (!aiResponseArea) return; + + const lastConfirm = aiResponseArea.querySelector('.ai-run-executing'); + if (lastConfirm) lastConfirm.closest('.ai-run-confirm').remove(); + + const wrapper = document.createElement('div'); + wrapper.className = 'ai-query-result'; + + if (data.error) { + wrapper.innerHTML = `
Query error: ${escHtml(data.error)}
`; + aiResponseArea.appendChild(wrapper); + aiResponseArea.scrollTop = aiResponseArea.scrollHeight; + return; + } + + const rowCount = data.rowCount ?? (data.rows ? data.rows.length : 0); + let html = `
Query returned ${rowCount} row${rowCount !== 1 ? 's' : ''}`; + if (data.columns && data.rows && data.rows.length > 0) { + html += ` — `; + } + html += `
`; + wrapper.innerHTML = html; + + const csvBtn = wrapper.querySelector('.ai-csv-btn'); + if (csvBtn) csvBtn.addEventListener('click', () => _downloadQueryCsv(data)); + + aiResponseArea.appendChild(wrapper); + aiResponseArea.scrollTop = aiResponseArea.scrollHeight; + + _sendResultsToAI(data); +} + +function _sendResultsToAI(data) { + const rowCount = data.rowCount ?? (data.rows ? data.rows.length : 0); + let resultContext = `Query results (${rowCount} row${rowCount !== 1 ? 's' : ''}):\n`; + + if (data.columns && data.rows && data.rows.length > 0) { + resultContext += data.columns.join(' | ') + '\n'; + resultContext += data.rows.slice(0, 50).map(row => + data.columns.map(col => row[col] === null ? 'NULL' : String(row[col])).join(' | ') + ).join('\n'); + if (data.rows.length > 50) resultContext += `\n… (${data.rows.length - 50} more rows truncated)`; + } else { + resultContext += '(no rows returned)'; + } + + const question = (data.question || 'Answer the original question using the query results below.') + + '\n\n' + resultContext; + + renderAiLoading(true); + vscode.postMessage({ command: 'askAI', question, context: buildContextSummary() }); +} + +function _downloadQueryCsv(data) { + const { columns, rows } = data; + const esc = val => { + const s = val === null ? '' : String(val); + return (s.includes(',') || s.includes('"') || s.includes('\n')) + ? `"${s.replace(/"/g, '""')}"` : s; + }; + const lines = [columns.join(','), ...rows.map(row => columns.map(col => esc(row[col])).join(','))]; + vscode.postMessage({ command: 'downloadCsv', csv: lines.join('\n'), filename: 'query_results.csv' }); +} + +function sendAiQuestion(question) { + if (!question || !question.trim()) return; + openAiPanel(); + const q = question.trim(); + _lastAiQuestion = q; + _appendAiMessage('user', escHtml(q).replace(/\n/g, '
')); + vscode.postMessage({ + command: 'askAI', + question: q, + context: buildContextSummary(), + }); + if (aiQuestionInput) aiQuestionInput.value = ''; +} + +function renderAiLoading(loading) { + if (!aiResponseArea) return; + const existing = aiResponseArea.querySelector('.ai-loading-dots'); + if (loading && !existing) { + const dots = document.createElement('div'); + dots.className = 'ai-loading-dots'; + dots.innerHTML = ''; + aiResponseArea.appendChild(dots); + aiResponseArea.scrollTop = aiResponseArea.scrollHeight; + } else if (!loading && existing) { + existing.remove(); + } +} + +function renderAiResponse(text) { + _appendAiMessage('assistant', _parseAiMarkdown(text)); + if (aiResponseArea) aiResponseArea.scrollTop = aiResponseArea.scrollHeight; +} + +// ── AI Markdown + SQL Highlighting ────────────────────────────────── + +let _aiCodeBlockCounter = 0; +let _aiMarkedRenderer = null; + +function _highlightSqlTokens(code) { + const keywords = ['SELECT', 'FROM', 'WHERE', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE', 'ALTER', 'TABLE', 'INDEX', 'VIEW', 'FUNCTION', 'TRIGGER', 'PROCEDURE', 'CONSTRAINT', 'PRIMARY', 'KEY', 'FOREIGN', 'REFERENCES', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER', 'FULL', 'CROSS', 'ON', 'GROUP', 'BY', 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'UNION', 'ALL', 'DISTINCT', 'AS', 'AND', 'OR', 'NOT', 'IN', 'EXISTS', 'BETWEEN', 'LIKE', 'ILIKE', 'IS', 'NULL', 'TRUE', 'FALSE', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'DEFAULT', 'VALUES', 'SET', 'RETURNING', 'BEGIN', 'COMMIT', 'ROLLBACK', 'TRANSACTION', 'GRANT', 'REVOKE', 'WITH', 'ANALYZE', 'EXPLAIN', 'VACUUM', 'REINDEX', 'CLUSTER', 'COALESCE', 'NULLIF', 'CAST']; + const types = ['INT', 'INTEGER', 'BIGINT', 'SMALLINT', 'VARCHAR', 'TEXT', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'TIMESTAMPTZ', 'NUMERIC', 'DECIMAL', 'FLOAT', 'REAL', 'JSON', 'JSONB', 'UUID', 'SERIAL', 'BIGSERIAL', 'BYTEA', 'OID', 'REGCLASS']; + + let html = ''; + let rest = code; + while (rest.length > 0) { + let m; + if ((m = rest.match(/^(--[^\n]*)/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^(\/\*[\s\S]*?\*\/)/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^('(?:[^'\\]|\\.)*')/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^(\d+\.?\d*)/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/))) { + const w = m[0], u = w.toUpperCase(); + if (keywords.includes(u)) html += '' + w + ''; + else if (types.includes(u)) html += '' + w + ''; + else if (/^\s*\(/.test(rest.slice(w.length))) html += '' + w + ''; + else html += '' + w + ''; + rest = rest.slice(w.length); continue; + } + if ((m = rest.match(/^(&[a-zA-Z]+;)/))) { html += m[0]; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^([+\-/*=<>!|%]+)/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + if ((m = rest.match(/^([,;().[\]]+)/))) { html += '' + m[0] + ''; rest = rest.slice(m[0].length); continue; } + html += rest[0]; rest = rest.slice(1); + } + return html; +} + +function _getAiMarkedRenderer() { + if (_aiMarkedRenderer) return _aiMarkedRenderer; + if (typeof marked === 'undefined') return null; + + const renderer = new marked.Renderer(); + + renderer.code = function ({ text, lang }) { + const id = 'ai-code-' + (++_aiCodeBlockCounter); + const language = lang || 'text'; + const displayLang = language === 'text' ? 'CODE' : language.toUpperCase(); + const isSql = ['sql', 'pgsql', 'postgresql', 'plpgsql'].includes(language.toLowerCase()); + + const escaped = text.replace(/&/g, '&').replace(//g, '>'); + const highlighted = isSql ? _highlightSqlTokens(escaped) : escaped; + + return `
+
+ ${displayLang} + +
+
${highlighted}
+
`; + }; + + renderer.codespan = function ({ text }) { + return `${text.replace(//g, '>')}`; + }; + + _aiMarkedRenderer = renderer; + return _aiMarkedRenderer; +} + +function _parseAiMarkdown(text) { + if (typeof marked !== 'undefined') { + try { + const renderer = _getAiMarkedRenderer(); + if (renderer) { + return marked.parse(text, { renderer, breaks: true }); + } + } catch (e) { /* fall through */ } + } + // Fallback: minimal escaping + return text.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); +} + +if (aiToggleBtn) { + aiToggleBtn.addEventListener('click', () => { + if (aiPanel && aiPanel.classList.contains('open')) { + closeAiPanel(); + } else { + openAiPanel(); + } + }); +} + +if (aiCloseBtn) { + aiCloseBtn.addEventListener('click', closeAiPanel); +} + +if (aiSendBtn) { + aiSendBtn.addEventListener('click', () => { + sendAiQuestion(aiQuestionInput ? aiQuestionInput.value : ''); + }); +} + +if (aiQuestionInput) { + aiQuestionInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendAiQuestion(aiQuestionInput.value); + } + }); +} + +if (aiAutoNotify) { + aiAutoNotify.addEventListener('change', () => { + vscode.postMessage({ command: 'toggleAutoNotify', enabled: aiAutoNotify.checked }); + }); +} + +document.querySelectorAll('.ai-chip').forEach(btn => { + btn.addEventListener('click', () => { + const prompt = quickPromptMap[btn.dataset.prompt]; + if (prompt) sendAiQuestion(prompt); + }); +}); + +document.addEventListener('click', e => { + const btn = e.target.closest('.card-ai-btn'); + if (btn) { + e.stopPropagation(); + const metric = btn.dataset.metric; + const prompt = metricPromptMap[metric] || `Analyze the ${metric} metric and explain what I should know.`; + sendAiQuestion(prompt); + } + + // Copy button inside AI code blocks + const copyBtn = e.target.closest('.ai-copy-btn'); + if (copyBtn) { + const codeId = copyBtn.dataset.codeId; + const codeEl = codeId && document.getElementById(codeId); + if (codeEl) { + navigator.clipboard.writeText(codeEl.textContent || '').then(() => { + copyBtn.textContent = 'Copied!'; + setTimeout(() => { + copyBtn.innerHTML = ` Copy`; + }, 1500); + }); + } + } +}); + +// Cache stats for AI context +const _origUpdateDashboard = updateDashboard; +// Intercept to keep currentStats fresh +const _dashboardStatsScript = document.getElementById('dashboard-stats'); +if (_dashboardStatsScript) { + try { currentStats = JSON.parse(_dashboardStatsScript.textContent); } catch (_) {} +} +window.addEventListener('message', e => { + if (e.data && e.data.command === 'updateStats') { + currentStats = e.data.stats; + } +}, true); diff --git a/templates/dashboard/styles.css b/templates/dashboard/styles.css index 6f6dcf7..9d222b6 100644 --- a/templates/dashboard/styles.css +++ b/templates/dashboard/styles.css @@ -561,4 +561,533 @@ tr.row-focus { transition-duration: 0.01ms !important; scroll-behavior: auto !important; } +} + +/* ── AI Insights Panel ─────────────────────────────────────────── */ + +.btn-ai { + background: linear-gradient(135deg, rgba(var(--vscode-textLink-activeForeground, 99,179,237), 0.15), transparent); + border-color: var(--accent-color); + color: var(--accent-color); +} + +.btn-ai:hover { + background: rgba(99, 179, 237, 0.25); +} + +.card-ai-btn { + background: none; + border: none; + cursor: pointer; + font-size: 11px; + opacity: 0.5; + padding: 0 2px; + line-height: 1; + vertical-align: middle; + transition: opacity 0.15s; +} + +.card-ai-btn:hover { + opacity: 1; +} + +.ai-panel { + position: fixed; + top: 0; + right: 0; + width: 380px; + height: 100vh; + background: var(--vscode-sideBar-background, var(--bg-color)); + border-left: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 1000; + transform: translateX(100%); + transition: transform 0.25s ease; + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.25); +} + +.ai-panel.open { + transform: translateX(0); +} + +.ai-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.ai-panel-title { + font-size: 13px; + font-weight: 600; + color: var(--fg-color); + flex: 1; +} + +.ai-auto-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--muted-color); + cursor: pointer; + white-space: nowrap; +} + +.ai-panel-close { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + color: var(--muted-color); + padding: 2px 4px; + line-height: 1; + border-radius: 3px; +} + +.ai-panel-close:hover { + color: var(--fg-color); + background: rgba(128, 128, 128, 0.15); +} + +.ai-quick-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color); + flex-shrink: 0; +} + +.ai-chip { + background: rgba(128, 128, 128, 0.1); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 3px 10px; + font-size: 11px; + cursor: pointer; + color: var(--fg-color); + transition: background 0.15s, border-color 0.15s; +} + +.ai-chip:hover { + background: rgba(128, 128, 128, 0.2); + border-color: var(--accent-color); + color: var(--accent-color); +} + +.ai-response-area { + flex: 1; + overflow-y: auto; + padding: 10px 12px; + font-size: 12px; + line-height: 1.5; + display: flex; + flex-direction: column; + gap: 8px; +} + +.ai-welcome { + color: var(--muted-color); + text-align: center; + padding: 20px 0; + font-size: 12px; +} + +/* ── Message Bubbles (matches SQL Assistant style) ── */ + +.ai-message { + display: flex; + flex-direction: column; + gap: 3px; + animation: ai-msg-in 0.2s ease; +} + +@keyframes ai-msg-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.ai-message.user { align-items: flex-end; } +.ai-message.assistant { align-items: flex-start; } + +.ai-message-role { + font-size: 9px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--muted-color); + padding: 0 3px; + opacity: 0.7; +} + +.ai-message-bubble { + max-width: 94%; + padding: 9px 12px; + border-radius: 10px; + word-wrap: break-word; + line-height: 1.5; +} + +.ai-message.user .ai-message-bubble { + background: transparent; + border: 1px solid var(--border-color); + border-bottom-right-radius: 3px; + color: var(--fg-color); +} + +.ai-message.assistant .ai-message-bubble { + background: rgba(128, 128, 128, 0.08); + border: 1px solid rgba(128, 128, 128, 0.15); + border-bottom-left-radius: 3px; +} + +/* ── Message Content Typography (tight, chat-matching) ── */ + +.ai-message-content { + font-size: 12px; + line-height: 1.55; +} + +.ai-message-content p { + margin: 5px 0; +} + +.ai-message-content p:first-child { margin-top: 0; } +.ai-message-content p:last-child { margin-bottom: 0; } + +.ai-message-content ul, +.ai-message-content ol { + margin: 5px 0; + padding-left: 16px; +} + +.ai-message-content li { + margin: 2px 0; + line-height: 1.5; +} + +.ai-message-content li::marker { + color: var(--muted-color); +} + +.ai-message-content strong { + font-weight: 600; + color: var(--fg-color); +} + +.ai-message-content h1, +.ai-message-content h2, +.ai-message-content h3 { + margin: 10px 0 4px; + font-weight: 600; + color: var(--fg-color); +} + +.ai-message-content h1 { font-size: 1.1em; } +.ai-message-content h2 { font-size: 1.0em; } +.ai-message-content h3 { font-size: 0.95em; } + +.ai-message-content blockquote { + margin: 8px 0; + padding: 8px 12px; + border-left: 3px solid var(--accent-color); + background: rgba(128, 128, 128, 0.07); + border-radius: 0 6px 6px 0; + font-style: italic; + color: var(--muted-color); +} + +.ai-loading-dots { + display: inline-flex; + gap: 4px; + padding: 12px; +} + +.ai-loading-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent-color); + animation: ai-dot-bounce 1.2s ease-in-out infinite; + opacity: 0.6; +} + +.ai-loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.ai-loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes ai-dot-bounce { + 0%, 80%, 100% { transform: scale(1); opacity: 0.6; } + 40% { transform: scale(1.4); opacity: 1; } +} + +.ai-input-row { + display: flex; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--border-color); + flex-shrink: 0; +} + +.ai-question-input { + flex: 1; + background: rgba(128, 128, 128, 0.08); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--fg-color); + font-family: var(--font-family); + font-size: 12px; + padding: 6px 8px; + resize: none; + line-height: 1.4; +} + +.ai-question-input:focus { + outline: none; + border-color: var(--accent-color); +} + +/* ── AI Code Block & SQL Syntax Highlighting ──────────────────── */ + +.ai-code-block { + position: relative; + margin: 10px 0; + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + font-family: 'SF Mono', 'Segoe UI Mono', 'Roboto Mono', monospace; +} + +.ai-code-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 5px 10px; + background: rgba(0, 0, 0, 0.2); + border-bottom: 1px solid var(--border-color); +} + +.ai-code-lang { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted-color); +} + +.ai-copy-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + font-size: 10px; + color: var(--muted-color); + background: rgba(128, 128, 128, 0.1); + border: 1px solid var(--border-color); + border-radius: 3px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + font-family: var(--font-family); +} + +.ai-copy-btn:hover { + background: rgba(128, 128, 128, 0.2); + color: var(--fg-color); +} + +.ai-copy-btn svg { flex-shrink: 0; } + +.ai-code-content { + display: block; + padding: 12px; + font-size: 11.5px; + line-height: 1.55; + overflow-x: auto; + white-space: pre; + background: rgba(0, 0, 0, 0.12); + color: var(--fg-color); +} + +.ai-inline-code { + font-family: 'SF Mono', 'Segoe UI Mono', monospace; + font-size: 11px; + background: rgba(128, 128, 128, 0.15); + padding: 1px 5px; + border-radius: 3px; +} + +/* SQL Syntax Highlight Tokens */ +.sql-keyword { color: var(--vscode-symbolIcon-keywordForeground, #569cd6); font-weight: 600; } +.sql-function { color: var(--vscode-symbolIcon-functionForeground, #dcdcaa); } +.sql-string { color: var(--vscode-symbolIcon-stringForeground, #ce9178); } +.sql-number { color: var(--vscode-symbolIcon-numberForeground, #b5cea8); } +.sql-comment { color: var(--vscode-symbolIcon-commentForeground, #6a9955); font-style: italic; } +.sql-operator { color: var(--vscode-symbolIcon-operatorForeground, #d4d4d4); } +.sql-identifier { color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); } +.sql-punctuation{ color: var(--vscode-symbolIcon-punctuationForeground, #d4d4d4); } +.sql-type { color: var(--vscode-symbolIcon-typeParameterForeground, #4ec9b0); } + +/* ── Connection Analytics ──────────────────────────────────────── */ + +.conn-app-bar { + height: 8px; + border-radius: 4px; + display: flex; + overflow: hidden; + min-width: 60px; +} + +.conn-bar-active { background: var(--success-color); } +.conn-bar-idle { background: rgba(128, 128, 128, 0.4); } +.conn-bar-waiting { background: var(--warning-color); } + +/* ── AI Query Execution ─────────────────────────────────────────── */ + +.ai-run-confirm { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 6px; + padding: 6px 10px; + background: rgba(128, 128, 128, 0.08); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 11px; +} + +.ai-run-prompt { flex: 1; color: var(--fg-color); opacity: 0.85; } + +.ai-run-actions { display: flex; gap: 6px; } + +.ai-run-ok-btn { + background: var(--button-bg); + color: var(--button-fg); + border: none; + border-radius: 3px; + padding: 3px 10px; + font-size: 11px; + cursor: pointer; +} +.ai-run-ok-btn:hover { opacity: 0.85; } + +.ai-run-skip-btn { + background: transparent; + color: var(--fg-color); + border: 1px solid var(--border-color); + border-radius: 3px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + opacity: 0.7; +} +.ai-run-skip-btn:hover { opacity: 1; } + +.ai-run-executing { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + opacity: 0.7; +} + +.ai-run-spinner { + width: 10px; + height: 10px; + border: 2px solid var(--border-color); + border-top-color: var(--accent-color, var(--button-bg)); + border-radius: 50%; + animation: spin 0.7s linear infinite; + display: inline-block; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.ai-query-result { + margin: 4px 0 6px 0; + font-size: 12px; +} + +.ai-result-meta { + font-size: 11px; + opacity: 0.6; + margin-bottom: 4px; +} + +.ai-result-error { + color: var(--danger-color); + font-size: 12px; + padding: 6px 8px; + background: rgba(255, 80, 80, 0.08); + border-radius: 4px; +} + +.ai-result-empty { opacity: 0.6; font-size: 11px; } + +.ai-csv-btn { + background: transparent; + border: none; + color: var(--vscode-textLink-foreground, #4ec9b0); + font-size: 11px; + padding: 0 2px; + cursor: pointer; + text-decoration: underline; + opacity: 0.85; +} +.ai-csv-btn:hover { opacity: 1; } + +/* ── Schema Health Tab ─────────────────────────────────────────── */ + +.bloat-bar { + height: 6px; + border-radius: 3px; + background: var(--success-color); + display: inline-block; + min-width: 2px; + max-width: 80px; +} + +.bloat-bar.warn { background: var(--warning-color); } +.bloat-bar.crit { background: var(--danger-color); } + +.vacuum-progress-bar { + height: 8px; + background: rgba(128, 128, 128, 0.15); + border-radius: 4px; + overflow: hidden; + min-width: 100px; +} + +.vacuum-progress-fill { + height: 100%; + background: var(--accent-color); + border-radius: 4px; + transition: width 0.3s ease; +} + +.schema-badge { + display: inline-block; + padding: 1px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 500; +} + +.schema-badge.ok { background: rgba(74, 222, 128, 0.15); color: var(--success-color); } +.schema-badge.warn { background: rgba(250, 204, 21, 0.15); color: var(--warning-color); } +.schema-badge.crit { background: rgba(248, 113, 113, 0.15); color: var(--danger-color); } + +/* Adjust body padding when AI panel is open so content doesn't hide behind panel */ +body.ai-panel-open #main-view { + margin-right: 385px; + transition: margin-right 0.25s ease; +} + +@media (max-width: 820px) { + .ai-panel { + width: 100%; + } } \ No newline at end of file From 2706989d07bf4b7054e3dcb1430e94f8f892fed5 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 15:48:12 +0530 Subject: [PATCH 6/8] refactor: Update dashboard metrics and enhance AI interaction - Renamed "Cache Hit Ratio" to "Shared Buffer Hit Ratio" with updated notes. - Changed "Index Hit Ratio (Context)" to "Index Block Hit Ratio (Context)" for clarity. - Updated schema health notes and descriptions for unused indexes and dead tuple pressure. - Introduced new metrics tracking for schema health, including unused indexes and vacuum attention. - Enhanced AI panel with a clear conversation button and improved context handling. - Added quirky loading messages during AI processing for better user experience. - Implemented follow-up question chips for AI responses to facilitate user interaction. - Adjusted CSS styles for new components and improved visual feedback on hover states. --- CHANGELOG.md | 16 + README.md | 13 + docs/WEBSITE_CONTEXT.md | 3 +- docs/html/assistant-panel.html | 4 +- docs/html/editor-file-views.html | 89 ++++- docs/html/minimized-overview.html | 39 ++- docs/index.html | 31 +- docs/js/core-state.js | 28 +- src/dashboard/DashboardData.ts | 179 ++++++++-- src/dashboard/DashboardPanel.ts | 272 +++++++++++++-- templates/dashboard/index.html | 29 +- templates/dashboard/scripts.js | 560 +++++++++++++++++++++++++++--- templates/dashboard/styles.css | 60 +++- 13 files changed, 1169 insertions(+), 154 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee11894..ddae873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to the PostgreSQL Explorer extension will be documented in t The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-04-19 + +### Added +- **SQL Assistant editor tabs**: Added `postgres-explorer.openSqlAssistantTab` so users can open SQL Assistant in the main editor area, not only in the sidebar container. +- **Multi-tab SQL Assistant workflow**: SQL Assistant now supports opening multiple editor tabs for parallel AI conversations and context switching. +- **AI Insights dashboard panel**: Added a richer dashboard assistant surface with schema-health metrics, connection analytics, vacuum progress, and direct Ask AI actions. + +### Fixed +- **SQL completion deduplication**: Table and column completion items are now deduplicated before caching, preventing repeated suggestions in notebook SQL autocomplete. +- **Assistant routing consistency**: Chat attachments and assistant updates now route to the active SQL Assistant webview (sidebar view or editor tab), which keeps multi-tab conversations in sync. +- **Review changes UI stability**: The result review / compare UI now renders more consistently and keeps action visibility aligned with the active table state. + +### Changed +- **Dashboard telemetry expansion**: Dashboard stats now include unused indexes, high sequential-scan tables, table bloat, autovacuum progress, tables needing vacuum, and connections grouped by application name. +- **Dashboard AI interactions**: AI prompts can be launched from dashboard context, queries can be executed for analysis, CSV can be downloaded from AI-assisted query results, and health degradation can trigger auto-notify behavior. + ## [1.0.0] - 2026-04-14 ### Production Stable Release diff --git a/README.md b/README.md index 354a449..b165b0c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,13 @@ --- +## 🆕 Since v1.0.0 + +- 🧩 **SQL Assistant in Editor Tabs** — Open SQL Assistant directly in the main editor area using `SQL Assistant: Open in Editor Tab`. +- 🗂️ **Multiple SQL Assistant Tabs** — Run parallel assistant conversations in separate tabs and switch context faster. + +--- + ## 📺 Video Guides ### 1. Setup @@ -56,6 +63,7 @@ - 🛡️ **Auto-LIMIT** — Intelligent query protection (configurable, default 1000 rows) - 🌍 **Foreign Data Wrappers** — Manage foreign servers, user mappings & tables - 🤖 **AI-Powered** — Generate, Optimize, Explain & Analyze with guided follow-ups and next-step suggestions (GitHub Models, OpenAI, Anthropic, Gemini, VS Code LM) +- 🧩 **Flexible SQL Assistant Layout** — Open SQL Assistant in editor tabs and keep multiple assistant tabs open simultaneously - 🖼️ **Vision AI** — Paste or upload images directly in the SQL Assistant; sent to vision-capable AI providers - 📎 **File Preview** — Click attached file chips to open them as preview tabs in the editor - 📤 **Export Data** — Export results to CSV, JSON, or Excel @@ -242,6 +250,11 @@ Use GitHub Models without manually managing a PAT in normal VS Code authenticati - **Model Catalog Access**: List and select available GitHub-hosted models. - **Session-Based Auth**: Uses VS Code GitHub authentication sessions instead of storing provider tokens. +### 🧩 SQL Assistant Tabs +Use SQL Assistant where you work, not only in the sidebar. +- **Open in Editor Tab**: Run `SQL Assistant: Open in Editor Tab` from Command Palette. +- **Parallel Assistants**: Open multiple SQL Assistant tabs for separate tasks (e.g., optimization, migration, and schema exploration). + ### 🪄 Generate Query (Natural Language → SQL) Describe what you need in plain English (e.g., "Show me top 10 users by order count"), and PgStudio will generate the SQL for you using your schema context. - **Command Palette**: `AI: Generate Query` diff --git a/docs/WEBSITE_CONTEXT.md b/docs/WEBSITE_CONTEXT.md index 3bb6cb3..d38e10d 100644 --- a/docs/WEBSITE_CONTEXT.md +++ b/docs/WEBSITE_CONTEXT.md @@ -1,6 +1,6 @@ # Docs Website Context -Last updated: 2026-04-16 +Last updated: 2026-04-19 Primary entry: docs/index.html ## What This Website Is @@ -80,6 +80,7 @@ The page positions PgStudio around five practical outcomes: - Explorer-driven schema navigation - Notebook-first SQL workflows - AI-assisted SQL reasoning and optimization +- Flexible SQL Assistant placement (sidebar or editor tabs) with multi-tab workflows - Performance tooling and explainability The assistant panel is designed as a guided onboarding surface, not a full chatbot backend. Responses are curated to demonstrate capabilities and funnel to installation. diff --git a/docs/html/assistant-panel.html b/docs/html/assistant-panel.html index 4d787c8..4f6b676 100644 --- a/docs/html/assistant-panel.html +++ b/docs/html/assistant-panel.html @@ -24,7 +24,7 @@

SQL Assistant

-

Quick highlights from PgStudio’s README to help you explore the product.

+

Quick highlights from PgStudio’s README, including editor-tab and multi-tab assistant workflows.

+
+
💬 Common Questions
+
+ +
+ ? + Is PgStudio free? +
+

Yes — completely free, no subscription or account required. Install directly from the VS Code Marketplace or Open VSX Registry in under a minute.

+
+
+ +
+ ? + Which AI providers are supported? +
+

OpenAI (GPT-4o), Anthropic Claude, Google Gemini, GitHub Models (free, no API key needed), and VS Code Language Model API. GitHub Models requires no configuration.

+
+
+ +
+ ? + How is PgStudio different from pgAdmin or DBeaver? +
+

PgStudio runs inside VS Code — no separate app to install or context-switch to. Your SQL notebooks, schema explorer, and AI assistant stay in the same window as your code.

+
+
+ +
+ ? + What PostgreSQL versions are supported? +
+

PostgreSQL 12 through 17, plus compatible managed services like Amazon RDS PostgreSQL, Supabase, Neon, and other pg-compatible databases.

+
+
+ +
+ ? + Does PgStudio support SSH tunnels? +
+

Yes — built-in SSH tunnel support lets you connect to databases on private networks or behind firewalls with no extra tooling required.

+
+
+ +
+ ? + Can I share SQL notebooks with my team? +
+

.pgsql notebooks are plain files that commit to git like any source file. Your team can replay the same queries against their own connections — no export needed.

+
+
+ +
+ ? + Does PgStudio work on Windows, macOS, and Linux? +
+

Yes. PgStudio works anywhere VS Code runs, including Windows, macOS, and Linux distributions used for PostgreSQL development.

+
+
+ +
+
+
@@ -470,12 +547,18 @@

✨ AI Assistant

questions, writes the right SQL.

-
4AI providers
+
5AI providers
<80msafter index fix
0 keysneeded with GitHub Models
+
+ +
Supported AI providers: OpenAI (GPT-4o, GPT-4-turbo), Anthropic Claude (claude-3-5-sonnet, claude-3-opus), Google Gemini (gemini-1.5-pro), GitHub Models (free, zero config), and VS Code Language Model API for Copilot users.
+
+

AI SQL assistant features include text to SQL generation, SQL explanation, EXPLAIN plan analysis, index recommendations, and PostgreSQL query tuning suggestions.

+
EXAMPLE: EXPLAIN ANALYSIS
Seq Scan on orders  (cost=0.00..14823.40 rows=480000)
diff --git a/docs/html/minimized-overview.html b/docs/html/minimized-overview.html
index 9ac7430..9b19334 100644
--- a/docs/html/minimized-overview.html
+++ b/docs/html/minimized-overview.html
@@ -1,9 +1,17 @@
-
+
+ + + + + +
+ + +
-

VS Code Marketplace 0.9.20260409

+

VS Code Marketplace 0.9.20260409

Powerful PostgreSQL Management Inside VS Code

-

Write SQL, manage databases, and leverage AI - all without leaving your editor. Interactive - notebooks, live dashboards, and intelligent assistance in one seamless extension.

+

Write SQL, manage PostgreSQL databases, and leverage AI — all without leaving VS Code. Interactive SQL notebooks (.pgsql), live dashboards, schema explorer, and AI-assisted query optimization with OpenAI, Anthropic Claude, Gemini, and GitHub Models.

+ +

PgStudio helps developers query PostgreSQL in VS Code with notebooks, explorer navigation, schema tools, AI-assisted SQL generation, EXPLAIN analysis, and performance optimization workflows.

+
\ No newline at end of file diff --git a/docs/index.html b/docs/index.html index ccca2ea..218e565 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,23 +4,25 @@ - PgStudio - PostgreSQL Management for VS Code + PgStudio - PostgreSQL VS Code Extension with SQL Notebooks, AI Assistant, and Explorer + content="PgStudio is a PostgreSQL extension for VS Code with SQL notebooks, database explorer, AI SQL assistant, schema tools, and performance optimization workflows."> + content="postgresql vscode extension, sql notebooks, postgres explorer, ai sql assistant, query optimization, explain analyze, schema designer, erd diagram, dbeaver alternative, pgadmin alternative, github models sql"> + - + + content="Run PostgreSQL workflows in VS Code with notebooks, explorer tree, AI SQL help, schema tools, and query performance insights."> + - + + content="Connect, query, analyze, and optimize PostgreSQL in VS Code with AI assistance and notebook workflows."> @@ -37,9 +39,22 @@ "name": "PgStudio", "applicationCategory": "DeveloperApplication", "operatingSystem": "Windows, macOS, Linux", - "description": "PostgreSQL management inside VS Code with explorer, notebooks, AI assistance, and performance tools.", + "description": "PostgreSQL management inside VS Code with SQL notebooks, database explorer, AI assistance, schema design, and performance tools.", "url": "https://pgstudio.astrx.dev/", "downloadUrl": "https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer", + "keywords": "postgresql vscode extension, sql notebooks, ai sql assistant, schema explorer, query optimization", + "featureList": [ + "Interactive .pgsql SQL notebooks", + "PostgreSQL database explorer", + "AI assistant for SQL generation and optimization", + "Visual schema tools and ERD diagrams", + "Safety controls for production databases" + ], + "sameAs": [ + "https://github.com/dev-asterix/PgStudio", + "https://open-vsx.org/extension/ric-v/postgres-explorer", + "https://marketplace.visualstudio.com/items?itemName=ric-v.postgres-explorer" + ], "offers": { "@type": "Offer", "price": "0", diff --git a/docs/js/core-state.js b/docs/js/core-state.js index e829204..2f54e27 100644 --- a/docs/js/core-state.js +++ b/docs/js/core-state.js @@ -33,10 +33,10 @@ const BREADCRUMB_LABELS = { }; const FEATURE_DETAILS = { - notebooks: "Notebook workflows keep query logic, explanations, and outcomes together so teams can review and repeat analysis.", - explorer: "Explorer navigation keeps schema context close to your query so you spend less time switching between tools.", - ai: "AI assistance can explain query intent, suggest safer rewrites, and provide targeted optimization guidance.", - safety: "Environment tags and confirmation prompts reduce accidental execution in sensitive systems." + notebooks: "Notebook workflows keep SQL query logic, explanations, and outcomes together so teams can review, version, and replay PostgreSQL analysis in VS Code.", + explorer: "Explorer navigation keeps PostgreSQL schema context close to your query so you spend less time switching between tools and more time shipping data work.", + ai: "AI assistance can generate SQL from plain English, explain query intent, suggest safer rewrites, and provide targeted PostgreSQL optimization guidance.", + safety: "Environment tags, read-only controls, and confirmation prompts reduce accidental execution in sensitive development, staging, and production systems." }; const PRODUCT_HIGHLIGHT_ORDER = [ @@ -127,16 +127,16 @@ const PRODUCT_HIGHLIGHTS = { // ── Search index ────────────────────────────────────────── const SEARCH_INDEX = [ - { key: "readme", label: "README.md", path: "PGSTUDIO", text: "overview connect sql notebooks explorer ai assistant schema safety performance pgstudio" }, - { key: "query", label: "query.pgsql", path: "NOTEBOOKS", text: "sql query run execute select orders revenue notebook cell results daily aggregation" }, - { key: "features", label: "features.md", path: "PGSTUDIO", text: "features notebooks explorer ai assistant schema tools visual safety performance 50 capabilities" }, - { key: "connections", label: "connections.demo", path: "WORKFLOW", text: "connect database host port username password ssl tls dev stage prod environment connection" }, - { key: "install", label: "INSTALL.md", path: "WORKFLOW", text: "install marketplace extension vscode download get started setup" }, - { key: "doc-notebooks", label: "01_notebooks.md", path: "DOCUMENTATION", text: "notebook pgsql sql cells run execute results export csv json history saved queries markdown notes" }, - { key: "doc-explorer", label: "02_explorer.md", path: "DOCUMENTATION", text: "explorer tables views columns indexes constraints right-click generate scripts schema objects" }, - { key: "doc-ai", label: "03_ai-assist.md", path: "DOCUMENTATION", text: "ai assistant openai claude gemini ask plain english sql explain optimize diagnose github models" }, - { key: "doc-schema", label: "04_schema-tools.md", path: "DOCUMENTATION", text: "schema visual designer erd diagram diff migration import csv json compare alter table" }, - { key: "doc-safety", label: "05_safety.md", path: "DOCUMENTATION", text: "safety prod dev stage read-only risk score environment labels ssh tunnel performance explain index advisor" } + { key: "readme", label: "README.md", path: "PGSTUDIO", text: "overview connect sql notebooks explorer ai assistant schema safety performance pgstudio postgres postgresql vs code extension free open source database management developer tool sql ide database client query editor productivity" }, + { key: "query", label: "query.pgsql", path: "NOTEBOOKS", text: "sql query run execute select orders revenue notebook cell results daily aggregation date_trunc count sum group by where interval explain analyze explain plan query tuning performance optimization index recommendation" }, + { key: "features", label: "features.md", path: "PGSTUDIO", text: "features notebooks explorer ai assistant schema tools visual safety performance 50 capabilities pgadmin dbeaver alternative vs code native postgres client free faq questions postgresql gui sql workflow developer experience" }, + { key: "connections", label: "connections.demo", path: "WORKFLOW", text: "connect database host port username password ssl tls dev stage prod environment connection ssh tunnel rds supabase neon amazon secure secretstorage connection manager postgres connection setup" }, + { key: "install", label: "INSTALL.md", path: "WORKFLOW", text: "install marketplace extension vscode download get started setup quick start pgsql extension id ric-v postgres-explorer open vsx visual studio code install postgres extension" }, + { key: "doc-notebooks", label: "01_notebooks.md", path: "DOCUMENTATION", text: "notebook pgsql sql cells run execute results export csv json history saved queries markdown notes jupyter git commit share team reproducible interactive data analysis workflow" }, + { key: "doc-explorer", label: "02_explorer.md", path: "DOCUMENTATION", text: "explorer tables views columns indexes constraints right-click generate scripts schema objects functions triggers partitions fdw foreign data wrapper materialized views sequences database browser postgres object explorer" }, + { key: "doc-ai", label: "03_ai-assist.md", path: "DOCUMENTATION", text: "ai assistant openai gpt-4o claude anthropic gemini google github models ask plain english sql explain optimize diagnose index advisor slow query performance vs code lm api copilot text to sql sql assistant" }, + { key: "doc-schema", label: "04_schema-tools.md", path: "DOCUMENTATION", text: "schema visual designer erd entity relationship diagram diff migration import csv json compare alter table create primary key foreign key constraint unique check index ddl generator" }, + { key: "doc-safety", label: "05_safety.md", path: "DOCUMENTATION", text: "safety prod dev stage read-only risk score environment labels ssh tunnel performance explain index advisor delete without where truncate drop confirmation limit 1000 production protection guardrails" } ]; // ── SQL Assistant responses ─────────────────────────────── diff --git a/src/dashboard/DashboardData.ts b/src/dashboard/DashboardData.ts index 6cc109a..aed871e 100644 --- a/src/dashboard/DashboardData.ts +++ b/src/dashboard/DashboardData.ts @@ -64,6 +64,7 @@ export interface DashboardStats { }[]; waitEvents: { type: string; count: number }[]; longRunningQueries: number; + sharedCacheHitRatio: number | null; indexHitRatio: number; oldestTransactionAgeSeconds: number; vacuumTablesNeedingAttention: number; @@ -80,9 +81,9 @@ export interface DashboardStats { /** Currently running autovacuum workers. */ autovacuumProgress: { pid: number; table_name: string; phase: string; heap_blks_scanned: number; heap_blks_total: number }[]; /** Tables with notable dead tuples that need vacuum. */ - tablesNeedingVacuum: { table_name: string; n_dead_tup: number; n_live_tup: number; last_autovacuum: string | null; last_autoanalyze: string | null }[]; + tablesNeedingVacuum: { table_name: string; n_dead_tup: number; n_live_tup: number; dead_tuple_threshold: number; last_autovacuum: string | null; last_autoanalyze: string | null }[]; /** Active connections grouped by application_name and state. */ - connectionsByApp: { application_name: string; state: string; count: number }[]; + connectionsByApp: { application_name: string; state: string; waiting: boolean; count: number }[]; } export interface WalReplicationStats { @@ -131,6 +132,51 @@ export interface WalReplicationStats { import { Client, PoolClient } from 'pg'; export async function fetchStats(client: Client | PoolClient, dbName: string): Promise { + // Resolve version-specific stats source for checkpoints. + let serverVersionNum = 0; + try { + const versionRes = await client.query(`SHOW server_version_num`); + serverVersionNum = Number(versionRes.rows?.[0]?.server_version_num || 0); + } catch { + serverVersionNum = 0; + } + + const checkpointStatsView = serverVersionNum >= 170000 + ? 'pg_stat_checkpointer' + : 'pg_stat_bgwriter'; + + // Resolve pg_stat_statements timing column across PostgreSQL versions. + let pgStatStatementsTimeExpr = 'total_time'; + let pgStatStatementsMeanExpr = 'mean_time'; + try { + const pgStatStatementsColumnRes = await client.query(` + SELECT + EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = to_regclass('pg_stat_statements') + AND attname = 'total_exec_time' + AND NOT attisdropped + ) AS has_total_exec_time, + EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = to_regclass('pg_stat_statements') + AND attname = 'mean_exec_time' + AND NOT attisdropped + ) AS has_mean_exec_time + `); + if (Boolean(pgStatStatementsColumnRes.rows?.[0]?.has_total_exec_time)) { + pgStatStatementsTimeExpr = 'total_exec_time'; + } + if (Boolean(pgStatStatementsColumnRes.rows?.[0]?.has_mean_exec_time)) { + pgStatStatementsMeanExpr = 'mean_exec_time'; + } + } catch { + pgStatStatementsTimeExpr = 'total_time'; + pgStatStatementsMeanExpr = 'mean_time'; + } + // Fetch data with error handling for each query to prevent one failure from breaking the entire dashboard const [ dbInfoRes, @@ -142,6 +188,7 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P locksRes, metricsRes, settingsRes, + checkpointMetricsRes, pgStatRes, waitsRes, longQueriesRes, @@ -245,13 +292,23 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid LEFT JOIN pg_catalog.pg_class c ON c.oid = blocked_locks.relation WHERE NOT blocked_locks.granted + AND blocked_activity.datname = $1 AND blocking_activity.datname = $1 `, [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) + // Database metrics scoped to the selected database. client.query(` - SELECT * + SELECT + xact_commit, + xact_rollback, + blks_read, + blks_hit, + deadlocks, + conflicts, + temp_bytes, + temp_files, + tup_fetched, + tup_returned FROM pg_stat_database WHERE datname = $1 `, [dbName]), @@ -259,13 +316,22 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P // Settings (Max Connections) client.query(`SHOW max_connections`), + // Checkpoint counters are cluster-level and moved from pg_stat_bgwriter to pg_stat_checkpointer in PG 17. + client.query(` + SELECT + COALESCE(checkpoints_timed, 0) AS checkpoints_timed, + COALESCE(checkpoints_req, 0) AS checkpoints_req + FROM ${checkpointStatsView} + LIMIT 1 + `), + // 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 + client.query(` + SELECT query, calls, ${pgStatStatementsTimeExpr} AS total_time, ${pgStatStatementsMeanExpr} AS mean_time, rows FROM pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = $1) - ORDER BY total_time DESC + ORDER BY ${pgStatStatementsTimeExpr} DESC LIMIT 10 `, [dbName]), @@ -404,15 +470,22 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P ORDER BY slot_name `), - // Unused indexes (never scanned) + // Unused indexes (never scanned), excluding PK/UNIQUE/constraint-backed indexes. client.query(` - SELECT schemaname || '.' || indexrelname AS index_name, - tablename AS table_name, - pg_size_pretty(pg_relation_size(indexrelid)) AS index_size, - pg_relation_size(indexrelid) AS raw_size - FROM pg_stat_user_indexes - WHERE idx_scan = 0 - AND schemaname NOT IN ('pg_catalog', 'information_schema') + SELECT s.schemaname || '.' || s.indexrelname AS index_name, + s.tablename AS table_name, + pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size, + pg_relation_size(s.indexrelid) AS raw_size + FROM pg_stat_user_indexes s + JOIN pg_index i + ON i.indexrelid = s.indexrelid + LEFT JOIN pg_constraint c + ON c.conindid = s.indexrelid + WHERE s.idx_scan = 0 + AND s.schemaname NOT IN ('pg_catalog', 'information_schema') + AND c.oid IS NULL + AND NOT i.indisprimary + AND NOT i.indisunique ORDER BY raw_size DESC LIMIT 20 `), @@ -433,7 +506,7 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P LIMIT 20 `), - // Table bloat via dead tuple ratio + // Dead-tuple pressure proxy (not a physical bloat estimate). client.query(` SELECT schemaname || '.' || relname AS table_name, n_live_tup, @@ -458,19 +531,42 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P COALESCE(heap_blks_scanned, 0) AS heap_blks_scanned, COALESCE(heap_blks_total, 0) AS heap_blks_total FROM pg_stat_progress_vacuum - `), + WHERE datname = $1 + `, [dbName]), - // Tables needing vacuum (notable dead tuples) + // Tables needing vacuum based on effective autovacuum thresholds. client.query(` - SELECT schemaname || '.' || relname AS table_name, - n_dead_tup, - n_live_tup, - last_autovacuum::text, - last_autoanalyze::text - FROM pg_stat_user_tables - WHERE n_dead_tup > 500 - AND schemaname NOT IN ('pg_catalog', 'information_schema') - ORDER BY n_dead_tup DESC + SELECT st.schemaname || '.' || st.relname AS table_name, + st.n_dead_tup, + st.n_live_tup, + ROUND( + COALESCE(opts.vacuum_threshold, current_setting('autovacuum_vacuum_threshold')::numeric) + + COALESCE(opts.vacuum_scale_factor, current_setting('autovacuum_vacuum_scale_factor')::numeric) + * GREATEST(st.n_live_tup, 0), + 0 + )::bigint AS dead_tuple_threshold, + st.last_autovacuum::text, + st.last_autoanalyze::text + FROM pg_stat_user_tables st + JOIN pg_class c ON c.oid = st.relid + LEFT JOIN LATERAL ( + SELECT + MAX(CASE WHEN option_name = 'autovacuum_vacuum_threshold' THEN option_value::numeric END) AS vacuum_threshold, + MAX(CASE WHEN option_name = 'autovacuum_vacuum_scale_factor' THEN option_value::numeric END) AS vacuum_scale_factor + FROM pg_options_to_table(c.reloptions) + ) opts ON TRUE + WHERE st.schemaname NOT IN ('pg_catalog', 'information_schema') + AND st.n_dead_tup > ( + COALESCE(opts.vacuum_threshold, current_setting('autovacuum_vacuum_threshold')::numeric) + + COALESCE(opts.vacuum_scale_factor, current_setting('autovacuum_vacuum_scale_factor')::numeric) + * GREATEST(st.n_live_tup, 0) + ) + ORDER BY (st.n_dead_tup - ROUND( + COALESCE(opts.vacuum_threshold, current_setting('autovacuum_vacuum_threshold')::numeric) + + COALESCE(opts.vacuum_scale_factor, current_setting('autovacuum_vacuum_scale_factor')::numeric) + * GREATEST(st.n_live_tup, 0), + 0 + )) DESC LIMIT 20 `), @@ -478,13 +574,15 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P client.query(` SELECT COALESCE(NULLIF(application_name, ''), 'unknown') AS application_name, COALESCE(state, 'unknown') AS state, + (wait_event_type IS NOT NULL) AS waiting, COUNT(*)::int AS count FROM pg_stat_activity WHERE pid <> pg_backend_pid() - GROUP BY application_name, state + AND datname = $1 + GROUP BY application_name, state, waiting ORDER BY count DESC LIMIT 20 - `), + `, [dbName]), ]); // Helper to safely extract result or return empty default @@ -506,10 +604,11 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P 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, - temp_bytes: 0, temp_files: 0, checkpoints_timed: 0, checkpoints_req: 0, + temp_bytes: 0, temp_files: 0, tup_fetched: 0, tup_returned: 0 }; const maxConnRow = getResult(settingsRes).rows[0] || { max_connections: '100' }; + const checkpointMetricsRow = getResult(checkpointMetricsRes).rows[0] || { checkpoints_timed: 0, checkpoints_req: 0 }; const pgStatRows = getResult(pgStatRes).rows || []; const waitEventsRows = getResult(waitsRes).rows; const longQueriesRow = getResult(longQueriesRes).rows[0] || { count: 0 }; @@ -616,6 +715,13 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P connectionStates.push({ state: row.state || 'unknown', count }); }); + const metricsBlksRead = parseInt(metricsRow.blks_read || '0'); + const metricsBlksHit = parseInt(metricsRow.blks_hit || '0'); + const totalBlockAccesses = metricsBlksRead + metricsBlksHit; + const sharedCacheHitRatio = totalBlockAccesses > 0 + ? (100.0 * metricsBlksHit) / totalBlockAccesses + : null; + return { dbName: dbName, owner: dbInfo?.owner || 'Unknown', @@ -665,14 +771,14 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P metrics: { xact_commit: parseInt(metricsRow.xact_commit || '0'), xact_rollback: parseInt(metricsRow.xact_rollback || '0'), - blks_read: parseInt(metricsRow.blks_read || '0'), - blks_hit: parseInt(metricsRow.blks_hit || '0'), + blks_read: metricsBlksRead, + blks_hit: metricsBlksHit, deadlocks: parseInt(metricsRow.deadlocks || '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'), + checkpoints_timed: parseInt(checkpointMetricsRow.checkpoints_timed || '0'), + checkpoints_req: parseInt(checkpointMetricsRow.checkpoints_req || '0'), tuples_fetched: parseInt(metricsRow.tup_fetched || '0'), tuples_returned: parseInt(metricsRow.tup_returned || '0') }, @@ -688,6 +794,7 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P count: parseInt(r.count) })), longRunningQueries: parseInt(longQueriesRow.count), + sharedCacheHitRatio, indexHitRatio: Math.max(0, Math.min(100, Number(indexHitRow.index_hit_ratio || 100))), oldestTransactionAgeSeconds: parseInt(oldestTxRow.oldest_tx_age_seconds || '0'), vacuumTablesNeedingAttention: parseInt(vacuumHealthRow.tables_needing_attention || '0'), @@ -723,12 +830,14 @@ export async function fetchStats(client: Client | PoolClient, dbName: string): P table_name: r.table_name, n_dead_tup: parseInt(r.n_dead_tup || '0'), n_live_tup: parseInt(r.n_live_tup || '0'), + dead_tuple_threshold: parseInt(r.dead_tuple_threshold || '0'), last_autovacuum: r.last_autovacuum ?? null, last_autoanalyze: r.last_autoanalyze ?? null, })), connectionsByApp: connectionsByAppRows.map((r: any) => ({ application_name: r.application_name, state: r.state, + waiting: r.waiting === true || r.waiting === 't' || r.waiting === 'true', count: parseInt(r.count || '0'), })), }; diff --git a/src/dashboard/DashboardPanel.ts b/src/dashboard/DashboardPanel.ts index e08cfa0..5d2a18c 100644 --- a/src/dashboard/DashboardPanel.ts +++ b/src/dashboard/DashboardPanel.ts @@ -6,6 +6,7 @@ import { ConnectionManager } from '../services/ConnectionManager'; import { ConnectionConfig } from '../common/types'; import { createMetadata, createAndShowNotebook } from '../commands/connection'; import { AiService } from '../providers/chat/AiService'; +import { ChatMessage } from '../providers/chat/types'; export class DashboardPanel { private static panels: Map = new Map(); @@ -16,6 +17,7 @@ export class DashboardPanel { private _lastStats: DashboardStats | null = null; private _autoNotifyEnabled = false; private _lastHealthCritical = false; + private _conversationMessages: ChatMessage[] = []; private constructor(panel: vscode.WebviewPanel, private readonly config: ConnectionConfig, private readonly dbName: string, panelKey: string, private readonly extensionUri: vscode.Uri) { this._panel = panel; @@ -73,6 +75,9 @@ export class DashboardPanel { case 'toggleAutoNotify': this._autoNotifyEnabled = Boolean(message.enabled); break; + case 'clearConversation': + this._conversationMessages = []; + break; case 'downloadCsv': await this._downloadCsv(message.csv, message.filename); break; @@ -163,13 +168,28 @@ export class DashboardPanel { try { const config = vscode.workspace.getConfiguration('postgresExplorer'); const provider = config.get('aiProvider') ?? 'vscode-lm'; + + // Refresh system prompt with latest stats context each turn const systemPrompt = this._buildDashboardSystemPrompt(context); + + // Pass conversation history so the AI has multi-turn context + this._aiService.setMessages(this._conversationMessages); + let result: { text: string }; if (provider === 'vscode-lm') { result = await this._aiService.callVsCodeLm(question, config, systemPrompt); } else { result = await this._aiService.callDirectApi(provider, question, config, systemPrompt); } + + // Persist this turn so follow-up questions have context + this._conversationMessages.push({ role: 'user', content: question }); + this._conversationMessages.push({ role: 'assistant', content: result.text }); + // Cap history at 20 messages (10 turns) to avoid token bloat + if (this._conversationMessages.length > 20) { + this._conversationMessages = this._conversationMessages.slice(-20); + } + this._panel.webview.postMessage({ command: 'aiResponse', text: result.text }); } catch (err: any) { this._panel.webview.postMessage({ command: 'aiResponse', text: `**Error:** ${err.message}` }); @@ -180,7 +200,11 @@ export class DashboardPanel { private async _executeQueryForAI(sql: string, question: string) { const trimmed = sql.trim(); - const upper = trimmed.toUpperCase().replace(/\s+/g, ' '); + const sqlWithoutLeadingComments = trimmed + .replace(/^\s*\/\*[\s\S]*?\*\//, '') + .replace(/^\s*--.*(?:\r?\n|$)/gm, '') + .trim(); + const upper = sqlWithoutLeadingComments.toUpperCase().replace(/\s+/g, ' '); const isReadOnly = /^(SELECT|WITH|EXPLAIN)\b/.test(upper); if (!isReadOnly) { this._panel.webview.postMessage({ @@ -194,12 +218,15 @@ export class DashboardPanel { client = await this.getClient(); await client.query('SET statement_timeout = 10000'); const result = await client.query(trimmed); + const normalizedRows = result.rows + .slice(0, 100) + .map((row: Record) => this._normalizeQueryRow(row)); this._panel.webview.postMessage({ command: 'queryForAIResult', sql: trimmed, question, columns: result.fields.map((f: any) => f.name), - rows: result.rows.slice(0, 100), + rows: normalizedRows, rowCount: result.rowCount ?? result.rows.length, }); } catch (err: any) { @@ -214,64 +241,251 @@ export class DashboardPanel { } } + private _normalizeQueryRow(row: Record): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(row || {})) { + normalized[key] = this._normalizeQueryValue(value); + } + return normalized; + } + + private _normalizeQueryValue(value: any): any { + if (value === null || value === undefined) return value; + + const valueType = typeof value; + if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') { + return value; + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return value.map(v => this._normalizeQueryValue(v)); + } + + if (valueType === 'object') { + const intervalText = this._formatIntervalLikeObject(value); + if (intervalText) return intervalText; + + const toPostgres = (value as { toPostgres?: () => string }).toPostgres; + if (typeof toPostgres === 'function') { + try { + return toPostgres.call(value); + } catch { + // Fall through to JSON serialization. + } + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + return String(value); + } + + private _formatIntervalLikeObject(value: any): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + + const keys = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'milliseconds']; + const hasIntervalShape = keys.some(k => k in value); + if (!hasIntervalShape) return null; + + const toNum = (v: any): number => { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + }; + + const parts: string[] = []; + const units: Array<{ key: string; label: string }> = [ + { key: 'years', label: 'year' }, + { key: 'months', label: 'month' }, + { key: 'days', label: 'day' }, + { key: 'hours', label: 'hour' }, + { key: 'minutes', label: 'minute' }, + { key: 'seconds', label: 'second' }, + { key: 'milliseconds', label: 'millisecond' } + ]; + + for (const unit of units) { + const n = toNum(value[unit.key]); + if (n === 0) continue; + const abs = Math.abs(n); + parts.push(`${n} ${unit.label}${abs === 1 ? '' : 's'}`); + } + + return parts.length > 0 ? parts.join(' ') : '0 seconds'; + } + private _buildDashboardSystemPrompt(contextSummary: string): string { - return `You are an expert PostgreSQL DBA assistant embedded in a live monitoring dashboard. + return `# PostgreSQL DBA Dashboard Assistant -${contextSummary ? `## Current Database State\n${contextSummary}\n` : ''} +You are an expert PostgreSQL DBA assistant embedded in a live monitoring dashboard. +Your job is to help operators understand metrics, diagnose bottlenecks, and navigate performance issues step by step. -## Your Role -- Answer questions about database health, performance, locks, and schema directly and concisely -- When you need live data, write a SELECT query in a \`\`\`sql block — the UI will present it to the user for approval and execution, then return the results to you automatically -- When query results are provided in the message, interpret them directly and answer the original question — do NOT ask to run another query -- Keep answers short and to the point +## Safety Rules (CRITICAL — Never Violate) +- ONLY generate SELECT, WITH (read-only CTEs), or EXPLAIN queries +- NEVER generate INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, or any DDL/DML +- Always add a LIMIT clause (max 100 rows) unless the user explicitly requests more +- If the user asks for a write operation, explain this is a read-only monitoring dashboard and suggest using the SQL notebook for write operations -## Rules for SQL Generation -- ONLY write SELECT, WITH (read-only CTE), or EXPLAIN queries — NEVER INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, or TRUNCATE -- Always wrap SQL in \`\`\`sql fenced code blocks -- Always add a LIMIT clause (max 100 rows unless the user asks for more) -- Do NOT ask "shall I run this?" or "would you like me to execute?" — the UI handles that automatically -- Do NOT invent or assume data values — query for them +## SQL Query Rules +- Always wrap SQL in \`\`\`sql fenced blocks so the UI can render the Run button +- Do NOT ask "shall I run this?" or "would you like me to execute?" — the UI handles approval automatically +- When query results arrive in the conversation, interpret them directly — do NOT ask to run another query +- Use parameterized-safe patterns, reference real pg_catalog / information_schema views +- NEVER repeat the same SQL query (or a trivial variant) already attempted in this thread unless the user explicitly asks to re-run it +- NEVER repeat the same follow-up question already asked in this thread +- If evidence is sufficient, stop generating SQL and provide a final investigation summary + +## How to Investigate Systematically +1. Start with the current snapshot provided below — answer from it when sufficient +2. If you need deeper data, write one targeted diagnostic SQL query +3. When results return, synthesize and give a concrete actionable answer +4. Suggest the next logical investigation step + +## Common Investigation Paths +- **Blocking locks**: check pg_locks + pg_stat_activity, identify blocking PID's query, suggest pg_cancel_backend vs pg_terminate_backend +- **Slow queries**: check pg_stat_activity for active long-running, correlate with pg_stat_statements for chronic offenders +- **High connections**: check pg_stat_activity by application_name and state, look for idle-in-transaction +- **Vacuum pressure**: check pg_stat_user_tables for n_dead_tup, autovacuum_count, last_autovacuum +- **Cache miss**: investigate pg_statio_user_tables for heap_blks_read vs heap_blks_hit by table +- **Seq scans**: investigate pg_stat_user_tables, suggest CREATE INDEX after checking column selectivity + +## Current Database Snapshot +${contextSummary || '(No snapshot available — ask the user to refresh the dashboard)'} ## Response Format -- Answer in plain sentences — no bullet points for simple answers -- Use bullets only for lists of 3+ items -- Skip preamble — answer directly`; +- Lead with a direct 1–3 sentence answer or diagnosis +- Use bullet points only for lists of 3+ items +- For SQL queries: briefly state what it investigates before the code block +- Order recommendations by severity (critical → warning → advisory) +- Skip preamble — answer directly + +## Investigation Closure (REQUIRED) +- At logical completion, provide a concise "Investigation summary" that explicitly states: + 1) Whether there is a major finding (yes/no) + 2) Whether anything suspicious was found on the current thread (yes/no) + 3) What was ruled out +- If nothing suspicious is found, explicitly say so and switch to the next likely problem area +- Only propose one next query at a time, and only if it explores a different diagnostic angle + +## Follow-up Questions (CONDITIONAL) +- Provide 2–4 follow-up questions only when they are genuinely new and non-redundant +- Do not include follow-up questions when you already provided a final investigation summary unless user asks to continue + +**Follow-up questions:** +1. [Question that builds on what was just discussed] +2. [Question from a different diagnostic angle] +3. [Deeper investigation question] + +## Next Step Suggestion Bubbles (OPTIONAL) +If there are 2–3 clear next investigation actions, append this JSON at the very end of the response (no markdown wrapper, no code fence): +{"next_steps": ["Short action phrase", "Short action phrase", "Short action phrase"]} + +Each phrase should be 3–6 words, max 40 characters. Only include if genuinely useful — omit entirely if no clear next steps. + +IMPORTANT: When the user sends a bare number (1, 2, or 3), treat it as selecting that numbered follow-up question from the previous response and answer it directly.`; } private _buildContextSummary(stats: DashboardStats): string { const health = stats.blockingLocks.length > 0 ? 'Degraded (blocking locks)' : stats.waitingConnections > 0 ? 'Degraded (waiting sessions)' : 'OK'; + const sharedCacheHitRatio = stats.sharedCacheHitRatio != null + ? `${stats.sharedCacheHitRatio.toFixed(1)}%` + : 'n/a'; + const connPct = stats.maxConnections > 0 + ? ((stats.totalConnections / stats.maxConnections) * 100).toFixed(0) + : '0'; + const lines = [ `Database: ${stats.dbName} | Size: ${stats.size} | Owner: ${stats.owner}`, `Health: ${health}`, - `Connections: ${stats.activeConnections} active / ${stats.idleConnections} idle / ${stats.waitingConnections} waiting / ${stats.maxConnections} max`, + `Connections: ${stats.activeConnections} active / ${stats.idleConnections} idle / ${stats.waitingConnections} waiting / ${stats.maxConnections} max (${connPct}% capacity)`, `Blocking locks: ${stats.blockingLocks.length}`, `Long-running queries (>5s): ${stats.longRunningQueries}`, `Wait events: ${stats.waitEvents.map(w => `${w.type}=${w.count}`).join(', ') || 'none'}`, - `Index hit ratio: ${stats.indexHitRatio.toFixed(1)}%`, + `Shared cache hit ratio: ${sharedCacheHitRatio}`, + `Index block hit ratio: ${stats.indexHitRatio.toFixed(1)}%`, `Oldest transaction age: ${stats.oldestTransactionAgeSeconds}s`, - `Tables needing vacuum attention: ${stats.vacuumTablesNeedingAttention}`, + `Tables needing vacuum: ${stats.tablesNeedingVacuum.length} (attention signal: ${stats.vacuumTablesNeedingAttention})`, `Unused indexes: ${stats.unusedIndexes.length}`, - `Bloated tables (>1000 dead tuples): ${stats.tableBloat.length}`, - `Tables needing vacuum: ${stats.tablesNeedingVacuum.length}`, + `Dead-tuple pressure tables: ${stats.tableBloat.length}`, + `High sequential scan tables: ${stats.highSeqScanTables.length}`, ]; + + // Specific blocking lock details if (stats.blockingLocks.length > 0) { - lines.push(`Blocking lock details: ${stats.blockingLocks.slice(0, 3).map(l => - `PID ${l.blocking_pid} blocks PID ${l.blocked_pid} on ${l.locked_object} (${l.lock_mode})` - ).join('; ')}`); + const lockDetails = stats.blockingLocks.slice(0, 3).map(l => + `PID ${l.blocking_pid} (${l.blocking_user}) blocks PID ${l.blocked_pid} (${l.blocked_user}) on "${l.locked_object}" [${l.lock_mode}]` + + (l.blocking_query ? ` — blocking query: ${l.blocking_query.substring(0, 80)}…` : '') + ); + lines.push(`\nBlocking lock details:\n${lockDetails.map(d => ` - ${d}`).join('\n')}`); } + + // Active long-running queries + const longRunning = stats.activeQueries.filter(q => + q.state === 'active' && q.duration && q.duration > '00:00:05' + ).slice(0, 3); + if (longRunning.length > 0) { + const lrDetails = longRunning.map(q => + `PID ${q.pid} (${q.usename}): ${q.duration} — ${q.query.substring(0, 80)}…` + ); + lines.push(`\nSample long-running queries:\n${lrDetails.map(d => ` - ${d}`).join('\n')}`); + } + + // Top pg_stat_statements if (stats.pgStatStatements && stats.pgStatStatements.length > 0) { - const top = stats.pgStatStatements[0]; - lines.push(`Top SQL by total time: ${top.total_time.toFixed(0)}ms total, ${top.calls} calls, avg ${top.mean_time.toFixed(1)}ms`); + const topStatements = stats.pgStatStatements.slice(0, 3).map((s, i) => + `#${i + 1}: ${s.total_time.toFixed(0)}ms total, ${s.calls} calls, avg ${s.mean_time.toFixed(1)}ms — ${s.query.substring(0, 80)}…` + ); + lines.push(`\nTop SQL by total time (pg_stat_statements):\n${topStatements.map(d => ` ${d}`).join('\n')}`); } + + // Schema health signals + if (stats.unusedIndexes.length > 0) { + const topUnused = stats.unusedIndexes.slice(0, 3).map(i => + `${i.index_name} on ${i.table_name} (${i.index_size})` + ); + lines.push(`\nTop unused indexes: ${topUnused.join(', ')}`); + } + if (stats.highSeqScanTables.length > 0) { + const topSeq = stats.highSeqScanTables.slice(0, 3).map(t => + `${t.table_name} (${t.seq_scan_pct.toFixed(0)}% seq scans, ${t.row_count.toLocaleString()} rows)` + ); + lines.push(`Top high seq-scan tables: ${topSeq.join(', ')}`); + } + if (stats.tableBloat.length > 0) { + const topBloat = stats.tableBloat.slice(0, 3).map(t => + `${t.table_name} (${t.bloat_pct.toFixed(0)}% dead tuples, ${t.n_dead_tup.toLocaleString()} dead)` + ); + lines.push(`Top dead-tuple tables: ${topBloat.join(', ')}`); + } + if (stats.tablesNeedingVacuum.length > 0) { + const topVacuum = stats.tablesNeedingVacuum.slice(0, 3).map(t => + `${t.table_name} (${t.n_dead_tup.toLocaleString()} dead, last autovacuum: ${t.last_autovacuum || 'never'})` + ); + lines.push(`Tables most needing vacuum: ${topVacuum.join(', ')}`); + } + return lines.join('\n'); } private _isHealthCritical(stats: DashboardStats): boolean { + const hasSevereSchemaPressure = + stats.tablesNeedingVacuum.length > 5 || + stats.tableBloat.some(t => t.bloat_pct >= 30) || + stats.highSeqScanTables.some(t => t.seq_scan_pct >= 95 && t.row_count >= 100000); + return stats.blockingLocks.length > 0 || stats.waitingConnections > 5 || stats.longRunningQueries > 3 || - (stats.totalConnections / stats.maxConnections) > 0.9; + (stats.totalConnections / stats.maxConnections) > 0.9 || + hasSevereSchemaPressure; } private async _update() { diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html index 1616bb1..b3bb298 100644 --- a/templates/dashboard/index.html +++ b/templates/dashboard/index.html @@ -238,8 +238,8 @@

Session Activity

-
Cache Hit Ratio
-
Good: >95%. 90-95% is warning. Below 90% may indicate memory pressure.
+
Shared Buffer Hit Ratio
+
From pg_stat_database: blks_hit / (blks_hit + blks_read). Good: >95%. 90-95% is warning.
@@ -261,7 +261,7 @@

Session Activity

-
Index Hit Ratio (Context)
+
Index Block Hit Ratio (Context)
Awaiting telemetry...
-
@@ -370,6 +370,7 @@

No blocking locks detected

+
Tracking schema-health risk signals over time.

Index Health

@@ -377,7 +378,7 @@

Index Health

Unused Indexes
-
Indexes that have never been scanned. They waste write overhead and storage — consider dropping them.
+
Indexes with zero scans excluding PK/UNIQUE/constraint-backed indexes. Review before dropping.
@@ -413,13 +414,13 @@

Index Health

- -

Table Bloat

+ +

Dead Tuple Pressure

-
Bloated Tables (Dead Tuple Ratio)
-
Tables with significant dead tuples. High bloat% slows queries and wastes disk. Run VACUUM to reclaim space.
+
Dead Tuple Ratio (Proxy)
+
Dead tuple ratio is a vacuum pressure proxy, not a physical storage-bloat measurement.
@@ -427,7 +428,7 @@

Table Bloat

- + @@ -462,13 +463,14 @@

Autovacuum
Tables Needing Vacuum
-
Tables with notable dead tuples that autovacuum should process soon.
+
Tables where dead tuples exceed effective autovacuum thresholds (table options + global settings).

Table Live Rows Dead RowsBloat%Dead% Size Action
+ @@ -489,6 +491,7 @@

Autovacuum Auto on degradation +
@@ -498,15 +501,17 @@

Autovacuum + +

Ask about any metric, or click a quick-action above.

-

Context from the current dashboard snapshot is sent with each question.

+

Context from the current dashboard snapshot is sent with each question. The AI will suggest follow-up questions after each response.

- +
diff --git a/templates/dashboard/scripts.js b/templates/dashboard/scripts.js index a9a7eb3..b3bd251 100644 --- a/templates/dashboard/scripts.js +++ b/templates/dashboard/scripts.js @@ -63,6 +63,12 @@ const kpiHistory = { }; const lockEventsHistory = []; +const schemaHealthHistory = { + unusedIndexes: [], + highSeqScanTables: [], + deadTuplePressureTables: [], + vacuumAttentionTables: [] +}; let activeQueriesCache = []; let blockingPids = new Set(); @@ -477,13 +483,13 @@ function updateDashboard(stats) { const commits = stats.metrics.xact_commit - lastMetrics.xact_commit; const rollbacks = stats.metrics.xact_rollback - lastMetrics.xact_rollback; - const reads = stats.metrics.blks_read - lastMetrics.blks_read; - const hits = stats.metrics.blks_hit - lastMetrics.blks_hit; const tps = Math.max(0, Math.round((commits + rollbacks) / timeDiff)); const rollbackRate = Math.max(0, Math.round(rollbacks / timeDiff)); - const totalIo = reads + hits; - const hitRatio = Math.min(100, Math.max(0, totalIo > 0 ? (hits / totalIo) * 100 : 100)); + const sharedCacheHitRatioRaw = Number(stats.sharedCacheHitRatio); + const sharedCacheHitRatio = Number.isFinite(sharedCacheHitRatioRaw) + ? Math.min(100, Math.max(0, sharedCacheHitRatioRaw)) + : null; pushWithLimit(timeLabels, formatTimeLabel(now)); pushWithLimit(tpsHistory, tps); @@ -494,7 +500,7 @@ function updateDashboard(stats) { const incomingActiveSessions = (stats.activeQueries || []).filter(query => (query.state || '').toLowerCase() === 'active').length; pushWithLimit(activeSessionHistory, incomingActiveSessions); pushWithLimit(rollbackHistory, rollbackRate); - pushWithLimit(cacheHitHistory, hitRatio); + pushWithLimit(cacheHitHistory, sharedCacheHitRatio); pushWithLimit(longRunningHistory, Math.max(0, stats.longRunningQueries || 0)); const cpTimed = Math.max(0, (stats.metrics.checkpoints_timed || 0) - (lastMetrics.checkpoints_timed || 0)); @@ -880,7 +886,8 @@ function updateOverviewSignals(stats) { const vacuumChip = document.getElementById('signal-vacuum'); if (indexChip) { - const ratio = Number(stats.indexHitRatio || 0); + const parsedRatio = Number(stats.indexHitRatio); + const ratio = Number.isFinite(parsedRatio) ? parsedRatio : 100; indexChip.textContent = `Index Hit: ${ratio.toFixed(1)}%`; indexChip.classList.remove('warn', 'crit'); if (ratio < 90) indexChip.classList.add('crit'); @@ -909,15 +916,16 @@ function updatePerformanceInsights(stats) { const indexNote = document.getElementById('perf-index-hit-note'); const topSqlList = document.getElementById('perf-top-sql-list'); - const ratio = Number(stats.indexHitRatio || 0); + const parsedRatio = Number(stats.indexHitRatio); + const ratio = Number.isFinite(parsedRatio) ? parsedRatio : 100; if (indexValue) { indexValue.textContent = `${ratio.toFixed(1)}%`; indexValue.style.color = ratio < 90 ? 'var(--danger-color)' : ratio < 95 ? 'var(--warning-color)' : 'var(--success-color)'; } if (indexNote) { - if (ratio < 90) indexNote.textContent = 'Low cache reuse. Validate indexes, execution plans, and memory settings.'; - else if (ratio < 95) indexNote.textContent = 'Moderate cache reuse. Check hottest read paths and index coverage.'; - else indexNote.textContent = 'Healthy cache reuse for indexed access patterns.'; + if (ratio < 90) indexNote.textContent = 'Low index block cache reuse. Validate indexes, execution plans, and memory settings.'; + else if (ratio < 95) indexNote.textContent = 'Moderate index block cache reuse. Check hottest read paths and index coverage.'; + else indexNote.textContent = 'Healthy index block cache reuse for indexed access patterns.'; } if (!topSqlList) return; @@ -1676,10 +1684,14 @@ function updateConnectionsByApp(connectionsByApp) { if (!byApp[row.application_name]) { byApp[row.application_name] = { active: 0, idle: 0, waiting: 0, other: 0 }; } + if (row.waiting) { + byApp[row.application_name].waiting += row.count; + continue; + } const st = (row.state || '').toLowerCase(); if (st === 'active') byApp[row.application_name].active += row.count; else if (st === 'idle') byApp[row.application_name].idle += row.count; - else if (st === 'waiting' || st === 'idle in transaction (aborted)') byApp[row.application_name].waiting += row.count; + else if (st === 'idle in transaction (aborted)') byApp[row.application_name].waiting += row.count; else byApp[row.application_name].other += row.count; } @@ -1714,6 +1726,12 @@ function updateConnectionsByApp(connectionsByApp) { // ── Schema Health Tab ──────────────────────────────────────────────── function updateSchemaHealth(stats) { + pushWithLimit(schemaHealthHistory.unusedIndexes, (stats.unusedIndexes || []).length); + pushWithLimit(schemaHealthHistory.highSeqScanTables, (stats.highSeqScanTables || []).length); + pushWithLimit(schemaHealthHistory.deadTuplePressureTables, (stats.tableBloat || []).length); + pushWithLimit(schemaHealthHistory.vacuumAttentionTables, (stats.tablesNeedingVacuum || []).length); + updateSchemaHealthTrendNote(); + renderUnusedIndexes(stats.unusedIndexes || []); renderHighSeqScan(stats.highSeqScanTables || []); renderTableBloat(stats.tableBloat || []); @@ -1721,11 +1739,28 @@ function updateSchemaHealth(stats) { renderTablesNeedingVacuum(stats.tablesNeedingVacuum || []); } +function updateSchemaHealthTrendNote() { + const note = document.getElementById('schema-health-note'); + if (!note) return; + + const delta = (arr) => { + if (!arr || arr.length < 2) return 0; + return Number(arr[arr.length - 1] || 0) - Number(arr[arr.length - 2] || 0); + }; + + const unusedDelta = delta(schemaHealthHistory.unusedIndexes); + const seqDelta = delta(schemaHealthHistory.highSeqScanTables); + const deadDelta = delta(schemaHealthHistory.deadTuplePressureTables); + const vacuumDelta = delta(schemaHealthHistory.vacuumAttentionTables); + + note.textContent = `Trend: unused indexes ${unusedDelta >= 0 ? '+' : ''}${unusedDelta}, high seq-scan tables ${seqDelta >= 0 ? '+' : ''}${seqDelta}, dead-tuple pressure tables ${deadDelta >= 0 ? '+' : ''}${deadDelta}, vacuum-attention tables ${vacuumDelta >= 0 ? '+' : ''}${vacuumDelta}`; +} + function renderUnusedIndexes(indexes) { const tbody = document.querySelector('#unused-indexes-table tbody'); if (!tbody) return; if (!indexes.length) { - tbody.innerHTML = '

'; + tbody.innerHTML = ''; return; } tbody.innerHTML = indexes.map(idx => { @@ -1766,7 +1801,7 @@ function renderTableBloat(tables) { const tbody = document.querySelector('#table-bloat-table tbody'); if (!tbody) return; if (!tables.length) { - tbody.innerHTML = ''; + tbody.innerHTML = ''; return; } tbody.innerHTML = tables.map(t => { @@ -1820,17 +1855,19 @@ function renderTablesNeedingVacuum(tables) { const tbody = document.querySelector('#tables-needing-vacuum-table tbody'); if (!tbody) return; if (!tables.length) { - tbody.innerHTML = ''; + tbody.innerHTML = ''; return; } tbody.innerHTML = tables.map(t => { const dead = t.n_dead_tup || 0; + const threshold = t.dead_tuple_threshold || 0; const cls = dead > 10000 ? 'row-crit' : dead > 2000 ? 'row-warn' : ''; const lastVac = t.last_autovacuum ? new Date(t.last_autovacuum).toLocaleString() : 'Never'; const lastAna = t.last_autoanalyze ? new Date(t.last_autoanalyze).toLocaleString() : 'Never'; return ` + `; @@ -1844,6 +1881,7 @@ let currentStats = null; const aiPanel = document.getElementById('ai-panel'); const aiToggleBtn = document.getElementById('ai-toggle-btn'); const aiCloseBtn = document.getElementById('ai-panel-close'); +const aiClearBtn = document.getElementById('ai-clear-btn'); const aiSendBtn = document.getElementById('ai-send-btn'); const aiQuestionInput = document.getElementById('ai-question'); const aiResponseArea = document.getElementById('ai-response-area'); @@ -1864,35 +1902,74 @@ function closeAiPanel() { function buildContextSummary() { if (!currentStats) return ''; const s = currentStats; + const parsedIndexHitRatio = Number(s.indexHitRatio); + const indexHitRatio = Number.isFinite(parsedIndexHitRatio) ? parsedIndexHitRatio : 100; + const sharedCacheHitRatioRaw = Number(s.sharedCacheHitRatio); + const sharedCacheHitRatio = Number.isFinite(sharedCacheHitRatioRaw) + ? `${sharedCacheHitRatioRaw.toFixed(1)}%` + : 'n/a'; + const connPct = s.maxConnections > 0 + ? ((s.totalConnections / s.maxConnections) * 100).toFixed(0) + : '0'; + const health = (s.blockingLocks || []).length > 0 ? 'Degraded (blocking locks)' : + (s.waitingConnections || 0) > 0 ? 'Degraded (waiting sessions)' : 'OK'; + const lines = [ `Database: ${s.dbName || '-'} | Size: ${s.size || '-'}`, - `Connections: ${s.activeConnections || 0} active, ${s.idleConnections || 0} idle, ${s.waitingConnections || 0} waiting / ${s.maxConnections || 0} max`, + `Health: ${health}`, + `Connections: ${s.activeConnections || 0} active, ${s.idleConnections || 0} idle, ${s.waitingConnections || 0} waiting / ${s.maxConnections || 0} max (${connPct}% capacity)`, `Blocking locks: ${(s.blockingLocks || []).length}`, `Long-running queries (>5s): ${s.longRunningQueries || 0}`, `Wait events: ${(s.waitEvents || []).map(w => `${w.type}=${w.count}`).join(', ') || 'none'}`, - `Index hit ratio: ${(s.indexHitRatio || 0).toFixed(1)}%`, + `Shared cache hit ratio: ${sharedCacheHitRatio}`, + `Index hit ratio: ${indexHitRatio.toFixed(1)}%`, `Oldest transaction age: ${s.oldestTransactionAgeSeconds || 0}s`, `Tables needing vacuum: ${(s.tablesNeedingVacuum || []).length}`, `Unused indexes: ${(s.unusedIndexes || []).length}`, - `Bloated tables: ${(s.tableBloat || []).length}`, + `Dead-tuple pressure tables: ${(s.tableBloat || []).length}`, + `High seq-scan tables: ${(s.highSeqScanTables || []).length}`, ]; + if ((s.blockingLocks || []).length > 0) { - lines.push(`Blocking lock: PID ${s.blockingLocks[0].blocking_pid} blocks PID ${s.blockingLocks[0].blocked_pid} on ${s.blockingLocks[0].locked_object}`); + const lockDetails = s.blockingLocks.slice(0, 3).map(l => + `PID ${l.blocking_pid} (${l.blocking_user}) blocks PID ${l.blocked_pid} (${l.blocked_user}) on "${l.locked_object}" [${l.lock_mode}]` + ); + lines.push(`\nBlocking lock details:\n${lockDetails.map(d => ` - ${d}`).join('\n')}`); } + if ((s.pgStatStatements || []).length > 0) { - const top = s.pgStatStatements[0]; - lines.push(`Top SQL: ${Number(top.total_time).toFixed(0)}ms total, ${top.calls} calls`); + const topStatements = s.pgStatStatements.slice(0, 3).map((st, i) => + `#${i + 1}: ${Number(st.total_time).toFixed(0)}ms total, ${st.calls} calls, avg ${Number(st.mean_time).toFixed(1)}ms — ${String(st.query || '').substring(0, 80)}…` + ); + lines.push(`\nTop SQL (pg_stat_statements):\n${topStatements.map(d => ` ${d}`).join('\n')}`); + } + + if ((s.highSeqScanTables || []).length > 0) { + const topSeq = s.highSeqScanTables.slice(0, 3).map(t => + `${t.table_name} (${Number(t.seq_scan_pct).toFixed(0)}% seq, ${Number(t.row_count).toLocaleString()} rows)` + ); + lines.push(`Top high seq-scan tables: ${topSeq.join(', ')}`); } + + if ((s.tableBloat || []).length > 0) { + const topBloat = s.tableBloat.slice(0, 3).map(t => + `${t.table_name} (${Number(t.bloat_pct).toFixed(0)}% dead, ${Number(t.n_dead_tup).toLocaleString()} dead tuples)` + ); + lines.push(`Top bloated tables: ${topBloat.join(', ')}`); + } + return lines.join('\n'); } const quickPromptMap = { - 'analyze-health': 'Analyze the current database health status and explain what is causing any issues.', - 'explain-locks': 'Explain the blocking lock situation in detail and provide steps to resolve it.', - 'slow-queries': 'What are the long-running queries indicating and how should I address them?', - 'top-sql': 'Review the top SQL statements by total time and suggest specific optimizations.', - 'schema-health': 'Analyze the schema health — unused indexes, table bloat, and sequential scan patterns. What should I fix first?', - 'vacuum-advice': 'Review the vacuum status and dead tuple counts. What vacuum actions should I take?', + 'analyze-health': 'Analyze the current database health status and explain what is causing any issues. Walk me through each problem area step by step.', + 'explain-locks': 'Explain the blocking lock situation in detail: which PIDs are involved, what are they doing, and what are my options to resolve it safely?', + 'slow-queries': 'What are the long-running queries indicating and how should I address them? Show me queries I can run to investigate further.', + 'top-sql': 'Review the top SQL statements by total time and suggest specific optimizations. Which query should I tackle first and why?', + 'schema-health': 'Analyze the schema health — unused indexes, table bloat, and sequential scan patterns. Prioritize what I should fix first and explain the impact of each.', + 'vacuum-advice': 'Review the vacuum status and dead tuple counts. What vacuum actions should I take, in what order, and what thresholds should I monitor?', + 'connection-triage': 'Analyze the connection patterns: which applications are consuming connections, are there idle-in-transaction sessions, and am I at risk of connection exhaustion?', + 'index-recommendations': 'Based on the high sequential scan tables and query patterns, what indexes should I consider creating? Show me the CREATE INDEX statements.', }; const metricPromptMap = { @@ -1906,9 +1983,151 @@ const metricPromptMap = { 'autovacuum': 'Analyze the autovacuum status. Is autovacuum keeping up? Should I tune any settings?', }; +const quirkyMessages = [ + "🔍 Inspecting wait events and active sessions...", + "🔒 Tracing blocking chains and lock contenders...", + "📈 Correlating spikes across throughput and latency...", + "🧭 Mapping the connection-state distribution...", + "🧠 Interpreting cache hit behavior by workload pattern...", + "🧱 Checking temp spill signals for sort/hash pressure...", + "🧹 Reviewing dead tuples and vacuum pressure indicators...", + "🛠️ Verifying autovacuum progress and backlog risk...", + "📊 Ranking top SQL by total execution time...", + "🧪 Testing alternate hypotheses for this symptom...", + "🛰️ Sampling WAL and replication health signals...", + "📚 Comparing current telemetry with baseline patterns...", + "🪪 Identifying sessions most likely driving the issue...", + "🎯 Narrowing to one high-impact next investigation step...", + "🧯 Looking for immediate mitigation opportunities...", + "🧵 Stitching metrics into a coherent incident story...", + "🧰 Validating if index strategy matches access patterns...", + "📉 Checking for sequential scan hotspots and plan drift...", + "✅ Summarizing findings with confidence level and risk...", + "➡️ Preparing the next diagnostic query if needed..." +]; + +let aiLoadingMessageInterval = null; +let aiLoadingMessageIndex = 0; + let _lastAiQuestion = ''; +const _aiAutoFixState = { + attempts: 0, + maxAttempts: 5, + history: [] +}; +const _investigationState = { + executedSql: [], + askedQuestions: [], +}; + +function _normalizeSqlForComparison(sql) { + return String(sql || '') + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--.*$/gm, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function _normalizeQuestionForComparison(text) { + return String(text || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function _rememberQuestion(question) { + const norm = _normalizeQuestionForComparison(question); + if (!norm) return; + if (_investigationState.askedQuestions.includes(norm)) return; + _investigationState.askedQuestions.push(norm); + if (_investigationState.askedQuestions.length > 30) { + _investigationState.askedQuestions.shift(); + } +} + +function _rememberExecutedSql(sql) { + const norm = _normalizeSqlForComparison(sql); + if (!norm) return; + if (_investigationState.executedSql.includes(norm)) return; + _investigationState.executedSql.push(norm); + if (_investigationState.executedSql.length > 20) { + _investigationState.executedSql.shift(); + } +} + +function _resetAiAutoFixState() { + _aiAutoFixState.attempts = 0; + _aiAutoFixState.history = []; +} + +function _resetInvestigationState() { + _investigationState.executedSql = []; + _investigationState.askedQuestions = []; +} + +function _buildAutoFixHistorySummary() { + if (_aiAutoFixState.history.length === 0) return 'No attempts were recorded.'; + return _aiAutoFixState.history.map((entry, index) => { + const sql = String(entry.sql || '').replace(/\s+/g, ' ').trim(); + const compactSql = sql.length > 180 ? `${sql.slice(0, 180)}...` : sql; + return `${index + 1}. Error: ${entry.error}\n SQL: ${compactSql}`; + }).join('\n'); +} + +function _requestAiAutoFixForQueryError(data) { + const sql = String(data.sql || '').trim(); + const error = String(data.error || 'Unknown query execution error').trim(); + + _aiAutoFixState.attempts += 1; + _aiAutoFixState.history.push({ + sql, + error, + at: new Date().toISOString() + }); + + if (_aiAutoFixState.attempts > _aiAutoFixState.maxAttempts) { + const summary = _buildAutoFixHistorySummary(); + _appendAiMessage( + 'assistant', + _parseAiMarkdown( + `I attempted to auto-fix this query ${_aiAutoFixState.maxAttempts} times and it is still failing.\n\n` + + `Please share how you want to proceed (for example: adjust intent, simplify query scope, or provide schema details).\n\n` + + `What I tried:\n\n${summary}` + ) + ); + renderAiLoading(false); + return; + } + + const summary = _buildAutoFixHistorySummary(); + const autoFixQuestion = [ + 'The last SQL query execution failed in PostgreSQL.', + 'Analyze the failure and produce a corrected query.', + 'Respond with:', + '1) A brief explanation of why it failed', + '2) What you changed', + '3) A corrected SQL query in a ```sql fenced block', + '4) A short note asking the user to run the fixed query again', + '', + `Attempt: ${_aiAutoFixState.attempts}/${_aiAutoFixState.maxAttempts}`, + `Failed SQL:\n${sql}`, + `Error:\n${error}`, + '', + 'Previous attempts (oldest to newest):', + summary + ].join('\n'); + + _lastAiQuestion = autoFixQuestion; + renderAiLoading(true); + vscode.postMessage({ + command: 'askAI', + question: autoFixQuestion, + context: buildContextSummary(), + }); +} -function _appendAiMessage(role, htmlContent) { +function _appendAiMessage(role, htmlContent, meta = {}) { if (!aiResponseArea) return; const welcome = aiResponseArea.querySelector('.ai-welcome'); if (welcome) welcome.remove(); @@ -1928,18 +2147,67 @@ function _appendAiMessage(role, htmlContent) { content.innerHTML = htmlContent; bubble.appendChild(content); + + if (role === 'user' && meta.contextSummary) { + const rawContext = String(meta.contextSummary || '').trim(); + const maxChars = 650; + const lines = rawContext.split('\n'); + const maxLines = 14; + const clippedByChars = rawContext.length > maxChars; + const clippedByLines = lines.length > maxLines; + const clippedText = clippedByLines + ? lines.slice(0, maxLines).join('\n') + : rawContext; + const finalText = clippedByChars + ? `${clippedText.slice(0, maxChars)}\n... (context truncated)` + : (clippedByLines ? `${clippedText}\n... (context truncated)` : clippedText); + + const details = document.createElement('details'); + details.className = 'ai-context-attachment'; + details.style.margin = '10px 0 0 0'; + details.style.border = '1px solid rgba(128, 128, 128, 0.35)'; + details.style.borderRadius = '6px'; + details.style.background = 'rgba(128, 128, 128, 0.08)'; + details.style.overflow = 'hidden'; + + const summary = document.createElement('summary'); + summary.style.cursor = 'pointer'; + summary.style.listStyle = 'none'; + summary.style.padding = '6px 10px'; + summary.style.fontSize = '0.78rem'; + summary.style.color = 'var(--muted-color)'; + summary.style.userSelect = 'none'; + summary.textContent = 'Attachment: Dashboard context snapshot'; + + const body = document.createElement('pre'); + body.className = 'ai-context-quote'; + body.style.margin = '0'; + body.style.padding = '8px 10px 10px 10px'; + body.style.borderTop = '1px solid rgba(128, 128, 128, 0.25)'; + body.style.fontSize = '0.78rem'; + body.style.color = 'var(--muted-color)'; + body.style.whiteSpace = 'pre-wrap'; + body.style.maxHeight = '180px'; + body.style.overflow = 'auto'; + body.textContent = finalText; + + details.appendChild(summary); + details.appendChild(body); + bubble.appendChild(details); + } + msgDiv.appendChild(roleLabel); msgDiv.appendChild(bubble); aiResponseArea.appendChild(msgDiv); if (role === 'assistant') { - _addSqlRunButtons(msgDiv); + _addSqlRunButtons(msgDiv, meta.runQuestion); } aiResponseArea.scrollTop = aiResponseArea.scrollHeight; } -function _addSqlRunButtons(msgDiv) { +function _addSqlRunButtons(msgDiv, runQuestionOverride) { msgDiv.querySelectorAll('.ai-code-block').forEach(block => { const langEl = block.querySelector('.ai-code-lang'); const lang = langEl ? langEl.textContent.trim().toUpperCase() : ''; @@ -1961,7 +2229,8 @@ function _addSqlRunButtons(msgDiv) { confirmRow.querySelector('.ai-run-ok-btn').addEventListener('click', () => { const sql = codeEl.textContent.trim(); confirmRow.innerHTML = ' Executing query…'; - vscode.postMessage({ command: 'executeQueryForAI', sql, question: _lastAiQuestion }); + const runQuestion = runQuestionOverride || _lastAiQuestion || 'Investigate this SQL query result and explain findings.'; + vscode.postMessage({ command: 'executeQueryForAI', sql, question: runQuestion }); }); confirmRow.querySelector('.ai-run-skip-btn').addEventListener('click', () => { @@ -1983,9 +2252,15 @@ function _handleQueryResult(data) { wrapper.innerHTML = `
Query error: ${escHtml(data.error)}
`; aiResponseArea.appendChild(wrapper); aiResponseArea.scrollTop = aiResponseArea.scrollHeight; + _requestAiAutoFixForQueryError(data); return; } + const hadAutoFixAttempt = _aiAutoFixState.history.length > 0; + _rememberExecutedSql(data.sql || ''); + // Reset retry loop after any successful execution. + _resetAiAutoFixState(); + const rowCount = data.rowCount ?? (data.rows ? data.rows.length : 0); let html = `
Query returned ${rowCount} row${rowCount !== 1 ? 's' : ''}`; if (data.columns && data.rows && data.rows.length > 0) { @@ -2000,25 +2275,84 @@ function _handleQueryResult(data) { aiResponseArea.appendChild(wrapper); aiResponseArea.scrollTop = aiResponseArea.scrollHeight; - _sendResultsToAI(data); + _sendResultsToAI(data, { summarizeAfterFix: hadAutoFixAttempt }); } -function _sendResultsToAI(data) { +function _sendResultsToAI(data, options = {}) { + const summarizeAfterFix = Boolean(options.summarizeAfterFix); const rowCount = data.rowCount ?? (data.rows ? data.rows.length : 0); let resultContext = `Query results (${rowCount} row${rowCount !== 1 ? 's' : ''}):\n`; if (data.columns && data.rows && data.rows.length > 0) { resultContext += data.columns.join(' | ') + '\n'; resultContext += data.rows.slice(0, 50).map(row => - data.columns.map(col => row[col] === null ? 'NULL' : String(row[col])).join(' | ') + data.columns.map(col => { + const normalized = normalizeResultValue(row[col]); + return normalized === null || normalized === undefined ? 'NULL' : String(normalized); + }).join(' | ') ).join('\n'); if (data.rows.length > 50) resultContext += `\n… (${data.rows.length - 50} more rows truncated)`; } else { resultContext += '(no rows returned)'; } - const question = (data.question || 'Answer the original question using the query results below.') + - '\n\n' + resultContext; + const zeroRowGuidance = rowCount === 0 + ? [ + 'Important: the SQL execution succeeded and returned 0 rows.', + 'Treat this as an empty result set, not as a query failure.', + 'Explain what this implies and whether this is expected for current filters/conditions.' + ].join('\n') + : ''; + + const previousSql = _investigationState.executedSql.length > 0 + ? _investigationState.executedSql.map((q, i) => `${i + 1}. ${q}`).join('\n') + : 'None'; + const previousQuestions = _investigationState.askedQuestions.length > 0 + ? _investigationState.askedQuestions.map((q, i) => `${i + 1}. ${q}`).join('\n') + : 'None'; + + const question = summarizeAfterFix + ? [ + 'The corrected query executed successfully.', + 'Summarize what the returned data means for the user’s original problem in concise terms.', + 'Explain what failed previously and what was fixed to make this run succeed.', + 'Do NOT generate another SQL query unless the data is clearly insufficient to answer the question.', + 'If more data is required, provide exactly one targeted SQL query and explain why it is needed.', + '', + 'Original repair request and context:', + data.question || 'N/A', + '', + zeroRowGuidance, + zeroRowGuidance ? '' : '', + 'Previously executed SQL (do not repeat):', + previousSql, + '', + 'Previously asked follow-up questions (do not repeat):', + previousQuestions, + '', + resultContext + ].join('\n') + : [ + (data.question || 'Answer the original question using the query results below.'), + '', + 'Important investigation rules:', + '- Do not repeat a previously executed SQL query.', + '- Do not repeat previously asked follow-up questions.', + '- If current evidence is enough, stop querying and provide a final investigation summary.', + '- In final summary, explicitly state: (a) major finding yes/no, (b) suspicious activity yes/no on current thread.', + '- If nothing suspicious is found, clearly say so and switch to the next likely issue area.', + '- Only propose one new SQL query if genuinely required and from a different diagnostic angle.', + '', + zeroRowGuidance, + zeroRowGuidance ? '' : '', + 'Previously executed SQL (do not repeat):', + previousSql, + '', + 'Previously asked follow-up questions (do not repeat):', + previousQuestions, + '', + resultContext + ].join('\n'); renderAiLoading(true); vscode.postMessage({ command: 'askAI', question, context: buildContextSummary() }); @@ -2027,7 +2361,8 @@ function _sendResultsToAI(data) { function _downloadQueryCsv(data) { const { columns, rows } = data; const esc = val => { - const s = val === null ? '' : String(val); + const normalized = normalizeResultValue(val); + const s = normalized === null || normalized === undefined ? '' : String(normalized); return (s.includes(',') || s.includes('"') || s.includes('\n')) ? `"${s.replace(/"/g, '""')}"` : s; }; @@ -2035,36 +2370,169 @@ function _downloadQueryCsv(data) { vscode.postMessage({ command: 'downloadCsv', csv: lines.join('\n'), filename: 'query_results.csv' }); } +function normalizeResultValue(value) { + if (value === null || value === undefined) return value; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + + if (Array.isArray(value)) { + return value.map(item => normalizeResultValue(item)); + } + + if (typeof value === 'object') { + if (typeof value.toPostgres === 'function') { + try { + return value.toPostgres(); + } catch { + // Fallback to JSON serialization. + } + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + + return String(value); +} + function sendAiQuestion(question) { if (!question || !question.trim()) return; openAiPanel(); const q = question.trim(); + const contextSummary = buildContextSummary(); + _resetAiAutoFixState(); + _rememberQuestion(q); _lastAiQuestion = q; - _appendAiMessage('user', escHtml(q).replace(/\n/g, '
')); + _appendAiMessage('user', escHtml(q).replace(/\n/g, '
'), { contextSummary }); vscode.postMessage({ command: 'askAI', question: q, - context: buildContextSummary(), + context: contextSummary, }); if (aiQuestionInput) aiQuestionInput.value = ''; } +function clearConversation() { + if (!aiResponseArea) return; + aiResponseArea.innerHTML = `
+

Ask about any metric, or click a quick-action above.

+

Context from the current dashboard snapshot is sent with each question.

+
`; + _resetAiAutoFixState(); + _resetInvestigationState(); + _lastAiQuestion = ''; + vscode.postMessage({ command: 'clearConversation' }); +} + function renderAiLoading(loading) { if (!aiResponseArea) return; - const existing = aiResponseArea.querySelector('.ai-loading-dots'); + const existing = aiResponseArea.querySelector('.ai-loading-quirky'); if (loading && !existing) { - const dots = document.createElement('div'); - dots.className = 'ai-loading-dots'; - dots.innerHTML = ''; - aiResponseArea.appendChild(dots); + aiLoadingMessageIndex = Math.floor(Math.random() * quirkyMessages.length); + const loadingEl = document.createElement('div'); + loadingEl.className = 'ai-loading-quirky'; + loadingEl.textContent = quirkyMessages[aiLoadingMessageIndex]; + aiResponseArea.appendChild(loadingEl); + + if (aiLoadingMessageInterval) { + clearInterval(aiLoadingMessageInterval); + } + aiLoadingMessageInterval = setInterval(() => { + const activeEl = aiResponseArea.querySelector('.ai-loading-quirky'); + if (!activeEl) return; + aiLoadingMessageIndex = (aiLoadingMessageIndex + 1) % quirkyMessages.length; + activeEl.textContent = quirkyMessages[aiLoadingMessageIndex]; + }, 2500); + aiResponseArea.scrollTop = aiResponseArea.scrollHeight; } else if (!loading && existing) { + if (aiLoadingMessageInterval) { + clearInterval(aiLoadingMessageInterval); + aiLoadingMessageInterval = null; + } existing.remove(); } } +function _extractNextSteps(text) { + // Extract {"next_steps": [...]} JSON block at end of response + const match = text.match(/\{[\s\S]*?"next_steps"\s*:\s*\[[\s\S]*?\]\s*\}/); + if (!match) return { cleanText: text, nextSteps: [] }; + try { + const parsed = JSON.parse(match[0]); + const nextSteps = Array.isArray(parsed.next_steps) ? parsed.next_steps : []; + const cleanText = text.slice(0, match.index).trimEnd(); + return { cleanText, nextSteps }; + } catch (_) { + return { cleanText: text, nextSteps: [] }; + } +} + +function _extractFollowUpQuestions(text) { + // Extract numbered follow-up questions from "**Follow-up questions:**\n1. ...\n2. ..." + const questions = []; + const sectionMatch = text.match(/\*\*Follow-up questions:\*\*\s*\n((?:\d+\.\s*.+\n?)+)/i); + if (sectionMatch) { + const block = sectionMatch[1]; + const itemRegex = /\d+\.\s*(.+)/g; + let m; + while ((m = itemRegex.exec(block)) !== null) { + questions.push(m[1].trim()); + } + } + const deduped = []; + const seen = new Set(); + for (const q of questions) { + const norm = _normalizeQuestionForComparison(q); + if (!norm || seen.has(norm) || _investigationState.askedQuestions.includes(norm)) continue; + seen.add(norm); + deduped.push(q); + } + return deduped; +} + +function _renderSuggestionChips(container, items, isFollowUp) { + if (!items || items.length === 0) return; + const chipRow = document.createElement('div'); + chipRow.className = isFollowUp ? 'ai-followup-chips' : 'ai-nextstep-chips'; + items.forEach((item, idx) => { + const btn = document.createElement('button'); + btn.className = 'ai-suggestion-chip'; + btn.textContent = isFollowUp ? `${idx + 1}. ${item}` : item; + btn.title = item; + btn.addEventListener('click', () => { + const q = isFollowUp ? String(idx + 1) : item; + sendAiQuestion(q); + }); + chipRow.appendChild(btn); + }); + container.appendChild(chipRow); +} + function renderAiResponse(text) { - _appendAiMessage('assistant', _parseAiMarkdown(text)); + const { cleanText, nextSteps } = _extractNextSteps(text); + const followUps = _extractFollowUpQuestions(cleanText); + + for (const followUp of followUps) { + _rememberQuestion(followUp); + } + + _lastAiQuestion = cleanText; + _appendAiMessage('assistant', _parseAiMarkdown(cleanText), { runQuestion: cleanText }); + + // Find the last assistant message bubble to attach chips to + const messages = aiResponseArea ? aiResponseArea.querySelectorAll('.ai-message.assistant') : []; + const lastMsg = messages[messages.length - 1]; + if (lastMsg) { + const bubble = lastMsg.querySelector('.ai-message-bubble'); + if (bubble) { + if (followUps.length > 0) _renderSuggestionChips(bubble, followUps, true); + if (nextSteps.length > 0) _renderSuggestionChips(bubble, nextSteps, false); + } + } + if (aiResponseArea) aiResponseArea.scrollTop = aiResponseArea.scrollHeight; } @@ -2163,6 +2631,10 @@ if (aiCloseBtn) { aiCloseBtn.addEventListener('click', closeAiPanel); } +if (aiClearBtn) { + aiClearBtn.addEventListener('click', clearConversation); +} + if (aiSendBtn) { aiSendBtn.addEventListener('click', () => { sendAiQuestion(aiQuestionInput ? aiQuestionInput.value : ''); diff --git a/templates/dashboard/styles.css b/templates/dashboard/styles.css index 9d222b6..d7fe4cb 100644 --- a/templates/dashboard/styles.css +++ b/templates/dashboard/styles.css @@ -637,7 +637,8 @@ tr.row-focus { white-space: nowrap; } -.ai-panel-close { +.ai-panel-close, +.ai-panel-clear { background: none; border: none; cursor: pointer; @@ -648,7 +649,8 @@ tr.row-focus { border-radius: 3px; } -.ai-panel-close:hover { +.ai-panel-close:hover, +.ai-panel-clear:hover { color: var(--fg-color); background: rgba(128, 128, 128, 0.15); } @@ -807,6 +809,17 @@ tr.row-focus { padding: 12px; } +.ai-loading-quirky { + margin: 8px 0; + padding: 10px 12px; + border: 1px dashed rgba(128, 128, 128, 0.4); + border-radius: 8px; + background: rgba(128, 128, 128, 0.08); + color: var(--muted-color); + font-size: 0.82rem; + line-height: 1.35; +} + .ai-loading-dots span { width: 6px; height: 6px; @@ -986,6 +999,49 @@ tr.row-focus { } .ai-run-skip-btn:hover { opacity: 1; } +/* Follow-up question chips (numbered, rendered below AI bubble) */ +.ai-followup-chips, +.ai-nextstep-chips { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border-color); +} + +.ai-suggestion-chip { + background: transparent; + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 3px 9px; + font-size: 10.5px; + cursor: pointer; + color: var(--muted-color); + transition: background 0.15s, border-color 0.15s, color 0.15s; + text-align: left; + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ai-followup-chips .ai-suggestion-chip:hover { + border-color: var(--accent-color); + color: var(--accent-color); + background: rgba(99, 179, 237, 0.07); +} + +.ai-nextstep-chips .ai-suggestion-chip { + border-color: rgba(74, 222, 128, 0.4); + color: #4ade80; +} + +.ai-nextstep-chips .ai-suggestion-chip:hover { + background: rgba(74, 222, 128, 0.08); + border-color: #4ade80; +} + .ai-run-executing { display: flex; align-items: center; From eb64d41718a20c0db4f3e0295ce6035fc941b4b6 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 16:39:43 +0530 Subject: [PATCH 7/8] Bump version to 1.0.1-nightly --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 460c909..f1485be 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.2.0", + "version": "1.0.1-nightly", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, From 657dabfe0a651e5eabab738a979d09c53453bcf4 Mon Sep 17 00:00:00 2001 From: Richie Varghese Date: Sun, 19 Apr 2026 16:49:56 +0530 Subject: [PATCH 8/8] Bump version to 1.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f1485be..460c909 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.0.1-nightly", + "version": "1.2.0", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false,
Table Dead TuplesThreshold Last Autovacuum Last Autoanalyze
No unused indexes detected.
No non-constraint unused indexes detected.
No significant table bloat detected.
No significant dead-tuple pressure detected.
No tables need immediate vacuum.
No tables exceed autovacuum vacuum thresholds.
${escHtml(t.table_name)} ${dead.toLocaleString()}${threshold.toLocaleString()} ${lastVac} ${lastAna}