diff --git a/.gitignore b/.gitignore index 232593e..7f9b52d 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,7 @@ docs/.next/ .nycrc roadmap.md FIXES_APPLIED.md -.cursor/ \ No newline at end of file +.cursor/ +.agents/ +skills-lock.json +graphify-out/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7a73a41..fae8e3d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,2 +1,4 @@ { -} \ No newline at end of file + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} diff --git a/CHANGELOG.md b/CHANGELOG.md index c342001..f64adeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,23 +5,44 @@ 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.3] - 2026-04-29 +## [1.2.4] - 2026-05-03 ### Added -**Result AI toolbar** — Replaces the hover-only overlay: a **toggle** (sparkles + chevron, `aria-expanded`, default **collapsed**) shows/hides the action strip; strip is **flex-docked** under the output area. **Add to chat** sends query text, sampled rows (capped), optional NOTICEs, and preset helper text. +- **Backup and restore** — Database backup and restore from the extension: connection/database selection, a dedicated backup/restore webview with guided options, `pg_dump` / `pg_restore` (and related) argument builders with safe identifier handling and extra CLI args parsing, task-provider integration for scheduled dumps from VS Code, and clearer logging and errors across the flow. Chat assistant gains backup-oriented tooling and prompts where relevant. +- **PostgreSQL server version awareness** — New server-version helper so the extension can adapt queries and metadata reads; SQL helpers and the database tree use the live server version for better behavior on PostgreSQL 10 and 11. SQL completions and the DDL viewer incorporate version-aware paths where capabilities differ by release. -**Telemetry modes** — **`off`**: none. **`basic`**: usage counters only (commands, features, sessions, coarse connection/AI). **`detailed`**: + buckets for query duration/result size and spans (no SQL, hosts, or schema). Gated by VS Code global telemetry. Controls: status bar, **Set Telemetry Mode**, **Telemetry: Off | Basic | Detailed**, What’s New links - [Basic](command:postgres-explorer.telemetry.setModeBasic) | [Detailed](command:postgres-explorer.telemetry.setModeDetailed) | [Picker](command:postgres-explorer.telemetry.openModePicker). Details: **README**, **SECURITY.md**. +### Changed + +- **Database commands** — Refactored database command surface to align with the new backup/restore entry points and shared resolution of connections for external tools. +- **Chat webview** — Wiring updates to support backup-related assistant flows alongside existing chat behavior. + +## [1.2.3] - 2026-05-02 + +### Added -- Dashboard: WAL/checkpointer/version-safe stats; unused-index severity; statement stats if extension present. -- Connection test/save: TCP preflight; SSL cert paths for verify modes. -- Webviews: typed message IDs + validation; shared panel CSS; chat CSP nonce. -- Saved-query import: counts + merge by id/title; CI triggers documented; tree item keys; What’s New `command:` URIs. +- **Multi-statement failure strategies** — Added `postgresExplorer.query.executionFailureStrategy` so long SQL batches can keep going, stop hard, or ask you what to do next. +- **Execution summaries** — Mixed results now end with a clear markdown recap, so you can see what succeeded, what failed, and how far the cell got without digging through noise. +- **Dangerous SQL transaction UX** — Risky SQL now asks once per cell and gives you a safer `Execute in Transaction` path with an explicit COMMIT/ROLLBACK follow-up. +- **Result AI toolbar** — The result actions are easier to find now: a proper toggle reveals the strip when you need it, and **Add to chat** sends the query, sample rows, and helpful context without extra clicking. +- **Telemetry modes** — Choose between no telemetry, basic usage counters, or detailed performance buckets. It stays behind VS Code's global telemetry switch and is surfaced where you can actually change it. + - Dashboard: WAL/checkpointer/version-safe stats; unused-index severity; statement stats if extension present. + - Connection test/save: TCP preflight; SSL cert paths for verify modes. + - Webviews: typed message IDs + validation; shared panel CSS; chat CSP nonce. + - Saved-query import: counts + merge by id/title; CI triggers documented; tree item keys; What’s New `command:` URIs. +- **SQL completion warm cache** — Completions can reuse a warm cache tied to notebook metadata, with invalidation when the tree or executed SQL updates schema-related context. +- **ERD 2.0 across schemas & DBML import** — Schema designer adds commands to open an ERD spanning multiple schemas and to import DBML for visualization and workflow (DBML parsing via `@dbml/core`). +- **Lazy result tabs** — Chart, analyst, and explain experiences load on demand in the notebook result renderer to keep heavy UI off the critical path. ### Changed -- Telemetry: `mode` + sinks/batching (see settings); lifecycle + usage + optional performance events. -- Connections: no SSL downgrade when env is **production**; AI: provider allowlist + empty-message guard + usage events. -- Grid prefs: structured responses; phased coverage merges before report; ignore `.cursor/`. +- **Improved SQL keyword suggestions**: Notebook SQL suggestions now pay attention to where you are in the query, so the list feels less random and more helpful. +- Query analysis now explains ALTER impact more clearly and asks for confirmation more consistently when a change could hurt production. +- Telemetry now uses explicit modes and sinks, with lifecycle, usage, and optional performance events routed more intentionally. +- Connections no longer silently relax SSL in production, and AI providers are kept on an allowlist with a guard for empty messages. +- Grid preferences now return structured responses, coverage merges happen before reporting, and `.cursor/` is ignored. +- **SQL completion depth** — Parser support for stripping comments and normalizing identifiers; completions respect `search_path`-changing statements, derived subquery aliases, and session metadata; shared completion helpers consolidated. +- **Schema designer / ERD** — ERD panel and webview reorganized into focused modules (queries, types, HTML, DBML import, export/migration draft helpers); AI settings/chat templates updated alongside AI service wiring. +- **Notebook result renderer** — Chart.js registration is idempotent; large result rendering split into dedicated modules (`renderQueryResult`, review/edit helpers) for clearer structure and easier maintenance. ### Fixed - Explorer favorites key typo (trailing space). @@ -29,30 +50,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.2] - 2026-04-28 ### Added -- **Sliding-window result streaming**: Optional PostgreSQL `SCROLL` cursor execution for eligible parameter-free `SELECT` queries (`postgresExplorer.performance.slidingWindowSelects`, default on). Keeps a bounded row buffer in the grid and extension host; scrolling fetches the next/previous window. Configurable cap via `postgresExplorer.performance.slidingWindowRowCap` (10–500 rows). Dismissible streaming hint banner with optional mute for the session/workspace. -- **`bytea` display modes**: Setting `postgresExplorer.query.byteaDisplayFormat` — `hex0x` (default), PostgreSQL `\x` hex text, or JSON-oriented debug shape — applied consistently in result grids and history. -- **SQL Assistant turn controls**: Regenerate the latest assistant reply without duplicating the user turn; resend / branch from a chosen user message (truncate history after that turn and rerun). Attach-to-assistant flows can prefill the composer when a message string is supplied (toast copy updated accordingly). -- **Result grid toolbar & editing workflow**: Result identity bar, consolidated toolbar/footer, view selector, inline banners, and a commit-confirmation step for pending cell edits. Source notebook cell index is surfaced for returning focus to the executing cell. +- **Sliding-window result streaming**: Large `SELECT` results can now stream in windows instead of overwhelming the grid, with a bounded buffer, a configurable row cap, and a hint banner you can dismiss when you already know the feature. +- **`bytea` display modes**: `bytea` values now show up the way you expect, whether you prefer `hex0x`, PostgreSQL `\x` text, or a JSON-friendly debug shape. +- **SQL Assistant turn controls**: You can regenerate the latest reply without repeating yourself, branch from an earlier turn, or prefill the composer when you want to move faster. +- **Result grid toolbar & editing workflow**: The result grid now feels steadier to work in, with a clearer toolbar, better banners, and a final commit check before edits are written back. ### Changed -- **Export vs Auto-LIMIT**: Query results carry `exportQuery` (original SQL before Auto-LIMIT) so full CSV/JSON/Excel exports can rerun the unrestricted statement when the grid was limited for display. -- **Renderer & executor integration**: Server-side cursor sessions coordinate with the webview for windowed fetches; grid-derived queries and edit-commit preferences are handled in the extension host so UI actions stay consistent with execution policy. +- **Export vs Auto-LIMIT**: Exports now rerun the original SQL instead of the display-limited query, so CSV/JSON/Excel downloads can include everything you asked for. +- **Renderer & executor integration**: Windowed fetches and edit commits are coordinated in the extension host so the UI stays aligned with what the database is actually doing. ## [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. +- **SQL Assistant editor tabs**: You can open SQL Assistant in the main editor now, which makes the flow feel less cramped when the sidebar is not enough. +- **Multi-tab SQL Assistant workflow**: Multiple assistant tabs are supported, so you can keep separate conversations going without losing your place. +- **AI Insights dashboard panel**: The dashboard now surfaces schema health, connection analytics, vacuum progress, and direct Ask AI actions in one place. ### 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. +- **SQL completion deduplication**: Table and column suggestions are deduplicated before caching, so notebook autocomplete stops repeating itself. +- **Assistant routing consistency**: Chat attachments and assistant updates now follow the active SQL Assistant webview, which keeps multi-tab conversations in sync. +- **Review changes UI stability**: The result review / compare UI now renders more consistently and keeps actions 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. +- **Dashboard telemetry expansion**: Dashboard stats now show unused indexes, sequential scans, table bloat, autovacuum progress, tables needing vacuum, and connections grouped by application name. +- **Dashboard AI interactions**: You can launch AI prompts from the dashboard, run queries for analysis, download CSV from AI-assisted results, and get notified when health starts slipping. ## [1.0.0] - 2026-04-14 @@ -163,40 +184,40 @@ PgStudio v1.0.0 is a major milestone release with comprehensive stability improv ## [0.9.5] - 2026-04-09 ### Added -- **Image support in SQL Assistant**: Paste images directly from clipboard or upload via the new image button (🖼) in the chat input. Images render as fixed 56×56px thumbnails in a dedicated preview strip above the textarea. -- **Image lightbox**: Click any image thumbnail (in the input strip or in message history) to open a full-size overlay preview. -- **Vision AI support**: Images are now properly sent to AI providers that support vision — OpenAI/custom as `image_url` parts, Anthropic as `base64` image blocks, Gemini as `inline_data` parts, and VS Code LM via `LanguageModelImagePart`. -- **File preview from chat**: Clicking an attached file chip (in the input area or in message history) opens the file as a preview tab in the VS Code editor. Works for files attached via the file picker, "Send to Chat", and "Analyze Data" buttons. -- **GitHub Models account sign-in**: Added first-class GitHub Models provider support using VS Code GitHub authentication sessions, including model listing and connection checks from AI Settings. +- **Image support in SQL Assistant**: You can paste or upload images right into chat, with compact thumbnails so the composer stays usable. +- **Image lightbox**: Thumbnails open into a full-size preview when you want to inspect an image before sending it on. +- **Vision AI support**: Image attachments now reach the providers that can actually use them, including OpenAI, Anthropic, Gemini, and VS Code LM. +- **File preview from chat**: Attached files open in a preview tab, whether they came from the picker, Send to Chat, or Analyze Data. +- **GitHub Models account sign-in**: GitHub Models now plugs into VS Code auth sessions, with model listing and connection checks from AI Settings. ### Changed -- **GitHub auth UX**: GitHub provider connection now uses the standard VS Code GitHub sign-in flow in AI Settings, with provider state reflected in the UI. -- **Nightly release channel**: Nightly builds are now available as pre-release updates, including a dedicated Open VSX nightly companion package for early access testing. +- **GitHub auth UX**: GitHub sign-in now follows the standard VS Code flow, and the provider state is reflected where you make the choice. +- **Nightly release channel**: Nightly builds ship as pre-release updates, with a dedicated Open VSX companion package for early access testing. ### Fixed -- **Image CSP**: Added `img-src data: blob:` to the webview Content Security Policy so image thumbnails actually render (previously blocked by `default-src 'none'`). -- **File path missing on attach**: Files picked via the attachment button now include their filesystem path, enabling click-to-preview. -- **Open VSX GitHub auth fallback**: Removed invalid OAuth scope requests for GitHub session auth to prevent users from being redirected to PAT-only fallback prompts. +- **Image CSP**: Webviews now allow `data:` and `blob:` image sources, so the thumbnails actually show up. +- **File path missing on attach**: Attachment-picked files now keep their filesystem path, which makes preview tabs work. +- **Open VSX GitHub auth fallback**: Invalid OAuth scope requests were removed so GitHub auth no longer bounces people into PAT fallback prompts. --- ## [0.9.2] - 2026-04-07 ### Added -- **Local AI model support**: New **Ollama** and **LM Studio** providers connect to locally-running models at their default endpoints (`http://localhost:11434` and `http://localhost:1234`). No API key required. -- **Nightly build pipeline**: Automated GitHub Actions workflow publishes pre-release builds to VS Code Marketplace and Open VSX on every push to `main`. Nightly versions use odd minor numbers (e.g., `0.9.1.{run}`). -- **AI response timing**: Chat responses now display elapsed time alongside token usage for quick performance feedback. -- **Code snippet execution**: Suggestion bubbles in chat can now run code snippets directly via a new `runSnippet()` action. +- **Local AI model support**: Ollama and LM Studio now connect to local models at their default endpoints, with no API key needed. +- **Nightly build pipeline**: Every push to `main` now publishes pre-release builds to VS Code Marketplace and Open VSX. +- **AI response timing**: Chat replies now show elapsed time alongside token usage, which makes slow turns easier to notice. +- **Code snippet execution**: Suggestion bubbles can now run snippets directly when you want to act on an idea instead of copying it out. ### Changed -- **Connection edit flow**: Editing a connection now opens `ConnectionFormPanel` directly instead of dispatching a command, making the flow more reliable. -- **Connection card styling**: Environment-specific accent colors (green for DEV, orange for STAGING, red for PROD) applied consistently across connection cards. -- **Chat input focus**: `sendSuggestion()` now properly focuses the input and positions the cursor after inserting a suggestion. -- **Publish workflow**: Version mismatch between the git tag and `package.json` now fails the build instead of emitting a warning. +- **Connection edit flow**: Editing a connection now opens the form directly, which makes the flow feel more dependable. +- **Connection card styling**: Environment-specific accent colors are applied consistently across connection cards. +- **Chat input focus**: `sendSuggestion()` now focuses the input and leaves the cursor where people expect it. +- **Publish workflow**: Version mismatches between the git tag and `package.json` now fail fast instead of slipping through as warnings. ### Fixed -- **Inline code rendering**: Fixed markdown rendering of inline code in chat responses (resolves display issues with meta-notation like `(u, o)`). -- **SVG icon sizing**: Code block action buttons now have explicit `width`/`height` attributes, preventing layout inconsistencies across themes. +- **Inline code rendering**: Markdown inline code now renders correctly in chat responses, including meta-notation like `(u, o)`. +- **SVG icon sizing**: Code block action buttons now size consistently across themes. ### Removed - **Tree filter commands**: `postgres-explorer.filterTree` and `postgres-explorer.clearFilter` removed from activation — these experimental commands were unused. @@ -206,61 +227,58 @@ PgStudio v1.0.0 is a major milestone release with comprehensive stability improv ## [0.9.0] - 2026-04-06 ### Added -- **Anthropic model discovery**: AI Settings now lists Anthropic models from the official `/v1/models` API instead of a fixed local list. -- **Guided chat responses**: Assistant replies can now include numbered follow-up questions, optional next-step suggestion bubbles, and contextual quote-style factoids or jokes when they genuinely fit. +- **Anthropic model discovery**: AI Settings now pulls Anthropic models from the official `/v1/models` API, so the list stays in sync without manual upkeep. +- **Guided chat responses**: Assistant replies can now offer numbered follow-ups, optional next-step bubbles, and the occasional well-placed factoid or joke when it actually helps. ### Changed -- **AI key lookup**: Direct AI provider keys now resolve from `SecretStorage` first, fixing false “API key required” errors when the key is already saved. -- **Chat identity and styling**: Assistant messages are labeled **PG Studio Bot**, with improved assistant bubble contrast and quote styling for richer responses. -- **Composer UX**: The chat input and suggestion bubbles were tightened for readability, capped to a compact height, and styled to avoid carrying stale next-step actions between chats. +- **AI key lookup**: Direct AI provider keys now resolve from `SecretStorage` first, which prevents the frustrating false "API key required" message when the key is already saved. +- **Chat identity and styling**: Assistant messages are labeled **PG Studio Bot**, with clearer contrast and quote styling that makes replies easier to read. +- **Composer UX**: The chat input and suggestion bubbles were tightened for readability and kept from carrying stale next-step actions between chats. ### Fixed -- **Follow-up selection**: Typing a number now resolves to the corresponding numbered follow-up question from the previous assistant message, instead of being treated as a fresh prompt. -- **Next-step carry-over**: Next-step bubbles are hidden when a new follow-up is sent or when switching chats, so actions remain specific to the active conversation. +- **Follow-up selection**: Typing a number now picks the matching follow-up question from the previous assistant message instead of starting over. +- **Next-step carry-over**: Next-step bubbles now stay tied to the active conversation, so they do not leak into the next chat. ## [0.8.8] - 2026-03-21 ### Added -- **Command palette — release notes**: **PgStudio: Show Release Notes / What's New** is registered in the manifest for discovery (changelog opens in an editor-area webview panel). -- **Tests**: Integration coverage for notebook renderer message flow (`NotebookRendererFlow.test.ts`) and unit tests for query save/delete handlers (`QueryHandlers.test.ts`). -- **Table Designer (create mode)**: Drag-and-drop column reorder with clear create-vs-edit UI behavior. -- **AI chat**: Explicit **production safety** rules in the system prompt (read-first bias, transaction/rollback guidance, guarded writes). +- **Command palette - release notes**: **PgStudio: Show Release Notes / What's New** is now easy to find, and it opens in the editor area instead of hiding off to the side. +- **Tests**: Added coverage for notebook renderer message flow and query save/delete handlers. +- **Table Designer (create mode)**: Drag-and-drop column reorder now has clearer create-vs-edit behavior. +- **AI chat**: The system prompt now carries explicit production-safety rules, so the assistant stays more cautious around writes. ### Changed -- **Sidebar layout**: **Connections** and **SQL Assistant** are listed first; **Saved Queries** and **Query History** use new view identifiers and start **collapsed by default** (VS Code only applies manifest defaults when it has no prior UI state for that view). -- **Release notes**: **What's New** is no longer a sidebar section; use the command palette command. Automatic release-note panels on upgrade are not shown on activation. -- **Notebook inline edits**: `SaveChangesHandler` and bulk/table deletes use **parameterized** `UPDATE`/`DELETE`, run inside a **transaction** (`BEGIN` / `COMMIT` / `ROLLBACK` on failure), and build predicates with proper identifier quoting (fixes composite-key and NULL edge cases). +- **Sidebar layout**: **Connections** and **SQL Assistant** now lead the sidebar, while **Saved Queries** and **Query History** start collapsed. +- **Release notes**: **What's New** moved out of the sidebar and into the command palette, and upgrade popups no longer appear on activation. +- **Notebook inline edits**: Inline saves and bulk/table deletes now use parameterized SQL inside transactions with proper identifier quoting. --- ## [0.8.6...0.8.7] - 2026-03-15 ### Added -- **.pgpass support**: Native backwards-compatible support with explicit resolvers parsing standard `.pgpass` (and Windows `%APPDATA%\postgresql\pgpass.conf`) secret files. +- **.pgpass support**: Standard `.pgpass` files are now supported, including the Windows `%APPDATA%\postgresql\pgpass.conf` path. ### Fixed -- **Authentication Resilience**: Resolved standard connection `password authentication failed` issues that fell back to implicit OS defaults incorrectly. -- **SSL Fallback Reliability**: Fixed `DatabaseTreeProvider` stripping configuration details (such as direct inline passwords and sslmode) during fallback client re-trigger calculations. -- **.pgpass lookup scope**: Avoided resolving implicit machine name environments by strictly isolating parsing searches for explicit username options, fixing backward compatibility for local `trust` authentications. +- **Authentication Resilience**: Fixed connection failures that were incorrectly falling back to implicit OS defaults. +- **SSL Fallback Reliability**: `DatabaseTreeProvider` now keeps the full connection configuration when it retries clients. +- **.pgpass lookup scope**: `.pgpass` lookup now stays scoped to explicit usernames, which avoids confusing machine-name fallbacks and preserves local `trust` auth. --- ## [0.8.4...0.8.5] - 2026-02-19 ### Added -- **Visual Table Designer**: A robust, interactive UI for creating tables. Define columns, data types, constraints, and foreign keys visually without writing SQL. -- **Visual Index & Constraint Manager**: Manage indexes and constraints with a modern GUI. Analyze usage, drop unused indexes, and create new constraints with ease. -- **Smart Paste**: Intelligent clipboard handling that detects SQL, CSV, or JSON content and offers context-aware actions (e.g., "Insert as Rows", "Format SQL"). -- **Dashboard Improvements**: - - **Visual Lock Viewer**: Diagnostic tree view to identify and resolve blocking chains and deadlocks. - - **Enhanced Metrics**: Real-time charts for IO, Checkpoints, and System Load. - - **Active Query Management**: Kill/Cancel blocking queries directly from the dashboard. +- **Visual Table Designer**: A visual table builder for defining columns, data types, constraints, and foreign keys without hand-writing SQL. +- **Visual Index & Constraint Manager**: A GUI for managing indexes and constraints, checking usage, and dropping or creating them more comfortably. +- **Smart Paste**: Clipboard handling now recognizes SQL, CSV, and JSON content and offers the right action. +- **Dashboard Improvements**: Added lock diagnostics, better live metrics, and direct blocking-query controls in the dashboard. ### Improved -- **Stability**: Fixed dashboard template loading issues to ensure reliable rendering on all platforms. +- **Stability**: Dashboard template loading is fixed, so rendering is more reliable across platforms. ### Fixed -- https://github.com/dev-asterix/PgStudio/issues/56 - Resolved local AI model API implementation with support for http and custom port. +- Local AI model API support now works with HTTP and custom ports. --- diff --git a/package-lock.json b/package-lock.json index 45df1d0..39ba209 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { "name": "postgres-explorer", - "version": "1.0.0", + "version": "1.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "postgres-explorer", - "version": "1.0.0", + "version": "1.3.4", "license": "MIT", "dependencies": { + "@cursor/sdk": "^1.0.12", + "@dbml/core": "^7.1.2", "@types/pg-cursor": "^2.7.2", "chart.js": "^4.5.1", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", "esbuild": ">=0.28.0", "pg": "^8.20.0", "pg-cursor": "^2.19.0", @@ -19,6 +23,8 @@ }, "devDependencies": { "@types/chai": "^5.2.3", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", "@types/jsdom": "^28.0.1", "@types/mocha": "^10.0.10", "@types/module-alias": "^2.0.4", @@ -597,6 +603,49 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bufbuild/protobuf": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", + "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@connectrpc/connect": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz", + "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", + "license": "Apache-2.0", + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0" + } + }, + "node_modules/@connectrpc/connect-node": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@connectrpc/connect-node/-/connect-node-1.7.0.tgz", + "integrity": "sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==", + "license": "Apache-2.0", + "dependencies": { + "undici": "^5.28.4" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@bufbuild/protobuf": "^1.10.0", + "@connectrpc/connect": "1.7.0" + } + }, + "node_modules/@connectrpc/connect-node/node_modules/undici": { + "version": "5.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.29.0.tgz", + "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^2.0.0" + }, + "engines": { + "node": ">=14.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -761,6 +810,126 @@ "node": ">=20.19.0" } }, + "node_modules/@cursor/sdk": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk/-/sdk-1.0.12.tgz", + "integrity": "sha512-jGx0wFY1N9uIdIKr303CfM6m/dLXmRCUnU/0yNP/oiOpkBXqgqaThGbgYbcOeVrYonMZc/DZJ9EydXOEPJLcbg==", + "license": "SEE LICENSE IN LICENSE.md", + "dependencies": { + "@bufbuild/protobuf": "1.10.0", + "@connectrpc/connect": "^1.6.1", + "@connectrpc/connect-node": "^1.6.1", + "@statsig/js-client": "3.31.0", + "sqlite3": "^5.1.7", + "zod": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@cursor/sdk-darwin-arm64": "1.0.12", + "@cursor/sdk-darwin-x64": "1.0.12", + "@cursor/sdk-linux-arm64": "1.0.12", + "@cursor/sdk-linux-x64": "1.0.12", + "@cursor/sdk-win32-x64": "1.0.12" + } + }, + "node_modules/@cursor/sdk-darwin-arm64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-arm64/-/sdk-darwin-arm64-1.0.12.tgz", + "integrity": "sha512-AOFx+aX+4SntAeC66YncHACXk5duxp+HzDrxxF4Tl93N6nLjHaHEKSAXbt87ivL34MCHop4v/3c70QzBhamB2g==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cursor/sdk-darwin-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-darwin-x64/-/sdk-darwin-x64-1.0.12.tgz", + "integrity": "sha512-/ZDAYFUrnPd8hAGRky9ZGcROqZSZ2b5W+aEjTdINzLhJ8x5ZNXtjaz0ZYSHabOn2BeErjXgTcq+4bX2/To4C1A==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@cursor/sdk-linux-arm64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-arm64/-/sdk-linux-arm64-1.0.12.tgz", + "integrity": "sha512-kAxNqiB3dPtlW9fVjjIZEdbIGEGLA9moOM3zYwsXh8J1Qw942nJYMGDGR4o8x0zglwZ24a1JpovvZamrCaC3Yw==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cursor/sdk-linux-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-linux-x64/-/sdk-linux-x64-1.0.12.tgz", + "integrity": "sha512-RmBiBCPKMZC5McDerGk2Rk4P47xz2A+uzRoRgH6sMoOjklc33ry11iAZC0D5F5xH85chgY878086A/Q8+XrAuA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@cursor/sdk-win32-x64": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@cursor/sdk-win32-x64/-/sdk-win32-x64-1.0.12.tgz", + "integrity": "sha512-uH4shdHrKOdtNLapy1uuScJ9lL2Pc8zc9I9ZKC6b6bx+0UX6xLAqjPP7dqVPfO6D9u61yLq1Hs86XOLs5ZVkPA==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@dbml/core": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@dbml/core/-/core-7.1.2.tgz", + "integrity": "sha512-QItKIGA+CNnC90HhlfRcJXyTVEaIAErqR3NYa8C1pu1o1bxUuKBidpdKd+xZON8eMBDBhrgeDIc9F/+hDA9TrA==", + "license": "Apache-2.0", + "dependencies": { + "@dbml/parse": "^7.1.2", + "antlr4": "^4.13.1", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "luxon": "^3.4.4", + "parsimmon": "^1.13.0", + "pluralize": "^8.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@dbml/parse": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-7.1.2.tgz", + "integrity": "sha512-jFQWXakYSS/I4oJTpPj+irQCADiPuucIfBKqaB3oYBvuHb48qNGrb1jtDPdUPLqZmTxeKK0uhfBRfC4BW6OFUg==", + "license": "Apache-2.0", + "dependencies": { + "lodash-es": "^4.18.1", + "luxon": "^3.7.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", @@ -1453,6 +1622,22 @@ } } }, + "node_modules/@fastify/busboy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "license": "MIT", + "optional": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2006,6 +2191,71 @@ "node": ">= 8" } }, + "node_modules/@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + } + }, + "node_modules/@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "license": "MIT", + "optional": true, + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@npmcli/move-file/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2245,6 +2495,21 @@ "node": ">=4" } }, + "node_modules/@statsig/client-core": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@statsig/client-core/-/client-core-3.31.0.tgz", + "integrity": "sha512-SuxQD6TmVszPG7FoMKwTk/uyBuVFk7XnxI3T/E0uyb7PL7GNjONtfsoh+NqBBVUJVse0CUeSFfgJPoZy1ZOslQ==", + "license": "ISC" + }, + "node_modules/@statsig/js-client": { + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/@statsig/js-client/-/js-client-3.31.0.tgz", + "integrity": "sha512-LFa5E0LjT6sTfZv3sNGoyRLSZ1078+agdgOA+Vm1ecjG+KbSOfBLTW7hMwimrJ29slRwbYDzbtKaPJo/R37N2g==", + "license": "ISC", + "dependencies": { + "@statsig/client-core": "3.31.0" + } + }, "node_modules/@textlint/ast-node-types": { "version": "15.4.0", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.4.0.tgz", @@ -2329,6 +2594,16 @@ "@textlint/ast-node-types": "15.4.0" } }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", @@ -2379,6 +2654,20 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3148,6 +3437,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC", + "optional": true + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3194,11 +3490,24 @@ "node": ">= 14" } }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -3270,6 +3579,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/antlr4": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/antlr4/-/antlr4-4.13.2.tgz", + "integrity": "sha512-QiVbZhyy4xAZ17UPEuG3YTOt8ZaoeOR1CvEAqrEsDBsOqINslaB147i9xqljZqoyf5S+EUlGStaj+t22LT9MOg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16" + } + }, "node_modules/append-transform": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", @@ -3283,6 +3601,13 @@ "node": ">=8" } }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC", + "optional": true + }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", @@ -3290,6 +3615,21 @@ "dev": true, "license": "MIT" }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3358,14 +3698,13 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3380,8 +3719,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/baseline-browser-mapping": { "version": "2.10.17", @@ -3431,13 +3769,20 @@ "url": "https://bevry.me/fund" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -3462,7 +3807,7 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -3527,7 +3872,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -3543,7 +3887,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3591,61 +3934,169 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/caching-transform": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", - "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", - "dev": true, - "license": "MIT", + "node_modules/cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "license": "ISC", + "optional": true, "dependencies": { - "hasha": "^5.0.0", - "make-dir": "^3.0.0", - "package-hash": "^4.0.0", - "write-file-atomic": "^3.0.0" + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" }, "engines": { - "node": ">=8" + "node": ">= 10" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, + "node_modules/cacache/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "optional": true, "engines": { - "node": ">= 0.4" + "node": ">=10" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", + "node_modules/cacache/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">= 0.4" + "node": "*" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" + "node_modules/cacache/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache/node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/camelcase": { @@ -3782,9 +4233,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ci-info": { "version": "2.0.0", @@ -3797,7 +4246,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3889,6 +4338,16 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "optional": true, + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3923,9 +4382,16 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC", + "optional": true + }, "node_modules/convert-source-map": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", @@ -4013,6 +4479,56 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -4041,7 +4557,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4076,9 +4592,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -4093,9 +4607,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=4.0.0" } @@ -4212,13 +4724,18 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT", + "optional": true + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -4358,9 +4875,19 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -4379,9 +4906,7 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -4399,6 +4924,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -4412,6 +4947,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT", + "optional": true + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4794,9 +5336,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, "license": "(MIT OR WTFPL)", - "optional": true, "engines": { "node": ">=6" } @@ -4912,6 +5452,12 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5071,9 +5617,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "11.3.2", @@ -5090,6 +5634,37 @@ "node": ">=14.14" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC", + "optional": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5100,6 +5675,57 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/gauge/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/gauge/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/gauge/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -5173,9 +5799,7 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/glob": { "version": "10.5.0", @@ -5306,7 +5930,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/has-flag": { @@ -5361,6 +5985,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC", + "optional": true + }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -5477,6 +6108,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -5505,11 +6143,21 @@ "node": ">= 14" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5522,7 +6170,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -5537,8 +6184,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "7.0.5", @@ -5581,7 +6227,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -5591,7 +6237,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5610,21 +6256,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "license": "ISC", + "optional": true + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "optional": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } }, "node_modules/is-ci": { "version": "2.0.0", @@ -5669,7 +6340,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5720,6 +6391,13 @@ "node": ">=12" } }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "license": "MIT", + "optional": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5820,7 +6498,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -6286,7 +6964,12 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.flattendeep": { @@ -6380,7 +7063,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -6389,6 +7072,15 @@ "node": ">=10" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -6422,6 +7114,89 @@ "dev": true, "license": "ISC" }, + "node_modules/make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "license": "ISC", + "optional": true, + "dependencies": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/markdown-it": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -6535,9 +7310,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -6549,7 +7322,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6562,7 +7335,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6575,16 +7347,186 @@ "dev": true, "license": "BlueOak-1.0.0", "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-collect/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "optionalDependencies": { + "encoding": "^0.1.12" + } + }, + "node_modules/minipass-fetch/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/mocha": { "version": "11.7.5", @@ -6715,7 +7657,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/mute-stream": { @@ -6736,9 +7678,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -6775,13 +7715,21 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-abi": { "version": "3.85.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -6797,6 +7745,70 @@ "license": "MIT", "optional": true }, + "node_modules/node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">= 10.12.0" + } + }, + "node_modules/node-gyp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "optional": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-gyp/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "optional": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -6831,6 +7843,22 @@ "node": ">=20" } }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -6866,6 +7894,23 @@ "dev": true, "license": "ISC" }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "optional": true, + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -7202,9 +8247,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", - "optional": true, "dependencies": { "wrappy": "1" } @@ -7451,6 +8494,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parsimmon": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/parsimmon/-/parsimmon-1.18.1.tgz", + "integrity": "sha512-u7p959wLfGAhJpSDJVYXoyMCXWYwHia78HhRBWqk7AIbxdmlrfdp5wX0l3xv/iTSH5HvhN9K7o26hwwpgS5Nmw==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -7461,6 +8510,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -7706,7 +8765,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -7755,9 +8813,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -7818,13 +8874,32 @@ "node": ">=8" } }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "license": "ISC", + "optional": true + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7927,9 +9002,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -8023,9 +9096,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -8108,6 +9179,16 @@ "node": ">=0.12" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -8264,7 +9345,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -8333,7 +9413,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8356,7 +9435,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/shebang-command": { @@ -8475,7 +9554,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, "funding": [ { "type": "github", @@ -8490,14 +9568,12 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, "funding": [ { "type": "github", @@ -8513,7 +9589,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -8579,6 +9654,60 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", + "integrity": "sha512-NlGELfPrgX2f1TAAcz0WawlLn+0r3FyhhCRpFFK2CemXenPYvzMWWZINv3eDNo9ucdwme7oCHRY0Jnbs4aIkog==", + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8710,6 +9839,36 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/sqlite3": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.7.tgz", + "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.1", + "tar": "^6.1.11" + }, + "optionalDependencies": { + "node-gyp": "8.x" + }, + "peerDependencies": { + "node-gyp": "8.x" + }, + "peerDependenciesMeta": { + "node-gyp": { + "optional": true + } + } + }, + "node_modules/sqlite3/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT" + }, "node_modules/ssh2": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", @@ -8727,13 +9886,37 @@ "nan": "^2.23.0" } }, + "node_modules/ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ssri/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -8742,7 +9925,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -8796,7 +9979,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -8806,7 +9989,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -8869,9 +10052,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -8963,13 +10144,29 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tar-fs": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -8981,9 +10178,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -8995,6 +10190,24 @@ "node": ">=6" } }, + "node_modules/tar/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -9377,9 +10590,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -9509,6 +10720,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^2.0.0" + } + }, + "node_modules/unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -9571,9 +10802,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/uuid": { "version": "8.3.2", @@ -9681,7 +10910,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -9700,6 +10929,16 @@ "dev": true, "license": "ISC" }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9819,9 +11058,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/write-file-atomic": { "version": "3.0.3", @@ -9923,7 +11160,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, "license": "ISC" }, "node_modules/yargs": { @@ -10055,6 +11291,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 637527a..a353e1d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "1.2.3-nightly", + "version": "1.2.4", "description": "PostgreSQL database explorer for VS Code with notebook support [Nightly]", "publisher": "ric-v", "private": false, @@ -691,6 +691,12 @@ "title": "Restore...", "icon": "$(cloud-upload)" }, + { + "command": "postgres-explorer.openBackupWorkspace", + "title": "Backup & Restore Workspace: Choose Connection & Database…", + "category": "PostgreSQL", + "icon": "$(archive)" + }, { "command": "postgres-explorer.generateCreateScript", "title": "CREATE Script", @@ -1119,6 +1125,18 @@ "icon": "$(type-hierarchy)", "category": "PgStudio: Schema" }, + { + "command": "postgres-explorer.openErdMulti", + "title": "View ERD (pick schemas)…", + "icon": "$(type-hierarchy)", + "category": "PgStudio: Schema" + }, + { + "command": "postgres-explorer.importDbml", + "title": "Import DBML → CREATE TABLE…", + "icon": "$(file-code)", + "category": "PgStudio: Schema" + }, { "command": "postgres-explorer.importData", "title": "Import Data…", @@ -1164,6 +1182,11 @@ "title": "Telemetry: Detailed", "category": "PgStudio" }, + { + "command": "postgres-explorer.showWhatsNew", + "title": "What's New in PgStudio", + "category": "PgStudio" + }, { "command": "postgres-explorer.listTriggers", "title": "List Triggers", @@ -1719,6 +1742,7 @@ "enum": [ "vscode-lm", "github", + "cursor", "openai", "anthropic", "gemini", @@ -1837,6 +1861,16 @@ "default": "hex0x", "description": "Display format for binary (bytea) columns in query results: 0x-prefixed hex, PostgreSQL \\x hex, or JSON Buffer shape (debug)." }, + "postgresExplorer.query.executionFailureStrategy": { + "type": "string", + "enum": [ + "continue-on-error", + "fail-on-error", + "prompt-on-error" + ], + "default": "continue-on-error", + "description": "Strategy when a statement fails in multi-statement execution: 'continue-on-error' (execute remaining, show summary), 'fail-on-error' (stop immediately), 'prompt-on-error' (ask user per failure)." + }, "postgresExplorer.parameters.cacheLastValues": { "type": "boolean", "default": true, @@ -1924,6 +1958,53 @@ "editor.acceptSuggestionOnEnter": "on" } }, + "taskDefinitions": [ + { + "type": "pgstudio-pgdump", + "required": [ + "connectionId", + "databaseName", + "outputPath" + ], + "properties": { + "connectionId": { + "type": "string", + "description": "Connection id (postgresExplorer.connections[].id)" + }, + "databaseName": { + "type": "string", + "description": "Database to dump" + }, + "outputPath": { + "type": "string", + "description": "Output file or directory path (supports ${workspaceFolder})" + }, + "dumpFormat": { + "type": "string", + "enum": [ + "custom", + "plain", + "directory", + "tar" + ], + "description": "pg_dump -F mapping", + "default": "custom" + }, + "verbose": { + "type": "boolean", + "default": true + }, + "schemaOnly": { + "type": "boolean", + "default": false + }, + "dataOnly": { + "type": "boolean", + "default": false + } + } + } + ], "menus": { "notebook/toolbar": [ { @@ -2590,6 +2671,11 @@ "when": "view == postgresExplorer && viewItem == database", "group": "inline@2" }, + { + "command": "postgres-explorer.openErdMulti", + "when": "view == postgresExplorer && viewItem == database", + "group": "1_actions" + }, { "command": "postgres-explorer.psqlTool", "when": "viewItem == database", @@ -3205,17 +3291,18 @@ ], "main": "./dist/extension.js", "scripts": { - "vscode:prepublish": "npm run esbuild-base -- --minify && npm run esbuild-renderer -- --minify", + "vscode:prepublish": "npm run esbuild-base -- --minify && npm run esbuild-renderer -- --minify && npm run esbuild-erd-webview -- --minify", "prepare:nightly:manifests": "node ./scripts/prepare-nightly-manifests.js", "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/ui/renderer/renderer_v2.ts --bundle --outfile=dist/renderer_v2.js --format=esm --platform=browser", + "esbuild-base": "esbuild ./src/extension.ts --bundle --outfile=dist/extension.js --external:vscode --external:ssh2 --external:pg --external:@cursor/sdk --format=cjs --platform=node --main-fields=main", + "esbuild-renderer": "esbuild ./src/ui/renderer/renderer_v2.ts --bundle --outdir=dist --format=esm --platform=browser --splitting", + "esbuild-erd-webview": "esbuild ./src/schemaDesigner/erd/webview/main.ts --bundle --outfile=dist/erd-webview.js --format=iife --platform=browser", "copy-templates": "cp -r templates dist/", - "compile": "tsc -p ./ && npm run esbuild-renderer && npm run copy-templates", + "compile": "tsc -p ./ && npm run esbuild-renderer && npm run esbuild-erd-webview && npm run copy-templates", "watch": "tsc -watch -p ./", - "esbuild": "npm run esbuild-base -- --sourcemap && npm run esbuild-renderer -- --sourcemap", - "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch & npm run esbuild-renderer -- --sourcemap --watch", + "esbuild": "npm run esbuild-base -- --sourcemap && npm run esbuild-renderer -- --sourcemap && npm run esbuild-erd-webview -- --sourcemap", + "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch & npm run esbuild-renderer -- --sourcemap --watch & npm run esbuild-erd-webview -- --sourcemap --watch", "test": "npm run compile && mocha --loader ./node_modules/ts-node/esm/transpile-only.mjs -r tsconfig-paths/register -r src/test/setup.ts 'src/test/unit/**/*.test.ts'", "test:unit": "npm run compile && TS_NODE_PROJECT=src/test/tsconfig.json node -r ts-node/register/transpile-only -r tsconfig-paths/register -r ./src/test/setup.ts ./node_modules/mocha/bin/mocha --exit 'src/test/unit/**/*.test.ts'", "test:integration": "npm run compile && ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/integration/**/*.test.ts'", @@ -3244,8 +3331,12 @@ "coverage:report": "npx c8 report --temp-directory ./.nyc_output --config .c8rc.phase-report.json --reporter=html --reporter=text" }, "dependencies": { + "@cursor/sdk": "^1.0.12", + "@dbml/core": "^7.1.2", "@types/pg-cursor": "^2.7.2", "chart.js": "^4.5.1", + "d3-force": "^3.0.0", + "d3-selection": "^3.0.0", "esbuild": ">=0.28.0", "pg": "^8.20.0", "pg-cursor": "^2.19.0", @@ -3254,6 +3345,8 @@ }, "devDependencies": { "@types/chai": "^5.2.3", + "@types/d3-force": "^3.0.10", + "@types/d3-selection": "^3.0.11", "@types/jsdom": "^28.0.1", "@types/mocha": "^10.0.10", "@types/module-alias": "^2.0.4", @@ -3291,4 +3384,4 @@ "webpack": "^5.76.0", "webpack-cli": "^5.0.0" } -} +} \ No newline at end of file diff --git a/src/activation/TelemetryStatusBar.ts b/src/activation/TelemetryStatusBar.ts deleted file mode 100644 index 8b1ceef..0000000 --- a/src/activation/TelemetryStatusBar.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as vscode from 'vscode'; - -const TELEMETRY_CONFIG = 'postgresExplorer.telemetry'; - -/** - * Status bar control for the current telemetry mode. Click opens the mode picker - * (same as Command Palette: PgStudio: Set Telemetry Mode). - */ -export class TelemetryStatusBar implements vscode.Disposable { - private readonly item: vscode.StatusBarItem; - private readonly disposables: vscode.Disposable[] = []; - - constructor() { - this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, -100); - this.item.command = 'postgres-explorer.telemetry.openModePicker'; - this.disposables.push( - this.item, - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration(TELEMETRY_CONFIG)) { - this.refresh(); - } - }), - ); - this.refresh(); - this.item.show(); - } - - private refresh(): void { - const mode = vscode.workspace.getConfiguration(TELEMETRY_CONFIG).get('mode', 'basic'); - const icon = - mode === 'off' ? '$(circle-slash)' : mode === 'basic' ? '$(pulse)' : '$(graph-line)'; - this.item.text = `${icon} PgStudio telemetry: ${mode}`; - this.item.tooltip = `Telemetry mode: ${mode}. Click to switch between off, basic, or detailed.`; - } - - dispose(): void { - for (const d of this.disposables) { - d.dispose(); - } - } -} diff --git a/src/activation/WhatsNewManager.ts b/src/activation/WhatsNewManager.ts index be72b3a..37fd840 100644 --- a/src/activation/WhatsNewManager.ts +++ b/src/activation/WhatsNewManager.ts @@ -44,12 +44,25 @@ export class WhatsNewManager { ); panel.webview.html = await this.getWebviewContent(panel.webview, version); + + const messageSub = panel.webview.onDidReceiveMessage(async (message: { type?: string; command?: string }) => { + if (message?.type !== 'runCommand' || typeof message.command !== 'string') { + return; + } + if (!message.command.startsWith('postgres-explorer.')) { + return; + } + await vscode.commands.executeCommand(message.command); + }); + panel.onDidDispose(() => messageSub.dispose()); } private async getWebviewContent(webview: vscode.Webview, version: string): Promise { const changelogContent = await this.getChangelogContent(); const logoPath = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'postgres-explorer.png')); const markedUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'marked.min.js')); + const highlightScriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'highlight.min.js')); + const highlightCssUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'resources', 'highlight.css')); const encodedChangelog = Buffer.from(changelogContent).toString('base64'); @@ -60,10 +73,14 @@ export class WhatsNewManager { What's New in PgStudio + + - - - ${content} - -`; -} - /** * cmdScriptAlterDatabase - Command to generate ALTER DATABASE script. */ diff --git a/src/commands/schema/operations.ts b/src/commands/schema/operations.ts index e303d62..0f607d0 100644 --- a/src/commands/schema/operations.ts +++ b/src/commands/schema/operations.ts @@ -9,6 +9,7 @@ import { validateCategoryItem, } from '../helper'; import { SchemaSQL } from '../sql'; +import { queryServerVersionNum } from '../../lib/postgresServerVersion'; @@ -119,10 +120,11 @@ export async function cmdShowSchemaProperties(item: DatabaseTreeItem, context: v dbConn = await getDatabaseConnection(item); const { client, metadata } = dbConn; + const serverVersionNum = await queryServerVersionNum(client); // Gather comprehensive schema information const [schemaInfo, objectsInfo, sizeInfo, privilegesInfo, dependenciesInfo, extensionsInfo] = await Promise.all([ client.query(QueryBuilder.schemaDetails(item.schema!)), - client.query(QueryBuilder.schemaObjectCounts(item.schema!)), + client.query(QueryBuilder.schemaObjectCounts(item.schema!, serverVersionNum)), client.query(QueryBuilder.schemaSize(item.schema!)), client.query(QueryBuilder.schemaPrivileges(item.schema!)), client.query(QueryBuilder.schemaDependencies(item.schema!)), diff --git a/src/commands/schemaDesigner.ts b/src/commands/schemaDesigner.ts index 73b25f4..c18656a 100644 --- a/src/commands/schemaDesigner.ts +++ b/src/commands/schemaDesigner.ts @@ -5,6 +5,8 @@ import { SchemaDiffPanel } from '../schemaDesigner/SchemaDiffPanel'; import { ErdPanel } from '../schemaDesigner/ErdPanel'; import { ImportDataPanel } from '../schemaDesigner/ImportDataPanel'; import { ConnectionManager } from '../services/ConnectionManager'; +import { resolveTreeItemConnection } from '../schemaDesigner/connectionHelper'; +import { ErrorHandlers } from './helper'; /** * Open the Visual Table Designer for an existing table (Edit mode) @@ -179,6 +181,107 @@ export async function cmdOpenErd( await ErdPanel.open(item, context); } +/** + * ERD across multiple schemas (context: database node). + */ +export async function cmdOpenErdMultiFromDatabase( + item: DatabaseTreeItem, + context: vscode.ExtensionContext +): Promise { + const conn = await resolveTreeItemConnection(item); + if (!conn) { + return; + } + try { + const sch = await conn.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 picked = await vscode.window.showQuickPick( + names.map((s) => ({ label: s, picked: s === 'public' })), + { canPickMany: true, title: 'ERD: choose schema(s)' } + ); + if (!picked || picked.length === 0) { + return; + } + await ErdPanel.openForSchemas( + context, + item, + picked.map((p) => p.label) + ); + } catch (err: unknown) { + await ErrorHandlers.handleCommandError(err, 'open multi-schema ERD'); + } finally { + conn.release(); + } +} + +/** + * Import a DBML file and emit PostgreSQL CREATE TABLE statements. + */ +export async function cmdImportDbml( + _item: DatabaseTreeItem | undefined, + _context: vscode.ExtensionContext +): Promise { + const uris = await vscode.window.showOpenDialog({ + openLabel: 'Import DBML', + filters: { DBML: ['dbml', 'txt'], 'All files': ['*'] }, + canSelectMany: false, + }); + if (!uris?.length) { + return; + } + const buf = await vscode.workspace.fs.readFile(uris[0]); + const text = Buffer.from(buf).toString('utf8'); + const { dbmlToPostgresCreateTables } = await import('../schemaDesigner/erd/erdDbmlImport'); + const { sql, errors } = dbmlToPostgresCreateTables(text); + if (errors.length > 0) { + await vscode.window.showWarningMessage(errors.join('; ')); + } + if (sql.length === 0) { + await vscode.window.showErrorMessage('No CREATE TABLE statements generated from DBML.'); + return; + } + const connections = + vscode.workspace.getConfiguration().get>>('postgresExplorer.connections') || + []; + type ConnPick = vscode.QuickPickItem & { conn?: Record }; + const items: ConnPick[] = [ + { label: 'Open as SQL buffer', description: 'Untitled editor', alwaysShow: true }, + ...connections.map((c: Record) => ({ + label: `Notebook: ${(c.name as string) || `${c.host}:${c.port}`}`, + description: (c.database as string) || 'postgres', + conn: c, + })), + ]; + const pick = await vscode.window.showQuickPick(items, { title: 'DBML import: open as…' }); + if (!pick) { + return; + } + if (!('conn' in pick) || !pick.conn) { + const doc = await vscode.workspace.openTextDocument({ + content: sql.join('\n\n'), + language: 'postgres', + }); + await vscode.window.showTextDocument(doc); + return; + } + const { createAndShowNotebook, createMetadata } = await import('./connection'); + const md = + `### DBML import\n\nSource: \`${uris[0].fsPath}\`\n\nReview before executing. Partial types and refs may need manual fixes.`; + await createAndShowNotebook( + [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, md, 'markdown'), + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, sql.join('\n\n'), 'sql'), + ], + createMetadata(pick.conn, (pick.conn.database as string) || 'postgres') + ); +} + /** * Open the Import Data tool (pgAdmin-style CSV/TSV import wizard) */ diff --git a/src/commands/sql/helper.ts b/src/commands/sql/helper.ts index 11379e5..26aed3a 100644 --- a/src/commands/sql/helper.ts +++ b/src/commands/sql/helper.ts @@ -4,6 +4,8 @@ * that were previously in helpers/helper.ts */ +import { PG_VERSION_10, PG_VERSION_11 } from '../../lib/postgresServerVersion'; + /** * Common SQL query templates */ @@ -329,9 +331,12 @@ WHERE schemaname = '${schema}' AND relname = '${table}'`, /** * Build table info query + * @param serverVersionNum from `SHOW server_version_num` (default assumes PostgreSQL 16+ features). */ - tableInfo: (schema: string, table: string): string => - `SELECT + tableInfo: (schema: string, table: string, serverVersionNum: number = 160_000): string => { + const isPartitionExpr = + serverVersionNum >= PG_VERSION_10 ? 'c.relispartition as is_partition' : 'false AS is_partition'; + return `SELECT c.relname as table_name, n.nspname as schema_name, pg_catalog.pg_get_userbyid(c.relowner) as owner, @@ -339,10 +344,11 @@ WHERE schemaname = '${schema}' AND relname = '${table}'`, c.reltuples::bigint as row_estimate, c.relpages as page_count, c.relhasindex as has_indexes, - c.relispartition as is_partition + ${isPartitionExpr} FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace -WHERE n.nspname = '${schema}' AND c.relname = '${table}'`, +WHERE n.nspname = '${schema}' AND c.relname = '${table}'`; + }, /** * Build view definition query @@ -585,9 +591,14 @@ WHERE n.nspname = '${schema}'`, /** * Build schema object counts query + * @param serverVersionNum from `SHOW server_version_num` (default assumes PostgreSQL 16+). */ - schemaObjectCounts: (schema: string): string => - `SELECT + schemaObjectCounts: (schema: string, serverVersionNum: number = 160_000): string => { + const procedureCountExpr = + serverVersionNum >= PG_VERSION_11 + ? `(SELECT COUNT(*) FROM pg_proc p WHERE p.pronamespace = n.oid AND p.prokind = 'p') as procedure_count` + : `0::bigint as procedure_count`; + return `SELECT COUNT(*) FILTER (WHERE c.relkind = 'r') as table_count, COUNT(*) FILTER (WHERE c.relkind = 'v') as view_count, COUNT(*) FILTER (WHERE c.relkind = 'm') as matview_count, @@ -595,7 +606,7 @@ WHERE n.nspname = '${schema}'`, COUNT(*) FILTER (WHERE c.relkind = 'f') as foreign_table_count, COUNT(*) FILTER (WHERE c.relkind = 'p') as partitioned_table_count, (SELECT COUNT(*) FROM pg_proc p WHERE p.pronamespace = n.oid) as function_count, - (SELECT COUNT(*) FROM pg_proc p WHERE p.pronamespace = n.oid AND p.prokind = 'p') as procedure_count, + ${procedureCountExpr}, (SELECT COUNT(*) FROM pg_type t WHERE t.typnamespace = n.oid AND t.typtype = 'c') as type_count, (SELECT COUNT(*) FROM pg_trigger t JOIN pg_class tc ON t.tgrelid = tc.oid @@ -603,7 +614,8 @@ WHERE n.nspname = '${schema}'`, FROM pg_namespace n LEFT JOIN pg_class c ON c.relnamespace = n.oid WHERE n.nspname = '${schema}' -GROUP BY n.oid`, +GROUP BY n.oid`; + }, /** * Build schema size query diff --git a/src/commands/sql/pgCron.ts b/src/commands/sql/pgCron.ts index 4ecde10..5b32005 100644 --- a/src/commands/sql/pgCron.ts +++ b/src/commands/sql/pgCron.ts @@ -56,4 +56,22 @@ SELECT * FROM cron.job ORDER BY jobid DESC LIMIT 5;`, SELECT jobid, jobname, schedule, active, database, username FROM cron.job ORDER BY jobid;`, + + /** + * Documents that pg_dump scheduling belongs on the server OS or VS Code tasks — not inside cron.schedule SQL. + * Includes a harmless maintenance example for pg_cron. + */ + scheduleBackupShellExample: (): string => + `-- pg_cron runs SQL inside PostgreSQL, not your developer machine shell. +-- For pg_dump on a schedule use: OS cron/systemd on the DB host, or a VS Code task (type pgstudio-pgdump). +-- +-- Example pg_cron maintenance job: + +SELECT cron.schedule( + 'nightly_vacuum_example', + '0 3 * * *', + $$VACUUM ANALYZE public.my_table;$$ +); + +SELECT * FROM cron.job ORDER BY jobid DESC LIMIT 5;`, }; diff --git a/src/commands/tables/operations.ts b/src/commands/tables/operations.ts index fdd7a3b..f572d4c 100644 --- a/src/commands/tables/operations.ts +++ b/src/commands/tables/operations.ts @@ -10,6 +10,7 @@ import { QueryBuilder } from '../helper'; import { TableSQL } from '../sql'; +import { queryServerVersionNum } from '../../lib/postgresServerVersion'; export async function cmdTableOperations(item: DatabaseTreeItem, context: vscode.ExtensionContext) { await CommandBase.run(context, item, 'create table operations notebook', async (conn, client, metadata) => { @@ -170,9 +171,10 @@ export async function cmdTruncateTable(item: DatabaseTreeItem, context: vscode.E export async function cmdShowTableProperties(item: DatabaseTreeItem, context: vscode.ExtensionContext) { await CommandBase.run(context, item, 'view table properties', async (conn, client, metadata) => { + const serverVersionNum = await queryServerVersionNum(client); // Gather comprehensive table information const [tableInfo, columnInfo, constraintInfo, indexInfo, statsInfo, sizeInfo] = await Promise.all([ - client.query(QueryBuilder.tableInfo(item.schema!, item.label)), + client.query(QueryBuilder.tableInfo(item.schema!, item.label, serverVersionNum)), client.query(QueryBuilder.tableColumns(item.schema!, item.label)), client.query(QueryBuilder.tableConstraints(item.schema!, item.label)), client.query(QueryBuilder.tableIndexes(item.schema!, item.label)), diff --git a/src/dashboard/DashboardPanel.ts b/src/dashboard/DashboardPanel.ts index e1608bc..937f267 100644 --- a/src/dashboard/DashboardPanel.ts +++ b/src/dashboard/DashboardPanel.ts @@ -177,12 +177,7 @@ export class DashboardPanel { // 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); - } + const result = await this._aiService.callProvider(provider, question, config, systemPrompt); // Persist this turn so follow-up questions have context this._conversationMessages.push({ role: 'user', content: question }); diff --git a/src/extension.ts b/src/extension.ts index 69dd65d..b8c8958 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -134,7 +134,6 @@ export async function activate(context: vscode.ExtensionContext) { const telemetry = TelemetryService.getInstance(); telemetry.initialize(context); telemetry.trackEvent('extension_activated', { version: context.extension.packageJSON.version }); - telemetry.trackSessionStart(); SecretStorageService.getInstance(context); ConnectionManager.getInstance(); @@ -159,8 +158,21 @@ export async function activate(context: vscode.ExtensionContext) { }); if (hasChanges) { + // Before we write connections back to settings, migrate any inline + // passwords into Secret Storage so users don't lose credentials. + for (const conn of migratedConnections) { + if (conn.password) { + try { + await SecretStorageService.getInstance(context).setPassword(conn.id, conn.password); + delete conn.password; + } catch (err) { + console.error(`Failed to migrate inline password for connection ${conn.name || conn.id}:`, err); + } + } + } + await config.update('postgresExplorer.connections', migratedConnections, vscode.ConfigurationTarget.Global); - console.log('Migrated legacy connections to include IDs'); + console.log('Migrated legacy connections to include IDs and preserved inline passwords'); } const azureTimeoutMigrationKey = 'postgresExplorer.migrations.azureConnectionTimeouts.v0_8_9'; @@ -192,14 +204,13 @@ export async function activate(context: vscode.ExtensionContext) { 'postgresExplorer.favorites', ]); - const [providersModule, commandsModule, notebookKernelModule, whatsNewModule, statusBarModule, telemetryStatusBarModule] = + const [providersModule, commandsModule, notebookKernelModule, whatsNewModule, statusBarModule] = await Promise.all([ import('./activation/providers'), import('./activation/commands'), import('./providers/NotebookKernel'), import('./activation/WhatsNewManager'), import('./activation/statusBar'), - import('./activation/TelemetryStatusBar'), ]); const { databaseTreeProvider, treeView, chatViewProviderInstance: chatView, savedQueriesTreeProvider, notebooksTreeProvider, autoRefreshService } = providersModule.registerProviders(context, outputChannel); @@ -209,7 +220,19 @@ export async function activate(context: vscode.ExtensionContext) { // Store tree view instance for reveal functionality (databaseTreeProvider as any).setTreeView(treeView); - commandsModule.registerAllCommands(context, databaseTreeProvider, chatView, outputChannel, savedQueriesTreeProvider, notebooksTreeProvider); + const whatsNewManager = new whatsNewModule.WhatsNewManager(context, context.extensionUri); + commandsModule.registerAllCommands( + context, + databaseTreeProvider, + chatView, + outputChannel, + whatsNewManager, + savedQueriesTreeProvider, + notebooksTreeProvider + ); + + const { registerPgDumpTaskProvider } = await import('./features/backup/backupTaskProvider'); + registerPgDumpTaskProvider(context); const rendererMessaging = vscode.notebooks.createRendererMessaging('postgres-query-renderer'); @@ -247,8 +270,6 @@ export async function activate(context: vscode.ExtensionContext) { ensureNotebookKernels(); } - // What's New / Welcome Screen - const whatsNewManager = new whatsNewModule.WhatsNewManager(context, context.extensionUri); // SQL Formatter command + format-on-save listener context.subscriptions.push( vscode.commands.registerCommand('postgres-explorer.formatSql', async () => { @@ -262,11 +283,6 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(createFormatOnSaveListener()); }); - context.subscriptions.push( - vscode.commands.registerCommand('postgres-explorer.showWhatsNew', () => { - void whatsNewManager.checkAndShow(true); - }) - ); // Auto-open once on install/update; manager tracks the last shown version in global state. runDeferredStartupTask('showWhatsNew', async () => { await whatsNewManager.checkAndShow(false); @@ -276,8 +292,6 @@ export async function activate(context: vscode.ExtensionContext) { statusBar = new statusBarModule.NotebookStatusBar(); context.subscriptions.push(statusBar); - context.subscriptions.push(new telemetryStatusBarModule.TelemetryStatusBar()); - // Register Message Handlers const registry = MessageHandlerRegistry.getInstance(); let handlersInitialized = false; @@ -317,8 +331,7 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { outputChannel?.appendLine('Deactivating PgStudio extension - closing all connections'); const telemetry = TelemetryService.getInstance(); - telemetry.trackSessionEnd(); - telemetry.trackEvent('extension_deactivated', { sessionDurationBucket: 'captured' }); + telemetry.trackExtensionDeactivate(); try { // Close all database connections (pools and sessions) diff --git a/src/features/aiAssistant/settings/aiSettingsPanel.ts b/src/features/aiAssistant/settings/aiSettingsPanel.ts index d62cc23..56ceaf0 100644 --- a/src/features/aiAssistant/settings/aiSettingsPanel.ts +++ b/src/features/aiAssistant/settings/aiSettingsPanel.ts @@ -6,6 +6,7 @@ import { getChatViewProvider } from '../../../extension'; export interface AiSettings { provider: string; apiKey?: string; + cursorApiKey?: string; model?: string; endpoint?: string; githubAuth?: { @@ -20,6 +21,12 @@ const GITHUB_MODELS_SCOPES: string[] = []; const GITHUB_MODELS_API_VERSION = '2026-03-10'; const DEFAULT_GITHUB_MODEL = 'openai/gpt-4.1'; +/** Webview `getFormData()` sends Cursor keys as `apiKey`; accept both shapes. */ +function cursorApiKeyFromSettings(settings: { cursorApiKey?: string; apiKey?: string }): string { + const raw = settings.cursorApiKey ?? settings.apiKey ?? ''; + return typeof raw === 'string' ? raw.trim() : ''; +} + export class AiSettingsPanel { public static currentPanel: AiSettingsPanel | undefined; private readonly _panel: vscode.WebviewPanel; @@ -56,6 +63,13 @@ export class AiSettingsPanel { // Store API key in secret storage if (settings.provider === 'github') { await this._extensionContext.secrets.delete('postgresExplorer.aiApiKey'); + } else if (settings.provider === 'cursor') { + const ck = cursorApiKeyFromSettings(settings); + if (ck) { + await this._extensionContext.secrets.store('postgresExplorer.cursorApiKey', ck); + } else { + await this._extensionContext.secrets.delete('postgresExplorer.cursorApiKey'); + } } else if (settings.apiKey) { await this._extensionContext.secrets.store('postgresExplorer.aiApiKey', settings.apiKey); } else { @@ -123,6 +137,8 @@ export class AiSettingsPanel { } else if (settings.provider === 'github') { const session = await this._requestGitHubSession(true); testResult = await this._testGitHubModels(session.accessToken, settings.model || DEFAULT_GITHUB_MODEL); + } else if (settings.provider === 'cursor') { + testResult = await this._testCursor(cursorApiKeyFromSettings(settings), settings.model || 'auto'); } else if (settings.provider === 'openai') { // Test OpenAI connection if (!settings.apiKey) { @@ -178,7 +194,7 @@ export class AiSettingsPanel { case 'listModels': try { const settings = message.settings; - let models: string[] = []; + let models: Array = []; if (settings.provider === 'vscode-lm') { const availableModels = await vscode.lm.selectChatModels(); @@ -191,6 +207,8 @@ export class AiSettingsPanel { } else if (settings.provider === 'github') { const session = await this._requestGitHubSession(true); models = await this._listGitHubModels(session.accessToken); + } else if (settings.provider === 'cursor') { + models = await this._listCursorModels(cursorApiKeyFromSettings(settings)); } else if (settings.provider === 'openai') { if (!settings.apiKey) { throw new Error('API Key is required to list models'); @@ -525,6 +543,7 @@ export class AiSettingsPanel { private async _sendSettingsLoaded(): Promise { const config = vscode.workspace.getConfiguration('postgresExplorer'); const apiKey = await this._extensionContext.secrets.get('postgresExplorer.aiApiKey'); + const cursorApiKey = await this._extensionContext.secrets.get('postgresExplorer.cursorApiKey'); const githubSession = await this._getGitHubSession(); await this._panel.webview.postMessage({ @@ -532,6 +551,7 @@ export class AiSettingsPanel { settings: { provider: config.get('aiProvider', 'vscode-lm'), apiKey: apiKey || '', + cursorApiKey: cursorApiKey || '', model: config.get('aiModel', ''), endpoint: config.get('aiEndpoint', ''), githubAuth: { @@ -604,6 +624,49 @@ export class AiSettingsPanel { }); } + private async _loadCursorSdk(): Promise { + try { + return await import('@cursor/sdk'); + } catch { + throw new Error('Cursor SDK is not installed. Install @cursor/sdk to use the Cursor provider.'); + } + } + + private _resolveCursorApiKey(apiKey: string): string { + return apiKey || process.env.CURSOR_API_KEY || ''; + } + + private async _listCursorModels(apiKey: string): Promise> { + const { Cursor } = await this._loadCursorSdk(); + const resolvedApiKey = this._resolveCursorApiKey(apiKey); + const models = await Cursor.models.list({ apiKey: resolvedApiKey }); + + return (models || []) + .map((model: any) => ({ + id: model.id, + displayName: model.displayName || model.id, + })) + .filter((model: { id: string }) => !!model.id) + .sort((left: { id: string }, right: { id: string }) => left.id.localeCompare(right.id)); + } + + private async _testCursor(apiKey: string, model: string): Promise { + const { Cursor } = await this._loadCursorSdk(); + const resolvedApiKey = this._resolveCursorApiKey(apiKey); + if (!resolvedApiKey) { + throw new Error('Cursor API key is required. Set CURSOR_API_KEY or save it in AI Settings.'); + } + + const user = await Cursor.me({ apiKey: resolvedApiKey }); + const models = await Cursor.models.list({ apiKey: resolvedApiKey }); + const matching = (models || []).find((entry: any) => entry.id === model || entry.displayName === model); + if (model && model !== 'auto' && !matching) { + throw new Error(`Configured Cursor model "${model}" not found. Available models: ${(models || []).map((entry: any) => entry.id).join(', ')}`); + } + + return `Cursor connection successful${user.userEmail ? ` for ${user.userEmail}` : ''}${model && model !== 'auto' ? `! Model: ${model}` : '!'}`; + } + private async _testOpenAI(apiKey: string, model: string): Promise { return new Promise((resolve, reject) => { const data = JSON.stringify({ diff --git a/src/features/backup/BackupRestoreHtml.ts b/src/features/backup/BackupRestoreHtml.ts new file mode 100644 index 0000000..aa47496 --- /dev/null +++ b/src/features/backup/BackupRestoreHtml.ts @@ -0,0 +1,64 @@ +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import { MODERN_WEBVIEW_BASE_CSS } from '../../common/htmlStyles'; + +/** + * Loads `templates/backup-restore/` (index.html, styles.css, scripts.js), injects shared base CSS, + * CSP, and script nonce — same pattern as {@link dashboard/DashboardHtml.ts}. + */ +export async function getBackupRestoreHtml( + webview: vscode.Webview, + extensionUri: vscode.Uri +): Promise { + const nonce = crypto.randomBytes(16).toString('hex'); + const csp = [ + `default-src 'none'`, + `style-src ${webview.cspSource} 'unsafe-inline'`, + `script-src 'nonce-${nonce}'` + ].join('; '); + + try { + const dir = vscode.Uri.joinPath(extensionUri, 'templates', 'backup-restore'); + const [htmlBuf, cssBuf, jsBuf] = await Promise.all([ + vscode.workspace.fs.readFile(vscode.Uri.joinPath(dir, 'index.html')), + vscode.workspace.fs.readFile(vscode.Uri.joinPath(dir, 'styles.css')), + vscode.workspace.fs.readFile(vscode.Uri.joinPath(dir, 'scripts.js')) + ]); + + let html = new TextDecoder().decode(htmlBuf); + const backupCss = new TextDecoder().decode(cssBuf); + const js = new TextDecoder().decode(jsBuf); + const inlineStyles = `${MODERN_WEBVIEW_BASE_CSS}\n${backupCss}`; + + html = html.replace(/\{\{CSP\}\}/g, csp); + html = html.replace(/\{\{INLINE_STYLES\}\}/g, inlineStyles); + html = html.replace(/\{\{NONCE\}\}/g, nonce); + html = html.replace(/\{\{INLINE_SCRIPTS\}\}/g, js); + return html; + } catch (e) { + console.error('Failed to load backup-restore templates:', e); + return getBackupRestoreErrorHtml(e instanceof Error ? e.message : String(e)); + } +} + +function getBackupRestoreErrorHtml(message: string): string { + return ` + + + + + + +

Backup & Restore failed to load webview templates.

+
${escapeHtml(message)}
+ +`; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/src/features/backup/BackupRestorePanel.ts b/src/features/backup/BackupRestorePanel.ts new file mode 100644 index 0000000..6763268 --- /dev/null +++ b/src/features/backup/BackupRestorePanel.ts @@ -0,0 +1,735 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import { getChatViewProvider } from '../../extension'; +import type { ConnectionConfig } from '../../common/types'; +import { ConnectionManager } from '../../services/ConnectionManager'; +import { createMetadata, getConnectionWithPassword } from '../../commands/connection'; +import { queryServerVersionNum } from '../../lib/postgresServerVersion'; +import { buildPgDumpArgv } from './buildPgDumpArgs'; +import { buildPgDumpallArgv } from './buildPgDumpallArgs'; +import { buildPgRestoreArgv, buildPgRestoreListArgv } from './buildPgRestoreArgs'; +import { runPgTool } from './PgToolRunner'; +import { + prependConnectionArgs, + resolveConnectionForTools +} from './resolveConnectionForTools'; +import { + getPgDumpVersion, + getPgRestoreVersion, + isMajorMismatch, + serverMajorFromVersionNum +} from './toolVersion'; +import { parseExtraCliArgs } from './parseExtraCliArgs'; +import { parseRestoreListOutput } from './restoreListParser'; +import { openNotebookWithBackupLog, tryAppendBackupLogToActiveNotebook } from './backupNotebookLog'; +import type { PgDumpFormatFlag } from './types'; +import { getBackupRestoreHtml } from './BackupRestoreHtml'; + +export interface BackupPanelLaunchOptions { + initialTab: 'dump' | 'restore' | 'dumpall'; + connectionId: string; + databaseName: string; + databaseLabel: string; +} + +interface TableChoiceRow { + qualified: string; + schema: string; +} + +interface InitPayload { + initialTab: string; + connectionId: string; + databaseName: string; + databaseLabel: string; + databases: string[]; + /** Schema names for filter dropdown */ + schemas: string[]; + /** Tables/views for multi-select (-t) */ + tableChoices: TableChoiceRow[]; + serverVersionNum: number; + serverMajor: number; + pgDumpMajor: number; + pgRestoreMajor: number; + versionMismatchDump: boolean; + versionMismatchRestore: boolean; + sshEnabled: boolean; +} + +export class BackupRestorePanel { + public static current: BackupRestorePanel | undefined; + + private readonly _panel: vscode.WebviewPanel; + private readonly _context: vscode.ExtensionContext; + private _options: BackupPanelLaunchOptions; + private _connectionRow: ConnectionConfig | undefined; + private _password: string | undefined; + private _disposables: vscode.Disposable[] = []; + private _cancelSource: vscode.CancellationTokenSource | undefined; + private _output: vscode.OutputChannel; + /** Last init payload (versions, SSH) for assistant context */ + private _lastInit: InitPayload | undefined; + + public static async show( + context: vscode.ExtensionContext, + options: BackupPanelLaunchOptions + ): Promise { + const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One; + + if (BackupRestorePanel.current) { + await BackupRestorePanel.current._refreshAndReveal(options); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'pgstudioBackupRestore', + 'PostgreSQL · Backup & Restore', + column, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'resources')] + } + ); + + BackupRestorePanel.current = new BackupRestorePanel(panel, context, options); + context.subscriptions.push(panel); + } + + private constructor( + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext, + options: BackupPanelLaunchOptions + ) { + this._panel = panel; + this._context = context; + this._options = options; + this._output = vscode.window.createOutputChannel('PostgreSQL Backup'); + + this._disposables.push(panel.onDidDispose(() => this.dispose())); + this._disposables.push( + panel.webview.onDidReceiveMessage(msg => this._onMessage(msg)) + ); + + void this._loadAndSendInit(); + } + + private dispose(): void { + BackupRestorePanel.current = undefined; + this._cancelSource?.dispose(); + for (const d of this._disposables) { + d.dispose(); + } + this._disposables = []; + this._output.dispose(); + } + + private async _refreshAndReveal(options: BackupPanelLaunchOptions): Promise { + this._options = options; + this._panel.title = `Backup & Restore · ${options.databaseLabel}`; + await this._loadAndSendInit(); + this._panel.reveal(vscode.window.activeTextEditor?.viewColumn); + } + + private async _loadAndSendInit(): Promise { + try { + const conn = await getConnectionWithPassword(this._options.connectionId, this._options.databaseName); + this._password = conn.password; + const connections = + vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + this._connectionRow = connections.find(c => c.id === conn.id); + + const client = await ConnectionManager.getInstance().getPooledClient({ + id: conn.id, + host: conn.host, + port: conn.port, + username: conn.username, + database: this._options.databaseName, + name: conn.name + }); + + let serverVersionNum = 0; + let databases: string[] = []; + let schemas: string[] = []; + let tableChoices: TableChoiceRow[] = []; + try { + serverVersionNum = await queryServerVersionNum(client); + const dbs = await client.query<{ datname: string }>(` + SELECT datname FROM pg_database + WHERE datallowconn = true AND datistemplate = false + ORDER BY datname + `); + databases = dbs.rows.map(r => r.datname); + + const schResult = await client.query<{ schema_name: string }>(` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema') + ORDER BY schema_name + `); + schemas = schResult.rows.map(r => r.schema_name); + + const tabResult = await client.query<{ qualified: string; schema: string }>(` + SELECT + t.table_schema AS schema, + quote_ident(t.table_schema) || '.' || quote_ident(t.table_name) AS qualified + FROM information_schema.tables t + WHERE t.table_type IN ('BASE TABLE', 'PARTITIONED TABLE', 'VIEW', 'FOREIGN TABLE') + AND t.table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY t.table_schema, t.table_name + `); + tableChoices = tabResult.rows.map(r => ({ qualified: r.qualified, schema: r.schema })); + } finally { + client.release(); + } + + const serverMajor = serverMajorFromVersionNum(serverVersionNum); + let pgDumpMajor = 0; + let pgRestoreMajor = 0; + try { + pgDumpMajor = (await getPgDumpVersion()).major; + } catch { + /* ignore */ + } + try { + pgRestoreMajor = (await getPgRestoreVersion()).major; + } catch { + /* ignore */ + } + + const cfg = this._connectionRow; + const payload: InitPayload = { + initialTab: this._options.initialTab, + connectionId: this._options.connectionId, + databaseName: this._options.databaseName, + databaseLabel: this._options.databaseLabel, + databases, + schemas, + tableChoices, + serverVersionNum, + serverMajor, + pgDumpMajor, + pgRestoreMajor, + versionMismatchDump: isMajorMismatch(pgDumpMajor, serverMajor), + versionMismatchRestore: isMajorMismatch(pgRestoreMajor, serverMajor), + sshEnabled: !!cfg?.ssh?.enabled + }; + + this._lastInit = payload; + this._panel.webview.html = await getBackupRestoreHtml(this._panel.webview, this._context.extensionUri); + this._panel.title = `Backup & Restore · ${this._options.databaseLabel}`; + void this._panel.webview.postMessage({ type: 'init', payload }); + } catch (e) { + vscode.window.showErrorMessage(`Backup panel: ${e}`); + } + } + + private async _onMessage(msg: { type?: string; payload?: unknown }): Promise { + switch (msg.type) { + case 'pickSaveFile': + await this._pickSaveFile(msg.payload as { defaultName?: string }); + break; + case 'pickOpenFile': + await this._pickOpenFile(); + break; + case 'pickDirectory': + await this._pickDirectory(); + break; + case 'runDump': + await this._runDump(msg.payload as Record); + break; + case 'runRestore': + await this._runRestore(msg.payload as Record); + break; + case 'runDumpall': + await this._runDumpall(msg.payload as Record); + break; + case 'listArchive': + await this._listArchive(msg.payload as Record); + break; + case 'backupToolsAssist': + await this._backupToolsAssist(msg.payload as Record); + break; + case 'cancel': + this._cancelSource?.cancel(); + break; + case 'appendNotebook': + await this._appendNotebook(msg.payload as { title: string; log: string }); + break; + case 'generateTask': + await this._generateTaskSnippet(msg.payload as Record); + break; + default: + break; + } + } + + private async _pickSaveFile(payload?: { defaultName?: string }): Promise { + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(payload?.defaultName ?? 'backup.dump'), + filters: { + 'PostgreSQL archive': ['dump', 'backup', 'sql', 'tar'], + 'All files': ['*'] + }, + title: 'Backup output file' + }); + if (uri) { + void this._panel.webview.postMessage({ type: 'pickedPath', kind: 'save', path: uri.fsPath }); + } + } + + private async _pickOpenFile(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'Dump / archive': ['dump', 'backup', 'sql', 'tar'], + 'All files': ['*'] + }, + title: 'Select backup file' + }); + if (uris?.[0]) { + void this._panel.webview.postMessage({ type: 'pickedPath', kind: 'open', path: uris[0].fsPath }); + } + } + + private async _pickDirectory(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: 'Select output directory' + }); + if (uris?.[0]) { + void this._panel.webview.postMessage({ type: 'pickedPath', kind: 'dir', path: uris[0].fsPath }); + } + } + + /** + * Optional webview `extraCliArgs` string → argv tokens. Returns null on parse error (notification shown). + */ + private _extraArgvFromPayload(payload: Record): string[] | null { + const raw = payload.extraCliArgs; + if (raw === undefined || raw === null) { + return []; + } + const s = String(raw).trim(); + if (!s) { + return []; + } + try { + return parseExtraCliArgs(s); + } catch (e) { + void vscode.window.showErrorMessage(`Invalid extra CLI args: ${e}`); + return null; + } + } + + private async _runDump(payload: Record): Promise { + const cfg = this._connectionRow; + if (!cfg) { + return; + } + + const extraArgv = this._extraArgvFromPayload(payload); + if (extraArgv === null) { + return; + } + + this._cancelSource?.dispose(); + this._cancelSource = new vscode.CancellationTokenSource(); + const token = this._cancelSource.token; + + let log = ''; + const append = (s: string) => { + log += s; + void this._panel.webview.postMessage({ type: 'logChunk', chunk: s }); + }; + + let resolvedDispose: (() => void) | undefined; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'pg_dump', + cancellable: true + }, + async (progress, pToken) => { + pToken.onCancellationRequested(() => this._cancelSource?.cancel()); + + try { + const resolved = await resolveConnectionForTools(cfg, this._password); + resolvedDispose = resolved.dispose; + + const formatMap: Record = { + custom: 'c', + plain: 'p', + directory: 'd', + tar: 't' + }; + const fmt = formatMap[String(payload.format)] ?? 'c'; + + const tableList = Array.isArray(payload.tableQualifiedList) + ? (payload.tableQualifiedList as unknown[]).map(x => String(x)).filter(Boolean) + : []; + const schemaList = Array.isArray(payload.schemaNameList) + ? (payload.schemaNameList as unknown[]).map(x => String(x)).filter(Boolean) + : []; + + const argv = buildPgDumpArgv({ + format: fmt, + verbose: !!payload.verbose, + schemaOnly: !!payload.schemaOnly, + dataOnly: !!payload.dataOnly, + blobs: !!payload.blobs, + parallelJobs: Number(payload.parallelJobs) || 1, + compression: + payload.compression === null || payload.compression === undefined + ? null + : Number(payload.compression), + outputPath: String(payload.outputPath ?? ''), + database: String(payload.database ?? this._options.databaseName), + tableQualifiedList: tableList.length > 0 ? tableList : undefined, + schemaNameList: schemaList.length > 0 ? schemaList : undefined, + extraArgv: extraArgv.length > 0 ? extraArgv : undefined + }); + + const fullArgv = prependConnectionArgs(argv, resolved); + append(`$ ${this._safeArgvDisplay(fullArgv)}\n\n`); + + const result = await runPgTool({ + argv: fullArgv, + env: resolved.env, + token, + onStdout: chunk => append(chunk), + onStderr: chunk => append(chunk) + }); + + append(`\n[exit ${result.exitCode}]\n`); + progress.report({ increment: 100 }); + + if (!token.isCancellationRequested && result.exitCode !== 0) { + vscode.window.showErrorMessage(`pg_dump exited with code ${result.exitCode}`); + } + } catch (e) { + const err = `${e}`; + append(`\n${err}\n`); + vscode.window.showErrorMessage(err); + } finally { + resolvedDispose?.(); + } + } + ); + + void this._panel.webview.postMessage({ type: 'runDone', log }); + } + + private async _runRestore(payload: Record): Promise { + const cfg = this._connectionRow; + if (!cfg) { + return; + } + + const extraArgv = this._extraArgvFromPayload(payload); + if (extraArgv === null) { + return; + } + + const inputPath = String(payload.inputPath ?? ''); + if (/\.sql$/i.test(inputPath)) { + vscode.window.showWarningMessage( + 'Plain SQL dumps are restored with psql, not pg_restore. Run: psql -f ... against the target database.' + ); + return; + } + + this._cancelSource?.dispose(); + this._cancelSource = new vscode.CancellationTokenSource(); + const token = this._cancelSource.token; + + let log = ''; + const append = (s: string) => { + log += s; + void this._panel.webview.postMessage({ type: 'logChunk', chunk: s }); + }; + + let resolvedDispose: (() => void) | undefined; + const selectedLines = Array.isArray(payload.selectedLines) + ? (payload.selectedLines as string[]) + : undefined; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'pg_restore', + cancellable: true + }, + async (progress, pToken) => { + pToken.onCancellationRequested(() => this._cancelSource?.cancel()); + + let tempFiles: string[] = []; + + try { + const resolved = await resolveConnectionForTools(cfg, this._password); + resolvedDispose = resolved.dispose; + + const listResult = buildPgRestoreArgv({ + verbose: !!payload.verbose, + jobs: Number(payload.jobs) || 1, + targetDatabase: String(payload.targetDatabase ?? ''), + inputPath, + selectedListLines: selectedLines && selectedLines.length > 0 ? selectedLines : undefined, + extraArgv: extraArgv.length > 0 ? extraArgv : undefined + }); + tempFiles = listResult.tempFiles; + + const fullArgv = prependConnectionArgs(listResult.argv, resolved); + append(`$ ${this._safeArgvDisplay(fullArgv)}\n\n`); + + const result = await runPgTool({ + argv: fullArgv, + env: resolved.env, + token, + onStdout: chunk => append(chunk), + onStderr: chunk => append(chunk) + }); + + append(`\n[exit ${result.exitCode}]\n`); + progress.report({ increment: 100 }); + + if (!token.isCancellationRequested && result.exitCode !== 0) { + vscode.window.showErrorMessage(`pg_restore exited with code ${result.exitCode}`); + } + } catch (e) { + const err = `${e}`; + append(`\n${err}\n`); + vscode.window.showErrorMessage(err); + } finally { + for (const f of tempFiles) { + try { + fs.unlinkSync(f); + } catch { + /* ignore */ + } + } + resolvedDispose?.(); + } + } + ); + + void this._panel.webview.postMessage({ type: 'runDone', log }); + } + + private async _runDumpall(payload: Record): Promise { + const cfg = this._connectionRow; + if (!cfg) { + return; + } + + const extraArgv = this._extraArgvFromPayload(payload); + if (extraArgv === null) { + return; + } + + this._cancelSource?.dispose(); + this._cancelSource = new vscode.CancellationTokenSource(); + const token = this._cancelSource.token; + + let log = ''; + const append = (s: string) => { + log += s; + void this._panel.webview.postMessage({ type: 'logChunk', chunk: s }); + }; + + let resolvedDispose: (() => void) | undefined; + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'pg_dumpall', + cancellable: true + }, + async (progress, pToken) => { + pToken.onCancellationRequested(() => this._cancelSource?.cancel()); + + try { + const resolved = await resolveConnectionForTools(cfg, this._password); + resolvedDispose = resolved.dispose; + + const argv = buildPgDumpallArgv({ + verbose: !!payload.verbose, + globalsOnly: !!payload.globalsOnly, + rolesOnly: !!payload.rolesOnly, + outputPath: String(payload.outputPath ?? ''), + extraArgv: extraArgv.length > 0 ? extraArgv : undefined + }); + + const fullArgv = prependConnectionArgs(argv, resolved); + append(`$ ${this._safeArgvDisplay(fullArgv)}\n\n`); + + const result = await runPgTool({ + argv: fullArgv, + env: resolved.env, + token, + onStdout: chunk => append(chunk), + onStderr: chunk => append(chunk) + }); + + append(`\n[exit ${result.exitCode}]\n`); + progress.report({ increment: 100 }); + + if (!token.isCancellationRequested && result.exitCode !== 0) { + vscode.window.showErrorMessage(`pg_dumpall exited with code ${result.exitCode}`); + } + } catch (e) { + append(`\n${e}\n`); + vscode.window.showErrorMessage(`${e}`); + } finally { + resolvedDispose?.(); + } + } + ); + + void this._panel.webview.postMessage({ type: 'runDone', log }); + } + + private async _listArchive(payload: Record): Promise { + const p = String(payload?.path ?? ''); + if (!p) { + return; + } + + const extraArgv = this._extraArgvFromPayload(payload); + if (extraArgv === null) { + void this._panel.webview.postMessage({ + type: 'listResult', + error: 'Invalid extra CLI args', + raw: '' + }); + return; + } + + let log = ''; + try { + const argv = buildPgRestoreListArgv(p, extraArgv.length > 0 ? extraArgv : undefined); + const result = await runPgTool({ + argv, + env: { ...process.env }, + onStdout: chunk => { + log += chunk; + }, + onStderr: chunk => { + log += chunk; + } + }); + + if (result.exitCode !== 0) { + void this._panel.webview.postMessage({ + type: 'listResult', + error: `pg_restore --list exited ${result.exitCode}`, + raw: log + }); + return; + } + + const rows = parseRestoreListOutput(log); + void this._panel.webview.postMessage({ + type: 'listResult', + rows, + raw: log + }); + } catch (e) { + void this._panel.webview.postMessage({ + type: 'listResult', + error: `${e}`, + raw: '' + }); + } + } + + private async _backupToolsAssist(payload: Record): Promise { + const chat = getChatViewProvider(); + if (!chat) { + void vscode.window.showWarningMessage('SQL Assistant is not available yet. Open the SQL Assistant view once, then retry.'); + return; + } + + const scenarioRaw = String(payload.scenario ?? 'tool_log'); + const init = this._lastInit; + let scenario: 'version_banner' | 'tool_log' = 'tool_log'; + let toolLog: string | undefined = + typeof payload.logText === 'string' && payload.logText.trim() ? String(payload.logText) : undefined; + + if (scenarioRaw === 'version_banner') { + scenario = 'version_banner'; + toolLog = undefined; + } else if (scenarioRaw === 'ssh_banner') { + scenario = 'tool_log'; + toolLog = + '[Context: user opened assistant from the Backup & Restore **SSH** info banner.]\n' + + 'PgStudio shows that SSH is enabled and that CLI tools (pg_dump / pg_restore) use the same tunnel as the SQL driver (local port forward).\n\n' + + 'Please explain what that means for running backups/restores, common pitfalls (host/port, identity file, timeouts), and how to verify the tunnel matches the connection.\n'; + } + + try { + await chat.openBackupToolsAssistant({ + scenario, + connectionId: this._options.connectionId, + databaseLabel: this._options.databaseLabel, + databaseName: this._options.databaseName, + connection: this._connectionRow, + toolLog: scenario === 'tool_log' ? toolLog : undefined, + serverMajor: init?.serverMajor ?? 0, + pgDumpMajor: init?.pgDumpMajor ?? 0, + pgRestoreMajor: init?.pgRestoreMajor ?? 0 + }); + } catch (e) { + void vscode.window.showErrorMessage(`Backup assistant: ${e}`); + } + } + + private async _appendNotebook(payload: { title: string; log: string }): Promise { + const conn = await getConnectionWithPassword(this._options.connectionId, this._options.databaseName); + const meta = createMetadata(conn, this._options.databaseName); + + const appended = await tryAppendBackupLogToActiveNotebook( + payload.title, + payload.log, + this._options.connectionId, + this._options.databaseName + ); + if (!appended) { + await openNotebookWithBackupLog(payload.title, payload.log, meta); + } + } + + private async _generateTaskSnippet(payload: Record): Promise { + const task = { + label: String(payload.label ?? 'PostgreSQL backup'), + type: 'pgstudio-pgdump', + connectionId: this._options.connectionId, + databaseName: String(payload.database ?? this._options.databaseName), + dumpFormat: String(payload.format ?? 'custom'), + outputPath: String(payload.outputPath ?? '${workspaceFolder}/backup.dump') + }; + + const json = JSON.stringify(task, null, 2); + const doc = `{ + "version": "2.0.0", + "tasks": [ + ${json.split('\n').join('\n ')} + ] +}`; + await vscode.env.clipboard.writeText(doc); + vscode.window.showInformationMessage( + 'Sample tasks.json snippet copied to clipboard. Paste into .vscode/tasks.json and adjust paths.' + ); + } + + /** Hide password if ever present in argv */ + private _safeArgvDisplay(argv: string[]): string { + return argv.join(' '); + } + +} diff --git a/src/features/backup/PgToolRunner.ts b/src/features/backup/PgToolRunner.ts new file mode 100644 index 0000000..f978deb --- /dev/null +++ b/src/features/backup/PgToolRunner.ts @@ -0,0 +1,76 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; +import * as vscode from 'vscode'; + +export interface RunPgToolOptions { + argv: string[]; + env: NodeJS.ProcessEnv; + cwd?: string; + token?: vscode.CancellationToken; + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; +} + +export interface RunPgToolResult { + exitCode: number | null; + signal: NodeJS.Signals | null; +} + +/** + * Runs a PostgreSQL CLI tool with streamed stdout/stderr. Uses argv array (no shell). + */ +export async function runPgTool(options: RunPgToolOptions): Promise { + const [command, ...args] = options.argv; + if (!command) { + throw new Error('Missing command'); + } + + return await new Promise((resolve, reject) => { + const proc: ChildProcessWithoutNullStreams = spawn(command, args, { + env: options.env, + cwd: options.cwd, + shell: false, + windowsHide: true + }); + + let cancelled = false; + + const killProc = () => { + if (!proc.killed && proc.pid) { + try { + proc.kill('SIGTERM'); + } catch { + /* ignore */ + } + } + }; + + const sub = options.token?.onCancellationRequested(() => { + cancelled = true; + killProc(); + }); + + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + + proc.stdout.on('data', (data: string) => { + options.onStdout?.(data); + }); + proc.stderr.on('data', (data: string) => { + options.onStderr?.(data); + }); + + proc.on('error', err => { + sub?.dispose(); + reject(err); + }); + + proc.on('close', (code, signal) => { + sub?.dispose(); + if (cancelled) { + resolve({ exitCode: code, signal }); + return; + } + resolve({ exitCode: code, signal }); + }); + }); +} diff --git a/src/features/backup/backupNotebookLog.ts b/src/features/backup/backupNotebookLog.ts new file mode 100644 index 0000000..597fa9a --- /dev/null +++ b/src/features/backup/backupNotebookLog.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; +import type { PostgresMetadata } from '../../common/types'; +import { createAndShowNotebook } from '../../commands/connection'; + +function escapeFence(text: string): string { + return text.replace(/\r\n/g, '\n').replace(/```/g, '\\`\\`\\`'); +} + +/** Opens a new notebook with a markdown cell containing the backup/restore log. */ +export async function openNotebookWithBackupLog(title: string, logBody: string, metadata: PostgresMetadata): Promise { + const md = + `## ${title}\n\n` + + `\`\`\`text\n${escapeFence(logBody)}\n\`\`\`\n`; + const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, md, 'markdown'); + await createAndShowNotebook([cell], metadata); +} + +/** + * If the active editor is a postgres notebook for the same connection/database, append a markdown cell at the end. + * Returns true if appended. + */ +export async function tryAppendBackupLogToActiveNotebook( + title: string, + logBody: string, + connectionId: string, + databaseName: string +): Promise { + const editor = vscode.window.activeNotebookEditor; + if (!editor) { + return false; + } + const nb = editor.notebook; + if (nb.notebookType !== 'postgres-notebook' && nb.notebookType !== 'postgres-query') { + return false; + } + const meta = nb.metadata as { connectionId?: string; databaseName?: string } | undefined; + if (meta?.connectionId !== connectionId || meta?.databaseName !== databaseName) { + return false; + } + + const md = + `## ${title}\n\n` + + `\`\`\`text\n${escapeFence(logBody)}\n\`\`\`\n`; + const cell = new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, md, 'markdown'); + const edit = vscode.NotebookEdit.insertCells(nb.cellCount, [cell]); + const ws = new vscode.WorkspaceEdit(); + ws.set(nb.uri, [edit]); + await vscode.workspace.applyEdit(ws); + return true; +} diff --git a/src/features/backup/backupTaskProvider.ts b/src/features/backup/backupTaskProvider.ts new file mode 100644 index 0000000..943810e --- /dev/null +++ b/src/features/backup/backupTaskProvider.ts @@ -0,0 +1,110 @@ +import * as vscode from 'vscode'; +import type { ConnectionConfig } from '../../common/types'; +import { getConnectionWithPassword } from '../../commands/connection'; +import { buildPgDumpArgv } from './buildPgDumpArgs'; +import { runPgTool } from './PgToolRunner'; +import { prependConnectionArgs, resolveConnectionForTools } from './resolveConnectionForTools'; +import type { PgDumpFormatFlag } from './types'; + +export interface PgStudioPgDumpTaskDefinition extends vscode.TaskDefinition { + type: 'pgstudio-pgdump'; + connectionId: string; + databaseName: string; + outputPath: string; + dumpFormat?: 'custom' | 'plain' | 'directory' | 'tar'; + verbose?: boolean; + schemaOnly?: boolean; + dataOnly?: boolean; +} + +export function registerPgDumpTaskProvider(context: vscode.ExtensionContext): void { + const provider: vscode.TaskProvider = { + provideTasks: () => Promise.resolve([]), + + resolveTask(task: vscode.Task): vscode.Task | undefined { + if (task.definition.type !== 'pgstudio-pgdump') { + return undefined; + } + const def = task.definition as PgStudioPgDumpTaskDefinition; + if (!def.connectionId || !def.databaseName || !def.outputPath) { + return undefined; + } + + const execution = new vscode.CustomExecution(async () => { + const writeEmitter = new vscode.EventEmitter(); + const closeEmitter = new vscode.EventEmitter(); + + const pty: vscode.Pseudoterminal = { + onDidWrite: writeEmitter.event, + onDidClose: closeEmitter.event, + open: async () => { + let exitCode = 1; + try { + const connRow = await getConnectionWithPassword(def.connectionId, def.databaseName); + const connections = + vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const cfg = connections.find(c => c.id === connRow.id); + if (!cfg) { + writeEmitter.fire('Connection id not found in settings.\r\n'); + closeEmitter.fire(1); + return; + } + + const resolved = await resolveConnectionForTools(cfg, connRow.password); + try { + const fmtMap: Record = { + custom: 'c', + plain: 'p', + directory: 'd', + tar: 't' + }; + const fmt = fmtMap[def.dumpFormat ?? 'custom'] ?? 'c'; + const argv = buildPgDumpArgv({ + format: fmt, + verbose: def.verbose ?? true, + schemaOnly: def.schemaOnly ?? false, + dataOnly: def.dataOnly ?? false, + blobs: true, + parallelJobs: 1, + compression: fmt === 'c' ? 9 : null, + outputPath: def.outputPath, + database: def.databaseName + }); + const fullArgv = prependConnectionArgs(argv, resolved); + writeEmitter.fire(fullArgv.map(a => (a.includes(' ') ? `"${a}"` : a)).join(' ') + '\r\n\r\n'); + + const result = await runPgTool({ + argv: fullArgv, + env: resolved.env, + onStdout: chunk => writeEmitter.fire(chunk.replace(/\n/g, '\r\n')), + onStderr: chunk => writeEmitter.fire(chunk.replace(/\n/g, '\r\n')) + }); + exitCode = result.exitCode === 0 ? 0 : 1; + } finally { + resolved.dispose(); + } + } catch (e) { + writeEmitter.fire(`${e}\r\n`); + exitCode = 1; + } + closeEmitter.fire(exitCode); + }, + close: () => {} + }; + + return pty; + }); + + return new vscode.Task( + task.definition as vscode.TaskDefinition, + task.scope ?? vscode.TaskScope.Workspace, + task.name || `pg_dump ${def.databaseName}`, + task.source || 'pgstudio', + execution, + task.problemMatchers ?? [] + ); + } + }; + + context.subscriptions.push(vscode.tasks.registerTaskProvider('pgstudio-pgdump', provider)); +} diff --git a/src/features/backup/buildPgDumpArgs.ts b/src/features/backup/buildPgDumpArgs.ts new file mode 100644 index 0000000..617d38e --- /dev/null +++ b/src/features/backup/buildPgDumpArgs.ts @@ -0,0 +1,81 @@ +import type { PgDumpFormState } from './types'; +import { assertSafeCliIdentifier, assertSafePgDumpTableArg, assertSafeTableQualified } from './identifierSafe'; + +/** Builds pg_dump argv (program name first). Connection host/port/user passed separately by runner. */ +export function buildPgDumpArgv(opts: PgDumpFormState): string[] { + assertSafeCliIdentifier(opts.database, 'database'); + if (opts.outputPath) { + assertSafeCliIdentifier(opts.outputPath, 'outputPath'); + } + const hasSchemaList = opts.schemaNameList && opts.schemaNameList.length > 0; + if (hasSchemaList) { + for (const s of opts.schemaNameList!) { + assertSafeCliIdentifier(s, 'schema'); + } + } else if (opts.schemaName) { + assertSafeCliIdentifier(opts.schemaName, 'schema'); + } + + const hasTableList = opts.tableQualifiedList && opts.tableQualifiedList.length > 0; + if (opts.tableQualified && !hasTableList) { + assertSafeTableQualified(opts.tableQualified); + } + if (hasTableList) { + for (const t of opts.tableQualifiedList!) { + assertSafePgDumpTableArg(t); + } + } + + const argv: string[] = ['pg_dump']; + + argv.push('-F', opts.format); + if (opts.verbose) { + argv.push('-v'); + } + if (opts.schemaOnly) { + argv.push('-s'); + } + if (opts.dataOnly) { + argv.push('-a'); + } + if (opts.blobs) { + argv.push('-b'); + } + + if (opts.format === 'd' && opts.parallelJobs > 1) { + argv.push('-j', String(Math.floor(opts.parallelJobs))); + } + + if (opts.format === 'c' && opts.compression !== null && opts.compression !== undefined) { + const z = Math.max(0, Math.min(9, Math.floor(opts.compression))); + argv.push('-Z', String(z)); + } + + argv.push('-f', opts.outputPath); + + if (hasTableList) { + for (const t of opts.tableQualifiedList!) { + argv.push('-t', t); + } + } else if (opts.tableQualified) { + argv.push('-t', opts.tableQualified); + } + if (!hasTableList && !opts.tableQualified) { + if (hasSchemaList) { + for (const s of opts.schemaNameList!) { + argv.push('-n', s); + } + } else if (opts.schemaName) { + argv.push('-n', opts.schemaName); + } + } + + if (opts.extraArgv?.length) { + for (const t of opts.extraArgv) { + argv.push(t); + } + } + + argv.push(opts.database); + return argv; +} diff --git a/src/features/backup/buildPgDumpallArgs.ts b/src/features/backup/buildPgDumpallArgs.ts new file mode 100644 index 0000000..4adf65a --- /dev/null +++ b/src/features/backup/buildPgDumpallArgs.ts @@ -0,0 +1,24 @@ +import type { PgDumpallFormState } from './types'; +import { assertSafeCliIdentifier } from './identifierSafe'; + +export function buildPgDumpallArgv(opts: PgDumpallFormState): string[] { + assertSafeCliIdentifier(opts.outputPath, 'output path'); + + const argv: string[] = ['pg_dumpall']; + if (opts.verbose) { + argv.push('-v'); + } + if (opts.globalsOnly) { + argv.push('-g'); + } + if (opts.rolesOnly) { + argv.push('-r'); + } + if (opts.extraArgv?.length) { + for (const t of opts.extraArgv) { + argv.push(t); + } + } + argv.push('-f', opts.outputPath); + return argv; +} diff --git a/src/features/backup/buildPgRestoreArgs.ts b/src/features/backup/buildPgRestoreArgs.ts new file mode 100644 index 0000000..7ee1494 --- /dev/null +++ b/src/features/backup/buildPgRestoreArgs.ts @@ -0,0 +1,51 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { PgRestoreFormState } from './types'; +import { assertSafeCliIdentifier } from './identifierSafe'; + +export interface PgRestoreArgvResult { + argv: string[]; + /** Temp file to unlink after spawn exits (list file) */ + tempFiles: string[]; +} + +export function buildPgRestoreArgv(opts: PgRestoreFormState): PgRestoreArgvResult { + assertSafeCliIdentifier(opts.targetDatabase, 'target database'); + assertSafeCliIdentifier(opts.inputPath, 'input path'); + + const argv: string[] = ['pg_restore']; + const tempFiles: string[] = []; + + if (opts.verbose) { + argv.push('-v'); + } + if (opts.jobs > 1) { + argv.push('-j', String(Math.floor(opts.jobs))); + } + + if (opts.extraArgv?.length) { + for (const t of opts.extraArgv) { + argv.push(t); + } + } + + argv.push('-d', opts.targetDatabase); + + if (opts.selectedListLines && opts.selectedListLines.length > 0) { + const body = opts.selectedListLines.join('\n') + '\n'; + const tmp = path.join(os.tmpdir(), `pgstudio-restore-list-${Date.now()}-${Math.random().toString(36).slice(2)}.lst`); + fs.writeFileSync(tmp, body, 'utf8'); + tempFiles.push(tmp); + argv.push('-L', tmp); + } + + argv.push(opts.inputPath); + return { argv, tempFiles }; +} + +/** argv for pg_restore --list (dry-run catalog); does not use DB connection */ +export function buildPgRestoreListArgv(archivePath: string, extraArgv?: string[]): string[] { + assertSafeCliIdentifier(archivePath, 'archive path'); + return ['pg_restore', ...(extraArgv ?? []), '--list', archivePath]; +} diff --git a/src/features/backup/identifierSafe.ts b/src/features/backup/identifierSafe.ts new file mode 100644 index 0000000..8455296 --- /dev/null +++ b/src/features/backup/identifierSafe.ts @@ -0,0 +1,47 @@ +/** + * Reject values that could break argv or inject flags when passed to pg_dump/pg_restore. + * Allows typical PostgreSQL identifiers and quoted-style names without semicolons or option markers. + */ +export function assertSafeCliIdentifier(value: string, fieldName: string): void { + if (!value || typeof value !== 'string') { + throw new Error(`${fieldName} is required`); + } + if (value.includes('\0') || value.includes('\n') || value.includes('\r')) { + throw new Error(`${fieldName} contains invalid characters`); + } + if (value.includes('--')) { + throw new Error(`${fieldName} must not contain "--"`); + } + if (value.trim().startsWith('-')) { + throw new Error(`${fieldName} must not start with "-"`); + } +} + +/** schema.table — both parts checked (unquoted identifiers only). */ +export function assertSafeTableQualified(qualified: string): void { + assertSafeCliIdentifier(qualified, 'table'); + const parts = qualified.split('.'); + if (parts.length !== 2) { + throw new Error('Table must be schema.table'); + } + assertSafeCliIdentifier(parts[0]!, 'schema'); + assertSafeCliIdentifier(parts[1]!, 'table name'); +} + +/** + * Validates a pg_dump `-t` argument produced by PostgreSQL quote_ident (may contain quoted identifiers). + */ +export function assertSafePgDumpTableArg(value: string): void { + if (!value || typeof value !== 'string') { + throw new Error('Table pattern is required'); + } + if (value.includes('\0') || value.includes('\n') || value.includes('\r')) { + throw new Error('Table pattern contains invalid characters'); + } + if (value.includes('--')) { + throw new Error('Table pattern must not contain "--"'); + } + if (value.trimStart().startsWith('-')) { + throw new Error('Table pattern must not start with "-"'); + } +} diff --git a/src/features/backup/parseExtraCliArgs.ts b/src/features/backup/parseExtraCliArgs.ts new file mode 100644 index 0000000..bc7bf42 --- /dev/null +++ b/src/features/backup/parseExtraCliArgs.ts @@ -0,0 +1,79 @@ +/** Upper bound so webview cannot pass huge argv. */ +const MAX_EXTRA_INPUT_CHARS = 8192; +const MAX_EXTRA_TOKENS = 128; +const MAX_TOKEN_CHARS = 4096; + +/** + * Split optional user-supplied flags for pg_* tools (not a shell — spawn argv only). + * Supports double quotes (with \" for literal quote) and single quotes (no escapes). + */ +export function parseExtraCliArgs(raw: string): string[] { + const s = raw.trim(); + if (!s) { + return []; + } + if (s.length > MAX_EXTRA_INPUT_CHARS) { + throw new Error(`Extra CLI args exceed ${MAX_EXTRA_INPUT_CHARS} characters`); + } + if (s.includes('\0')) { + throw new Error('Extra CLI args must not contain NUL'); + } + + const out: string[] = []; + let cur = ''; + let quote: '"' | "'" | null = null; + + const pushCur = (): void => { + if (!cur.length) { + return; + } + if (cur.length > MAX_TOKEN_CHARS) { + throw new Error(`Extra CLI token exceeds ${MAX_TOKEN_CHARS} characters`); + } + out.push(cur); + cur = ''; + if (out.length > MAX_EXTRA_TOKENS) { + throw new Error(`Extra CLI args: at most ${MAX_EXTRA_TOKENS} tokens`); + } + }; + + for (let i = 0; i < s.length; i++) { + const c = s[i]!; + if (quote === '"') { + if (c === '\\' && i + 1 < s.length && s[i + 1] === '"') { + cur += '"'; + i++; + continue; + } + if (c === '"') { + quote = null; + continue; + } + cur += c; + continue; + } + if (quote === "'") { + if (c === "'") { + quote = null; + continue; + } + cur += c; + continue; + } + if (c === '"' || c === "'") { + quote = c; + continue; + } + if (/\s/.test(c)) { + pushCur(); + continue; + } + cur += c; + } + + if (quote !== null) { + throw new Error('Extra CLI args: unclosed quote'); + } + pushCur(); + return out; +} diff --git a/src/features/backup/resolveConnectionForTools.ts b/src/features/backup/resolveConnectionForTools.ts new file mode 100644 index 0000000..9af0d2e --- /dev/null +++ b/src/features/backup/resolveConnectionForTools.ts @@ -0,0 +1,130 @@ +import { Client } from 'ssh2'; +import * as fs from 'fs'; +import * as net from 'net'; +import type { ConnectionConfig } from '../../common/types'; + +export interface ResolvedToolConnection { + host: string; + port: number; + env: NodeJS.ProcessEnv; + username: string; + dispose: () => void; +} + +/** Libpq-compatible env for pg_dump / pg_restore / pg_dumpall child processes. */ +export function buildLibpqEnv(config: ConnectionConfig, password?: string): NodeJS.ProcessEnv { + const env = { ...process.env } as NodeJS.ProcessEnv; + if (password) { + env.PGPASSWORD = password; + } + const mode = config.sslmode || 'prefer'; + env.PGSSLMODE = mode; + if (config.sslCertPath) { + env.PGSSLCERT = config.sslCertPath; + } + if (config.sslKeyPath) { + env.PGSSLKEY = config.sslKeyPath; + } + if (config.sslRootCertPath) { + env.PGSSLROOTCERT = config.sslRootCertPath; + } + return env; +} + +function connectSshClient(ssh: NonNullable): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + conn.once('ready', () => resolve(conn)); + conn.once('error', err => reject(err)); + const connectConfig: import('ssh2').ConnectConfig = { + host: ssh.host, + port: ssh.port, + username: ssh.username + }; + if (ssh.privateKeyPath) { + try { + connectConfig.privateKey = fs.readFileSync(ssh.privateKeyPath); + } catch (e) { + reject(new Error(`Failed to read SSH private key at ${ssh.privateKeyPath}: ${e}`)); + return; + } + } + conn.connect(connectConfig); + }); +} + +/** + * Resolves host/port for CLI tools. Non-SSH: config host/port. SSH: local TCP forward to DB via ssh2 forwardOut. + */ +export async function resolveConnectionForTools( + config: ConnectionConfig, + dbPassword: string | undefined +): Promise { + const env = buildLibpqEnv(config, dbPassword); + const username = config.username || process.env.PGUSER || process.env.USER || 'postgres'; + + if (!config.ssh?.enabled) { + return { + host: config.host, + port: config.port, + env, + username, + dispose: () => {} + }; + } + + const conn = await connectSshClient(config.ssh); + const dbHost = config.host; + const dbPort = config.port; + + const server = net.createServer(socket => { + conn.forwardOut( + socket.remoteAddress ?? '127.0.0.1', + socket.remotePort ?? 0, + dbHost, + dbPort, + (err, stream) => { + if (err || !stream) { + socket.destroy(); + return; + } + socket.pipe(stream as NodeJS.ReadWriteStream).pipe(socket); + stream.on('close', () => socket.destroy()); + socket.on('close', () => stream.destroy()); + } + ); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + + const addr = server.address(); + const localPort = typeof addr === 'object' && addr !== null ? addr.port : 0; + if (!localPort) { + server.close(); + conn.end(); + throw new Error('Failed to bind local forward port'); + } + + return { + host: '127.0.0.1', + port: localPort, + env, + username, + dispose: () => { + server.close(); + conn.end(); + } + }; +} + +/** Prepends -h -p -U after command name for pg_* tools. */ +export function prependConnectionArgs(argv: string[], resolved: ResolvedToolConnection): string[] { + const [cmd, ...rest] = argv; + if (!cmd) { + throw new Error('Invalid argv'); + } + return [cmd, '-h', resolved.host, '-p', String(resolved.port), '-U', resolved.username, ...rest]; +} diff --git a/src/features/backup/restoreListParser.ts b/src/features/backup/restoreListParser.ts new file mode 100644 index 0000000..8e55610 --- /dev/null +++ b/src/features/backup/restoreListParser.ts @@ -0,0 +1,39 @@ +/** + * Parses `pg_restore --list` output into selectable TOC rows. + * Lines look like: "123; 1259 16384 TABLE DATA public foo postgres" + */ + +export interface RestoreListRow { + /** Full original line */ + rawLine: string; + /** Leading numeric id before semicolon, if present */ + id: string | null; + /** Rough classification for UI */ + kind: string; +} + +const TOC_LINE = /^(\d+);\s*(.*)$/; + +export function parseRestoreListOutput(text: string): RestoreListRow[] { + const rows: RestoreListRow[] = []; + const lines = text.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trimEnd(); + if (!trimmed || trimmed.startsWith(';')) { + continue; + } + const m = TOC_LINE.exec(trimmed); + if (m) { + const rest = m[2] ?? ''; + const parts = rest.trim().split(/\s+/); + const kind = parts.length >= 3 ? parts[2]! : 'UNKNOWN'; + rows.push({ rawLine: trimmed, id: m[1]!, kind }); + } + } + return rows; +} + +/** Build list file body for pg_restore -L from selected raw lines (preserves order) */ +export function buildListFileFromSelection(selectedRawLines: string[]): string { + return selectedRawLines.join('\n') + '\n'; +} diff --git a/src/features/backup/toolVersion.ts b/src/features/backup/toolVersion.ts new file mode 100644 index 0000000..171fba6 --- /dev/null +++ b/src/features/backup/toolVersion.ts @@ -0,0 +1,55 @@ +import { spawn } from 'child_process'; + +export interface ToolVersionInfo { + /** Major version number from --version output, e.g. 16 */ + major: number; + raw: string; +} + +function parseMajorFromVersionOutput(text: string): number { + const m = /(\d+)\.(\d+)/.exec(text); + if (m) { + return parseInt(m[1]!, 10); + } + return 0; +} + +async function runVersionFlag(tool: 'pg_dump' | 'pg_restore'): Promise { + return await new Promise((resolve, reject) => { + const proc = spawn(tool, ['--version'], { shell: false, windowsHide: true }); + let out = ''; + proc.stdout.setEncoding('utf8'); + proc.stderr.setEncoding('utf8'); + proc.stdout.on('data', (d: string) => { + out += d; + }); + proc.stderr.on('data', (d: string) => { + out += d; + }); + proc.on('error', reject); + proc.on('close', () => { + const raw = out.trim(); + resolve({ major: parseMajorFromVersionOutput(raw), raw }); + }); + }); +} + +export async function getPgDumpVersion(): Promise { + return await runVersionFlag('pg_dump'); +} + +export async function getPgRestoreVersion(): Promise { + return await runVersionFlag('pg_restore'); +} + +/** server_version_num / 10000 → major */ +export function serverMajorFromVersionNum(serverVersionNum: number): number { + return Math.floor(serverVersionNum / 10000); +} + +export function isMajorMismatch(toolMajor: number, serverMajor: number): boolean { + if (toolMajor <= 0 || serverMajor <= 0) { + return false; + } + return toolMajor !== serverMajor; +} diff --git a/src/features/backup/types.ts b/src/features/backup/types.ts new file mode 100644 index 0000000..a6f3ecc --- /dev/null +++ b/src/features/backup/types.ts @@ -0,0 +1,45 @@ +/** Dump format flag values for pg_dump -F */ +export type PgDumpFormatFlag = 'c' | 'p' | 'd' | 't'; + +export interface PgDumpFormState { + format: PgDumpFormatFlag; + verbose: boolean; + schemaOnly: boolean; + dataOnly: boolean; + blobs: boolean; + parallelJobs: number; + compression: number | null; + outputPath: string; + database: string; + /** Optional schema.table for single-table dump (legacy) */ + tableQualified?: string; + /** Multiple -t flags (preferred when chosen from catalog picker) */ + tableQualifiedList?: string[]; + /** Multiple -n flags (schema subset; ignored by pg_dump when -t is used) */ + schemaNameList?: string[]; + /** Optional single-schema dump (e.g. tasks) */ + schemaName?: string; + /** Optional extra argv tokens (after built-in flags, before final dbname) */ + extraArgv?: string[]; +} + +export interface PgRestoreFormState { + verbose: boolean; + jobs: number; + targetDatabase: string; + /** Archive file or directory path */ + inputPath: string; + /** When set, only restore TOC entries whose raw lines are included */ + selectedListLines?: string[]; + /** Optional extra argv tokens (after -j/-v, before -d) */ + extraArgv?: string[]; +} + +export interface PgDumpallFormState { + verbose: boolean; + globalsOnly: boolean; + rolesOnly: boolean; + outputPath: string; + /** Optional extra argv tokens (after built-in flags, before -f) */ + extraArgv?: string[]; +} diff --git a/src/features/savedQueries/SaveQueryPanel.ts b/src/features/savedQueries/SaveQueryPanel.ts index c321d74..fa73bda 100644 --- a/src/features/savedQueries/SaveQueryPanel.ts +++ b/src/features/savedQueries/SaveQueryPanel.ts @@ -187,12 +187,7 @@ export class SaveQueryPanel { } // Call AI - let result: { text: string }; - if (provider === 'vscode-lm') { - result = await this._aiService.callVsCodeLm(prompt, config, ''); - } else { - result = await this._aiService.callDirectApi(provider, prompt, config, ''); - } + const result = await this._aiService.callProvider(provider, prompt, config, ''); // Clean up the response let generated = result.text.trim(); @@ -205,23 +200,17 @@ export class SaveQueryPanel { if (field === 'all') { // Generate title const titlePrompt = `Analyze this SQL query and generate a SHORT, DESCRIPTIVE title (max 6 words):\n\n${this._queryText}\n\nRespond with ONLY the title, nothing else.`; - const titleResult = provider === 'vscode-lm' - ? await this._aiService.callVsCodeLm(titlePrompt, config, '') - : await this._aiService.callDirectApi(provider, titlePrompt, config, ''); + const titleResult = await this._aiService.callProvider(provider, titlePrompt, config, ''); const title = titleResult.text.trim().replace(/^["']|["']$/g, '').trim(); // Generate description const descPrompt = `Analyze this SQL query and generate a brief description (1-2 sentences) explaining what it does:\n\n${this._queryText}\n\nRespond with ONLY the description, nothing else.`; - const descResult = provider === 'vscode-lm' - ? await this._aiService.callVsCodeLm(descPrompt, config, '') - : await this._aiService.callDirectApi(provider, descPrompt, config, ''); + const descResult = await this._aiService.callProvider(provider, descPrompt, config, ''); const description = descResult.text.trim().replace(/^["']|["']$/g, '').trim(); // Generate tags const tagsPrompt = `Analyze this SQL query and generate 3-5 relevant tags (single words or short phrases) separated by commas:\n\n${this._queryText}\n\nRespond with ONLY the comma-separated tags, nothing else.`; - const tagsResult = provider === 'vscode-lm' - ? await this._aiService.callVsCodeLm(tagsPrompt, config, '') - : await this._aiService.callDirectApi(provider, tagsPrompt, config, ''); + const tagsResult = await this._aiService.callProvider(provider, tagsPrompt, config, ''); const tags = tagsResult.text.trim().replace(/^["']|["']$/g, '').trim(); this._panel.webview.postMessage({ diff --git a/src/lib/postgresServerVersion.ts b/src/lib/postgresServerVersion.ts new file mode 100644 index 0000000..928074e --- /dev/null +++ b/src/lib/postgresServerVersion.ts @@ -0,0 +1,19 @@ +import type { PoolClient } from 'pg'; + +/** PostgreSQL 10 — logical replication catalogs, `pg_class.relispartition`, `relkind` `p`, `pg_sequences`, etc. */ +export const PG_VERSION_10 = 100_000; + +/** PostgreSQL 11 — `pg_proc.prokind`, SQL procedures */ +export const PG_VERSION_11 = 110_000; + +export type PgQueryable = Pick; + +export async function queryServerVersionNum(client: PgQueryable): Promise { + try { + const r = await client.query<{ server_version_num: string }>(`SHOW server_version_num`); + const n = Number(r.rows?.[0]?.server_version_num); + return Number.isFinite(n) ? n : 0; + } catch { + return 0; + } +} diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 5242ec7..16da771 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -21,8 +21,35 @@ import { SessionService, getWebviewHtml } from './chat'; +import type { ConnectionConfig, NoticeLogEntry } from '../common/types'; +import { buildBackupToolsSystemPrompt, buildBackupToolsUserMessage } from './chat/backupToolsAssistantPrompt'; import { ErrorService } from '../services/ErrorService'; -import type { NoticeLogEntry } from '../common/types'; + +/** Params for {@link ChatViewProvider.openBackupToolsAssistant} (Backup & Restore panel). */ +export interface OpenBackupToolsAssistantParams { + scenario: 'version_banner' | 'tool_log'; + connectionId: string; + databaseLabel: string; + databaseName: string; + connection?: ConnectionConfig; + toolLog?: string; + serverMajor: number; + pgDumpMajor: number; + pgRestoreMajor: number; +} + +function inferBackupToolFromLog(log: string): string | undefined { + if (/pg_restore:/m.test(log)) { + return 'pg_restore'; + } + if (/pg_dumpall:/m.test(log)) { + return 'pg_dumpall'; + } + if (/pg_dump:/m.test(log)) { + return 'pg_dump'; + } + return undefined; +} export class ChatViewProvider implements vscode.WebviewViewProvider { public static readonly viewType = 'postgresExplorer.chatView'; @@ -42,6 +69,9 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { private _currentEnvironment: 'production' | 'staging' | 'development' | undefined; private _currentReadOnlyMode: boolean = false; + /** When `backup_tools`, AI uses backup/restore specialist system prompt until new/clear chat or session load. */ + private _chatSystemPromptMode: 'default' | 'backup_tools' = 'default'; + // Services private _dbObjectService: DbObjectService; private _aiService: AiService; @@ -143,12 +173,14 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { case 'clearChat': this._messages = []; this._sessionService.clearCurrentSession(); + this._chatSystemPromptMode = 'default'; this._updateChatHistory(); break; case 'newChat': await this._saveCurrentSession(); this._messages = []; this._sessionService.clearCurrentSession(); + this._chatSystemPromptMode = 'default'; this._updateChatHistory(); this._sendHistoryToWebview(); break; @@ -564,17 +596,20 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { let usageInfo: string | undefined; const aiStartTime = Date.now(); - if (provider === 'vscode-lm') { - console.log('[ChatView] Calling VS Code LM API...'); - const result = await this._aiService.callVsCodeLm(aiMessage, config); - responseText = result.text; - usageInfo = result.usage; - } else { - console.log('[ChatView] Calling direct API:', provider); - const result = await this._aiService.callDirectApi(provider, aiMessage, config); - responseText = result.text; - usageInfo = result.usage; - } + console.log('[ChatView] Calling AI provider:', provider); + const customSystem = + this._chatSystemPromptMode === 'backup_tools' + ? buildBackupToolsSystemPrompt({ + connectionDisplayName: this._currentConnectionName, + databaseName: this._currentDatabase, + environment: this._currentEnvironment, + readOnlyMode: this._currentReadOnlyMode + }) + : undefined; + + const result = await this._aiService.callProvider(provider, aiMessage, config, customSystem); + responseText = result.text; + usageInfo = result.usage; const aiElapsed = ((Date.now() - aiStartTime) / 1000).toFixed(1); if (usageInfo) { @@ -929,6 +964,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { const messages = this._sessionService.loadSession(sessionId); if (messages) { this._messages = messages; + this._chatSystemPromptMode = 'default'; this._updateChatHistory(); } } @@ -940,6 +976,7 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { if (wasCurrentSession) { this._messages = []; + this._chatSystemPromptMode = 'default'; this._updateChatHistory(); } @@ -1135,6 +1172,86 @@ Why is this query running slower than its historical baseline? What could have c await this._handleUserMessage(prompt); } + /** + * Opens SQL Assistant with a **backup-tools** system prompt (pg_dump/pg_restore focus), + * starts a fresh chat, and sends one auto-generated user turn with panel context. + */ + public async openBackupToolsAssistant(params: OpenBackupToolsAssistantParams): Promise { + if (this._isProcessing) { + vscode.window.showWarningMessage('SQL Assistant is busy. Cancel the current request or wait.'); + return; + } + + const target = await this._ensureChatWebview(); + if (!target) { + vscode.window.showWarningMessage('Could not open SQL Assistant.'); + return; + } + + await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); + await new Promise(resolve => setTimeout(resolve, 280)); + + await this._saveCurrentSession(); + this._messages = []; + this._sessionService.clearCurrentSession(); + this._chatSystemPromptMode = 'backup_tools'; + + const conn = params.connection; + this._currentConnectionName = conn?.name ?? params.databaseLabel; + this._currentDatabase = params.databaseName; + this._currentEnvironment = conn?.environment; + this._currentReadOnlyMode = conn?.readOnlyMode === true; + this._aiService.setConnectionContext({ + environment: this._currentEnvironment, + readOnlyMode: this._currentReadOnlyMode, + connectionName: this._currentConnectionName + }); + this._sendContextUpdate(); + + const inferred = params.toolLog ? inferBackupToolFromLog(params.toolLog) : undefined; + const userMsg = buildBackupToolsUserMessage({ + scenario: params.scenario, + connectionId: params.connectionId, + databaseLabel: params.databaseLabel, + databaseName: params.databaseName, + host: conn?.host, + port: conn?.port, + username: conn?.username, + sshEnabled: !!conn?.ssh?.enabled, + serverMajor: params.serverMajor, + pgDumpMajor: params.pgDumpMajor, + pgRestoreMajor: params.pgRestoreMajor, + toolLog: params.toolLog, + inferredTool: inferred + }); + + this._isProcessing = true; + try { + this._messages.push({ role: 'user', content: userMsg }); + this._updateChatHistory(); + this._sendHistoryToWebview(); + + this._setTypingIndicator(true); + try { + await this._runAiRequest(userMsg); + } finally { + this._setTypingIndicator(false); + this._updateChatHistory(); + } + + await this._saveCurrentSession(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + this._messages.push({ + role: 'assistant', + content: `❌ Error: ${msg}\n\nPlease check your AI provider settings.` + }); + this._updateChatHistory(); + } finally { + this._isProcessing = false; + } + } + public async handleGenerateQuery( description: string, schemaContext?: Array<{ type: string, schema: string, name: string, columns?: string[] }> diff --git a/src/providers/DatabaseTreeProvider.ts b/src/providers/DatabaseTreeProvider.ts index 6a02719..818c552 100644 --- a/src/providers/DatabaseTreeProvider.ts +++ b/src/providers/DatabaseTreeProvider.ts @@ -6,6 +6,11 @@ import { getSchemaCache, SchemaCache } from '../lib/schema-cache'; import { Debouncer } from '../lib/debounce'; import { AutoRefreshService } from '../services/AutoRefreshService'; import { buildTreeItemKey, buildTreeItemKeyFromParts } from './tree/treeItemKey'; +import { + PG_VERSION_10, + PG_VERSION_11, + queryServerVersionNum, +} from '../lib/postgresServerVersion'; const buildItemKey = buildTreeItemKey; @@ -19,6 +24,8 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider; private _autoRefreshService: AutoRefreshService | undefined; + /** Cached `SHOW server_version_num` per connection (invalidated on full tree refresh). */ + private readonly _serverVersionByConnection = new Map(); // Filter, Favorites, and Recent Items private _favorites: Set = new Set(); @@ -179,6 +186,16 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider { + const cached = this._serverVersionByConnection.get(connectionId); + if (cached !== undefined) { + return cached; + } + const v = await queryServerVersionNum(client); + this._serverVersionByConnection.set(connectionId, v); + return v; + } + /** * Get database objects (tables, views, functions, procedures) for a connection * Used by AI Generate Query feature to provide schema context @@ -195,6 +212,7 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider = []; // Fetch tables with columns @@ -251,8 +269,10 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider= PG_VERSION_11 + ? ` SELECT n.nspname as schema_name, p.proname as function_name @@ -262,6 +282,17 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider= PG_VERSION_11) { + const proceduresQuery = ` SELECT n.nspname as schema_name, p.proname as procedure_name @@ -286,14 +318,15 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider { - objects.push({ - type: 'procedure', - schema: row.schema_name, - name: row.procedure_name + const proceduresResult = await client.query(proceduresQuery); + proceduresResult.rows.forEach((row: any) => { + objects.push({ + type: 'procedure', + schema: row.schema_name, + name: row.procedure_name + }); }); - }); + } return objects; } finally { @@ -307,11 +340,25 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider { + const completion = SqlCompletionProvider.getInstance(); + if (!completion) { + return; + } + if (!element) { + completion.invalidateAll(); + } else if (element.connectionId && element.databaseName) { + completion.invalidate(element.connectionId, element.databaseName); + } else if (element.connectionId) { + completion.invalidate(element.connectionId); + } + }); this._onDidChangeTreeData.fire(element); }, 300); // Debounce for 300ms to batch rapid updates } @@ -456,6 +503,10 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider= PG_VERSION_10 ? 'AND NOT c.relispartition\n ' : ''}AND t.table_name NOT LIKE 'pg\_%' ESCAPE '\\' AND t.table_name NOT LIKE 'sql\_%' ESCAPE '\\' ORDER BY t.table_name`, [element.schema] @@ -1175,6 +1232,9 @@ i.relname as index_name, }); case 'Procedures': + if (pgVer < PG_VERSION_11) { + return []; + } const procedureResult = await client.query( `SELECT p.proname AS procedure_name FROM pg_proc p @@ -1284,7 +1344,10 @@ i.relname as index_name, case 'Sequences': const seqResult = await client.query( - "SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = $1 ORDER BY sequencename", + pgVer >= PG_VERSION_10 + ? 'SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = $1 ORDER BY sequencename' + : `SELECT sequence_name AS sequencename, NULL::bigint AS last_value + FROM information_schema.sequences WHERE sequence_schema = $1 ORDER BY sequence_name`, [element.schema] ); return seqResult.rows.map((row: any) => new DatabaseTreeItem( @@ -1331,10 +1394,11 @@ i.relname as index_name, )); case 'Aggregates': - const aggResult = await client.query( - `SELECT DISTINCT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.prokind = 'a' AND n.nspname = $1 ORDER BY p.proname`, - [element.schema] - ); + const aggListSql = + pgVer >= PG_VERSION_11 + ? `SELECT DISTINCT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.prokind = 'a' AND n.nspname = $1 ORDER BY p.proname` + : `SELECT DISTINCT p.proname FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.proisagg AND n.nspname = $1 ORDER BY p.proname`; + const aggResult = await client.query(aggListSql, [element.schema]); return aggResult.rows.map((row: any) => new DatabaseTreeItem( row.proname, vscode.TreeItemCollapsibleState.None, @@ -1376,10 +1440,16 @@ i.relname as index_name, )); case 'Publications': - const pubResult = await client.query( - `SELECT pubname FROM pg_publication ORDER BY pubname` - ); - return pubResult.rows.map((row: any) => new DatabaseTreeItem( + let pubRows: any[] = []; + try { + const pubResult = await client.query( + `SELECT pubname FROM pg_publication ORDER BY pubname` + ); + pubRows = pubResult.rows; + } catch { + // pg_publication exists only in PostgreSQL 10+ + } + return pubRows.map((row: any) => new DatabaseTreeItem( row.pubname, vscode.TreeItemCollapsibleState.None, 'publication', @@ -1431,8 +1501,7 @@ i.relname as index_name, JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = t.table_schema WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' - AND NOT c.relispartition - AND t.table_name NOT LIKE 'pg\\_%' ESCAPE E'\\\\' + ${pgVer >= PG_VERSION_10 ? 'AND NOT c.relispartition\n ' : ''}AND t.table_name NOT LIKE 'pg\\_%' ESCAPE E'\\\\' AND t.table_name NOT LIKE 'sql\\_%' ESCAPE E'\\\\'`, [element.schema] ); @@ -1452,13 +1521,16 @@ i.relname as index_name, [element.schema] ); - const proceduresCountResult = await client.query( - `SELECT COUNT(*) + const proceduresCountResult = + pgVer >= PG_VERSION_11 + ? await client.query( + `SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace WHERE n.nspname = $1 AND p.prokind = 'p'`, - [element.schema] - ); + [element.schema] + ) + : { rows: [{ count: 0 }] }; const materializedViewsCountResult = await client.query( "SELECT COUNT(*) FROM pg_matviews WHERE schemaname = $1", @@ -1476,7 +1548,9 @@ i.relname as index_name, ); const seqCountResult = await client.query( - "SELECT COUNT(*) FROM pg_sequences WHERE schemaname = $1", + pgVer >= PG_VERSION_10 + ? 'SELECT COUNT(*) FROM pg_sequences WHERE schemaname = $1' + : 'SELECT COUNT(*) FROM information_schema.sequences WHERE sequence_schema = $1', [element.schema] ); @@ -1491,7 +1565,9 @@ i.relname as index_name, ); const aggCountResult = await client.query( - "SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.prokind = 'a' AND n.nspname = $1", + pgVer >= PG_VERSION_11 + ? "SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.prokind = 'a' AND n.nspname = $1" + : "SELECT COUNT(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace = n.oid WHERE p.proisagg AND n.nspname = $1", [element.schema] ); diff --git a/src/providers/NotebookKernel.ts b/src/providers/NotebookKernel.ts index 7125b37..85850e7 100644 --- a/src/providers/NotebookKernel.ts +++ b/src/providers/NotebookKernel.ts @@ -182,7 +182,6 @@ export class PostgresKernel implements vscode.Disposable { private async _executeAll(cells: vscode.NotebookCell[], _notebook: vscode.NotebookDocument, _controller: vscode.NotebookController): Promise { const telemetry = TelemetryService.getInstance(); - telemetry.trackEvent('feature_used', { feature: 'notebook' }); telemetry.trackEvent('notebook_executed', { cellCountBucket: cells.length < 5 ? 'lt_5' : cells.length < 20 ? '5_19' : 'gte_20', }); diff --git a/src/providers/SqlCompletionProvider.ts b/src/providers/SqlCompletionProvider.ts index 8b86c0e..7c6dc19 100644 --- a/src/providers/SqlCompletionProvider.ts +++ b/src/providers/SqlCompletionProvider.ts @@ -1,9 +1,22 @@ import * as vscode from 'vscode'; import { ConnectionManager } from '../services/ConnectionManager'; +import { SqlParser } from './kernel/SqlParser'; +import { outputChannel } from '../extension'; +import { sqlFormatIdentifier } from './sql-completion-shared'; +import { PG_VERSION_10, PG_VERSION_11, queryServerVersionNum } from '../lib/postgresServerVersion'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- interface TableInfo { schema: string; - tableName: string; + objectName: string; + objectType: string; + arguments?: string; + callArguments?: string; + /** Materialized view: whether relation is populated (pg_class.relispopulated). */ + isPopulated?: boolean; } interface ColumnInfo { @@ -11,272 +24,2371 @@ interface ColumnInfo { tableName: string; columnName: string; dataType: string; + /** information_schema — composite / domain typing hints */ + udtSchema?: string; + udtName?: string; +} + +interface ForeignKeyInfo { + schema: string; + tableName: string; + columns: string[]; + referencedSchema: string; + referencedTable: string; + referencedColumns: string[]; +} + +interface RelationContext { + schema: string | null; + objectName: string; + alias: string; } +interface ParsedQuery { + cleanText: string; + clause: SqlClause; + relations: RelationContext[]; + aliasMap: Map; + qualifiedMap: Map; + referencedTables: Set; + cteColumns: Map; + dotQualifier: string | null; + hasQualifiedPrefix: boolean; + dotPartial: string | null; + insertTarget: RelationContext | null; + updateTarget: RelationContext | null; + /** Subquery / derived columns: alias -> projected column names */ + derivedColumns: Map; + /** Inner SELECT bodies for derived-table aliases (wildcard expansion) */ + derivedBodies: Map; + /** Raw CTE bodies for wildcard expansion */ + cteBodies: Map; + /** Column data type (information_schema) before cursor in WHERE/HAVING for operator filtering */ + precedingWhereColumnType: string | null; +} + +interface SchemaCache { + objects: TableInfo[]; + columns: ColumnInfo[]; + foreignKeys: ForeignKeyInfo[]; + searchPath: string[]; + /** Composite types (typtype=c): key lower(schema.typname) -> attribute names */ + compositeAttrs: Map; + /** Role names for GRANT ... TO */ + roles: string[]; + updatedAt: number; +} + +const EMPTY_CACHE: SchemaCache = { + objects: [], + columns: [], + foreignKeys: [], + searchPath: ['public'], + compositeAttrs: new Map(), + roles: [], + updatedAt: 0 +}; + +enum SqlClause { + Unknown = 'unknown', + Select = 'select', + From = 'from', + Join = 'join', + Where = 'where', + GroupBy = 'groupBy', + OrderBy = 'orderBy', + Having = 'having', + On = 'on', + InsertColumns = 'insertColumns', + InsertTarget = 'insertTarget', + UpdateSet = 'updateSet', + Returning = 'returning', + DeleteFrom = 'deleteFrom', + DeleteUsing = 'deleteUsing', + ExplainOptions = 'explainOptions', + CopyOptions = 'copyOptions', + CreateTableColumn = 'createTableColumn', + AlterTableOp = 'alterTableOp', + GrantOn = 'grantOn', + GrantTo = 'grantTo' +} + +// --------------------------------------------------------------------------- +// Keyword / snippet catalogs +// --------------------------------------------------------------------------- + +const AGGREGATE_FUNCTIONS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'COUNT(*)', snippet: 'COUNT(*)', detail: 'Count all rows' }, + { label: 'COUNT', snippet: 'COUNT(${1:column})', detail: 'Count non-null values' }, + { label: 'SUM', snippet: 'SUM(${1:column})', detail: 'Sum of values' }, + { label: 'AVG', snippet: 'AVG(${1:column})', detail: 'Average value' }, + { label: 'MIN', snippet: 'MIN(${1:column})', detail: 'Minimum value' }, + { label: 'MAX', snippet: 'MAX(${1:column})', detail: 'Maximum value' }, + { label: 'STRING_AGG', snippet: "STRING_AGG(${1:column}, '${2:,}')", detail: 'Concatenate strings' }, + { label: 'ARRAY_AGG', snippet: 'ARRAY_AGG(${1:column})', detail: 'Aggregate into array' }, + { label: 'JSON_AGG', snippet: 'JSON_AGG(${1:column})', detail: 'Aggregate into JSON array' }, + { label: 'JSONB_AGG', snippet: 'JSONB_AGG(${1:column})', detail: 'Aggregate into JSONB array' }, + { label: 'BOOL_AND', snippet: 'BOOL_AND(${1:column})', detail: 'True if all true' }, + { label: 'BOOL_OR', snippet: 'BOOL_OR(${1:column})', detail: 'True if any true' } +]; + +const WINDOW_FUNCTIONS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'ROW_NUMBER()', snippet: 'ROW_NUMBER() OVER (${1:PARTITION BY ${2:col} }ORDER BY ${3:col})', detail: 'Row number within partition' }, + { label: 'RANK()', snippet: 'RANK() OVER (${1:PARTITION BY ${2:col} }ORDER BY ${3:col})', detail: 'Rank with gaps' }, + { label: 'DENSE_RANK()', snippet: 'DENSE_RANK() OVER (${1:PARTITION BY ${2:col} }ORDER BY ${3:col})', detail: 'Rank without gaps' }, + { label: 'LAG', snippet: 'LAG(${1:column}, ${2:1}) OVER (ORDER BY ${3:col})', detail: 'Previous row value' }, + { label: 'LEAD', snippet: 'LEAD(${1:column}, ${2:1}) OVER (ORDER BY ${3:col})', detail: 'Next row value' }, + { label: 'FIRST_VALUE', snippet: 'FIRST_VALUE(${1:column}) OVER (ORDER BY ${2:col})', detail: 'First value in partition' }, + { label: 'LAST_VALUE', snippet: 'LAST_VALUE(${1:column}) OVER (ORDER BY ${2:col})', detail: 'Last value in partition' }, + { label: 'NTILE', snippet: 'NTILE(${1:4}) OVER (ORDER BY ${2:col})', detail: 'Distribute into N buckets' }, + { label: 'PERCENT_RANK()', snippet: 'PERCENT_RANK() OVER (ORDER BY ${1:col})', detail: 'Relative rank 0-1' }, + { label: 'CUME_DIST()', snippet: 'CUME_DIST() OVER (ORDER BY ${1:col})', detail: 'Cumulative distribution' } +]; + +const SCALAR_FUNCTIONS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'COALESCE', snippet: 'COALESCE(${1:col}, ${2:default})', detail: 'First non-null value' }, + { label: 'NULLIF', snippet: 'NULLIF(${1:col}, ${2:value})', detail: 'Null if equal' }, + { label: 'GREATEST', snippet: 'GREATEST(${1:a}, ${2:b})', detail: 'Largest value' }, + { label: 'LEAST', snippet: 'LEAST(${1:a}, ${2:b})', detail: 'Smallest value' }, + { label: 'NOW()', snippet: 'NOW()', detail: 'Current timestamp with tz' }, + { label: 'CURRENT_TIMESTAMP', snippet: 'CURRENT_TIMESTAMP', detail: 'Current timestamp' }, + { label: 'CURRENT_DATE', snippet: 'CURRENT_DATE', detail: 'Current date' }, + { label: 'EXTRACT', snippet: "EXTRACT(${1|YEAR,MONTH,DAY,HOUR,MINUTE,SECOND,DOW,DOY,EPOCH|} FROM ${2:col})", detail: 'Extract date part' }, + { label: 'DATE_TRUNC', snippet: "DATE_TRUNC('${1|year,month,week,day,hour,minute,second|}', ${2:col})", detail: 'Truncate to date part' }, + { label: 'DATE_PART', snippet: "DATE_PART('${1|year,month,day,hour,minute,second|}', ${2:col})", detail: 'Extract date part (numeric)' }, + { label: 'TO_CHAR', snippet: "TO_CHAR(${1:col}, '${2:YYYY-MM-DD}')", detail: 'Format to string' }, + { label: 'TO_DATE', snippet: "TO_DATE('${1:str}', '${2:YYYY-MM-DD}')", detail: 'Parse date from string' }, + { label: 'INTERVAL', snippet: "INTERVAL '${1:7 days}'", detail: 'Time interval literal' }, + { label: 'UPPER', snippet: 'UPPER(${1:col})', detail: 'Uppercase string' }, + { label: 'LOWER', snippet: 'LOWER(${1:col})', detail: 'Lowercase string' }, + { label: 'TRIM', snippet: 'TRIM(${1:col})', detail: 'Remove leading/trailing whitespace' }, + { label: 'LENGTH', snippet: 'LENGTH(${1:col})', detail: 'String length' }, + { label: 'CONCAT', snippet: 'CONCAT(${1:a}, ${2:b})', detail: 'Concatenate strings' }, + { label: 'REPLACE', snippet: "REPLACE(${1:col}, '${2:from}', '${3:to}')", detail: 'Replace substring' }, + { label: 'SUBSTRING', snippet: "SUBSTRING(${1:col} FROM ${2:1} FOR ${3:10})", detail: 'Extract substring' }, + { label: 'SPLIT_PART', snippet: "SPLIT_PART(${1:col}, '${2:delimiter}', ${3:1})", detail: 'Split and return part' }, + { label: 'REGEXP_REPLACE', snippet: "REGEXP_REPLACE(${1:col}, '${2:pattern}', '${3:replacement}')", detail: 'Regex replace' }, + { label: 'CAST', snippet: 'CAST(${1:col} AS ${2:type})', detail: 'Type cast' }, + { label: 'GENERATE_SERIES', snippet: 'GENERATE_SERIES(${1:1}, ${2:10}, ${3:1})', detail: 'Generate a series of values' }, + { label: 'UNNEST', snippet: 'UNNEST(${1:array_col})', detail: 'Expand array to rows' }, + { label: 'JSON_BUILD_OBJECT', snippet: "JSON_BUILD_OBJECT('${1:key}', ${2:value})", detail: 'Build JSON object' }, + { label: 'JSONB_BUILD_OBJECT', snippet: "JSONB_BUILD_OBJECT('${1:key}', ${2:value})", detail: 'Build JSONB object' }, + { label: 'TO_JSON', snippet: 'TO_JSON(${1:value})', detail: 'Convert to JSON' }, + { label: 'ROW_TO_JSON', snippet: 'ROW_TO_JSON(${1:row})', detail: 'Convert row to JSON' }, + { label: 'ARRAY_LENGTH', snippet: 'ARRAY_LENGTH(${1:col}, 1)', detail: 'Length of array dimension' }, + { label: 'CARDINALITY', snippet: 'CARDINALITY(${1:col})', detail: 'Number of elements in array' } +]; + +const EXPLAIN_OPTION_KEYWORDS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'ANALYZE', snippet: 'ANALYZE', detail: 'EXPLAIN option' }, + { label: 'VERBOSE', snippet: 'VERBOSE', detail: 'EXPLAIN option' }, + { label: 'COSTS', snippet: 'COSTS', detail: 'EXPLAIN option' }, + { label: 'BUFFERS', snippet: 'BUFFERS', detail: 'EXPLAIN option' }, + { label: 'TIMING', snippet: 'TIMING', detail: 'EXPLAIN option' }, + { label: 'SUMMARY', snippet: 'SUMMARY', detail: 'EXPLAIN option' }, + { label: 'FORMAT TEXT', snippet: 'FORMAT TEXT', detail: 'EXPLAIN output format' }, + { label: 'FORMAT JSON', snippet: 'FORMAT JSON', detail: 'EXPLAIN output format' }, + { label: 'FORMAT XML', snippet: 'FORMAT XML', detail: 'EXPLAIN output format' }, + { label: 'FORMAT YAML', snippet: 'FORMAT YAML', detail: 'EXPLAIN output format' }, + { label: 'WAL', snippet: 'WAL', detail: 'EXPLAIN option (PG13+)' }, + { label: 'SETTINGS', snippet: 'SETTINGS', detail: 'EXPLAIN option' } +]; + +const COPY_WITH_OPTIONS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'FORMAT CSV', snippet: 'FORMAT CSV', detail: 'COPY format' }, + { label: 'FORMAT TEXT', snippet: 'FORMAT TEXT', detail: 'COPY format' }, + { label: 'FORMAT BINARY', snippet: 'FORMAT BINARY', detail: 'COPY format' }, + { label: 'DELIMITER', snippet: "DELIMITER '${1:,}'", detail: 'COPY TEXT delimiter' }, + { label: 'NULL', snippet: "NULL '${1:\\\\N}'", detail: 'COPY null string' }, + { label: 'HEADER', snippet: 'HEADER', detail: 'CSV header row' }, + { label: 'QUOTE', snippet: "QUOTE '${1:\"}'", detail: 'CSV quote character' }, + { label: 'ESCAPE', snippet: "ESCAPE '\\'", detail: 'CSV escape' }, + { label: 'ENCODING', snippet: 'UTF8', detail: 'Character encoding' }, + { label: 'FREEZE', snippet: 'FREEZE', detail: 'COPY FREEZE' }, + { label: 'FORCE_QUOTE', snippet: 'FORCE_QUOTE (*)', detail: 'CSV force quote' }, + { label: 'FORCE_NOT_NULL', snippet: 'FORCE_NOT_NULL (${1:col})', detail: 'CSV columns' } +]; + +const PG_TYPE_GROUPS = { + numeric: /smallint|integer|bigint|decimal|numeric|real|double|serial|money/i, + dateTime: /date|time|timestamp|interval/i, + string: /character|varchar|text|name|uuid|bytea|bit/i, + boolean: /boolean/i, + json: /jsonb?|json/i, + geometric: /point|line|lseg|box|path|polygon|circle/i, + network: /cidr|inet|macaddr/i, + array: /\[\]/i, + fullText: /tsvector|tsquery/i +}; + +const CREATE_TABLE_SNIPPETS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'id SERIAL PRIMARY KEY', snippet: 'id SERIAL PRIMARY KEY', detail: 'Common surrogate key' }, + { label: 'INTEGER', snippet: 'INTEGER', detail: 'Type' }, + { label: 'BIGINT', snippet: 'BIGINT', detail: 'Type' }, + { label: 'TEXT', snippet: 'TEXT', detail: 'Type' }, + { label: 'VARCHAR(n)', snippet: 'VARCHAR(${1:255})', detail: 'Variable-length text' }, + { label: 'BOOLEAN', snippet: 'BOOLEAN', detail: 'Type' }, + { label: 'TIMESTAMPTZ', snippet: 'TIMESTAMPTZ', detail: 'Timestamp with time zone' }, + { label: 'UUID', snippet: 'UUID', detail: 'Type' }, + { label: 'JSONB', snippet: 'JSONB', detail: 'Binary JSON' }, + { label: 'NUMERIC(p,s)', snippet: 'NUMERIC(${1:10},${2:2})', detail: 'Exact numeric' }, + { label: 'NOT NULL', snippet: 'NOT NULL', detail: 'Constraint' }, + { label: 'PRIMARY KEY', snippet: 'PRIMARY KEY', detail: 'Constraint' }, + { label: 'REFERENCES', snippet: 'REFERENCES ${1:table}(${2:id})', detail: 'FK' }, + { label: 'UNIQUE', snippet: 'UNIQUE', detail: 'Constraint' }, + { label: 'DEFAULT', snippet: 'DEFAULT ${1:value}', detail: 'Default value' }, + { label: 'CHECK (...)', snippet: 'CHECK (${1:condition})', detail: 'Check constraint' }, + { label: 'GENERATED ALWAYS AS IDENTITY', snippet: 'GENERATED ALWAYS AS IDENTITY', detail: 'Identity column' } +]; + +const ALTER_TABLE_KEYWORDS = [ + 'ADD COLUMN', + 'DROP COLUMN', + 'RENAME COLUMN', + 'RENAME TO', + 'ALTER COLUMN', + 'SET SCHEMA', + 'ENABLE TRIGGER', + 'DISABLE TRIGGER', + 'ATTACH PARTITION', + 'DETACH PARTITION' +]; + +const GROUP_ORDER_SNIPPETS = [ + { label: 'LIMIT 100', snippet: 'LIMIT 100', detail: 'Cap rows' }, + { label: 'OFFSET 0', snippet: 'OFFSET 0', detail: 'Skip rows' }, + { label: 'HAVING COUNT(*) > 1', snippet: 'HAVING COUNT(*) > 1', detail: 'Filter aggregates' }, + { label: 'GROUP BY ROLLUP (...)', snippet: 'GROUP BY ROLLUP (${1:col})', detail: 'Rollup' }, + { label: 'GROUP BY GROUPING SETS (...)', snippet: 'GROUP BY GROUPING SETS (${1:()})', detail: 'Grouping sets' } +]; + +const WHERE_OPERATORS: Array<{ label: string; snippet: string; detail: string }> = [ + { label: 'IS NULL', snippet: 'IS NULL', detail: 'Check for null' }, + { label: 'IS NOT NULL', snippet: 'IS NOT NULL', detail: 'Check for non-null' }, + { label: 'IN (...)', snippet: 'IN (${1:value})', detail: 'Match any value in list' }, + { label: 'NOT IN (...)', snippet: 'NOT IN (${1:value})', detail: 'Not in list' }, + { label: 'BETWEEN', snippet: 'BETWEEN ${1:low} AND ${2:high}', detail: 'Inclusive range check' }, + { label: 'LIKE', snippet: "LIKE '${1:%pattern%}'", detail: 'Pattern match (case sensitive)' }, + { label: 'ILIKE', snippet: "ILIKE '${1:%pattern%}'", detail: 'Pattern match (case insensitive)' }, + { label: 'NOT LIKE', snippet: "NOT LIKE '${1:%pattern%}'", detail: 'Negate pattern match' }, + { label: '~', snippet: "~ '${1:regex}'", detail: 'Regex match (case sensitive)' }, + { label: '~*', snippet: "~* '${1:regex}'", detail: 'Regex match (case insensitive)' }, + { label: 'ANY', snippet: 'ANY(${1:array_col})', detail: 'Match any element in array' }, + { label: 'ALL', snippet: 'ALL(${1:subquery})', detail: 'Match all elements' }, + { label: 'EXISTS', snippet: 'EXISTS (${1:SELECT 1 FROM ...})', detail: 'Subquery exists' }, + { label: 'NOT EXISTS', snippet: 'NOT EXISTS (${1:SELECT 1 FROM ...})', detail: 'Subquery does not exist' } +]; + +const SQL_RESERVED_ALIAS = new Set([ + 'select', 'from', 'where', 'join', 'on', 'group', 'order', 'having', 'limit', 'offset', + 'left', 'right', 'inner', 'outer', 'full', 'cross', 'into', 'values', 'set', 'returning', + 'as', 'and', 'or', 'not', 'update', 'delete', 'insert', 'table', 'call', 'truncate' +]); + +/** Relation kinds suitable for "table named q in search_path" disambiguation vs schema-qualified `q.` */ +const RELATION_OBJECT_TYPES = new Set(['table', 'view', 'materialized view']); + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + export class SqlCompletionProvider implements vscode.CompletionItemProvider { - private tableCache: Map = new Map(); - private columnCache: Map = new Map(); - private lastCacheUpdate: Map = new Map(); - private readonly CACHE_TTL = 60000; // 1 minute cache + private static instance: SqlCompletionProvider | null = null; + + private schemaCache: Map = new Map(); + private catalogEpoch: Map = new Map(); + private fetchLocks: Map> = new Map(); + + private readonly CACHE_TTL_MS = 120_000; + + private static readonly RELATION_LEAD_IN = + '(?:from|join|update|into|table|delete\\s+from|truncate\\s+table|call)\\s+(?:lateral\\s+)?'; + + private static buildCatalogObjectsSql(pgVer: number): string { + const tableWhere = + pgVer >= PG_VERSION_10 + ? `c.relkind IN ('r', 'p') AND NOT c.relispartition` + : `c.relkind = 'r'`; + const routinesUnion = + pgVer >= PG_VERSION_11 + ? `SELECT + CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END AS object_type, + n.nspname AS schema, + p.proname AS object_name, + pg_get_function_arguments(p.oid) AS arguments, + pg_get_function_identity_arguments(p.oid) AS call_arguments, + NULL::boolean AS is_populated + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE p.prokind IN ('f', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema')` + : `SELECT + 'function'::text AS object_type, + n.nspname AS schema, + p.proname AS object_name, + pg_get_function_arguments(p.oid) AS arguments, + pg_get_function_identity_arguments(p.oid) AS call_arguments, + NULL::boolean AS is_populated + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE NOT p.proisagg + AND n.nspname NOT IN ('pg_catalog', 'information_schema')`; + return ` + SELECT * FROM ( + SELECT 'table' AS object_type, n.nspname AS schema, c.relname AS object_name, + NULL::text AS arguments, NULL::text AS call_arguments, + NULL::boolean AS is_populated + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE ${tableWhere} + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + UNION ALL + SELECT 'view', n.nspname, c.relname, NULL::text, NULL::text, NULL::boolean + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'v' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + UNION ALL + SELECT 'materialized view', n.nspname, c.relname, NULL::text, NULL::text, c.relispopulated + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'm' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + UNION ALL + ${routinesUnion} + ) q ORDER BY schema, object_name + `; + } + + private static readonly CATALOG_COLUMNS_SQL = ` + SELECT + table_schema as schema, + table_name, + column_name, + data_type, + udt_schema, + udt_name + FROM information_schema.columns + WHERE table_schema NOT IN ('pg_catalog', 'information_schema') + ORDER BY table_schema, table_name, ordinal_position + `; + + private static readonly CATALOG_COMPOSITE_SQL = ` + SELECT n.nspname AS schema, t.typname AS type_name, + array_agg(a.attname ORDER BY a.attnum) AS attrs + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_attribute a ON a.attrelid = t.typrelid AND a.attnum > 0 AND NOT a.attisdropped + WHERE t.typtype = 'c' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY n.nspname, t.typname + `; + + private static readonly CATALOG_FK_SQL = ` + SELECT + n.nspname AS schema, + c.relname AS table_name, + array_agg(a.attname ORDER BY u.attposition) AS columns, + rn.nspname AS ref_schema, + rc.relname AS ref_table, + array_agg(ra.attname ORDER BY u.attposition) AS ref_columns + FROM pg_constraint con + JOIN pg_class c ON con.conrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + JOIN pg_class rc ON con.confrelid = rc.oid + JOIN pg_namespace rn ON rc.relnamespace = rn.oid + JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS u(conkey, confkey, attposition) ON true + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum = u.conkey + JOIN pg_attribute ra ON ra.attrelid = rc.oid AND ra.attnum = u.confkey + WHERE con.contype = 'f' + GROUP BY con.oid, n.nspname, c.relname, rn.nspname, rc.relname; + `; + + public static setInstance(instance: SqlCompletionProvider): void { + SqlCompletionProvider.instance = instance; + } + + public static getInstance(): SqlCompletionProvider | null { + return SqlCompletionProvider.instance; + } + + /** Shared prefix builder for completion + signature help (notebook-aware). */ + public static sqlTextBeforeCursor(document: vscode.TextDocument, position: vscode.Position): string { + const lines = document.getText().split(/\r?\n/); + let inCellText: string; + if (position.line >= lines.length) { + inCellText = lines.join('\n'); + } else { + const beforeLines = lines.slice(0, position.line).join('\n'); + const linePrefix = (lines[position.line] || '').slice(0, position.character); + inCellText = beforeLines ? `${beforeLines}\n${linePrefix}` : linePrefix; + } + + if (document.uri.scheme !== 'vscode-notebook-cell') { + return inCellText; + } + + const notebook = vscode.workspace.notebookDocuments.find(nb => + nb.getCells().some(cell => cell.document.uri.toString() === document.uri.toString()) + ); + if (!notebook) { + return inCellText; + } + + const cells = notebook.getCells(); + const idx = cells.findIndex(cell => cell.document.uri.toString() === document.uri.toString()); + const priorSql = cells + .slice(0, idx) + .filter(cell => cell.kind === vscode.NotebookCellKind.Code && cell.document.languageId === 'sql') + .map(cell => cell.document.getText().trim()) + .filter(Boolean) + .join(';\n'); + + return priorSql ? `${priorSql};\n${inCellText}` : inCellText; + } + + /** Prior SQL cells + **entire** current cell (for relations/clauses after cursor — e.g. SELECT list with FROM below). */ + public static sqlFullNotebookSqlRaw(document: vscode.TextDocument): string { + const fullCell = document.getText(); + if (document.uri.scheme !== 'vscode-notebook-cell') { + return fullCell; + } + + const notebook = vscode.workspace.notebookDocuments.find(nb => + nb.getCells().some(cell => cell.document.uri.toString() === document.uri.toString()) + ); + if (!notebook) { + return fullCell; + } + + const cells = notebook.getCells(); + const idx = cells.findIndex(cell => cell.document.uri.toString() === document.uri.toString()); + const priorSql = cells + .slice(0, idx) + .filter(cell => cell.kind === vscode.NotebookCellKind.Code && cell.document.languageId === 'sql') + .map(cell => cell.document.getText().trim()) + .filter(Boolean) + .join(';\n'); + + return priorSql ? `${priorSql};\n${fullCell}` : fullCell; + } + + public invalidate(connectionId: string, database?: string): void { + if (database) { + const cacheKey = `${connectionId}-${database}`; + this._bumpEpoch(cacheKey); + this.schemaCache.delete(cacheKey); + return; + } + + const prefix = `${connectionId}-`; + for (const key of [...this.schemaCache.keys()]) { + if (key.startsWith(prefix)) { + this._bumpEpoch(key); + this.schemaCache.delete(key); + } + } + } + + public invalidateAll(): void { + this.catalogEpoch.clear(); + this.fetchLocks.clear(); + this.schemaCache.clear(); + } + + /** Used by signature help and other providers sharing the same notebook connection. */ + public async ensureSchemaForNotebook(document: vscode.TextDocument): Promise { + const conn = await this._getNotebookConnection(document); + if (!conn) { + return null; + } + const cfg = await this._resolveConnectionConfig(conn.connectionId); + if (!cfg) { + return null; + } + const cacheKey = `${conn.connectionId}-${conn.database}`; + await this._ensureCache(cacheKey, cfg, conn.database); + return this.schemaCache.get(cacheKey) ?? null; + } + + public async warmCache(connectionId: string, database: string): Promise { + const cacheKey = `${connectionId}-${database}`; + const cfg = await this._resolveConnectionConfig(connectionId); + if (!cfg) { + return; + } + const epoch = this.catalogEpoch.get(cacheKey) ?? 0; + let lock = this.fetchLocks.get(cacheKey); + if (!lock) { + lock = this._fetchAndStoreCache(cacheKey, cfg, database, epoch); + this.fetchLocks.set(cacheKey, lock); + } + try { + await lock; + } finally { + if (this.fetchLocks.get(cacheKey) === lock) { + this.fetchLocks.delete(cacheKey); + } + } + } + + private _bumpEpoch(cacheKey: string): void { + this.catalogEpoch.set(cacheKey, (this.catalogEpoch.get(cacheKey) ?? 0) + 1); + } async provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, - token: vscode.CancellationToken, - context: vscode.CompletionContext + _token: vscode.CancellationToken, + _context: vscode.CompletionContext ): Promise { - const completionItems: vscode.CompletionItem[] = []; - try { - // Get connection info from notebook metadata or active connection - const connectionInfo = await this._getConnectionInfo(document); - if (!connectionInfo) { + const conn = await this._getNotebookConnection(document); + if (!conn) { return []; } - const { connectionId, database } = connectionInfo; + const { connectionId, database } = conn; const cacheKey = `${connectionId}-${database}`; + const cfg = await this._resolveConnectionConfig(connectionId); + + if (!cfg) { + const parsed = this._parseQuery(document, position); + this._enrichWildcardColumns(parsed, EMPTY_CACHE.columns); + if (parsed.clause === SqlClause.Where || parsed.clause === SqlClause.Having) { + parsed.precedingWhereColumnType = this._resolvePrecedingColumnDataType(parsed.cleanText, parsed, EMPTY_CACHE.columns); + } + const items = this._buildCompletions(parsed, EMPTY_CACHE, document, position); + items.push( + ...this._keywordItems([ + 'SELECT', + 'INSERT INTO', + 'UPDATE', + 'DELETE FROM', + 'WITH', + 'CREATE TABLE', + 'EXPLAIN ANALYZE' + ]) + ); + return items; + } + + await this._ensureCache(cacheKey, cfg, database); + const cache = this.schemaCache.get(cacheKey) ?? EMPTY_CACHE; - // Update cache if needed - if (this._shouldUpdateCache(cacheKey)) { - await this._updateCache(connectionId, database, cacheKey); + const parsed = this._parseQuery(document, position); + this._enrichWildcardColumns(parsed, cache.columns); + if (parsed.clause === SqlClause.Where || parsed.clause === SqlClause.Having) { + parsed.precedingWhereColumnType = this._resolvePrecedingColumnDataType(parsed.cleanText, parsed, cache.columns); + } + return this._buildCompletions(parsed, cache, document, position); + } catch (error) { + outputChannel?.appendLine(`[SqlCompletionProvider] ${error}`); + return []; + } + } + + // =========================================================================== + // Cache + // =========================================================================== + + private async _ensureCache( + cacheKey: string, + cfg: { id: string; host: string; port: number; username: string; name: string }, + database: string + ): Promise { + const cached = this.schemaCache.get(cacheKey); + if (cached && Date.now() - cached.updatedAt < this.CACHE_TTL_MS) { + return; + } + + let lock = this.fetchLocks.get(cacheKey); + if (!lock) { + const epoch = this.catalogEpoch.get(cacheKey) ?? 0; + lock = this._fetchAndStoreCache(cacheKey, cfg, database, epoch); + this.fetchLocks.set(cacheKey, lock); + } + + try { + await lock; + } finally { + if (this.fetchLocks.get(cacheKey) === lock) { + this.fetchLocks.delete(cacheKey); } + } + } - // Get current line and word being typed - const lineText = document.lineAt(position).text; - const wordRange = document.getWordRangeAtPosition(position); - const currentWord = wordRange ? document.getText(wordRange) : ''; + private async _fetchAndStoreCache( + cacheKey: string, + cfg: { id: string; host: string; port: number; username: string; name: string }, + database: string, + epochAtStart: number + ): Promise { + let client; + try { + client = await ConnectionManager.getInstance().getPooledClient({ + id: cfg.id, + host: cfg.host, + port: cfg.port, + username: cfg.username, + database, + name: cfg.name + }); - // Parse query to find referenced tables - const fullText = document.getText(); - const referencedTables = this._extractTableNames(fullText); + const pgVer = await queryServerVersionNum(client); + const objectsResult = await client.query(SqlCompletionProvider.buildCatalogObjectsSql(pgVer)); + const objects = this._dedupeTables( + objectsResult.rows.map( + (row: { + schema: string; + object_name: string; + object_type: string; + arguments?: string; + call_arguments?: string; + is_populated?: boolean | null; + }) => ({ + schema: row.schema, + objectName: row.object_name, + objectType: row.object_type, + arguments: row.arguments, + callArguments: row.call_arguments, + isPopulated: row.is_populated ?? undefined + }) + ) + ); - // Add SQL keywords - completionItems.push(...this._getSqlKeywords()); + const columnsResult = await client.query(SqlCompletionProvider.CATALOG_COLUMNS_SQL); + const columns = this._dedupeColumns( + columnsResult.rows.map( + (row: { + schema: string; + table_name: string; + column_name: string; + data_type: string; + udt_schema?: string; + udt_name?: string; + }) => ({ + schema: row.schema, + tableName: row.table_name, + columnName: row.column_name, + dataType: row.data_type, + udtSchema: row.udt_schema, + udtName: row.udt_name + }) + ) + ); - // Add table suggestions with high priority - const tables = this.tableCache.get(cacheKey) || []; - completionItems.push(...this._getTableCompletions(tables, referencedTables)); + const fkResult = await client.query(SqlCompletionProvider.CATALOG_FK_SQL); + const foreignKeys: ForeignKeyInfo[] = fkResult.rows.map( + (row: { schema: string; table_name: string; columns: string[]; ref_schema: string; ref_table: string; ref_columns: string[] }) => ({ + schema: row.schema, + tableName: row.table_name, + columns: row.columns || [], + referencedSchema: row.ref_schema, + referencedTable: row.ref_table, + referencedColumns: row.ref_columns || [] + }) + ); - // Add column suggestions based on context - const columns = this.columnCache.get(cacheKey) || []; - completionItems.push(...this._getColumnCompletions(columns, referencedTables, lineText)); + const searchPathResult = await client.query('SHOW search_path'); + const searchPath = this._parseSearchPath(searchPathResult.rows[0]?.search_path || '', cfg.username); + const compositeResult = await client.query(SqlCompletionProvider.CATALOG_COMPOSITE_SQL); + const compositeAttrs = new Map(); + for (const row of compositeResult.rows as { schema: string; type_name: string; attrs: string[] }[]) { + const key = `${row.schema}.${row.type_name}`.toLowerCase(); + compositeAttrs.set(key, row.attrs || []); + } + + const rolesResult = await client.query(`SELECT rolname FROM pg_roles ORDER BY rolname`); + const roles = (rolesResult.rows as { rolname: string }[]).map(r => r.rolname); + + if ((this.catalogEpoch.get(cacheKey) ?? 0) !== epochAtStart) { + return; + } + + this.schemaCache.set(cacheKey, { + objects, + columns, + foreignKeys, + searchPath, + compositeAttrs, + roles, + updatedAt: Date.now() + }); } catch (error) { - console.error('SQL completion error:', error); + outputChannel?.appendLine(`[SqlCompletionProvider] catalog fetch failed: ${error}`); + } finally { + if (client) { + client.release(); + } } + } - return completionItems; + private _parseSearchPath(raw: string, username: string): string[] { + return raw + .split(',') + .map(segment => segment.trim()) + .map(segment => segment.replace(/^"|"$/g, '')) + .map(segment => (segment === '$user' ? username : segment)) + .filter(Boolean) + .map(s => s.toLowerCase()); } - private async _getConnectionInfo(document: vscode.TextDocument): Promise<{ connectionId: string; database: string } | null> { - // For notebooks, get from metadata - if (document.uri.scheme === 'vscode-notebook-cell') { - const notebook = vscode.workspace.notebookDocuments.find(nb => - nb.getCells().some(cell => cell.document.uri.toString() === document.uri.toString()) - ); + // =========================================================================== + // Single parse pass + // =========================================================================== + + private _parseQuery(document: vscode.TextDocument, position: vscode.Position): ParsedQuery { + const textBeforeCursor = this._getTextBeforeCursor(document, position); + const cleanText = SqlParser.stripCommentsAndStrings(textBeforeCursor); + + const fullRaw = SqlCompletionProvider.sqlFullNotebookSqlRaw(document); + const fullClean = SqlParser.stripCommentsAndStrings(fullRaw); + const cursorIdx = Math.min(cleanText.length, fullClean.length); + + const { stmt: activeStmt, cursorInStmt } = this._activeStatementSliceForCursor(fullClean, cursorIdx); + + const tailTrim = cleanText.trimEnd(); + const strictDot = /(\"[^\"]*\"|[a-z_][a-z0-9_]*)\.([a-z_][a-z0-9_]*)?$/i.exec(tailTrim); + const hasQualifiedPrefix = strictDot !== null; + const dotQualifier = strictDot ? SqlParser.normalizeIdentifier(strictDot[1]) : null; + const dotPartial = strictDot && strictDot[2] ? strictDot[2].toLowerCase() : null; + + /** CTEs: full notebook buffer. Relations / derived tables / clause: full active statement (includes FROM after cursor). */ + const { columns: cteColumns, bodies: cteBodies } = this._parseCtes(fullClean); + const { relations, aliasMap, qualifiedMap, referencedTables } = this._extractAllRelations(activeStmt); + const { columns: derivedColumns, bodies: derivedBodies } = this._extractDerivedSubqueries(activeStmt); + + const cursorBounded = Math.max(0, Math.min(cursorInStmt, activeStmt.length)); + let clause = this._detectClauseAtCursor(activeStmt, cursorBounded); + clause = this._refineClause(activeStmt, clause); - if (notebook?.metadata) { - const metadata = notebook.metadata; - return { - connectionId: metadata.connectionId, - database: metadata.databaseName || 'postgres' - }; + const insertTarget = this._extractInsertTarget(activeStmt, aliasMap, qualifiedMap); + const updateTarget = this._extractUpdateTarget(activeStmt, aliasMap, qualifiedMap); + + return { + cleanText, + clause, + relations, + aliasMap, + qualifiedMap, + referencedTables, + cteColumns, + cteBodies, + derivedColumns, + derivedBodies, + dotQualifier, + hasQualifiedPrefix, + dotPartial, + insertTarget, + updateTarget, + precedingWhereColumnType: null + }; + } + + private _refineClause(stmt: string, clause: SqlClause): SqlClause { + if (clause === SqlClause.InsertColumns) { + return clause; + } + if (this._detectExplainOptions(stmt)) { + return SqlClause.ExplainOptions; + } + if (this._detectCopyWithOptions(stmt)) { + return SqlClause.CopyOptions; + } + if (this._detectGrantOnContext(stmt)) { + return SqlClause.GrantOn; + } + if (this._detectGrantToContext(stmt)) { + return SqlClause.GrantTo; + } + if (this._detectCreateTableColumn(stmt)) { + return SqlClause.CreateTableColumn; + } + if (this._detectAlterTableOp(stmt)) { + return SqlClause.AlterTableOp; + } + if (this._isInsertTargetOnly(stmt)) { + return SqlClause.InsertTarget; + } + return clause; + } + + private _isInsertTargetOnly(stmt: string): boolean { + const openCols = /\binsert\s+into\s+(?:"[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)\s*\(/i.exec(stmt); + if (openCols) { + return false; + } + let last = -1; + const re = /\binsert\s+into\s+/gi; + let mm: RegExpExecArray | null; + while ((mm = re.exec(stmt)) !== null) { + last = mm.index; + } + if (last < 0) { + return false; + } + const tail = stmt.slice(last).replace(/\binsert\s+into\s+/i, '').trimStart(); + if (tail.startsWith('(')) { + return false; + } + if (/^(select|with)\b/i.test(tail)) { + return false; + } + if (/^values\b/i.test(tail)) { + return false; + } + return true; + } + + private _detectExplainOptions(stmt: string): boolean { + const m = /\bexplain\s+/i.exec(stmt); + if (!m) { + return false; + } + let pos = m.index + m[0].length; + while (pos < stmt.length && /\s/.test(stmt[pos])) { + pos++; + } + if (/^analyze\b/i.test(stmt.slice(pos))) { + pos += stmt.slice(pos).match(/^analyze\b/i)![0].length; + while (pos < stmt.length && /\s/.test(stmt[pos])) { + pos++; } } + if (stmt[pos] !== '(') { + return false; + } + let d = 0; + for (let i = pos; i < stmt.length; i++) { + const ch = stmt[i]; + if (ch === '(') { + d++; + } else if (ch === ')') { + d--; + if (d === 0) { + return false; + } + } + } + return d > 0; + } + + private _detectCopyWithOptions(stmt: string): boolean { + const m = /\bwith\s*\(/i.exec(stmt); + if (!m || !/\bcopy\b/i.test(stmt)) { + return false; + } + const openIdx = m.index + m[0].length - 1; + let d = 0; + for (let i = openIdx; i < stmt.length; i++) { + const ch = stmt[i]; + if (ch === '(') { + d++; + } else if (ch === ')') { + d--; + if (d === 0) { + return false; + } + } + } + return d > 0; + } + + private _detectGrantOnContext(stmt: string): boolean { + const grant = /\bgrant\b/i.exec(stmt); + if (!grant) { + return false; + } + const tail = stmt.slice(grant.index).trimEnd(); + return /\bon\s+$/i.test(tail); + } + + private _detectGrantToContext(stmt: string): boolean { + const grant = /\bgrant\b/i.exec(stmt); + if (!grant) { + return false; + } + const tail = stmt.slice(grant.index).trimEnd(); + return /\bto\s+$/i.test(tail); + } - // For regular files, try to get from workspace state or recent connection - // This is a fallback - ideally user should use notebooks for better context + private _detectCreateTableColumn(stmt: string): boolean { + const m = /\bcreate\s+table\b/i.exec(stmt); + if (!m) { + return false; + } + let pos = m.index + m[0].length; + while (pos < stmt.length && /\s/.test(stmt[pos])) { + pos++; + } + if (/^if\s+not\s+exists\s+/i.test(stmt.slice(pos))) { + pos += stmt.slice(pos).match(/^if\s+not\s+exists\s+/i)![0].length; + while (pos < stmt.length && /\s/.test(stmt[pos])) { + pos++; + } + } + while (pos < stmt.length && stmt[pos] !== '(') { + pos++; + } + if (stmt[pos] !== '(') { + return false; + } + let d = 0; + for (let i = pos; i < stmt.length; i++) { + const ch = stmt[i]; + if (ch === '(') { + d++; + } else if (ch === ')') { + d--; + if (d === 0) { + return false; + } + } + } + return d > 0; + } + + private _detectAlterTableOp(stmt: string): boolean { + const m = /\balter\s+table\b/i.exec(stmt); + if (!m) { + return false; + } + const tail = stmt.slice(m.index + m[0].length).trimStart(); + const rest = tail.replace(/^("[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)\s+/i, '').trimStart(); + return rest.length === 0 || !/^(add|drop|rename|alter|enable|disable|attach|detach)\b/i.test(rest); + } + + private _resolvePrecedingColumnDataType(cleanText: string, parsed: Pick, cols: ColumnInfo[]): string | null { + const tail = cleanText.trimEnd(); + const dotForm = tail.match(/(?:^|[^\w.])(["\w][\w"]*)\.(["\w][\w"]*)\s*$/); + if (dotForm) { + const alias = SqlParser.normalizeIdentifier(dotForm[1]); + const colName = SqlParser.normalizeIdentifier(dotForm[2]); + const rel = parsed.aliasMap.get(alias); + if (rel) { + const hit = cols.find( + c => + c.tableName.toLowerCase() === rel.objectName.toLowerCase() && + (!rel.schema || c.schema.toLowerCase() === rel.schema.toLowerCase()) && + c.columnName.toLowerCase() === colName.toLowerCase() + ); + return hit?.dataType ?? null; + } + } + const bare = tail.match(/(?:^|[^\w.])(["\w][\w"]*)\s*$/); + if (bare) { + const name = SqlParser.normalizeIdentifier(bare[1]); + for (const rel of parsed.relations) { + const hit = cols.find( + c => + c.tableName.toLowerCase() === rel.objectName.toLowerCase() && + (!rel.schema || c.schema.toLowerCase() === rel.schema.toLowerCase()) && + c.columnName.toLowerCase() === name.toLowerCase() + ); + if (hit) { + return hit.dataType; + } + } + } return null; } - private _shouldUpdateCache(cacheKey: string): boolean { - const lastUpdate = this.lastCacheUpdate.get(cacheKey); - if (!lastUpdate) { - return true; + private _enrichWildcardColumns(parsed: ParsedQuery, cacheColumns: ColumnInfo[]): void { + for (const [name, body] of parsed.cteBodies) { + let cols = parsed.cteColumns.get(name) ?? []; + cols = this._expandStarMarkers(cols, body, cacheColumns); + if (cols.length > 0) { + parsed.cteColumns.set(name, cols); + } + } + for (const [alias, cols] of parsed.derivedColumns) { + const innerBody = parsed.derivedBodies.get(alias); + const expanded = innerBody ? this._expandStarMarkers(cols, innerBody, cacheColumns) : cols.filter(c => c !== '*'); + if (expanded.length > 0) { + parsed.derivedColumns.set(alias, expanded); + } } - return Date.now() - lastUpdate > this.CACHE_TTL; } - private async _updateCache(connectionId: string, database: string, cacheKey: string): Promise { - try { - const config = vscode.workspace.getConfiguration(); - const connections = config.get('postgresExplorer.connections') || []; - const connection = connections.find(c => c.id === connectionId); + private _expandStarMarkers(markers: string[], body: string, cacheColumns: ColumnInfo[]): string[] { + if (!markers.some(m => m === '*' || m.endsWith('.*'))) { + return markers.filter(m => m !== '*'); + } + const fromIdx = this._findTopLevelFromIndex(body); + if (fromIdx < 0) { + return markers.filter(x => x !== '*' && !x.endsWith('.*')); + } + const fromRest = body.slice(fromIdx); + const { relations } = this._extractAllRelations(fromRest); + const tableCols = (rel: RelationContext) => + cacheColumns + .filter( + c => + c.tableName.toLowerCase() === rel.objectName.toLowerCase() && + (!rel.schema || c.schema.toLowerCase() === rel.schema.toLowerCase()) + ) + .map(c => c.columnName); - if (!connection) { - return; + const out: string[] = []; + for (const m of markers) { + if (m === '*') { + for (const r of relations) { + out.push(...tableCols(r)); + } + } else if (m.endsWith('.*')) { + const al = m.slice(0, -2).toLowerCase(); + const rel = relations.find(rr => rr.alias.toLowerCase() === al || rr.objectName.toLowerCase() === al); + if (rel) { + out.push(...tableCols(rel)); + } + } else if (m !== '*') { + out.push(m); } + } + return [...new Set(out)]; + } - let client; - try { - client = await ConnectionManager.getInstance().getPooledClient({ - id: connection.id, - host: connection.host, - port: connection.port, - username: connection.username, - database: database, - name: connection.name - }); + private _findTopLevelFromIndex(sql: string): number { + let depth = 0; + for (let i = 0; i < sql.length; i++) { + const c = sql[i]; + if (c === '(') { + depth++; + } else if (c === ')') { + depth = Math.max(0, depth - 1); + } else if (depth === 0 && /^from\b/i.test(sql.slice(i))) { + return i; + } + } + return -1; + } - // Fetch tables - const tablesQuery = ` - SELECT schemaname as schema, tablename as table_name - FROM pg_tables - WHERE schemaname NOT IN ('pg_catalog', 'information_schema') - ORDER BY schemaname, tablename - `; - const tablesResult = await client.query(tablesQuery); - const tables: TableInfo[] = this._dedupeTables(tablesResult.rows.map(row => ({ - schema: row.schema, - tableName: row.table_name - }))); + private _extractDerivedSubqueries(stmt: string): { columns: Map; bodies: Map } { + const columns = new Map(); + const bodies = new Map(); + const len = stmt.length; + let depth = 0; + let i = 0; + while (i < len) { + const c = stmt[i]; + if (c === '(') { + depth++; + } else if (c === ')') { + depth = Math.max(0, depth - 1); + } else if (depth === 0) { + const slice = stmt.slice(i); + let kwLen = 0; + const fromSub = slice.match(/^from\s+(?:lateral\s+)?\(/i); + const joinSub = slice.match(/^(?:(?:left|right|full\s+outer|inner|cross)\s+)+join\s+(?:lateral\s+)?\(/i); + if (fromSub) { + kwLen = fromSub[0].length; + } else if (joinSub) { + kwLen = joinSub[0].length; + } + if (kwLen > 0) { + const openParen = i + kwLen - 1; + let d = 1; + let j = openParen + 1; + while (j < len && d > 0) { + if (stmt[j] === '(') { + d++; + } else if (stmt[j] === ')') { + d--; + } + j++; + } + const inner = stmt.slice(openParen + 1, j - 1); + if (/^\s*select\b/i.test(inner)) { + const after = stmt.slice(j).trimStart(); + const aliasM = after.match(/^(?:as\s+)?("[^"]+"|[a-z_][a-z0-9_]*)/i); + if (aliasM) { + const aliasTok = SqlParser.normalizeIdentifier(aliasM[1].replace(/^as\s+/i, '')); + bodies.set(aliasTok, inner); + columns.set(aliasTok, this._extractSelectColumnNames(inner)); + } + i = j; + continue; + } + } + } + i++; + } + return { columns, bodies }; + } - // Fetch columns - const columnsQuery = ` - SELECT - table_schema as schema, - table_name, - column_name, - data_type - FROM information_schema.columns - WHERE table_schema NOT IN ('pg_catalog', 'information_schema') - ORDER BY table_schema, table_name, ordinal_position - `; - const columnsResult = await client.query(columnsQuery); - const columns: ColumnInfo[] = this._dedupeColumns(columnsResult.rows.map(row => ({ - schema: row.schema, - tableName: row.table_name, - columnName: row.column_name, - dataType: row.data_type - }))); + /** + * Statement text used for clause + relation extraction. If the cursor sits immediately after `;`, + * use the preceding statement (matches pre-rulebook behavior and typical UX). + */ + private _activeStatementForClause(cleanText: string): string { + const trimmed = cleanText.trimEnd(); + let depth = 0; + const semis: number[] = []; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if (ch === ';' && depth === 0) { + semis.push(i); + } + } + if (semis.length === 0) { + return trimmed; + } + const lastSemi = semis[semis.length - 1]; + const afterLast = trimmed.slice(lastSemi + 1).trimStart(); + if (afterLast.length > 0) { + return afterLast; + } + const prevSemi = semis.length >= 2 ? semis[semis.length - 2] : -1; + return trimmed.slice(prevSemi + 1, lastSemi).trim(); + } - this.tableCache.set(cacheKey, tables); - this.columnCache.set(cacheKey, columns); - this.lastCacheUpdate.set(cacheKey, Date.now()); - } finally { - if (client) client.release(); + /** + * Active SQL statement (depth-0 `;` split) that contains `cursorIdx`, and cursor offset inside that slice. + * Uses length-aligned stripped text (same as prefix strip) so cursor position matches the editor. + */ + private _activeStatementSliceForCursor(trimmedFull: string, cursorIdx: number): { stmt: string; cursorInStmt: number } { + const trimmed = trimmedFull.trimEnd(); + const idx = Math.min(Math.max(0, cursorIdx), trimmed.length); + let depth = 0; + let stmtStart = 0; + for (let i = 0; i < trimmed.length; i++) { + const ch = trimmed[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth = Math.max(0, depth - 1); + } else if (ch === ';' && depth === 0) { + if (idx <= i) { + return { stmt: trimmed.slice(stmtStart, i), cursorInStmt: idx - stmtStart }; + } + stmtStart = i + 1; + } + } + return { stmt: trimmed.slice(stmtStart), cursorInStmt: idx - stmtStart }; + } + + /** + * Like `_detectClause`, but only keywords at or before `cursorInStmt` apply — fixes SELECT-list completion + * when `FROM ...` appears after the cursor (user edits the projection list). + */ + private _detectClauseAtCursor(stmt: string, cursorInStmt: number): SqlClause { + const clauseRegex = + /\(|\)|\b(select|from|delete\s+from|where|using|join|left\s+join|right\s+join|inner\s+join|cross\s+join|full\s+outer\s+join|group\s+by|order\s+by|having|on|insert\s+into|update|set|returning)\b/gi; + + const bound = Math.max(0, Math.min(cursorInStmt, stmt.length)); + let depth = 0; + let clause: SqlClause = SqlClause.Unknown; + let updateSeen = false; + let deleteFromSeen = false; + + let match: RegExpExecArray | null; + clauseRegex.lastIndex = 0; + while ((match = clauseRegex.exec(stmt)) !== null) { + if (match.index > bound) { + break; + } + const token = match[0]; + if (token === '(') { + depth++; + continue; + } + if (token === ')') { + depth = Math.max(0, depth - 1); + continue; + } + if (depth !== 0) { + continue; + } + + const low = token.toLowerCase(); + if (low === 'select') { + clause = SqlClause.Select; + } else if (low === 'delete from') { + clause = SqlClause.DeleteFrom; + deleteFromSeen = true; + } else if (low === 'using' && deleteFromSeen) { + clause = SqlClause.DeleteUsing; + } else if (low === 'from') { + clause = SqlClause.From; + } else if ( + low === 'join' || + low === 'left join' || + low === 'right join' || + low === 'inner join' || + low === 'cross join' || + low === 'full outer join' + ) { + clause = SqlClause.Join; + } else if (low === 'where') { + clause = SqlClause.Where; + } else if (low === 'group by') { + clause = SqlClause.GroupBy; + } else if (low === 'order by') { + clause = SqlClause.OrderBy; + } else if (low === 'having') { + clause = SqlClause.Having; + } else if (low === 'on') { + clause = SqlClause.On; + } else if (low === 'returning') { + clause = SqlClause.Returning; + } else if (low === 'insert into') { + clause = SqlClause.Unknown; + } else if (low === 'update') { + updateSeen = true; + clause = SqlClause.Unknown; + } else if (low === 'set' && updateSeen) { + clause = SqlClause.UpdateSet; + } + } + + const insertCol = /\binsert\s+into\s+(?:"[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)\s*\(/i.exec(stmt); + if (insertCol && insertCol.index !== undefined && insertCol.index < bound) { + const openIdx = insertCol.index + insertCol[0].length - 1; + if (stmt[openIdx] === '(' && bound > openIdx) { + let pd = 0; + for (let i = openIdx; i < bound; i++) { + const ch = stmt[i]; + if (ch === '(') { + pd++; + } else if (ch === ')') { + pd--; + } + } + if (pd > 0) { + return SqlClause.InsertColumns; + } + } + } + + return clause; + } + + /** + * Last clause keyword at paren depth 0; INSERT column list overrides via paren depth. + */ + private _detectClause(stmt: string): SqlClause { + const clauseRegex = + /\(|\)|\b(select|from|delete\s+from|where|using|join|left\s+join|right\s+join|inner\s+join|cross\s+join|full\s+outer\s+join|group\s+by|order\s+by|having|on|insert\s+into|update|set|returning)\b/gi; + + let depth = 0; + let clause: SqlClause = SqlClause.Unknown; + let updateSeen = false; + let deleteFromSeen = false; + + let match: RegExpExecArray | null; + while ((match = clauseRegex.exec(stmt)) !== null) { + const token = match[0]; + if (token === '(') { + depth++; + continue; + } + if (token === ')') { + depth = Math.max(0, depth - 1); + continue; + } + if (depth !== 0) { + continue; + } + + const low = token.toLowerCase(); + if (low === 'select') { + clause = SqlClause.Select; + } else if (low === 'delete from') { + clause = SqlClause.DeleteFrom; + deleteFromSeen = true; + } else if (low === 'using' && deleteFromSeen) { + clause = SqlClause.DeleteUsing; + } else if (low === 'from') { + clause = SqlClause.From; + } else if ( + low === 'join' || + low === 'left join' || + low === 'right join' || + low === 'inner join' || + low === 'cross join' || + low === 'full outer join' + ) { + clause = SqlClause.Join; + } else if (low === 'where') { + clause = SqlClause.Where; + } else if (low === 'group by') { + clause = SqlClause.GroupBy; + } else if (low === 'order by') { + clause = SqlClause.OrderBy; + } else if (low === 'having') { + clause = SqlClause.Having; + } else if (low === 'on') { + clause = SqlClause.On; + } else if (low === 'returning') { + clause = SqlClause.Returning; + } else if (low === 'insert into') { + clause = SqlClause.Unknown; + } else if (low === 'update') { + updateSeen = true; + clause = SqlClause.Unknown; + } else if (low === 'set' && updateSeen) { + clause = SqlClause.UpdateSet; + } + } + + const insertCol = /\binsert\s+into\s+(?:"[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)\s*\(/i.exec(stmt); + if (insertCol && insertCol.index !== undefined) { + const afterParen = stmt.slice(insertCol.index + insertCol[0].length); + let pd = 1; + for (const ch of afterParen) { + if (ch === '(') { + pd++; + } else if (ch === ')') { + pd--; + if (pd === 0) { + break; + } + } + } + if (pd > 0) { + return SqlClause.InsertColumns; } - } catch (error) { - console.error('Cache update error:', error); } + + return clause; } - private _extractTableNames(sqlText: string): Set { - const tables = new Set(); - const text = sqlText.toLowerCase(); + private _extractAllRelations(cleanText: string): { + relations: RelationContext[]; + aliasMap: Map; + qualifiedMap: Map; + referencedTables: Set; + } { + const relations: RelationContext[] = []; + const aliasMap = new Map(); + const qualifiedMap = new Map(); + const referencedTables = new Set(); + + const identifier = '(?:"[^"]+"|[a-z_][a-z0-9_]*)'; + const relationRegex = new RegExp( + `${SqlCompletionProvider.RELATION_LEAD_IN}(${identifier}(?:\\s*\\.\\s*${identifier}){0,2})(?:\\s+(?:as\\s+)?(${identifier}))?`, + 'gi' + ); + + let m: RegExpExecArray | null; + while ((m = relationRegex.exec(cleanText)) !== null) { + let aliasTok = m[2] || null; + if (aliasTok && SQL_RESERVED_ALIAS.has(aliasTok.toLowerCase())) { + aliasTok = null; + } + const rawName = m[1].trim(); + if (rawName.startsWith('(')) { + continue; + } + const parsed = this._parseQualifiedIdentifier(rawName); + const schema = parsed.schema; + const objectName = parsed.name; + if (!objectName || objectName === '(') { + continue; + } + + const aliasNorm = aliasTok ? SqlParser.normalizeIdentifier(aliasTok) : objectName; + const rel: RelationContext = { schema, objectName, alias: aliasNorm }; + + relations.push(rel); + referencedTables.add(objectName); + + aliasMap.set(aliasNorm, rel); + if (aliasNorm !== objectName) { + aliasMap.set(objectName, rel); + } - // Match FROM clause - const fromRegex = /from\s+([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?)/gi; - let match; - while ((match = fromRegex.exec(text)) !== null) { - const tableName = match[1].split('.').pop() || match[1]; - tables.add(tableName); + const qKey = `${schema ?? ''}.${objectName}`; + qualifiedMap.set(qKey, rel); + qualifiedMap.set(objectName, rel); } - // Match JOIN clauses - const joinRegex = /join\s+([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)?)/gi; - while ((match = joinRegex.exec(text)) !== null) { - const tableName = match[1].split('.').pop() || match[1]; - tables.add(tableName); + return { relations, aliasMap, qualifiedMap, referencedTables }; + } + + private _parseQualifiedIdentifier(input: string): { schema: string | null; name: string } { + const parts: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === '.' && !inQuotes) { + parts.push(current.trim()); + current = ''; + continue; + } + current += ch; + } + + if (current.trim()) { + parts.push(current.trim()); } - return tables; + if (parts.length === 0) { + return { schema: null, name: '' }; + } + if (parts.length === 1) { + return { schema: null, name: SqlParser.normalizeIdentifier(parts[0]) }; + } + const schema = SqlParser.normalizeIdentifier(parts[parts.length - 2]); + const name = SqlParser.normalizeIdentifier(parts[parts.length - 1]); + return { schema, name }; } - private _getSqlKeywords(): vscode.CompletionItem[] { - const keywords = [ - 'SELECT', 'FROM', 'WHERE', 'JOIN', 'LEFT JOIN', 'RIGHT JOIN', 'INNER JOIN', 'OUTER JOIN', - 'ON', 'AND', 'OR', 'NOT', 'IN', 'LIKE', 'BETWEEN', 'IS NULL', 'IS NOT NULL', - 'GROUP BY', 'HAVING', 'ORDER BY', 'LIMIT', 'OFFSET', - 'INSERT INTO', 'VALUES', 'UPDATE', 'SET', 'DELETE FROM', - 'CREATE TABLE', 'ALTER TABLE', 'DROP TABLE', - 'AS', 'DISTINCT', 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', - 'CASE', 'WHEN', 'THEN', 'ELSE', 'END' - ]; + private _parseCtes(activeStmt: string): { columns: Map; bodies: Map } { + const columns = new Map(); + const bodies = new Map(); + const s = activeStmt.trimStart(); + if (!/^with\s+/i.test(s)) { + return { columns, bodies }; + } + let pos = s.match(/^with\s+/i)![0].length; + if (/^recursive\s+/i.test(s.slice(pos))) { + pos += s.slice(pos).match(/^recursive\s+/i)![0].length; + } + while (pos < s.length) { + while (pos < s.length && /\s/.test(s[pos])) { + pos++; + } + if (pos >= s.length) { + break; + } + if (s[pos] === ',') { + pos++; + continue; + } + const nameM = s.slice(pos).match(/^("[^"]+"|[a-z_][a-z0-9_]*)\s+as\s*\(/i); + if (!nameM) { + break; + } + const cteName = SqlParser.normalizeIdentifier(nameM[1]); + pos += nameM[0].length; + const bodyStart = pos; + let depth = 1; + while (pos < s.length && depth > 0) { + const ch = s[pos]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } + pos++; + } + const body = s.slice(bodyStart, pos - 1); + bodies.set(cteName, body); + columns.set(cteName, this._extractSelectColumnNames(body)); + while (pos < s.length && /\s/.test(s[pos])) { + pos++; + } + if (pos < s.length && s[pos] === ',') { + pos++; + continue; + } + break; + } + return { columns, bodies }; + } + + private _extractSelectColumnNames(selectBody: string): string[] { + const fromIdx = this._findTopLevelFromIndex(selectBody); + const selectList = fromIdx >= 0 ? selectBody.slice(0, fromIdx) : selectBody; + const cols: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i <= selectList.length; i++) { + const ch = selectList[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth--; + } else if ((ch === ',' || i === selectList.length) && depth === 0) { + const part = selectList.slice(start, i).trim(); + if (/^\s*\*\s*$/.test(part)) { + cols.push('*'); + } else { + const qualStar = /^\s*("[^"]+"|[a-z_][a-z0-9_]*)\s*\.\s*\*\s*$/i.exec(part); + if (qualStar) { + cols.push(`${SqlParser.normalizeIdentifier(qualStar[1])}.*`); + } else { + const aliasMatch = + part.match(/\bas\s+("[^"]+"|[a-z_][a-z0-9_]*)$/i) || + part.match(/("[^"]+"|[a-z_][a-z0-9_]*)$/i); + if (aliasMatch) { + cols.push(SqlParser.normalizeIdentifier(aliasMatch[1])); + } + } + } + start = i + 1; + } + } + return cols; + } + + private _extractInsertTarget( + cleanText: string, + aliasMap: Map, + qualifiedMap: Map + ): RelationContext | null { + const m = cleanText.match( + /\binsert\s+into\s+(?:"[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)/i + ); + if (!m) { + return null; + } + return this._resolveRelationFromText(m[0].replace(/^insert\s+into\s+/i, '').trim(), aliasMap, qualifiedMap); + } + + private _extractUpdateTarget( + cleanText: string, + aliasMap: Map, + qualifiedMap: Map + ): RelationContext | null { + const m = cleanText.match(/\bupdate\s+(?:"[^"]+"|[a-z_][a-z0-9_]*(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_]*))*)/i); + if (!m) { + return null; + } + return this._resolveRelationFromText(m[0].replace(/^update\s+/i, '').trim(), aliasMap, qualifiedMap); + } + + private _resolveRelationFromText( + name: string, + aliasMap: Map, + qualifiedMap: Map + ): RelationContext { + const parsed = this._parseQualifiedIdentifier(name); + const { schema, name: objName } = parsed; + const hit = + qualifiedMap.get(`${schema ?? ''}.${objName}`) || + qualifiedMap.get(objName) || + aliasMap.get(objName); + if (hit) { + return hit; + } + return { schema, objectName: objName, alias: objName }; + } - return keywords.map(keyword => { - const item = new vscode.CompletionItem(keyword, vscode.CompletionItemKind.Keyword); - item.sortText = `3-${keyword}`; // Lower priority than tables and columns + // =========================================================================== + // Completion builder (rule cascade) + // =========================================================================== + + private _typedPrefix(document: vscode.TextDocument, position: vscode.Position): string { + const wordRange = document.getWordRangeAtPosition(position); + return wordRange ? document.getText(wordRange).toLowerCase() : ''; + } + + private _buildCompletions( + parsed: ParsedQuery, + cache: SchemaCache, + document: vscode.TextDocument, + position: vscode.Position + ): vscode.CompletionItem[] { + const typedPrefix = this._typedPrefix(document, position); + const items: vscode.CompletionItem[] = []; + + if (parsed.hasQualifiedPrefix && parsed.dotQualifier) { + return this._qualifiedPrefixCompletions(parsed, cache); + } + + if (parsed.clause === SqlClause.InsertTarget) { + return this._relationObjectItems(cache.objects, cache.searchPath); + } + + if (parsed.clause === SqlClause.InsertColumns && parsed.insertTarget) { + return this._columnItemsOrdinal(cache.columns, parsed.insertTarget, true, typedPrefix); + } + + if (parsed.clause === SqlClause.UpdateSet && parsed.updateTarget) { + items.push(...this._columnItemsOrdinal(cache.columns, parsed.updateTarget, true, typedPrefix)); + items.push(...this._scalarFunctionItems()); + return items; + } + + if (parsed.clause === SqlClause.On) { + return this._onClauseCompletions(parsed, cache, typedPrefix); + } + + if (parsed.clause === SqlClause.DeleteFrom) { + items.push(...this._relationObjectItems(cache.objects, cache.searchPath)); + items.push( + ...this._keywordItems(['USING', 'WHERE', 'RETURNING', 'ORDER BY', 'LIMIT', 'OFFSET']) + ); + return items; + } + + if (parsed.clause === SqlClause.DeleteUsing) { + items.push(...this._relationObjectItems(cache.objects, cache.searchPath)); + items.push(...this._keywordItems(['WHERE', 'RETURNING'])); + return items; + } + + if (parsed.clause === SqlClause.ExplainOptions) { + return EXPLAIN_OPTION_KEYWORDS.map(op => { + const item = new vscode.CompletionItem(op.label, vscode.CompletionItemKind.EnumMember); + item.detail = op.detail; + item.insertText = new vscode.SnippetString(op.snippet); + item.sortText = `0-${op.label}`; + return item; + }); + } + + if (parsed.clause === SqlClause.CopyOptions) { + return COPY_WITH_OPTIONS.map(op => { + const item = new vscode.CompletionItem(op.label, vscode.CompletionItemKind.EnumMember); + item.detail = op.detail; + item.insertText = new vscode.SnippetString(op.snippet); + item.sortText = `0-${op.label}`; + return item; + }); + } + + if (parsed.clause === SqlClause.CreateTableColumn) { + return CREATE_TABLE_SNIPPETS.map(t => { + const item = new vscode.CompletionItem(t.label, vscode.CompletionItemKind.Snippet); + item.detail = t.detail; + item.insertText = new vscode.SnippetString(t.snippet); + item.sortText = `0-${t.label}`; + return item; + }); + } + + if (parsed.clause === SqlClause.AlterTableOp) { + return this._keywordItems(ALTER_TABLE_KEYWORDS); + } + + if (parsed.clause === SqlClause.GrantOn) { + items.push(...this._relationObjectItems(cache.objects, cache.searchPath)); + items.push(...this._keywordItems(['SCHEMA', 'ALL TABLES IN SCHEMA', 'ALL SEQUENCES IN SCHEMA', 'DATABASE'])); + return items; + } + + if (parsed.clause === SqlClause.GrantTo) { + return cache.roles.map(r => { + const item = new vscode.CompletionItem(r, vscode.CompletionItemKind.Unit); + item.detail = 'Role'; + item.insertText = sqlFormatIdentifier(r); + item.filterText = r; + return item; + }); + } + + if (parsed.clause === SqlClause.From || parsed.clause === SqlClause.Join) { + items.push(...this._relationObjectItems(cache.objects, cache.searchPath)); + items.push( + ...this._keywordItems([ + 'JOIN', + 'LEFT JOIN', + 'RIGHT JOIN', + 'INNER JOIN', + 'FULL OUTER JOIN', + 'CROSS JOIN', + 'LATERAL', + 'WHERE', + 'GROUP BY', + 'ORDER BY', + 'LIMIT' + ]) + ); + return items; + } + + if (parsed.clause === SqlClause.Select) { + items.push(...this._contextualColumnItems(parsed, cache.columns, '0', typedPrefix)); + items.push(...this._derivedColumnItems(parsed, typedPrefix)); + items.push(...this._cteColumnItems(parsed, typedPrefix)); + items.push(...this._aggregateFunctionItems()); + items.push(...this._windowFunctionItems()); + items.push(...this._scalarFunctionItems()); + items.push( + ...this._keywordItems([ + 'DISTINCT', + 'FROM', + 'WHERE', + 'AS', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'OVER', + 'PARTITION BY', + 'COALESCE', + 'NULLIF', + 'CAST', + 'EXISTS' + ]) + ); + this._markPreselectForPrefix(items, typedPrefix); + return items; + } + + if (parsed.clause === SqlClause.Where || parsed.clause === SqlClause.Having) { + items.push(...this._contextualColumnItems(parsed, cache.columns, '0', typedPrefix)); + items.push(...this._derivedColumnItems(parsed, typedPrefix)); + items.push(...this._cteColumnItems(parsed, typedPrefix)); + items.push(...this._scalarFunctionItems()); + items.push(...this._filteredWhereOperators(parsed.precedingWhereColumnType)); + if (parsed.precedingWhereColumnType && PG_TYPE_GROUPS.json.test(parsed.precedingWhereColumnType)) { + items.push( + ...[ + { label: '->', snippet: '->${1:key}', detail: 'JSON path' }, + { label: '->>', snippet: "->>'${1:key}'", detail: 'JSON path text' }, + { label: '#>', snippet: "#>'{${1:path}}'", detail: 'JSON path array' }, + { label: '#>>', snippet: "#>>'{${1:path}}'", detail: 'JSON path text' } + ].map(op => { + const item = new vscode.CompletionItem(op.label, vscode.CompletionItemKind.Operator); + item.detail = op.detail; + item.insertText = new vscode.SnippetString(op.snippet); + item.sortText = `5-json-${op.label}`; + return item; + }) + ); + } + items.push( + ...this._keywordItems([ + 'AND', + 'OR', + 'NOT', + 'EXISTS', + 'IN', + 'NOT IN', + 'BETWEEN', + 'IS NULL', + 'IS NOT NULL', + 'ANY', + 'ALL', + 'LIKE', + 'ILIKE', + 'CASE', + 'WHEN' + ]) + ); + this._markPreselectForPrefix(items, typedPrefix); + return items; + } + + if (parsed.clause === SqlClause.GroupBy || parsed.clause === SqlClause.OrderBy) { + items.push(...this._contextualColumnItems(parsed, cache.columns, '0', typedPrefix)); + items.push(...this._derivedColumnItems(parsed, typedPrefix)); + items.push(...this._scalarFunctionItems()); + items.push( + ...GROUP_ORDER_SNIPPETS.map(s => { + const item = new vscode.CompletionItem(s.label, vscode.CompletionItemKind.Snippet); + item.detail = s.detail; + item.insertText = new vscode.SnippetString(s.snippet); + item.sortText = `8-${s.label}`; + return item; + }) + ); + if (parsed.clause === SqlClause.OrderBy) { + items.push(...this._keywordItems(['ASC', 'DESC', 'NULLS FIRST', 'NULLS LAST'])); + } + return items; + } + + if (parsed.clause === SqlClause.Returning) { + const target = parsed.updateTarget || parsed.insertTarget; + if (target) { + items.push(...this._columnItemsOrdinal(cache.columns, target, false, typedPrefix)); + } + items.push(...this._contextualColumnItems(parsed, cache.columns, '0', typedPrefix)); + return items; + } + + items.push(...this._objectItemsAll(cache.objects, cache.searchPath)); + items.push(...this._contextualColumnItems(parsed, cache.columns, '0', typedPrefix)); + items.push( + ...this._keywordItems([ + 'SELECT', + 'INSERT INTO', + 'UPDATE', + 'DELETE FROM', + 'CREATE TABLE', + 'ALTER TABLE', + 'DROP TABLE', + 'WITH', + 'EXPLAIN', + 'EXPLAIN ANALYZE', + 'VACUUM', + 'ANALYZE' + ]) + ); + return items; + } + + private _markPreselectForPrefix(items: vscode.CompletionItem[], typedPrefix: string): void { + if (!typedPrefix) { + return; + } + const hit = items.find( + i => + typeof i.label === 'string' && + i.label.toLowerCase().startsWith(typedPrefix) && + i.kind === vscode.CompletionItemKind.Field + ); + if (hit) { + hit.preselect = true; + } + } + + private _derivedColumnItems(parsed: ParsedQuery, typedPrefix: string): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + for (const [alias, cols] of parsed.derivedColumns) { + const safeAlias = sqlFormatIdentifier(alias); + cols.forEach((col, idx) => { + const safeCol = sqlFormatIdentifier(col); + const item = new vscode.CompletionItem(col, vscode.CompletionItemKind.Field); + item.detail = `Derived (${alias})`; + const prefixMatch = typedPrefix && col.toLowerCase().startsWith(typedPrefix); + item.sortText = `${prefixMatch ? '0a' : '0b'}-dv-${alias}-${String(idx).padStart(4, '0')}`; + item.insertText = `${safeAlias}.${safeCol}`; + item.filterText = `${col} ${alias}.${col}`; + items.push(item); + }); + } + return items; + } + + private _filteredWhereOperators(dataType: string | null): vscode.CompletionItem[] { + if (!dataType) { + return this._whereOperatorItems(); + } + const dt = dataType.toLowerCase(); + const excludedForBool = new Set(['LIKE', 'ILIKE', 'NOT LIKE', '~', '~*', 'BETWEEN']); + const excludedForNumeric = new Set(['LIKE', 'ILIKE', 'NOT LIKE', '~', '~*']); + let ops = WHERE_OPERATORS; + if (PG_TYPE_GROUPS.boolean.test(dt)) { + ops = WHERE_OPERATORS.filter(o => !excludedForBool.has(o.label)); + } else if (PG_TYPE_GROUPS.numeric.test(dt) || PG_TYPE_GROUPS.dateTime.test(dt)) { + ops = WHERE_OPERATORS.filter(o => !excludedForNumeric.has(o.label)); + } else if (PG_TYPE_GROUPS.json.test(dt)) { + ops = WHERE_OPERATORS.filter(o => !['LIKE', 'ILIKE', 'NOT LIKE', '~', '~*'].includes(o.label)); + } + return ops.map(op => { + const item = new vscode.CompletionItem(op.label, vscode.CompletionItemKind.Operator); + item.detail = op.detail; + item.insertText = new vscode.SnippetString(op.snippet); + item.sortText = `5-${op.label}`; return item; }); } - private _getTableCompletions(tables: TableInfo[], referencedTables: Set): vscode.CompletionItem[] { - return tables.map(table => { - const item = new vscode.CompletionItem( - table.tableName, - vscode.CompletionItemKind.Class - ); + private _qualifiedPrefixCompletions(parsed: ParsedQuery, cache: SchemaCache): vscode.CompletionItem[] { + const q = parsed.dotQualifier!; + const partial = parsed.dotPartial; - item.detail = `Table (${table.schema})`; - item.documentation = new vscode.MarkdownString(`**Table:** \`${table.schema}.${table.tableName}\``); + const cteCols = parsed.cteColumns.get(q); + if (cteCols && cteCols.length > 0) { + return cteCols + .filter(col => !partial || col.toLowerCase().startsWith(partial)) + .map(col => { + const safe = sqlFormatIdentifier(col); + const item = new vscode.CompletionItem(col, vscode.CompletionItemKind.Field); + item.detail = `CTE column (${q})`; + item.sortText = `0-${col}`; + item.insertText = safe; + item.filterText = col; + return item; + }); + } - // Higher priority for already referenced tables - if (referencedTables.has(table.tableName.toLowerCase())) { - item.sortText = `0-${table.tableName}`; - } else { - item.sortText = `1-${table.tableName}`; + const derivedCols = parsed.derivedColumns.get(q); + if (derivedCols && derivedCols.length > 0) { + return derivedCols + .filter(col => !partial || col.toLowerCase().startsWith(partial)) + .map(col => { + const safe = sqlFormatIdentifier(col); + const item = new vscode.CompletionItem(col, vscode.CompletionItemKind.Field); + item.detail = `Derived (${q})`; + item.sortText = `0-${col}`; + item.insertText = safe; + item.filterText = col; + return item; + }); + } + + const qLower = q.toLowerCase(); + const qIsCatalogSchema = cache.objects.some(o => o.schema.toLowerCase() === qLower); + const searchPathSet = new Set(cache.searchPath.map(s => s.toLowerCase())); + + const rel = parsed.aliasMap.get(q); + if (rel) { + const bareSelfAlias = rel.alias === rel.objectName && rel.schema === null; + if (bareSelfAlias && qIsCatalogSchema) { + const tableNamedQOnPath = cache.objects.some( + o => + o.objectName.toLowerCase() === qLower && + RELATION_OBJECT_TYPES.has(o.objectType) && + searchPathSet.has(o.schema.toLowerCase()) + ); + if (!tableNamedQOnPath) { + return this._objectItemsInSchema( + cache.objects.filter(o => o.schema.toLowerCase() === qLower), + true + ); + } } + return this._columnItemsForRelationBare(cache.columns, rel, partial); + } + + const compositeFields = this._compositeAttrsForColumnNamedQualifier(q, parsed, cache, partial); + if (compositeFields.length > 0) { + return compositeFields; + } + + const schemaHits = cache.objects.filter(o => o.schema.toLowerCase() === qLower); + if (schemaHits.length > 0) { + return this._objectItemsInSchema(schemaHits, true); + } + + return []; + } - // Add schema prefix as insert text if needed - item.insertText = table.tableName; - item.filterText = `${table.schema}.${table.tableName} ${table.tableName}`; + /** When `q` names a column on a referenced table and its udt is composite, suggest attributes. */ + private _compositeAttrsForColumnNamedQualifier( + q: string, + parsed: ParsedQuery, + cache: SchemaCache, + partial: string | null + ): vscode.CompletionItem[] { + const qn = q.toLowerCase(); + for (const rel of parsed.relations) { + const match = cache.columns.find( + c => + c.tableName.toLowerCase() === rel.objectName.toLowerCase() && + (!rel.schema || c.schema.toLowerCase() === rel.schema.toLowerCase()) && + c.columnName.toLowerCase() === qn + ); + if (!match?.udtSchema || !match.udtName) { + continue; + } + const key = `${match.udtSchema}.${match.udtName}`.toLowerCase(); + const attrs = cache.compositeAttrs.get(key); + if (!attrs?.length) { + continue; + } + return attrs + .filter(a => !partial || a.toLowerCase().startsWith(partial)) + .map((a, idx) => { + const item = new vscode.CompletionItem(a, vscode.CompletionItemKind.Field); + item.detail = `Composite ${match.udtName}`; + item.sortText = `0c-${String(idx).padStart(4, '0')}`; + item.insertText = sqlFormatIdentifier(a); + item.filterText = a; + return item; + }); + } + return []; + } + private _columnItemsForRelationBare(columns: ColumnInfo[], rel: RelationContext, dotPartial: string | null): vscode.CompletionItem[] { + const cols = columns.filter( + c => + c.tableName.toLowerCase() === rel.objectName && + (!rel.schema || c.schema.toLowerCase() === rel.schema) && + (!dotPartial || c.columnName.toLowerCase().startsWith(dotPartial)) + ); + return cols.map((col, idx) => { + const safeName = sqlFormatIdentifier(col.columnName); + const item = new vscode.CompletionItem(col.columnName, vscode.CompletionItemKind.Field); + item.detail = `${col.dataType} · ${col.schema}.${col.tableName}`; + const prefixMatch = dotPartial && col.columnName.toLowerCase().startsWith(dotPartial); + item.sortText = `${prefixMatch ? '0a' : '0b'}-${String(idx).padStart(4, '0')}`; + item.insertText = safeName; + item.filterText = col.columnName; return item; }); } - private _getColumnCompletions( + private _columnItemsOrdinal( columns: ColumnInfo[], - referencedTables: Set, - lineText: string + rel: RelationContext, + bare: boolean, + typedPrefix: string ): vscode.CompletionItem[] { - const completions: vscode.CompletionItem[] = []; - - // Filter columns by referenced tables - const relevantColumns = columns.filter(col => - referencedTables.has(col.tableName.toLowerCase()) + const cols = columns.filter( + c => + c.tableName.toLowerCase() === rel.objectName && + (!rel.schema || c.schema.toLowerCase() === rel.schema) ); + return cols.map((col, idx) => { + const safeCol = sqlFormatIdentifier(col.columnName); + const safeAlias = sqlFormatIdentifier(rel.alias); + const item = new vscode.CompletionItem(col.columnName, vscode.CompletionItemKind.Field); + item.detail = `${col.dataType} · ${col.schema}.${col.tableName}`; + const prefixMatch = typedPrefix && col.columnName.toLowerCase().startsWith(typedPrefix); + item.sortText = `${prefixMatch ? '0a' : '0b'}-${String(idx).padStart(4, '0')}`; + item.insertText = bare ? safeCol : `${safeAlias}.${safeCol}`; + item.filterText = `${col.columnName} ${rel.alias}.${col.columnName}`; + return item; + }); + } - // Add all columns, but prioritize relevant ones - const allColumns = relevantColumns.length > 0 ? relevantColumns : columns; + private _contextualColumnItems( + parsed: ParsedQuery, + allColumns: ColumnInfo[], + sortPrefix: string, + typedPrefix: string + ): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + const seen = new Set(); - for (const column of allColumns) { - const item = new vscode.CompletionItem( - column.columnName, - vscode.CompletionItemKind.Field + parsed.relations.forEach((rel, relIdx) => { + const cols = allColumns.filter( + c => + c.tableName.toLowerCase() === rel.objectName && + (!rel.schema || c.schema.toLowerCase() === rel.schema) ); - item.detail = `${column.dataType} (${column.schema}.${column.tableName})`; - item.documentation = new vscode.MarkdownString( - `**Column:** \`${column.columnName}\`\n\n` + - `**Type:** \`${column.dataType}\`\n\n` + - `**Table:** \`${column.schema}.${column.tableName}\`` - ); + cols.forEach((col, colIdx) => { + const key = `${rel.objectName}.${col.columnName}`; + if (seen.has(key)) { + return; + } + seen.add(key); + + const safeCol = sqlFormatIdentifier(col.columnName); + const safeAlias = sqlFormatIdentifier(rel.alias); + const item = new vscode.CompletionItem(col.columnName, vscode.CompletionItemKind.Field); + item.detail = `${col.dataType} · ${rel.alias} (${rel.objectName})`; + const prefixMatch = typedPrefix && col.columnName.toLowerCase().startsWith(typedPrefix); + item.sortText = `${sortPrefix}-${prefixMatch ? 'a' : 'b'}-${String(relIdx).padStart(2, '0')}-${String(colIdx).padStart(4, '0')}`; + item.insertText = `${safeAlias}.${safeCol}`; + item.filterText = `${col.columnName} ${rel.alias}.${col.columnName}`; + items.push(item); + }); + }); + + return items; + } + + private _cteColumnItems(parsed: ParsedQuery, typedPrefix: string): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + for (const [, cols] of parsed.cteColumns) { + cols.forEach((col, idx) => { + const safe = sqlFormatIdentifier(col); + const item = new vscode.CompletionItem(col, vscode.CompletionItemKind.Field); + item.detail = 'CTE column'; + const prefixMatch = typedPrefix && col.toLowerCase().startsWith(typedPrefix); + item.sortText = `1-cte-${prefixMatch ? 'a' : 'b'}-${String(idx).padStart(4, '0')}`; + item.insertText = safe; + item.filterText = col; + items.push(item); + }); + } + return items; + } + + private _onClauseCompletions(parsed: ParsedQuery, cache: SchemaCache, typedPrefix: string): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + if (parsed.relations.length < 2) { + items.push(...this._derivedColumnItems(parsed, typedPrefix)); + items.push(...this._contextualColumnItems(parsed, cache.columns, '2', typedPrefix)); + return items; + } + + const right = parsed.relations[parsed.relations.length - 1]; + const priors = parsed.relations.slice(0, -1); + + const fkSeen = new Set(); + const fkItems: vscode.CompletionItem[] = []; + for (const left of priors) { + for (const fk of this._fkJoinSuggestions(left, right, cache.foreignKeys)) { + if (!fkSeen.has(fk.label as string)) { + fkSeen.add(fk.label as string); + fkItems.push(fk); + } + } + } + items.push(...fkItems); + + if (fkItems.length === 0) { + for (const left of priors) { + items.push(...this._nameMatchJoinSuggestions(left, right, cache.columns)); + } + } + + items.push(...this._derivedColumnItems(parsed, typedPrefix)); + items.push(...this._contextualColumnItems(parsed, cache.columns, '2', typedPrefix)); + return items; + } + + private _fkJoinSuggestions(left: RelationContext, right: RelationContext, fks: ForeignKeyInfo[]): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + + const schemaOk = (tblSchema: string | null, fkSch: string) => + !tblSchema || fkSch.toLowerCase() === tblSchema.toLowerCase(); + + for (const fk of fks) { + const fkTable = fk.tableName.toLowerCase(); + const fkRef = fk.referencedTable.toLowerCase(); + + let fkRel: RelationContext | undefined; + let pkRel: RelationContext | undefined; + + if ( + fkTable === right.objectName && + fkRef === left.objectName && + schemaOk(right.schema, fk.schema) && + schemaOk(left.schema, fk.referencedSchema) + ) { + fkRel = right; + pkRel = left; + } else if ( + fkTable === left.objectName && + fkRef === right.objectName && + schemaOk(left.schema, fk.schema) && + schemaOk(right.schema, fk.referencedSchema) + ) { + fkRel = left; + pkRel = right; + } + + if (!fkRel || !pkRel) { + continue; + } + + const conditions = fk.columns + .map((col, i) => `${pkRel!.alias}.${fk.referencedColumns[i]} = ${fkRel!.alias}.${col}`) + .join(' AND '); + + const item = new vscode.CompletionItem(conditions, vscode.CompletionItemKind.Value); + item.detail = `Foreign key: ${fk.schema}.${fk.tableName} → ${fk.referencedSchema}.${fk.referencedTable}`; + item.insertText = new vscode.SnippetString(conditions); + item.sortText = `0-fk-${fk.tableName}-${fk.columns.join('-')}`; + items.push(item); + } + + return items; + } + + private _nameMatchJoinSuggestions(left: RelationContext, right: RelationContext, allColumns: ColumnInfo[]): vscode.CompletionItem[] { + const items: vscode.CompletionItem[] = []; + const leftCols = allColumns.filter( + c => c.tableName.toLowerCase() === left.objectName && (!left.schema || c.schema.toLowerCase() === left.schema) + ); + const rightCols = allColumns.filter( + c => c.tableName.toLowerCase() === right.objectName && (!right.schema || c.schema.toLowerCase() === right.schema) + ); + const rightByName = new Map(rightCols.map(c => [c.columnName.toLowerCase(), c] as const)); + + for (const lc of leftCols) { + const ln = lc.columnName.toLowerCase(); + const match = + rightByName.get(ln) || + (ln === `id` ? undefined : rightByName.get(`${right.objectName}_id`)) || + (ln.endsWith('_id') ? rightByName.get(ln.replace(/_id$/, '')) : undefined) || + (ln.endsWith('_id') ? rightByName.get('id') : undefined); + + if (!match) { + continue; + } + const snippet = `${left.alias}.${lc.columnName} = ${right.alias}.${match.columnName}`; + const item = new vscode.CompletionItem(snippet, vscode.CompletionItemKind.Value); + item.detail = 'Suggested join condition'; + item.sortText = `1-match-${lc.columnName}`; + item.insertText = new vscode.SnippetString(snippet); + items.push(item); + } + return items; + } - // Highest priority for columns from referenced tables - if (referencedTables.has(column.tableName.toLowerCase())) { - item.sortText = `0-${column.columnName}`; + /** FROM / JOIN: tables, views, matviews, functions, procedures (PostgreSQL allows routines in FROM). */ + private _relationObjectItems(objects: TableInfo[], searchPath: string[]): vscode.CompletionItem[] { + const sp = new Set(searchPath.map(s => s.toLowerCase())); + return objects.map(obj => this._makeObjectItem(obj, sp, false)); + } + + private _objectItemsAll(objects: TableInfo[], searchPath: string[]): vscode.CompletionItem[] { + const sp = new Set(searchPath.map(s => s.toLowerCase())); + return objects.map(obj => this._makeObjectItem(obj, sp, false)); + } + + private _objectItemsInSchema(objects: TableInfo[], schemaAlreadyInEditor: boolean): vscode.CompletionItem[] { + return objects.map(obj => { + const item = new vscode.CompletionItem(obj.objectName, kindForObject(obj.objectType)); + const tl = titleCaseType(obj.objectType); + item.detail = `${tl} · ${obj.schema}`; + if (obj.objectType === 'materialized view' && obj.isPopulated === false) { + item.detail += ' · not populated'; + } + if (obj.arguments) { + item.detail += ` · (${obj.arguments})`; + } + item.documentation = new vscode.MarkdownString(`**${tl}:** \`${obj.schema}.${obj.objectName}\``); + if (obj.arguments) { + item.documentation.appendMarkdown(`\n\n**Signature:** \`${obj.objectName}(${obj.arguments})\``); + } + item.sortText = `0-${obj.objectName}`; + if (obj.objectType === 'function' || obj.objectType === 'procedure') { + item.insertText = this._functionSnippet(obj); } else { - item.sortText = `2-${column.columnName}`; + const safeObj = sqlFormatIdentifier(obj.objectName); + const safeSch = sqlFormatIdentifier(obj.schema); + item.insertText = schemaAlreadyInEditor ? safeObj : `${safeSch}.${safeObj}`; } + item.filterText = `${obj.schema}.${obj.objectName} ${obj.objectName} ${obj.objectType}`; + return item; + }); + } + + private _makeObjectItem(obj: TableInfo, searchPath: Set, schemaQualifiedPrefix: boolean): vscode.CompletionItem { + const inPath = searchPath.has(obj.schema.toLowerCase()); + const item = new vscode.CompletionItem(obj.objectName, kindForObject(obj.objectType)); + const tl = titleCaseType(obj.objectType); + item.detail = `${tl} · ${obj.schema}`; + if (obj.objectType === 'materialized view' && obj.isPopulated === false) { + item.detail += ' · not populated'; + } + if (obj.arguments) { + item.detail += ` · (${obj.arguments})`; + } + item.documentation = new vscode.MarkdownString(`**${tl}:** \`${obj.schema}.${obj.objectName}\``); + if (obj.arguments) { + item.documentation.appendMarkdown(`\n\n**Signature:** \`${obj.objectName}(${obj.arguments})\``); + } + + item.sortText = inPath ? `0-${obj.objectName}` : `1-${obj.schema}-${obj.objectName}`; + + if (obj.objectType === 'function' || obj.objectType === 'procedure') { + item.insertText = this._functionSnippet(obj); + } else if (schemaQualifiedPrefix || inPath) { + item.insertText = sqlFormatIdentifier(obj.objectName); + } else { + item.insertText = `${sqlFormatIdentifier(obj.schema)}.${sqlFormatIdentifier(obj.objectName)}`; + } + + item.filterText = `${obj.schema}.${obj.objectName} ${obj.objectName} ${obj.objectType}`; + return item; + } + + private _functionSnippet(obj: TableInfo): vscode.SnippetString { + const names = this._extractArgumentNames(obj.callArguments || ''); + const fn = sqlFormatIdentifier(obj.objectName); + return new vscode.SnippetString( + names.length > 0 ? `${fn}(${names.map((a, i) => `\${${i + 1}:${a}}`).join(', ')})` : `${fn}()` + ); + } + + private _keywordItems(keywords: string[]): vscode.CompletionItem[] { + return keywords.map(kw => { + const item = new vscode.CompletionItem(kw, vscode.CompletionItemKind.Keyword); + item.sortText = `9-${kw}`; + return item; + }); + } + + private _aggregateFunctionItems(): vscode.CompletionItem[] { + return AGGREGATE_FUNCTIONS.map(fn => { + const item = new vscode.CompletionItem(fn.label, vscode.CompletionItemKind.Function); + item.detail = fn.detail; + item.insertText = new vscode.SnippetString(fn.snippet); + item.sortText = `2-agg-${fn.label}`; + return item; + }); + } - completions.push(item); + private _windowFunctionItems(): vscode.CompletionItem[] { + return WINDOW_FUNCTIONS.map(fn => { + const item = new vscode.CompletionItem(fn.label, vscode.CompletionItemKind.Function); + item.detail = fn.detail; + item.insertText = new vscode.SnippetString(fn.snippet); + item.sortText = `3-win-${fn.label}`; + return item; + }); + } + + private _scalarFunctionItems(): vscode.CompletionItem[] { + return SCALAR_FUNCTIONS.map(fn => { + const item = new vscode.CompletionItem(fn.label, vscode.CompletionItemKind.Function); + item.detail = fn.detail; + item.insertText = new vscode.SnippetString(fn.snippet); + item.sortText = `4-fn-${fn.label}`; + return item; + }); + } + + private _whereOperatorItems(): vscode.CompletionItem[] { + return WHERE_OPERATORS.map(op => { + const item = new vscode.CompletionItem(op.label, vscode.CompletionItemKind.Operator); + item.detail = op.detail; + item.insertText = new vscode.SnippetString(op.snippet); + item.sortText = `5-${op.label}`; + return item; + }); + } + + // =========================================================================== + // Connection / document helpers + // =========================================================================== + + private async _getNotebookConnection(document: vscode.TextDocument): Promise<{ connectionId: string; database: string } | null> { + if (document.uri.scheme !== 'vscode-notebook-cell') { + return null; + } + const notebook = vscode.workspace.notebookDocuments.find(nb => + nb.getCells().some(cell => cell.document.uri.toString() === document.uri.toString()) + ); + if (!notebook?.metadata?.connectionId) { + return null; } + const metadata = notebook.metadata as { connectionId: string; databaseName?: string }; + return { + connectionId: metadata.connectionId, + database: metadata.databaseName || 'postgres' + }; + } + + private async _resolveConnectionConfig(connectionId: string): Promise<{ + id: string; + host: string; + port: number; + username: string; + name: string; + } | null> { + const connections = + (vscode.workspace.getConfiguration().get>( + 'postgresExplorer.connections' + )) || []; + return connections.find(c => c.id === connectionId) ?? null; + } + + private _getTextBeforeCursor(document: vscode.TextDocument, position: vscode.Position): string { + return SqlCompletionProvider.sqlTextBeforeCursor(document, position); + } - return completions; + private _extractArgumentNames(argumentsText: string): string[] { + if (!argumentsText.trim()) { + return []; + } + const modes = new Set(['in', 'out', 'inout', 'variadic', 'table']); + return argumentsText.split(',').map((part, idx) => { + const withoutDefault = part.replace(/\s+default\s+.+$/i, '').trim(); + const tokens = withoutDefault.split(/\s+/).filter(Boolean); + const first = tokens[0]?.toLowerCase(); + const candidate = modes.has(first || '') ? tokens[1] : tokens[0]; + return candidate || `arg${idx + 1}`; + }); } private _dedupeTables(tables: TableInfo[]): TableInfo[] { const seen = new Set(); - return tables.filter(table => { - const key = `${table.schema}.${table.tableName}`; + const key = `${table.schema}.${table.objectName}`; if (seen.has(key)) { return false; } - seen.add(key); return true; }); @@ -284,15 +2396,25 @@ export class SqlCompletionProvider implements vscode.CompletionItemProvider { 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; }); } } + +function kindForObject(objectType: string): vscode.CompletionItemKind { + return objectType === 'function' || objectType === 'procedure' + ? vscode.CompletionItemKind.Function + : vscode.CompletionItemKind.Class; +} + +function titleCaseType(objectType: string): string { + return objectType === 'materialized view' + ? 'Materialized View' + : objectType.replace(/\b\w/g, ch => ch.toUpperCase()); +} diff --git a/src/providers/SqlSignatureHelpProvider.ts b/src/providers/SqlSignatureHelpProvider.ts new file mode 100644 index 0000000..80e2851 --- /dev/null +++ b/src/providers/SqlSignatureHelpProvider.ts @@ -0,0 +1,141 @@ +import * as vscode from 'vscode'; + +import { SqlCompletionProvider } from './SqlCompletionProvider'; +import { SqlParser } from './kernel/SqlParser'; + +/** + * Parameter hints for function calls in SQL notebook cells (uses same schema cache as completions). + */ +export class SqlSignatureHelpProvider implements vscode.SignatureHelpProvider { + provideSignatureHelp( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken, + _context: vscode.SignatureHelpContext + ): vscode.ProviderResult { + if (document.uri.scheme !== 'vscode-notebook-cell' || document.languageId !== 'sql') { + return undefined; + } + const completion = SqlCompletionProvider.getInstance(); + if (!completion) { + return undefined; + } + + return (async () => { + const cache = await completion.ensureSchemaForNotebook(document); + if (!cache) { + return undefined; + } + + const text = SqlCompletionProvider.sqlTextBeforeCursor(document, position); + const openIdx = SqlSignatureHelpProvider._callOpenParenIndex(text); + if (openIdx < 0) { + return undefined; + } + + const beforeOpen = SqlParser.stripCommentsAndStrings(text.slice(0, openIdx)).trimEnd(); + const fnMatch = beforeOpen.match(/(?:^|[^\w.])(["\w][\w"]*)(?:\s*\.\s*(["\w][\w"]*))?\s*$/); + if (!fnMatch) { + return undefined; + } + let schema: string | null = null; + let fnName: string; + if (fnMatch[2]) { + schema = SqlParser.normalizeIdentifier(fnMatch[1]); + fnName = SqlParser.normalizeIdentifier(fnMatch[2]); + } else { + fnName = SqlParser.normalizeIdentifier(fnMatch[1]); + } + + const objs = cache.objects.filter( + o => + (o.objectType === 'function' || o.objectType === 'procedure') && + o.objectName.toLowerCase() === fnName.toLowerCase() && + (!schema || o.schema.toLowerCase() === schema.toLowerCase()) + ); + if (objs.length === 0) { + return undefined; + } + + const obj = objs[0]; + const argsText = obj.arguments || ''; + const paramLabels = SqlSignatureHelpProvider._splitTopLevelArgs(argsText); + const sigLabel = `${obj.objectName}(${argsText})`; + const sig = new vscode.SignatureInformation( + sigLabel, + new vscode.MarkdownString(obj.objectType === 'procedure' ? '*procedure*' : '*function*') + ); + sig.parameters = paramLabels.map(p => new vscode.ParameterInformation(p)); + + const commaIdx = SqlSignatureHelpProvider._commaIndexAtCursor(text, openIdx); + const help = new vscode.SignatureHelp(); + help.signatures = [sig]; + help.activeSignature = 0; + help.activeParameter = + sig.parameters.length === 0 ? 0 : Math.min(commaIdx, Math.max(0, sig.parameters.length - 1)); + + return help; + })(); + } + + private static _callOpenParenIndex(textBeforeCursor: string): number { + let depth = 0; + for (let i = textBeforeCursor.length - 1; i >= 0; i--) { + const ch = textBeforeCursor[i]; + if (ch === ')') { + depth++; + } else if (ch === '(') { + if (depth === 0) { + return i; + } + depth--; + } + } + return -1; + } + + private static _commaIndexAtCursor(textBeforeCursor: string, openIdx: number): number { + const inner = textBeforeCursor.slice(openIdx + 1); + let d = 0; + let commas = 0; + for (let j = 0; j < inner.length; j++) { + const c = inner[j]; + if (c === '(') { + d++; + } else if (c === ')') { + if (d === 0) { + break; + } + d--; + } else if (c === ',' && d === 0) { + commas++; + } + } + return commas; + } + + /** Split `pg_get_function_arguments` style argument list on top-level commas only. */ + private static _splitTopLevelArgs(args: string): string[] { + if (!args.trim()) { + return []; + } + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i <= args.length; i++) { + const ch = args[i]; + if (ch === '(') { + depth++; + } else if (ch === ')') { + depth = Math.max(0, depth - 1); + } else if ((ch === ',' && depth === 0) || i === args.length) { + const chunk = args.slice(start, i).trim(); + if (chunk) { + parts.push(chunk); + } + start = i + 1; + } + } + return parts; + } +} diff --git a/src/providers/chat/AiService.ts b/src/providers/chat/AiService.ts index 3c61914..1104423 100644 --- a/src/providers/chat/AiService.ts +++ b/src/providers/chat/AiService.ts @@ -14,6 +14,7 @@ const GITHUB_MODELS_SCOPES: string[] = []; const GITHUB_MODELS_API_BASE = 'https://models.github.ai'; const GITHUB_MODELS_API_VERSION = '2026-03-10'; const DEFAULT_GITHUB_MODEL = 'openai/gpt-4.1'; +const DEFAULT_CURSOR_MODEL = 'auto'; const DIRECT_API_PROVIDERS = new Set([ 'openai', 'anthropic', @@ -81,6 +82,18 @@ export class AiService { this._connectionContext = ctx; } + async callProvider(provider: string, userMessage: string, config: vscode.WorkspaceConfiguration, customSystemPrompt?: string): Promise<{ text: string, usage?: string }> { + if (provider === 'vscode-lm') { + return await this.callVsCodeLm(userMessage, config, customSystemPrompt); + } + + if (provider === 'cursor') { + return await this.callCursorAgent(userMessage, config, customSystemPrompt); + } + + return await this.callDirectApi(provider, userMessage, config, customSystemPrompt); + } + buildSystemPrompt(): string { const ctx = this._connectionContext; const isProduction = ctx?.environment === 'production'; @@ -440,6 +453,139 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; } } + private async _loadCursorSdk(): Promise { + try { + return await import('@cursor/sdk'); + } catch { + throw new Error('Cursor SDK is not installed. Install @cursor/sdk to use the Cursor provider.'); + } + } + + private async _getCursorApiKey(config: vscode.WorkspaceConfiguration): Promise { + const secretApiKey = await SecretStorageService.getInstance().getCursorApiKey(); + return secretApiKey || process.env.CURSOR_API_KEY || config.get('cursorApiKey') || ''; + } + + private async _listCursorModels(apiKey: string): Promise> { + const { Cursor } = await this._loadCursorSdk(); + const resolvedApiKey = apiKey || process.env.CURSOR_API_KEY || ''; + const models = await Cursor.models.list({ apiKey: resolvedApiKey }); + + return (models || []) + .map((model: any) => ({ + id: model.id, + displayName: model.displayName || model.id, + })) + .filter((model: { id: string }) => !!model.id); + } + + private async _resolveCursorModel(config: vscode.WorkspaceConfiguration, apiKey: string): Promise { + const configuredModel = config.get('aiModel'); + if (configuredModel) { + try { + const models = await this._listCursorModels(apiKey); + const match = models.find((model) => model.id === configuredModel || model.displayName === configuredModel); + if (match) { + return match.id; + } + } catch { + return configuredModel; + } + return configuredModel; + } + + try { + const models = await this._listCursorModels(apiKey); + return models[0]?.id || DEFAULT_CURSOR_MODEL; + } catch { + return DEFAULT_CURSOR_MODEL; + } + } + + private _buildCursorPrompt(userMessage: string, systemPrompt: string): string { + const history = this._messages.slice(-10).map((msg, index) => { + const role = msg.role === 'assistant' ? 'Assistant' : 'User'; + const content = this._sanitizeContent(this._getMessageContent(msg)).trim(); + return `${index + 1}. ${role}: ${content}`; + }).join('\n'); + + const sections = [ + systemPrompt ? `System instructions:\n${systemPrompt}` : '', + history ? `Conversation history:\n${history}` : '', + `Current user request:\n${userMessage}` + ].filter(Boolean); + + return sections.join('\n\n'); + } + + private async callCursorAgent(userMessage: string, config: vscode.WorkspaceConfiguration, customSystemPrompt?: string): Promise<{ text: string, usage?: string }> { + const telemetry = TelemetryService.getInstance(); + if (!userMessage || !userMessage.trim()) { + throw new Error('User message is required for AI requests.'); + } + + const apiKey = await this._getCursorApiKey(config); + if (!apiKey) { + throw new Error('Cursor API key is required. Set CURSOR_API_KEY or save it in AI Settings.'); + } + + const { Agent } = await this._loadCursorSdk(); + const model = await this._resolveCursorModel(config, apiKey); + const systemPrompt = customSystemPrompt !== undefined ? customSystemPrompt : this.buildSystemPrompt(); + const prompt = this._buildCursorPrompt(userMessage, systemPrompt); + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd(); + + this._cancellationTokenSource = new vscode.CancellationTokenSource(); + let agent: any; + + try { + agent = await Agent.create({ + apiKey, + model: { id: model }, + local: { cwd: workspaceRoot } + }); + + const run = await agent.send({ text: prompt }); + const cancellationListener = (this._cancellationTokenSource.token as any).onCancellationRequested?.(() => { + void run.cancel(); + }) ?? { dispose: () => undefined }; + + try { + const result = await run.wait(); + cancellationListener.dispose(); + + if (result.status === 'cancelled') { + throw new Error('AI request cancelled.'); + } + + const responseText = result.result || ''; + if (!responseText.trim()) { + throw new Error('AI model returned an empty response. Please retry or select a different model.'); + } + + const usage = result.durationMs ? `Cursor · ${result.durationMs}ms` : undefined; + telemetry.trackEvent('ai_request', { provider: 'cursor', success: true }); + return { text: responseText, usage }; + } catch (error) { + cancellationListener.dispose(); + throw error; + } + } catch (error) { + telemetry.trackEvent('ai_request', { provider: 'cursor', success: false }); + throw error; + } finally { + if (this._cancellationTokenSource) { + this._cancellationTokenSource.dispose(); + this._cancellationTokenSource = null; + } + try { + agent?.close(); + } catch { + // ignore cleanup errors + } + } + } + /** Best-effort token / usage string from a consumed VS Code LM response (shape varies by host). */ private static async _extractVsCodeLmUsageAfterStream(chatRequest: any): Promise { const direct = AiService._usageFromLmResponseObject(chatRequest); @@ -1205,6 +1351,27 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; } const anyModels = await this._selectChatModelsWithTimeout({}); return anyModels.length > 0 ? (anyModels[0].name || anyModels[0].id) : 'VS Code LM (No Models)'; + } else if (provider === 'cursor') { + const apiKey = await this._getCursorApiKey(config); + if (configuredModel) { + try { + const models = await this._listCursorModels(apiKey); + const matchingModel = models.find((model) => model.id === configuredModel || model.displayName === configuredModel); + if (matchingModel) { + return matchingModel.displayName || matchingModel.id; + } + } catch { + return configuredModel; + } + return configuredModel; + } + + try { + const models = await this._listCursorModels(apiKey); + return models[0]?.displayName || models[0]?.id || 'Cursor (No Models)'; + } catch { + return 'Cursor'; + } } else { return configuredModel || this._getDefaultModel(provider); } @@ -1216,6 +1383,7 @@ The UI will automatically parse this and show clickable suggestion bubbles.`; private _getDefaultModel(provider: string): string { switch (provider) { case 'github': return DEFAULT_GITHUB_MODEL; + case 'cursor': return DEFAULT_CURSOR_MODEL; case 'openai': return 'gpt-4o'; case 'anthropic': return 'claude-3-5-sonnet-20241022'; case 'gemini': return 'gemini-1.5-flash'; diff --git a/src/providers/chat/backupToolsAssistantPrompt.ts b/src/providers/chat/backupToolsAssistantPrompt.ts new file mode 100644 index 0000000..c5d158f --- /dev/null +++ b/src/providers/chat/backupToolsAssistantPrompt.ts @@ -0,0 +1,142 @@ +/** + * System prompt for SQL Assistant when invoked from Backup & Restore tooling. + * Separate from the default SQL assistant: focuses on pg_dump / pg_restore / pg_dumpall + * and operational diagnosis, not application query generation. + */ + +export interface BackupToolsPromptContext { + connectionDisplayName?: string; + databaseName?: string; + environment?: 'production' | 'staging' | 'development'; + readOnlyMode?: boolean; +} + +function safetyHeader(ctx: BackupToolsPromptContext): string { + const isProd = ctx.environment === 'production'; + const ro = ctx.readOnlyMode === true; + if (isProd) { + return ` +**Environment:** Production database (${ctx.connectionDisplayName ?? 'connection'}). +- Treat restore / DROP / TRUNCATE / destructive DDL as high risk. Tell the user to verify backups and targets before running. +- Never instruct them to pipe untrusted input into psql or pg_restore without caveats. + +`; + } + if (ro) { + return ` +**Environment:** Read-only connection — remind the user that writes / restores to this connection will fail unless they use a writable target. + +`; + } + return ''; +} + +export function buildBackupToolsSystemPrompt(ctx: BackupToolsPromptContext): string { + const head = safetyHeader(ctx); + return `${head}You are the **PostgreSQL backup & restore specialist** inside PgStudio (VS Code extension). The user opened you from the **Backup & Restore** panel or a **tool log**. + +## Your role (different from the regular SQL assistant) +- **Primary focus:** \`pg_dump\`, \`pg_restore\`, \`pg_dumpall\`, archive formats (\`-Fc\`, directory, tar), **TOC / list files** (\`pg_restore --list\`, \`-L\`), client vs server version alignment, SSH tunneling with CLI tools, partial restores, ownership / ACL / extension ordering, parallel jobs (\`-j\`), and common failure modes. +- **Not the primary goal:** Writing application DML/SELECT unless it genuinely helps **diagnose** (e.g. checking extensions, roles, or object existence). Prefer **CLI options and workflow** first. +- You **cannot** run commands yourself. Give **copy-pasteable** shell examples and explain what each flag does. Mention that PgStudio prepends \`-h -p -U\` for panel-driven runs when relevant. + +## Response pattern +1. **Restate the problem** in one short paragraph (what failed, which tool). +2. **Likely causes** (bullet list, ordered by probability). +3. **Concrete actions** — separate sections for: + - **pg_dump / pg_dumpall** fixes (flags, scope \`-n\` / \`-t\`, format choice, version match). + - **pg_restore** fixes (\`--list\` / \`-L\`, section order, \`--no-owner\`, \`--if-exists\` / \`--clean\` cautions, parallel restore limits, missing dependencies / cross-schema objects, \`CREATE SCHEMA public\` on newer PostgreSQL, etc.). + - **Optional diagnostic SQL or psql meta-commands** only when useful (e.g. \`SELECT version();\`, \`\\dx\`, checking schemas), clearly labeled as optional checks on the **same database / connection** the user is debugging unless they are verifying a **restore target**. +4. **Safety / rollback** one line if destructive. +5. End with **2–4 numbered follow-up questions** the user might ask next (same style as main assistant). + +## Output rules +- Use **markdown**. Use fenced \`bash\` or \`sql\` blocks for commands. +- **Do not** emit the \`next_steps\` JSON block used by the regular SQL assistant UI — omit it entirely in this mode. +- Do not claim you executed anything or saw live server state beyond what the user pasted. + +## Session behavior (PgStudio) +- Follow-up messages in **this** chat thread keep the backup-tools role until the user clicks **New chat** or **Clear chat** in SQL Assistant (then the extension switches back to the default SQL assistant system prompt). + +## Context in the user message +The user message will include structured fields (connection label, database, version majors, SSH note, tool output). Treat that block as authoritative for **which connection and database** they were using in the panel. + +**Connection:** ${ctx.connectionDisplayName ?? '(see user message)'} +**Database (panel):** ${ctx.databaseName ?? '(see user message)'}`; +} + +export type BackupToolsAssistScenario = 'version_banner' | 'tool_log'; + +export interface BackupToolsUserMessageInput { + scenario: BackupToolsAssistScenario; + connectionId: string; + databaseLabel: string; + databaseName: string; + host?: string; + port?: number; + username?: string; + sshEnabled: boolean; + serverMajor: number; + pgDumpMajor: number; + pgRestoreMajor: number; + /** Last tool output from panel log (may be truncated by caller) */ + toolLog?: string; + /** Inferred from log or explicit */ + inferredTool?: string; +} + +const MAX_USER_LOG_CHARS = 72_000; + +export function buildBackupToolsUserMessage(input: BackupToolsUserMessageInput): string { + const log = + input.toolLog && input.toolLog.length > MAX_USER_LOG_CHARS + ? input.toolLog.slice(-MAX_USER_LOG_CHARS) + : input.toolLog; + + const lines: string[] = [ + '## PgStudio · Backup & Restore assistant', + '', + `**Scenario:** ${input.scenario === 'version_banner' ? 'Client tool vs PostgreSQL server version mismatch (banner in panel)' : 'pg_dump / pg_restore / pg_dumpall output (errors or non-zero exit)'}`, + '', + '### Connection to debug', + `- **Connection ID (settings):** \`${input.connectionId}\``, + `- **Label:** ${input.databaseLabel}`, + `- **Database selected in panel:** ${input.databaseName}`, + `- **Host:** ${input.host ?? '(unknown)'}`, + `- **Port:** ${input.port ?? '(unknown)'}`, + `- **User (CLI / libpq):** ${input.username ?? '(unknown)'}`, + `- **SSH tunnel for CLI:** ${input.sshEnabled ? 'yes (pg_dump/pg_restore use local forward from panel connection)' : 'no'}`, + '', + '### Tool versions (from panel)', + `- **PostgreSQL server major:** ${input.serverMajor || '?'}`, + `- **pg_dump client major:** ${input.pgDumpMajor || '?'}`, + `- **pg_restore client major:** ${input.pgRestoreMajor || '?'}`, + '' + ]; + + if (input.scenario === 'version_banner') { + lines.push( + '### What the user sees', + 'The panel shows a warning that **pg_dump and/or pg_restore major version differs from the server**. They want guidance on whether this is a problem and how to align client tools (PATH, installers, Docker image, etc.).', + '' + ); + } else { + lines.push( + '### Inferred tool (best effort)', + input.inferredTool ? `- **Likely tool:** ${input.inferredTool}` : '- **Likely tool:** (infer from log prefix if possible)', + '', + '### Tool output (verbatim, possibly truncated at end)', + '```text', + (log && log.trim()) || '(no log captured — ask user to paste output)', + '```', + '' + ); + } + + lines.push( + '### What I need from you', + 'Diagnose using the above. Give **ordered, actionable** steps: CLI flags and order, `pg_restore --list` / `-L` strategy when relevant, version alignment, dependency / schema ordering, and optional diagnostic SQL or psql commands **only** if they clarify state. Remind me which database is **source** vs **restore target** when both matter.' + ); + + return lines.join('\n'); +} diff --git a/src/providers/chat/index.ts b/src/providers/chat/index.ts index 8d50fbf..fc4965e 100644 --- a/src/providers/chat/index.ts +++ b/src/providers/chat/index.ts @@ -3,3 +3,4 @@ export * from './DbObjectService'; export * from './AiService'; export * from './SessionService'; export * from './webviewHtml'; +export * from './backupToolsAssistantPrompt'; diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index 17ecc2a..5df723d 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import { NotebookCellOutput, NotebookCellOutputItem } from 'vscode'; import { ConnectionManager } from '../../services/ConnectionManager'; -import { TelemetryService, SpanNames } from '../../services/TelemetryService'; +import { TelemetryService } from '../../services/TelemetryService'; import { NoticeLogEntry, PostgresMetadata, @@ -27,6 +27,24 @@ import { CursorStreamBannerPolicy } from '../../services/CursorStreamBannerPolic /** Streaming NOTICE feed during a single-statement cell run (replaced by final result output). */ const MIME_NOTICES_LIVE = 'application/vnd.postgres-notebook.notices-live'; +/** Tracks result of a single statement execution */ +interface StatementResult { + stmtIndex: number; + query: string; + success: boolean; + rowCount?: number | null; + rows?: any[]; + columns?: string[]; + columnTypes?: Record; + command?: string; + error?: string; + errorCode?: string; + executionTime: number; +} + +/** Failure strategy for multi-statement execution */ +type FailureStrategy = 'continue-on-error' | 'fail-on-error' | 'prompt-on-error'; + export class SqlExecutor { private static readonly REVIEW_COUNT_KEY = 'postgresExplorer.reviewPrompt.successCount'; private static readonly REVIEW_SHOWN_KEY = 'postgresExplorer.reviewPrompt.shown'; @@ -38,6 +56,50 @@ export class SqlExecutor { constructor(private readonly _controller: vscode.NotebookController) { } + /** + * Get the configured failure strategy from settings. + * Default: 'continue-on-error' (best-effort execution) + */ + private getFailureStrategy(): FailureStrategy { + const config = vscode.workspace.getConfiguration('postgresExplorer.query'); + const strategy = config.get('executionFailureStrategy', 'continue-on-error'); + return strategy; + } + + /** + * Generate summary markdown for multi-statement execution results. + * Shows which statements succeeded and which failed. + */ + private generateSummaryMarkdown(results: StatementResult[]): string { + const succeeded = results.filter(r => r.success); + const failed = results.filter(r => !r.success); + + let markdown = '## Execution Summary\n\n'; + + if (succeeded.length > 0) { + markdown += `✅ **${succeeded.length} statement${succeeded.length === 1 ? '' : 's'} succeeded**\n\n`; + for (const result of succeeded) { + const rowInfo = result.rowCount !== null && result.rowCount !== undefined ? ` (${result.rowCount} rows)` : ''; + markdown += `- Statement ${result.stmtIndex + 1}: ${result.command}${rowInfo}\n`; + } + markdown += '\n'; + } + + if (failed.length > 0) { + markdown += `❌ **${failed.length} statement${failed.length === 1 ? '' : 's'} failed**\n\n`; + for (const result of failed) { + markdown += `- Statement ${result.stmtIndex + 1}: ${result.error}${result.errorCode ? ` (${result.errorCode})` : ''}\n`; + } + markdown += '\n'; + } + + if (succeeded.length > 0 && failed.length > 0) { + markdown += '💡 **Tip**: Review the changes above. If in a transaction, you can still COMMIT or ROLLBACK.\n'; + } + + return markdown; + } + private async maybePromptForReview(): Promise { if (!extensionContext) { return; @@ -259,6 +321,80 @@ export class SqlExecutor { return limitedQuery; } + /** + * Build a consolidated warning message for multiple dangerous operations in a cell. + * Groups operations by type, shows counts, and provides transaction guidance. + */ + private buildConsolidatedWarningMessage( + dangerousOpsWithAnalysis: Array<{ stmt: string; analysis: any }>, + connection: any + ): string { + // Aggregate all operations by type + const operationCounts: Record = {}; + + for (const { analysis } of dangerousOpsWithAnalysis) { + for (const op of analysis.operations) { + operationCounts[op.type] = (operationCounts[op.type] || 0) + 1; + } + } + + const envPrefix = + connection?.environment === 'production' + ? '⚠️ PRODUCTION DATABASE ⚠️\n\n' + : connection?.environment === 'staging' + ? '⚠️ STAGING DATABASE ⚠️\n\n' + : ''; + + // Build summary of operation counts + const operationSummary = Object.entries(operationCounts) + .map(([type, count]) => { + const plural = count === 1 ? '' : 's'; + return `• ${count} ${type}${plural}`; + }) + .join('\n'); + + const totalCount = Object.values(operationCounts).reduce((a, b) => a + b, 0); + + const message = + envPrefix + + `This cell contains ${totalCount} dangerous SQL command${totalCount === 1 ? '' : 's'}:\n\n` + + operationSummary + + '\n\n' + + '💡 Using a transaction block reduces risk:\n' + + 'Choose "Execute in Transaction" to wrap all commands in BEGIN...COMMIT.\n' + + 'This allows you to review changes before committing, or run ROLLBACK to undo if needed.\n\n' + + 'Are you sure you want to proceed?'; + + return message; + } + + private async insertTransactionControlCell(cell: vscode.NotebookCell): Promise { + const notebook = cell.notebook; + if (!notebook) { + return; + } + + const controlCellContent = [ + '-- Transaction controls', + '-- COMMIT; applies all changes in this transaction permanently.', + '-- ROLLBACK; undoes all changes made since BEGIN.', + '', + '-- Review the results above, then choose one command to run:', + 'COMMIT;', + '-- ROLLBACK;' + ].join('\n'); + + const insertionIndex = Math.min(notebook.cellCount, cell.index + 1); + const newCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, controlCellContent, 'sql'); + const edit = new vscode.WorkspaceEdit(); + edit.set(notebook.uri, [vscode.NotebookEdit.insertCells(insertionIndex, [newCell])]); + + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + vscode.window.showWarningMessage('Transaction started, but PgStudio could not insert the follow-up COMMIT/ROLLBACK cell.'); + } + } + /** * Optional execution directives embedded as SQL comments at top-level. * - pgstudio:full-dataset => disable streaming + disable auto-limit for this statement. @@ -379,8 +515,12 @@ export class SqlExecutor { console.log('SqlExecutor: Executing', statements.length, 'statement(s)'); - // Safety check: Analyze queries for dangerous operations + // Safety check: Pre-analyze all queries for dangerous operations const queryAnalyzer = QueryAnalyzer.getInstance(); + let userConfirmedDangerousOps: 'Execute' | 'Execute in Transaction' | 'Cancelled' | null = null; + + // Collect all dangerous operations and perform read-only checks + const allDangerousOps: Array<{ stmt: string; analysis: any }> = []; for (const stmt of statements) { // Check read-only mode if (connection.readOnlyMode && !queryAnalyzer.isReadOnlyQuery(stmt)) { @@ -389,37 +529,54 @@ export class SqlExecutor { // Analyze for dangerous operations const analysis = queryAnalyzer.analyzeQuery(stmt, connection); - if (analysis.requiresConfirmation && analysis.warningMessage) { - const action = await vscode.window.showWarningMessage( - analysis.warningMessage, - { modal: true }, - 'Execute', - 'Execute in Transaction' - ); + if (analysis.requiresConfirmation) { + allDangerousOps.push({ stmt, analysis }); + } + } - if (!action) { - throw new Error('Query execution cancelled by user'); - } else if (action === 'Execute in Transaction') { - // Wrap in transaction if not already in one - const txManager = getTransactionManager(); - const sessionId = cell.notebook.uri.toString(); - const txInfo = txManager.getTransactionInfo(sessionId); - - if (!txInfo || !txInfo.isActive) { - await client.query('BEGIN'); - if (!txInfo) { - txManager.initializeSession(sessionId, true); - } - pushNotice( - 'Transaction started automatically for safety. Run COMMIT or ROLLBACK when done.', - ); - emitLiveNoticesIfNeeded(); + // If there are dangerous operations, show ONE consolidated confirmation + if (allDangerousOps.length > 0) { + const consolidatedMessage = this.buildConsolidatedWarningMessage( + allDangerousOps, + connection + ); + const action = await vscode.window.showWarningMessage( + consolidatedMessage, + { modal: true }, + 'Execute', + 'Execute in Transaction' + ); + + if (!action) { + throw new Error('Query execution cancelled by user'); + } + + userConfirmedDangerousOps = action as 'Execute' | 'Execute in Transaction'; + + // If user chose "Execute in Transaction", start one now + if (userConfirmedDangerousOps === 'Execute in Transaction') { + const txManager = getTransactionManager(); + const sessionId = cell.notebook.uri.toString(); + const txInfo = txManager.getTransactionInfo(sessionId); + + if (!txInfo || !txInfo.isActive) { + await client.query('BEGIN'); + if (!txInfo) { + txManager.initializeSession(sessionId, true); } + pushNotice( + 'Transaction started automatically for safety. Run COMMIT or ROLLBACK when done.', + ); + emitLiveNoticesIfNeeded(); } + + await this.insertTransactionControlCell(cell); } } // Execute each statement + const statementsResults: StatementResult[] = []; + const failureStrategy = this.getFailureStrategy(); for (let stmtIndex = 0; stmtIndex < statements.length; stmtIndex++) { ResultCursorService.closeSessionsForCellUri(cell.document.uri.toString()); liveNoticesActive = false; @@ -516,13 +673,7 @@ export class SqlExecutor { let result; const telemetry = TelemetryService.getInstance(); - let spanId = ''; try { - spanId = telemetry.startSpan(SpanNames.QUERY_EXECUTE, { - statementIndex: stmtIndex + 1, - statementCount: statements.length - }); - if (usedSlidingWindow && openedSession) { result = { rows: openedSession.rows, @@ -614,7 +765,7 @@ export class SqlExecutor { const rows = result.rows || []; telemetry.trackEvent('query_executed', { success: true, - durationBucket: durationMs < 500 ? 'lt_500ms' : durationMs < 2000 ? '500ms_2s' : durationMs < 10000 ? '2_10s' : 'gte_10s', + durationBucket: telemetry.durationBucket(durationMs), resultSizeBucket: rows.length === 0 ? '0' : rows.length < 10 ? '1_9' : rows.length < 100 ? '10_99' : rows.length < 1000 ? '100_999' : 'gte_1000', }); let columns = result.fields?.map((f: any) => f.name) || []; @@ -688,8 +839,6 @@ export class SqlExecutor { sourceCellIndex: cell.index, }; - telemetry.endSpan(spanId, { success: 'true', rowCount: result.rowCount ?? rows.length }); - // Clear notices for next statement notices.length = 0; @@ -723,18 +872,36 @@ export class SqlExecutor { connectionName: connection.name }); + // Collect successful result + statementsResults.push({ + stmtIndex, + query: queryForExecution, + success: true, + rowCount: result.rowCount, + rows: rows, + columns: columns, + columnTypes: columnTypes, + command: result.command, + executionTime, + }); + + const qa = QueryAnalyzer.getInstance(); + if (qa.isCatalogInvalidatingSql(statements[stmtIndex]) || qa.isSearchPathChangingSql(statements[stmtIndex])) { + const dbName = metadata.databaseName || connection.database || 'postgres'; + void import('../SqlCompletionProvider').then(mod => { + mod.SqlCompletionProvider.getInstance()?.invalidate(connection.id, dbName); + }); + } + await this.maybePromptForReview(); } catch (err: any) { const stmtEndTime = Date.now(); const executionTime = (stmtEndTime - stmtStartTime) / 1000; const durationMs = executionTime * 1000; - if (spanId) { - telemetry.recordError(spanId, err instanceof Error ? err : new Error(String(err))); - } telemetry.trackEvent('query_executed', { success: false, - durationBucket: durationMs < 500 ? 'lt_500ms' : durationMs < 2000 ? '500ms_2s' : durationMs < 10000 ? '2_10s' : 'gte_10s', + durationBucket: telemetry.durationBucket(durationMs), resultSizeBucket: '0', }); @@ -792,8 +959,51 @@ export class SqlExecutor { connectionName: connection.name }); - // Stop execution on error - break; + // Collect error result + statementsResults.push({ + stmtIndex, + query, + success: false, + error: err.message, + errorCode: pgErrorCode, + executionTime, + }); + + // Handle failure strategy + if (failureStrategy === 'fail-on-error') { + // Stop execution on error (current behavior) + break; + } else if (failureStrategy === 'prompt-on-error') { + // Ask user whether to continue + const choice = await vscode.window.showErrorMessage( + `Statement ${stmtIndex + 1} failed: ${err.message}\n\nContinue executing remaining statements?`, + { modal: true }, + 'Continue', + 'Stop' + ); + if (!choice || choice === 'Stop') { + break; + } + // Otherwise continue to next statement + } + // If 'continue-on-error', just continue without breaking + } + } + + // If multi-statement with mixed results, append summary + if (statements.length > 1 && statementsResults.length > 0) { + const succeeded = statementsResults.filter(r => r.success); + const failed = statementsResults.filter(r => !r.success); + + if (succeeded.length > 0 && failed.length > 0) { + const summaryMarkdown = this.generateSummaryMarkdown(statementsResults); + const summaryOutput = new NotebookCellOutput([ + new NotebookCellOutputItem( + Buffer.from(summaryMarkdown, 'utf8'), + 'text/markdown', + ), + ]); + await execution.appendOutput(summaryOutput); } } diff --git a/src/providers/kernel/SqlParser.ts b/src/providers/kernel/SqlParser.ts index 41c41d7..33237e8 100644 --- a/src/providers/kernel/SqlParser.ts +++ b/src/providers/kernel/SqlParser.ts @@ -182,6 +182,111 @@ export class SqlParser { private static escapePgIdentifier(value: string): string { return `"${value.replace(/"/g, '""')}"`; } + + /** + * Strip SQL comments and string literals while preserving code-only text. + * Double-quoted identifiers are preserved. + */ + public static stripCommentsAndStrings(sql: string): 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; + out += ' '; + i += 2; + continue; + } + + if (inBlockComment && char === '*' && nextChar === '/') { + inBlockComment = false; + out += ' '; + i += 2; + continue; + } + + if (inBlockComment) { + out += ' '; + i++; + continue; + } + + if (!inSingleQuote && !inDollarQuote && char === '-' && nextChar === '-') { + const lineEnd = sql.indexOf('\n', i); + if (lineEnd === -1) { + out += ' '.repeat(sql.length - i); + break; + } + out += ' '.repeat(lineEnd - i + 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; + out += ' '.repeat(tag.length); + i += tag.length; + continue; + } + if (tag === dollarQuoteTag) { + inDollarQuote = false; + dollarQuoteTag = ''; + out += ' '.repeat(tag.length); + i += tag.length; + continue; + } + } + } + + if (!inDollarQuote && char === "'") { + if (inSingleQuote && nextChar === "'") { + out += ' '; + i += 2; + continue; + } + inSingleQuote = !inSingleQuote; + out += ' '; + i++; + continue; + } + + if (inSingleQuote || inDollarQuote) { + out += ' '; + i++; + continue; + } + + out += char; + i++; + } + + return out; + } + + /** + * Normalize identifier casing: quoted identifiers preserve case; unquoted identifiers are lowercased. + */ + public static normalizeIdentifier(identifier: string): string { + const trimmed = identifier.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/""/g, '"'); + } + return trimmed.toLowerCase(); + } /** * Split SQL text into individual statements, respecting semicolons but ignoring them inside: * - String literals (single quotes) diff --git a/src/providers/sql-completion-shared.ts b/src/providers/sql-completion-shared.ts new file mode 100644 index 0000000..069da24 --- /dev/null +++ b/src/providers/sql-completion-shared.ts @@ -0,0 +1,142 @@ +/** + * Shared SQL completion utilities (quoting, reserved words) used by completion and signature providers. + */ + +import { SqlParser } from './kernel/SqlParser'; + +/** Minimal PostgreSQL reserved-word set for safe unquoted inserts (expand as needed). */ +export const PG_RESERVED_WORDS = new Set( + [ + 'all', + 'analyse', + 'analyze', + 'and', + 'any', + 'array', + 'as', + 'asc', + 'asymmetric', + 'authorization', + 'binary', + 'both', + 'case', + 'cast', + 'check', + 'collate', + 'column', + 'concurrently', + 'constraint', + 'create', + 'cross', + 'current_catalog', + 'current_date', + 'current_role', + 'current_schema', + 'current_time', + 'current_timestamp', + 'current_user', + 'default', + 'deferrable', + 'desc', + 'distinct', + 'do', + 'else', + 'end', + 'except', + 'false', + 'fetch', + 'for', + 'foreign', + 'freeze', + 'from', + 'full', + 'grant', + 'group', + 'having', + 'ilike', + 'in', + 'initially', + 'inner', + 'intersect', + 'into', + 'is', + 'isnull', + 'join', + 'lateral', + 'leading', + 'left', + 'like', + 'limit', + 'localtime', + 'localtimestamp', + 'natural', + 'not', + 'notnull', + 'null', + 'offset', + 'on', + 'only', + 'or', + 'order', + 'outer', + 'overlaps', + 'placing', + 'primary', + 'references', + 'returning', + 'right', + 'select', + 'session_user', + 'similar', + 'some', + 'symmetric', + 'table', + 'then', + 'to', + 'trailing', + 'true', + 'union', + 'unique', + 'user', + 'using', + 'variadic', + 'verbose', + 'when', + 'where', + 'window', + 'with' + ] +); + +export function sqlNeedsQuoting(identifier: string): boolean { + const n = SqlParser.normalizeIdentifier(identifier); + if (!n) { + return true; + } + if (!/^[a-z_][a-z0-9_]*$/.test(n)) { + return true; + } + return PG_RESERVED_WORDS.has(n.toLowerCase()); +} + +/** Escape for double-quoted PostgreSQL identifier (preserves case inside quotes). */ +export function sqlQuoteIdentifier(identifier: string): string { + let raw = identifier.trim(); + if (raw.startsWith('"') && raw.endsWith('"')) { + raw = raw.slice(1, -1).replace(/""/g, '"'); + } else { + raw = SqlParser.normalizeIdentifier(raw); + } + return `"${raw.replace(/"/g, '""')}"`; +} + +export function sqlFormatIdentifier(identifier: string): string { + const trimmed = identifier.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed; + } + if (sqlNeedsQuoting(trimmed)) { + return sqlQuoteIdentifier(trimmed); + } + return SqlParser.normalizeIdentifier(trimmed); +} diff --git a/src/renderer/components/chart/ChartRenderer.ts b/src/renderer/components/chart/ChartRenderer.ts index d9c8f49..7c57086 100644 --- a/src/renderer/components/chart/ChartRenderer.ts +++ b/src/renderer/components/chart/ChartRenderer.ts @@ -1,10 +1,8 @@ -import { Chart, registerables, ChartType, TooltipPositionerFunction } from 'chart.js'; +import { Chart, ChartType, TooltipPositionerFunction } from 'chart.js'; +import { ensureChartJsRegistered } from './chartJsRegister'; import { createGradient, darkenColor, formatDate, isDateColumn } from '../../utils/formatting'; import { ChartRenderOptions } from '../../../common/types'; -// Register Chart.js components -Chart.register(...registerables); - // Default colors matching renderer_v2.ts export const DEFAULT_COLORS = [ 'rgba(54, 162, 235, 0.6)', // Blue @@ -35,6 +33,7 @@ export class ChartRenderer { constructor(private canvas: HTMLCanvasElement) { } public render(rows: any[], options: ChartRenderOptions) { + ensureChartJsRegistered(); // Destroy existing chart this.destroy(); diff --git a/src/renderer/components/chart/chartJsRegister.ts b/src/renderer/components/chart/chartJsRegister.ts new file mode 100644 index 0000000..82c5bf9 --- /dev/null +++ b/src/renderer/components/chart/chartJsRegister.ts @@ -0,0 +1,12 @@ +import { Chart, registerables } from 'chart.js'; + +let registered = false; + +/** Idempotent Chart.js registration — call before constructing Chart instances. */ +export function ensureChartJsRegistered(): void { + if (registered) { + return; + } + Chart.register(...registerables); + registered = true; +} diff --git a/src/schemaDesigner/ErdPanel.ts b/src/schemaDesigner/ErdPanel.ts index c70958b..aa3e20d 100644 --- a/src/schemaDesigner/ErdPanel.ts +++ b/src/schemaDesigner/ErdPanel.ts @@ -2,41 +2,17 @@ import * as vscode from 'vscode'; import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; import { resolveTreeItemConnection } from './connectionHelper'; import { ErrorHandlers } from '../commands/helper'; +import { createAndShowNotebook, createMetadata } from '../commands/connection'; +import type { PostgresMetadata } from '../common/types'; +import { fetchErdSnapshot } from './erd/erdQueries'; +import { buildErdWebviewHtml } from './erd/erdWebviewHtml'; +import { patchesToMigrationSql, type ErdModelPatch } from './erd/erdMigrationDraft'; +import type { ErdWebviewPayload } from './erd/erdTypes'; -interface ErdColumn { - name: string; - type: string; - notNull: boolean; - isPk: boolean; - isFk: boolean; -} - -interface ErdForeignKey { - fromTable: string; - fromColumn: string; - toTable: string; - toColumn: string; - constraintName: string; -} - -interface ErdTable { - name: string; - schema: string; - /** Approximate row count from pg_class.reltuples (ANALYZE refreshes). */ - estRows?: number; - columns: ErdColumn[]; -} +export type { ErdModelPatch }; /** - * Entity-Relationship Diagram (ERD) Panel - * - * Visualises all tables in a schema along with their foreign key relationships. - * Tables are rendered as cards on a pannable/zoomable canvas; FK arrows connect - * related columns. Users can: - * - Drag tables to reposition them (layout is auto-saved in the webview state). - * - Click a table to highlight all its FK links. - * - Jump to a table's definition via "Open in Designer". - * - Export the current view as an SVG. + * Entity-Relationship Diagram (ERD) Panel — multi-schema, layers, exports, migration draft. */ export class ErdPanel { public static readonly viewType = 'pgStudio.erd'; @@ -50,27 +26,48 @@ export class ErdPanel { this._panel.onDidDispose(() => this.dispose(), null, this._disposables); } - public static async open( + /** + * Open ERD for a single schema (from tree context). + */ + public static async open(item: DatabaseTreeItem, context: vscode.ExtensionContext): Promise { + const labelStr = typeof item.label === 'string' ? item.label : (item.label as { label?: string })?.label ?? ''; + const schema = item.schema || labelStr || 'public'; + await ErdPanel.openForSchemas(context, item, [schema]); + } + + /** + * Open ERD for multiple schemas on the same connection/database. + */ + public static async openForSchemas( + context: vscode.ExtensionContext, item: DatabaseTreeItem, - context: vscode.ExtensionContext + schemas: string[] ): Promise { - let conn: any; + let conn: Awaited> | undefined; + try { conn = await resolveTreeItemConnection(item); - if (!conn) { return; } + if (!conn) { + return; + } - const { client, metadata } = conn; - const labelStr = typeof item.label === 'string' ? item.label : (item.label as any)?.label ?? ''; - const schema = item.schema || labelStr || 'public'; + const { client, metadata, connection } = conn; const db = item.databaseName || metadata?.databaseName || 'postgres'; + const uniqSchemas = [...new Set(schemas)].sort(); await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: `Building ERD for "${schema}"…`, cancellable: false }, + { + location: vscode.ProgressLocation.Notification, + title: `Building ERD (${uniqSchemas.join(', ')})…`, + cancellable: false, + }, async () => { - const tables = await ErdPanel._fetchTables(client, schema); - const foreignKeys = await ErdPanel._fetchForeignKeys(client, schema); + const snapshot = await fetchErdSnapshot(client, uniqSchemas); + const readOnlyConnection = + (connection as { readOnlyMode?: boolean }).readOnlyMode === true || + item.readOnlyMode === true; - const panelKey = `erd:${item.connectionId}:${db}:${schema}`; + const panelKey = `erd:${item.connectionId}:${db}:${uniqSchemas.join(',')}`; if (ErdPanel._panels.has(panelKey)) { ErdPanel._panels.get(panelKey)!._panel.reveal(vscode.ViewColumn.One); return; @@ -78,793 +75,224 @@ export class ErdPanel { const panel = vscode.window.createWebviewPanel( ErdPanel.viewType, - `ERD: ${schema}`, + `ERD: ${uniqSchemas.join(', ')}`, vscode.ViewColumn.One, - { enableScripts: true, retainContextWhenHidden: true } + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + } ); const erdPanel = new ErdPanel(panel); ErdPanel._panels.set(panelKey, erdPanel); panel.onDidDispose(() => ErdPanel._panels.delete(panelKey)); - panel.webview.html = ErdPanel._buildHtml(schema, tables, foreignKeys); - - panel.webview.onDidReceiveMessage(async (msg) => { - if (msg.type === 'exportSvg') { - const uri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(`erd-${schema}.svg`), - filters: { 'SVG Image': ['svg'] } - }); - if (uri) { - await vscode.workspace.fs.writeFile(uri, Buffer.from(msg.svg, 'utf8')); - vscode.window.showInformationMessage(`ERD exported to ${uri.fsPath}`); + const payload: ErdWebviewPayload = { + snapshot, + readOnlyConnection, + }; + panel.webview.html = buildErdWebviewHtml(panel.webview, context.extensionUri, payload); + + panel.webview.onDidReceiveMessage( + async (msg: Record) => { + if (msg.type === 'exportSvg' && typeof msg.svg === 'string') { + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`erd-${uniqSchemas.join('-')}.svg`), + filters: { 'SVG Image': ['svg'] }, + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(msg.svg, 'utf8')); + vscode.window.showInformationMessage(`Exported SVG to ${uri.fsPath}`); + } + } else if (msg.type === 'exportPng' && typeof msg.base64 === 'string') { + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`erd-${uniqSchemas.join('-')}.png`), + filters: { PNG: ['png'] }, + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(msg.base64, 'base64')); + vscode.window.showInformationMessage(`Exported PNG to ${uri.fsPath}`); + } + } else if (msg.type === 'exportText' && typeof msg.content === 'string') { + const kind = msg.kind === 'mermaid' ? 'mermaid' : 'dbml'; + const ext = kind === 'mermaid' ? 'md' : 'dbml'; + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`erd-${uniqSchemas.join('-')}.${ext}`), + filters: + kind === 'mermaid' + ? { Markdown: ['md'] } + : { DBML: ['dbml'] }, + }); + if (uri) { + await vscode.workspace.fs.writeFile(uri, Buffer.from(msg.content, 'utf8')); + vscode.window.showInformationMessage(`Exported to ${uri.fsPath}`); + } + } else if (msg.type === 'erdRenameTable') { + const qual = typeof msg.qual === 'string' ? msg.qual : ''; + const schema = typeof msg.schema === 'string' ? msg.schema : ''; + const currentName = typeof msg.currentName === 'string' ? msg.currentName : ''; + if (!qual || !schema || !currentName) { + return; + } + const next = await vscode.window.showInputBox({ + title: `Rename table — ${schema}.${currentName}`, + prompt: 'New table name', + value: currentName, + validateInput: (v) => { + const t = v?.trim(); + if (!t) { + return 'Enter a table name'; + } + if (t === currentName) { + return 'Name unchanged'; + } + return null; + }, + }); + if (next === undefined) { + return; + } + const to = next.trim(); + await panel.webview.postMessage({ + type: 'erdRenameTableResult', + qual, + schema, + from: currentName, + to, + }); + } else if (msg.type === 'erdRenameColumn') { + const qual = typeof msg.qual === 'string' ? msg.qual : ''; + const schema = typeof msg.schema === 'string' ? msg.schema : ''; + const tableName = typeof msg.table === 'string' ? msg.table : ''; + const currentColumn = typeof msg.currentColumn === 'string' ? msg.currentColumn : ''; + if (!qual || !schema || !tableName || !currentColumn) { + return; + } + const next = await vscode.window.showInputBox({ + title: `Rename column — ${schema}.${tableName}.${currentColumn}`, + prompt: 'New column name', + value: currentColumn, + validateInput: (v) => { + const t = v?.trim(); + if (!t) { + return 'Enter a column name'; + } + if (t === currentColumn) { + return 'Name unchanged'; + } + return null; + }, + }); + if (next === undefined) { + return; + } + const to = next.trim(); + await panel.webview.postMessage({ + type: 'erdRenameColumnResult', + qual, + schema, + table: tableName, + from: currentColumn, + to, + }); + } else if (msg.type === 'erdAddColumn') { + const qual = typeof msg.qual === 'string' ? msg.qual : ''; + const schema = typeof msg.schema === 'string' ? msg.schema : ''; + const tableName = typeof msg.table === 'string' ? msg.table : ''; + if (!qual || !schema || !tableName) { + return; + } + const name = await vscode.window.showInputBox({ + title: `Add column — ${schema}.${tableName}`, + prompt: 'Column name', + validateInput: (v) => (v?.trim() ? null : 'Enter a column name'), + }); + if (name === undefined) { + return; + } + const dataType = await vscode.window.showInputBox({ + title: `Add column — ${name.trim()}`, + prompt: 'PostgreSQL type', + value: 'text', + validateInput: (v) => (v?.trim() ? null : 'Enter a type'), + }); + if (dataType === undefined) { + return; + } + const nullPick = await vscode.window.showQuickPick(['NOT NULL', 'Nullable'], { + title: 'Nullability', + placeHolder: 'Column nullability', + }); + if (nullPick === undefined) { + return; + } + await panel.webview.postMessage({ + type: 'erdAddColumnResult', + qual, + schema, + table: tableName, + name: name.trim(), + dataType: dataType.trim(), + notNull: nullPick === 'NOT NULL', + }); + } else if (msg.type === 'syncMigration') { + const patches = msg.patches as ErdModelPatch[]; + if (!Array.isArray(patches) || patches.length === 0) { + vscode.window.showInformationMessage('No pending ERD edits to sync.'); + return; + } + const stmts = patchesToMigrationSql(patches); + const readOnlyNote = + msg.readOnly === true + ? '\n\n**Note:** This connection is read-only — review SQL before running elsewhere.' + : ''; + const md = + `### ERD migration draft\n\n` + + `Generated **${stmts.length}** statement(s) from ERD edits.${readOnlyNote}\n\n` + + `Review inside an explicit transaction; uncomment **COMMIT** or **ROLLBACK** at the bottom.`; + + const sqlCell = + `-- ERD migration (draft)\n-- Schemas: ${uniqSchemas.join(', ')}\n\n` + + `BEGIN;\n\n${stmts.join('\n\n')}\n\n` + + `-- COMMIT;\n-- ROLLBACK;`; + + const metaBase = createMetadata(connection, db) as PostgresMetadata; + const meta: PostgresMetadata = { + ...metaBase, + readOnlyMode: (connection as { readOnlyMode?: boolean }).readOnlyMode === true, + }; + + await createAndShowNotebook( + [ + new vscode.NotebookCellData(vscode.NotebookCellKind.Markup, md, 'markdown'), + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, sqlCell, 'sql'), + ], + meta + ); } - } - }, null, erdPanel._disposables); + }, + null, + erdPanel._disposables + ); } ); - } catch (err: any) { + } catch (err: unknown) { await ErrorHandlers.handleCommandError(err, 'open ERD'); } finally { - if (conn?.release) { conn.release(); } - } - } - - // --------------------------------------------------------------------------- - // Data fetching - // --------------------------------------------------------------------------- - - private static async _fetchTables(client: any, schema: string): Promise { - const tablesResult = await client.query( - `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' - ORDER BY c.relname`, - [schema] - ); - - // Collect PK columns for each table - const pkResult = await client.query( - `SELECT kcu.table_name, kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = $1`, - [schema] - ); - const pkMap = new Map>(); - for (const row of pkResult.rows) { - if (!pkMap.has(row.table_name)) { pkMap.set(row.table_name, new Set()); } - pkMap.get(row.table_name)!.add(row.column_name); - } - - // Collect FK columns - const fkColResult = await client.query( - `SELECT kcu.table_name, kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1`, - [schema] - ); - const fkMap = new Map>(); - for (const row of fkColResult.rows) { - if (!fkMap.has(row.table_name)) { fkMap.set(row.table_name, new Set()); } - fkMap.get(row.table_name)!.add(row.column_name); - } - - 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, - a.attnotnull AS not_null - FROM pg_catalog.pg_attribute a - WHERE a.attrelid = ($1 || '.' || $2)::regclass - AND a.attnum > 0 AND NOT a.attisdropped - ORDER BY a.attnum`, - [schema, tableName] - ); - - const pkCols = pkMap.get(tableName) ?? new Set(); - const fkCols = fkMap.get(tableName) ?? new Set(); - - tables.push({ - name: tableName, - schema, - ...(estRows !== undefined ? { estRows } : {}), - columns: colResult.rows.map((r: any) => ({ - name: r.column_name, - type: r.data_type, - notNull: r.not_null, - isPk: pkCols.has(r.column_name), - isFk: fkCols.has(r.column_name), - })), - }); - } - return tables; - } - - private static async _fetchForeignKeys(client: any, schema: string): Promise { - const result = await client.query( - `SELECT - tc.constraint_name, - tc.table_name AS from_table, - kcu.column_name AS from_column, - ccu.table_name AS to_table, - ccu.column_name AS to_column - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - AND tc.table_schema = ccu.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = $1 - ORDER BY tc.table_name, kcu.column_name`, - [schema] - ); - return result.rows.map((r: any) => ({ - fromTable: r.from_table, - fromColumn: r.from_column, - toTable: r.to_table, - toColumn: r.to_column, - constraintName: r.constraint_name, - })); - } - - // --------------------------------------------------------------------------- - // HTML - // --------------------------------------------------------------------------- - - private static _buildHtml( - schema: string, - tables: ErdTable[], - foreignKeys: ErdForeignKey[] - ): string { - const tablesJson = JSON.stringify(tables); - const fksJson = JSON.stringify(foreignKeys); - - return /* html */` - - - - ERD: ${schema} - - - - -
-

📊 ERD —

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

ERD —

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

No tables in selected schema(s).

'; + return; + } + + initGridLayout(); + runForceLayout(); + wireHostToWebviewMessages(); + wireToolbar(); + wireCanvas(); + renderAll(); + setTimeout(fitView, 50); +} + +boot(); diff --git a/src/services/ConnectionManager.ts b/src/services/ConnectionManager.ts index e20ae39..9bf44e7 100644 --- a/src/services/ConnectionManager.ts +++ b/src/services/ConnectionManager.ts @@ -121,16 +121,20 @@ export class ConnectionManager { const telemetry = TelemetryService.getInstance(); const key = this.getConnectionKey(config); let pool = this.pools.get(key); + let trackNewPooledConnection = false; if (!pool) { const clientConfig = await this.createClientConfig(config); pool = this.createPool(clientConfig, key); this.pools.set(key, pool); + trackNewPooledConnection = true; } try { const client = await pool.connect(); - telemetry.trackEvent('connection_opened', { connectionKind: 'pooled' }); + if (trackNewPooledConnection) { + telemetry.trackEvent('connection_opened', { connectionKind: 'pooled' }); + } // Apply read-only mode if configured if (config.readOnlyMode) { diff --git a/src/services/DdlViewerService.ts b/src/services/DdlViewerService.ts index f7bba93..1274555 100644 --- a/src/services/DdlViewerService.ts +++ b/src/services/DdlViewerService.ts @@ -3,6 +3,7 @@ import { PoolClient } from 'pg'; import { DatabaseTreeItem } from '../providers/DatabaseTreeProvider'; import { ConnectionManager } from './ConnectionManager'; import { createMetadata, getConnectionWithPassword } from '../commands/connection'; +import { PG_VERSION_10, queryServerVersionNum } from '../lib/postgresServerVersion'; const DDL_VIEWER_SCHEME = 'pgstudio-ddl'; const DDL_VIEWER_ENABLED_CONFIG = 'pgstudio.ddlViewer.enabled'; @@ -460,8 +461,10 @@ class DdlContentProvider implements vscode.TextDocumentContentProvider { } private async generateTableDdl(client: PoolClient, target: DdlViewerTarget): Promise { - const metaResult = await client.query( - `SELECT + const pgVer = await queryServerVersionNum(client); + const metaSql = + pgVer >= PG_VERSION_10 + ? `SELECT c.oid, pg_get_userbyid(c.relowner) AS owner, COALESCE(ts.spcname, 'pg_default') AS tablespace, @@ -476,9 +479,24 @@ class DdlContentProvider implements vscode.TextDocumentContentProvider { LEFT JOIN pg_tablespace ts ON ts.oid = c.reltablespace WHERE n.nspname = $1 AND c.relname = $2 - AND c.relkind IN ('r', 'p')`, - [target.schema, target.objectName] - ); + AND c.relkind IN ('r', 'p')` + : `SELECT + c.oid, + pg_get_userbyid(c.relowner) AS owner, + COALESCE(ts.spcname, 'pg_default') AS tablespace, + c.reltuples::bigint AS estimated_rows, + c.relkind, + c.relrowsecurity, + false AS relispartition, + NULL::text AS partition_key, + obj_description(c.oid, 'pg_class') AS table_comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + LEFT JOIN pg_tablespace ts ON ts.oid = c.reltablespace + WHERE n.nspname = $1 + AND c.relname = $2 + AND c.relkind = 'r'`; + const metaResult = await client.query(metaSql, [target.schema, target.objectName]); const tableMeta = metaResult.rows[0]; if (!tableMeta) { diff --git a/src/services/QueryAnalyzer.ts b/src/services/QueryAnalyzer.ts index ed05e8d..aa32c6d 100644 --- a/src/services/QueryAnalyzer.ts +++ b/src/services/QueryAnalyzer.ts @@ -1,4 +1,5 @@ import { ConnectionConfig } from '../common/types'; +import { SqlParser } from '../providers/kernel/SqlParser'; /** * Execution plan performance metrics extracted from EXPLAIN JSON @@ -200,16 +201,20 @@ export class QueryAnalyzer { // Detect ALTER operations const alterMatch = normalizedQuery.match( - /\bALTER\s+(TABLE|DATABASE|SCHEMA|VIEW|FUNCTION|PROCEDURE)\s+([^\s;]+)/i + /\bALTER\s+(TABLE|DATABASE|SCHEMA|VIEW|FUNCTION|PROCEDURE)\s+([^\s;]+)(?:\s+(.+?))?(?:;|$)/i ); if (alterMatch) { + const objectType = alterMatch[1].toLowerCase(); + const objectName = alterMatch[2]; + const alterAction = (alterMatch[3] || '').trim(); + const actionSummary = alterAction ? ` (${alterAction})` : ''; operations.push({ type: 'ALTER', severity: 'high', - reason: `Altering ${alterMatch[1].toLowerCase()}: ${alterMatch[2]}`, - affectedObjects: [alterMatch[2]], + reason: `Altering ${objectType}: ${objectName}${actionSummary}`, + affectedObjects: [objectName], hasWhereClause: false, - estimatedImpact: 'Schema changes may affect dependent objects', + estimatedImpact: this.describeAlterImpact(alterAction), }); } @@ -260,6 +265,27 @@ export class QueryAnalyzer { }; } + /** + * Whether successfully executing this statement should drop SQL completion caches + * (objects/columns/FKs) for the current database. + */ + public isCatalogInvalidatingSql(sql: string): boolean { + const q = SqlParser.stripCommentsAndStrings(sql).trim(); + if (!q) { + return false; + } + return /^\s*(CREATE|ALTER|DROP)\s+/i.test(q) || /^\s*TRUNCATE\s+/i.test(q); + } + + /** Session changes that affect completion search_path ordering (refresh catalog cache). */ + public isSearchPathChangingSql(sql: string): boolean { + const q = SqlParser.stripCommentsAndStrings(sql).trim(); + if (!q) { + return false; + } + return /^\s*set\s+search_path\b/i.test(q); + } + /** * Normalize query by removing comments and extra whitespace */ @@ -317,23 +343,23 @@ export class QueryAnalyzer { operations: DangerousOperation[], connection?: ConnectionConfig ): boolean { - // Always require confirmation for critical operations - if (operations.some((op) => op.severity === 'critical')) { + // Always require confirmation for destructive operations. + if (operations.some((op) => ['DROP', 'TRUNCATE', 'DELETE', 'UPDATE', 'ALTER'].includes(op.type))) { return true; } - // Require confirmation for high severity on production + // Require confirmation for CREATE on production. if ( connection?.environment === 'production' && - operations.some((op) => op.severity === 'high') + operations.some((op) => op.type === 'CREATE') ) { return true; } - // Require confirmation for medium severity on production without WHERE + // Require confirmation for permission changes on production or when they are broad. if ( - connection?.environment === 'production' && - operations.some((op) => op.severity === 'medium' && !op.hasWhereClause) + operations.some((op) => op.type === 'GRANT' || op.type === 'REVOKE') && + (connection?.environment === 'production' || operations.some((op) => !op.hasWhereClause)) ) { return true; } @@ -341,6 +367,35 @@ export class QueryAnalyzer { return false; } + /** + * Describe the impact of an ALTER TABLE action using the specific subcommand when possible. + */ + private describeAlterImpact(alterAction: string): string { + const action = alterAction.toUpperCase(); + if (action.startsWith('ADD COLUMN')) { + return 'New column will be added to the table'; + } + if (action.startsWith('DROP COLUMN')) { + return 'Column and its data may be removed'; + } + if (action.startsWith('ALTER COLUMN')) { + return 'Existing column definition may change'; + } + if (action.startsWith('RENAME TO') || action.startsWith('RENAME COLUMN')) { + return 'Object names will change and dependent queries may break'; + } + if (action.startsWith('SET DATA TYPE')) { + return 'Column data type will change and may require a table rewrite'; + } + if (action.startsWith('ADD CONSTRAINT')) { + return 'A new constraint will be enforced on future writes'; + } + if (action.startsWith('DROP CONSTRAINT')) { + return 'An existing constraint will be removed'; + } + return 'Schema changes may affect dependent objects'; + } + /** * Build warning message for user confirmation */ diff --git a/src/services/SecretStorageService.ts b/src/services/SecretStorageService.ts index 5901997..da1f0d5 100644 --- a/src/services/SecretStorageService.ts +++ b/src/services/SecretStorageService.ts @@ -22,6 +22,10 @@ export class SecretStorageService { return await this.context.secrets.get('postgresExplorer.aiApiKey'); } + public async getCursorApiKey(): Promise { + return await this.context.secrets.get('postgresExplorer.cursorApiKey'); + } + public async setPassword(connectionId: string, password: string): Promise { await this.context.secrets.store(`postgres-password-${connectionId}`, password); } @@ -30,6 +34,10 @@ export class SecretStorageService { await this.context.secrets.store('postgresExplorer.aiApiKey', apiKey); } + public async setCursorApiKey(apiKey: string): Promise { + await this.context.secrets.store('postgresExplorer.cursorApiKey', apiKey); + } + public async deletePassword(connectionId: string): Promise { await this.context.secrets.delete(`postgres-password-${connectionId}`); } @@ -38,6 +46,10 @@ export class SecretStorageService { await this.context.secrets.delete('postgresExplorer.aiApiKey'); } + public async deleteCursorApiKey(): Promise { + await this.context.secrets.delete('postgresExplorer.cursorApiKey'); + } + /** GitHub PAT with `gist` scope — used only for “Publish notebook to Gist”. */ public async getGithubGistToken(): Promise { return await this.context.secrets.get('postgresExplorer.githubGistToken'); @@ -57,26 +69,57 @@ export class SecretStorageService { * This keeps the logic isolated but accessible to extension.ts */ export async function migrateExistingPasswords(context: vscode.ExtensionContext): Promise { - const connections = context.globalState.get('postgresql.connections') || []; + // Support both the modern settings-based connections and older globalState + const settings = vscode.workspace.getConfiguration(); + const settingsKey = 'postgresExplorer.connections'; + const legacyKey = 'postgresql.connections'; + + const settingsConnections = settings.get(settingsKey) || []; + const legacyConnections = context.globalState.get(legacyKey) || []; + let migratedCount = 0; + let settingsDirty = false; + let legacyDirty = false; - for (const conn of connections) { - if (conn.password) { - try { - // Store in secret storage - await SecretStorageService.getInstance(context).setPassword(conn.id, conn.password); - - // Remove from connection object and update globalState - delete conn.password; - migratedCount++; - } catch (error) { - console.error(`Failed to migrate password for connection ${conn.name}:`, error); - } + const ensureId = (conn: any, idx: number) => { + if (!conn.id) { + conn.id = `${Date.now()}-${idx}`; } + }; + + const tryMigrate = async (conn: any, idx: number, source: 'settings' | 'legacy') => { + if (!conn || !conn.password) return; + try { + ensureId(conn, idx); + await SecretStorageService.getInstance(context).setPassword(conn.id, conn.password); + delete conn.password; + migratedCount++; + if (source === 'settings') settingsDirty = true; else legacyDirty = true; + } catch (error) { + console.error(`Failed to migrate password for connection ${conn.name || conn.id}:`, error); + } + }; + + // Migrate from settings-based connections + for (let i = 0; i < settingsConnections.length; i++) { + await tryMigrate(settingsConnections[i], i, 'settings'); + } + + // Migrate from legacy globalState connections + for (let i = 0; i < legacyConnections.length; i++) { + await tryMigrate(legacyConnections[i], i, 'legacy'); + } + + // Persist any cleaned-up sources + if (settingsDirty) { + await settings.update(settingsKey, settingsConnections, vscode.ConfigurationTarget.Global); + } + + if (legacyDirty) { + await context.globalState.update(legacyKey, legacyConnections); } if (migratedCount > 0) { - await context.globalState.update('postgresql.connections', connections); console.log(`Migrated ${migratedCount} passwords to Secret Storage`); } } diff --git a/src/services/TelemetryService.ts b/src/services/TelemetryService.ts index 42ff78f..a17a8ba 100644 --- a/src/services/TelemetryService.ts +++ b/src/services/TelemetryService.ts @@ -7,19 +7,16 @@ type TelemetryEventKind = 'usage' | 'performance'; const DEFAULT_FLUSH_INTERVAL_MS = 10_000; const DEFAULT_MAX_BATCH_SIZE = 25; +/** Cap queued envelopes so bursty callers cannot grow memory unbounded; oldest dropped first. */ +const MAX_QUEUED_EVENTS = 500; const MAX_PROPERTY_VALUE_LENGTH = 200; const REDACTED_VALUE = '[redacted]'; -const SAFE_ERROR_CATEGORY_UNKNOWN = 'unknown'; const SENSITIVE_KEY_PATTERN = /(password|secret|token|authorization|cookie|sql|query|database|schema|host|user|email|name|credential|connection)/i; const EVENT_SCHEMA: Record }> = { extension_activated: { kind: 'usage', allowedProps: new Set(['version']) }, - extension_deactivated: { kind: 'usage', allowedProps: new Set(['sessionDurationBucket']) }, - session_started: { kind: 'usage', allowedProps: new Set(['source']) }, - session_ended: { kind: 'usage', allowedProps: new Set(['durationBucket']) }, - active_day_ping: { kind: 'usage', allowedProps: new Set(['day']) }, - active_week_ping: { kind: 'usage', allowedProps: new Set(['week']) }, + extension_deactivated: { kind: 'usage', allowedProps: new Set(['durationBucket']) }, command_invoked: { kind: 'usage', allowedProps: new Set(['group']) }, feature_used: { kind: 'usage', allowedProps: new Set(['feature']) }, connection_opened: { kind: 'usage', allowedProps: new Set(['connectionKind']) }, @@ -202,6 +199,10 @@ export class TelemetryService { }; this.queue.push(envelope); + if (this.queue.length > MAX_QUEUED_EVENTS) { + const overflow = this.queue.length - MAX_QUEUED_EVENTS; + this.queue.splice(0, overflow); + } if (this.queue.length >= this.config.maxBatchSize) { void this.flush(); } @@ -222,13 +223,15 @@ export class TelemetryService { } } - public trackSessionStart(): void { - this.trackEvent('session_started', { source: 'extension' }); + /** One usage event on shutdown; avoids duplicate session_ended + extension_deactivated. */ + public trackExtensionDeactivate(): void { + const bucket = this.bucketDuration(Date.now() - this.sessionStartMs); + this.trackEvent('extension_deactivated', { durationBucket: bucket }); } - public trackSessionEnd(): void { - const bucket = this.bucketDuration(Date.now() - this.sessionStartMs); - this.trackEvent('session_ended', { durationBucket: bucket }); + /** Public so callers can align performance buckets with span/query telemetry. */ + public durationBucket(durationMs: number): string { + return this.bucketDuration(durationMs); } public startSpan(name: string, attributes?: Record): string { @@ -279,7 +282,6 @@ export class TelemetryService { durationBucket: this.bucketDuration(durationMs), success: false, }); - this.trackEvent('connection_error', { errorCategory: this.errorCategory(error) }); this.spans.delete(spanId); } @@ -413,15 +415,6 @@ export class TelemetryService { if (durationMs < 30_000) return '5_30s'; return 'gte_30s'; } - - private errorCategory(error: Error): string { - const message = error.message.toLowerCase(); - if (message.includes('timeout')) return 'timeout'; - if (message.includes('auth')) return 'auth'; - if (message.includes('network')) return 'network'; - if (message.includes('ssl')) return 'ssl'; - return SAFE_ERROR_CATEGORY_UNKNOWN; - } } export interface TelemetrySummary { diff --git a/src/test/unit/AiService.test.ts b/src/test/unit/AiService.test.ts index d628a7a..ff470bc 100644 --- a/src/test/unit/AiService.test.ts +++ b/src/test/unit/AiService.test.ts @@ -157,6 +157,41 @@ describe('AiService', () => { expect(githubDefault).to.equal('openai/gpt-4.1'); }); + it('uses the Cursor SDK for model info and request execution', async () => { + const service = new AiService(); + const cursorModels = [ + { id: 'composer-2', displayName: 'Composer 2' }, + { id: 'auto', displayName: 'Auto' } + ]; + const loadCursorSdkStub = sandbox.stub(service as any, '_loadCursorSdk').resolves({ + Cursor: { + me: sandbox.stub().resolves({ userEmail: 'user@example.com' }), + models: { + list: sandbox.stub().resolves(cursorModels) + } + }, + Agent: { + create: sandbox.stub().resolves({ + send: sandbox.stub().resolves({ + wait: sandbox.stub().resolves({ status: 'finished', result: 'Cursor answer', durationMs: 1234 }), + cancel: sandbox.stub() + }), + close: sandbox.stub() + }) + } + }); + sandbox.stub(SecretStorageService, 'getInstance').returns({ + getCursorApiKey: sandbox.stub().resolves('cursor-secret') + } as any); + + const modelInfo = await service.getModelInfo('cursor', createConfig({})); + expect(modelInfo).to.equal('Composer 2'); + + const result = await service.callProvider('cursor', 'Summarize the query', createConfig({ aiModel: 'composer-2' }), 'Use concise SQL guidance'); + expect(result.text).to.equal('Cursor answer'); + expect(loadCursorSdkStub.called).to.be.true; + }); + it('calls VS Code LM with image attachments and streamed text', async () => { const service = new AiService(); service.setMessages([createImageMessage() as any]); diff --git a/src/test/unit/ChatViewProvider.test.ts b/src/test/unit/ChatViewProvider.test.ts index 6aa69b4..b46a3bd 100644 --- a/src/test/unit/ChatViewProvider.test.ts +++ b/src/test/unit/ChatViewProvider.test.ts @@ -36,6 +36,12 @@ function createProviderHarness(sandbox: sinon.SinonSandbox) { const aiService = { getModelInfo: sandbox.stub().resolves('Mock AI'), + callProvider: sandbox.stub().callsFake(async (provider: string, message: string, config: any, systemPrompt?: string) => { + if (provider === 'vscode-lm') { + return await aiService.callVsCodeLm(message, config, systemPrompt); + } + return await aiService.callDirectApi(provider, message, config, systemPrompt); + }), callVsCodeLm: sandbox.stub().resolves({ text: 'SELECT 1;', usage: 'usage' }), callDirectApi: sandbox.stub(), setMessages: sandbox.stub(), @@ -345,7 +351,7 @@ describe('ChatViewProvider', () => { await clock.tickAsync(200); await attachPromise; - expect(showStub.calledOnceWithExactly(true)).to.be.true; + expect(showStub.called).to.be.false; expect(getObjectSchemaStub.calledOnce).to.be.true; expect(postMessage.calledWithMatch({ type: 'addMentionFromTree', diff --git a/src/test/unit/QueryAnalyzer.test.ts b/src/test/unit/QueryAnalyzer.test.ts index 815e7d3..6943544 100644 --- a/src/test/unit/QueryAnalyzer.test.ts +++ b/src/test/unit/QueryAnalyzer.test.ts @@ -34,6 +34,7 @@ describe('QueryAnalyzer', () => { hasWhereClause: false, estimatedImpact: 'All rows will be deleted' }); + expect(truncateResult.operations[0].reason).to.contain('Truncating table: audit_log'); expect(truncateResult.riskScore).to.equal(40); expect(truncateResult.requiresConfirmation).to.be.true; @@ -52,10 +53,12 @@ describe('QueryAnalyzer', () => { type: 'ALTER', severity: 'high', hasWhereClause: false, - estimatedImpact: 'Schema changes may affect dependent objects' + estimatedImpact: 'New column will be added to the table' }); + expect(alterResult.operations[0].reason).to.contain('ADD COLUMN active boolean'); expect(alterResult.riskScore).to.equal(25); - expect(alterResult.requiresConfirmation).to.be.false; + expect(alterResult.requiresConfirmation).to.be.true; + expect(alterResult.warningMessage).to.contain('ADD COLUMN active boolean'); const createResult = analyzer.analyzeQuery('CREATE TABLE public.logs (id int)', production); expect(createResult.operations[0]).to.include({ @@ -83,7 +86,7 @@ describe('QueryAnalyzer', () => { hasWhereClause: false, estimatedImpact: 'Permission changes' }); - expect(revokeResult.requiresConfirmation).to.be.false; + expect(revokeResult.requiresConfirmation).to.be.true; }); it('distinguishes write queries with and without WHERE clauses', () => { @@ -105,7 +108,7 @@ describe('QueryAnalyzer', () => { hasWhereClause: true }); expect(deleteWithWhere.riskScore).to.equal(10); - expect(deleteWithWhere.requiresConfirmation).to.be.false; + expect(deleteWithWhere.requiresConfirmation).to.be.true; const updateWithoutWhere = analyzer.analyzeQuery('UPDATE public.users SET active = false', production); expect(updateWithoutWhere.operations[0]).to.include({ @@ -123,7 +126,7 @@ describe('QueryAnalyzer', () => { hasWhereClause: true }); expect(updateWithWhere.riskScore).to.equal(20); - expect(updateWithWhere.requiresConfirmation).to.be.false; + expect(updateWithWhere.requiresConfirmation).to.be.true; }); it('recognizes read-only queries after stripping comments and whitespace', () => { @@ -208,7 +211,9 @@ describe('QueryAnalyzer', () => { maxExecutionTime: 120, stdDev: 5, sampleCount: 4, - lastUpdated: Date.now() + lastUpdated: Date.now(), + m2: 0, + schemaVersion: 0 }, explainPlan); expect(degraded.isDegraded).to.be.true; expect(degraded.degradationPercent).to.equal(50); @@ -221,7 +226,9 @@ describe('QueryAnalyzer', () => { maxExecutionTime: 120, stdDev: 5, sampleCount: 4, - lastUpdated: Date.now() + lastUpdated: Date.now(), + m2: 0, + schemaVersion: 0 }, explainPlan); expect(withinBaseline.isDegraded).to.be.false; expect(withinBaseline.degradationPercent).to.equal(0); @@ -234,8 +241,9 @@ describe('QueryAnalyzer', () => { const heavy = analyzer.analyzeQuery('DROP TABLE users; TRUNCATE audit_logs;', staging); expect(heavy.isDangerous).to.be.true; expect(heavy.operations).to.have.length.greaterThan(0); - expect(heavy.riskScore).to.equal(60); + expect(heavy.riskScore).to.equal(100); expect(heavy.warningMessage).to.contain('STAGING DATABASE'); + expect(heavy.warningMessage).to.contain('Truncating table: audit_logs'); const readOnly = analyzer.analyzeQuery('SELECT * FROM users WHERE id = 1'); expect(readOnly.isDangerous).to.be.false; @@ -247,4 +255,11 @@ describe('QueryAnalyzer', () => { expect(analyzer.isReadOnlyQuery('/* cleanup */\nWITH x AS (SELECT 1) SELECT * FROM x;')).to.be.true; expect(analyzer.isReadOnlyQuery('WITH d AS (DELETE FROM users WHERE id=1) SELECT * FROM d;')).to.be.false; }); + + it('detects SET search_path as session metadata invalidating completion ordering', () => { + expect(analyzer.isSearchPathChangingSql('SET search_path TO foo, public')).to.be.true; + expect(analyzer.isSearchPathChangingSql('SET search_path = bar')).to.be.true; + expect(analyzer.isSearchPathChangingSql('SELECT set_search_path')).to.be.false; + expect(analyzer.isSearchPathChangingSql('SELECT 1')).to.be.false; + }); }); \ No newline at end of file diff --git a/src/test/unit/SqlCompletionProvider.test.ts b/src/test/unit/SqlCompletionProvider.test.ts index 7184030..435cea4 100644 --- a/src/test/unit/SqlCompletionProvider.test.ts +++ b/src/test/unit/SqlCompletionProvider.test.ts @@ -18,6 +18,15 @@ function attachNotebook(document: vscode.TextDocument, metadata: any) { return notebook; } +function attachNotebookMultiCells(documents: vscode.TextDocument[], metadata: any) { + const notebook = new vscode.NotebookDocument(vscode.Uri.file('/workspace/sql-notebook.pgsql'), metadata); + (notebook as any).notebookType = 'postgres-notebook'; + const cells = documents.map((doc, i) => new vscode.NotebookCell(doc, i, vscode.NotebookCellKind.Code)); + notebook.getCells = () => cells; + vscode.workspace.notebookDocuments = [notebook]; + return notebook; +} + describe('SqlCompletionProvider', () => { let sandbox: sinon.SinonSandbox; let getConfigurationStub: sinon.SinonStub; @@ -46,6 +55,23 @@ describe('SqlCompletionProvider', () => { vscode.workspace.notebookDocuments = []; }); + /** Query order: objects, columns, FKs, search_path, composites, roles (single `_fetchAndStoreCache` round-trip). */ + const setupCacheResults = ( + objectsRows: any[], + columnsRows: any[], + foreignKeyRows: any[] = [], + searchPath = 'public', + compositeRows: any[] = [], + roleRows: any[] = [{ rolname: 'postgres' }] + ) => { + queryStub.onCall(0).resolves({ rows: objectsRows }); + queryStub.onCall(1).resolves({ rows: columnsRows }); + queryStub.onCall(2).resolves({ rows: foreignKeyRows }); + queryStub.onCall(3).resolves({ rows: [{ search_path: searchPath }] }); + queryStub.onCall(4).resolves({ rows: compositeRows }); + queryStub.onCall(5).resolves({ rows: roleRows }); + }; + it('returns empty completions for unsupported documents', async () => { const provider = new SqlCompletionProvider(); @@ -84,48 +110,68 @@ describe('SqlCompletionProvider', () => { : undefined) } as any); - queryStub.onFirstCall().resolves({ - rows: [ - { schema: 'public', table_name: 'users' }, - { schema: 'sales', table_name: 'orders' } - ] - }); - queryStub.onSecondCall().resolves({ - rows: [ + setupCacheResults( + [ + { schema: 'public', object_name: 'users', object_type: 'table' }, + { schema: 'sales', object_name: 'orders', object_type: 'table' }, + { schema: 'sales', object_name: 'monthly_sales', object_type: 'view' }, + { schema: 'sales', object_name: 'recompute_totals', object_type: 'function', arguments: 'customer_id integer, include_tax boolean', call_arguments: 'customer_id integer, include_tax boolean' }, + { schema: 'sales', object_name: 'sync_inventory', object_type: 'procedure', arguments: 'warehouse_id integer', call_arguments: 'warehouse_id integer' } + ], + [ { schema: 'public', table_name: 'users', column_name: 'user_id', data_type: 'integer' }, { 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 u.user_id, o.order_total FROM public.users u JOIN sales.orders o ON o.user_id = u.user_id;' - ); + const sql = + 'SELECT u.user_id, o.order_total FROM public.users u JOIN sales.orders o ON o.user_id = u.user_id'; + const document = createNotebookCellDocument(sql); attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); - const firstItems = await provider.provideCompletionItems(document, new vscode.Position(0, document.text.length), {} as any, {} as any); - const secondItems = await provider.provideCompletionItems(document, new vscode.Position(0, document.text.length), {} as any, {} as any); + await provider.warmCache('conn-1', 'appdb'); + + const selectClausePos = new vscode.Position(0, 'SELECT '.length); + const firstItems = await provider.provideCompletionItems(document, selectClausePos, {} as any, {} as any); + const secondItems = await provider.provideCompletionItems(document, selectClausePos, {} as any, {} as any); const firstLabels = firstItems.map(item => item.label); expect(getPooledClientStub.calledOnce).to.be.true; expect(releaseStub.calledOnce).to.be.true; - expect(queryStub.calledTwice).to.be.true; - expect(firstLabels).to.include('SELECT'); - expect(firstLabels).to.include('users'); - expect(firstLabels).to.include('orders'); + expect(queryStub.callCount).to.equal(6); + expect(queryStub.firstCall.args[0]).to.contain('NULL::text as arguments'); + expect(queryStub.firstCall.args[0]).to.contain('pg_get_function_arguments(p.oid) AS arguments'); + expect(queryStub.firstCall.args[0]).to.contain('pg_get_function_identity_arguments(p.oid) AS call_arguments'); expect(firstLabels).to.include('user_id'); + expect(firstLabels).to.include('email'); expect(firstLabels).to.include('order_total'); + expect(firstLabels).to.include('recompute_totals'); + expect(firstLabels).to.include('sync_inventory'); - const usersItem = firstItems.find(item => item.label === 'users'); const emailItem = firstItems.find(item => item.label === 'email'); const orderTotalItem = firstItems.find(item => item.label === 'order_total'); + const recomputeTotalsItem = firstItems.find(item => item.label === 'recompute_totals'); + const syncInventoryItem = firstItems.find(item => item.label === 'sync_inventory'); - expect(usersItem?.sortText).to.equal('0-users'); - expect(emailItem?.sortText).to.equal('0-email'); - expect(orderTotalItem?.sortText).to.equal('0-order_total'); + expect(emailItem?.sortText).to.equal('0-00-0001'); + expect(orderTotalItem?.sortText).to.equal('0-01-0000'); + expect(emailItem?.insertText).to.equal('u.email'); + expect(orderTotalItem?.insertText).to.equal('o.order_total'); + expect((recomputeTotalsItem?.insertText as any)?.value || recomputeTotalsItem?.insertText).to.equal('recompute_totals(${1:customer_id}, ${2:include_tax})'); + expect((syncInventoryItem?.insertText as any)?.value || syncInventoryItem?.insertText).to.equal('sync_inventory(${1:warehouse_id})'); expect(secondItems.map(item => item.label)).to.deep.equal(firstLabels); + const sqlFromAfterCursor = 'SELECT u FROM public.users u'; + const docAliasPrefix = createNotebookCellDocument(sqlFromAfterCursor, 'cell-alias-prefix'); + attachNotebook(docAliasPrefix, { connectionId: 'conn-1', databaseName: 'appdb' }); + const aliasPrefixPos = new vscode.Position(0, 'SELECT u'.length); + const aliasPrefixItems = await provider.provideCompletionItems(docAliasPrefix, aliasPrefixPos, {} as any, {} as any); + const aliasPrefixLabels = aliasPrefixItems.map(item => item.label); + expect(aliasPrefixLabels).to.include('email'); + expect(aliasPrefixItems.find(i => i.label === 'email')?.insertText).to.equal('u.email'); + const fallbackDocument = createNotebookCellDocument('SELECT 1;', 'cell-2'); attachNotebook(fallbackDocument, { connectionId: 'conn-1', databaseName: 'appdb' }); const fallbackItems = await provider.provideCompletionItems(fallbackDocument, new vscode.Position(0, fallbackDocument.text.length), {} as any, {} as any); @@ -140,30 +186,348 @@ describe('SqlCompletionProvider', () => { : 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: [ + setupCacheResults( + [ + { schema: 'public', object_name: 'users', object_type: 'table' }, + { schema: 'public', object_name: 'users', object_type: 'table' }, + { schema: 'sales', object_name: 'orders', object_type: 'table' } + ], + [ { 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;'); + const sql = 'SELECT * FROM public.users u JOIN sales.orders o WHERE '; + const document = createNotebookCellDocument(sql); attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); - const items = await provider.provideCompletionItems(document, new vscode.Position(0, document.text.length), {} as any, {} as any); + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, sql.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); + expect(labels.filter(label => label === 'order_total')).to.have.length(1); + }); + + it('after FROM schema. suggests objects in that schema, not columns', 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); + + setupCacheResults( + [ + { schema: 'sales', object_name: 'orders', object_type: 'table' }, + { schema: 'public', object_name: 'customers', object_type: 'table' } + ], + [ + { schema: 'sales', table_name: 'orders', column_name: 'id', data_type: 'integer' }, + { schema: 'public', table_name: 'customers', column_name: 'email', data_type: 'text' } + ] + ); + + const provider = new SqlCompletionProvider(); + const sql = 'SELECT * FROM sales.'; + const document = createNotebookCellDocument(sql); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, sql.length), {} as any, {} as any); + const labels = items.map(item => item.label); + + expect(labels).to.include('orders'); + expect(labels).to.not.include('customers'); + expect(labels).to.not.include('id'); + expect(labels).to.not.include('email'); + }); + + it('keeps the schema context when inserting objects from a schema-prefixed completion', 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); + + setupCacheResults( + [ + { schema: 'public', object_name: 'users', object_type: 'table' }, + { schema: 'sales', object_name: 'orders', object_type: 'table' }, + { schema: 'sales', object_name: 'monthly_sales', object_type: 'view' }, + { schema: 'sales', object_name: 'recompute_totals', object_type: 'function' } + ], + [] + ); + + const provider = new SqlCompletionProvider(); + const document = createNotebookCellDocument('SELECT * FROM sales.'); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', '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); + const ordersItem = items.find(item => item.label === 'orders'); + const usersItem = items.find(item => item.label === 'users'); + + expect(labels).to.include('orders'); + expect(labels).to.include('monthly_sales'); + expect(labels).to.include('recompute_totals'); + expect(labels).to.not.include('users'); + expect(ordersItem?.insertText).to.equal('orders'); + expect(usersItem).to.be.undefined; + }); + + it('narrows column completions to the relation in the current query context', 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); + + setupCacheResults( + [ + { schema: 'public', object_name: 'users', object_type: 'table' }, + { schema: 'sales', object_name: 'orders', object_type: 'table' } + ], + [ + { schema: 'public', table_name: 'users', column_name: 'id', data_type: 'integer' }, + { 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 sql = 'SELECT FROM public.users u'; + const document = createNotebookCellDocument(sql); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, 'SELECT '.length), {} as any, {} as any); + const labels = items.map(item => item.label); + const idItem = items.find(item => item.label === 'id'); + + expect(labels).to.include('id'); + expect(labels).to.include('email'); + expect(labels).to.not.include('order_total'); + expect(idItem?.insertText).to.equal('u.id'); + }); + + it('does not duplicate an already typed column qualifier', 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); + + setupCacheResults( + [ + { schema: 'public', object_name: 'users', object_type: 'table' } + ], + [ + { schema: 'public', table_name: 'users', column_name: 'created_at', data_type: 'timestamp with time zone' } + ] + ); + + const provider = new SqlCompletionProvider(); + const document = createNotebookCellDocument('SELECT tn. FROM public.users tn'); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, 'SELECT tn.'.length), {} as any, {} as any); + const createdAtItem = items.find(item => item.label === 'created_at'); + + expect(createdAtItem?.insertText).to.equal('created_at'); + }); + + it('shows only columns from the specified alias when using qualified prefix with multiple joins', 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); + + setupCacheResults( + [ + { schema: 'ecom', object_name: 'orders', object_type: 'table' }, + { schema: 'ecom', object_name: 'order_items', object_type: 'table' } + ], + [ + { schema: 'ecom', table_name: 'orders', column_name: 'id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'orders', column_name: 'customer_id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'orders', column_name: 'order_status', data_type: 'text' }, + { schema: 'ecom', table_name: 'order_items', column_name: 'item_id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'order_items', column_name: 'order_id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'order_items', column_name: 'product_id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'order_items', column_name: 'quantity', data_type: 'integer' } + ] + ); + + const provider = new SqlCompletionProvider(); + const document = createNotebookCellDocument('SELECT * FROM ecom.orders o JOIN ecom.order_items oi ON o.id = oi.order_id WHERE oi.'); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const textWithoutDot = 'SELECT * FROM ecom.orders o JOIN ecom.order_items oi ON o.id = oi.order_id WHERE oi'; + const position = new vscode.Position(0, textWithoutDot.length + 1); + const items = await provider.provideCompletionItems(document, position, {} as any, {} as any); + const labels = items.map(item => item.label); + + // Should include columns from order_items + expect(labels).to.include('item_id'); + expect(labels).to.include('order_id'); + expect(labels).to.include('product_id'); + expect(labels).to.include('quantity'); + + // Should NOT include columns from orders + expect(labels).to.not.include('customer_id'); + expect(labels).to.not.include('order_status'); + + // Verify insert text is just the column name (no prefix duplication) + const itemIdItem = items.find(item => item.label === 'item_id'); + expect(itemIdItem?.insertText).to.equal('item_id'); + }); + + it('binds alias after duplicated schema segment (ecom.ecom.table) to the real table', 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); + + setupCacheResults( + [ + { schema: 'ecom', object_name: 'orders', object_type: 'table' }, + { schema: 'ecom', object_name: 'order_items', object_type: 'table' } + ], + [ + { schema: 'ecom', table_name: 'orders', column_name: 'customer_id', data_type: 'integer' }, + { schema: 'ecom', table_name: 'order_items', column_name: 'product_id', data_type: 'integer' } + ] + ); + + const provider = new SqlCompletionProvider(); + const sql = + 'SELECT * FROM ecom.orders o JOIN ecom.ecom.order_items oi ON o.id = oi.order_id WHERE oi.'; + const document = createNotebookCellDocument(sql); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const position = new vscode.Position(0, sql.length); + const items = await provider.provideCompletionItems(document, position, {} as any, {} as any); + const labels = items.map(item => item.label); + + expect(labels).to.include('product_id'); + expect(labels).to.not.include('customer_id'); + }); + + it('loads full catalog in one round-trip on first completion (four queries, one connection)', 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); + + setupCacheResults( + [{ schema: 'public', object_name: 'users', object_type: 'table' }], + [{ schema: 'public', table_name: 'users', column_name: 'id', data_type: 'integer' }] + ); + + const provider = new SqlCompletionProvider(); + const sql = 'SELECT FROM public.users u'; + const document = createNotebookCellDocument(sql); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + const pos = new vscode.Position(0, 'SELECT '.length); + const itemsFirst = await provider.provideCompletionItems(document, pos, {} as any, {} as any); + + expect(getPooledClientStub.calledOnce).to.be.true; + expect(releaseStub.calledOnce).to.be.true; + expect(queryStub.callCount).to.equal(6); + expect(itemsFirst.map(i => i.label)).to.include('id'); + + queryStub.resetHistory(); + getPooledClientStub.resetHistory(); + releaseStub.resetHistory(); + + const itemsCached = await provider.provideCompletionItems(document, pos, {} as any, {} as any); + expect(getPooledClientStub.called).to.be.false; + expect(itemsCached.map(i => i.label)).to.include('id'); + }); + + it('includes prior notebook SQL cells so CTE names resolve in a later cell', 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); + + setupCacheResults([{ schema: 'public', object_name: 'users', object_type: 'table' }], []); + + const provider = new SqlCompletionProvider(); + const cellA = createNotebookCellDocument('WITH t AS (SELECT 1 AS cx)', 'cell-a'); + const cellB = createNotebookCellDocument('SELECT t.', 'cell-b'); + attachNotebookMultiCells([cellA, cellB], { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(cellB, new vscode.Position(0, 'SELECT t.'.length), {} as any, {} as any); + expect(items.map(i => i.label)).to.include('cx'); + }); + + it('suggests columns from a derived subquery alias', 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); + + setupCacheResults( + [{ schema: 'public', object_name: 'users', object_type: 'table' }], + [{ schema: 'public', table_name: 'users', column_name: 'user_id', data_type: 'integer' }] + ); + + const provider = new SqlCompletionProvider(); + const sql = 'SELECT sq. FROM (SELECT user_id AS uid FROM public.users) sq'; + const document = createNotebookCellDocument(sql); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, 'SELECT sq.'.length), {} as any, {} as any); + expect(items.map(i => i.label)).to.include('uid'); + }); + + it('INSERT INTO target lists relation objects only', 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); + + setupCacheResults([{ schema: 'public', object_name: 'orders', object_type: 'table' }], []); + + const provider = new SqlCompletionProvider(); + const document = createNotebookCellDocument('INSERT INTO '); + attachNotebook(document, { connectionId: 'conn-1', databaseName: 'appdb' }); + + await provider.warmCache('conn-1', 'appdb'); + + const items = await provider.provideCompletionItems(document, new vscode.Position(0, 'INSERT INTO '.length), {} as any, {} as any); + const labels = items.map(i => i.label); + expect(labels).to.include('orders'); + expect(labels).to.not.include('SELECT'); }); }); \ No newline at end of file diff --git a/src/test/unit/SqlExecutor.bestEffort.test.ts b/src/test/unit/SqlExecutor.bestEffort.test.ts new file mode 100644 index 0000000..6ba3d3c --- /dev/null +++ b/src/test/unit/SqlExecutor.bestEffort.test.ts @@ -0,0 +1,242 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import * as vscode from 'vscode'; +import { SqlExecutor } from '../../providers/kernel/SqlExecutor'; + +/** + * Tests for multi-statement execution with different failure strategies. + * Note: These are unit tests that mock VS Code APIs and test the SqlExecutor's + * summary markdown generation and failure strategy handling logic. + */ +describe('SqlExecutor - Multi-Statement Failure Handling', () => { + let executor: SqlExecutor; + let mockController: sinon.SinonStubbedInstance; + + beforeEach(() => { + // Mock NotebookController + mockController = sinon.createStubInstance(vscode.NotebookController); + executor = new SqlExecutor(mockController as any); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('Failure Strategy Settings', () => { + it('should default to "continue-on-error" strategy', () => { + // This test verifies the default behavior is best-effort + const config = vscode.workspace.getConfiguration('postgresExplorer.query'); + const strategy = config.get('executionFailureStrategy', 'continue-on-error'); + expect(strategy).to.equal('continue-on-error'); + }); + + it('should support "fail-on-error" strategy option', () => { + // Verify that fail-on-error is a valid strategy + const validStrategies = ['continue-on-error', 'fail-on-error', 'prompt-on-error']; + expect(validStrategies).to.include('fail-on-error'); + }); + + it('should support "prompt-on-error" strategy option', () => { + // Verify the setting accepts prompt-on-error as a valid value + const validStrategies = ['continue-on-error', 'fail-on-error', 'prompt-on-error']; + expect(validStrategies).to.include('prompt-on-error'); + }); + }); + + describe('Summary Markdown Generation', () => { + it('should generate summary for all succeeded statements', () => { + const results: any[] = [ + { + stmtIndex: 0, + query: 'SELECT * FROM users;', + success: true, + command: 'SELECT', + rowCount: 5, + executionTime: 0.1, + }, + { + stmtIndex: 1, + query: 'INSERT INTO logs (msg) VALUES (\'test\');', + success: true, + command: 'INSERT', + rowCount: 1, + executionTime: 0.05, + }, + ]; + + // Call the private method through a proxy + const markdown = (executor as any).generateSummaryMarkdown(results); + + expect(markdown).to.contain('## Execution Summary'); + expect(markdown).to.contain('✅ **2 statements succeeded**'); + expect(markdown).to.contain('Statement 1: SELECT (5 rows)'); + expect(markdown).to.contain('Statement 2: INSERT (1 rows)'); + expect(markdown).not.to.contain('❌ **'); + }); + + it('should generate summary for all failed statements', () => { + const results: any[] = [ + { + stmtIndex: 0, + query: 'DROP TABLE nonexistent;', + success: false, + error: 'relation "nonexistent" does not exist', + errorCode: '42P01', + executionTime: 0.02, + }, + { + stmtIndex: 1, + query: 'ALTER TABLE bad_table ADD COLUMN;', + success: false, + error: 'syntax error at or near ";"', + errorCode: '42601', + executionTime: 0.01, + }, + ]; + + const markdown = (executor as any).generateSummaryMarkdown(results); + + expect(markdown).to.contain('## Execution Summary'); + expect(markdown).to.contain('❌ **2 statements failed**'); + expect(markdown).to.contain('Statement 1: relation "nonexistent" does not exist (42P01)'); + expect(markdown).to.contain('Statement 2: syntax error at or near ";" (42601)'); + expect(markdown).not.to.contain('✅ **'); + }); + + it('should generate summary for mixed success/failure results', () => { + const results: any[] = [ + { + stmtIndex: 0, + query: 'SELECT * FROM users;', + success: true, + command: 'SELECT', + rowCount: 5, + executionTime: 0.1, + }, + { + stmtIndex: 1, + query: 'DELETE FROM logs WHERE id = 999;', + success: false, + error: 'permission denied for table logs', + errorCode: '42501', + executionTime: 0.05, + }, + { + stmtIndex: 2, + query: 'INSERT INTO audit (action) VALUES (\'test\');', + success: true, + command: 'INSERT', + rowCount: 1, + executionTime: 0.03, + }, + ]; + + const markdown = (executor as any).generateSummaryMarkdown(results); + + expect(markdown).to.contain('## Execution Summary'); + expect(markdown).to.contain('✅ **2 statements succeeded**'); + expect(markdown).to.contain('❌ **1 statement failed**'); + expect(markdown).to.contain('Statement 1: SELECT (5 rows)'); + expect(markdown).to.contain('Statement 2: permission denied for table logs (42501)'); + expect(markdown).to.contain('Statement 3: INSERT (1 rows)'); + expect(markdown).to.contain('💡 **Tip**: Review the changes above'); + }); + + it('should handle results with no row count', () => { + const results: any[] = [ + { + stmtIndex: 0, + query: 'CREATE TABLE test (id INT);', + success: true, + command: 'CREATE', + rowCount: undefined, + executionTime: 0.15, + }, + { + stmtIndex: 1, + query: 'DROP TABLE test;', + success: true, + command: 'DROP', + rowCount: null, + executionTime: 0.08, + }, + ]; + + const markdown = (executor as any).generateSummaryMarkdown(results); + + expect(markdown).to.contain('Statement 1: CREATE'); + expect(markdown).to.contain('Statement 2: DROP'); + // Should not include "(undefined rows)" or "(null rows)" + expect(markdown).not.to.contain('undefined'); + expect(markdown).not.to.contain('(null'); + }); + + it('should format singular/plural correctly in summary', () => { + const singleSuccess: any[] = [ + { + stmtIndex: 0, + query: 'SELECT 1;', + success: true, + command: 'SELECT', + rowCount: 1, + executionTime: 0.01, + }, + ]; + + const markdown = (executor as any).generateSummaryMarkdown(singleSuccess); + expect(markdown).to.contain('✅ **1 statement succeeded**'); // singular + expect(markdown).not.to.contain('statements succeeded'); + + const singleFailed: any[] = [ + { + stmtIndex: 0, + query: 'BAD SQL;', + success: false, + error: 'syntax error', + executionTime: 0.01, + }, + ]; + + const markdownFailed = (executor as any).generateSummaryMarkdown(singleFailed); + expect(markdownFailed).to.contain('❌ **1 statement failed**'); // singular + }); + }); + + describe('Multi-Statement Strategy Behavior', () => { + it('should collect all statement results in order', () => { + // This is a conceptual test to document expected behavior + // In a real scenario with mocked clients, this would test: + // 1. Statements are executed sequentially + // 2. Results collected in statementsResults array + // 3. Array maintains order (stmtIndex matches position) + + const expectedResults = [ + { stmtIndex: 0, query: 'STMT 1', success: true }, + { stmtIndex: 1, query: 'STMT 2', success: false }, + { stmtIndex: 2, query: 'STMT 3', success: true }, + ]; + + // Verify indices are sequential + expectedResults.forEach((result, index) => { + expect(result.stmtIndex).to.equal(index); + }); + }); + + it('should determine whether to show summary based on statement count', () => { + // Single statement: no summary needed + // Multiple statements with all success: no summary needed + // Multiple statements with mixed results: summary shown + + const multiStatementMixed = [ + { success: true }, + { success: false }, + ]; + + const hasFailures = multiStatementMixed.some(r => !r.success); + const hasSuccesses = multiStatementMixed.some(r => r.success); + + expect(hasFailures && hasSuccesses).to.be.true; + // In this case, summary should be shown + }); + }); +}); diff --git a/src/test/unit/SqlParser.test.ts b/src/test/unit/SqlParser.test.ts index 8a571b0..ac0f20d 100644 --- a/src/test/unit/SqlParser.test.ts +++ b/src/test/unit/SqlParser.test.ts @@ -237,4 +237,24 @@ describe('SqlParser', () => { expect(SqlParser.hasNamedParameters('SELECT 1::int')).to.be.false; }); }); + + describe('stripCommentsAndStrings', () => { + it('removes comments and string literals while keeping code tokens', () => { + const sql = "SELECT 'value -- not comment', col /* block */ FROM \"My Table\" -- line comment\nWHERE name = $$literal$$;"; + const stripped = SqlParser.stripCommentsAndStrings(sql); + expect(stripped).to.contain('SELECT'); + expect(stripped).to.contain('FROM "My Table"'); + expect(stripped).to.contain('WHERE name ='); + expect(stripped).to.not.contain('value -- not comment'); + expect(stripped).to.not.contain('block'); + expect(stripped).to.not.contain('line comment'); + }); + }); + + describe('normalizeIdentifier', () => { + it('lowercases unquoted identifiers and preserves quoted identifiers', () => { + expect(SqlParser.normalizeIdentifier('Users')).to.equal('users'); + expect(SqlParser.normalizeIdentifier('"CaseSensitive"')).to.equal('CaseSensitive'); + }); + }); }); diff --git a/src/test/unit/SqlTemplates.test.ts b/src/test/unit/SqlTemplates.test.ts index 67e6933..03b1fa8 100644 --- a/src/test/unit/SqlTemplates.test.ts +++ b/src/test/unit/SqlTemplates.test.ts @@ -134,6 +134,7 @@ describe('SQL template modules', () => { expectSqlContains(PgCronSQL.jobDetail(3), ['FROM cron.job', 'WHERE jobid = 3']); expectSqlContains(PgCronSQL.unschedule(9), ['cron.unschedule', '9']); expectSqlContains(PgCronSQL.scheduleNewJob(), ['cron.schedule']); + expectSqlContains(PgCronSQL.scheduleBackupShellExample(), ['pgstudio-pgdump', 'cron.schedule']); }); it('covers RLS policy SQL templates', () => { diff --git a/src/test/unit/mocks/vscode.ts b/src/test/unit/mocks/vscode.ts index ecc2b24..907cf4c 100644 --- a/src/test/unit/mocks/vscode.ts +++ b/src/test/unit/mocks/vscode.ts @@ -97,7 +97,10 @@ export const window = { export const commands = { registerCommand: (_name: string, _cb?: any) => ({ dispose: () => { } }), executeCommand: async (_cmd: string, ..._args: any[]) => undefined } as any; -export const languages = { registerCompletionItemProvider: (_selector: any, _provider: any) => ({ dispose: () => { } }) } as any; +export const languages = { + registerCompletionItemProvider: (_selector: any, _provider: any) => ({ dispose: () => { } }), + registerSignatureHelpProvider: (_selector: any, _provider: any, ..._chars: string[]) => ({ dispose: () => { } }) +} as any; export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 } as const; export type TreeItemCollapsibleState = typeof TreeItemCollapsibleState[keyof typeof TreeItemCollapsibleState]; diff --git a/src/test/unit/providers/kernel/SqlExecutor.consolidatedWarning.test.ts b/src/test/unit/providers/kernel/SqlExecutor.consolidatedWarning.test.ts new file mode 100644 index 0000000..5457e75 --- /dev/null +++ b/src/test/unit/providers/kernel/SqlExecutor.consolidatedWarning.test.ts @@ -0,0 +1,107 @@ +import { expect } from 'chai'; +import { QueryAnalyzer } from '../../../../services/QueryAnalyzer'; + +describe('SqlExecutor - Consolidated Modal Confirmation', () => { + it('should collect and group dangerous operations from multiple statements', () => { + const queryAnalyzer = QueryAnalyzer.getInstance(); + + // Test case: cell with multiple DROP commands + const queries = [ + 'DROP TABLE users', + 'DROP TABLE roles', + 'DELETE FROM audit' + ]; + + const analyses = queries.map(q => queryAnalyzer.analyzeQuery(q)); + + // Count by type + const operationCounts: Record = {}; + for (const analysis of analyses) { + for (const op of analysis.operations) { + operationCounts[op.type] = (operationCounts[op.type] || 0) + 1; + } + } + + expect(operationCounts).to.deep.equal({ + DROP: 2, + DELETE: 1 + }); + + const totalCount = Object.values(operationCounts).reduce((a, b) => a + b, 0); + expect(totalCount).to.equal(3); + }); + + it('should detect dangerous operations across mixed statement types', () => { + const queryAnalyzer = QueryAnalyzer.getInstance(); + + const statements = [ + 'SELECT * FROM users', + 'DROP TABLE archive_users', + 'SELECT COUNT(*) FROM products', + 'DELETE FROM logs', + 'UPDATE products SET price = 0 WHERE id > 100' // UPDATE with WHERE is medium risk on non-production + ]; + + const dangerousOps: any[] = []; + for (const stmt of statements) { + const analysis = queryAnalyzer.analyzeQuery(stmt); + if (analysis.requiresConfirmation) { + dangerousOps.push({ stmt, analysis }); + } + } + + // Should find 2 dangerous operations requiring confirmation (DROP, DELETE) + // UPDATE with WHERE is medium risk but doesn't require confirmation on non-production + expect(dangerousOps.length).to.be.greaterThanOrEqual(2); + + // Collect and verify counts + const operationCounts: Record = {}; + for (const { analysis } of dangerousOps) { + for (const op of analysis.operations) { + operationCounts[op.type] = (operationCounts[op.type] || 0) + 1; + } + } + + expect(operationCounts).to.include({ DROP: 1, DELETE: 1 }); + }); + + it('should handle production database context', () => { + const connection = { environment: 'production' }; + expect(connection.environment).to.equal('production'); + + const connection2 = { environment: 'staging' }; + expect(connection2.environment).to.equal('staging'); + + const connection3 = {}; + expect(connection3.environment).to.be.undefined; + }); + + it('should classify operation severity correctly', () => { + const queryAnalyzer = QueryAnalyzer.getInstance(); + + const dropAnalysis = queryAnalyzer.analyzeQuery('DROP TABLE users'); + expect(dropAnalysis.operations[0].severity).to.equal('critical'); + + const deleteNoWhereAnalysis = queryAnalyzer.analyzeQuery('DELETE FROM users'); + expect(deleteNoWhereAnalysis.operations[0].severity).to.equal('critical'); + + const deleteWithWhereAnalysis = queryAnalyzer.analyzeQuery('DELETE FROM users WHERE id = 1'); + expect(deleteWithWhereAnalysis.operations[0].severity).to.equal('medium'); + + const updateNoWhereAnalysis = queryAnalyzer.analyzeQuery('UPDATE products SET price = 0'); + expect(updateNoWhereAnalysis.operations[0].severity).to.equal('high'); + }); + + it('should mark operations requiring confirmation correctly', () => { + const queryAnalyzer = QueryAnalyzer.getInstance(); + + const dropAnalysis = queryAnalyzer.analyzeQuery('DROP TABLE users'); + expect(dropAnalysis.requiresConfirmation).to.be.true; + + const selectAnalysis = queryAnalyzer.analyzeQuery('SELECT * FROM users'); + expect(selectAnalysis.requiresConfirmation).to.be.false; + + const deleteWithoutWhereAnalysis = queryAnalyzer.analyzeQuery('DELETE FROM audit'); + expect(deleteWithoutWhereAnalysis.requiresConfirmation).to.be.true; + }); +}); diff --git a/src/ui/renderer/lazy/analystTab.ts b/src/ui/renderer/lazy/analystTab.ts new file mode 100644 index 0000000..00b6b5f --- /dev/null +++ b/src/ui/renderer/lazy/analystTab.ts @@ -0,0 +1,55 @@ +import type { PivotAiHelpContext } from '../../../renderer/components/analyst/AnalystPanel'; + +export interface MountAnalystTabOptions { + columns: string[]; + rows: unknown[]; + columnTypes: Record | undefined; + isStreaming: boolean; + buildPivotOptimizeUserMessage: (ctx: PivotAiHelpContext, sql: string) => string; + buildFullDatasetRerunQuery: () => string | undefined; + exportQuery: string | undefined; + query: string | undefined; + postMessage: (msg: Record) => void; +} + +export async function mountAnalystTab( + viewContainer: HTMLElement, + opts: MountAnalystTabOptions, +): Promise { + const { renderAnalystPanel } = await import('../../../renderer/components/analyst/AnalystPanel'); + + viewContainer.appendChild( + renderAnalystPanel({ + columns: opts.columns, + rows: opts.rows as Record[], + columnTypes: opts.columnTypes, + isStreaming: opts.isStreaming, + onAskAiForPivotHelp: (pivotCtx) => { + const sqlText = (opts.buildFullDatasetRerunQuery() || opts.exportQuery || opts.query || '').trim(); + opts.postMessage({ + type: 'sendToChat', + data: { + query: sqlText || opts.query || '', + message: opts.buildPivotOptimizeUserMessage(pivotCtx, sqlText || opts.query || ''), + }, + }); + }, + onRunFullDataset: () => { + const rerunQuery = opts.buildFullDatasetRerunQuery(); + if (!rerunQuery) { + opts.postMessage({ + type: 'showErrorMessage', + message: 'No query available to rerun for full dataset.', + }); + return; + } + opts.postMessage({ + type: 'runDerivedQuery', + query: rerunQuery, + source: 'streaming-analyst-pivot-full-dataset', + fullDataset: true, + }); + }, + }), + ); +} diff --git a/src/ui/renderer/lazy/chartTab.ts b/src/ui/renderer/lazy/chartTab.ts new file mode 100644 index 0000000..96ce774 --- /dev/null +++ b/src/ui/renderer/lazy/chartTab.ts @@ -0,0 +1,52 @@ +import { ChartControls } from '../../../renderer/components/chart/ChartControls'; +import { ChartRenderer } from '../../../renderer/components/chart/ChartRenderer'; + +export interface MountChartTabOptions { + columns: string[]; + rows: unknown[]; + /** Banner when sliding-window streaming applies */ + createStreamingWarning: () => HTMLElement | null; +} + +export function mountChartTab( + viewContainer: HTMLElement, + opts: MountChartTabOptions, +): { chartRenderer: ChartRenderer; chartCanvas: HTMLCanvasElement } { + const streamingHint = opts.createStreamingWarning(); + if (streamingHint) { + viewContainer.appendChild(streamingHint); + } + + const chartCanvas = document.createElement('canvas'); + const chartRenderer = new ChartRenderer(chartCanvas); + + const chartWrapper = document.createElement('div'); + chartWrapper.style.cssText = + 'flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden;'; + + const controlsContainer = document.createElement('div'); + controlsContainer.style.cssText = + 'width: 20%; min-width: 160px; max-width: 240px; display: flex; flex-direction: column; border-right: 1px solid var(--vscode-widget-border);'; + + const canvasContainer = document.createElement('div'); + canvasContainer.style.cssText = 'flex: 1; padding: 8px; position: relative; min-height: 0;'; + canvasContainer.appendChild(chartCanvas); + + const innerContainer = document.createElement('div'); + innerContainer.style.cssText = 'display: flex; flex: 1; overflow: hidden; height: 100%;'; + innerContainer.appendChild(controlsContainer); + innerContainer.appendChild(canvasContainer); + chartWrapper.appendChild(innerContainer); + + viewContainer.appendChild(chartWrapper); + + new ChartControls(controlsContainer, { + columns: opts.columns, + rows: opts.rows, + onConfigChange: (config) => { + chartRenderer.render(opts.rows as any[], config); + }, + }); + + return { chartRenderer, chartCanvas }; +} diff --git a/src/ui/renderer/lazy/explainTab.ts b/src/ui/renderer/lazy/explainTab.ts new file mode 100644 index 0000000..bb201cd --- /dev/null +++ b/src/ui/renderer/lazy/explainTab.ts @@ -0,0 +1,16 @@ +export async function mountExplainTab( + explainWrapper: HTMLElement, + explainPlan: unknown, +): Promise { + const { ExplainVisualizer } = await import('../../../renderer/components/ExplainVisualizer'); + if (explainPlan) { + try { + new ExplainVisualizer(explainWrapper, explainPlan).render(); + } catch (e) { + explainWrapper.textContent = 'Failed to render explain plan: ' + String(e); + } + } else { + explainWrapper.textContent = + 'No explain plan data available. Run EXPLAIN (ANALYZE, FORMAT JSON) to get a visual plan.'; + } +} diff --git a/src/ui/renderer/mimeRouter.ts b/src/ui/renderer/mimeRouter.ts new file mode 100644 index 0000000..78c5e5e --- /dev/null +++ b/src/ui/renderer/mimeRouter.ts @@ -0,0 +1,24 @@ +import type { ActivationFunction } from 'vscode-notebook-renderer'; +import type { NoticeLogEntry } from '../../common/types'; +import { + renderNoticesLiveStream, +} from '../../renderer/components/notices/NoticesPanel'; +import { renderPostgresNotebookResult } from './queryResult/renderQueryResult'; + +export const activate: ActivationFunction = (context) => ({ + renderOutputItem(data, element) { + if (data.mime === 'application/x-postgres-notebook-header+json') { + element.innerHTML = ''; + 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; + } + + renderPostgresNotebookResult(context, data, element); + }, +}); diff --git a/src/ui/renderer/queryResult/editHelpers.ts b/src/ui/renderer/queryResult/editHelpers.ts new file mode 100644 index 0000000..6a09176 --- /dev/null +++ b/src/ui/renderer/queryResult/editHelpers.ts @@ -0,0 +1,84 @@ +export function 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) }; +} + +export function formatDiffValue(value: unknown): string { + if (value === null || value === undefined) return 'NULL'; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); +} + +export function buildEditDiffRows( + modifiedCells: Map, + originalRows: unknown[], + tableInfo: { primaryKeys?: string[] } | undefined, +): 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] as Record)?.[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; +} + +export function buildDeletionReviewRows( + rowsMarkedForDeletion: Set, + originalRows: unknown[], + tableInfo: { primaryKeys?: string[] } | undefined, +): Array<{ rowIndex: number; rowLabel: string }> { + const sorted = Array.from(rowsMarkedForDeletion).sort((a, b) => a - b); + return sorted.map((rowIndex) => { + const pkLabel = tableInfo?.primaryKeys?.length + ? tableInfo.primaryKeys + .map((pk: string) => `${pk}=${formatDiffValue((originalRows[rowIndex] as Record)?.[pk])}`) + .join(', ') + : `row #${rowIndex + 1}`; + return { + rowIndex, + rowLabel: pkLabel, + }; + }); +} diff --git a/src/ui/renderer/queryResult/renderQueryResult.ts b/src/ui/renderer/queryResult/renderQueryResult.ts new file mode 100644 index 0000000..6e04cb9 --- /dev/null +++ b/src/ui/renderer/queryResult/renderQueryResult.ts @@ -0,0 +1,1765 @@ +import type { ActivationFunction } from 'vscode-notebook-renderer'; +import type { ChartRenderer } from '../../../renderer/components/chart/ChartRenderer'; +import { + createExportButton, + positionExportDropdown, + setExportToolbarButtonLabel, + EXPORT_MENU_Z_INDEX, +} from '../../../renderer/features/export'; +import { TableRenderer } from '../../../renderer/components/table/TableRenderer'; +import { createErrorPanel } from '../../../renderer/components/ErrorPanel'; +import { + createAiMenuButton, + type AiMenuOptions, + type RowToolsOptions, +} from '../../../renderer/components/ActionBar'; +import { + applyResultRowToolStyle, + applyResultViewTabStyle, + attachResultRowToolInteractions, + attachResultViewTabHover, + fillToolbarButtonContent, + fillOutputHoverToolButton, + type ResultToolbarGlyph, +} from '../../../renderer/components/ResultToolbarUi'; +import { createResultIdentityBar } from '../../../renderer/components/ResultIdentityBar'; +import { createInlineBanner } from '../../../renderer/components/InlineBanner'; +import { openCommitConfirmDialog } from '../../../renderer/components/CommitConfirmDialog'; +import { + createResultFooter, + formatResultExecutionStats, +} from '../../../renderer/components/ResultFooter'; +import { createTransactionBanner } from '../../../renderer/components/TransactionBanner'; +import { buildQueryPreview } from '../../../renderer/utils/queryPreview'; +import { + addResultToHistory, + getResultHistory, + renderTabStrip, +} from '../../../renderer/components/ResultTabStrip'; +import { renderTransposeTable } from '../../../renderer/components/TransposeView'; +import { + BYTEA_DISPLAY_DEFAULT, + type ByteaDisplayFormat, + type NoticeLogEntry, + type QueryResults, + type FilterState, + type SortState, + type TableRenderOptions, +} from '../../../common/types'; +import { + normalizeNoticesPayload, + renderNoticesPanel, +} from '../../../renderer/components/notices/NoticesPanel'; +import { BRAND_ACCENT } from '../rendererConstants'; +import { + buildChatResultsSampleJson, + buildPivotOptimizeUserMessage, + CHAT_SEND_SAMPLE_ROW_CAP, + startButtonLoading, + ensureAmberGutterStyle, + clearTransactionUI, +} from './utils'; +import { parseCellKey } from './editHelpers'; +import { createRenderReviewChangesView, syncReviewTabButtonUi } from './reviewChanges'; + +type NotebookRendererContext = Parameters[0]; + +/** Track ChartRenderer instances per output element (lazy-loaded with chart tab). */ +const chartInstances = new WeakMap(); +const tableInstances = new WeakMap(); + +export function renderPostgresNotebookResult( + context: NotebookRendererContext, + data: { mime: string; json: () => unknown }, + element: HTMLElement, +): void { + const json = data.json() as Partial & { error?: string } | null; + + if (!json) { + element.innerText = 'No data'; + return; + } + + const { + columns = [], + rows, + rowCount, + command, + query, + notices, + executionTime, + tableInfo, + columnTypes, + backendPid, + breadcrumb, + autoLimitApplied, + autoLimitValue, + } = json; + const exportQuery: string | undefined = + typeof json.exportQuery === 'string' && json.exportQuery.trim().length > 0 + ? json.exportQuery + : query; + + let slideMeta: QueryResults['slidingWindow'] = json.slidingWindow; + + const byteaDisplayFormat: ByteaDisplayFormat = + json.byteaDisplayFormat === 'postgresql' || + json.byteaDisplayFormat === 'json' || + json.byteaDisplayFormat === 'hex0x' + ? json.byteaDisplayFormat + : BYTEA_DISPLAY_DEFAULT; + + const noticeItems = normalizeNoticesPayload(notices); + + const sourceCellIndex = + typeof json.sourceCellIndex === 'number' && json.sourceCellIndex >= 0 + ? json.sourceCellIndex + : -1; + + // Transaction state from payload + const transactionState: { isActive: boolean; statementCount: number } | undefined = + json.transactionState; + const pendingCommit: boolean = !!json.pendingCommit; + + // Data Management + let originalRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; + let currentRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; + let slideBufferedStartRow = slideMeta?.windowStartRow ?? 1; + let slideHasMoreBefore = slideMeta?.hasMoreBefore ?? false; + let slideHasMoreAfter = slideMeta?.hasMoreAfter ?? false; + let localFilterState: FilterState = { globalQuery: '', clauses: [] }; + let localSortState: SortState = { column: null, direction: 'none' }; + const selectedIndices = new Set(); + const modifiedCells = new Map(); + const rowsMarkedForDeletion = new Set(); + + // FK lookup pending callbacks — keyed by requestId + const fkCallbacks = new Map void>(); + + const buildTableRenderOptions = (): TableRenderOptions => ({ + columns, + rows: currentRows, + originalRows, + columnTypes, + tableInfo, + foreignKeys: tableInfo?.foreignKeys, + initialSelectedIndices: selectedIndices, + modifiedCells, + rowsMarkedForDeletion, + byteaDisplayFormat, + ...(slideMeta?.sessionId ? { rowNumberBaseline: slideBufferedStartRow } : {}), + }); + + const quoteIdentifier = (value: string): string => `"${value.replace(/"/g, '""')}"`; + const escapeSqlLiteral = (value: string): string => value.replace(/'/g, "''"); + const hasActiveLocalFilter = (): boolean => + localFilterState.globalQuery.trim().length > 0 || localFilterState.clauses.length > 0; + const hasActiveLocalSort = (): boolean => + !!localSortState.column && localSortState.direction !== 'none'; + const buildDerivedQueryFromLocalScope = (): string | undefined => { + const base = (exportQuery || query || '').trim(); + if (!base) return undefined; + const baseNoSemicolon = base.replace(/;\s*$/, ''); + const alias = 'pgstudio_src'; + const globalParts: string[] = []; + const whereParts: string[] = []; + + const appendLikeCondition = (column: string, mode: 'contains' | 'startsWith' | 'endsWith', raw: string) => { + const v = escapeSqlLiteral(raw); + const pattern = mode === 'contains' ? `%${v}%` : mode === 'startsWith' ? `${v}%` : `%${v}`; + whereParts.push(`CAST(${alias}.${quoteIdentifier(column)} AS text) ILIKE '${pattern}'`); + }; + + const globalQuery = localFilterState.globalQuery.trim(); + if (globalQuery) { + const pat = `%${escapeSqlLiteral(globalQuery)}%`; + for (const c of columns) { + globalParts.push(`CAST(${alias}.${quoteIdentifier(c)} AS text) ILIKE '${pat}'`); + } + if (globalParts.length > 0) { + whereParts.push(`(${globalParts.join(' OR ')})`); + } + } + + for (const clause of localFilterState.clauses) { + if (!columns.includes(clause.column)) continue; + const value = clause.value ?? ''; + if (clause.operator === 'equals') { + whereParts.push( + `CAST(${alias}.${quoteIdentifier(clause.column)} AS text) = '${escapeSqlLiteral(value)}'`, + ); + } else if (clause.operator === 'contains') { + appendLikeCondition(clause.column, 'contains', value); + } else if (clause.operator === 'startsWith') { + appendLikeCondition(clause.column, 'startsWith', value); + } else if (clause.operator === 'endsWith') { + appendLikeCondition(clause.column, 'endsWith', value); + } + } + + const hasWhere = whereParts.length > 0; + const sortColumn = + localSortState.column && columns.includes(localSortState.column) + ? localSortState.column + : null; + const hasSort = !!sortColumn && localSortState.direction !== 'none'; + + if (!hasWhere && !hasSort) { + return undefined; + } + + const whereSql = hasWhere ? `\nWHERE ${whereParts.join('\n AND ')}` : ''; + const orderSql = hasSort + ? `\nORDER BY ${alias}.${quoteIdentifier(sortColumn!)} ${localSortState.direction.toUpperCase()}` + : ''; + + return `SELECT *\nFROM (\n${baseNoSemicolon}\n) AS ${alias}${whereSql}${orderSql};`; + }; + + const buildFullDatasetRerunQuery = (): string | undefined => { + const scoped = buildDerivedQueryFromLocalScope(); + if (scoped) { + return scoped; + } + const base = (exportQuery || query || '').trim(); + if (!base) { + return undefined; + } + return base.endsWith(';') ? base : `${base};`; + }; + + const createAnalyticsStreamingWarning = ( + modeLabel: 'Chart' | 'Analyst', + ): HTMLElement | null => { + if (!slideMeta?.sessionId) { + return null; + } + const banner = createInlineBanner({ + severity: 'warning', + message: `${modeLabel} in streaming mode uses loaded rows only. Run on full dataset for accurate results; this may have performance impact depending on local machine capacity.`, + actionLabel: 'Run on full dataset', + onAction: () => { + const rerunQuery = buildFullDatasetRerunQuery(); + if (!rerunQuery) { + context.postMessage?.({ + type: 'showErrorMessage', + message: 'No query available to rerun for full dataset.', + }); + return; + } + context.postMessage?.({ + type: 'runDerivedQuery', + query: rerunQuery, + source: `streaming-${modeLabel.toLowerCase()}-full-dataset`, + fullDataset: true, + }); + }, + dismissible: false, + }); + banner.setAttribute('data-streaming-analytics-hint', modeLabel.toLowerCase()); + return banner; + }; + + const refreshStreamingScopeNotice = (): void => { + mainContainer.querySelector('[data-streaming-scope-hint="true"]')?.remove(); + if (!slideMeta?.sessionId) return; + const activeFilter = hasActiveLocalFilter(); + const activeSort = hasActiveLocalSort(); + if (!activeFilter && !activeSort) return; + + const scopeBits: string[] = []; + if (activeFilter) scopeBits.push('filter'); + if (activeSort) scopeBits.push('sort'); + const msg = `Streaming mode: ${scopeBits.join(' + ')} is applied to loaded rows only.`; + + const hint = createInlineBanner({ + severity: 'warning', + message: msg, + actionLabel: 'Apply to full dataset', + onAction: () => { + const derived = buildDerivedQueryFromLocalScope(); + if (!derived) { + context.postMessage?.({ + type: 'showErrorMessage', + message: 'No active local filter/sort to apply.', + }); + return; + } + context.postMessage?.({ + type: 'runDerivedQuery', + query: derived, + source: 'streaming-local-scope', + fullDataset: true, + }); + }, + dismissible: false, + }); + hint.setAttribute('data-streaming-scope-hint', 'true'); + mainContainer.appendChild(hint); + }; + + // Result history for tab strip — persists across re-renders in same output element + const historyEntry = { + columns, + rows: currentRows, + columnTypes, + tableInfo, + command, + rowCount, + executionTime, + query, + notices: noticeItems.length ? [...noticeItems] : undefined, + timestamp: Date.now(), + byteaDisplayFormat, + }; + const resultHistory = addResultToHistory(element, historyEntry); + + // Main Container + const mainContainer = document.createElement('div'); + mainContainer.style.cssText = ` + font-family: var(--vscode-font-family), "Segoe UI", "Helvetica Neue", sans-serif; + font-size: 13px; + color: var(--vscode-editor-foreground); + border: 1px solid var(--vscode-widget-border); + border-top: 2px solid ${BRAND_ACCENT}; + border-radius: 4px; + overflow: hidden; + margin-bottom: 8px; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); + `; + + const contentContainer = document.createElement('div'); + contentContainer.style.cssText = 'display: flex; flex-direction: column; height: 100%;'; + + let switchTab: (mode: string) => void = () => {}; + let showOverflowMenu: (anchorEl: HTMLElement) => void = () => {}; + + let isExpanded = true; + + const updateIdentityStats = (): void => { + const el = mainContainer.querySelector('[data-result-stats]') as HTMLElement | null; + if (!el) return; + let text: string; + if (slideMeta) { + const lastRow = slideMeta.windowStartRow + Math.max(currentRows.length, 1) - 1; + text = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} · window ${slideMeta.windowSize.toLocaleString()} · streaming`; + if (executionTime !== undefined) { + const ms = Math.round(executionTime * 1000); + text += ms >= 1000 ? ` · ${executionTime.toFixed(2)}s` : ` · ${ms}ms`; + } + } else { + text = formatResultExecutionStats(currentRows.length, executionTime); + } + el.textContent = text; + el.style.display = text.trim() ? 'inline-block' : 'none'; + }; + + const identityBar = createResultIdentityBar({ + queryPreview: buildQueryPreview(query, (command || 'QUERY').toUpperCase()), + queryFull: query, + command, + statsLine: json.error + ? undefined + : slideMeta + ? (() => { + const lastRow = slideMeta.windowStartRow + Math.max(rows?.length ?? 0, 1) - 1; + let t = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} · window ${slideMeta.windowSize.toLocaleString()} · streaming`; + if (executionTime !== undefined) { + const ms = Math.round(executionTime * 1000); + t += ms >= 1000 ? ` · ${executionTime.toFixed(2)}s` : ` · ${ms}ms`; + } + return t; + })() + : formatResultExecutionStats(currentRows.length, executionTime), + isCollapsed: false, + onToggleCollapse: () => { + isExpanded = !isExpanded; + contentContainer.style.display = isExpanded ? 'flex' : 'none'; + const ch = identityBar.querySelector('[data-chevron]'); + if (ch) { + ch.textContent = isExpanded ? '▼' : '▶'; + } + }, + onOverflow: (anchorEl) => showOverflowMenu(anchorEl), + onExpand: () => + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'expand', + cellIndex: sourceCellIndex, + }), + }); + mainContainer.appendChild(identityBar); + + if (autoLimitApplied) { + const limitMsg = + autoLimitValue !== undefined + ? `Auto-LIMIT applied: showing ${rowCount?.toLocaleString() ?? '?'} rows (limit ${autoLimitValue})` + : 'A row limit was appended to this SELECT.'; + mainContainer.appendChild(createInlineBanner({ severity: 'info', message: limitMsg })); + } + + if (slideMeta && json.showSlidingCursorBanner === true && !json.error) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'info', + message: + 'Server-side cursor: only one window of rows is loaded at a time. Scroll the grid near the top or bottom edge to fetch the previous or next page.', + onDismiss: () => context.postMessage?.({ type: 'cursorStreamBannerDismiss' }), + onMuteForever: () => context.postMessage?.({ type: 'cursorStreamBannerMute' }), + }), + ); + } + + if (json.performanceAnalysis?.isDegraded || json.slowQuery) { + const degraded = Boolean(json.performanceAnalysis?.isDegraded); + const perfMsg = degraded + ? json.performanceAnalysis!.analysis + : 'Slow query detected. Consider reviewing indexes and filters.'; + mainContainer.appendChild( + createInlineBanner({ severity: degraded ? 'warning' : 'info', message: perfMsg }), + ); + } + + if (noticeItems.length > 0) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'warning', + message: `${noticeItems.length} notice${noticeItems.length !== 1 ? 's' : ''} from PostgreSQL`, + actionLabel: 'View', + onAction: () => switchTab('notices'), + }), + ); + } + + if (pendingCommit) { + mainContainer.appendChild( + createInlineBanner({ + severity: 'info', + message: + 'This result was produced inside an open transaction — changes are not durable until COMMIT.', + dismissible: false, + }), + ); + } + + mainContainer.appendChild(contentContainer); + + // Error Section + if (json.error) { + const errorPanel = createErrorPanel({ + errorCode: json.errorCode, + errorMessage: json.error, + explanation: json.errorExplanation, + onExplainError: () => { + context.postMessage?.({ type: 'explainError', error: json.error, query: json.query }); + }, + onFixWithAI: () => { + context.postMessage?.({ type: 'fixQuery', error: json.error, query: json.query }); + }, + onRetry: () => { + // Client-side retry: re-execute the cell by posting retryCell to the kernel + context.postMessage?.({ type: 'retryCell', query: json.query }); + }, + }); + contentContainer.appendChild(errorPanel); + } + + // Build the hidden export button to reuse its existing dropdown flow + const exportBtn = createExportButton(columns, currentRows, tableInfo, context, query); + exportBtn.style.display = 'none'; + + const gridPrefRequestId = `gcp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + let skipGridCommitConfirm = false; + if (!json.error) { + context.postMessage?.({ + type: 'gridCommitPreference', + action: 'get', + requestId: gridPrefRequestId, + }); + } + + /** Export dropdown for footer row tools + kernel export flows */ + const openResultExportMenu = (anchorBtn: HTMLElement): void => { + const existing = document.querySelector('.export-dropdown'); + if (existing) { + existing.remove(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'export-dropdown'; + menu.style.cssText = + `position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:${EXPORT_MENU_Z_INDEX};min-width:160px;border-radius:3px;padding:4px 0;visibility:hidden;`; + + const addItem = (label: string, onClick: () => void) => { + const item = document.createElement('div'); + item.textContent = label; + item.style.cssText = + 'padding:6px 12px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;'; + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; + }; + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; + }; + item.onclick = (e) => { + e.stopPropagation(); + onClick(); + menu.remove(); + }; + menu.appendChild(item); + }; + + const postExport = ( + format: 'csv' | 'json' | 'markdown' | 'clipboard' | 'sqlinsert', + ): void => { + context.postMessage?.({ + type: 'export_request', + format, + query: exportQuery, + columns, + rows: currentRows, // fallback only if full query export fails + tableInfo, + }); + }; + + addItem('Save as CSV', () => postExport('csv')); + addItem('Save as JSON', () => postExport('json')); + addItem('Save as Markdown', () => postExport('markdown')); + addItem('Copy to Clipboard', () => { + postExport('clipboard'); + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); + setTimeout(() => { + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); + }, 2000); + }); + if (tableInfo) { + addItem('Copy SQL INSERT', () => { + postExport('sqlinsert'); + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); + setTimeout(() => { + setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); + }, 2000); + }); + } + + document.body.appendChild(menu); + + positionExportDropdown(menu, anchorBtn); + menu.style.visibility = 'visible'; + setTimeout(() => { + const close = () => { + menu.remove(); + document.removeEventListener('click', close); + }; + document.addEventListener('click', close); + }, 0); + }; + + const aiMenuCallbacks: AiMenuOptions = { + onSendToChat: () => { + const resultsJson = buildChatResultsSampleJson( + columns, + currentRows, + CHAT_SEND_SAMPLE_ROW_CAP, + ); + context.postMessage?.({ + type: 'sendToChat', + data: { + query: json.query || '', + ...(resultsJson ? { results: resultsJson } : {}), + message: + currentRows.length === 0 + ? 'I ran this query. There were no rows; please help me interpret or fix it.' + : `I ran this query. The attachment includes at most ${CHAT_SEND_SAMPLE_ROW_CAP} sample rows from the result (not the full grid). Please help me understand the results.`, + }, + }); + }, + onAnalyzeWithAI: () => { + const escapeCSV = (val: any): string => { + if (val === null || val === undefined) return ''; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + const csvHeader = columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','); + const csvRows = currentRows.map((r: any) => + columns.map((c: string) => escapeCSV(r[c])).join(','), + ); + const dataCsv = [csvHeader, ...csvRows].join('\n'); + context.postMessage?.({ + type: 'analyzeData', + data: dataCsv, + query: json.query || '', + rowCount: currentRows.length, + }); + }, + onOptimize: () => { + context.postMessage?.({ + type: 'optimizeQuery', + query: json.query, + executionTime: json.executionTime, + }); + }, + }; + + // Save Changes Logic — review panel wired after TableRenderer + + let reviewTabBtn: HTMLButtonElement | null = null; + + /** Active view — used so the footer hides Delete when not on the table tab */ + let currentMode: string = 'table'; + + let syncReviewTabButton: () => void = () => {}; + + let stopPendingSaveLoading: (() => void) | undefined; + + let refreshResultFooter: () => void = () => {}; + + function syncPendingChangesUi(): void { + syncReviewTabButton(); + refreshResultFooter(); + } + + function runPerformSaveCommit(): void { + console.log('Renderer: Commit / save invoked'); + console.log('Renderer: Modified cells size:', modifiedCells.size); + console.log('Renderer: Rows marked for deletion:', rowsMarkedForDeletion.size); + + const updates: any[] = []; + modifiedCells.forEach((diff, key) => { + const parsed = parseCellKey(key); + if (!parsed) return; + const { rowIndex, colName } = parsed; + + console.log(`Renderer: Processing diff for row ${rowIndex}, col ${colName}`); + + if (tableInfo?.primaryKeys) { + const pkValues: Record = {}; + tableInfo.primaryKeys.forEach((pk: string) => { + pkValues[pk] = originalRows[rowIndex][pk]; + }); + updates.push({ + keys: pkValues, + column: colName, + value: diff.newValue, + originalValue: diff.originalValue, + }); + } else { + console.warn('Renderer: No primary keys found in tableInfo', tableInfo); + } + }); + + const deletions: any[] = []; + rowsMarkedForDeletion.forEach((rowIndex) => { + if (tableInfo?.primaryKeys) { + const pkValues: Record = {}; + tableInfo.primaryKeys.forEach((pk: string) => { + pkValues[pk] = originalRows[rowIndex][pk]; + }); + deletions.push({ + keys: pkValues, + row: originalRows[rowIndex], + }); + } + }); + + console.log('Renderer: Updates prepared:', updates); + console.log('Renderer: Deletions prepared:', deletions); + + if (updates.length > 0 || deletions.length > 0) { + console.log('Renderer: Posting saveChanges message'); + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; + const commitBtn = contentContainer.querySelector( + '[data-pg-result-commit]', + ) as HTMLButtonElement | null; + stopPendingSaveLoading = commitBtn + ? startButtonLoading(commitBtn, 'Saving...') + : undefined; + context.postMessage?.({ + type: 'saveChanges', + updates, + deletions, + tableInfo, + }); + } else { + const reason = !tableInfo?.primaryKeys + ? 'No primary keys found for this table.' + : 'Unknown error preparing updates.'; + console.warn(`Renderer: Save failed. ${reason}`); + context.postMessage?.({ + type: 'showErrorMessage', + message: `Cannot save changes: ${reason} (Primary keys are required to identify rows)`, + }); + } + } + + function performSave(): void { + const dirty = modifiedCells.size + rowsMarkedForDeletion.size; + if (dirty <= 0) { + return; + } + if (skipGridCommitConfirm) { + runPerformSaveCommit(); + return; + } + openCommitConfirmDialog({ + confirmLabel: `Commit (${dirty})`, + onConfirm: (dontAskAgain) => { + if (dontAskAgain) { + skipGridCommitConfirm = true; + context.postMessage?.({ + type: 'gridCommitPreference', + action: 'set', + skipConfirm: true, + }); + } + runPerformSaveCommit(); + }, + onCancel: () => {}, + }); + } + + let applyCursorResponse: ((message: any) => void) | undefined; + + function markSelectedRowsForDeletion(): void { + if (selectedIndices.size === 0) return; + selectedIndices.forEach((index) => { + rowsMarkedForDeletion.add(index); + }); + selectedIndices.clear(); + syncPendingChangesUi(); + tableRenderer.render(buildTableRenderOptions()); + updateActionsVisibility(); + } + + // Listen for messages from extension host + context.onDidReceiveMessage?.((message: any) => { + if ( + message.type === 'gridCommitPreferenceResponse' && + message.requestId === gridPrefRequestId && + message.skipConfirm === true + ) { + skipGridCommitConfirm = true; + return; + } + + // FK lookup response — resolve the waiting dropdown callback + if (message.type === 'fkLookupResponse') { + const cb = fkCallbacks.get(message.requestId); + if (cb) { + cb(message.rows || [], message.columns || []); + fkCallbacks.delete(message.requestId); + } + return; + } + + if (message.type === 'resultCursorResponse') { + applyCursorResponse?.(message); + return; + } + + // In-grid insert row result + if (message.type === 'insertSuccess') { + tableRenderer.replaceInsertRow(message.tempId, message.actualRow); + return; + } + if (message.type === 'insertFailed') { + tableRenderer.markInsertFailed(message.tempId, message.error || 'Insert failed'); + return; + } + + if (message.type === 'saveSuccess') { + console.log( + 'Renderer: Received saveSuccess, clearing modified cells and removing deleted rows', + ); + + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; + + // Update originalRows with edited values before removing any rows. + // 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 parsed = parseCellKey(key); + if (!parsed) return; + const { rowIndex, colName } = parsed; + if (rowIndex >= 0 && rowIndex < originalRows.length) { + originalRows[rowIndex][colName] = diff.newValue; + } + }); + + // Remove deleted rows from arrays (in reverse order to maintain indices) + const deletedIndices = Array.from(rowsMarkedForDeletion).sort((a, b) => b - a); + deletedIndices.forEach((index) => { + currentRows.splice(index, 1); + originalRows.splice(index, 1); + }); + + // Clear all pending changes + modifiedCells.clear(); + rowsMarkedForDeletion.clear(); + + syncPendingChangesUi(); + + // Re-render table to remove highlights and deleted rows + if (tableRenderer) { + tableRenderer.render(buildTableRenderOptions()); + } + } + + if (message.type === 'saveFailed') { + stopPendingSaveLoading?.(); + stopPendingSaveLoading = undefined; + } + }); + + /** Last Table / Chart / Analyst view when browsing notices etc. */ + let lastPrimaryMode: 'table' | 'chart' | 'analyst' = 'table'; + + // Secondary band: left = Table / Chart / … + optional View Plan; right = Export chart + AI (after chart init) + const secondaryTabsOuter = document.createElement('div'); + secondaryTabsOuter.style.cssText = + 'display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;padding:6px 12px;border-bottom:1px solid var(--vscode-panel-border);background:var(--vscode-editor-background);'; + + const secondaryTabsLeft = document.createElement('div'); + secondaryTabsLeft.style.cssText = + 'display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1;min-width:0;'; + + const secondaryTabsRight = document.createElement('div'); + secondaryTabsRight.style.cssText = + 'display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto;'; + + const isExplainQuery = + json.explainPlan || + (query && /^\s*EXPLAIN/i.test(query)) || + command === 'EXPLAIN' || + (columns.length === 1 && columns[0] === 'QUERY PLAN'); + + if (isExplainQuery) { + const explainPlanBtn = document.createElement('button'); + explainPlanBtn.type = 'button'; + fillToolbarButtonContent(explainPlanBtn, 'explain', 'View Plan'); + applyResultRowToolStyle(explainPlanBtn); + attachResultRowToolInteractions(explainPlanBtn); + explainPlanBtn.title = json.explainPlan + ? 'Open EXPLAIN ANALYZE plan view' + : 'Convert to JSON format and open visual plan view'; + + explainPlanBtn.onclick = () => { + if (json.explainPlan) { + switchTab('explain'); + } else { + console.log('Converting EXPLAIN to JSON, query:', query); + if (!query) { + alert('Cannot convert EXPLAIN plan: query not available'); + return; + } + context.postMessage?.({ + type: 'convertExplainToJson', + query: query, + }); + } + }; + secondaryTabsLeft.appendChild(explainPlanBtn); + } + + const tableViewBtn = document.createElement('button'); + tableViewBtn.type = 'button'; + fillToolbarButtonContent(tableViewBtn, 'table', 'Table'); + tableViewBtn.onclick = () => switchTab('table'); + attachResultViewTabHover(tableViewBtn); + + const chartViewBtn = document.createElement('button'); + chartViewBtn.type = 'button'; + fillToolbarButtonContent(chartViewBtn, 'chart', 'Chart'); + chartViewBtn.onclick = () => switchTab('chart'); + attachResultViewTabHover(chartViewBtn); + + const analystViewBtn = document.createElement('button'); + analystViewBtn.type = 'button'; + fillToolbarButtonContent(analystViewBtn, 'analyst', 'Analyst'); + analystViewBtn.onclick = () => switchTab('analyst'); + attachResultViewTabHover(analystViewBtn); + + const syncPrimaryButtons = () => { + applyResultViewTabStyle(tableViewBtn, lastPrimaryMode === 'table'); + applyResultViewTabStyle(chartViewBtn, lastPrimaryMode === 'chart'); + applyResultViewTabStyle(analystViewBtn, lastPrimaryMode === 'analyst'); + }; + syncPrimaryButtons(); + + const noticesBtn = document.createElement('button'); + noticesBtn.type = 'button'; + const noticesLabel = + noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; + fillToolbarButtonContent(noticesBtn, 'notices', noticesLabel); + noticesBtn.onclick = () => switchTab('notices'); + applyResultViewTabStyle(noticesBtn, false); + attachResultViewTabHover(noticesBtn); + + const transposeBtn = document.createElement('button'); + transposeBtn.type = 'button'; + fillToolbarButtonContent(transposeBtn, 'transpose', 'Transpose'); + transposeBtn.onclick = () => switchTab('transpose'); + applyResultViewTabStyle(transposeBtn, false); + attachResultViewTabHover(transposeBtn); + + reviewTabBtn = document.createElement('button'); + reviewTabBtn.type = 'button'; + reviewTabBtn.onclick = () => switchTab('review'); + + let explainTabBtn: HTMLButtonElement | null = null; + if (json.explainPlan) { + explainTabBtn = document.createElement('button'); + explainTabBtn.type = 'button'; + fillToolbarButtonContent(explainTabBtn, 'explain', 'Explain Plan'); + explainTabBtn.onclick = () => switchTab('explain'); + applyResultViewTabStyle(explainTabBtn, false); + attachResultViewTabHover(explainTabBtn); + } + + const REVIEW_AMBER = '#f59e0b'; + syncReviewTabButton = () => { + syncReviewTabButtonUi(reviewTabBtn, { + modifiedCells, + rowsMarkedForDeletion, + currentMode, + }); + }; + + reviewTabBtn.addEventListener('mouseenter', () => { + if (!reviewTabBtn || currentMode === 'review') return; + const pending = modifiedCells.size + rowsMarkedForDeletion.size; + reviewTabBtn.style.background = pending > 0 + ? `color-mix(in srgb, ${REVIEW_AMBER} 16%, color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 48%, transparent))` + : 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }); + reviewTabBtn.addEventListener('mouseleave', () => syncReviewTabButton()); + + secondaryTabsLeft.appendChild(tableViewBtn); + secondaryTabsLeft.appendChild(chartViewBtn); + secondaryTabsLeft.appendChild(analystViewBtn); + secondaryTabsLeft.appendChild(noticesBtn); + secondaryTabsLeft.appendChild(transposeBtn); + secondaryTabsLeft.appendChild(reviewTabBtn); + if (explainTabBtn) secondaryTabsLeft.appendChild(explainTabBtn); + + secondaryTabsOuter.appendChild(secondaryTabsLeft); + secondaryTabsOuter.appendChild(secondaryTabsRight); + + if (!json.error) { + contentContainer.appendChild(secondaryTabsOuter); + } + + syncReviewTabButton(); + + // Views Containers + const viewContainer = document.createElement('div'); + viewContainer.style.cssText = + 'flex: 1; overflow: hidden; display: flex; flex-direction: column; position: relative; max-height: 500px;'; + if (!json.error) { + contentContainer.appendChild(viewContainer); + } + + // TABLE RENDERER + const tableRenderer = new TableRenderer(viewContainer, { + onSelectionChange: (indices) => { + selectedIndices.clear(); + indices.forEach((i) => selectedIndices.add(i)); + updateActionsVisibility(); + }, + onDataChange: (_rowIndex, _col, _newVal, _originalVal) => { + syncPendingChangesUi(); + updateActionsVisibility(); + if (currentMode === 'review') { + switchTab('review'); + } + }, + onInsertRow: (values, tempId) => { + context.postMessage?.({ type: 'insertRow', tableInfo, values, tempId }); + }, + onFkLookup: (requestId, fkSchema, fkTable, fkColumn, searchText, callback) => { + fkCallbacks.set(requestId, callback); + context.postMessage?.({ + type: 'fkLookup', + requestId, + fkSchema, + fkTable, + fkColumn, + searchText, + limit: 50, + }); + }, + onSortChange: (column, direction) => { + localSortState = { column, direction }; + refreshStreamingScopeNotice(); + }, + onFilterChange: (state) => { + localFilterState = { + globalQuery: state.globalQuery || '', + clauses: state.clauses.map((c) => ({ ...c })), + }; + refreshStreamingScopeNotice(); + }, + }); + + // Store for cleanup on disposal + tableInstances.set(element, tableRenderer); + + const renderReviewChangesView = createRenderReviewChangesView({ + columns, + originalRows, + tableInfo, + modifiedCells, + rowsMarkedForDeletion, + tableRenderer, + buildTableRenderOptions, + syncPendingChangesUi, + switchTab: (mode: string) => switchTab(mode), + }); + + let slideFetchBusy = false; + let pendingSlideRequestId = ''; + let pendingSlideTargetStart: number | undefined; + let suppressSlideScrollUntil = 0; + let slideScrollCleanup: (() => void) | undefined; + const DEFAULT_ROW_HEIGHT_PX = 30; + const getSlideWindowSize = (): number => + Math.max(10, slideMeta?.windowSize ?? 100); + const getMaxBufferedRows = (): number => getSlideWindowSize() * 3; + const estimateDataRowHeight = (): number => { + const row = tableRenderer + .getScrollContainer() + .querySelector('tr[data-source-index]') as HTMLElement | null; + if (!row) { + return DEFAULT_ROW_HEIGHT_PX; + } + return Math.max(16, row.offsetHeight || DEFAULT_ROW_HEIGHT_PX); + }; + const syncSlideMetaFromBuffer = (): void => { + if (!slideMeta?.sessionId) { + return; + } + slideMeta = { + sessionId: slideMeta.sessionId, + windowStartRow: slideBufferedStartRow, + windowSize: getSlideWindowSize(), + hasMoreBefore: slideHasMoreBefore, + hasMoreAfter: slideHasMoreAfter, + }; + }; + + const attachSlideScroll = (): void => { + slideScrollCleanup?.(); + slideScrollCleanup = undefined; + if (!slideMeta?.sessionId) { + return; + } + const root = tableRenderer.getScrollContainer(); + let ticking = false; + const EDGE_PX = 72; + const onScroll = (): void => { + if (!slideMeta?.sessionId || slideFetchBusy) { + return; + } + if (Date.now() < suppressSlideScrollUntil) { + return; + } + if (ticking) { + return; + } + ticking = true; + requestAnimationFrame(() => { + ticking = false; + if (!slideMeta?.sessionId || slideFetchBusy) { + return; + } + const distBottom = root.scrollHeight - root.scrollTop - root.clientHeight; + const distTop = root.scrollTop; + let nextStart: number | undefined; + if (slideHasMoreAfter && distBottom < EDGE_PX) { + nextStart = slideBufferedStartRow + currentRows.length; + } else if (slideHasMoreBefore && distTop < EDGE_PX) { + nextStart = Math.max(1, slideBufferedStartRow - getSlideWindowSize()); + } + if (nextStart === undefined || nextStart === slideBufferedStartRow) { + return; + } + slideFetchBusy = true; + pendingSlideTargetStart = nextStart; + pendingSlideRequestId = `slide-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + context.postMessage?.({ + type: 'resultCursorFetch', + sessionId: slideMeta.sessionId, + pageStartRow: nextStart, + requestId: pendingSlideRequestId, + }); + }); + }; + root.addEventListener('scroll', onScroll, { passive: true }); + slideScrollCleanup = (): void => { + root.removeEventListener('scroll', onScroll); + }; + }; + + applyCursorResponse = (message: any): void => { + if (pendingSlideRequestId && message.requestId !== pendingSlideRequestId) { + return; + } + const previousWindowStart = slideBufferedStartRow; + const requestedStart = pendingSlideTargetStart; + const rootBefore = tableRenderer.getScrollContainer(); + const prevScrollTop = rootBefore.scrollTop; + const rowHeight = estimateDataRowHeight(); + let scrollAdjustPx = 0; + slideFetchBusy = false; + pendingSlideRequestId = ''; + pendingSlideTargetStart = undefined; + if (message.error) { + slideScrollCleanup?.(); + slideScrollCleanup = undefined; + slideMeta = undefined; + mainContainer.insertBefore( + createInlineBanner({ severity: 'warning', message: String(message.error) }), + contentContainer, + ); + refreshStreamingScopeNotice(); + return; + } + const incomingRows = message.rows ? JSON.parse(JSON.stringify(message.rows)) : []; + const incomingOriginalRows = JSON.parse(JSON.stringify(incomingRows)); + const movedForward = + typeof requestedStart === 'number' && requestedStart > previousWindowStart; + const movedBackward = + typeof requestedStart === 'number' && requestedStart < previousWindowStart; + + if (!slideMeta?.sessionId || !requestedStart) { + currentRows = incomingRows; + originalRows = incomingOriginalRows; + slideBufferedStartRow = message.slidingWindow?.windowStartRow ?? slideBufferedStartRow; + } else if (movedForward) { + currentRows = [...currentRows, ...incomingRows]; + originalRows = [...originalRows, ...incomingOriginalRows]; + } else if (movedBackward) { + currentRows = [...incomingRows, ...currentRows]; + originalRows = [...incomingOriginalRows, ...originalRows]; + slideBufferedStartRow = requestedStart; + scrollAdjustPx += incomingRows.length * rowHeight; + } else { + currentRows = incomingRows; + originalRows = incomingOriginalRows; + slideBufferedStartRow = requestedStart; + } + + const maxBufferedRows = getMaxBufferedRows(); + if (currentRows.length > maxBufferedRows) { + const overflow = currentRows.length - maxBufferedRows; + if (movedForward) { + currentRows = currentRows.slice(overflow); + originalRows = originalRows.slice(overflow); + slideBufferedStartRow += overflow; + scrollAdjustPx -= overflow * rowHeight; + } else if (movedBackward) { + currentRows = currentRows.slice(0, currentRows.length - overflow); + originalRows = originalRows.slice(0, originalRows.length - overflow); + } else { + currentRows = currentRows.slice(0, maxBufferedRows); + originalRows = originalRows.slice(0, maxBufferedRows); + } + } + + if (message.slidingWindow) { + if (movedForward) { + slideHasMoreAfter = message.slidingWindow.hasMoreAfter; + } else if (movedBackward) { + slideHasMoreBefore = message.slidingWindow.hasMoreBefore; + } else { + slideHasMoreBefore = message.slidingWindow.hasMoreBefore; + slideHasMoreAfter = message.slidingWindow.hasMoreAfter; + } + if (slideBufferedStartRow > 1) { + slideHasMoreBefore = true; + } + syncSlideMetaFromBuffer(); + } + refreshStreamingScopeNotice(); + selectedIndices.clear(); + modifiedCells.clear(); + rowsMarkedForDeletion.clear(); + if (currentMode === 'table') { + tableRenderer.render(buildTableRenderOptions()); + } + updateIdentityStats(); + refreshResultFooter(); + suppressSlideScrollUntil = Date.now() + 120; + requestAnimationFrame(() => { + const root = tableRenderer.getScrollContainer(); + const nextScrollTop = Math.max(0, prevScrollTop + scrollAdjustPx); + root.scrollTop = nextScrollTop; + }); + }; + + const rowToolHandlers: RowToolsOptions = { + onSelectAll: () => { + if (selectedIndices.size === currentRows.length && currentRows.length > 0) { + selectedIndices.clear(); + } else { + currentRows.forEach((_: any, i: number) => selectedIndices.add(i)); + } + tableRenderer.updateSelection(selectedIndices); + refreshResultFooter(); + }, + onCopy: () => { + const rowsToCopy = + selectedIndices.size > 0 + ? Array.from(selectedIndices).map((i) => currentRows[i]) + : currentRows; + const escapeCSV = (val: any): string => { + if (val === null || val === undefined) return ''; + const str = typeof val === 'object' ? JSON.stringify(val) : String(val); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }; + const csv = [ + columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','), + ...rowsToCopy.map((r: any) => columns.map((c: string) => escapeCSV(r[c])).join(',')), + ].join('\n'); + navigator.clipboard?.writeText(csv); + }, + onImport: () => { + void import('../../../renderer/features/import').then(({ showImportModal }) => { + showImportModal(columns, tableInfo, context); + }); + }, + onExport: openResultExportMenu, + }; + + refreshResultFooter = () => { + if (json.error) return; + contentContainer.querySelector('[data-result-footer="true"]')?.remove(); + const dirty = modifiedCells.size + rowsMarkedForDeletion.size; + const tableView = currentMode === 'table'; + const sel = tableView ? selectedIndices.size : 0; + contentContainer.appendChild( + createResultFooter({ + rowTools: + columns.length > 0 + ? { + ...rowToolHandlers, + allRowsSelected: + tableView && + currentRows.length > 0 && + selectedIndices.size === currentRows.length, + } + : undefined, + onAddRow: tableInfo + ? () => { + switchTab('table'); + requestAnimationFrame(() => tableRenderer.triggerAddRow()); + } + : undefined, + dirtyCount: dirty, + onCommit: dirty > 0 ? performSave : undefined, + deleteSelectionCount: sel, + onDeleteSelected: sel > 0 && tableView ? markSelectedRowsForDeletion : undefined, + deleteUnavailableReason: + sel > 0 && !tableInfo?.primaryKeys + ? 'Warning: No primary keys detected. Deletion may fail.' + : undefined, + onRevert: + dirty > 0 + ? () => { + tableRenderer.revertAllPendingChanges(); + syncPendingChangesUi(); + } + : undefined, + }), + ); + updateIdentityStats(); + }; + + /** Lazily populated when Chart tab is opened (see `lazy/chartTab`). */ + let chartRenderer: ChartRenderer | undefined; + let lazyViewGeneration = 0; + + const exportChartBtn = document.createElement('button'); + exportChartBtn.type = 'button'; + fillToolbarButtonContent(exportChartBtn, 'chart', 'Export Chart'); + applyResultRowToolStyle(exportChartBtn); + attachResultRowToolInteractions(exportChartBtn); + exportChartBtn.style.display = 'none'; // Hidden by default + exportChartBtn.onclick = () => { + const dataUrl = chartRenderer?.exportImage('png'); + if (dataUrl) { + const a = document.createElement('a'); + a.href = dataUrl; + a.download = `chart-${new Date().toISOString()}.png`; + a.click(); + } + }; + secondaryTabsRight.appendChild(exportChartBtn); + secondaryTabsRight.appendChild(createAiMenuButton(aiMenuCallbacks)); + + const updateActionsVisibility = () => { + if (currentMode === 'chart') { + exportChartBtn.style.display = 'inline-block'; + } else { + exportChartBtn.style.display = 'none'; + } + refreshResultFooter(); + }; + + // Switch Tab Logic + + const setSecondaryActive = (mode: string | null) => { + applyResultViewTabStyle(noticesBtn, mode === 'notices'); + applyResultViewTabStyle(transposeBtn, mode === 'transpose'); + if (explainTabBtn) applyResultViewTabStyle(explainTabBtn, mode === 'explain'); + syncReviewTabButton(); + }; + + switchTab = (mode: string) => { + currentMode = mode; + viewContainer.innerHTML = ''; + lazyViewGeneration += 1; + const viewGen = lazyViewGeneration; + + if (mode === 'table' || mode === 'chart' || mode === 'analyst') { + lastPrimaryMode = mode; + syncPrimaryButtons(); + setSecondaryActive(null); + } else { + syncPrimaryButtons(); + setSecondaryActive(mode); + } + + if (mode === 'table') { + updateActionsVisibility(); + tableRenderer.render(buildTableRenderOptions()); + attachSlideScroll(); + } else if (mode === 'notices') { + 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') { + updateActionsVisibility(); + const transposeEl = renderTransposeTable( + columns, + currentRows, + columnTypes, + byteaDisplayFormat, + ); + viewContainer.appendChild(transposeEl); + } else if (mode === 'review') { + updateActionsVisibility(); + viewContainer.appendChild(renderReviewChangesView()); + } else if (mode === 'explain') { + updateActionsVisibility(); + + const explainWrapper = document.createElement('div'); + explainWrapper.style.cssText = + 'flex: 1; overflow: auto; height: 100%; display: flex; flex-direction: column;'; + viewContainer.appendChild(explainWrapper); + + void import('../lazy/explainTab').then(({ mountExplainTab }) => { + if (viewGen !== lazyViewGeneration) { + return; + } + void mountExplainTab(explainWrapper, json.explainPlan); + }); + } else if (mode === 'analyst') { + updateActionsVisibility(); + const streamingHint = createAnalyticsStreamingWarning('Analyst'); + if (streamingHint) { + viewContainer.appendChild(streamingHint); + } + void import('../lazy/analystTab').then(({ mountAnalystTab }) => { + if (viewGen !== lazyViewGeneration) { + return; + } + void mountAnalystTab(viewContainer, { + columns, + rows: currentRows, + columnTypes, + isStreaming: !!slideMeta?.sessionId, + buildPivotOptimizeUserMessage, + buildFullDatasetRerunQuery, + exportQuery, + query, + postMessage: (msg) => context.postMessage?.(msg), + }); + }); + } else { + // chart — lazy chunk loads Chart.js + controls + updateActionsVisibility(); + const loading = document.createElement('div'); + loading.style.cssText = + 'padding:12px;color:var(--vscode-descriptionForeground);font-size:12px;'; + loading.textContent = 'Loading chart…'; + viewContainer.appendChild(loading); + + void import('../lazy/chartTab').then(({ mountChartTab }) => { + if (viewGen !== lazyViewGeneration) { + return; + } + viewContainer.innerHTML = ''; + const { chartRenderer: cr } = mountChartTab(viewContainer, { + columns, + rows: currentRows, + createStreamingWarning: () => createAnalyticsStreamingWarning('Chart'), + }); + chartRenderer = cr; + chartInstances.set(element, cr); + }); + } + refreshResultFooter(); + }; + + showOverflowMenu = (anchorEl: HTMLElement) => { + const existing = document.querySelector('.result-overflow-menu'); + if (existing) { + existing.remove(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'result-overflow-menu'; + menu.style.cssText = + 'position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:1000;min-width:170px;border-radius:4px;padding:3px 0;'; + + const addItem = (label: string, onClick: () => void) => { + const item = document.createElement('div'); + item.textContent = label; + item.style.cssText = + 'padding:6px 14px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;font-family:var(--vscode-font-family);'; + item.onmouseenter = () => { + item.style.background = 'var(--vscode-menu-selectionBackground)'; + item.style.color = 'var(--vscode-menu-selectionForeground)'; + }; + item.onmouseleave = () => { + item.style.background = 'transparent'; + item.style.color = 'var(--vscode-menu-foreground)'; + }; + item.onclick = (e) => { + e.stopPropagation(); + onClick(); + menu.remove(); + }; + menu.appendChild(item); + }; + + addItem('⇄ Transpose', () => switchTab('transpose')); + if (noticeItems.length > 0) { + addItem(`Notices (${noticeItems.length})`, () => switchTab('notices')); + } + if (json.explainPlan) { + addItem('Explain Plan', () => switchTab('explain')); + } + if (breadcrumb?.connectionName) { + addItem('Switch connection…', () => + context.postMessage?.({ + type: 'showConnectionSwitcher', + connectionId: breadcrumb.connectionId, + }), + ); + } + if (breadcrumb?.database) { + addItem('Switch database…', () => + context.postMessage?.({ + type: 'showDatabaseSwitcher', + connectionId: breadcrumb.connectionId, + currentDatabase: breadcrumb.database, + }), + ); + } + + document.body.appendChild(menu); + const rect = anchorEl.getBoundingClientRect(); + menu.style.top = `${rect.bottom + 4}px`; + const mw = 200; + menu.style.left = `${Math.max(8, Math.min(rect.right - mw, window.innerWidth - mw - 8))}px`; + + setTimeout(() => { + const close = () => { + menu.remove(); + document.removeEventListener('click', close); + }; + document.addEventListener('click', close); + }, 0); + }; + + // Initial Render + if (columns.length > 0) { + switchTab('table'); + } else if (noticeItems.length > 0) { + switchTab('notices'); + } else { + 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 + const tabStripEl = renderTabStrip(element, resultHistory, 0, (selectedIndex) => { + // Re-render with a previous result's data + const entry = getResultHistory(element)[selectedIndex]; + if (!entry) return; + element.innerHTML = ''; + // Re-trigger renderOutputItem with the historical data by re-building the output + // For now: show history entry as a read-only view + const histContainer = document.createElement('div'); + histContainer.style.cssText = + 'padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-bottom:1px solid var(--vscode-widget-border);background:var(--vscode-editor-background);'; + histContainer.textContent = `Showing result from ${new Date(entry.timestamp).toLocaleTimeString()} — ${(entry.rowCount ?? entry.rows?.length ?? 0).toLocaleString()} rows`; + element.appendChild(histContainer); + + const histTableContainer = document.createElement('div'); + histTableContainer.style.cssText = 'max-height:400px;overflow:auto;'; + const histRenderer = new TableRenderer(histTableContainer, {}); + histRenderer.render({ + columns: entry.columns, + rows: entry.rows || [], + originalRows: entry.rows || [], + columnTypes: entry.columnTypes, + tableInfo: entry.tableInfo, + byteaDisplayFormat: entry.byteaDisplayFormat ?? BYTEA_DISPLAY_DEFAULT, + }); + element.appendChild(histTableContainer); + }); + if (tabStripEl) element.appendChild(tabStripEl); + + const outputRoot = document.createElement('div'); + outputRoot.setAttribute('data-pg-output-hover-root', 'true'); + outputRoot.style.cssText = 'position:relative;display:flex;flex-direction:column;'; + + const hoverToolbar = document.createElement('div'); + hoverToolbar.setAttribute('role', 'toolbar'); + hoverToolbar.setAttribute('aria-label', 'Result quick actions'); + hoverToolbar.style.cssText = ` + display:flex; + flex-wrap:wrap; + justify-content:flex-end; + align-items:center; + gap:6px; + max-width:min(680px, 100%); + padding:5px 8px; + border-radius:10px; + background:color-mix(in srgb, var(--vscode-editor-background) 86%, transparent); + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 42%, transparent); + box-shadow:0 4px 18px rgba(0,0,0,0.1); + backdrop-filter:blur(10px); + `; + + const toolbarDock = document.createElement('div'); + toolbarDock.style.cssText = ` + display:flex; + flex-direction:column; + align-items:flex-end; + gap:6px; + position:absolute; + right:10px; + top:-30px; + z-index:34; + `; + const toolbarToggle = document.createElement('button'); + toolbarToggle.type = 'button'; + fillOutputHoverToolButton(toolbarToggle, 'sparkles', 'AI actions'); + toolbarToggle.style.padding = '5px 12px'; + toolbarToggle.style.fontSize = '11px'; + const toggleChevron = document.createElement('span'); + toggleChevron.style.cssText = 'font-size:11px;line-height:1;opacity:0.85;'; + toggleChevron.textContent = '▸'; + toolbarToggle.appendChild(toggleChevron); + + let toolbarCollapsed = true; + const updateToolbarVisibility = (): void => { + hoverToolbar.style.display = toolbarCollapsed ? 'none' : 'flex'; + toolbarToggle.setAttribute('aria-expanded', toolbarCollapsed ? 'false' : 'true'); + toolbarToggle.title = toolbarCollapsed ? 'Show result AI actions' : 'Hide result AI actions'; + toggleChevron.textContent = toolbarCollapsed ? '▸' : '▾'; + }; + toolbarToggle.addEventListener('click', (ev) => { + ev.stopPropagation(); + toolbarCollapsed = !toolbarCollapsed; + updateToolbarVisibility(); + }); + updateToolbarVisibility(); + + const addHoverTool = ( + glyph: ResultToolbarGlyph, + label: string, + onClick: () => void, + opts?: { disabled?: boolean; title?: string }, + ): void => { + const btn = document.createElement('button'); + btn.type = 'button'; + fillOutputHoverToolButton(btn, glyph, label); + const title = opts?.title ?? label; + btn.title = title; + btn.setAttribute('aria-label', title); + if (opts?.disabled) { + btn.disabled = true; + btn.style.opacity = '0.42'; + btn.style.cursor = 'not-allowed'; + } + btn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (btn.disabled) { + return; + } + onClick(); + }); + hoverToolbar.appendChild(btn); + }; + + const queryTrimmed = (query || '').trim(); + const cellLinked = sourceCellIndex >= 0; + + addHoverTool( + 'menuChat', + 'Add to chat', + () => { + const resultsJson = buildChatResultsSampleJson( + columns, + currentRows, + CHAT_SEND_SAMPLE_ROW_CAP, + ); + context.postMessage?.({ + type: 'sendToChat', + data: { + query: queryTrimmed, + ...(resultsJson ? { results: resultsJson } : {}), + ...(noticeItems.length > 0 + ? { notices: noticeItems.map(n => (n.message || '').trim()).filter(Boolean) } + : {}), + message: + currentRows.length === 0 + ? 'I ran this query. No rows were returned. Help me validate the query intent and next checks.' + : `I ran this query. The attachment includes at most ${CHAT_SEND_SAMPLE_ROW_CAP} sample rows from the result (not the full grid). Help me interpret it.`, + }, + }); + }, + { + disabled: !queryTrimmed, + title: queryTrimmed + ? 'Attach SQL and sampled result rows to SQL Assistant' + : 'No query text', + }, + ); + addHoverTool( + 'menuBolt', + 'Optimize', + () => { + aiMenuCallbacks.onOptimize(); + }, + { + disabled: !queryTrimmed, + title: queryTrimmed ? 'Suggest optimizations for this query' : 'No query text', + }, + ); + addHoverTool( + 'sparkles', + 'Ask AI', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'aiAssist', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked + ? 'Ask AI to modify this query' + : 'Re-run the cell to link actions to the source cell', + }, + ); + addHoverTool( + 'save', + 'Save', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'saveQuery', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked ? 'Save query to library' : 'Re-run the cell to link actions to the source cell', + }, + ); + addHoverTool( + 'expandCell', + 'Expand', + () => { + context.postMessage?.({ + type: 'notebookOutputToolbar', + action: 'expand', + cellIndex: sourceCellIndex, + }); + }, + { + disabled: !cellLinked, + title: cellLinked ? 'Focus the SQL cell in the editor' : 'Re-run the cell to link actions to the source cell', + }, + ); + + outputRoot.appendChild(mainContainer); + toolbarDock.appendChild(toolbarToggle); + toolbarDock.appendChild(hoverToolbar); + outputRoot.appendChild(toolbarDock); + element.appendChild(outputRoot); + + // Transaction state: show banner and amber gutter + ensureAmberGutterStyle(); + if (transactionState?.isActive) { + mainContainer.classList.add('amber-gutter'); + + // Only add one banner per document (remove stale ones first) + const existingBanner = document.querySelector('[data-transaction-banner="true"]'); + if (!existingBanner) { + const banner = createTransactionBanner({ + statementCount: transactionState.statementCount, + onCommit: () => { + context.postMessage?.({ type: 'commitTransaction' }); + }, + onRollback: () => { + context.postMessage?.({ type: 'rollbackTransaction' }); + }, + }); + // Insert banner before the first output container in the element's parent + const outputHost = element.parentElement || element; + outputHost.insertBefore(banner, outputHost.firstChild); + } + } else { + // Transaction closed — clear all transaction UI + clearTransactionUI(); + } +} diff --git a/src/ui/renderer/queryResult/reviewChanges.ts b/src/ui/renderer/queryResult/reviewChanges.ts new file mode 100644 index 0000000..f5cdf8d --- /dev/null +++ b/src/ui/renderer/queryResult/reviewChanges.ts @@ -0,0 +1,344 @@ +import { + RESULT_TOOLBAR_ICON_CLASS, + RESULT_TOOLBAR_LABEL_CLASS, + applyResultViewTabStyle, + resultToolbarSvg, +} from '../../../renderer/components/ResultToolbarUi'; +import type { TableRenderer } from '../../../renderer/components/table/TableRenderer'; +import type { TableRenderOptions } from '../../../common/types'; +import { + buildDeletionReviewRows, + buildEditDiffRows, + formatDiffValue, +} from './editHelpers'; + +export interface ReviewChangesDeps { + columns: string[]; + originalRows: unknown[]; + tableInfo: { primaryKeys?: string[] } | undefined; + modifiedCells: Map; + rowsMarkedForDeletion: Set; + tableRenderer: TableRenderer; + buildTableRenderOptions: () => TableRenderOptions; + syncPendingChangesUi: () => void; + switchTab: (mode: string) => void; +} + +export function createRenderReviewChangesView(deps: ReviewChangesDeps): () => HTMLElement { + return () => { + const diffRows = buildEditDiffRows( + deps.modifiedCells, + deps.originalRows, + deps.tableInfo, + ); + const deletionRows = buildDeletionReviewRows( + deps.rowsMarkedForDeletion, + deps.originalRows, + deps.tableInfo, + ); + const pendingCount = deps.modifiedCells.size + deps.rowsMarkedForDeletion.size; + + const wrap = document.createElement('div'); + wrap.style.cssText = 'height:100%;overflow:auto;display:flex;flex-direction:column;'; + + const header = document.createElement('div'); + header.style.cssText = + 'padding:10px 12px;border-bottom:1px solid var(--vscode-widget-border);display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:10px;'; + + const headerText = document.createElement('div'); + headerText.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;'; + + 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; + const subParts: string[] = []; + if (diffRows.length > 0) { + subParts.push( + `${editedRowCount} row${editedRowCount !== 1 ? 's' : ''}, ${diffRows.length} edited cell${diffRows.length !== 1 ? 's' : ''}`, + ); + } + if (deletionRows.length > 0) { + subParts.push( + `${deletionRows.length} row${deletionRows.length !== 1 ? 's' : ''} marked for deletion`, + ); + } + subtitleEl.textContent = subParts.length > 0 ? subParts.join(' · ') : 'No pending changes'; + subtitleEl.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);'; + + headerText.appendChild(titleEl); + headerText.appendChild(subtitleEl); + header.appendChild(headerText); + + if (pendingCount > 0) { + const revertReviewBtn = document.createElement('button'); + revertReviewBtn.type = 'button'; + revertReviewBtn.textContent = 'Revert all'; + revertReviewBtn.title = 'Discard all unstaged edits and staged deletions'; + revertReviewBtn.style.cssText = ` + flex-shrink:0;padding:4px 12px;font-size:11px;font-family:var(--vscode-font-family); + cursor:pointer;border-radius:3px;font-weight:600; + background:color-mix(in srgb,#22c55e 14%,transparent); + color:#22c55e; + border:1px solid color-mix(in srgb,#22c55e 38%,transparent); + `; + revertReviewBtn.onmouseover = () => { + revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 22%,transparent)'; + }; + revertReviewBtn.onmouseout = () => { + revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 14%,transparent)'; + }; + revertReviewBtn.onclick = () => { + deps.tableRenderer.revertAllPendingChanges(); + deps.syncPendingChangesUi(); + deps.switchTab('table'); + }; + header.appendChild(revertReviewBtn); + } + + wrap.appendChild(header); + + if (diffRows.length === 0 && deletionRows.length === 0) { + const empty = document.createElement('div'); + empty.style.cssText = + 'padding:20px 16px;color:var(--vscode-descriptionForeground);font-size:12px;'; + empty.textContent = 'No pending edits or deletions to review.'; + wrap.appendChild(empty); + return wrap; + } + + const appendEditTable = () => { + if (diffRows.length === 0) return; + + const sectionLabel = document.createElement('div'); + sectionLabel.textContent = 'Cell edits'; + sectionLabel.style.cssText = + 'padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; + wrap.appendChild(sectionLabel); + + 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); + }; + + const appendDeletionCards = () => { + if (deletionRows.length === 0) return; + + const sectionLabel = document.createElement('div'); + sectionLabel.textContent = 'Rows to delete'; + sectionLabel.style.cssText = + 'padding:12px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; + wrap.appendChild(sectionLabel); + + const divider = document.createElement('div'); + divider.style.cssText = + 'height:1px;margin:2px 12px 12px;background:color-mix(in srgb,var(--vscode-widget-border) 85%,transparent);'; + wrap.appendChild(divider); + + const cardsWrap = document.createElement('div'); + cardsWrap.style.cssText = + 'display:flex;flex-direction:column;gap:12px;padding:0 12px 16px;'; + + deletionRows.forEach(({ rowIndex, rowLabel }) => { + const rowData = deps.originalRows[rowIndex] as Record | undefined; + + const card = document.createElement('article'); + card.style.cssText = ` + border:1px solid color-mix(in srgb, var(--vscode-widget-border) 70%, transparent); + border-radius:8px; + overflow:hidden; + background:color-mix(in srgb, #dc2626 7%, var(--vscode-editor-background)); + box-shadow:0 1px 2px rgba(0,0,0,0.06); + `; + + const head = document.createElement('header'); + head.style.cssText = ` + display:flex; + align-items:center; + justify-content:space-between; + gap:12px; + padding:8px 12px; + border-bottom:1px solid color-mix(in srgb, var(--vscode-widget-border) 55%, transparent); + background:color-mix(in srgb, #dc2626 11%, transparent); + `; + + const title = document.createElement('div'); + title.style.cssText = + 'font-size:12px;font-weight:700;font-family:var(--vscode-editor-font-family),monospace;color:var(--vscode-editor-foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; + title.textContent = `Row ${rowLabel}`; + + const undoBtn = document.createElement('button'); + undoBtn.type = 'button'; + undoBtn.textContent = 'Undo'; + undoBtn.title = 'Remove this row from the deletion queue'; + undoBtn.style.cssText = ` + flex-shrink:0;padding:3px 10px;font-size:11px;font-family:var(--vscode-font-family); + cursor:pointer;border-radius:4px;font-weight:600; + background:transparent;color:var(--vscode-textLink-foreground); + border:1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 38%, transparent); + `; + undoBtn.onmouseover = () => { + undoBtn.style.background = + 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; + }; + undoBtn.onmouseout = () => { + undoBtn.style.background = 'transparent'; + }; + undoBtn.onclick = () => { + deps.rowsMarkedForDeletion.delete(rowIndex); + deps.syncPendingChangesUi(); + deps.tableRenderer.render(deps.buildTableRenderOptions()); + deps.switchTab('review'); + }; + + head.appendChild(title); + head.appendChild(undoBtn); + + const body = document.createElement('div'); + body.style.cssText = + 'padding:10px 12px;display:flex;flex-wrap:wrap;gap:10px 16px;align-items:flex-start;'; + + deps.columns.forEach((colName: string) => { + const chip = document.createElement('span'); + chip.style.cssText = + 'display:inline-flex;align-items:baseline;gap:4px;font-size:11px;font-family:var(--vscode-editor-font-family),monospace;line-height:1.4;max-width:100%;word-break:break-word;'; + const k = document.createElement('span'); + k.style.cssText = 'color:var(--vscode-descriptionForeground);font-weight:600;flex-shrink:0;'; + k.textContent = `${colName}=`; + const v = document.createElement('span'); + v.style.color = 'var(--vscode-editor-foreground)'; + v.textContent = formatDiffValue(rowData?.[colName]); + chip.appendChild(k); + chip.appendChild(v); + body.appendChild(chip); + }); + + const foot = document.createElement('footer'); + foot.style.cssText = + 'padding:7px 12px 10px;font-size:10px;color:var(--vscode-descriptionForeground);font-style:italic;border-top:1px dashed color-mix(in srgb, var(--vscode-widget-border) 55%, transparent);'; + foot.textContent = '→ Will be removed when you commit.'; + + card.appendChild(head); + card.appendChild(body); + card.appendChild(foot); + cardsWrap.appendChild(card); + }); + + wrap.appendChild(cardsWrap); + }; + + appendEditTable(); + appendDeletionCards(); + return wrap; + }; +} + +/** Sync Review tab button label, badge, and styles */ +export function syncReviewTabButtonUi( + reviewTabBtn: HTMLButtonElement | null, + deps: { + modifiedCells: Map; + rowsMarkedForDeletion: Set; + currentMode: string; + }, +): void { + const REVIEW_AMBER = '#f59e0b'; + if (!reviewTabBtn) return; + const pending = deps.modifiedCells.size + deps.rowsMarkedForDeletion.size; + const isActive = deps.currentMode === 'review'; + + reviewTabBtn.replaceChildren(); + const ic = document.createElement('span'); + ic.className = RESULT_TOOLBAR_ICON_CLASS; + ic.innerHTML = resultToolbarSvg('review'); + const title = document.createElement('span'); + title.className = RESULT_TOOLBAR_LABEL_CLASS; + title.textContent = 'Review Changes'; + reviewTabBtn.appendChild(ic); + reviewTabBtn.appendChild(title); + + if (pending > 0) { + const badge = document.createElement('span'); + badge.textContent = String(pending); + badge.title = `${pending} pending change(s)`; + badge.style.cssText = ` + display:inline-block; + margin-left:6px; + min-width:18px; + text-align:center; + padding:0 6px; + border-radius:999px; + font-size:10px; + font-weight:700; + line-height:16px; + vertical-align:middle; + background:color-mix(in srgb, ${REVIEW_AMBER} 26%, transparent); + color:${REVIEW_AMBER}; + border:1px solid color-mix(in srgb, ${REVIEW_AMBER} 48%, transparent); + `; + reviewTabBtn.appendChild(badge); + } + + applyResultViewTabStyle(reviewTabBtn, isActive); + if (pending > 0) { + reviewTabBtn.style.background = isActive + ? `color-mix(in srgb, ${REVIEW_AMBER} 18%, color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 88%, transparent))` + : `color-mix(in srgb, ${REVIEW_AMBER} 14%, transparent)`; + reviewTabBtn.style.borderColor = `color-mix(in srgb, ${REVIEW_AMBER} 42%, var(--vscode-widget-border))`; + } + if (!isActive) { + reviewTabBtn.style.color = 'var(--vscode-editor-foreground)'; + } +} diff --git a/src/ui/renderer/queryResult/utils.ts b/src/ui/renderer/queryResult/utils.ts new file mode 100644 index 0000000..0250f9b --- /dev/null +++ b/src/ui/renderer/queryResult/utils.ts @@ -0,0 +1,123 @@ +import type { PivotAiHelpContext } from '../../../renderer/components/analyst/AnalystPanel'; +import { SPINNER_FRAMES } from '../rendererConstants'; +import { prefersReducedMotion } from '../../theme/motion'; + +export const PIVOT_HELP_SQL_INLINE_MAX_CHARS = 12000; + +/** Rows attached as CSV when using Send to Chat from results (full grids are rarely useful). */ +export const CHAT_SEND_SAMPLE_ROW_CAP = 10; + +export function buildChatResultsSampleJson( + columns: string[], + rows: unknown[], + maxRows: number, +): string | undefined { + if (maxRows <= 0 || rows.length === 0) { + return undefined; + } + return JSON.stringify({ + columns, + rows: rows.slice(0, maxRows), + }); +} + +/** User message for SQL Assistant when pivot cardinality exceeds the client cap. */ +export function buildPivotOptimizeUserMessage(ctx: PivotAiHelpContext, sourceSql: string): string { + const trimmed = sourceSql.trim(); + let sqlInline = trimmed; + let truncationNote = ''; + if (trimmed.length > PIVOT_HELP_SQL_INLINE_MAX_CHARS) { + sqlInline = trimmed.slice(0, PIVOT_HELP_SQL_INLINE_MAX_CHARS); + truncationNote = `\n-- … truncated for chat prompt (${trimmed.length.toLocaleString()} chars total); full SQL is attached as a file.`; + } + + const valueLine = + ctx.aggregation === 'count' && !ctx.valueColumn + ? 'Count rows (no separate value column)' + : ctx.valueColumn ?? '—'; + + return [ + 'PgStudio Analyst tab: the in-browser pivot failed because there are too many distinct row or column labels.', + '', + 'Help me rewrite my PostgreSQL query using server-side pre-aggregation (GROUP BY, rollups, bucketing, date_trunc, FILTER, CASE expressions, etc.) so pivot dimensions stay within a manageable cardinality.', + '', + `Pivot error: ${ctx.errorMessage}`, + '', + 'Pivot configuration:', + `- Row dimension: ${ctx.rowDimension}`, + `- Column dimension: ${ctx.columnDimension}`, + `- Value column / measure: ${valueLine}`, + `- Aggregation: ${ctx.aggregation}`, + '', + 'Context:', + `- UI cap (distinct values per axis): ${ctx.maxDistinctPerAxis}`, + `- Rows currently in this result grid: ${ctx.inMemoryRowCount.toLocaleString()}`, + `- Streaming sliding window: ${ctx.isStreamingWindow ? 'yes (only a subset of server rows may be loaded)' : 'no'}`, + '', + 'No result grid CSV is attached (usually redundant here; use the attached SQL file and pivot fields above).', + '', + 'Source SQL (also attached as a .sql file):', + '```sql', + sqlInline + truncationNote, + '```', + '', + 'Please propose efficient PostgreSQL that returns an aggregation-friendly result set I can pivot in the notebook, plus any index notes if relevant.', + ].join('\n'); +} + +/** + * 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. + */ +export function startButtonLoading(btn: HTMLElement, loadingLabel: string): () => void { + const originalText = btn.innerText; + const originalDisabled = (btn as HTMLButtonElement).disabled; + (btn as HTMLButtonElement).disabled = true; + 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(() => { + frame = (frame + 1) % SPINNER_FRAMES.length; + btn.innerText = `${SPINNER_FRAMES[frame]} ${loadingLabel}`; + }, 100); + + return () => { + clearInterval(interval); + restore(); + }; +} + +/** Inject amber-gutter CSS once */ +export function ensureAmberGutterStyle(): void { + const STYLE_ID = 'amber-gutter-style'; + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = ` + .amber-gutter { + border-left: 4px solid #ffb000 !important; + } + `; + document.head.appendChild(style); +} + +/** Remove all transaction banners and amber gutters from the document */ +export function clearTransactionUI(): void { + document.querySelectorAll('[data-transaction-banner="true"]').forEach((el) => el.remove()); + document.querySelectorAll('.amber-gutter').forEach((el) => el.classList.remove('amber-gutter')); +} diff --git a/src/ui/renderer/renderer_v2.ts b/src/ui/renderer/renderer_v2.ts index 3f86cc6..ecadd85 100644 --- a/src/ui/renderer/renderer_v2.ts +++ b/src/ui/renderer/renderer_v2.ts @@ -1,2296 +1 @@ -import type { ActivationFunction } from 'vscode-notebook-renderer'; -import { Chart, registerables } from 'chart.js'; -import { - createExportButton, - positionExportDropdown, - setExportToolbarButtonLabel, - EXPORT_MENU_Z_INDEX, -} 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 { - createAiMenuButton, - type AiMenuOptions, - type RowToolsOptions, -} from '../../renderer/components/ActionBar'; -import { - RESULT_TOOLBAR_ICON_CLASS, - RESULT_TOOLBAR_LABEL_CLASS, - applyResultRowToolStyle, - applyResultViewTabStyle, - attachResultRowToolInteractions, - attachResultViewTabHover, - fillToolbarButtonContent, - fillOutputHoverToolButton, - resultToolbarSvg, - type ResultToolbarGlyph, -} from '../../renderer/components/ResultToolbarUi'; -import { createResultIdentityBar } from '../../renderer/components/ResultIdentityBar'; -import { createInlineBanner } from '../../renderer/components/InlineBanner'; -import { openCommitConfirmDialog } from '../../renderer/components/CommitConfirmDialog'; -import { - createResultFooter, - formatResultExecutionStats, -} from '../../renderer/components/ResultFooter'; -import { showImportModal } from '../../renderer/features/import'; -import { createTransactionBanner } from '../../renderer/components/TransactionBanner'; -import { buildQueryPreview } from '../../renderer/utils/queryPreview'; -import { - addResultToHistory, - getResultHistory, - renderTabStrip, -} from '../../renderer/components/ResultTabStrip'; -import { renderTransposeTable } from '../../renderer/components/TransposeView'; -import { - renderAnalystPanel, - type PivotAiHelpContext, -} from '../../renderer/components/analyst/AnalystPanel'; -import { - BYTEA_DISPLAY_DEFAULT, - type ByteaDisplayFormat, - type NoticeLogEntry, - type QueryResults, - type FilterState, - type SortState, - type TableRenderOptions, -} 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); - -// Track renderer instances and their containers per output element for cleanup -const chartInstances = new WeakMap(); -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 { - const originalText = btn.innerText; - const originalDisabled = (btn as HTMLButtonElement).disabled; - (btn as HTMLButtonElement).disabled = true; - 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(() => { - frame = (frame + 1) % SPINNER_FRAMES.length; - btn.innerText = `${SPINNER_FRAMES[frame]} ${loadingLabel}`; - }, 100); - - return () => { - clearInterval(interval); - restore(); - }; -} - -// Inject amber-gutter CSS once -function ensureAmberGutterStyle(): void { - const STYLE_ID = 'amber-gutter-style'; - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = ` - .amber-gutter { - border-left: 4px solid #ffb000 !important; - } - `; - document.head.appendChild(style); -} - -/** Remove all transaction banners and amber gutters from the document */ -function clearTransactionUI(): void { - document.querySelectorAll('[data-transaction-banner="true"]').forEach((el) => el.remove()); - document.querySelectorAll('.amber-gutter').forEach((el) => el.classList.remove('amber-gutter')); -} - -const PIVOT_HELP_SQL_INLINE_MAX_CHARS = 12000; - -/** Rows attached as CSV when using Send to Chat from results (full grids are rarely useful). */ -const CHAT_SEND_SAMPLE_ROW_CAP = 10; - -function buildChatResultsSampleJson( - columns: string[], - rows: unknown[], - maxRows: number, -): string | undefined { - if (maxRows <= 0 || rows.length === 0) { - return undefined; - } - return JSON.stringify({ - columns, - rows: rows.slice(0, maxRows), - }); -} - -/** User message for SQL Assistant when pivot cardinality exceeds the client cap. */ -function buildPivotOptimizeUserMessage(ctx: PivotAiHelpContext, sourceSql: string): string { - const trimmed = sourceSql.trim(); - let sqlInline = trimmed; - let truncationNote = ''; - if (trimmed.length > PIVOT_HELP_SQL_INLINE_MAX_CHARS) { - sqlInline = trimmed.slice(0, PIVOT_HELP_SQL_INLINE_MAX_CHARS); - truncationNote = `\n-- … truncated for chat prompt (${trimmed.length.toLocaleString()} chars total); full SQL is attached as a file.`; - } - - const valueLine = - ctx.aggregation === 'count' && !ctx.valueColumn - ? 'Count rows (no separate value column)' - : ctx.valueColumn ?? '—'; - - return [ - 'PgStudio Analyst tab: the in-browser pivot failed because there are too many distinct row or column labels.', - '', - 'Help me rewrite my PostgreSQL query using server-side pre-aggregation (GROUP BY, rollups, bucketing, date_trunc, FILTER, CASE expressions, etc.) so pivot dimensions stay within a manageable cardinality.', - '', - `Pivot error: ${ctx.errorMessage}`, - '', - 'Pivot configuration:', - `- Row dimension: ${ctx.rowDimension}`, - `- Column dimension: ${ctx.columnDimension}`, - `- Value column / measure: ${valueLine}`, - `- Aggregation: ${ctx.aggregation}`, - '', - 'Context:', - `- UI cap (distinct values per axis): ${ctx.maxDistinctPerAxis}`, - `- Rows currently in this result grid: ${ctx.inMemoryRowCount.toLocaleString()}`, - `- Streaming sliding window: ${ctx.isStreamingWindow ? 'yes (only a subset of server rows may be loaded)' : 'no'}`, - '', - 'No result grid CSV is attached (usually redundant here; use the attached SQL file and pivot fields above).', - '', - 'Source SQL (also attached as a .sql file):', - '```sql', - sqlInline + truncationNote, - '```', - '', - 'Please propose efficient PostgreSQL that returns an aggregation-friendly result set I can pivot in the notebook, plus any index notes if relevant.', - ].join('\n'); -} - -export const activate: ActivationFunction = (context) => { - return { - renderOutputItem(data, element) { - // Silently ignore the legacy TopBar header output (removed feature) - if (data.mime === 'application/x-postgres-notebook-header+json') { - element.innerHTML = ''; - 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) { - element.innerText = 'No data'; - return; - } - - const { - columns = [], - rows, - rowCount, - command, - query, - notices, - executionTime, - tableInfo, - columnTypes, - backendPid, - breadcrumb, - autoLimitApplied, - autoLimitValue, - } = json; - const exportQuery: string | undefined = - typeof json.exportQuery === 'string' && json.exportQuery.trim().length > 0 - ? json.exportQuery - : query; - - let slideMeta: QueryResults['slidingWindow'] = json.slidingWindow; - - const byteaDisplayFormat: ByteaDisplayFormat = - json.byteaDisplayFormat === 'postgresql' || - json.byteaDisplayFormat === 'json' || - json.byteaDisplayFormat === 'hex0x' - ? json.byteaDisplayFormat - : BYTEA_DISPLAY_DEFAULT; - - const noticeItems = normalizeNoticesPayload(notices); - - const sourceCellIndex = - typeof json.sourceCellIndex === 'number' && json.sourceCellIndex >= 0 - ? json.sourceCellIndex - : -1; - - // Transaction state from payload - const transactionState: { isActive: boolean; statementCount: number } | undefined = - json.transactionState; - const pendingCommit: boolean = !!json.pendingCommit; - - // Data Management - let originalRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; - let currentRows: any[] = rows ? JSON.parse(JSON.stringify(rows)) : []; - let slideBufferedStartRow = slideMeta?.windowStartRow ?? 1; - let slideHasMoreBefore = slideMeta?.hasMoreBefore ?? false; - let slideHasMoreAfter = slideMeta?.hasMoreAfter ?? false; - let localFilterState: FilterState = { globalQuery: '', clauses: [] }; - let localSortState: SortState = { column: null, direction: 'none' }; - const selectedIndices = new Set(); - const modifiedCells = new Map(); - const rowsMarkedForDeletion = new Set(); - - // FK lookup pending callbacks — keyed by requestId - const fkCallbacks = new Map void>(); - - const buildTableRenderOptions = (): TableRenderOptions => ({ - columns, - rows: currentRows, - originalRows, - columnTypes, - tableInfo, - foreignKeys: tableInfo?.foreignKeys, - initialSelectedIndices: selectedIndices, - modifiedCells, - rowsMarkedForDeletion, - byteaDisplayFormat, - ...(slideMeta?.sessionId ? { rowNumberBaseline: slideBufferedStartRow } : {}), - }); - - const quoteIdentifier = (value: string): string => `"${value.replace(/"/g, '""')}"`; - const escapeSqlLiteral = (value: string): string => value.replace(/'/g, "''"); - const hasActiveLocalFilter = (): boolean => - localFilterState.globalQuery.trim().length > 0 || localFilterState.clauses.length > 0; - const hasActiveLocalSort = (): boolean => - !!localSortState.column && localSortState.direction !== 'none'; - const buildDerivedQueryFromLocalScope = (): string | undefined => { - const base = (exportQuery || query || '').trim(); - if (!base) return undefined; - const baseNoSemicolon = base.replace(/;\s*$/, ''); - const alias = 'pgstudio_src'; - const globalParts: string[] = []; - const whereParts: string[] = []; - - const appendLikeCondition = (column: string, mode: 'contains' | 'startsWith' | 'endsWith', raw: string) => { - const v = escapeSqlLiteral(raw); - const pattern = mode === 'contains' ? `%${v}%` : mode === 'startsWith' ? `${v}%` : `%${v}`; - whereParts.push(`CAST(${alias}.${quoteIdentifier(column)} AS text) ILIKE '${pattern}'`); - }; - - const globalQuery = localFilterState.globalQuery.trim(); - if (globalQuery) { - const pat = `%${escapeSqlLiteral(globalQuery)}%`; - for (const c of columns) { - globalParts.push(`CAST(${alias}.${quoteIdentifier(c)} AS text) ILIKE '${pat}'`); - } - if (globalParts.length > 0) { - whereParts.push(`(${globalParts.join(' OR ')})`); - } - } - - for (const clause of localFilterState.clauses) { - if (!columns.includes(clause.column)) continue; - const value = clause.value ?? ''; - if (clause.operator === 'equals') { - whereParts.push( - `CAST(${alias}.${quoteIdentifier(clause.column)} AS text) = '${escapeSqlLiteral(value)}'`, - ); - } else if (clause.operator === 'contains') { - appendLikeCondition(clause.column, 'contains', value); - } else if (clause.operator === 'startsWith') { - appendLikeCondition(clause.column, 'startsWith', value); - } else if (clause.operator === 'endsWith') { - appendLikeCondition(clause.column, 'endsWith', value); - } - } - - const hasWhere = whereParts.length > 0; - const sortColumn = - localSortState.column && columns.includes(localSortState.column) - ? localSortState.column - : null; - const hasSort = !!sortColumn && localSortState.direction !== 'none'; - - if (!hasWhere && !hasSort) { - return undefined; - } - - const whereSql = hasWhere ? `\nWHERE ${whereParts.join('\n AND ')}` : ''; - const orderSql = hasSort - ? `\nORDER BY ${alias}.${quoteIdentifier(sortColumn!)} ${localSortState.direction.toUpperCase()}` - : ''; - - return `SELECT *\nFROM (\n${baseNoSemicolon}\n) AS ${alias}${whereSql}${orderSql};`; - }; - - const buildFullDatasetRerunQuery = (): string | undefined => { - const scoped = buildDerivedQueryFromLocalScope(); - if (scoped) { - return scoped; - } - const base = (exportQuery || query || '').trim(); - if (!base) { - return undefined; - } - return base.endsWith(';') ? base : `${base};`; - }; - - const createAnalyticsStreamingWarning = ( - modeLabel: 'Chart' | 'Analyst', - ): HTMLElement | null => { - if (!slideMeta?.sessionId) { - return null; - } - const banner = createInlineBanner({ - severity: 'warning', - message: `${modeLabel} in streaming mode uses loaded rows only. Run on full dataset for accurate results; this may have performance impact depending on local machine capacity.`, - actionLabel: 'Run on full dataset', - onAction: () => { - const rerunQuery = buildFullDatasetRerunQuery(); - if (!rerunQuery) { - context.postMessage?.({ - type: 'showErrorMessage', - message: 'No query available to rerun for full dataset.', - }); - return; - } - context.postMessage?.({ - type: 'runDerivedQuery', - query: rerunQuery, - source: `streaming-${modeLabel.toLowerCase()}-full-dataset`, - fullDataset: true, - }); - }, - dismissible: false, - }); - banner.setAttribute('data-streaming-analytics-hint', modeLabel.toLowerCase()); - return banner; - }; - - const refreshStreamingScopeNotice = (): void => { - mainContainer.querySelector('[data-streaming-scope-hint="true"]')?.remove(); - if (!slideMeta?.sessionId) return; - const activeFilter = hasActiveLocalFilter(); - const activeSort = hasActiveLocalSort(); - if (!activeFilter && !activeSort) return; - - const scopeBits: string[] = []; - if (activeFilter) scopeBits.push('filter'); - if (activeSort) scopeBits.push('sort'); - const msg = `Streaming mode: ${scopeBits.join(' + ')} is applied to loaded rows only.`; - - const hint = createInlineBanner({ - severity: 'warning', - message: msg, - actionLabel: 'Apply to full dataset', - onAction: () => { - const derived = buildDerivedQueryFromLocalScope(); - if (!derived) { - context.postMessage?.({ - type: 'showErrorMessage', - message: 'No active local filter/sort to apply.', - }); - return; - } - context.postMessage?.({ - type: 'runDerivedQuery', - query: derived, - source: 'streaming-local-scope', - fullDataset: true, - }); - }, - dismissible: false, - }); - hint.setAttribute('data-streaming-scope-hint', 'true'); - mainContainer.appendChild(hint); - }; - - // Result history for tab strip — persists across re-renders in same output element - const historyEntry = { - columns, - rows: currentRows, - columnTypes, - tableInfo, - command, - rowCount, - executionTime, - query, - notices: noticeItems.length ? [...noticeItems] : undefined, - timestamp: Date.now(), - byteaDisplayFormat, - }; - const resultHistory = addResultToHistory(element, historyEntry); - - // Main Container - const mainContainer = document.createElement('div'); - mainContainer.style.cssText = ` - font-family: var(--vscode-font-family), "Segoe UI", "Helvetica Neue", sans-serif; - font-size: 13px; - color: var(--vscode-editor-foreground); - border: 1px solid var(--vscode-widget-border); - border-top: 2px solid ${BRAND_ACCENT}; - border-radius: 4px; - overflow: hidden; - margin-bottom: 8px; - box-shadow: 0 6px 14px rgba(0, 0, 0, 0.08); - `; - - const contentContainer = document.createElement('div'); - contentContainer.style.cssText = 'display: flex; flex-direction: column; height: 100%;'; - - let switchTab: (mode: string) => void = () => {}; - let showOverflowMenu: (anchorEl: HTMLElement) => void = () => {}; - - let isExpanded = true; - - const updateIdentityStats = (): void => { - const el = mainContainer.querySelector('[data-result-stats]') as HTMLElement | null; - if (!el) return; - let text: string; - if (slideMeta) { - const lastRow = slideMeta.windowStartRow + Math.max(currentRows.length, 1) - 1; - text = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} · window ${slideMeta.windowSize.toLocaleString()} · streaming`; - if (executionTime !== undefined) { - const ms = Math.round(executionTime * 1000); - text += ms >= 1000 ? ` · ${executionTime.toFixed(2)}s` : ` · ${ms}ms`; - } - } else { - text = formatResultExecutionStats(currentRows.length, executionTime); - } - el.textContent = text; - el.style.display = text.trim() ? 'inline-block' : 'none'; - }; - - const identityBar = createResultIdentityBar({ - queryPreview: buildQueryPreview(query, (command || 'QUERY').toUpperCase()), - queryFull: query, - command, - statsLine: json.error - ? undefined - : slideMeta - ? (() => { - const lastRow = slideMeta.windowStartRow + Math.max(rows?.length ?? 0, 1) - 1; - let t = `${slideMeta.windowStartRow.toLocaleString()}–${lastRow.toLocaleString()} · window ${slideMeta.windowSize.toLocaleString()} · streaming`; - if (executionTime !== undefined) { - const ms = Math.round(executionTime * 1000); - t += ms >= 1000 ? ` · ${executionTime.toFixed(2)}s` : ` · ${ms}ms`; - } - return t; - })() - : formatResultExecutionStats(currentRows.length, executionTime), - isCollapsed: false, - onToggleCollapse: () => { - isExpanded = !isExpanded; - contentContainer.style.display = isExpanded ? 'flex' : 'none'; - const ch = identityBar.querySelector('[data-chevron]'); - if (ch) { - ch.textContent = isExpanded ? '▼' : '▶'; - } - }, - onOverflow: (anchorEl) => showOverflowMenu(anchorEl), - onExpand: () => - context.postMessage?.({ - type: 'notebookOutputToolbar', - action: 'expand', - cellIndex: sourceCellIndex, - }), - }); - mainContainer.appendChild(identityBar); - - if (autoLimitApplied) { - const limitMsg = - autoLimitValue !== undefined - ? `Auto-LIMIT applied: showing ${rowCount?.toLocaleString() ?? '?'} rows (limit ${autoLimitValue})` - : 'A row limit was appended to this SELECT.'; - mainContainer.appendChild(createInlineBanner({ severity: 'info', message: limitMsg })); - } - - if (slideMeta && json.showSlidingCursorBanner === true && !json.error) { - mainContainer.appendChild( - createInlineBanner({ - severity: 'info', - message: - 'Server-side cursor: only one window of rows is loaded at a time. Scroll the grid near the top or bottom edge to fetch the previous or next page.', - onDismiss: () => context.postMessage?.({ type: 'cursorStreamBannerDismiss' }), - onMuteForever: () => context.postMessage?.({ type: 'cursorStreamBannerMute' }), - }), - ); - } - - if (json.performanceAnalysis?.isDegraded || json.slowQuery) { - const degraded = Boolean(json.performanceAnalysis?.isDegraded); - const perfMsg = degraded - ? json.performanceAnalysis!.analysis - : 'Slow query detected. Consider reviewing indexes and filters.'; - mainContainer.appendChild( - createInlineBanner({ severity: degraded ? 'warning' : 'info', message: perfMsg }), - ); - } - - if (noticeItems.length > 0) { - mainContainer.appendChild( - createInlineBanner({ - severity: 'warning', - message: `${noticeItems.length} notice${noticeItems.length !== 1 ? 's' : ''} from PostgreSQL`, - actionLabel: 'View', - onAction: () => switchTab('notices'), - }), - ); - } - - if (pendingCommit) { - mainContainer.appendChild( - createInlineBanner({ - severity: 'info', - message: - 'This result was produced inside an open transaction — changes are not durable until COMMIT.', - dismissible: false, - }), - ); - } - - mainContainer.appendChild(contentContainer); - - // Error Section - if (json.error) { - const errorPanel = createErrorPanel({ - errorCode: json.errorCode, - errorMessage: json.error, - explanation: json.errorExplanation, - onExplainError: () => { - context.postMessage?.({ type: 'explainError', error: json.error, query: json.query }); - }, - onFixWithAI: () => { - context.postMessage?.({ type: 'fixQuery', error: json.error, query: json.query }); - }, - onRetry: () => { - // Client-side retry: re-execute the cell by posting retryCell to the kernel - context.postMessage?.({ type: 'retryCell', query: json.query }); - }, - }); - contentContainer.appendChild(errorPanel); - } - - // Build the hidden export button to reuse its existing dropdown flow - const exportBtn = createExportButton(columns, currentRows, tableInfo, context, query); - exportBtn.style.display = 'none'; - - const gridPrefRequestId = `gcp-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - let skipGridCommitConfirm = false; - if (!json.error) { - context.postMessage?.({ - type: 'gridCommitPreference', - action: 'get', - requestId: gridPrefRequestId, - }); - } - - /** Export dropdown for footer row tools + kernel export flows */ - const openResultExportMenu = (anchorBtn: HTMLElement): void => { - const existing = document.querySelector('.export-dropdown'); - if (existing) { - existing.remove(); - return; - } - - const menu = document.createElement('div'); - menu.className = 'export-dropdown'; - menu.style.cssText = - `position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:${EXPORT_MENU_Z_INDEX};min-width:160px;border-radius:3px;padding:4px 0;visibility:hidden;`; - - const addItem = (label: string, onClick: () => void) => { - const item = document.createElement('div'); - item.textContent = label; - item.style.cssText = - 'padding:6px 12px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;'; - item.onmouseenter = () => { - item.style.background = 'var(--vscode-menu-selectionBackground)'; - item.style.color = 'var(--vscode-menu-selectionForeground)'; - }; - item.onmouseleave = () => { - item.style.background = 'transparent'; - item.style.color = 'var(--vscode-menu-foreground)'; - }; - item.onclick = (e) => { - e.stopPropagation(); - onClick(); - menu.remove(); - }; - menu.appendChild(item); - }; - - const postExport = ( - format: 'csv' | 'json' | 'markdown' | 'clipboard' | 'sqlinsert', - ): void => { - context.postMessage?.({ - type: 'export_request', - format, - query: exportQuery, - columns, - rows: currentRows, // fallback only if full query export fails - tableInfo, - }); - }; - - addItem('Save as CSV', () => postExport('csv')); - addItem('Save as JSON', () => postExport('json')); - addItem('Save as Markdown', () => postExport('markdown')); - addItem('Copy to Clipboard', () => { - postExport('clipboard'); - setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); - setTimeout(() => { - setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); - }, 2000); - }); - if (tableInfo) { - addItem('Copy SQL INSERT', () => { - postExport('sqlinsert'); - setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Working...'); - setTimeout(() => { - setExportToolbarButtonLabel(anchorBtn as HTMLButtonElement, 'Export'); - }, 2000); - }); - } - - document.body.appendChild(menu); - - positionExportDropdown(menu, anchorBtn); - menu.style.visibility = 'visible'; - setTimeout(() => { - const close = () => { - menu.remove(); - document.removeEventListener('click', close); - }; - document.addEventListener('click', close); - }, 0); - }; - - const aiMenuCallbacks: AiMenuOptions = { - onSendToChat: () => { - const resultsJson = buildChatResultsSampleJson( - columns, - currentRows, - CHAT_SEND_SAMPLE_ROW_CAP, - ); - context.postMessage?.({ - type: 'sendToChat', - data: { - query: json.query || '', - ...(resultsJson ? { results: resultsJson } : {}), - message: - currentRows.length === 0 - ? 'I ran this query. There were no rows; please help me interpret or fix it.' - : `I ran this query. The attachment includes at most ${CHAT_SEND_SAMPLE_ROW_CAP} sample rows from the result (not the full grid). Please help me understand the results.`, - }, - }); - }, - onAnalyzeWithAI: () => { - const escapeCSV = (val: any): string => { - if (val === null || val === undefined) return ''; - const str = typeof val === 'object' ? JSON.stringify(val) : String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - }; - const csvHeader = columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','); - const csvRows = currentRows.map((r: any) => - columns.map((c: string) => escapeCSV(r[c])).join(','), - ); - const dataCsv = [csvHeader, ...csvRows].join('\n'); - context.postMessage?.({ - type: 'analyzeData', - data: dataCsv, - query: json.query || '', - rowCount: currentRows.length, - }); - }, - onOptimize: () => { - context.postMessage?.({ - type: 'optimizeQuery', - query: json.query, - executionTime: json.executionTime, - }); - }, - }; - - // 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 buildDeletionReviewRows = (): Array<{ - rowIndex: number; - rowLabel: string; - }> => { - const sorted = Array.from(rowsMarkedForDeletion).sort((a, b) => a - b); - return sorted.map((rowIndex) => { - const pkLabel = tableInfo?.primaryKeys?.length - ? tableInfo.primaryKeys - .map((pk: string) => `${pk}=${formatDiffValue(originalRows[rowIndex]?.[pk])}`) - .join(', ') - : `row #${rowIndex + 1}`; - return { - rowIndex, - rowLabel: pkLabel, - }; - }); - }; - - const renderReviewChangesView = (): HTMLElement => { - const diffRows = buildEditDiffRows(); - const deletionRows = buildDeletionReviewRows(); - const pendingCount = modifiedCells.size + rowsMarkedForDeletion.size; - - const wrap = document.createElement('div'); - wrap.style.cssText = 'height:100%;overflow:auto;display:flex;flex-direction:column;'; - - const header = document.createElement('div'); - header.style.cssText = - 'padding:10px 12px;border-bottom:1px solid var(--vscode-widget-border);display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:10px;'; - - const headerText = document.createElement('div'); - headerText.style.cssText = 'display:flex;flex-direction:column;gap:2px;min-width:0;flex:1;'; - - 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; - const subParts: string[] = []; - if (diffRows.length > 0) { - subParts.push( - `${editedRowCount} row${editedRowCount !== 1 ? 's' : ''}, ${diffRows.length} edited cell${diffRows.length !== 1 ? 's' : ''}`, - ); - } - if (deletionRows.length > 0) { - subParts.push( - `${deletionRows.length} row${deletionRows.length !== 1 ? 's' : ''} marked for deletion`, - ); - } - subtitleEl.textContent = subParts.length > 0 ? subParts.join(' · ') : 'No pending changes'; - subtitleEl.style.cssText = 'font-size:11px;color:var(--vscode-descriptionForeground);'; - - headerText.appendChild(titleEl); - headerText.appendChild(subtitleEl); - header.appendChild(headerText); - - if (pendingCount > 0) { - const revertReviewBtn = document.createElement('button'); - revertReviewBtn.type = 'button'; - revertReviewBtn.textContent = 'Revert all'; - revertReviewBtn.title = 'Discard all unstaged edits and staged deletions'; - revertReviewBtn.style.cssText = ` - flex-shrink:0;padding:4px 12px;font-size:11px;font-family:var(--vscode-font-family); - cursor:pointer;border-radius:3px;font-weight:600; - background:color-mix(in srgb,#22c55e 14%,transparent); - color:#22c55e; - border:1px solid color-mix(in srgb,#22c55e 38%,transparent); - `; - revertReviewBtn.onmouseover = () => { - revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 22%,transparent)'; - }; - revertReviewBtn.onmouseout = () => { - revertReviewBtn.style.background = 'color-mix(in srgb,#22c55e 14%,transparent)'; - }; - revertReviewBtn.onclick = () => { - tableRenderer.revertAllPendingChanges(); - syncPendingChangesUi(); - switchTab('table'); - }; - header.appendChild(revertReviewBtn); - } - - wrap.appendChild(header); - - if (diffRows.length === 0 && deletionRows.length === 0) { - const empty = document.createElement('div'); - empty.style.cssText = - 'padding:20px 16px;color:var(--vscode-descriptionForeground);font-size:12px;'; - empty.textContent = 'No pending edits or deletions to review.'; - wrap.appendChild(empty); - return wrap; - } - - const appendEditTable = () => { - if (diffRows.length === 0) return; - - const sectionLabel = document.createElement('div'); - sectionLabel.textContent = 'Cell edits'; - sectionLabel.style.cssText = - 'padding:8px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; - wrap.appendChild(sectionLabel); - - 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); - }; - - const appendDeletionCards = () => { - if (deletionRows.length === 0) return; - - const sectionLabel = document.createElement('div'); - sectionLabel.textContent = 'Rows to delete'; - sectionLabel.style.cssText = - 'padding:12px 12px 4px;font-size:11px;font-weight:600;color:var(--vscode-descriptionForeground);text-transform:uppercase;letter-spacing:0.04em;'; - wrap.appendChild(sectionLabel); - - const divider = document.createElement('div'); - divider.style.cssText = - 'height:1px;margin:2px 12px 12px;background:color-mix(in srgb,var(--vscode-widget-border) 85%,transparent);'; - wrap.appendChild(divider); - - const cardsWrap = document.createElement('div'); - cardsWrap.style.cssText = - 'display:flex;flex-direction:column;gap:12px;padding:0 12px 16px;'; - - deletionRows.forEach(({ rowIndex, rowLabel }) => { - const rowData = originalRows[rowIndex] as Record | undefined; - - const card = document.createElement('article'); - card.style.cssText = ` - border:1px solid color-mix(in srgb, var(--vscode-widget-border) 70%, transparent); - border-radius:8px; - overflow:hidden; - background:color-mix(in srgb, #dc2626 7%, var(--vscode-editor-background)); - box-shadow:0 1px 2px rgba(0,0,0,0.06); - `; - - const head = document.createElement('header'); - head.style.cssText = ` - display:flex; - align-items:center; - justify-content:space-between; - gap:12px; - padding:8px 12px; - border-bottom:1px solid color-mix(in srgb, var(--vscode-widget-border) 55%, transparent); - background:color-mix(in srgb, #dc2626 11%, transparent); - `; - - const title = document.createElement('div'); - title.style.cssText = - 'font-size:12px;font-weight:700;font-family:var(--vscode-editor-font-family),monospace;color:var(--vscode-editor-foreground);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'; - title.textContent = `Row ${rowLabel}`; - - const undoBtn = document.createElement('button'); - undoBtn.type = 'button'; - undoBtn.textContent = 'Undo'; - undoBtn.title = 'Remove this row from the deletion queue'; - undoBtn.style.cssText = ` - flex-shrink:0;padding:3px 10px;font-size:11px;font-family:var(--vscode-font-family); - cursor:pointer;border-radius:4px;font-weight:600; - background:transparent;color:var(--vscode-textLink-foreground); - border:1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 38%, transparent); - `; - undoBtn.onmouseover = () => { - undoBtn.style.background = - 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; - }; - undoBtn.onmouseout = () => { - undoBtn.style.background = 'transparent'; - }; - undoBtn.onclick = () => { - rowsMarkedForDeletion.delete(rowIndex); - syncPendingChangesUi(); - tableRenderer.render(buildTableRenderOptions()); - switchTab('review'); - }; - - head.appendChild(title); - head.appendChild(undoBtn); - - const body = document.createElement('div'); - body.style.cssText = - 'padding:10px 12px;display:flex;flex-wrap:wrap;gap:10px 16px;align-items:flex-start;'; - - columns.forEach((colName: string) => { - const chip = document.createElement('span'); - chip.style.cssText = - 'display:inline-flex;align-items:baseline;gap:4px;font-size:11px;font-family:var(--vscode-editor-font-family),monospace;line-height:1.4;max-width:100%;word-break:break-word;'; - const k = document.createElement('span'); - k.style.cssText = 'color:var(--vscode-descriptionForeground);font-weight:600;flex-shrink:0;'; - k.textContent = `${colName}=`; - const v = document.createElement('span'); - v.style.color = 'var(--vscode-editor-foreground)'; - v.textContent = formatDiffValue(rowData?.[colName]); - chip.appendChild(k); - chip.appendChild(v); - body.appendChild(chip); - }); - - const foot = document.createElement('footer'); - foot.style.cssText = - 'padding:7px 12px 10px;font-size:10px;color:var(--vscode-descriptionForeground);font-style:italic;border-top:1px dashed color-mix(in srgb, var(--vscode-widget-border) 55%, transparent);'; - foot.textContent = '→ Will be removed when you commit.'; - - card.appendChild(head); - card.appendChild(body); - card.appendChild(foot); - cardsWrap.appendChild(card); - }); - - wrap.appendChild(cardsWrap); - }; - - appendEditTable(); - appendDeletionCards(); - return wrap; - }; - - let reviewTabBtn: HTMLButtonElement | null = null; - - /** Active view — used so the footer hides Delete when not on the table tab */ - let currentMode: string = 'table'; - - let syncReviewTabButton: () => void = () => {}; - - let stopPendingSaveLoading: (() => void) | undefined; - - let refreshResultFooter: () => void = () => {}; - - function syncPendingChangesUi(): void { - syncReviewTabButton(); - refreshResultFooter(); - } - - function runPerformSaveCommit(): void { - console.log('Renderer: Commit / save invoked'); - console.log('Renderer: Modified cells size:', modifiedCells.size); - console.log('Renderer: Rows marked for deletion:', rowsMarkedForDeletion.size); - - const updates: any[] = []; - modifiedCells.forEach((diff, key) => { - const parsed = parseCellKey(key); - if (!parsed) return; - const { rowIndex, colName } = parsed; - - console.log(`Renderer: Processing diff for row ${rowIndex}, col ${colName}`); - - if (tableInfo?.primaryKeys) { - const pkValues: Record = {}; - tableInfo.primaryKeys.forEach((pk: string) => { - pkValues[pk] = originalRows[rowIndex][pk]; - }); - updates.push({ - keys: pkValues, - column: colName, - value: diff.newValue, - originalValue: diff.originalValue, - }); - } else { - console.warn('Renderer: No primary keys found in tableInfo', tableInfo); - } - }); - - const deletions: any[] = []; - rowsMarkedForDeletion.forEach((rowIndex) => { - if (tableInfo?.primaryKeys) { - const pkValues: Record = {}; - tableInfo.primaryKeys.forEach((pk: string) => { - pkValues[pk] = originalRows[rowIndex][pk]; - }); - deletions.push({ - keys: pkValues, - row: originalRows[rowIndex], - }); - } - }); - - console.log('Renderer: Updates prepared:', updates); - console.log('Renderer: Deletions prepared:', deletions); - - if (updates.length > 0 || deletions.length > 0) { - console.log('Renderer: Posting saveChanges message'); - stopPendingSaveLoading?.(); - stopPendingSaveLoading = undefined; - const commitBtn = contentContainer.querySelector( - '[data-pg-result-commit]', - ) as HTMLButtonElement | null; - stopPendingSaveLoading = commitBtn - ? startButtonLoading(commitBtn, 'Saving...') - : undefined; - context.postMessage?.({ - type: 'saveChanges', - updates, - deletions, - tableInfo, - }); - } else { - const reason = !tableInfo?.primaryKeys - ? 'No primary keys found for this table.' - : 'Unknown error preparing updates.'; - console.warn(`Renderer: Save failed. ${reason}`); - context.postMessage?.({ - type: 'showErrorMessage', - message: `Cannot save changes: ${reason} (Primary keys are required to identify rows)`, - }); - } - } - - function performSave(): void { - const dirty = modifiedCells.size + rowsMarkedForDeletion.size; - if (dirty <= 0) { - return; - } - if (skipGridCommitConfirm) { - runPerformSaveCommit(); - return; - } - openCommitConfirmDialog({ - confirmLabel: `Commit (${dirty})`, - onConfirm: (dontAskAgain) => { - if (dontAskAgain) { - skipGridCommitConfirm = true; - context.postMessage?.({ - type: 'gridCommitPreference', - action: 'set', - skipConfirm: true, - }); - } - runPerformSaveCommit(); - }, - onCancel: () => {}, - }); - } - - let applyCursorResponse: ((message: any) => void) | undefined; - - function markSelectedRowsForDeletion(): void { - if (selectedIndices.size === 0) return; - selectedIndices.forEach((index) => { - rowsMarkedForDeletion.add(index); - }); - selectedIndices.clear(); - syncPendingChangesUi(); - tableRenderer.render(buildTableRenderOptions()); - updateActionsVisibility(); - } - - // Listen for messages from extension host - context.onDidReceiveMessage?.((message: any) => { - if ( - message.type === 'gridCommitPreferenceResponse' && - message.requestId === gridPrefRequestId && - message.skipConfirm === true - ) { - skipGridCommitConfirm = true; - return; - } - - // FK lookup response — resolve the waiting dropdown callback - if (message.type === 'fkLookupResponse') { - const cb = fkCallbacks.get(message.requestId); - if (cb) { - cb(message.rows || [], message.columns || []); - fkCallbacks.delete(message.requestId); - } - return; - } - - if (message.type === 'resultCursorResponse') { - applyCursorResponse?.(message); - return; - } - - // In-grid insert row result - if (message.type === 'insertSuccess') { - tableRenderer.replaceInsertRow(message.tempId, message.actualRow); - return; - } - if (message.type === 'insertFailed') { - tableRenderer.markInsertFailed(message.tempId, message.error || 'Insert failed'); - return; - } - - if (message.type === 'saveSuccess') { - console.log( - 'Renderer: Received saveSuccess, clearing modified cells and removing deleted rows', - ); - - stopPendingSaveLoading?.(); - stopPendingSaveLoading = undefined; - - // Update originalRows with edited values before removing any rows. - // 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 parsed = parseCellKey(key); - if (!parsed) return; - const { rowIndex, colName } = parsed; - if (rowIndex >= 0 && rowIndex < originalRows.length) { - originalRows[rowIndex][colName] = diff.newValue; - } - }); - - // Remove deleted rows from arrays (in reverse order to maintain indices) - const deletedIndices = Array.from(rowsMarkedForDeletion).sort((a, b) => b - a); - deletedIndices.forEach((index) => { - currentRows.splice(index, 1); - originalRows.splice(index, 1); - }); - - // Clear all pending changes - modifiedCells.clear(); - rowsMarkedForDeletion.clear(); - - syncPendingChangesUi(); - - // Re-render table to remove highlights and deleted rows - if (tableRenderer) { - tableRenderer.render(buildTableRenderOptions()); - } - } - - if (message.type === 'saveFailed') { - stopPendingSaveLoading?.(); - stopPendingSaveLoading = undefined; - } - }); - - /** Last Table / Chart / Analyst view when browsing notices etc. */ - let lastPrimaryMode: 'table' | 'chart' | 'analyst' = 'table'; - - // Secondary band: left = Table / Chart / … + optional View Plan; right = Export chart + AI (after chart init) - const secondaryTabsOuter = document.createElement('div'); - secondaryTabsOuter.style.cssText = - 'display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px;padding:6px 12px;border-bottom:1px solid var(--vscode-panel-border);background:var(--vscode-editor-background);'; - - const secondaryTabsLeft = document.createElement('div'); - secondaryTabsLeft.style.cssText = - 'display:flex;flex-wrap:wrap;align-items:center;gap:6px;flex:1;min-width:0;'; - - const secondaryTabsRight = document.createElement('div'); - secondaryTabsRight.style.cssText = - 'display:flex;align-items:center;gap:8px;flex-shrink:0;margin-left:auto;'; - - const isExplainQuery = - json.explainPlan || - (query && /^\s*EXPLAIN/i.test(query)) || - command === 'EXPLAIN' || - (columns.length === 1 && columns[0] === 'QUERY PLAN'); - - if (isExplainQuery) { - const explainPlanBtn = document.createElement('button'); - explainPlanBtn.type = 'button'; - fillToolbarButtonContent(explainPlanBtn, 'explain', 'View Plan'); - applyResultRowToolStyle(explainPlanBtn); - attachResultRowToolInteractions(explainPlanBtn); - explainPlanBtn.title = json.explainPlan - ? 'Open EXPLAIN ANALYZE plan view' - : 'Convert to JSON format and open visual plan view'; - - explainPlanBtn.onclick = () => { - if (json.explainPlan) { - switchTab('explain'); - } else { - console.log('Converting EXPLAIN to JSON, query:', query); - if (!query) { - alert('Cannot convert EXPLAIN plan: query not available'); - return; - } - context.postMessage?.({ - type: 'convertExplainToJson', - query: query, - }); - } - }; - secondaryTabsLeft.appendChild(explainPlanBtn); - } - - const tableViewBtn = document.createElement('button'); - tableViewBtn.type = 'button'; - fillToolbarButtonContent(tableViewBtn, 'table', 'Table'); - tableViewBtn.onclick = () => switchTab('table'); - attachResultViewTabHover(tableViewBtn); - - const chartViewBtn = document.createElement('button'); - chartViewBtn.type = 'button'; - fillToolbarButtonContent(chartViewBtn, 'chart', 'Chart'); - chartViewBtn.onclick = () => switchTab('chart'); - attachResultViewTabHover(chartViewBtn); - - const analystViewBtn = document.createElement('button'); - analystViewBtn.type = 'button'; - fillToolbarButtonContent(analystViewBtn, 'analyst', 'Analyst'); - analystViewBtn.onclick = () => switchTab('analyst'); - attachResultViewTabHover(analystViewBtn); - - const syncPrimaryButtons = () => { - applyResultViewTabStyle(tableViewBtn, lastPrimaryMode === 'table'); - applyResultViewTabStyle(chartViewBtn, lastPrimaryMode === 'chart'); - applyResultViewTabStyle(analystViewBtn, lastPrimaryMode === 'analyst'); - }; - syncPrimaryButtons(); - - const noticesBtn = document.createElement('button'); - noticesBtn.type = 'button'; - const noticesLabel = - noticeItems.length > 0 ? `Notices (${noticeItems.length})` : 'Notices'; - fillToolbarButtonContent(noticesBtn, 'notices', noticesLabel); - noticesBtn.onclick = () => switchTab('notices'); - applyResultViewTabStyle(noticesBtn, false); - attachResultViewTabHover(noticesBtn); - - const transposeBtn = document.createElement('button'); - transposeBtn.type = 'button'; - fillToolbarButtonContent(transposeBtn, 'transpose', 'Transpose'); - transposeBtn.onclick = () => switchTab('transpose'); - applyResultViewTabStyle(transposeBtn, false); - attachResultViewTabHover(transposeBtn); - - reviewTabBtn = document.createElement('button'); - reviewTabBtn.type = 'button'; - reviewTabBtn.onclick = () => switchTab('review'); - - let explainTabBtn: HTMLButtonElement | null = null; - if (json.explainPlan) { - explainTabBtn = document.createElement('button'); - explainTabBtn.type = 'button'; - fillToolbarButtonContent(explainTabBtn, 'explain', 'Explain Plan'); - explainTabBtn.onclick = () => switchTab('explain'); - applyResultViewTabStyle(explainTabBtn, false); - attachResultViewTabHover(explainTabBtn); - } - - const REVIEW_AMBER = '#f59e0b'; - syncReviewTabButton = () => { - if (!reviewTabBtn) return; - const pending = modifiedCells.size + rowsMarkedForDeletion.size; - const isActive = currentMode === 'review'; - - reviewTabBtn.replaceChildren(); - const ic = document.createElement('span'); - ic.className = RESULT_TOOLBAR_ICON_CLASS; - ic.innerHTML = resultToolbarSvg('review'); - const title = document.createElement('span'); - title.className = RESULT_TOOLBAR_LABEL_CLASS; - title.textContent = 'Review Changes'; - reviewTabBtn.appendChild(ic); - reviewTabBtn.appendChild(title); - - if (pending > 0) { - const badge = document.createElement('span'); - badge.textContent = String(pending); - badge.title = `${pending} pending change(s)`; - badge.style.cssText = ` - display:inline-block; - margin-left:6px; - min-width:18px; - text-align:center; - padding:0 6px; - border-radius:999px; - font-size:10px; - font-weight:700; - line-height:16px; - vertical-align:middle; - background:color-mix(in srgb, ${REVIEW_AMBER} 26%, transparent); - color:${REVIEW_AMBER}; - border:1px solid color-mix(in srgb, ${REVIEW_AMBER} 48%, transparent); - `; - reviewTabBtn.appendChild(badge); - } - - applyResultViewTabStyle(reviewTabBtn, isActive); - if (pending > 0) { - reviewTabBtn.style.background = isActive - ? `color-mix(in srgb, ${REVIEW_AMBER} 18%, color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 88%, transparent))` - : `color-mix(in srgb, ${REVIEW_AMBER} 14%, transparent)`; - reviewTabBtn.style.borderColor = `color-mix(in srgb, ${REVIEW_AMBER} 42%, var(--vscode-widget-border))`; - } - if (!isActive) { - reviewTabBtn.style.color = 'var(--vscode-editor-foreground)'; - } - }; - - reviewTabBtn.addEventListener('mouseenter', () => { - if (!reviewTabBtn || currentMode === 'review') return; - const pending = modifiedCells.size + rowsMarkedForDeletion.size; - reviewTabBtn.style.background = pending > 0 - ? `color-mix(in srgb, ${REVIEW_AMBER} 16%, color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 48%, transparent))` - : 'color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 55%, transparent)'; - }); - reviewTabBtn.addEventListener('mouseleave', () => syncReviewTabButton()); - - secondaryTabsLeft.appendChild(tableViewBtn); - secondaryTabsLeft.appendChild(chartViewBtn); - secondaryTabsLeft.appendChild(analystViewBtn); - secondaryTabsLeft.appendChild(noticesBtn); - secondaryTabsLeft.appendChild(transposeBtn); - secondaryTabsLeft.appendChild(reviewTabBtn); - if (explainTabBtn) secondaryTabsLeft.appendChild(explainTabBtn); - - secondaryTabsOuter.appendChild(secondaryTabsLeft); - secondaryTabsOuter.appendChild(secondaryTabsRight); - - if (!json.error) { - contentContainer.appendChild(secondaryTabsOuter); - } - - syncReviewTabButton(); - - // Views Containers - const viewContainer = document.createElement('div'); - viewContainer.style.cssText = - 'flex: 1; overflow: hidden; display: flex; flex-direction: column; position: relative; max-height: 500px;'; - if (!json.error) { - contentContainer.appendChild(viewContainer); - } - - // TABLE RENDERER - const tableRenderer = new TableRenderer(viewContainer, { - onSelectionChange: (indices) => { - selectedIndices.clear(); - indices.forEach((i) => selectedIndices.add(i)); - updateActionsVisibility(); - }, - onDataChange: (_rowIndex, _col, _newVal, _originalVal) => { - syncPendingChangesUi(); - updateActionsVisibility(); - if (currentMode === 'review') { - switchTab('review'); - } - }, - onInsertRow: (values, tempId) => { - context.postMessage?.({ type: 'insertRow', tableInfo, values, tempId }); - }, - onFkLookup: (requestId, fkSchema, fkTable, fkColumn, searchText, callback) => { - fkCallbacks.set(requestId, callback); - context.postMessage?.({ - type: 'fkLookup', - requestId, - fkSchema, - fkTable, - fkColumn, - searchText, - limit: 50, - }); - }, - onSortChange: (column, direction) => { - localSortState = { column, direction }; - refreshStreamingScopeNotice(); - }, - onFilterChange: (state) => { - localFilterState = { - globalQuery: state.globalQuery || '', - clauses: state.clauses.map((c) => ({ ...c })), - }; - refreshStreamingScopeNotice(); - }, - }); - - // Store for cleanup on disposal - tableInstances.set(element, tableRenderer); - - let slideFetchBusy = false; - let pendingSlideRequestId = ''; - let pendingSlideTargetStart: number | undefined; - let suppressSlideScrollUntil = 0; - let slideScrollCleanup: (() => void) | undefined; - const DEFAULT_ROW_HEIGHT_PX = 30; - const getSlideWindowSize = (): number => - Math.max(10, slideMeta?.windowSize ?? 100); - const getMaxBufferedRows = (): number => getSlideWindowSize() * 3; - const estimateDataRowHeight = (): number => { - const row = tableRenderer - .getScrollContainer() - .querySelector('tr[data-source-index]') as HTMLElement | null; - if (!row) { - return DEFAULT_ROW_HEIGHT_PX; - } - return Math.max(16, row.offsetHeight || DEFAULT_ROW_HEIGHT_PX); - }; - const syncSlideMetaFromBuffer = (): void => { - if (!slideMeta?.sessionId) { - return; - } - slideMeta = { - sessionId: slideMeta.sessionId, - windowStartRow: slideBufferedStartRow, - windowSize: getSlideWindowSize(), - hasMoreBefore: slideHasMoreBefore, - hasMoreAfter: slideHasMoreAfter, - }; - }; - - const attachSlideScroll = (): void => { - slideScrollCleanup?.(); - slideScrollCleanup = undefined; - if (!slideMeta?.sessionId) { - return; - } - const root = tableRenderer.getScrollContainer(); - let ticking = false; - const EDGE_PX = 72; - const onScroll = (): void => { - if (!slideMeta?.sessionId || slideFetchBusy) { - return; - } - if (Date.now() < suppressSlideScrollUntil) { - return; - } - if (ticking) { - return; - } - ticking = true; - requestAnimationFrame(() => { - ticking = false; - if (!slideMeta?.sessionId || slideFetchBusy) { - return; - } - const distBottom = root.scrollHeight - root.scrollTop - root.clientHeight; - const distTop = root.scrollTop; - let nextStart: number | undefined; - if (slideHasMoreAfter && distBottom < EDGE_PX) { - nextStart = slideBufferedStartRow + currentRows.length; - } else if (slideHasMoreBefore && distTop < EDGE_PX) { - nextStart = Math.max(1, slideBufferedStartRow - getSlideWindowSize()); - } - if (nextStart === undefined || nextStart === slideBufferedStartRow) { - return; - } - slideFetchBusy = true; - pendingSlideTargetStart = nextStart; - pendingSlideRequestId = `slide-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; - context.postMessage?.({ - type: 'resultCursorFetch', - sessionId: slideMeta.sessionId, - pageStartRow: nextStart, - requestId: pendingSlideRequestId, - }); - }); - }; - root.addEventListener('scroll', onScroll, { passive: true }); - slideScrollCleanup = (): void => { - root.removeEventListener('scroll', onScroll); - }; - }; - - applyCursorResponse = (message: any): void => { - if (pendingSlideRequestId && message.requestId !== pendingSlideRequestId) { - return; - } - const previousWindowStart = slideBufferedStartRow; - const requestedStart = pendingSlideTargetStart; - const rootBefore = tableRenderer.getScrollContainer(); - const prevScrollTop = rootBefore.scrollTop; - const rowHeight = estimateDataRowHeight(); - let scrollAdjustPx = 0; - slideFetchBusy = false; - pendingSlideRequestId = ''; - pendingSlideTargetStart = undefined; - if (message.error) { - slideScrollCleanup?.(); - slideScrollCleanup = undefined; - slideMeta = undefined; - mainContainer.insertBefore( - createInlineBanner({ severity: 'warning', message: String(message.error) }), - contentContainer, - ); - refreshStreamingScopeNotice(); - return; - } - const incomingRows = message.rows ? JSON.parse(JSON.stringify(message.rows)) : []; - const incomingOriginalRows = JSON.parse(JSON.stringify(incomingRows)); - const movedForward = - typeof requestedStart === 'number' && requestedStart > previousWindowStart; - const movedBackward = - typeof requestedStart === 'number' && requestedStart < previousWindowStart; - - if (!slideMeta?.sessionId || !requestedStart) { - currentRows = incomingRows; - originalRows = incomingOriginalRows; - slideBufferedStartRow = message.slidingWindow?.windowStartRow ?? slideBufferedStartRow; - } else if (movedForward) { - currentRows = [...currentRows, ...incomingRows]; - originalRows = [...originalRows, ...incomingOriginalRows]; - } else if (movedBackward) { - currentRows = [...incomingRows, ...currentRows]; - originalRows = [...incomingOriginalRows, ...originalRows]; - slideBufferedStartRow = requestedStart; - scrollAdjustPx += incomingRows.length * rowHeight; - } else { - currentRows = incomingRows; - originalRows = incomingOriginalRows; - slideBufferedStartRow = requestedStart; - } - - const maxBufferedRows = getMaxBufferedRows(); - if (currentRows.length > maxBufferedRows) { - const overflow = currentRows.length - maxBufferedRows; - if (movedForward) { - currentRows = currentRows.slice(overflow); - originalRows = originalRows.slice(overflow); - slideBufferedStartRow += overflow; - scrollAdjustPx -= overflow * rowHeight; - } else if (movedBackward) { - currentRows = currentRows.slice(0, currentRows.length - overflow); - originalRows = originalRows.slice(0, originalRows.length - overflow); - } else { - currentRows = currentRows.slice(0, maxBufferedRows); - originalRows = originalRows.slice(0, maxBufferedRows); - } - } - - if (message.slidingWindow) { - if (movedForward) { - slideHasMoreAfter = message.slidingWindow.hasMoreAfter; - } else if (movedBackward) { - slideHasMoreBefore = message.slidingWindow.hasMoreBefore; - } else { - slideHasMoreBefore = message.slidingWindow.hasMoreBefore; - slideHasMoreAfter = message.slidingWindow.hasMoreAfter; - } - if (slideBufferedStartRow > 1) { - slideHasMoreBefore = true; - } - syncSlideMetaFromBuffer(); - } - refreshStreamingScopeNotice(); - selectedIndices.clear(); - modifiedCells.clear(); - rowsMarkedForDeletion.clear(); - if (currentMode === 'table') { - tableRenderer.render(buildTableRenderOptions()); - } - updateIdentityStats(); - refreshResultFooter(); - suppressSlideScrollUntil = Date.now() + 120; - requestAnimationFrame(() => { - const root = tableRenderer.getScrollContainer(); - const nextScrollTop = Math.max(0, prevScrollTop + scrollAdjustPx); - root.scrollTop = nextScrollTop; - }); - }; - - const rowToolHandlers: RowToolsOptions = { - onSelectAll: () => { - if (selectedIndices.size === currentRows.length && currentRows.length > 0) { - selectedIndices.clear(); - } else { - currentRows.forEach((_: any, i: number) => selectedIndices.add(i)); - } - tableRenderer.updateSelection(selectedIndices); - refreshResultFooter(); - }, - onCopy: () => { - const rowsToCopy = - selectedIndices.size > 0 - ? Array.from(selectedIndices).map((i) => currentRows[i]) - : currentRows; - const escapeCSV = (val: any): string => { - if (val === null || val === undefined) return ''; - const str = typeof val === 'object' ? JSON.stringify(val) : String(val); - if (str.includes(',') || str.includes('"') || str.includes('\n')) { - return `"${str.replace(/"/g, '""')}"`; - } - return str; - }; - const csv = [ - columns.map((c: string) => `"${c.replace(/"/g, '""')}"`).join(','), - ...rowsToCopy.map((r: any) => columns.map((c: string) => escapeCSV(r[c])).join(',')), - ].join('\n'); - navigator.clipboard?.writeText(csv); - }, - onImport: () => { - showImportModal(columns, tableInfo, context); - }, - onExport: openResultExportMenu, - }; - - refreshResultFooter = () => { - if (json.error) return; - contentContainer.querySelector('[data-result-footer="true"]')?.remove(); - const dirty = modifiedCells.size + rowsMarkedForDeletion.size; - const tableView = currentMode === 'table'; - const sel = tableView ? selectedIndices.size : 0; - contentContainer.appendChild( - createResultFooter({ - rowTools: - columns.length > 0 - ? { - ...rowToolHandlers, - allRowsSelected: - tableView && - currentRows.length > 0 && - selectedIndices.size === currentRows.length, - } - : undefined, - onAddRow: tableInfo - ? () => { - switchTab('table'); - requestAnimationFrame(() => tableRenderer.triggerAddRow()); - } - : undefined, - dirtyCount: dirty, - onCommit: dirty > 0 ? performSave : undefined, - deleteSelectionCount: sel, - onDeleteSelected: sel > 0 && tableView ? markSelectedRowsForDeletion : undefined, - deleteUnavailableReason: - sel > 0 && !tableInfo?.primaryKeys - ? 'Warning: No primary keys detected. Deletion may fail.' - : undefined, - onRevert: - dirty > 0 - ? () => { - tableRenderer.revertAllPendingChanges(); - syncPendingChangesUi(); - } - : undefined, - }), - ); - updateIdentityStats(); - }; - - // CHART RENDERER - const chartCanvas = document.createElement('canvas'); - const chartRenderer = new ChartRenderer(chartCanvas); - - // Store for cleanup on disposal - chartInstances.set(element, chartRenderer); - - const exportChartBtn = document.createElement('button'); - exportChartBtn.type = 'button'; - fillToolbarButtonContent(exportChartBtn, 'chart', 'Export Chart'); - applyResultRowToolStyle(exportChartBtn); - attachResultRowToolInteractions(exportChartBtn); - exportChartBtn.style.display = 'none'; // Hidden by default - exportChartBtn.onclick = () => { - const dataUrl = chartRenderer.exportImage('png'); - if (dataUrl) { - const a = document.createElement('a'); - a.href = dataUrl; - a.download = `chart-${new Date().toISOString()}.png`; - a.click(); - } - }; - secondaryTabsRight.appendChild(exportChartBtn); - secondaryTabsRight.appendChild(createAiMenuButton(aiMenuCallbacks)); - - const updateActionsVisibility = () => { - if (currentMode === 'chart') { - exportChartBtn.style.display = 'inline-block'; - } else { - exportChartBtn.style.display = 'none'; - } - refreshResultFooter(); - }; - - // Switch Tab Logic - - const setSecondaryActive = (mode: string | null) => { - applyResultViewTabStyle(noticesBtn, mode === 'notices'); - applyResultViewTabStyle(transposeBtn, mode === 'transpose'); - if (explainTabBtn) applyResultViewTabStyle(explainTabBtn, mode === 'explain'); - syncReviewTabButton(); - }; - - switchTab = (mode: string) => { - currentMode = mode; - viewContainer.innerHTML = ''; - - if (mode === 'table' || mode === 'chart' || mode === 'analyst') { - lastPrimaryMode = mode; - syncPrimaryButtons(); - setSecondaryActive(null); - } else { - syncPrimaryButtons(); - setSecondaryActive(mode); - } - - if (mode === 'table') { - updateActionsVisibility(); - tableRenderer.render(buildTableRenderOptions()); - attachSlideScroll(); - } else if (mode === 'notices') { - 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') { - updateActionsVisibility(); - const transposeEl = renderTransposeTable( - columns, - currentRows, - columnTypes, - byteaDisplayFormat, - ); - viewContainer.appendChild(transposeEl); - } else if (mode === 'review') { - updateActionsVisibility(); - viewContainer.appendChild(renderReviewChangesView()); - } else if (mode === 'explain') { - updateActionsVisibility(); - - const explainWrapper = document.createElement('div'); - explainWrapper.style.cssText = - 'flex: 1; overflow: auto; height: 100%; display: flex; flex-direction: column;'; - viewContainer.appendChild(explainWrapper); - - if (json.explainPlan) { - try { - new ExplainVisualizer(explainWrapper, json.explainPlan).render(); - } catch (e) { - explainWrapper.textContent = 'Failed to render explain plan: ' + String(e); - } - } else { - explainWrapper.textContent = - 'No explain plan data available. Run EXPLAIN (ANALYZE, FORMAT JSON) to get a visual plan.'; - } - } else if (mode === 'analyst') { - updateActionsVisibility(); - const streamingHint = createAnalyticsStreamingWarning('Analyst'); - if (streamingHint) { - viewContainer.appendChild(streamingHint); - } - viewContainer.appendChild( - renderAnalystPanel({ - columns, - rows: currentRows, - columnTypes, - isStreaming: !!slideMeta?.sessionId, - onAskAiForPivotHelp: (pivotCtx) => { - const sqlText = (buildFullDatasetRerunQuery() || exportQuery || query || '').trim(); - context.postMessage?.({ - type: 'sendToChat', - data: { - query: sqlText || query || '', - message: buildPivotOptimizeUserMessage(pivotCtx, sqlText || query || ''), - }, - }); - }, - onRunFullDataset: () => { - const rerunQuery = buildFullDatasetRerunQuery(); - if (!rerunQuery) { - context.postMessage?.({ - type: 'showErrorMessage', - message: 'No query available to rerun for full dataset.', - }); - return; - } - context.postMessage?.({ - type: 'runDerivedQuery', - query: rerunQuery, - source: 'streaming-analyst-pivot-full-dataset', - fullDataset: true, - }); - }, - }), - ); - } else { - // chart - updateActionsVisibility(); - const streamingHint = createAnalyticsStreamingWarning('Chart'); - if (streamingHint) { - viewContainer.appendChild(streamingHint); - } - - const chartWrapper = document.createElement('div'); - chartWrapper.style.cssText = - 'flex: 1; display: flex; flex-direction: column; height: 100%; overflow: hidden;'; - - const controlsContainer = document.createElement('div'); - controlsContainer.style.cssText = - 'width: 20%; min-width: 160px; max-width: 240px; display: flex; flex-direction: column; border-right: 1px solid var(--vscode-widget-border);'; - - const canvasContainer = document.createElement('div'); - canvasContainer.style.cssText = - 'flex: 1; padding: 8px; position: relative; min-height: 0;'; - canvasContainer.appendChild(chartCanvas); - - const innerContainer = document.createElement('div'); - innerContainer.style.cssText = 'display: flex; flex: 1; overflow: hidden; height: 100%;'; - innerContainer.appendChild(controlsContainer); - innerContainer.appendChild(canvasContainer); - chartWrapper.appendChild(innerContainer); - - viewContainer.appendChild(chartWrapper); - - new ChartControls(controlsContainer, { - columns, - rows: currentRows, - onConfigChange: (config) => { - chartRenderer.render(currentRows, config); - }, - }); - } - refreshResultFooter(); - }; - - showOverflowMenu = (anchorEl: HTMLElement) => { - const existing = document.querySelector('.result-overflow-menu'); - if (existing) { - existing.remove(); - return; - } - - const menu = document.createElement('div'); - menu.className = 'result-overflow-menu'; - menu.style.cssText = - 'position:fixed;background:var(--vscode-menu-background);border:1px solid var(--vscode-menu-border);box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:1000;min-width:170px;border-radius:4px;padding:3px 0;'; - - const addItem = (label: string, onClick: () => void) => { - const item = document.createElement('div'); - item.textContent = label; - item.style.cssText = - 'padding:6px 14px;cursor:pointer;color:var(--vscode-menu-foreground);font-size:12px;font-family:var(--vscode-font-family);'; - item.onmouseenter = () => { - item.style.background = 'var(--vscode-menu-selectionBackground)'; - item.style.color = 'var(--vscode-menu-selectionForeground)'; - }; - item.onmouseleave = () => { - item.style.background = 'transparent'; - item.style.color = 'var(--vscode-menu-foreground)'; - }; - item.onclick = (e) => { - e.stopPropagation(); - onClick(); - menu.remove(); - }; - menu.appendChild(item); - }; - - addItem('⇄ Transpose', () => switchTab('transpose')); - if (noticeItems.length > 0) { - addItem(`Notices (${noticeItems.length})`, () => switchTab('notices')); - } - if (json.explainPlan) { - addItem('Explain Plan', () => switchTab('explain')); - } - if (breadcrumb?.connectionName) { - addItem('Switch connection…', () => - context.postMessage?.({ - type: 'showConnectionSwitcher', - connectionId: breadcrumb.connectionId, - }), - ); - } - if (breadcrumb?.database) { - addItem('Switch database…', () => - context.postMessage?.({ - type: 'showDatabaseSwitcher', - connectionId: breadcrumb.connectionId, - currentDatabase: breadcrumb.database, - }), - ); - } - - document.body.appendChild(menu); - const rect = anchorEl.getBoundingClientRect(); - menu.style.top = `${rect.bottom + 4}px`; - const mw = 200; - menu.style.left = `${Math.max(8, Math.min(rect.right - mw, window.innerWidth - mw - 8))}px`; - - setTimeout(() => { - const close = () => { - menu.remove(); - document.removeEventListener('click', close); - }; - document.addEventListener('click', close); - }, 0); - }; - - // Initial Render - if (columns.length > 0) { - switchTab('table'); - } else if (noticeItems.length > 0) { - switchTab('notices'); - } else { - 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 - const tabStripEl = renderTabStrip(element, resultHistory, 0, (selectedIndex) => { - // Re-render with a previous result's data - const entry = getResultHistory(element)[selectedIndex]; - if (!entry) return; - element.innerHTML = ''; - // Re-trigger renderOutputItem with the historical data by re-building the output - // For now: show history entry as a read-only view - const histContainer = document.createElement('div'); - histContainer.style.cssText = - 'padding:6px 12px;font-size:11px;color:var(--vscode-descriptionForeground);border-bottom:1px solid var(--vscode-widget-border);background:var(--vscode-editor-background);'; - histContainer.textContent = `Showing result from ${new Date(entry.timestamp).toLocaleTimeString()} — ${(entry.rowCount ?? entry.rows?.length ?? 0).toLocaleString()} rows`; - element.appendChild(histContainer); - - const histTableContainer = document.createElement('div'); - histTableContainer.style.cssText = 'max-height:400px;overflow:auto;'; - const histRenderer = new TableRenderer(histTableContainer, {}); - histRenderer.render({ - columns: entry.columns, - rows: entry.rows || [], - originalRows: entry.rows || [], - columnTypes: entry.columnTypes, - tableInfo: entry.tableInfo, - byteaDisplayFormat: entry.byteaDisplayFormat ?? BYTEA_DISPLAY_DEFAULT, - }); - element.appendChild(histTableContainer); - }); - if (tabStripEl) element.appendChild(tabStripEl); - - const outputRoot = document.createElement('div'); - outputRoot.setAttribute('data-pg-output-hover-root', 'true'); - outputRoot.style.cssText = 'position:relative;display:flex;flex-direction:column;'; - - const hoverToolbar = document.createElement('div'); - hoverToolbar.setAttribute('role', 'toolbar'); - hoverToolbar.setAttribute('aria-label', 'Result quick actions'); - hoverToolbar.style.cssText = ` - display:flex; - flex-wrap:wrap; - justify-content:flex-end; - align-items:center; - gap:6px; - max-width:min(680px, 100%); - padding:5px 8px; - border-radius:10px; - background:color-mix(in srgb, var(--vscode-editor-background) 86%, transparent); - border:1px solid color-mix(in srgb, var(--vscode-widget-border) 42%, transparent); - box-shadow:0 4px 18px rgba(0,0,0,0.1); - backdrop-filter:blur(10px); - `; - - const toolbarDock = document.createElement('div'); - toolbarDock.style.cssText = ` - display:flex; - flex-direction:column; - align-items:flex-end; - gap:6px; - position:absolute; - right:10px; - top:-30px; - z-index:34; - `; - const toolbarToggle = document.createElement('button'); - toolbarToggle.type = 'button'; - fillOutputHoverToolButton(toolbarToggle, 'sparkles', 'AI actions'); - toolbarToggle.style.padding = '5px 12px'; - toolbarToggle.style.fontSize = '11px'; - const toggleChevron = document.createElement('span'); - toggleChevron.style.cssText = 'font-size:11px;line-height:1;opacity:0.85;'; - toggleChevron.textContent = '▸'; - toolbarToggle.appendChild(toggleChevron); - - let toolbarCollapsed = true; - const updateToolbarVisibility = (): void => { - hoverToolbar.style.display = toolbarCollapsed ? 'none' : 'flex'; - toolbarToggle.setAttribute('aria-expanded', toolbarCollapsed ? 'false' : 'true'); - toolbarToggle.title = toolbarCollapsed ? 'Show result AI actions' : 'Hide result AI actions'; - toggleChevron.textContent = toolbarCollapsed ? '▸' : '▾'; - }; - toolbarToggle.addEventListener('click', (ev) => { - ev.stopPropagation(); - toolbarCollapsed = !toolbarCollapsed; - updateToolbarVisibility(); - }); - updateToolbarVisibility(); - - const addHoverTool = ( - glyph: ResultToolbarGlyph, - label: string, - onClick: () => void, - opts?: { disabled?: boolean; title?: string }, - ): void => { - const btn = document.createElement('button'); - btn.type = 'button'; - fillOutputHoverToolButton(btn, glyph, label); - const title = opts?.title ?? label; - btn.title = title; - btn.setAttribute('aria-label', title); - if (opts?.disabled) { - btn.disabled = true; - btn.style.opacity = '0.42'; - btn.style.cursor = 'not-allowed'; - } - btn.addEventListener('click', (ev) => { - ev.stopPropagation(); - if (btn.disabled) { - return; - } - onClick(); - }); - hoverToolbar.appendChild(btn); - }; - - const queryTrimmed = (query || '').trim(); - const cellLinked = sourceCellIndex >= 0; - - addHoverTool( - 'menuChat', - 'Add to chat', - () => { - const resultsJson = buildChatResultsSampleJson( - columns, - currentRows, - CHAT_SEND_SAMPLE_ROW_CAP, - ); - context.postMessage?.({ - type: 'sendToChat', - data: { - query: queryTrimmed, - ...(resultsJson ? { results: resultsJson } : {}), - ...(noticeItems.length > 0 - ? { notices: noticeItems.map(n => (n.message || '').trim()).filter(Boolean) } - : {}), - message: - currentRows.length === 0 - ? 'I ran this query. No rows were returned. Help me validate the query intent and next checks.' - : `I ran this query. The attachment includes at most ${CHAT_SEND_SAMPLE_ROW_CAP} sample rows from the result (not the full grid). Help me interpret it.`, - }, - }); - }, - { - disabled: !queryTrimmed, - title: queryTrimmed - ? 'Attach SQL and sampled result rows to SQL Assistant' - : 'No query text', - }, - ); - addHoverTool( - 'menuBolt', - 'Optimize', - () => { - aiMenuCallbacks.onOptimize(); - }, - { - disabled: !queryTrimmed, - title: queryTrimmed ? 'Suggest optimizations for this query' : 'No query text', - }, - ); - addHoverTool( - 'sparkles', - 'Ask AI', - () => { - context.postMessage?.({ - type: 'notebookOutputToolbar', - action: 'aiAssist', - cellIndex: sourceCellIndex, - }); - }, - { - disabled: !cellLinked, - title: cellLinked - ? 'Ask AI to modify this query' - : 'Re-run the cell to link actions to the source cell', - }, - ); - addHoverTool( - 'save', - 'Save', - () => { - context.postMessage?.({ - type: 'notebookOutputToolbar', - action: 'saveQuery', - cellIndex: sourceCellIndex, - }); - }, - { - disabled: !cellLinked, - title: cellLinked ? 'Save query to library' : 'Re-run the cell to link actions to the source cell', - }, - ); - addHoverTool( - 'expandCell', - 'Expand', - () => { - context.postMessage?.({ - type: 'notebookOutputToolbar', - action: 'expand', - cellIndex: sourceCellIndex, - }); - }, - { - disabled: !cellLinked, - title: cellLinked ? 'Focus the SQL cell in the editor' : 'Re-run the cell to link actions to the source cell', - }, - ); - - outputRoot.appendChild(mainContainer); - toolbarDock.appendChild(toolbarToggle); - toolbarDock.appendChild(hoverToolbar); - outputRoot.appendChild(toolbarDock); - element.appendChild(outputRoot); - - // Transaction state: show banner and amber gutter - ensureAmberGutterStyle(); - if (transactionState?.isActive) { - mainContainer.classList.add('amber-gutter'); - - // Only add one banner per document (remove stale ones first) - const existingBanner = document.querySelector('[data-transaction-banner="true"]'); - if (!existingBanner) { - const banner = createTransactionBanner({ - statementCount: transactionState.statementCount, - onCommit: () => { - context.postMessage?.({ type: 'commitTransaction' }); - }, - onRollback: () => { - context.postMessage?.({ type: 'rollbackTransaction' }); - }, - }); - // Insert banner before the first output container in the element's parent - const outputHost = element.parentElement || element; - outputHost.insertBefore(banner, outputHost.firstChild); - } - } else { - // Transaction closed — clear all transaction UI - clearTransactionUI(); - } - }, - }; -}; +export { activate } from './mimeRouter'; diff --git a/templates/ai-settings/index.html b/templates/ai-settings/index.html index c6b3db0..1a078dc 100644 --- a/templates/ai-settings/index.html +++ b/templates/ai-settings/index.html @@ -37,6 +37,7 @@

AI Configuration

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

Backup & Restore

+

+
+ + + + + +
+
+
+
Format & options
+
+ + +
+
+ + + + +
+
+ + +

Space-separated tokens appended before the database name (quoted strings supported). Omit connection flags; host, port, and user come from the connection.

+
+
+
+
Scope
+
+
+ + +

Directory format

+
+
+ + +

Custom format only (0–9)

+
+
+
+ + +
+
+ +

Optional: with no tables chosen below, dump only these schemas (-n). While any schema is selected here, the table list is limited to those schemas. If you pick tables, pg_dump uses -t only (no -n).

+ +
+ + +
+
+
+ +

Choose tables for a targeted dump. When schemas are selected above, only tables from those schemas appear here.

+
+ + +
+
+
+
+
Output
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
Archive
+
+ + +
+ +
+
+
Restore target
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +

Space-separated tokens for pg_restore and for Dry-run pg_restore --list (archive path is appended automatically). Omit -h/-p/-U/-d when restoring from this panel.

+
+ + +
+ +
+
+
+ +
+
+
Cluster dump
+
+ + +
+ +
+ + + +
+
+ + +

Space-separated tokens inserted before -f. Omit connection flags; host, port, and user come from the connection.

+
+

Uses the configured role; roles/globals often need elevated privileges.

+
+ +
+
+
+
+ +
+ +
+
+ +

Output log

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

📚 Chat History

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

📚 Chat History

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

📚 Chat History

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

📚 Chat History

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

📚 Chat History

- +