diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2ea66..ac64ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.2] - 2026-02-08 + +### Added +- **Advanced Saved Queries System**: Complete redesign with tag-based organization, connection context preservation, AI metadata generation, and rich metadata display. +- **Context Menu Actions**: Copy Query, Edit Query (modify title/description/tags/SQL), Open in Notebook (with context restoration), and Delete. +- **Professional Query Form**: Syntax-highlighted SQL preview, form validation, error handling, and AI-assisted metadata generation. +- **Tree View Organization**: Queries grouped by collapsible tag groups; untagged queries displayed separately. + +### Improved +- **Notebook Integration**: Saved queries now open directly in `.pgsql` notebooks with full connection metadata (connectionId, databaseName, schemaName) automatically restored. +- **Query Discovery**: Rich tooltips with creation/last-used dates, database name, connection name, and usage count on tree items. + +--- + ## [0.8.1] - 2026-02-08 ### Added diff --git a/MARKETPLACE.md b/MARKETPLACE.md index 2e34b73..33674b5 100644 --- a/MARKETPLACE.md +++ b/MARKETPLACE.md @@ -36,6 +36,7 @@ | šŸ›”ļø **Connection Safety** | Environment tagging (šŸ”“ PROD, 🟔 STAGING, 🟢 DEV), read-only mode, query safety analyzer | | šŸ“Š **Live Dashboard** | Real-time metrics, active query monitoring, and performance graphs | | šŸ““ **SQL Notebooks** | Interactive notebooks with rich output, AI assistance, and export options | +| šŸ’¾ **Saved Queries** | Tag-based organization, AI metadata generation, connection context restoration, edit & reuse | | 🌳 **Database Explorer** | Browse tables, views, functions, types, extensions, roles, and FDWs | | šŸ› ļø **Object Operations** | Full CRUD operations, scripts, VACUUM, ANALYZE, REINDEX | | šŸ“Š **Table Intelligence** | Profile, activity monitor, index usage analytics, definition viewer | @@ -128,6 +129,20 @@ Navigate your database with an intuitive hierarchical tree view: --- +## šŸ’¾ Saved Queries Library + +Organize, manage, and reuse your most important queries with intelligent tagging and context preservation. + +### Core Capabilities +- **šŸ·ļø Tag-Based Organization** — Group queries by purpose for instant discovery +- **šŸ”— Connection Context** — Queries remember their original connection, database, and schema +- **šŸ““ One-Click Reopening** — Restore queries with full context in a new notebook +- **āœļø In-Place Editing** — Modify queries without creating duplicates +- **šŸ¤– AI Metadata Generation** — Auto-generate titles, descriptions, and tags +- **šŸ“Š Rich Metadata Display** — See creation date, usage count, database, and connection at a glance + +--- + ## šŸ¤– AI-Powered Assistance Leverage AI to write, optimize, and debug your queries faster: diff --git a/README.md b/README.md index 12541da..8f033ef 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ - šŸ›”ļø **Connection Safety** — Environment tagging (šŸ”“ PROD, 🟔 STAGING, 🟢 DEV), read-only mode, query safety analyzer - šŸ“Š **Live Dashboard** — Real-time metrics & query monitoring - šŸ““ **SQL Notebooks** — Interactive notebooks with AI assistance +- šŸ’¾ **Saved Queries** — Tag-based organization, connection context restoration, AI metadata generation, edit & reuse - 🌳 **Database Explorer** — Browse tables, views, functions, types, FDWs - šŸ› ļø **Object Operations** — CRUD, scripts, VACUUM, ANALYZE, REINDEX - šŸ“Š **Table Intelligence** — Profile, activity monitor, index usage, definition viewer @@ -158,6 +159,27 @@ yape/ --- +## šŸ’¾ Saved Queries Library + +Organize, manage, and reuse your most important queries with intelligent tagging and context preservation. + +### Features +- **šŸ·ļø Tag-Based Organization** — Group queries by topic (e.g., "analytics", "maintenance", "daily-reports") +- **šŸ”— Connection Context** — Queries remember their original connection, database, and schema +- **šŸ““ Quick Reopening** — Click "Open in Notebook" to restore the query with full context in a new notebook +- **āœļø Edit Anytime** — Modify title, description, tags, and SQL without creating duplicates +- **šŸ¤– AI Metadata** — Auto-generate titles, descriptions, and tags using AI +- **šŸ“Š Rich Metadata Display** — Hover to see creation date, last used, database, and schema + +### Usage +1. **Save Query**: Click "Save Query" CodeLens button on any SQL cell in a notebook +2. **Add Metadata**: Enter title, description, and tags (AI can help auto-generate) +3. **Organize**: Use tags to group related queries +4. **Reuse**: Click a saved query → "Open in Notebook" to restore with original context +5. **Edit**: Right-click any saved query → "Edit Query" to modify it + +--- + ## šŸ¤– AI-Powered Operations PgStudio integrates advanced AI capabilities directly into your workflow, but keeps **YOU** in control. diff --git a/package.json b/package.json index 7788e1a..f834d13 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "postgres-explorer", "displayName": "PgStudio (PostgreSQL Explorer)", - "version": "0.8.1", + "version": "0.8.2", "description": "PostgreSQL database explorer for VS Code with notebook support", "publisher": "ric-v", "private": false, @@ -837,6 +837,102 @@ "title": "Switch Database", "icon": "$(database)", "category": "PgStudio" + }, + { + "command": "postgres-explorer.switchConnectionProfile", + "title": "Switch Connection Profile", + "icon": "$(person)", + "category": "PgStudio: Profiles" + }, + { + "command": "postgres-explorer.createConnectionProfile", + "title": "Create Connection Profile", + "icon": "$(add)", + "category": "PgStudio: Profiles" + }, + { + "command": "postgres-explorer.deleteConnectionProfile", + "title": "Delete Connection Profile", + "icon": "$(trash)", + "category": "PgStudio: Profiles" + }, + { + "command": "postgres-explorer.saveQueryToLibrary", + "title": "Save Query to Library", + "icon": "$(save)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.saveQueryToLibraryUI", + "title": "šŸ’¾ Save Query (UI Form)", + "icon": "$(save)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.loadSavedQuery", + "title": "Load Saved Query", + "icon": "$(folder-opened)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.loadSavedQueryUI", + "title": "šŸ“‚ Load Saved Query", + "icon": "$(folder-opened)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.viewSavedQuery", + "title": "šŸ“Œ View Saved Query", + "icon": "$(preview)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.copySavedQuery", + "title": "šŸ“‹ Copy Query", + "icon": "$(copy)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.editSavedQuery", + "title": "āœļø Edit Query", + "icon": "$(edit)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.openSavedQueryInNotebook", + "title": "šŸ“˜ Open in Notebook", + "icon": "$(notebook)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.deleteSavedQuery", + "title": "Delete Saved Query", + "icon": "$(trash)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.searchSavedQueries", + "title": "Search Saved Queries", + "icon": "$(search)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.showQueryRecommendations", + "title": "Show Query Recommendations", + "icon": "$(lightbulb)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.exportSavedQueries", + "title": "Export Saved Queries", + "icon": "$(export)", + "category": "PgStudio: Queries" + }, + { + "command": "postgres-explorer.importSavedQueries", + "title": "Import Saved Queries", + "icon": "$(import)", + "category": "PgStudio: Queries" } ], "submenus": [ @@ -891,6 +987,12 @@ "contextualTitle": "PostgreSQL Explorer", "icon": "resources/postgres-vsc-icon.png" }, + { + "id": "postgresExplorer.savedQueries", + "name": "Saved Queries", + "contextualTitle": "PostgreSQL Explorer", + "icon": "$(save)" + }, { "id": "postgresExplorer.chatView", "name": "SQL Assistant", @@ -1125,6 +1227,26 @@ "command": "postgres-explorer.clearHistory", "when": "view == postgresExplorer.history", "group": "navigation" + }, + { + "command": "postgres-explorer.switchConnectionProfile", + "when": "view == postgresExplorer", + "group": "1_phase7" + }, + { + "command": "postgres-explorer.createConnectionProfile", + "when": "view == postgresExplorer", + "group": "1_phase7" + }, + { + "command": "postgres-explorer.showQueryRecommendations", + "when": "view == postgresExplorer", + "group": "2_phase7" + }, + { + "command": "postgres-explorer.loadSavedQuery", + "when": "view == postgresExplorer", + "group": "2_phase7" } ], "view/item/context": [ @@ -1705,6 +1827,26 @@ "command": "postgres-explorer.indexOperations", "when": "view == postgresExplorer && viewItem == index", "group": "1_actions@0" + }, + { + "command": "postgres-explorer.copySavedQuery", + "when": "view == postgresExplorer.savedQueries && viewItem == savedQuery", + "group": "1_actions@1" + }, + { + "command": "postgres-explorer.editSavedQuery", + "when": "view == postgresExplorer.savedQueries && viewItem == savedQuery", + "group": "1_actions@2" + }, + { + "command": "postgres-explorer.openSavedQueryInNotebook", + "when": "view == postgresExplorer.savedQueries && viewItem == savedQuery", + "group": "1_actions@3" + }, + { + "command": "postgres-explorer.deleteSavedQuery", + "when": "view == postgresExplorer.savedQueries && viewItem == savedQuery", + "group": "2_delete@1" } ], "postgres-explorer.columnScriptsMenu": [ diff --git a/src/SaveQueryPanel.ts b/src/SaveQueryPanel.ts new file mode 100644 index 0000000..791abe4 --- /dev/null +++ b/src/SaveQueryPanel.ts @@ -0,0 +1,686 @@ +import * as vscode from 'vscode'; +import { SavedQueriesService, SavedQuery } from './services/SavedQueriesService'; +import { QueryAnalyzer } from './services/QueryAnalyzer'; +import { AiService } from './providers/chat/AiService'; + +export class SaveQueryPanel { + public static currentPanel: SaveQueryPanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private readonly _queryText: string; + private readonly _disposables: vscode.Disposable[] = []; + private readonly _aiService: AiService; + private _connectionMetadata: any = {}; + private _editMode: boolean = false; + private _editingQuery: SavedQuery | undefined; + + public static show( + extensionUri: vscode.Uri, + queryText: string, + connectionMetadata?: any, + context?: vscode.ExtensionContext + ) { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + // If we already have a panel, show it. + if (SaveQueryPanel.currentPanel) { + SaveQueryPanel.currentPanel._panel.reveal(column); + SaveQueryPanel.currentPanel._updateQuery(queryText); + if (connectionMetadata) { + SaveQueryPanel.currentPanel._connectionMetadata = connectionMetadata; + } + SaveQueryPanel.currentPanel._editMode = false; + SaveQueryPanel.currentPanel._editingQuery = undefined; + return; + } + + // Otherwise, create a new panel. + const panel = vscode.window.createWebviewPanel( + 'saveQuery', + 'Save Query', + column || vscode.ViewColumn.One, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'resources')] + } + ); + + SaveQueryPanel.currentPanel = new SaveQueryPanel(panel, extensionUri, queryText, connectionMetadata); + } + + public static showForEdit( + extensionUri: vscode.Uri, + query: SavedQuery, + connectionMetadata?: any + ) { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + // If we already have a panel, show it. + if (SaveQueryPanel.currentPanel) { + SaveQueryPanel.currentPanel._panel.reveal(column); + SaveQueryPanel.currentPanel._editMode = true; + SaveQueryPanel.currentPanel._editingQuery = query; + if (connectionMetadata) { + SaveQueryPanel.currentPanel._connectionMetadata = connectionMetadata; + } + SaveQueryPanel.currentPanel._updateForEdit(query); + return; + } + + // Otherwise, create a new panel. + const panel = vscode.window.createWebviewPanel( + 'saveQuery', + 'Edit Query', + column || vscode.ViewColumn.One, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'resources')] + } + ); + + SaveQueryPanel.currentPanel = new SaveQueryPanel(panel, extensionUri, query.query, connectionMetadata, true, query); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + queryText: string, + connectionMetadata?: any, + editMode: boolean = false, + editingQuery?: SavedQuery + ) { + if (connectionMetadata) { + this._connectionMetadata = connectionMetadata; + } + this._editMode = editMode; + this._editingQuery = editingQuery; + this._panel = panel; + this._extensionUri = extensionUri; + this._queryText = queryText; + this._aiService = new AiService(); + + // Set the webview's initial html content + this._update(); + + // Listen for when the panel is disposed + // This happens when the user closes the panel or when the panel is closed programmatically + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + // Handle messages from the webview + this._panel.webview.onDidReceiveMessage( + (message) => { + switch (message.command) { + case 'save': + this._handleSaveQuery(message); + return; + case 'cancel': + this._panel.dispose(); + return; + case 'generateAI': + this._handleAIGeneration(message.field); + return; + } + }, + null, + this._disposables + ); + } + + public dispose() { + SaveQueryPanel.currentPanel = undefined; + + // Clean up our resources + this._panel.dispose(); + + while (this._disposables.length) { + const x = this._disposables.pop(); + if (x) { + x.dispose(); + } + } + } + + private _updateQuery(queryText: string) { + // Update the webview with the new query + this._panel.webview.postMessage({ + command: 'updateQuery', + query: queryText + }); + } + + private async _handleAIGeneration(field: 'title' | 'description' | 'tags' | 'all') { + try { + const config = vscode.workspace.getConfiguration('postgresExplorer'); + const provider = config.get('aiProvider') || 'vscode-lm'; + + // Check if AI is available + try { + await this._aiService.getModelInfo(provider, config); + } catch (error) { + this._panel.webview.postMessage({ + command: 'aiError', + message: 'AI is not configured or available. Please configure your AI provider in settings or fill the fields manually.' + }); + return; + } + + // Show progress + this._panel.webview.postMessage({ command: 'aiGenerating', field }); + + // Build prompt based on field + let prompt = ''; + if (field === 'all' || field === 'title') { + prompt = `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.`; + } else if (field === 'description') { + prompt = `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.`; + } else if (field === 'tags') { + prompt = `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. Examples: users, analytics, performance, joins, aggregation`; + } + + // 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, ''); + } + + // Clean up the response + let generated = result.text.trim(); + // Remove any markdown code blocks or quotes + generated = generated.replace(/^```[\s\S]*?\n/, '').replace(/\n```$/, ''); + generated = generated.replace(/^["']|["']$/g, ''); + generated = generated.trim(); + + // For 'all', we need to generate each field separately + 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 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 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 tags = tagsResult.text.trim().replace(/^["']|["']$/g, '').trim(); + + this._panel.webview.postMessage({ + command: 'aiGenerated', + field: 'all', + values: { title, description, tags } + }); + } else { + // Send generated value to webview + this._panel.webview.postMessage({ + command: 'aiGenerated', + field, + value: generated + }); + } + + vscode.window.showInformationMessage('✨ AI suggestions generated!'); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this._panel.webview.postMessage({ + command: 'aiError', + message: `Failed to generate AI suggestions: ${errorMessage}. Please fill the fields manually.` + }); + } + } + + private async _handleSaveQuery(message: any) { + const { title, description, tags, query } = message; + + if (!title || !title.trim()) { + vscode.window.showErrorMessage('Please enter a query title'); + return; + } + + // Use connection metadata from panel if available, otherwise capture from active notebook + let connectionId: string | undefined; + let databaseName: string | undefined; + let schemaName: string | undefined; + + if (this._connectionMetadata?.connectionId) { + connectionId = this._connectionMetadata.connectionId; + databaseName = this._connectionMetadata.databaseName; + schemaName = this._connectionMetadata.schemaName; + } else { + // Fallback to active notebook metadata + const activeEditor = vscode.window.activeNotebookEditor; + if (activeEditor) { + const metadata = activeEditor.notebook.metadata as any; + connectionId = metadata?.connectionId; + databaseName = metadata?.databaseName; + schemaName = metadata?.schema; + } + } + + const service = SavedQueriesService.getInstance(); + + if (this._editMode && this._editingQuery) { + // Edit mode: update existing query + const updatedQuery: SavedQuery = { + ...this._editingQuery, + title: title.trim(), + description: description?.trim() || '', + query: query, + tags: tags ? tags.split(',').map((t: string) => t.trim()).filter((t: string) => t) : [], + lastUsed: Date.now(), + connectionId, + databaseName, + schemaName + }; + + await service.updateQuery(updatedQuery); + vscode.window.showInformationMessage(`āœ“ Query updated: "${title}"`); + } else { + // Save mode: create new query + const now = Date.now(); + const savedQuery: SavedQuery = { + id: `query_${now}`, + title: title.trim(), + description: description?.trim() || '', + query: query, + tags: tags ? tags.split(',').map((t: string) => t.trim()).filter((t: string) => t) : [], + usageCount: 0, + createdAt: now, + lastUsed: now, + connectionId, + databaseName, + schemaName + }; + + await service.saveQuery(savedQuery); + vscode.window.showInformationMessage(`āœ“ Query saved: "${title}"`); + } + + this._panel.dispose(); + + // Refresh saved queries tree + vscode.commands.executeCommand('postgresExplorer.savedQueries.refresh'); + } + + private _update() { + this._panel.webview.html = this._getHtmlForWebview(this._panel.webview); + } + + private _updateForEdit(query: SavedQuery) { + this._panel.title = `Edit Query: ${query.title}`; + const html = this._getHtmlForWebview(this._panel.webview); + this._panel.webview.html = html; + // Pass edit mode data to webview + this._panel.webview.postMessage({ + command: 'loadEditData', + data: { + title: query.title, + description: query.description || '', + tags: (query.tags || []).join(', '), + query: query.query + } + }); + } + + private _highlightSql(sql: string): string { + const escaped = sql.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const keywords = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|IN|EXISTS|LIKE|BETWEEN|ORDER|GROUP|BY|HAVING|LIMIT|OFFSET|AS|DISTINCT|UNION|ALL|CREATE|DROP|ALTER|TABLE|VIEW|INDEX|INSERT|UPDATE|DELETE|INTO|VALUES|SET|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|EXPLAIN|ANALYZE)\b/gi; + const strings = /('([^'\\]|\\.)*')/g; + const numbers = /\b(\d+(\.\d+)?|\.\d+)\b/g; + const comments = /(--.*)|(\/\*[\s\S]*?\*\/)/g; + const functions = /\b([a-z_]\w*)\s*\(/gi; + + return escaped + .replace(comments, '$&') + .replace(strings, '$&') + .replace(keywords, '$&') + .replace(functions, (match, funcName) => `${funcName}(`); + } + + private _getHtmlForWebview(webview: vscode.Webview): string { + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.css') + ); + + // Highlight query for preview + const highlightedQuery = this._highlightSql(this._queryText); + + return ` + + + + + Save Query + + + + +
+

šŸ’¾ Save Query

+

Save your query to the library for easy reuse

+ +
+ šŸ“Œ Make your queries discoverable by adding meaningful titles, descriptions, and tags +
+ + + +
+
+ + + A memorable name for this query +
+ +
+ + + Optional: Help your team understand the purpose of this query +
+ +
+ + + Comma-separated tags for easy filtering and discovery +
+ +
+ +
${highlightedQuery}
+
+ +
+ + +
+
+
+ + + +`; + } +} diff --git a/src/SavedQueryDetailsPanel.ts b/src/SavedQueryDetailsPanel.ts new file mode 100644 index 0000000..bba498d --- /dev/null +++ b/src/SavedQueryDetailsPanel.ts @@ -0,0 +1,377 @@ +import * as vscode from 'vscode'; +import { SavedQueriesService } from './services/SavedQueriesService'; + +export class SavedQueryDetailsPanel { + public static currentPanel: SavedQueryDetailsPanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private readonly _extensionUri: vscode.Uri; + private readonly _query: any; + private readonly _disposables: vscode.Disposable[] = []; + + public static show( + extensionUri: vscode.Uri, + query: any + ) { + const column = vscode.window.activeTextEditor + ? vscode.window.activeTextEditor.viewColumn + : undefined; + + if (SavedQueryDetailsPanel.currentPanel) { + SavedQueryDetailsPanel.currentPanel._panel.reveal(column); + SavedQueryDetailsPanel.currentPanel._updateQuery(query); + return; + } + + const panel = vscode.window.createWebviewPanel( + 'savedQueryDetails', + `šŸ“Œ ${query.title}`, + column || vscode.ViewColumn.One, + { + enableScripts: true, + enableFindWidget: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, 'resources')] + } + ); + + SavedQueryDetailsPanel.currentPanel = new SavedQueryDetailsPanel(panel, extensionUri, query); + } + + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + query: any + ) { + this._panel = panel; + this._extensionUri = extensionUri; + this._query = query; + + this._update(); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + + this._panel.webview.onDidReceiveMessage( + (message) => { + switch (message.command) { + case 'copy': + this._handleCopy(); + return; + case 'delete': + this._handleDelete(); + return; + } + }, + null, + this._disposables + ); + } + + public dispose() { + SavedQueryDetailsPanel.currentPanel = undefined; + + while (this._disposables.length) { + const x = this._disposables.pop(); + if (x) { + x.dispose(); + } + } + + this._panel.dispose(); + } + + private _updateQuery(query: any) { + this._panel.webview.postMessage({ + command: 'updateQuery', + query + }); + } + + private async _handleCopy() { + await vscode.env.clipboard.writeText(this._query.query); + vscode.window.showInformationMessage('āœ“ Query copied to clipboard'); + } + + private async _handleDelete() { + const confirm = await vscode.window.showWarningMessage( + `Delete saved query "${this._query.title}"?`, + { modal: true }, + 'Delete' + ); + + if (confirm === 'Delete') { + const service = SavedQueriesService.getInstance(); + await service.deleteQuery(this._query.id); + vscode.window.showInformationMessage(`āœ“ Query deleted: "${this._query.title}"`); + this._panel.dispose(); + vscode.commands.executeCommand('postgresExplorer.savedQueries.refresh'); + } + } + + private _update() { + this._panel.webview.html = this._getHtmlForWebview(this._panel.webview); + } + + private _highlightSql(sql: string): string { + const escaped = sql.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const keywords = /\b(SELECT|FROM|WHERE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|AND|OR|NOT|IN|EXISTS|LIKE|BETWEEN|ORDER|GROUP|BY|HAVING|LIMIT|OFFSET|AS|DISTINCT|UNION|ALL|CREATE|DROP|ALTER|TABLE|VIEW|INDEX|INSERT|UPDATE|DELETE|INTO|VALUES|SET|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|EXPLAIN|ANALYZE)\b/gi; + const strings = /('([^'\\\\]|\\\\.)*')/g; + const numbers = /\b(\d+(\.\d+)?|\.\d+)\b/g; + const comments = /(--.*)|((\/\*)([\s\S]*?)(\*\/))/g; + const functions = /\b([a-z_]\w*)\s*\(/gi; + + return escaped + .replace(comments, '$&') + .replace(strings, '$&') + .replace(keywords, '$&') + .replace(functions, (match, funcName) => `${funcName}(`); + } + + private _getHtmlForWebview(webview: vscode.Webview): string { + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'resources', 'highlight.css') + ); + + const query = this._query; + const highlightedQuery = this._highlightSql(query.query); + const createdDate = new Date(query.createdAt || Date.now()).toLocaleDateString(); + const lastUsedDate = query.lastUsed ? new Date(query.lastUsed).toLocaleDateString() : 'Never'; + + return ` + + + + + ${query.title} + + + + +
+
+
+

šŸ“Œ ${query.title}

+ ${query.description ? `

${query.description}

` : ''} +
+ ${query.tags && query.tags.length > 0 + ? query.tags.map((tag: string) => `${tag}`).join('') + : 'No tags' + } +
+
+
+ + +
+
+ +
+
šŸ“ SQL Query
+
${highlightedQuery}
+
+ +
+
šŸ“Š Metadata
+
+
+ Created + ${createdDate} +
+
+ Last Used + ${lastUsedDate} +
+
+ Usage Count + ${query.usageCount || 0} times +
+
+ Query ID + ${query.id} +
+
+
+
+ + + +`; + } +} \ No newline at end of file diff --git a/src/activation/commands.ts b/src/activation/commands.ts index bbe0b39..fff56c5 100644 --- a/src/activation/commands.ts +++ b/src/activation/commands.ts @@ -27,11 +27,33 @@ import { ConnectionFormPanel } from '../connectionForm'; import { ConnectionManagementPanel } from '../connectionManagement'; import { ConnectionUtils } from '../utils/connectionUtils'; +// Phase 7: Advanced Power User & AI features +import { + switchConnectionProfile, + createConnectionProfile, + deleteConnectionProfile, + saveQueryToLibrary, + saveQueryToLibraryUI, + loadSavedQuery, + loadSavedQueryUI, + viewSavedQuery, + deleteSavedQuery, + copySavedQuery, + editSavedQuery, + openSavedQueryInNotebook, + exportSavedQueries, + importSavedQueries, + searchSavedQueries, + showQueryRecommendations +} from '../commands/phase7'; +import { SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; + export function registerAllCommands( context: vscode.ExtensionContext, databaseTreeProvider: DatabaseTreeProvider, chatViewProviderInstance: ChatViewProvider | undefined, - outputChannel: vscode.OutputChannel + outputChannel: vscode.OutputChannel, + savedQueriesTreeProvider?: SavedQueriesTreeProvider ) { const commands = [ { @@ -1068,6 +1090,72 @@ export function registerAllCommands( } } }, + // Phase 7: Connection Profiles + { + command: 'postgres-explorer.switchConnectionProfile', + callback: () => switchConnectionProfile() + }, + { + command: 'postgres-explorer.createConnectionProfile', + callback: () => createConnectionProfile() + }, + { + command: 'postgres-explorer.deleteConnectionProfile', + callback: () => deleteConnectionProfile() + }, + // Phase 7: Saved Queries + { + command: 'postgres-explorer.saveQueryToLibrary', + callback: () => saveQueryToLibrary() + }, + { + command: 'postgres-explorer.loadSavedQuery', + callback: () => loadSavedQuery() + }, + { + command: 'postgres-explorer.exportSavedQueries', + callback: () => exportSavedQueries() + }, + { + command: 'postgres-explorer.importSavedQueries', + callback: () => importSavedQueries() + }, + { + command: 'postgres-explorer.searchSavedQueries', + callback: () => searchSavedQueries() + }, + { + command: 'postgres-explorer.showQueryRecommendations', + callback: () => showQueryRecommendations() + }, + { + command: 'postgres-explorer.saveQueryToLibraryUI', + callback: () => saveQueryToLibraryUI() + }, + { + command: 'postgres-explorer.viewSavedQuery', + callback: (query: any) => viewSavedQuery(query) + }, + { + command: 'postgres-explorer.copySavedQuery', + callback: (query: any) => copySavedQuery(query) + }, + { + command: 'postgres-explorer.editSavedQuery', + callback: (query: any) => editSavedQuery(query) + }, + { + command: 'postgres-explorer.openSavedQueryInNotebook', + callback: (query: any) => openSavedQueryInNotebook(query) + }, + { + command: 'postgres-explorer.deleteSavedQuery', + callback: (query: any) => deleteSavedQuery(query) + }, + { + command: 'postgres-explorer.loadSavedQueryUI', + callback: () => loadSavedQueryUI() + }, ]; console.log('Starting command registration...'); @@ -1085,5 +1173,14 @@ export function registerAllCommands( } }); + // Phase 7: Register refresh commands for tree views + context.subscriptions.push( + vscode.commands.registerCommand('postgresExplorer.savedQueries.refresh', () => { + if (savedQueriesTreeProvider) { + savedQueriesTreeProvider.refresh(); + } + }) + ); + outputChannel.appendLine('All commands registered successfully.'); } diff --git a/src/activation/providers.ts b/src/activation/providers.ts index c1eef51..a503350 100644 --- a/src/activation/providers.ts +++ b/src/activation/providers.ts @@ -6,6 +6,7 @@ import { PostgresNotebookSerializer } from '../postgresNotebook'; import { AiCodeLensProvider } from '../providers/AiCodeLensProvider'; import { QueryCodeLensProvider } from '../providers/QueryCodeLensProvider'; import { QueryHistoryProvider } from '../providers/QueryHistoryProvider'; +import { ProfilesTreeProvider, SavedQueriesTreeProvider } from '../providers/Phase7TreeProviders'; export function registerProviders(context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel) { // Create database tree provider instance @@ -98,10 +99,17 @@ export function registerProviders(context: vscode.ExtensionContext, outputChanne // Store query history provider instance for command access context.workspaceState.update('queryHistoryProviderInstance', queryHistoryProvider); + // Phase 7: Register Saved Queries Tree Provider + const savedQueriesTreeProvider = new SavedQueriesTreeProvider(); + context.subscriptions.push( + vscode.window.registerTreeDataProvider('postgresExplorer.savedQueries', savedQueriesTreeProvider) + ); + return { databaseTreeProvider, treeView, chatViewProviderInstance, - queryHistoryProvider + queryHistoryProvider, + savedQueriesTreeProvider }; } diff --git a/src/activation/statusBar.ts b/src/activation/statusBar.ts index 78487ef..6981e73 100644 --- a/src/activation/statusBar.ts +++ b/src/activation/statusBar.ts @@ -1,5 +1,7 @@ import * as vscode from 'vscode'; import { PostgresMetadata } from '../common/types'; +import { extensionContext } from '../extension'; +import { ProfileManager } from '../services/ProfileManager'; /** * Manages the notebook status bar items that display connection and database info. @@ -9,6 +11,7 @@ export class NotebookStatusBar implements vscode.Disposable { private readonly connectionItem: vscode.StatusBarItem; private readonly databaseItem: vscode.StatusBarItem; private readonly riskIndicatorItem: vscode.StatusBarItem; + private readonly profileItem: vscode.StatusBarItem; private readonly disposables: vscode.Disposable[] = []; constructor() { @@ -24,10 +27,15 @@ export class NotebookStatusBar implements vscode.Disposable { this.riskIndicatorItem.command = 'postgres-explorer.showConnectionSafety'; this.riskIndicatorItem.tooltip = 'Click to view connection safety details'; + this.profileItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 97); + this.profileItem.command = 'postgres-explorer.switchConnectionProfile'; + this.profileItem.tooltip = 'Click to switch connection profile'; + this.disposables.push( this.connectionItem, this.databaseItem, this.riskIndicatorItem, + this.profileItem, vscode.window.onDidChangeActiveNotebookEditor(() => this.update()), vscode.workspace.onDidChangeNotebookDocument((e) => { if (vscode.window.activeNotebookEditor?.notebook === e.notebook) { @@ -76,6 +84,7 @@ export class NotebookStatusBar implements vscode.Disposable { this.connectionItem.hide(); this.databaseItem.hide(); this.riskIndicatorItem.hide(); + this.profileItem.hide(); } private showNoConnection(): void { @@ -84,6 +93,7 @@ export class NotebookStatusBar implements vscode.Disposable { this.connectionItem.show(); this.databaseItem.hide(); this.riskIndicatorItem.hide(); + this.profileItem.hide(); } private showConnection(connection: any, metadata: PostgresMetadata): void { @@ -101,11 +111,46 @@ export class NotebookStatusBar implements vscode.Disposable { // Show risk indicator based on environment this.updateRiskIndicator(connection); + // Show active profile if one is set + this.updateProfileIndicator(); + // Update context for when clauses vscode.commands.executeCommand('setContext', 'pgstudio.connectionName', connName); vscode.commands.executeCommand('setContext', 'pgstudio.databaseName', dbName); } + private updateProfileIndicator(): void { + const editor = vscode.window.activeNotebookEditor; + if (!editor) { + this.profileItem.hide(); + return; + } + + const notebookKey = `activeProfile-${editor.notebook.uri.toString()}`; + const activeProfileContext = extensionContext?.globalState.get(notebookKey); + + if (!activeProfileContext) { + this.profileItem.hide(); + return; + } + + // Get the profile name from ProfileManager + const profileManager = ProfileManager.getInstance(); + const profile = profileManager.getProfiles().find(p => p.id === activeProfileContext.profileId); + const profileName = profile?.profileName || 'Unknown Profile'; + + // Build status text with icons for active constraints + const constraints: string[] = []; + if (activeProfileContext.readOnlyMode) constraints.push('šŸ”’ RO'); + if (activeProfileContext.autoLimitSelectResults > 0) constraints.push(`šŸ“Š Limit: ${activeProfileContext.autoLimitSelectResults}`); + + const constraintText = constraints.length > 0 ? ` [${constraints.join(' | ')}]` : ''; + + this.profileItem.text = `$(person) Profile: ${profileName}${constraintText}`; + this.profileItem.backgroundColor = new vscode.ThemeColor('statusBarItem.prominentBackground'); + this.profileItem.show(); + } + private updateRiskIndicator(connection: any): void { if (!connection) { this.riskIndicatorItem.hide(); diff --git a/src/commands/phase7.ts b/src/commands/phase7.ts new file mode 100644 index 0000000..57d5f98 --- /dev/null +++ b/src/commands/phase7.ts @@ -0,0 +1,785 @@ +import * as vscode from 'vscode'; +import { ChatViewProvider } from '../providers/ChatViewProvider'; +import { ProfileManager, ConnectionProfile } from '../services/ProfileManager'; +import { SavedQueriesService, SavedQuery } from '../services/SavedQueriesService'; +import { QueryAnalyzer } from '../services/QueryAnalyzer'; +import { ErrorService } from '../services/ErrorService'; +import { extensionContext, statusBar } from '../extension'; +import { SaveQueryPanel } from '../SaveQueryPanel'; +import { SavedQueryDetailsPanel } from '../SavedQueryDetailsPanel'; +import { ConnectionUtils } from '../utils/connectionUtils'; +import { SecretStorageService } from '../services/SecretStorageService'; + +/** + * Phase 7 Advanced Power User & AI commands + * - Connection profiles (presets for roles) + * - Saved queries (reusable query library) + * - Performance-driven AI analysis + */ + +/** + * Helper to refresh tree views and status bar after profile changes + */ +function refreshPhase7TreeViews(): void { + vscode.commands.executeCommand('postgresExplorer.savedQueries.refresh'); + // Update status bar to immediately reflect profile change + if (statusBar) { + statusBar.update(); + } +} + +/** + * Load default profile from connection config + * If the connection has a profileId set, apply that profile to the notebook + */ +export async function loadDefaultProfileFromConnection(): Promise { + const activeEditor = vscode.window.activeNotebookEditor; + if (!activeEditor) { + return; + } + + const notebook = activeEditor.notebook; + const metadata = notebook.metadata as any; + + if (!metadata?.connectionId) { + return; + } + + // Check if a default profile is already set in globalState for this notebook + const notebookKey = `activeProfile-${notebook.uri.toString()}`; + const existingProfile = extensionContext?.globalState.get(notebookKey); + if (existingProfile) { + return; // Profile already set, don't override + } + + // Get the connection and check for a default profileId + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const connection = connections.find(c => c.id === metadata.connectionId); + + if (!connection?.profileId) { + return; // No default profile set in connection config + } + + // Find the profile + const profileManager = ProfileManager.getInstance(); + const profile = profileManager.getProfiles().find(p => p.id === connection.profileId); + + if (!profile) { + return; // Profile not found + } + + // Apply the default profile + const profileContext = { + profileId: profile.id, + readOnlyMode: profile.rolePresets?.forceReadOnly ?? false, + autoLimitSelectResults: profile.rolePresets?.autoLimitSelectResults ?? 0, + autoApplySafetyCheck: profile.rolePresets?.autoApplySafetyCheck ?? true, + }; + + await extensionContext.globalState.update(notebookKey, profileContext); + refreshPhase7TreeViews(); +} + +/** + * Switch to a connection profile + */ +export async function switchConnectionProfile(): Promise { + const profileManager = ProfileManager.getInstance(); + const profiles = profileManager.getProfiles(); + + if (profiles.length === 0) { + vscode.window.showWarningMessage('No connection profiles available. Create one first.'); + return; + } + + const items = profiles.map((p) => ({ + label: p.profileName || p.name || `${p.host}:${p.port}`, + description: p.description || `${p.host}:${p.port}`, + profile: p, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a connection profile to switch to', + matchOnDescription: true, + }); + + if (selected) { + // Get active notebook editor + const activeEditor = vscode.window.activeNotebookEditor; + if (!activeEditor) { + vscode.window.showWarningMessage('No active notebook. Open a .pgsql notebook first.'); + return; + } + + const notebook = activeEditor.notebook; + const metadata = notebook.metadata as any; + + if (!metadata?.connectionId) { + vscode.window.showWarningMessage('Notebook has no connection configured.'); + return; + } + + // Store active profile context in extension globalState + // Use notebook URI as key to allow different notebooks to have different active profiles + const notebookKey = `activeProfile-${notebook.uri.toString()}`; + const profile = selected.profile; + + const profileContext = { + profileId: profile.id, + readOnlyMode: profile.rolePresets?.forceReadOnly ?? false, + autoLimitSelectResults: profile.rolePresets?.autoLimitSelectResults ?? 0, + autoApplySafetyCheck: profile.rolePresets?.autoApplySafetyCheck ?? true, + }; + + await extensionContext.globalState.update(notebookKey, profileContext); + + // Build a detailed message about the active profile settings + const settings: string[] = []; + if (profileContext.readOnlyMode) settings.push('šŸ”’ Read-Only (writes blocked)'); + if (profileContext.autoLimitSelectResults > 0) settings.push(`šŸ“Š Auto-Limit: ${profileContext.autoLimitSelectResults} rows`); + if (profileContext.autoApplySafetyCheck) settings.push('āš ļø Safety checks enabled'); + + const settingsText = settings.length > 0 ? `\n\nActive settings:\n${settings.join('\n')}` : '\nNo special constraints'; + + vscode.window.showInformationMessage( + `āœ“ Switched to profile: ${selected.label}${settingsText}` + ); + refreshPhase7TreeViews(); + } +} + +/** + * Create a new connection profile + */ +export async function createConnectionProfile(): Promise { + const profileName = await vscode.window.showInputBox({ + prompt: 'Enter profile name (e.g., "Read-Only Analyst")', + placeHolder: 'Profile Name', + }); + + if (!profileName) { + return; + } + + const description = await vscode.window.showInputBox({ + prompt: 'Enter profile description (optional)', + placeHolder: 'e.g., Safe read-only access for data analysts', + }); + + const hostStr = await vscode.window.showInputBox({ + prompt: 'Enter database host', + placeHolder: 'localhost', + }); + + const host = hostStr || 'localhost'; + + const portStr = await vscode.window.showInputBox({ + prompt: 'Enter database port', + placeHolder: '5432', + }); + + const port = parseInt(portStr || '5432', 10); + + const forceReadOnly = await vscode.window.showQuickPick( + ['Yes', 'No'], + { placeHolder: 'Force read-only mode for this profile?' } + ); + + const profile: ConnectionProfile = { + id: `profile_${Date.now()}`, + host, + port, + profileName, + description, + readOnlyMode: forceReadOnly === 'Yes', + rolePresets: { + forceReadOnly: forceReadOnly === 'Yes', + autoApplySafetyCheck: true, + autoLimitSelectResults: forceReadOnly === 'Yes' ? 1000 : 0, + }, + }; + + const profileManager = ProfileManager.getInstance(); + await profileManager.createProfile(profile); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage(`Profile created: ${profileName}`); +} + +/** + * Delete a connection profile + */ +export async function deleteConnectionProfile(): Promise { + const profileManager = ProfileManager.getInstance(); + const profiles = profileManager.getProfiles(); + + if (profiles.length === 0) { + vscode.window.showWarningMessage('No profiles to delete.'); + return; + } + + const items = profiles.map((p) => ({ + label: p.profileName || p.name || `${p.host}:${p.port}`, + description: p.description || '', + profile: p, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a profile to delete', + }); + + if (selected) { + const confirm = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: `Delete profile "${selected.label}"?`, + }); + + if (confirm === 'Yes') { + await profileManager.deleteProfile(selected.profile.id); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage(`Profile deleted: ${selected.label}`); + } + } +} + +/** + * Save current query to library + */ +export async function saveQueryToLibrary(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage('No active editor. Open a SQL file first.'); + return; + } + + const query = editor.document.getText(); + if (!query.trim()) { + vscode.window.showWarningMessage('Editor is empty.'); + return; + } + + const title = await vscode.window.showInputBox({ + prompt: 'Give this query a title', + placeHolder: 'e.g., "Active Users Report"', + }); + + if (!title) { + return; + } + + const description = await vscode.window.showInputBox({ + prompt: 'Optional description', + placeHolder: 'What does this query do?', + }); + + const tagsStr = await vscode.window.showInputBox({ + prompt: 'Tags (comma-separated, optional)', + placeHolder: 'e.g., analytics, reporting, maintenance', + }); + + const tags = tagsStr + ? tagsStr.split(',').map((t) => t.trim()).filter((t) => t) + : undefined; + + const savedQuery: SavedQuery = { + id: `query_${Date.now()}`, + title, + query, + description, + tags, + createdAt: Date.now(), + usageCount: 0, + }; + + const service = SavedQueriesService.getInstance(); + await service.saveQuery(savedQuery); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage(`Query saved: "${title}"`); +} + +/** + * Load a saved query + */ +export async function loadSavedQuery(): Promise { + const service = SavedQueriesService.getInstance(); + const queries = service.getQueries(); + + if (queries.length === 0) { + vscode.window.showInformationMessage('No saved queries yet.'); + return; + } + + const items = queries.map((q) => ({ + label: q.title, + description: q.description || `Created: ${new Date(q.createdAt).toLocaleDateString()}`, + detail: `Used ${q.usageCount} times${q.tags?.length ? ` • Tags: ${q.tags.join(', ')}` : ''}`, + query: q, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a query to load', + matchOnDescription: true, + }); + + if (selected) { + // Record usage + await service.recordUsage(selected.query.id); + + // Open in new editor using openTextDocument + const doc = await vscode.workspace.openTextDocument({ + language: 'pgsql', + content: selected.query.query, + }); + await vscode.window.showTextDocument(doc); + } +} + +/** + * Copy a saved query to clipboard - context menu action + */ +export async function copySavedQuery(treeItem: any): Promise { + // Handle both tree item (from context menu) and direct query object + const query = treeItem?.query || treeItem; + + if (!query) { + vscode.window.showWarningMessage('No query selected.'); + return; + } + + await vscode.env.clipboard.writeText(query.query); + vscode.window.showInformationMessage(`āœ“ Query copied to clipboard: "${query.title}"`); +} + +/** + * Open a saved query in a new notebook with its original connection context + */ +export async function openSavedQueryInNotebook(treeItem: any): Promise { + // Handle both tree item (from context menu) and direct query object + const query = treeItem?.query || treeItem; + + if (!query) { + vscode.window.showWarningMessage('No query selected.'); + return; + } + + try { + if (!query.connectionId) { + vscode.window.showWarningMessage('This query does not have a connection context. Please save it again from a notebook.'); + return; + } + + // Record usage + const service = SavedQueriesService.getInstance(); + await service.recordUsage(query.id); + + // Fetch the full connection details from config + const connection = ConnectionUtils.findConnection(query.connectionId); + if (!connection) { + vscode.window.showErrorMessage(`Connection "${query.connectionId}" not found. It may have been deleted.`); + return; + } + + // Get the stored password from secret storage + const password = await SecretStorageService.getInstance().getPassword(query.connectionId); + + // Build complete metadata for the notebook + const metadata = { + connectionId: query.connectionId, + databaseName: query.databaseName, + host: connection.host, + port: connection.port, + username: connection.username, + password: password || connection.password, + custom: { + cells: [], + metadata: { + connectionId: query.connectionId, + databaseName: query.databaseName, + schema: query.schemaName, + host: connection.host, + port: connection.port, + username: connection.username, + enableScripts: true + } + } + }; + + // Create notebook with SQL cell containing the query + const notebookData = new vscode.NotebookData([ + new vscode.NotebookCellData(vscode.NotebookCellKind.Code, query.query, 'sql') + ]); + notebookData.metadata = metadata; + + // Open notebook in the notebook editor + const notebook = await vscode.workspace.openNotebookDocument('postgres-notebook', notebookData); + await vscode.window.showNotebookDocument(notebook); + + vscode.window.showInformationMessage( + `āœ“ Opened query: "${query.title}"${ + query.databaseName ? ` (${query.databaseName})` : '' + }` + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to open query: ${errorMessage}`); + } +} + +/** + * Delete a saved query - context menu action + */ +export async function deleteSavedQuery(treeItem: any): Promise { + // Handle both tree item (from context menu) and direct query object + const query = treeItem?.query || treeItem; + + if (!query) { + vscode.window.showWarningMessage('No query selected.'); + return; + } + + const confirm = await vscode.window.showWarningMessage( + `Delete saved query "${query.title}"?`, + { modal: true }, + 'Delete' + ); + + if (confirm === 'Delete') { + const service = SavedQueriesService.getInstance(); + await service.deleteQuery(query.id); + vscode.window.showInformationMessage(`āœ“ Query deleted: "${query.title}"`); + vscode.commands.executeCommand('postgresExplorer.savedQueries.refresh'); + } +} + +/** + * Edit a saved query - opens the save panel in edit mode + */ +export async function editSavedQuery(treeItem: any): Promise { + // Handle both tree item (from context menu) and direct query object + const query = treeItem?.query || treeItem; + + if (!query) { + vscode.window.showWarningMessage('No query selected.'); + return; + } + + if (!extensionContext) { + vscode.window.showErrorMessage('Extension context not available.'); + return; + } + + // Open the save panel in edit mode with the query data + const connectionMetadata = { + connectionId: query.connectionId, + databaseName: query.databaseName, + schemaName: query.schemaName + }; + + SaveQueryPanel.showForEdit(extensionContext.extensionUri, query, connectionMetadata); +} +async function deleteSavedQueryLegacy(): Promise { + const service = SavedQueriesService.getInstance(); + const queries = service.getQueries(); + + if (queries.length === 0) { + vscode.window.showInformationMessage('No saved queries to delete.'); + return; + } + + const items = queries.map((q) => ({ + label: q.title, + description: q.description || '', + query: q, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a query to delete', + }); + + if (selected) { + const confirm = await vscode.window.showQuickPick(['Yes', 'No'], { + placeHolder: `Delete "${selected.label}"?`, + }); + + if (confirm === 'Yes') { + await service.deleteQuery(selected.query.id); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage(`Query deleted: "${selected.label}"`); + } + } +} + +/** + * Export saved queries as JSON file + */ +export async function exportSavedQueries(): Promise { + const service = SavedQueriesService.getInstance(); + const json = service.exportQueries(); + + const fileUri = await vscode.window.showSaveDialog({ + filters: { 'JSON Files': ['json'] }, + defaultUri: vscode.Uri.file( + `saved-queries-${new Date().toISOString().split('T')[0]}.json` + ), + }); + + if (fileUri) { + const fs = require('fs').promises; + await fs.writeFile(fileUri.fsPath, json, 'utf8'); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage(`Queries exported to: ${fileUri.fsPath}`); + } +} + +/** + * Import saved queries from JSON file + */ +export async function importSavedQueries(): Promise { + const files = await vscode.window.showOpenDialog({ + filters: { 'JSON Files': ['json'] }, + canSelectMany: false, + }); + + if (!files || files.length === 0) { + return; + } + + try { + const fs = require('fs').promises; + const content = await fs.readFile(files[0].fsPath, 'utf8'); + const service = SavedQueriesService.getInstance(); + await service.importQueries(content); + refreshPhase7TreeViews(); + vscode.window.showInformationMessage('Queries imported successfully.'); + } catch (error) { + ErrorService.getInstance().showError( + `Failed to import queries: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Search saved queries by text + */ +export async function searchSavedQueries(): Promise { + const searchText = await vscode.window.showInputBox({ + prompt: 'Search queries by title or description', + placeHolder: 'e.g., "user report"', + }); + + if (!searchText) { + return; + } + + const service = SavedQueriesService.getInstance(); + const results = service.searchQueries(searchText); + + if (results.length === 0) { + vscode.window.showInformationMessage('No matching queries found.'); + return; + } + + const items = results.map((q) => ({ + label: q.title, + description: q.description || '', + query: q, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a query', + }); + + if (selected) { + await service.recordUsage(selected.query.id); + const doc = await vscode.workspace.openTextDocument({ + language: 'pgsql', + content: selected.query.query, + }); + await vscode.window.showTextDocument(doc); + } +} + +/** + * Show query recommendations (most used/recent) + */ +export async function showQueryRecommendations(): Promise { + const service = SavedQueriesService.getInstance(); + const recent = service.getRecentQueries(5); + const mostUsed = service.getMostUsedQueries(5); + + if (recent.length === 0 && mostUsed.length === 0) { + vscode.window.showInformationMessage('No saved queries yet.'); + return; + } + + const items: vscode.QuickPickItem[] = []; + + if (recent.length > 0) { + items.push( + { label: '$(history) Recent Queries', kind: vscode.QuickPickItemKind.Separator }, + ...recent.map((q) => ({ + label: q.title, + description: `${q.usageCount} uses`, + query: q, + })) + ); + } + + if (mostUsed.length > 0) { + items.push( + { label: '$(star) Most Used Queries', kind: vscode.QuickPickItemKind.Separator }, + ...mostUsed.map((q) => ({ + label: q.title, + description: `${q.usageCount} uses`, + query: q, + })) + ); + } + + const selected = await vscode.window.showQuickPick(items as any[], { + placeHolder: 'Select a recommended query', + }); + + if (selected && 'query' in selected) { + await service.recordUsage(selected.query.id); + const doc = await vscode.workspace.openTextDocument({ + language: 'pgsql', + content: selected.query.query, + }); + await vscode.window.showTextDocument(doc); + } +} + +/** + * Save current query to library + * Opens a webview form for better UX + */ +export async function saveQueryToLibraryUI(): Promise { + const activeEditor = vscode.window.activeNotebookEditor; + + if (!activeEditor) { + vscode.window.showWarningMessage('No active notebook. Open a .pgsql notebook first.'); + return; + } + + // Get the currently selected/active cell + const notebook = activeEditor.notebook; + const selection = activeEditor.selections[0]; + + if (!selection) { + vscode.window.showWarningMessage('No cell selected. Please select a SQL cell first.'); + return; + } + + // Get the cell at the selection + const cell = notebook.cellAt(selection.start); + + // Only allow SQL cells (not markdown) + if (cell.kind !== vscode.NotebookCellKind.Code) { + vscode.window.showWarningMessage('Please select a code cell (SQL query), not a markdown cell.'); + return; + } + + const queryText = cell.document.getText(); + + if (!queryText.trim()) { + vscode.window.showWarningMessage('The selected cell is empty. Please write a query first.'); + return; + } + + // Capture connection metadata from the notebook + const metadata = notebook.metadata as any; + const connectionMetadata = { + connectionId: metadata?.connectionId, + databaseName: metadata?.databaseName, + schemaName: metadata?.schema + }; + + // Show the save query panel with connection metadata + if (!extensionContext) { + vscode.window.showErrorMessage('Extension context not available.'); + return; + } + SaveQueryPanel.show(extensionContext.extensionUri, queryText, connectionMetadata); +} + +/** + * View a saved query in detail panel + * Shows the query, allows copying and deletion + */ +export async function viewSavedQuery(treeItem?: any): Promise { + // Handle both tree item (from context menu) and direct query object + let query = treeItem?.query || treeItem; + + if (!query) { + // If no query passed, show a picker + const service = SavedQueriesService.getInstance(); + const queries = service.getQueries(); + + if (queries.length === 0) { + vscode.window.showInformationMessage('No saved queries yet.'); + return; + } + + const items = queries.map((q) => ({ + label: q.title, + description: q.description || `Used ${q.usageCount} times`, + query: q + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a query to view' + }); + + if (selected) { + query = selected.query; + } else { + return; + } + } + + if (query) { + SavedQueryDetailsPanel.show(vscode.Uri.file(__dirname).with({ scheme: 'file' }).with({ path: vscode.Uri.file(__dirname).fsPath.replace(/src.*/, '') }), query); + } +} + +/** + * Load a saved query into the active notebook + */ +export async function loadSavedQueryUI(): Promise { + const activeEditor = vscode.window.activeNotebookEditor; + + if (!activeEditor) { + vscode.window.showWarningMessage('No active notebook. Open a .pgsql notebook first.'); + return; + } + + const service = SavedQueriesService.getInstance(); + const queries = service.getQueries(); + + if (queries.length === 0) { + vscode.window.showInformationMessage('No saved queries yet.'); + return; + } + + const items = queries.map((q) => ({ + label: q.title, + description: q.description || `Used ${q.usageCount} times`, + query: q + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Select a query to load' + }); + + if (selected) { + // Record usage + await service.recordUsage(selected.query.id); + + // Open the query in a new document + const doc = await vscode.workspace.openTextDocument({ + language: 'sql', + content: selected.query.query, + }); + await vscode.window.showTextDocument(doc); + vscode.window.showInformationMessage(`āœ“ Loaded query: "${selected.query.title}"`); + } +} diff --git a/src/common/types.ts b/src/common/types.ts index 446dd1d..62de730 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -34,6 +34,11 @@ export interface PostgresMetadata { port: number; username?: string; password?: string; + // Profile settings + activeProfileId?: string; + readOnlyMode?: boolean; + autoLimitSelectResults?: number; + autoApplySafetyCheck?: boolean; // Transaction settings transactionSettings?: { autoRollback: boolean; diff --git a/src/extension.ts b/src/extension.ts index f44a835..56c1351 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,8 @@ import { PostgresMetadata } from './common/types'; import { PostgresKernel } from './providers/NotebookKernel'; import { ConnectionManager } from './services/ConnectionManager'; import { SecretStorageService } from './services/SecretStorageService'; +import { ProfileManager } from './services/ProfileManager'; +import { SavedQueriesService } from './services/SavedQueriesService'; import { ErrorHandlers } from './commands/helper'; import { registerProviders } from './activation/providers'; import { registerAllCommands } from './activation/commands'; @@ -15,6 +17,8 @@ import { ConnectionUtils } from './utils/connectionUtils'; import { ExplainProvider } from './providers/ExplainProvider'; export let outputChannel: vscode.OutputChannel; +export let extensionContext: vscode.ExtensionContext; +export let statusBar: NotebookStatusBar; let chatViewProvider: ChatViewProvider | undefined; @@ -23,6 +27,7 @@ export function getChatViewProvider(): ChatViewProvider | undefined { } export async function activate(context: vscode.ExtensionContext) { + extensionContext = context; outputChannel = vscode.window.createOutputChannel('PgStudio'); outputChannel.appendLine('Activating PgStudio extension'); @@ -30,13 +35,18 @@ export async function activate(context: vscode.ExtensionContext) { ConnectionManager.getInstance(); QueryHistoryService.initialize(context.workspaceState); - const { databaseTreeProvider, treeView, chatViewProviderInstance: chatView } = registerProviders(context, outputChannel); + // Phase 7: Initialize ProfileManager and SavedQueriesService + ProfileManager.getInstance().initialize(context); + SavedQueriesService.getInstance().initialize(context); + await ProfileManager.getInstance().initializeDefaultProfiles(); + + const { databaseTreeProvider, treeView, chatViewProviderInstance: chatView, savedQueriesTreeProvider } = registerProviders(context, outputChannel); chatViewProvider = chatView; // Store tree view instance for reveal functionality (databaseTreeProvider as any).setTreeView(treeView); - registerAllCommands(context, databaseTreeProvider, chatView, outputChannel); + registerAllCommands(context, databaseTreeProvider, chatView, outputChannel, savedQueriesTreeProvider); // Kernel initialization // Kernel initialization @@ -67,7 +77,7 @@ export async function activate(context: vscode.ExtensionContext) { const queryKernel = new PostgresKernel(context, 'postgres-query'); // Status bar for connection/database display - const statusBar = new NotebookStatusBar(); + statusBar = new NotebookStatusBar(); context.subscriptions.push(statusBar); const rendererMessaging = vscode.notebooks.createRendererMessaging('postgres-query-renderer'); diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index f342fc9..36f23ae 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -750,6 +750,101 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this._handleUserMessage(prompt); } + /** + * Handle "Explain this result" - feeds execution plan and performance metrics to AI + */ + public async handleExplainResult( + query: string, + executionTime: number, + rowCount: number, + explainPlan?: any + ): Promise { + const QueryAnalyzer = require('../services/QueryAnalyzer').QueryAnalyzer; + const analyzer = QueryAnalyzer.getInstance(); + + let planContext = ''; + let metricsContext = ''; + + if (explainPlan) { + const metrics = analyzer.extractPlanMetrics(explainPlan); + if (metrics) { + metricsContext = ` +Performance Metrics: +- Total Cost: ${metrics.totalCost.toFixed(2)} +- Planning Time: ${metrics.planningTime.toFixed(2)}ms +- Execution Time: ${metrics.executionTime.toFixed(2)}ms +- Sequential Scans: ${metrics.sequentialScans} +- Index Scans: ${metrics.indexScans} +${metrics.bufferStats ? `- Buffer Hit Ratio: ${metrics.bufferStats.hitRatio?.toFixed(1)}%` : ''} +${metrics.bottlenecks.length > 0 ? `\nBottlenecks Detected:\n${metrics.bottlenecks.map((b: string) => `- ${b}`).join('\n')}` : ''} +${metrics.recommendations.length > 0 ? `\nInitial Recommendations:\n${metrics.recommendations.map((r: string) => `- ${r}`).join('\n')}` : ''}`; + + planContext = `\n\nExecution Plan (JSON):\n\`\`\`json\n${JSON.stringify(explainPlan, null, 2)}\n\`\`\``; + } + } + + const prompt = `I just executed this query and got these results:\n\`\`\`sql\n${query}\n\`\`\` + +Execution Details: +- Time: ${executionTime.toFixed(3)}ms +- Rows Returned: ${rowCount} +${metricsContext}${planContext} + +Can you explain what this query is doing, how efficient it is, and what the execution plan tells us about its performance? What are the key performance factors?`; + + await this._handleUserMessage(prompt); + } + + /** + * Handle "Why slow?" - compares against baseline and provides performance analysis + */ + public async handleWhySlow( + query: string, + currentExecutionTime: number, + baselineAvgTime: number, + explainPlan?: any, + tableStats?: Array<{ table: string; rows: number; deadRows: number; lastVacuum?: string }> + ): Promise { + const QueryAnalyzer = require('../services/QueryAnalyzer').QueryAnalyzer; + const analyzer = QueryAnalyzer.getInstance(); + + let context = `Query:\n\`\`\`sql\n${query}\n\`\`\` + +Performance Comparison: +- Current Execution Time: ${currentExecutionTime.toFixed(3)}ms +- Historical Average: ${baselineAvgTime.toFixed(3)}ms +- Degradation: ${(((currentExecutionTime - baselineAvgTime) / baselineAvgTime) * 100).toFixed(1)}% slower`; + + if (explainPlan) { + const metrics = analyzer.extractPlanMetrics(explainPlan); + if (metrics) { + context += ` + +Current Execution Plan Metrics: +- Total Cost: ${metrics.totalCost.toFixed(2)} +- Sequential Scans: ${metrics.sequentialScans} +- Index Scans: ${metrics.indexScans} +${metrics.bufferStats ? `- Buffer Hit Ratio: ${metrics.bufferStats.hitRatio?.toFixed(1)}%` : ''} +${metrics.bottlenecks.length > 0 ? `\nBottlenecks:\n${metrics.bottlenecks.map((b: string) => `- ${b}`).join('\n')}` : ''}`; + } + } + + if (tableStats && tableStats.length > 0) { + context += ` + +Affected Table Statistics: +${tableStats.map((t: any) => `- ${t.table}: ${t.rows} rows, ${t.deadRows} dead rows${t.lastVacuum ? `, last vacuum ${t.lastVacuum}` : ''}`).join('\n')} + +This might indicate table bloat or stale statistics affecting query planning.`; + } + + const prompt = `${context} + +Why is this query running slower than its historical baseline? What could have changed (table growth, missing statistics, index bloat, lock contention, etc.)? Please provide specific next steps to diagnose and fix the performance regression.`; + + await this._handleUserMessage(prompt); + } + public async handleGenerateQuery( description: string, schemaContext?: Array<{ type: string, schema: string, name: string, columns?: string[] }> diff --git a/src/providers/Phase7TreeProviders.ts b/src/providers/Phase7TreeProviders.ts new file mode 100644 index 0000000..c869ee6 --- /dev/null +++ b/src/providers/Phase7TreeProviders.ts @@ -0,0 +1,257 @@ +import * as vscode from 'vscode'; +import { ProfileManager } from '../services/ProfileManager'; +import { SavedQueriesService } from '../services/SavedQueriesService'; +import { extensionContext } from '../extension'; + +/** + * Tree view item for connection profiles + */ +class ProfileTreeItem extends vscode.TreeItem { + constructor( + public readonly profile: any, + public readonly isActive: boolean = false, + public readonly command?: vscode.Command + ) { + const label = isActive + ? `ā— ${profile.profileName || profile.name || `${profile.host}:${profile.port}`}` + : profile.profileName || profile.name || `${profile.host}:${profile.port}`; + + super( + label, + vscode.TreeItemCollapsibleState.None + ); + this.description = isActive ? `${profile.description || `${profile.host}:${profile.port}`} (ACTIVE)` : (profile.description || `${profile.host}:${profile.port}`); + this.tooltip = `${profile.profileName}\n${profile.description || ''}\nHost: ${profile.host}:${profile.port}${isActive ? '\n\nāœ“ This profile is currently active' : ''}`; + this.contextValue = 'profile'; + this.iconPath = new vscode.ThemeIcon(profile.readOnlyMode ? 'lock' : 'person'); + + // Highlight active profile with bold styling if supported + if (isActive) { + this.resourceUri = vscode.Uri.parse('profile://active'); + } + } +} + +/** + * Tree view item for saved queries + */ +class SavedQueryTreeItem extends vscode.TreeItem { + constructor( + public readonly query: any + ) { + super(query.title, vscode.TreeItemCollapsibleState.None); + + // Build description with metadata + const parts: string[] = []; + + // Add database name if available + if (query.databaseName) { + parts.push(`šŸ“Š ${query.databaseName}`); + } + + // Add connection name if available + if (query.connectionId) { + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const connection = connections.find(c => c.id === query.connectionId); + if (connection) { + parts.push(`šŸ”— ${connection.name || connection.host}`); + } + } + + // Add usage count + parts.push(`${query.usageCount || 0}x used`); + + this.description = parts.join(' • '); + + // Build rich tooltip with all details + const createdDate = new Date(query.createdAt).toLocaleString(); + const lastUsedDate = query.lastUsed ? new Date(query.lastUsed).toLocaleString() : 'Never'; + const queryPreview = query.query.replace(/\n/g, ' ').substring(0, 120); + + const tooltipParts: string[] = [ + `šŸ“ ${queryPreview}${query.query.length > 120 ? '...' : ''}`, + '', + `šŸ“… Created: ${createdDate}`, + `ā±ļø Last Used: ${lastUsedDate}`, + `šŸ“Š Database: ${query.databaseName || 'N/A'}`, + `šŸ”— Schema: ${query.schemaName || 'N/A'}` + ]; + + if (query.description) { + tooltipParts.push('', `šŸ“‹ ${query.description}`); + } + + if (query.tags && query.tags.length > 0) { + tooltipParts.push('', `šŸ·ļø Tags: ${query.tags.join(', ')}`); + } + + this.tooltip = tooltipParts.join('\n'); + this.contextValue = 'savedQuery'; + this.iconPath = new vscode.ThemeIcon('save'); + } +} + +/** + * Tree view item for query tags - shows count and can be expanded + */ +class TagTreeItem extends vscode.TreeItem { + constructor( + public readonly tag: string, + public readonly queryCount: number + ) { + super(`${tag} (${queryCount})`, vscode.TreeItemCollapsibleState.Collapsed); + this.description = `${queryCount} quer${queryCount === 1 ? 'y' : 'ies'}`; + this.tooltip = `Click to expand and see all queries tagged with "${tag}"`; + this.contextValue = 'tag'; + this.iconPath = new vscode.ThemeIcon('tag'); + } +} + +/** + * Tree view provider for connection profiles + */ +export class ProfilesTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + constructor() {} + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: vscode.TreeItem): Promise { + const profileManager = ProfileManager.getInstance(); + const profiles = profileManager.getProfiles(); + + if (profiles.length === 0) { + const noItemsItem = new vscode.TreeItem('No profiles yet'); + noItemsItem.contextValue = 'emptyProfiles'; + noItemsItem.iconPath = new vscode.ThemeIcon('info'); + return [noItemsItem]; + } + + // Get currently active notebook to check which profile is active + const activeEditor = vscode.window.activeNotebookEditor; + const notebookKey = activeEditor + ? `activeProfile-${activeEditor.notebook.uri.toString()}` + : null; + const activeProfileContext = notebookKey + ? extensionContext?.globalState.get(notebookKey) + : null; + + return profiles.map( + (profile) => { + const isActive = activeProfileContext?.profileId === profile.id; + return new ProfileTreeItem(profile, isActive, { + command: 'postgres-explorer.switchConnectionProfile', + title: 'Switch Profile', + arguments: [profile.id], + }); + } + ); + } +} + +/** + * Tree view provider for saved queries + */ +export class SavedQueriesTreeProvider + implements vscode.TreeDataProvider +{ + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private _expandedTags = new Set(); + + constructor() {} + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + getTreeItem(element: vscode.TreeItem): vscode.TreeItem { + return element; + } + + async getChildren(element?: vscode.TreeItem): Promise { + const service = SavedQueriesService.getInstance(); + const queries = service.getQueries(); + + if (queries.length === 0) { + const noItemsItem = new vscode.TreeItem('No saved queries yet'); + noItemsItem.contextValue = 'emptySavedQueries'; + noItemsItem.iconPath = new vscode.ThemeIcon('info'); + return [noItemsItem]; + } + + // If no element is provided, show root level with tags + untagged queries + if (!element) { + return this._getRootItems(queries); + } + + // If a TagTreeItem was clicked, return queries with that tag + if (element instanceof TagTreeItem) { + return queries + .filter(q => q.tags && q.tags.includes(element.tag)) + .map(q => new SavedQueryTreeItem(q)); + } + + return []; + } + + private _getRootItems(queries: any[]): vscode.TreeItem[] { + // Collect all unique tags and their query counts + const tagMap = new Map(); + const untaggedQueries: any[] = []; + + for (const query of queries) { + if (query.tags && query.tags.length > 0) { + for (const tag of query.tags) { + if (!tagMap.has(tag)) { + tagMap.set(tag, []); + } + tagMap.get(tag)!.push(query); + } + } else { + untaggedQueries.push(query); + } + } + + const items: vscode.TreeItem[] = []; + + // Add tag groups (sorted alphabetically) + const sortedTags = Array.from(tagMap.keys()).sort(); + for (const tag of sortedTags) { + const queryCount = tagMap.get(tag)!.length; + items.push(new TagTreeItem(tag, queryCount)); + } + + // Add untagged queries section if there are any + if (untaggedQueries.length > 0) { + // Group untagged queries by database for better organization + const dbMap = new Map(); + for (const query of untaggedQueries) { + const db = query.databaseName || 'No Database'; + if (!dbMap.has(db)) { + dbMap.set(db, []); + } + dbMap.get(db)!.push(query); + } + + // If only one database, just show the queries + if (dbMap.size === 1) { + const [, dbQueries] = Array.from(dbMap.entries())[0]; + items.push(...dbQueries.map(q => new SavedQueryTreeItem(q))); + } else { + // Show untagged queries (recent ones first, limit to 10) + items.push(...untaggedQueries.slice(0, 10).map(q => new SavedQueryTreeItem(q))); + } + } + + return items; + } +} diff --git a/src/providers/QueryCodeLensProvider.ts b/src/providers/QueryCodeLensProvider.ts index d5e33e4..d8fcd7b 100644 --- a/src/providers/QueryCodeLensProvider.ts +++ b/src/providers/QueryCodeLensProvider.ts @@ -56,6 +56,15 @@ export class QueryCodeLensProvider implements vscode.CodeLensProvider { ); } + // Add Save Query codelens for all queries + codeLenses.push( + new vscode.CodeLens(range, { + title: '$(save) Save Query', + tooltip: 'Save this query to the library for easy reuse', + command: 'postgres-explorer.saveQueryToLibraryUI' + }) + ); + return codeLenses; } } diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index 9e89b89..49c8a06 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -9,25 +9,39 @@ import { ErrorService } from '../../services/ErrorService'; import { QueryHistoryService } from '../../services/QueryHistoryService'; import { getTransactionManager } from '../../services/TransactionManager'; import { QueryAnalyzer } from '../../services/QueryAnalyzer'; +import { extensionContext } from '../../extension'; export class SqlExecutor { constructor(private readonly _controller: vscode.NotebookController) { } /** * Apply auto-LIMIT to SELECT queries that don't already have one + * Respects both global settings and profile-level autoLimitSelectResults */ - private applyAutoLimit(query: string, connection: any): string { - // Check if auto-limit is enabled - const autoLimitEnabled = vscode.workspace.getConfiguration() - .get('postgresExplorer.query.autoLimitEnabled', true); + private applyAutoLimit(query: string, connection: any, notebookMetadata?: any, profileContext?: any): string { + // Check profile-level auto-limit first (takes precedence) + let limit: number | null = null; - if (!autoLimitEnabled && !connection.readOnlyMode) { - return query; + // Try profile context first, then metadata + if (profileContext?.autoLimitSelectResults !== undefined && profileContext.autoLimitSelectResults > 0) { + limit = profileContext.autoLimitSelectResults; + } else if (notebookMetadata?.autoLimitSelectResults !== undefined && notebookMetadata.autoLimitSelectResults > 0) { + limit = notebookMetadata.autoLimitSelectResults; + } else { + // Fall back to global settings + const autoLimitEnabled = vscode.workspace.getConfiguration() + .get('postgresExplorer.query.autoLimitEnabled', true); + + if (autoLimitEnabled || connection.readOnlyMode) { + limit = vscode.workspace.getConfiguration() + .get('postgresExplorer.performance.defaultLimit', 1000); + } } - // Get default limit - const defaultLimit = vscode.workspace.getConfiguration() - .get('postgresExplorer.performance.defaultLimit', 1000); + // If no limit determined, return query as-is + if (!limit) { + return query; + } // Only apply to SELECT queries const trimmed = query.trim(); @@ -45,7 +59,7 @@ export class SqlExecutor { const baseQuery = hasSemicolon ? trimmed.slice(0, -1) : trimmed; // Apply LIMIT - const limitedQuery = `${baseQuery} LIMIT ${defaultLimit}${hasSemicolon ? ';' : ''}`; + const limitedQuery = `${baseQuery} LIMIT ${limit}${hasSemicolon ? ';' : ''}`; return limitedQuery; } @@ -62,6 +76,11 @@ export class SqlExecutor { throw new Error('No connection metadata found'); } + // Fetch active profile context from globalState + // This allows different notebooks to have different active profiles + const notebookKey = `activeProfile-${cell.notebook.uri.toString()}`; + const activeProfileContext = extensionContext?.globalState.get(notebookKey); + // Get connection info const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; const connection = connections.find(c => c.id === metadata.connectionId); @@ -69,6 +88,18 @@ export class SqlExecutor { throw new Error('Connection not found'); } + // Apply profile settings from metadata to connection (metadata takes precedence) + if (metadata.readOnlyMode !== undefined) { + connection.readOnlyMode = metadata.readOnlyMode; + } + + // Apply profile settings from globalState if available + if (activeProfileContext) { + if (activeProfileContext.readOnlyMode !== undefined) { + connection.readOnlyMode = activeProfileContext.readOnlyMode; + } + } + const client = await ConnectionManager.getInstance().getSessionClient({ id: connection.id, host: connection.host, @@ -145,9 +176,9 @@ export class SqlExecutor { let query = statements[stmtIndex]; const stmtStartTime = Date.now(); - // Apply auto-LIMIT if applicable + // Apply auto-LIMIT if applicable (pass notebook metadata and profile context for settings) const originalQuery = query; - query = this.applyAutoLimit(query, connection); + query = this.applyAutoLimit(query, connection, metadata, activeProfileContext); const autoLimitApplied = query !== originalQuery; console.log(`SqlExecutor: Executing statement ${stmtIndex + 1}/${statements.length}:`, query.substring(0, 100)); diff --git a/src/services/ProfileManager.ts b/src/services/ProfileManager.ts new file mode 100644 index 0000000..9ae8299 --- /dev/null +++ b/src/services/ProfileManager.ts @@ -0,0 +1,220 @@ +import * as vscode from 'vscode'; +import { ConnectionConfig } from '../common/types'; +import { ConnectionManager } from './ConnectionManager'; + +/** + * Connection profile with preset safety and performance settings. + * Extends ConnectionConfig with role-based preferences. + */ +export interface ConnectionProfile extends ConnectionConfig { + /** Profile name (e.g., "Read-Only Analyst", "DB Admin") */ + profileName: string; + /** Profile description/use case */ + description?: string; + /** Role-based preset settings */ + rolePresets?: { + forceReadOnly?: boolean; + autoApplySafetyCheck?: boolean; + autoLimitSelectResults?: number; // 0 = disabled + }; +} + +/** + * Manages connection profiles: predefined connection templates with role-based settings. + * Singleton service that persists profiles in VS Code globalState. + */ +export class ProfileManager { + private static instance: ProfileManager; + private context: vscode.ExtensionContext | null = null; + private profiles: Map = new Map(); + private readonly STORAGE_KEY = 'postgres-explorer.connectionProfiles'; + + private constructor() {} + + static getInstance(): ProfileManager { + if (!ProfileManager.instance) { + ProfileManager.instance = new ProfileManager(); + } + return ProfileManager.instance; + } + + /** + * Initialize ProfileManager with extension context. + * Must be called during extension activation. + */ + initialize(context: vscode.ExtensionContext): void { + this.context = context; + this.loadProfiles(); + } + + /** + * Load profiles from persistent storage. + */ + private loadProfiles(): void { + if (!this.context) { + return; + } + const stored = this.context.globalState.get(this.STORAGE_KEY, []); + this.profiles.clear(); + stored.forEach((profile) => { + this.profiles.set(profile.id, profile); + }); + } + + /** + * Save profiles to persistent storage. + */ + private async saveProfiles(): Promise { + if (!this.context) { + return; + } + const profileArray = Array.from(this.profiles.values()); + await this.context.globalState.update(this.STORAGE_KEY, profileArray); + } + + /** + * Create or update a connection profile. + */ + async createProfile(profile: ConnectionProfile): Promise { + this.profiles.set(profile.id, profile); + await this.saveProfiles(); + } + + /** + * Delete a connection profile by ID. + */ + async deleteProfile(profileId: string): Promise { + this.profiles.delete(profileId); + await this.saveProfiles(); + } + + /** + * Get all profiles. + */ + getProfiles(): ConnectionProfile[] { + return Array.from(this.profiles.values()); + } + + /** + * Get a profile by ID. + */ + getProfile(profileId: string): ConnectionProfile | undefined { + return this.profiles.get(profileId); + } + + /** + * Apply profile settings to a connection config. + * Merges profile role presets into the connection. + */ + applyProfile(baseConfig: ConnectionConfig, profileId: string): ConnectionConfig { + const profile = this.getProfile(profileId); + if (!profile) { + return baseConfig; + } + + return { + ...baseConfig, + ...profile, + // Keep base config ID/secrets, override only connection details + password: baseConfig.password, // Preserve original password if not in profile + }; + } + + /** + * Initialize with built-in role presets. + * Call this once after extension loads to populate default profiles. + */ + async initializeDefaultProfiles(): Promise { + if (this.profiles.size > 0) { + return; // Already initialized + } + + const readOnlyAnalyst: ConnectionProfile = { + id: 'profile-readonly-analyst', + name: 'Read-Only Analyst', + profileName: 'Read-Only Analyst', + description: 'Safe read-only access for data analysts', + host: 'localhost', + port: 5432, + readOnlyMode: true, + rolePresets: { + forceReadOnly: true, + autoApplySafetyCheck: true, + autoLimitSelectResults: 1000, + }, + }; + + const dbAdmin: ConnectionProfile = { + id: 'profile-db-admin', + name: 'DB Admin', + profileName: 'DB Admin', + description: 'Full access for database administrators', + host: 'localhost', + port: 5432, + readOnlyMode: false, + rolePresets: { + forceReadOnly: false, + autoApplySafetyCheck: true, + autoLimitSelectResults: 0, // No auto-limit + }, + }; + + const stagingEnv: ConnectionProfile = { + id: 'profile-staging-dev', + name: 'Staging Dev', + profileName: 'Staging Dev', + description: 'Development connection on staging server', + host: 'staging.example.com', + port: 5432, + environment: 'staging', + readOnlyMode: false, + rolePresets: { + forceReadOnly: false, + autoApplySafetyCheck: true, + autoLimitSelectResults: 500, + }, + }; + + const prodReadOnly: ConnectionProfile = { + id: 'profile-prod-readonly', + name: 'Production Read-Only', + profileName: 'Production Read-Only', + description: 'Read-only access to production (safeguarded)', + host: 'prod.example.com', + port: 5432, + environment: 'production', + readOnlyMode: true, + rolePresets: { + forceReadOnly: true, + autoApplySafetyCheck: true, + autoLimitSelectResults: 100, + }, + }; + + await this.createProfile(readOnlyAnalyst); + await this.createProfile(dbAdmin); + await this.createProfile(stagingEnv); + await this.createProfile(prodReadOnly); + } + + /** + * Get profile suggestion based on connection config (for UI hints). + * Returns the most relevant profile role for a given connection. + */ + suggestProfile(config: ConnectionConfig): ConnectionProfile | undefined { + const profiles = this.getProfiles(); + + // Match by environment + if (config.environment === 'production' && config.readOnlyMode) { + return profiles.find((p) => p.id === 'profile-prod-readonly'); + } + if (config.readOnlyMode) { + return profiles.find((p) => p.id === 'profile-readonly-analyst'); + } + if (config.environment === 'staging') { + return profiles.find((p) => p.id === 'profile-staging-dev'); + } + + return profiles.find((p) => p.id === 'profile-db-admin'); + } +} diff --git a/src/services/QueryAnalyzer.ts b/src/services/QueryAnalyzer.ts index ddb1757..4f0e995 100644 --- a/src/services/QueryAnalyzer.ts +++ b/src/services/QueryAnalyzer.ts @@ -1,5 +1,47 @@ import { ConnectionConfig } from '../common/types'; +/** + * Execution plan performance metrics extracted from EXPLAIN JSON + */ +export interface PlanMetrics { + totalCost: number; + planningTime: number; + executionTime: number; + sequentialScans: number; + indexScans: number; + bufferStats?: { + bufferHits: number; + bufferReads: number; + hitRatio?: number; + }; + bottlenecks: string[]; + recommendations: string[]; +} + +/** + * Baseline statistics for a query (for trend comparison) + */ +export interface QueryBaseline { + queryHash: string; + avgExecutionTime: number; + minExecutionTime: number; + maxExecutionTime: number; + stdDev: number; + sampleCount: number; + lastUpdated: number; +} + +/** + * Query performance analysis result + */ +export interface PerformanceAnalysis { + metrics: PlanMetrics | null; + baseline: QueryBaseline | null; + isDegraded: boolean; + degradationPercent?: number; + analysis: string; +} + /** * Represents a dangerous SQL operation detected by the analyzer */ @@ -331,4 +373,176 @@ export class QueryAnalyzer { return !hasWriteOperation; } + + /** + * Extract performance metrics from EXPLAIN JSON plan. + * Analyzes the execution plan to identify bottlenecks and opportunities. + */ + public extractPlanMetrics(explainPlan: any): PlanMetrics | null { + if (!explainPlan || typeof explainPlan !== 'object') { + return null; + } + + // Handle both direct plan object and wrapped format + const plan = + explainPlan[0] || explainPlan; + + if (!plan || !plan['Plan']) { + return null; + } + + const planMetrics: PlanMetrics = { + totalCost: plan['Plan']['Total Cost'] || 0, + planningTime: plan['Planning Time'] || 0, + executionTime: plan['Execution Time'] || 0, + sequentialScans: 0, + indexScans: 0, + bottlenecks: [], + recommendations: [], + }; + + // Count scan types and identify bottlenecks + this.analyzePlanNode(plan['Plan'], planMetrics); + + // Extract buffer statistics if present + if (plan['Planning'] !== undefined && plan['Buffers']) { + const buffers = plan['Buffers']; + const totalHits = (buffers['Shared Hit Blocks'] || 0) + (buffers['Shared Read Blocks'] || 0); + const reads = buffers['Shared Read Blocks'] || 0; + planMetrics.bufferStats = { + bufferHits: buffers['Shared Hit Blocks'] || 0, + bufferReads: reads, + hitRatio: totalHits > 0 ? ((totalHits - reads) / totalHits * 100) : 0, + }; + } + + // Generate recommendations based on metrics + this.generateRecommendations(planMetrics); + + return planMetrics; + } + + /** + * Recursively analyze plan nodes to count operations and identify bottlenecks + */ + private analyzePlanNode(node: any, metrics: PlanMetrics): void { + if (!node) { + return; + } + + const nodeType = node['Node Type'] || ''; + const actualRows = node['Actual Rows'] || 0; + const planRows = node['Plan Rows'] || 0; + const actualTime = node['Actual Total Time'] || 0; + + // Count scan types + if (nodeType.includes('Seq Scan')) { + metrics.sequentialScans++; + } else if (nodeType.includes('Index Scan')) { + metrics.indexScans++; + } + + // Identify planning vs. execution mismatches (bottleneck) + if (planRows > 0 && actualRows > 0) { + const variance = Math.abs(actualRows - planRows) / planRows; + if (variance > 0.5) { + metrics.bottlenecks.push( + `Row estimation mismatch in ${nodeType}: planned ${planRows}, actual ${actualRows}` + ); + } + } + + // Flag slow operations + if (actualTime > 1000) { + metrics.bottlenecks.push(`${nodeType} took ${actualTime.toFixed(2)}ms`); + } + + // Recursively process child nodes + if (node['Plans'] && Array.isArray(node['Plans'])) { + node['Plans'].forEach((child: any) => this.analyzePlanNode(child, metrics)); + } + } + + /** + * Generate optimization recommendations based on plan metrics + */ + private generateRecommendations(metrics: PlanMetrics): void { + // Sequential scan optimization + if (metrics.sequentialScans > 0 && metrics.indexScans === 0) { + metrics.recommendations.push('Consider adding indexes on frequently filtered columns'); + } + + // High planning cost + if (metrics.totalCost > 10000) { + metrics.recommendations.push('Query planning cost is high; consider simplifying the query or analyzing table statistics'); + } + + // Buffer efficiency + if (metrics.bufferStats && metrics.bufferStats.hitRatio !== undefined) { + if (metrics.bufferStats.hitRatio < 80) { + metrics.recommendations.push('Low buffer hit ratio; consider increasing work_mem or improving indexes'); + } + } + + // Bottleneck-based recommendations + if (metrics.bottlenecks.length > 0) { + metrics.recommendations.push('Review bottlenecks: ' + metrics.bottlenecks[0]); + } + } + + /** + * Compute a hash of normalized query for baseline tracking + */ + public getQueryHash(query: string): string { + const normalized = this.normalizeQuery(query) + .toLowerCase() + .replace(/\?/g, ':param') // Normalize parameterized queries + .replace(/\d+/g, 'N'); // Normalize numeric literals + + // Simple hash function + let hash = 0; + for (let i = 0; i < normalized.length; i++) { + const char = normalized.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); + } + + /** + * Analyze query performance against historical baseline + */ + public analyzePerformanceAgainstBaseline( + executionTime: number, + baseline: QueryBaseline | null, + explainPlan: any + ): PerformanceAnalysis { + const metrics = this.extractPlanMetrics(explainPlan); + + if (!baseline) { + return { + metrics, + baseline: null, + isDegraded: false, + analysis: 'No baseline available for comparison. First execution will be recorded as baseline.', + }; + } + + const isDegraded = executionTime > baseline.avgExecutionTime * 1.2; // 20% slower + const degradationPercent = isDegraded + ? Math.round(((executionTime - baseline.avgExecutionTime) / baseline.avgExecutionTime) * 100) + : 0; + + const analysis = isDegraded + ? `Performance degradation detected: ${degradationPercent}% slower than baseline (${baseline.avgExecutionTime.toFixed(0)}ms avg vs ${executionTime.toFixed(0)}ms now).` + : `Query performance is within baseline (${baseline.avgExecutionTime.toFixed(0)}ms avg, ${executionTime.toFixed(0)}ms now).`; + + return { + metrics, + baseline, + isDegraded, + degradationPercent, + analysis, + }; + } } diff --git a/src/services/SavedQueriesService.ts b/src/services/SavedQueriesService.ts new file mode 100644 index 0000000..da47bbf --- /dev/null +++ b/src/services/SavedQueriesService.ts @@ -0,0 +1,228 @@ +import * as vscode from 'vscode'; + +/** + * Saved query with metadata for quick access and reuse + */ +export interface SavedQuery { + /** Unique identifier */ + id: string; + /** Query title/name */ + title: string; + /** SQL query text */ + query: string; + /** Optional description */ + description?: string; + /** Tags for organization (e.g., "analytics", "maintenance") */ + tags?: string[]; + /** When created */ + createdAt: number; + /** When last used */ + lastUsed?: number; + /** Usage count */ + usageCount: number; + /** Optional connection preset ID to use with this query */ + preferredProfileId?: string; + /** Connection context for reopening with same DB */ + connectionId?: string; + /** Database name to use when opening */ + databaseName?: string; + /** Schema name for context */ + schemaName?: string; +} + +/** + * Manages saved queries for quick reuse across sessions. + * Persists in VS Code workspace memento (workspace-local storage). + */ +export class SavedQueriesService { + private static instance: SavedQueriesService; + private context: vscode.ExtensionContext | null = null; + private queries: Map = new Map(); + private readonly STORAGE_KEY = 'postgres-explorer.savedQueries'; + + private constructor() {} + + static getInstance(): SavedQueriesService { + if (!SavedQueriesService.instance) { + SavedQueriesService.instance = new SavedQueriesService(); + } + return SavedQueriesService.instance; + } + + /** + * Initialize SavedQueriesService with extension context. + * Must be called during extension activation. + */ + initialize(context: vscode.ExtensionContext): void { + this.context = context; + this.loadQueries(); + } + + /** + * Load saved queries from workspace memento. + */ + private loadQueries(): void { + if (!this.context) { + return; + } + const stored = this.context.workspaceState.get(this.STORAGE_KEY, []); + this.queries.clear(); + stored.forEach((query) => { + this.queries.set(query.id, query); + }); + } + + /** + * Save queries to workspace memento. + */ + private async saveQueries(): Promise { + if (!this.context) { + return; + } + const queryArray = Array.from(this.queries.values()); + await this.context.workspaceState.update(this.STORAGE_KEY, queryArray); + } + + /** + * Save a new query or update existing one. + */ + async saveQuery(query: SavedQuery): Promise { + if (!query.id) { + query.id = this.generateId(); + } + if (!query.createdAt) { + query.createdAt = Date.now(); + } + this.queries.set(query.id, query); + await this.saveQueries(); + } + + /** + * Update an existing saved query. + */ + async updateQuery(query: SavedQuery): Promise { + if (!query.id) { + throw new Error('Cannot update query without ID'); + } + // Preserve original createdAt date + const existing = this.queries.get(query.id); + if (existing) { + query.createdAt = existing.createdAt; + } + this.queries.set(query.id, query); + await this.saveQueries(); + } + + /** + * Delete a saved query by ID. + */ + async deleteQuery(queryId: string): Promise { + this.queries.delete(queryId); + await this.saveQueries(); + } + + /** + * Get all saved queries. + */ + getQueries(): SavedQuery[] { + return Array.from(this.queries.values()).sort( + (a, b) => (b.lastUsed || b.createdAt) - (a.lastUsed || a.createdAt) + ); + } + + /** + * Get saved queries filtered by tag. + */ + getQueriesByTag(tag: string): SavedQuery[] { + return this.getQueries().filter((q) => q.tags?.includes(tag)); + } + + /** + * Search saved queries by title or description. + */ + searchQueries(searchText: string): SavedQuery[] { + const lower = searchText.toLowerCase(); + return this.getQueries().filter((q) => + q.title.toLowerCase().includes(lower) || + q.description?.toLowerCase().includes(lower) + ); + } + + /** + * Get a saved query by ID. + */ + getQuery(queryId: string): SavedQuery | undefined { + return this.queries.get(queryId); + } + + /** + * Mark a query as used (updates lastUsed and usageCount). + */ + async recordUsage(queryId: string): Promise { + const query = this.queries.get(queryId); + if (query) { + query.lastUsed = Date.now(); + query.usageCount = (query.usageCount || 0) + 1; + await this.saveQueries(); + } + } + + /** + * Get most frequently used queries. + */ + getMostUsedQueries(limit: number = 10): SavedQuery[] { + return this.getQueries() + .sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0)) + .slice(0, limit); + } + + /** + * Get recently used queries. + */ + getRecentQueries(limit: number = 10): SavedQuery[] { + return this.getQueries() + .sort((a, b) => (b.lastUsed || b.createdAt) - (a.lastUsed || a.createdAt)) + .slice(0, limit); + } + + /** + * Export all queries as JSON. + */ + exportQueries(): string { + return JSON.stringify(Array.from(this.queries.values()), null, 2); + } + + /** + * Import queries from JSON. + */ + async importQueries(jsonData: string): Promise { + try { + const imported = JSON.parse(jsonData) as SavedQuery[]; + for (const query of imported) { + await this.saveQuery(query); + } + } catch (error) { + throw new Error(`Failed to import queries: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Get all unique tags across all queries. + */ + getAllTags(): string[] { + const tags = new Set(); + this.getQueries().forEach((q) => { + if (q.tags) { + q.tags.forEach((tag) => tags.add(tag)); + } + }); + return Array.from(tags).sort(); + } + + /** + * Generate unique query ID. + */ + private generateId(): string { + return `query_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} diff --git a/templates/connection-form/index.html b/templates/connection-form/index.html index 3ff36c5..2b77514 100644 --- a/templates/connection-form/index.html +++ b/templates/connection-form/index.html @@ -78,7 +78,7 @@

{{HEADER_TITLE}}

-
ļæ½ļø
+
šŸ›”ļø
Safety & Security
@@ -109,7 +109,7 @@

{{HEADER_TITLE}}

-
ļæ½šŸ”
+
šŸ”
Authentication
@@ -144,7 +144,7 @@

{{HEADER_TITLE}}

-
+
šŸ”’
SSH Tunnel (Optional)
ā–¼ @@ -185,7 +185,7 @@

{{HEADER_TITLE}}

-
+
āš™ļø
Advanced Options
ā–¼ diff --git a/templates/connection-form/scripts.js b/templates/connection-form/scripts.js index 0511b78..8ff9135 100644 --- a/templates/connection-form/scripts.js +++ b/templates/connection-form/scripts.js @@ -137,6 +137,10 @@ function toggleAdvanced() { } } +// Attach click event listeners to collapsible headers +document.getElementById('ssh-header').addEventListener('click', toggleSSH); +document.getElementById('advanced-header').addEventListener('click', toggleAdvanced); + // SSL mode change handler - show cert fields for verify modes function updateSSLCertFields() { const sslmode = document.getElementById('sslmode').value;