diff --git a/CHANGELOG.md b/CHANGELOG.md index ac64ac6..05b9287 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.8.3] - 2026-02-14 + +### Added +- **Query Performance Tracking**: Automatically tracks execution times for queries and warns about performance degradation ( > 50% slower than baseline). +- **Visual Explain Plans**: Enhanced visualization for `EXPLAIN (FORMAT JSON)` results, providing a clear graphical representation of query execution paths. + +### Improved +- **Message Handling Architecture**: Refactored internal message routing to a robust `MessageHandler` registry pattern for improved stability and maintainability. +- **CodeLens UX**: Cleaned up and reordered CodeLens actions for better usability; removed redundant 'Explain' action (now accessible via main toolbar/menus). + +--- + ## [0.8.2] - 2026-02-08 ### Added diff --git a/README.md b/README.md index 8f033ef..8c3dc97 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ - 🔌 **Secure Connections** — VS Code SecretStorage encryption - đŸ›Ąī¸ **Connection Safety** — Environment tagging (🔴 PROD, 🟡 STAGING, đŸŸĸ DEV), read-only mode, query safety analyzer +- âąī¸ **Performance Tracking** — Historical query execution monitoring with degradation alerts - 📊 **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 @@ -93,6 +94,7 @@ - Real-time activity monitoring - Index usage analytics - Bloat detection & warnings +- Query performance history & alerts - Complete table definitions diff --git a/src/activation/providers.ts b/src/activation/providers.ts index a503350..215d616 100644 --- a/src/activation/providers.ts +++ b/src/activation/providers.ts @@ -3,7 +3,7 @@ import { ChatViewProvider } from '../providers/ChatViewProvider'; import { DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; import { PostgresNotebookProvider } from '../notebookProvider'; 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'; @@ -62,19 +62,7 @@ export function registerProviders(context: vscode.ExtensionContext, outputChanne ) ); - // Register CodeLens Provider for both 'postgres' and 'sql' languages - const aiCodeLensProvider = new AiCodeLensProvider(); - context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - { language: 'postgres', scheme: 'vscode-notebook-cell' }, - aiCodeLensProvider - ), - vscode.languages.registerCodeLensProvider( - { language: 'sql', scheme: 'vscode-notebook-cell' }, - aiCodeLensProvider - ) - ); - outputChannel.appendLine('AiCodeLensProvider registered for postgres and sql languages.'); + // Register Query CodeLens Provider for EXPLAIN actions const queryCodeLensProvider = new QueryCodeLensProvider(); diff --git a/src/common/types.ts b/src/common/types.ts index 62de730..142a093 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -91,6 +91,7 @@ export interface QueryResults { success?: boolean; backendPid?: number | null; explainPlan?: any; + performanceAnalysis?: any; slowQuery?: boolean; breadcrumb?: BreadcrumbContext; } diff --git a/src/extension.ts b/src/extension.ts index 56c1351..ef45fff 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -13,8 +13,16 @@ import { NotebookStatusBar } from './activation/statusBar'; import { WhatsNewManager } from './activation/WhatsNewManager'; import { ChatViewProvider } from './providers/ChatViewProvider'; import { QueryHistoryService } from './services/QueryHistoryService'; +import { QueryPerformanceService } from './services/QueryPerformanceService'; import { ConnectionUtils } from './utils/connectionUtils'; import { ExplainProvider } from './providers/ExplainProvider'; +import { MessageHandlerRegistry } from './services/MessageHandler'; +import { + ExplainErrorHandler, FixQueryHandler, AnalyzeDataHandler, OptimizeQueryHandler, + SendToChatHandler, ShowExplainPlanHandler, ConvertExplainHandler +} from './services/handlers/ExplainHandlers'; +import { ShowConnectionSwitcherHandler, ShowDatabaseSwitcherHandler, ShowErrorMessageHandler, ExportRequestHandler } from './services/handlers/CoreHandlers'; +import { ExecuteUpdateBackgroundHandler, ScriptDeleteHandler, SaveChangesHandler } from './services/handlers/QueryHandlers'; export let outputChannel: vscode.OutputChannel; export let extensionContext: vscode.ExtensionContext; @@ -34,6 +42,7 @@ export async function activate(context: vscode.ExtensionContext) { SecretStorageService.getInstance(context); ConnectionManager.getInstance(); QueryHistoryService.initialize(context.workspaceState); + QueryPerformanceService.initialize(context.globalState); // Phase 7: Initialize ProfileManager and SavedQueriesService ProfileManager.getInstance().initialize(context); @@ -50,7 +59,9 @@ export async function activate(context: vscode.ExtensionContext) { // Kernel initialization // Kernel initialization - const kernel = new PostgresKernel(context, 'postgres-notebook', async (msg: { type: string; command: string; format?: string; content?: string; filename?: string }) => { + const rendererMessaging = vscode.notebooks.createRendererMessaging('postgres-query-renderer'); + + const kernel = new PostgresKernel(context, rendererMessaging, 'postgres-notebook', async (msg: { type: string; command: string; format?: string; content?: string; filename?: string }) => { if (msg.type === 'custom' && msg.command === 'export') { vscode.commands.executeCommand('postgres-explorer.exportData', { format: msg.format, @@ -74,366 +85,40 @@ export async function activate(context: vscode.ExtensionContext) { }) ); - const queryKernel = new PostgresKernel(context, 'postgres-query'); + const queryKernel = new PostgresKernel(context, rendererMessaging, 'postgres-query'); // Status bar for connection/database display statusBar = new NotebookStatusBar(); context.subscriptions.push(statusBar); - const rendererMessaging = vscode.notebooks.createRendererMessaging('postgres-query-renderer'); - rendererMessaging.onDidReceiveMessage(async (event) => { - const message = event.message; - const notebook = event.editor.notebook; - - if (message.type === 'explainError') { - if (chatView) { - await chatView.handleExplainError(message.error, message.query); - } - return; - } - if (message.type === 'fixQuery') { - if (chatView) { - await chatView.handleFixQuery(message.error, message.query); - } - return; - } - if (message.type === 'analyzeData') { - if (chatView) { - await chatView.handleAnalyzeData(message.data, message.query, message.rowCount); - } - return; - } - if (message.type === 'optimizeQuery') { - if (chatView) { - await chatView.handleOptimizeQuery(message.query, message.executionTime); - } - return; - } - if (message.type === 'sendToChat') { - if (chatView) { - await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); - await chatView.sendToChat(message.data); - } - return; - } - - if (message.type === 'showExplainPlan') { - ExplainProvider.show(context.extensionUri, message.plan, message.query); - return; - } - - if (message.type === 'convertExplainToJson') { - // Convert text EXPLAIN to FORMAT JSON and show visual plan - const originalQuery = message.query; - - if (!originalQuery) { - vscode.window.showErrorMessage('No query available to convert'); - return; - } - - // Extract the actual query from EXPLAIN statement - const explainMatch = originalQuery.match(/^\s*EXPLAIN\s*(?:\([^)]*\))?\s*(.+)$/is); - const innerQuery = explainMatch ? explainMatch[1].trim() : originalQuery; - - // Create new query with FORMAT JSON - const jsonQuery = `EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS, VERBOSE)\n${innerQuery}`; - - // Execute and show plan - try { - const metadata = notebook.metadata as PostgresMetadata; - - // Get connection config from workspace settings - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - const connection = connections.find(c => c.id === metadata.connectionId); - - if (!connection) { - vscode.window.showErrorMessage('No active database connection'); - return; - } - - const password = await SecretStorageService.getInstance().getPassword(metadata.connectionId); - if (!password && connection.authMode === 'password') { - vscode.window.showErrorMessage('Password not found for connection'); - return; - } - - // Show progress - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: 'Converting EXPLAIN to JSON format...', - cancellable: false - }, async () => { - const { Pool } = await import('pg'); - const client = new Pool({ - host: connection.host, - port: connection.port, - user: connection.username, - password: password || undefined, - database: metadata.databaseName, - ssl: connection.ssl ? { rejectUnauthorized: false } : false - }); - - const result = await client.query(jsonQuery); - await client.end(); - - if (result.rows?.length) { - const planCell = result.rows[0]['QUERY PLAN'] ?? result.rows[0]['query_plan']; - if (planCell) { - const explainPlan = typeof planCell === 'string' ? JSON.parse(planCell) : planCell; - ExplainProvider.show(context.extensionUri, explainPlan, innerQuery); - } else { - vscode.window.showErrorMessage('No plan data returned from query'); - } - } else { - vscode.window.showErrorMessage('No results returned from EXPLAIN query'); - } - }); - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to convert EXPLAIN query: ${error.message}`); - console.error('EXPLAIN conversion error:', error); - } - return; - } - - if (message.type === 'showConnectionSwitcher') { - const metadata = notebook.metadata as PostgresMetadata; - const selected = await ConnectionUtils.showConnectionPicker(message.connectionId); - - if (selected && selected.id !== message.connectionId) { - await ConnectionUtils.updateNotebookMetadata(notebook, { - connectionId: selected.id, - databaseName: selected.database, - host: selected.host, - port: selected.port, - username: selected.username - }); - vscode.window.showInformationMessage(`Switched to: ${selected.name || selected.host}`); - statusBar.update(); - } - return; - } - - if (message.type === 'showDatabaseSwitcher') { - const metadata = notebook.metadata as PostgresMetadata; - const connection = ConnectionUtils.findConnection(message.connectionId); - - if (!connection) { - vscode.window.showErrorMessage('Connection not found'); - return; - } - - const selectedDb = await ConnectionUtils.showDatabasePicker(connection, message.currentDatabase); - - if (selectedDb && selectedDb !== message.currentDatabase) { - await ConnectionUtils.updateNotebookMetadata(notebook, { databaseName: selectedDb }); - vscode.window.showInformationMessage(`Switched to database: ${selectedDb}`); - statusBar.update(); - } - return; - } - - - if (message.type === 'execute_update_background') { - const { statements } = message; - let client; - try { - const metadata = notebook.metadata as PostgresMetadata; - if (!metadata?.connectionId) { - await ErrorHandlers.handleCommandError(new Error('No connection in notebook metadata'), 'background update'); - return; - } + // Register Message Handlers + const registry = MessageHandlerRegistry.getInstance(); + + // Explain & Chat Handlers + registry.register('explainError', new ExplainErrorHandler(chatView)); + registry.register('fixQuery', new FixQueryHandler(chatView)); + registry.register('analyzeData', new AnalyzeDataHandler(chatView)); + registry.register('optimizeQuery', new OptimizeQueryHandler(chatView)); + registry.register('sendToChat', new SendToChatHandler(chatView)); + registry.register('showExplainPlan', new ShowExplainPlanHandler(context.extensionUri)); + registry.register('convertExplainToJson', new ConvertExplainHandler(context)); + + // Core Handlers + registry.register('showConnectionSwitcher', new ShowConnectionSwitcherHandler(statusBar)); + registry.register('showDatabaseSwitcher', new ShowDatabaseSwitcherHandler(statusBar)); + registry.register('showErrorMessage', new ShowErrorMessageHandler()); + registry.register('export_request', new ExportRequestHandler()); + + // Query Execution Handlers + registry.register('execute_update_background', new ExecuteUpdateBackgroundHandler()); + registry.register('script_delete', new ScriptDeleteHandler()); + registry.register('saveChanges', new SaveChangesHandler()); - // Use ConnectionManager to get a pooled client (handles SSL, SSH, etc.) - const connectionConfig = { - id: metadata.connectionId, - name: metadata.host, // fallback name - host: metadata.host, - port: metadata.port, - username: metadata.username, - database: metadata.databaseName - }; - - client = await ConnectionManager.getInstance().getPooledClient(connectionConfig); - - // No need to connect(), pooled client is already connected - - let successCount = 0; - let errorCount = 0; - for (const stmt of statements) { - try { - await client.query(stmt); - successCount++; - } catch (err: any) { - errorCount++; - await ErrorHandlers.handleCommandError(err, 'update statement'); - } - } - - if (successCount > 0) { - vscode.window.showInformationMessage(`Successfully updated ${successCount} row(s)${errorCount > 0 ? `, ${errorCount} failed` : ''}`); - } - } catch (err: any) { - await ErrorHandlers.handleCommandError(err, 'background updates'); - } finally { - if (client) client.release(); - } - } else if (message.type === 'script_delete') { - const { schema, table, primaryKeys, rows, cellIndex } = message; - - try { - // Construct DELETE query - let query = ''; - for (const row of rows) { - const conditions: string[] = []; - - for (const pk of primaryKeys) { - const val = row[pk]; - const valStr = typeof val === 'string' ? `'${val.replace(/'/g, "''")}'` : val; - conditions.push(`"${pk}" = ${valStr}`); - } - query += `DELETE FROM "${schema}"."${table}" WHERE ${conditions.join(' AND ')};\n`; - } - - // Insert new cell with the query - const targetIndex = cellIndex + 1; - const newCell = new vscode.NotebookCellData( - vscode.NotebookCellKind.Code, - query, - 'sql' - ); - - const edit = new vscode.NotebookEdit( - new vscode.NotebookRange(targetIndex, targetIndex), - [newCell] - ); - - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.set(notebook.uri, [edit]); - await vscode.workspace.applyEdit(workspaceEdit); - } catch (err: any) { - await ErrorHandlers.handleCommandError(err, 'generate delete script'); - } - } else if (message.type === 'saveChanges') { - // Handle saveChanges from renderer - const { updates, deletions, tableInfo } = message; - const { schema, table } = tableInfo; - let client; - - try { - const metadata = notebook.metadata as PostgresMetadata; - if (!metadata?.connectionId) { - vscode.window.showErrorMessage('Cannot save changes: No connection in notebook metadata'); - return; - } - - // Use ConnectionManager to get a pooled client - const connectionConfig = { - id: metadata.connectionId, - name: metadata.host, - host: metadata.host, - port: metadata.port, - username: metadata.username, - database: metadata.databaseName - }; - - client = await ConnectionManager.getInstance().getPooledClient(connectionConfig); - - let successCount = 0; - let errorCount = 0; - - for (const update of updates) { - const { keys, column, value } = update; - - // Format value for SQL - let valueStr = 'NULL'; - if (value !== null && value !== undefined) { - if (typeof value === 'boolean') { - valueStr = value ? 'TRUE' : 'FALSE'; - } else if (typeof value === 'number') { - valueStr = String(value); - } else if (typeof value === 'object') { - valueStr = `'${JSON.stringify(value).replace(/'/g, "''")}'`; - } else { - valueStr = `'${String(value).replace(/'/g, "''")}'`; - } - } - - // Format conditions - const conditions: string[] = []; - for (const [pk, pkVal] of Object.entries(keys)) { - let pkValStr = 'NULL'; - if (pkVal !== null && pkVal !== undefined) { - if (typeof pkVal === 'number' || typeof pkVal === 'boolean') { - pkValStr = String(pkVal); - } else { - pkValStr = `'${String(pkVal).replace(/'/g, "''")}'`; - } - } - conditions.push(`"${pk}" = ${pkValStr}`); - } - - const query = `UPDATE "${schema}"."${table}" SET "${column}" = ${valueStr} WHERE ${conditions.join(' AND ')}`; - - try { - await client.query(query); - successCount++; - } catch (err: any) { - errorCount++; - console.error('Update failed:', query, err); - } - } - - // Process DELETE queries - let deletedCount = 0; - for (const deletion of deletions || []) { - const { keys } = deletion; - - // Build WHERE clause - const conditions: string[] = []; - for (const [pk, pkVal] of Object.entries(keys)) { - let pkValStr = 'NULL'; - if (pkVal !== null && pkVal !== undefined) { - if (typeof pkVal === 'number' || typeof pkVal === 'boolean') { - pkValStr = String(pkVal); - } else { - pkValStr = `'${String(pkVal).replace(/'/g, "''")}'`; - } - } - conditions.push(`"${pk}" = ${pkValStr}`); - } - - const query = `DELETE FROM "${schema}"."${table}" WHERE ${conditions.join(' AND ')}`; - - try { - await client.query(query); - deletedCount++; - successCount++; - } catch (err: any) { - errorCount++; - console.error('Delete failed:', query, err); - } - } - - if (successCount > 0) { - const parts = []; - const updateCount = (updates?.length || 0); - if (updateCount > 0) parts.push(`${updateCount} edit(s)`); - if (deletedCount > 0) parts.push(`${deletedCount} deletion(s)`); - - vscode.window.showInformationMessage(`✅ Successfully saved ${parts.join(', ')}${errorCount > 0 ? `, ${errorCount} failed` : ''}`); - // Notify renderer to clear modified cells and remove deleted rows - rendererMessaging.postMessage({ type: 'saveSuccess', successCount, errorCount, deletedCount }, event.editor); - } else if (errorCount > 0) { - vscode.window.showErrorMessage(`Failed to save changes: ${errorCount} error(s)`); - } - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to save changes: ${err.message}`); - } finally { - if (client) client.release(); - } - } else if (message.type === 'showErrorMessage') { - vscode.window.showErrorMessage(message.message); - } + rendererMessaging.onDidReceiveMessage(async (event) => { + await registry.handleMessage(event.message, { + editor: event.editor, + postMessage: (msg) => rendererMessaging.postMessage(msg, event.editor) + }); }); const { migrateExistingPasswords } = await import('./services/SecretStorageService'); @@ -442,7 +127,7 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { outputChannel?.appendLine('Deactivating PgStudio extension - closing all connections'); - + try { // Close all database connections (pools and sessions) await ConnectionManager.getInstance().closeAll(); @@ -451,6 +136,6 @@ export async function deactivate() { outputChannel?.appendLine(`Error closing connections during deactivation: ${err}`); console.error('Error during extension deactivation:', err); } - + outputChannel?.appendLine('PgStudio extension deactivated'); } diff --git a/src/providers/AiCodeLensProvider.ts b/src/providers/AiCodeLensProvider.ts deleted file mode 100644 index 5fd8ddd..0000000 --- a/src/providers/AiCodeLensProvider.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as vscode from 'vscode'; - -export class AiCodeLensProvider implements vscode.CodeLensProvider { - provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): vscode.CodeLens[] { - // Only provide CodeLens for PostgreSQL/SQL notebook cells - if (document.languageId !== 'postgres' && document.languageId !== 'sql') { - return []; - } - - const range = new vscode.Range(0, 0, 0, 0); - - const askAiCommand: vscode.Command = { - title: '$(sparkle) Ask AI', - tooltip: 'Ask AI to modify this query', - command: 'postgres-explorer.aiAssist', - arguments: [] - }; - - const chatCommand: vscode.Command = { - title: '$(comment-discussion) Chat', - tooltip: 'Open SQL Assistant chat with this query', - command: 'postgres-explorer.chatWithQuery', - arguments: [] - }; - - return [ - new vscode.CodeLens(range, askAiCommand), - new vscode.CodeLens(range, chatCommand) - ]; - } -} diff --git a/src/providers/ChatViewProvider.ts b/src/providers/ChatViewProvider.ts index 36f23ae..b4a84af 100644 --- a/src/providers/ChatViewProvider.ts +++ b/src/providers/ChatViewProvider.ts @@ -274,8 +274,8 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { await this._handleGetAllDbObjects(); break; case 'getDbHierarchy': - await this._handleGetDbHierarchy(data.path); - break; + await this._handleGetDbHierarchy(data.path); + break; case 'openAiSettings': vscode.commands.executeCommand('postgres-explorer.aiSettings'); break; @@ -303,7 +303,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { console.log('[ChatView] Mention details:', JSON.stringify(mentions, null, 2)); } - // Build message with attachments // Build message with attachments // For display (history), we only show links/names to keep UI clean let fullMessage = message; @@ -328,7 +327,6 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { aiMessage = message + attachmentContent; } - // Process @ mentions - add schema context for AI // Process @ mentions - add schema context for AI // aiMessage already has attachments, now add schema context if (mentions && mentions.length > 0) { @@ -506,34 +504,34 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { } private async _handleGetDbHierarchy(path: any): Promise { - try { - let items: DbObject[] = []; - - if (!path || !path.connectionId) { - items = await this._dbObjectService.getConnections(); - } else if (!path.database) { - items = await this._dbObjectService.getDatabases(path.connectionId); - } else if (!path.schema) { - items = await this._dbObjectService.getSchemas(path.connectionId, path.database); - } else { - items = await this._dbObjectService.getSchemaObjects(path.connectionId, path.database, path.schema); - } - - this._view?.webview.postMessage({ - type: 'dbHierarchyData', - path: path, - items: items - }); + try { + let items: DbObject[] = []; + + if (!path || !path.connectionId) { + items = await this._dbObjectService.getConnections(); + } else if (!path.database) { + items = await this._dbObjectService.getDatabases(path.connectionId); + } else if (!path.schema) { + items = await this._dbObjectService.getSchemas(path.connectionId, path.database); + } else { + items = await this._dbObjectService.getSchemaObjects(path.connectionId, path.database, path.schema); + } + + this._view?.webview.postMessage({ + type: 'dbHierarchyData', + path: path, + items: items + }); - } catch (error) { - console.error('Error fetching hierarchy:', error); - this._view?.webview.postMessage({ - type: 'dbHierarchyData', - path: path, - items: [], - error: 'Failed to load database objects' - }); - } + } catch (error) { + console.error('Error fetching hierarchy:', error); + this._view?.webview.postMessage({ + type: 'dbHierarchyData', + path: path, + items: [], + error: 'Failed to load database objects' + }); + } } // ==================== File Handling ==================== diff --git a/src/providers/NotebookKernel.ts b/src/providers/NotebookKernel.ts index 15b50bd..e460966 100644 --- a/src/providers/NotebookKernel.ts +++ b/src/providers/NotebookKernel.ts @@ -1,11 +1,18 @@ - import * as vscode from 'vscode'; -import { PostgresMetadata } from '../common/types'; -import { ConnectionManager } from '../services/ConnectionManager'; -import { ConnectionUtils } from '../utils/connectionUtils'; import { CompletionProvider } from './kernel/CompletionProvider'; import { SqlExecutor } from './kernel/SqlExecutor'; -import { getTransactionManager, IsolationLevel } from '../services/TransactionManager'; +import { getTransactionManager } from '../services/TransactionManager'; +import { MessageHandlerRegistry } from '../services/MessageHandler'; +import { + TransactionBeginHandler, TransactionCommitHandler, TransactionRollbackHandler, + SavepointCreateHandler, SavepointReleaseHandler, SavepointRollbackHandler +} from '../services/handlers/TransactionHandlers'; +import { + ExecuteUpdateBackgroundHandler, ScriptDeleteHandler, ExecuteUpdateHandler, + CancelQueryHandler, DeleteRowsHandler, SaveChangesHandler +} from '../services/handlers/QueryHandlers'; +import { ExportRequestHandler, ShowErrorMessageHandler } from '../services/handlers/CoreHandlers'; +import { SendToChatHandler } from '../services/handlers/ExplainHandlers'; export class PostgresKernel implements vscode.Disposable { readonly id = 'postgres-kernel'; @@ -15,7 +22,12 @@ export class PostgresKernel implements vscode.Disposable { private readonly _controller: vscode.NotebookController; private readonly _executor: SqlExecutor; - constructor(private readonly context: vscode.ExtensionContext, viewType: string = 'postgres-notebook', messageHandler?: (message: any) => void) { + constructor( + private readonly context: vscode.ExtensionContext, + private readonly messaging: vscode.NotebookRendererMessaging, + viewType: string = 'postgres-notebook', + messageHandler?: (message: any) => void + ) { this._controller = vscode.notebooks.createNotebookController( this.id + '-' + viewType, viewType, @@ -39,9 +51,35 @@ export class PostgresKernel implements vscode.Disposable { ); // Handle messages from renderer + const registry = MessageHandlerRegistry.getInstance(); + + // Register Handlers + registry.register('transaction_begin', new TransactionBeginHandler()); + registry.register('transaction_commit', new TransactionCommitHandler()); + registry.register('transaction_rollback', new TransactionRollbackHandler()); + registry.register('savepoint_create', new SavepointCreateHandler()); + registry.register('savepoint_release', new SavepointReleaseHandler()); + registry.register('savepoint_rollback', new SavepointRollbackHandler()); + + registry.register('cancel_query', new CancelQueryHandler()); + registry.register('execute_update_background', new ExecuteUpdateBackgroundHandler()); + registry.register('script_delete', new ScriptDeleteHandler()); + registry.register('execute_update', new ExecuteUpdateHandler()); + registry.register('export_request', new ExportRequestHandler()); + registry.register('delete_row', new DeleteRowsHandler()); + registry.register('delete_rows', new DeleteRowsHandler()); + registry.register('sendToChat', new SendToChatHandler(undefined)); + + registry.register('saveChanges', new SaveChangesHandler()); + registry.register('showErrorMessage', new ShowErrorMessageHandler()); + (this._controller as any).onDidReceiveMessage(async (event: any) => { - console.log('[NotebookKernel] onDidReceiveMessage triggered, event:', event); - this.handleMessage(event); + // console.log('[NotebookKernel] onDidReceiveMessage', event.message.type); + await registry.handleMessage(event.message, { + editor: event.editor, + executor: this._executor, + postMessage: (msg) => this.messaging.postMessage(msg, event.editor) + }); }); } @@ -51,391 +89,9 @@ export class PostgresKernel implements vscode.Disposable { } } - private async handleMessage(event: any) { - const { type } = event.message; - console.log(`[NotebookKernel] handleMessage: Received message type: ${type}`); - console.log(`[NotebookKernel] handleMessage: Full event.message:`, event.message); - - // Transaction management commands - if (type === 'transaction_begin') { - console.log('[NotebookKernel] Handling transaction_begin'); - await this.handleTransactionBegin(event); - } else if (type === 'transaction_commit') { - console.log('[NotebookKernel] Handling transaction_commit'); - await this.handleTransactionCommit(event); - } else if (type === 'transaction_rollback') { - console.log('[NotebookKernel] Handling transaction_rollback'); - await this.handleTransactionRollback(event); - } else if (type === 'savepoint_create') { - console.log('[NotebookKernel] Handling savepoint_create'); - await this.handleSavepointCreate(event); - } else if (type === 'savepoint_release') { - console.log('[NotebookKernel] Handling savepoint_release'); - await this.handleSavepointRelease(event); - } else if (type === 'savepoint_rollback') { - console.log('[NotebookKernel] Handling savepoint_rollback'); - await this.handleSavepointRollback(event); - } else if (type === 'cancel_query') { - console.log('[NotebookKernel] Handling cancel_query'); - await this._executor.cancelQuery(event.message); - } else if (type === 'execute_update_background') { - console.log('[NotebookKernel] Handling execute_update_background'); - await this._executor.executeBackgroundUpdate(event.message, event.editor.notebook); - } else if (type === 'script_delete') { - console.log('[NotebookKernel] Handling script_delete'); - await this.handleScriptDelete(event); - } else if (type === 'execute_update') { - console.log('[NotebookKernel] Handling execute_update'); - await this.handleExecuteUpdate(event); - } else if (type === 'export_request') { - console.log('[NotebookKernel] Handling export_request'); - await this.handleExportRequest(event); - } else if (type === 'delete_row' || type === 'delete_rows') { - console.log('[NotebookKernel] Handling delete_row/delete_rows'); - await this.handleDeleteRows(event); - } else if (type === 'sendToChat') { - console.log('[NotebookKernel] Handling sendToChat'); - const { data } = event.message; - await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); - await vscode.commands.executeCommand('postgres-explorer.sendToChat', data); - } else if (type === 'saveChanges') { - console.log('[NotebookKernel] Handling saveChanges'); - await this.handleSaveChanges(event); - } else if (type === 'showErrorMessage') { - console.log('[NotebookKernel] Handling showErrorMessage'); - vscode.window.showErrorMessage(event.message.message); - } else { - console.log(`[NotebookKernel] Unknown message type: ${type}`); - } - } - - private async handleSaveChanges(event: any) { - console.log('NotebookKernel: handleSaveChanges called'); - const { updates, tableInfo } = event.message; - console.log('NotebookKernel: Updates received:', JSON.stringify(updates)); - console.log('NotebookKernel: TableInfo:', JSON.stringify(tableInfo)); - - const { schema, table } = tableInfo; - const statements: string[] = []; - - for (const update of updates) { - const { keys, column, value } = update; - - // Format value for SQL - let valueStr = 'NULL'; - if (value !== null && value !== undefined) { - if (typeof value === 'boolean') { - valueStr = value ? 'TRUE' : 'FALSE'; - } else if (typeof value === 'number') { - valueStr = String(value); - } else if (typeof value === 'object') { - valueStr = `'${JSON.stringify(value).replace(/'/g, "''")}'`; - } else { - valueStr = `'${String(value).replace(/'/g, "''")}'`; - } - } - - // Format conditions - const conditions: string[] = []; - for (const [pk, pkVal] of Object.entries(keys)) { - let pkValStr = 'NULL'; - if (pkVal !== null && pkVal !== undefined) { - if (typeof pkVal === 'number' || typeof pkVal === 'boolean') { - pkValStr = String(pkVal); - } else { - pkValStr = `'${String(pkVal).replace(/'/g, "''")}'`; - } - } - conditions.push(`"${pk}" = ${pkValStr}`); - } - - const query = `UPDATE "${schema}"."${table}" SET "${column}" = ${valueStr} WHERE ${conditions.join(' AND ')};`; - console.log('NotebookKernel: Generated query:', query); - statements.push(query); - } - - if (statements.length === 0) { - console.warn('NotebookKernel: No statements generated'); - return; - } - - // Reuse existing background update executor - await this._executor.executeBackgroundUpdate({ statements }, event.editor.notebook); - } - - // --- Lightweight Message Handlers that don't need heavy services --- - - private async handleScriptDelete(event: any) { - const { schema, table, primaryKeys, rows, cellIndex } = event.message; - const notebook = event.editor.notebook; - try { - // Construct DELETE query - let query = ''; - for (const row of rows) { - const conditions: string[] = []; - for (const pk of primaryKeys) { - const val = row[pk]; - const valStr = typeof val === 'string' ? `'${val.replace(/'/g, "''")}'` : val; - conditions.push(`"${pk}" = ${valStr}`); - } - query += `DELETE FROM "${schema}"."${table}" WHERE ${conditions.join(' AND ')};\n`; - } - - this.insertCell(notebook, cellIndex + 1, query); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to generate delete script: ${err.message}`); - } - } - - private async handleExecuteUpdate(event: any) { - const { statements, cellIndex } = event.message; - const notebook = event.editor.notebook; - try { - const query = statements.join('\n'); - this.insertCell(notebook, cellIndex + 1, `-- Update statements generated\n${query}`); - vscode.window.showInformationMessage(`Generated ${statements.length} UPDATE statement(s).`); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to generate update script: ${err.message}`); - } - } - - private async insertCell(notebook: vscode.NotebookDocument, index: number, content: string) { - const newCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, content, 'sql'); - const edit = new vscode.NotebookEdit(new vscode.NotebookRange(index, index), [newCell]); - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.set(notebook.uri, [edit]); - await vscode.workspace.applyEdit(workspaceEdit); - } - - private async handleExportRequest(event: any) { - const { rows: displayRows, columns, query: originalQuery } = event.message; - // ... (Keep existing simple export logic here for now, or move to ResultFormatter if it grows) - - // For this refactor, let's keep the existing logic but compacted. - const selection = await vscode.window.showQuickPick(['Save as CSV', 'Save as JSON', 'Copy to Clipboard']); - if (!selection) return; - - // ... (Use displayRows for now) - - const rowsToExport = displayRows; // Simplified to just use displayed rows for this refactor step - - if (selection === 'Copy to Clipboard') { - const csv = this.rowsToCsv(rowsToExport, columns); - await vscode.env.clipboard.writeText(csv); - vscode.window.showInformationMessage('Copied to clipboard'); - } else if (selection === 'Save as CSV') { - const csv = this.rowsToCsv(rowsToExport, columns); - const uri = await vscode.window.showSaveDialog({ filters: { 'CSV': ['csv'] } }); - if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(csv)); - } else if (selection === 'Save as JSON') { - const json = JSON.stringify(rowsToExport, null, 2); - const uri = await vscode.window.showSaveDialog({ filters: { 'JSON': ['json'] } }); - if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(json)); - } - } - - private rowsToCsv(rows: any[], columns: string[]): string { - const header = columns.map(c => `"${c.replace(/"/g, '""')}"`).join(','); - const body = rows.map(row => columns.map(col => { - const val = row[col]; - const str = String(val ?? ''); - return str.includes(',') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str; - }).join(',')).join('\n'); - return `${header}\n${body}`; - } - - private async handleDeleteRows(event: any) { - console.log('[NotebookKernel] handleDeleteRows called, event.message:', event.message); - const { tableInfo, rows, row } = event.message; // Support both 'rows' (array) and legacy 'row' (single) - const targets = rows || (row ? [row] : []); - console.log('[NotebookKernel] targets:', targets); - - if (targets.length === 0) return; - - const { schema, table, primaryKeys } = tableInfo || event.message; // Support legacy payload structure if needed - console.log('[NotebookKernel] schema:', schema, 'table:', table, 'primaryKeys:', primaryKeys); - - if (!primaryKeys || primaryKeys.length === 0) { - vscode.window.showErrorMessage('Cannot delete: No primary keys defined for this table.'); - return; - } - - const notebook = event.editor.notebook; - const metadata = notebook.metadata as PostgresMetadata; - if (!metadata?.connectionId) return; - - try { - const connection = ConnectionUtils.findConnection(metadata.connectionId); - if (!connection) throw new Error('Connection not found'); - - // Use ConnectionManager with correct database from metadata - const config = { - ...connection, - database: metadata.databaseName || connection.database - }; - - const client = await ConnectionManager.getInstance().getSessionClient(config, notebook.uri.toString()); - - // Batch delete matching PKs - // DELETE FROM table WHERE (pk1, pk2) IN ((v1, v2), (v3, v4)) - // Constructing a safe parameterized query - - // Flatten all values for parameters - const allValues: any[] = []; - const rowConditions: string[] = []; - - let paramIndex = 1; - - for (const targetRow of targets) { - const conditions: string[] = []; - for (const pk of primaryKeys) { - conditions.push(`$${paramIndex++}`); - allValues.push(targetRow[pk]); - } - if (primaryKeys.length > 1) { - rowConditions.push(`(${conditions.join(', ')})`); - } else { - rowConditions.push(conditions[0]); - } - } - - const pkCols = primaryKeys.map((pk: string) => `"${pk}"`).join(', '); - const whereClause = primaryKeys.length > 1 - ? `(${pkCols}) IN (${rowConditions.join(', ')})` - : `${pkCols} IN (${rowConditions.join(', ')})`; - - const query = `DELETE FROM "${schema}"."${table}" WHERE ${whereClause}`; - console.log('[NotebookKernel] Executing query:', query); - console.log('[NotebookKernel] Query params:', allValues); - - const result = await client.query(query, allValues); - - vscode.window.showInformationMessage(`Deleted ${result.rowCount} row(s) from ${schema}.${table}`); - console.log('[NotebookKernel] Delete successful, rowCount:', result.rowCount); - - // Re-execute the cell to refresh the data - const cell = event.editor.document; - if (cell) { - console.log('[NotebookKernel] Re-executing cell to refresh data'); - await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [{ start: cell.index, end: cell.index + 1 }] }); - } - - } catch (err: any) { - console.error('[NotebookKernel] Delete failed:', err); - vscode.window.showErrorMessage(`Failed to delete rows: ${err.message}`); - } - } - - private async getSessionClient(notebook: vscode.NotebookDocument): Promise { - const metadata = notebook.metadata as PostgresMetadata; - if (!metadata?.connectionId) throw new Error('No connection found'); - - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - const connection = connections.find(c => c.id === metadata.connectionId); - if (!connection) throw new Error('Connection not found'); - - return await ConnectionManager.getInstance().getSessionClient({ - id: connection.id, - host: connection.host, - port: connection.port, - username: connection.username, - database: metadata.databaseName || connection.database, - name: connection.name - }, notebook.uri.toString()); - } - - private async handleTransactionBegin(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - const { isolationLevel = 'READ COMMITTED', readOnly = false, deferrable = false } = event.message; - - await txManager.beginTransaction(client, sessionId, isolationLevel as IsolationLevel, readOnly, deferrable); - - const summary = txManager.getTransactionSummary(sessionId); - vscode.window.showInformationMessage(summary); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to begin transaction: ${err.message}`); - } - } - - private async handleTransactionCommit(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - - await txManager.commitTransaction(client, sessionId); - vscode.window.showInformationMessage('✅ Transaction committed'); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to commit transaction: ${err.message}`); - } - } - - private async handleTransactionRollback(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - - await txManager.rollbackTransaction(client, sessionId); - vscode.window.showInformationMessage('âŽī¸ Transaction rolled back'); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to rollback transaction: ${err.message}`); - } - } - - private async handleSavepointCreate(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - - const savepointName = await txManager.createSavepoint(client, sessionId); - vscode.window.showInformationMessage(`📍 Savepoint created: ${savepointName}`); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to create savepoint: ${err.message}`); - } - } - - private async handleSavepointRelease(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - const { savepointName } = event.message; - - await txManager.releaseSavepoint(client, sessionId, savepointName); - vscode.window.showInformationMessage(`✓ Savepoint released: ${savepointName || 'latest'}`); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to release savepoint: ${err.message}`); - } - } - - private async handleSavepointRollback(event: any) { - try { - const notebook = event.editor.notebook; - const client = await this.getSessionClient(notebook); - const sessionId = notebook.uri.toString(); - const txManager = getTransactionManager(); - const { savepointName } = event.message; - - await txManager.rollbackToSavepoint(client, sessionId, savepointName); - vscode.window.showInformationMessage(`âŽī¸ Rolled back to savepoint: ${savepointName || 'latest'}`); - } catch (err: any) { - vscode.window.showErrorMessage(`Failed to rollback savepoint: ${err.message}`); - } - } - dispose() { - const txManager = getTransactionManager(); - // Cleanup will happen on extension deactivation + // getTransactionManager() call kept for consistency with previous code if it has side effects, though it seems unused. + getTransactionManager(); this._controller.dispose(); } } diff --git a/src/providers/QueryCodeLensProvider.ts b/src/providers/QueryCodeLensProvider.ts index d8fcd7b..13d8f9d 100644 --- a/src/providers/QueryCodeLensProvider.ts +++ b/src/providers/QueryCodeLensProvider.ts @@ -23,7 +23,7 @@ export class QueryCodeLensProvider implements vscode.CodeLensProvider { } const text = document.getText().trim(); - + // Don't show CodeLens for empty cells if (!text) { return []; @@ -35,20 +35,41 @@ export class QueryCodeLensProvider implements vscode.CodeLensProvider { const codeLenses: vscode.CodeLens[] = []; const range = new vscode.Range(0, 0, 0, 0); + // 1. Ask AI + codeLenses.push( + new vscode.CodeLens(range, { + title: '$(sparkle) Ask AI ', + tooltip: 'Ask AI to modify this query', + command: 'postgres-explorer.aiAssist', + arguments: [] + }) + ); + + // 2. Chat + codeLenses.push( + new vscode.CodeLens(range, { + title: '$(comment-discussion) Chat ', + tooltip: 'Open SQL Assistant chat with this query', + command: 'postgres-explorer.chatWithQuery', + arguments: [] + }) + ); + + // 3. Save Query (Always available) + codeLenses.push( + new vscode.CodeLens(range, { + title: '$(save) Save Query ', + tooltip: 'Save this query to the library for easy reuse', + command: 'postgres-explorer.saveQueryToLibraryUI' + }) + ); + // Show EXPLAIN options for any query that isn't already EXPLAIN if (!isExplainQuery) { + // 4. EXPLAIN ANALYZE codeLenses.push( new vscode.CodeLens(range, { - title: '$(graph) EXPLAIN', - tooltip: 'Show query execution plan without running the query', - command: 'postgres-explorer.explainQuery', - arguments: [document.uri, false] - }) - ); - - codeLenses.push( - new vscode.CodeLens(range, { - title: '$(telescope) EXPLAIN ANALYZE', + title: '$(telescope) Explain Analyze', tooltip: 'Show query execution plan with actual runtime statistics', command: 'postgres-explorer.explainQuery', arguments: [document.uri, true] @@ -56,15 +77,6 @@ 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/chat/index.ts b/src/providers/chat/index.ts index 7ac40d8..8d50fbf 100644 --- a/src/providers/chat/index.ts +++ b/src/providers/chat/index.ts @@ -1,8 +1,5 @@ -/** - * Chat module exports - */ export * from './types'; -export { DbObjectService } from './DbObjectService'; -export { AiService } from './AiService'; -export { SessionService } from './SessionService'; -export { getWebviewHtml } from './webviewHtml'; +export * from './DbObjectService'; +export * from './AiService'; +export * from './SessionService'; +export * from './webviewHtml'; diff --git a/src/providers/kernel/SqlExecutor.ts b/src/providers/kernel/SqlExecutor.ts index 49c8a06..5be4949 100644 --- a/src/providers/kernel/SqlExecutor.ts +++ b/src/providers/kernel/SqlExecutor.ts @@ -9,6 +9,7 @@ import { ErrorService } from '../../services/ErrorService'; import { QueryHistoryService } from '../../services/QueryHistoryService'; import { getTransactionManager } from '../../services/TransactionManager'; import { QueryAnalyzer } from '../../services/QueryAnalyzer'; +import { QueryPerformanceService } from '../../services/QueryPerformanceService'; import { extensionContext } from '../../extension'; export class SqlExecutor { @@ -21,7 +22,7 @@ export class SqlExecutor { private applyAutoLimit(query: string, connection: any, notebookMetadata?: any, profileContext?: any): string { // Check profile-level auto-limit first (takes precedence) let limit: number | null = null; - + // Try profile context first, then metadata if (profileContext?.autoLimitSelectResults !== undefined && profileContext.autoLimitSelectResults > 0) { limit = profileContext.autoLimitSelectResults; @@ -31,7 +32,7 @@ export class SqlExecutor { // 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); @@ -45,7 +46,10 @@ export class SqlExecutor { // Only apply to SELECT queries const trimmed = query.trim(); - if (!/^\s*SELECT/i.test(trimmed)) { + // Strip comments to reliably detect SELECT + const cleanQuery = query.replace(/--.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim(); + + if (!/^\s*SELECT/i.test(cleanQuery)) { return query; } @@ -92,7 +96,7 @@ export class SqlExecutor { if (metadata.readOnlyMode !== undefined) { connection.readOnlyMode = metadata.readOnlyMode; } - + // Apply profile settings from globalState if available if (activeProfileContext) { if (activeProfileContext.readOnlyMode !== undefined) { @@ -159,7 +163,7 @@ export class SqlExecutor { const txManager = getTransactionManager(); const sessionId = cell.notebook.uri.toString(); const txInfo = txManager.getTransactionInfo(sessionId); - + if (!txInfo || !txInfo.isActive) { await client.query('BEGIN'); if (!txInfo) { @@ -192,23 +196,36 @@ export class SqlExecutor { }); result = await client.query(query); + + const stmtEndTime = Date.now(); const executionTime = (stmtEndTime - stmtStartTime) / 1000; + const durationMs = executionTime * 1000; - // Add notice if auto-LIMIT was applied - if (autoLimitApplied) { - const defaultLimit = vscode.workspace.getConfiguration() - .get('postgresExplorer.performance.defaultLimit', 1000); - notices.push(`â„šī¸ Auto-LIMIT applied: Result set limited to ${defaultLimit} rows`); - } + // ... (auto-limit notice) const success = true; const slowThresholdMs = vscode.workspace.getConfiguration().get('postgresExplorer.performance.slowQueryThresholdMs', 2000); - const durationMs = executionTime * 1000; const isSlow = durationMs >= slowThresholdMs; + // Performance Tracking + const queryAnalyzer = QueryAnalyzer.getInstance(); + const queryHash = queryAnalyzer.getQueryHash(query); + const performanceService = QueryPerformanceService.getInstance(); + + // Record this execution + // We record *before* fetching baseline for next time? + // Or fetch baseline *before* recording simple current execution? + // Logic: Compare against *historical* baseline (excluding current). + const baseline = performanceService.getBaseline(queryHash); + + // Async record (fire and forget) + performanceService.recordExecution(queryHash, durationMs).catch(err => console.error('Failed to record performance:', err)); + // Extract EXPLAIN (FORMAT JSON) plan if available let explainPlan: any | undefined; + let performanceAnalysis: any | undefined; + if (result.command === 'EXPLAIN' && result.rows?.length) { const planCell = result.rows[0]['QUERY PLAN'] ?? result.rows[0]['query_plan']; if (planCell) { @@ -220,6 +237,18 @@ export class SqlExecutor { } } + // Always analyze performance against baseline (even if no plan) + performanceAnalysis = queryAnalyzer.analyzePerformanceAgainstBaseline( + durationMs, + baseline, + explainPlan + ); + + console.log('[Performance] Hash:', queryHash); + console.log('[Performance] Baseline:', JSON.stringify(baseline)); + console.log('[Performance] Duration:', durationMs); + console.log('[Performance] Analysis:', JSON.stringify(performanceAnalysis)); + // Build output data const tableInfo = await this.getTableInfo(client, result, query); const outputData: QueryResults = { @@ -239,6 +268,7 @@ export class SqlExecutor { backendPid, tableInfo, explainPlan, + performanceAnalysis, // Pass analysis to frontend slowQuery: isSlow, breadcrumb: { connectionId: connection.id, @@ -410,8 +440,10 @@ export class SqlExecutor { } } - public async executeBackgroundUpdate(message: any, notebook: vscode.NotebookDocument) { - const { statements } = message; + public async executeBatch(batch: { text: string; params: any[] }[], notebook: vscode.NotebookDocument) { + let client: any = null; + let done: any = null; + try { const metadata = notebook.metadata as PostgresMetadata; if (!metadata?.connectionId) throw new Error('No connection found'); @@ -420,7 +452,19 @@ export class SqlExecutor { const connection = connections.find(c => c.id === metadata.connectionId); if (!connection) throw new Error('Connection not found'); - const client = await ConnectionManager.getInstance().getSessionClient({ + // We need a dedicated client for the transaction, not a pooled one that might be shared if we're not careful, + // though getSessionClient typically returns a pool client. + // For transactions, we must ensure we hold the client for the duration. + // ConnectionManager.getSessionClient returns a persistent client for the session (notebook). + // However, that client might be busy. + // Let's use getPooledClient directly to get a fresh client for this background operation, + // to avoid interfering with any running query in the notebook interface (though usually single-threaded there). + // ACTUALLY, sticking to getSessionClient is safer for consistency with the session's state if we had temp tables, + // but for updates, a fresh pooled client is often cleaner. + // Let's use getSessionClient as before to minimize connection usage, but we need to ensure we don't interleave. + // Since VS Code notebooks are generally serial, it's fine. + + client = await ConnectionManager.getInstance().getSessionClient({ id: connection.id, host: connection.host, port: connection.port, @@ -429,9 +473,23 @@ export class SqlExecutor { name: connection.name }, notebook.uri.toString()); - await client.query(statements.join('\n')); - vscode.window.showInformationMessage(`✅ Successfully saved ${statements.length} change(s).`); + await client.query('BEGIN'); + + for (const item of batch) { + await client.query(item.text, item.params); + } + + await client.query('COMMIT'); + vscode.window.showInformationMessage(`✅ Successfully saved ${batch.length} change(s).`); + } catch (err: any) { + if (client) { + try { + await client.query('ROLLBACK'); + } catch (rollbackErr) { + console.error('Failed to rollback transaction:', rollbackErr); + } + } await ErrorService.getInstance().handleCommandError(err, 'save changes'); } } diff --git a/src/renderer/components/ExplainVisualizer.ts b/src/renderer/components/ExplainVisualizer.ts new file mode 100644 index 0000000..1c39b34 --- /dev/null +++ b/src/renderer/components/ExplainVisualizer.ts @@ -0,0 +1,278 @@ + +export interface ExplainNode { + 'Node Type': string; + 'Total Cost': number; + 'Startup Cost': number; + 'Plan Rows': number; + 'Plan Width': number; + 'Actual Startup Time'?: number; + 'Actual Total Time'?: number; + 'Actual Rows'?: number; + 'Actual Loops'?: number; + Plans?: ExplainNode[]; + [key: string]: any; +} + +export class ExplainVisualizer { + private container: HTMLElement; + private plan: ExplainNode; + private maxCost: number = 0; + + constructor(container: HTMLElement, plan: any) { + this.container = container; + // Handle different plan formats (generic JSON w/ Plan key vs direct array) + this.plan = (plan.Plan || (Array.isArray(plan) ? plan[0]?.Plan : plan)) as ExplainNode; + this.calculateStats(); + } + + private calculateStats() { + this.maxCost = this.findMaxCost(this.plan); + } + + private findMaxCost(node: ExplainNode): number { + let max = node['Total Cost'] || 0; + if (node.Plans) { + for (const child of node.Plans) { + max = Math.max(max, this.findMaxCost(child)); + } + } + return max; + } + + public render() { + this.container.innerHTML = ''; + + // Styles + const style = document.createElement('style'); + style.textContent = ` + .explain-tree { + font-family: var(--vscode-editor-font-family); + font-size: 13px; + padding: 20px; + overflow: auto; + height: 100%; + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + } + .explain-node { + border: 1px solid var(--vscode-widget-border); + border-radius: 4px; + margin: 8px 0; + padding: 8px; + background: var(--vscode-editor-background); + position: relative; + transition: all 0.2s; + } + .explain-node:hover { + border-color: var(--vscode-focusBorder); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + .explain-node-header { + display: flex; + justify-content: space-between; + align-items: center; + font-weight: 600; + cursor: pointer; + } + .explain-node-type { + display: flex; + align-items: center; + gap: 8px; + } + .explain-node-stats { + display: flex; + gap: 12px; + font-size: 0.9em; + opacity: 0.8; + } + .explain-children { + margin-left: 24px; + border-left: 1px dashed var(--vscode-widget-border); + padding-left: 12px; + } + .explain-details { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--vscode-widget-border); + font-size: 0.9em; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 8px; + } + .explain-detail-item { + display: flex; + flex-direction: column; + } + .explain-label { + opacity: 0.6; + font-size: 0.85em; + } + .cost-bar { + height: 4px; + background: var(--vscode-progressBar-background); + margin-top: 4px; + border-radius: 2px; + opacity: 0.3; + } + .high-cost { + border-left: 4px solid var(--vscode-errorForeground); + } + .medium-cost { + border-left: 4px solid var(--vscode-charts-yellow); + } + .toggle-icon { + width: 16px; + text-align: center; + transition: transform 0.2s; + } + .explain-node.collapsed .explain-children, + .explain-node.collapsed .explain-details { + display: none; + } + .explain-node.collapsed .toggle-icon { + transform: rotate(-90deg); + } + .badge { + padding: 2px 6px; + border-radius: 3px; + font-size: 0.85em; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + } + `; + this.container.appendChild(style); + + const treeContainer = document.createElement('div'); + treeContainer.className = 'explain-tree'; + + if (this.plan) { + treeContainer.appendChild(this.createNodeElement(this.plan)); + } else { + treeContainer.textContent = 'No plan data available'; + } + + this.container.appendChild(treeContainer); + } + + private createNodeElement(node: ExplainNode): HTMLElement { + const el = document.createElement('div'); + el.className = 'explain-node'; + + // Heuristic for cost coloring + const costRatio = (node['Total Cost'] || 0) / (this.maxCost || 1); + if (costRatio > 0.5) el.classList.add('high-cost'); + else if (costRatio > 0.2) el.classList.add('medium-cost'); + + // Header + const header = document.createElement('div'); + header.className = 'explain-node-header'; + + const typeSection = document.createElement('div'); + typeSection.className = 'explain-node-type'; + + // Toggle + if (node.Plans && node.Plans.length > 0) { + const toggle = document.createElement('span'); + toggle.className = 'toggle-icon'; + toggle.textContent = 'â–ŧ'; + typeSection.appendChild(toggle); + + header.onclick = (e) => { + // Don't toggle if clicking specific actions if we add them later + el.classList.toggle('collapsed'); + e.stopPropagation(); + }; + } else { + typeSection.style.marginLeft = '16px'; + } + + const typeName = document.createElement('span'); + typeName.textContent = node['Node Type']; + typeSection.appendChild(typeName); + + // Add badges for specific things (e.g. Scan direction, Strategy) + if (node['Scan Direction'] === 'Backward') { + const b = document.createElement('span'); + b.className = 'badge'; + b.textContent = 'Backward'; + typeSection.appendChild(b); + } + + header.appendChild(typeSection); + + // Stats Summary + const stats = document.createElement('div'); + stats.className = 'explain-node-stats'; + + const actualTime = node['Actual Total Time']; + const totalCost = node['Total Cost']; + + if (actualTime !== undefined) { + stats.innerHTML = `âąī¸ ${actualTime.toFixed(2)}ms`; + } + stats.innerHTML += `💰 ${totalCost.toFixed(2)}`; + + // Rows mismatch warning + const planRows = node['Plan Rows']; + const actualRows = node['Actual Rows']; + if (actualRows !== undefined && planRows !== undefined) { + const misEst = Math.abs(actualRows - planRows) / (planRows || 1); + if (misEst > 10 && actualRows > 0) { // Off by 10x + stats.innerHTML += `âš ī¸ Bad Est.`; + } + } + + header.appendChild(stats); + el.appendChild(header); + + // Cost Bar + const bar = document.createElement('div'); + bar.className = 'cost-bar'; + bar.style.width = `${Math.min(100, costRatio * 100)}%`; + // Color logic + if (costRatio > 0.5) bar.style.backgroundColor = 'var(--vscode-errorForeground)'; + else if (costRatio > 0.2) bar.style.backgroundColor = 'var(--vscode-charts-yellow)'; + el.appendChild(bar); + + // Details Panel + const details = document.createElement('div'); + details.className = 'explain-details'; + + // Populate details + const importantKeys = ['Relation Name', 'Alias', 'Index Name', 'Hash Cond', 'Filter', 'Join Filter', 'Output']; + const ignoredKeys = ['Node Type', 'Plans', 'Total Cost', 'Startup Cost', 'Plan Rows', 'Plan Width', 'Actual Startup Time', 'Actual Total Time', 'Actual Rows', 'Actual Loops']; + + // Add standard stats first + const mkDetail = (label: string, val: any) => { + const d = document.createElement('div'); + d.className = 'explain-detail-item'; + d.innerHTML = `${label}${val}`; + return d; + }; + + details.appendChild(mkDetail('Cost', `${node['Startup Cost']} .. ${node['Total Cost']}`)); + details.appendChild(mkDetail('Rows', `${node['Plan Rows']} (Plan) / ${node['Actual Rows'] ?? '?'} (Actual)`)); + if (node['Actual Loops']) details.appendChild(mkDetail('Loops', node['Actual Loops'])); + + // Dynamic keys + for (const [key, val] of Object.entries(node)) { + if (ignoredKeys.includes(key)) continue; + if (importantKeys.includes(key) || typeof val === 'string' || typeof val === 'number') { + details.appendChild(mkDetail(key, val)); + } + } + el.appendChild(details); + + // Children + if (node.Plans && node.Plans.length > 0) { + const children = document.createElement('div'); + children.className = 'explain-children'; + node.Plans.forEach(child => { + children.appendChild(this.createNodeElement(child)); + }); + el.appendChild(children); + } + + return el; + } +} diff --git a/src/renderer_v2.ts b/src/renderer_v2.ts index df15c6b..7ac3c19 100644 --- a/src/renderer_v2.ts +++ b/src/renderer_v2.ts @@ -8,6 +8,7 @@ import { ChartRenderer } from './renderer/components/chart/ChartRenderer'; import { ChartControls } from './renderer/components/chart/ChartControls'; import { TableInfo, QueryResults, ChartRenderOptions } from './common/types'; import { getNumericColumns, isDateColumn } from './renderer/utils/formatting'; +import { ExplainVisualizer } from './renderer/components/ExplainVisualizer'; // Register Chart.js components Chart.register(...registerables); @@ -79,11 +80,24 @@ export const activate: ActivationFunction = context => { if (!summaryText) summaryText = 'No results'; summary.textContent = summaryText; - header.appendChild(chevron); - header.appendChild(title); header.appendChild(summary); mainContainer.appendChild(header); + // Performance Warning + if (json.performanceAnalysis?.isDegraded) { + const perfBanner = document.createElement('div'); + perfBanner.style.cssText = ` + padding: 6px 12px; + background: rgba(255, 165, 0, 0.15); + border-bottom: 1px solid rgba(255, 165, 0, 0.3); + color: var(--vscode-editorWarning-foreground); + font-size: 11px; + display: flex; align-items: center; gap: 6px; + `; + perfBanner.innerHTML = `âš ī¸ ${json.performanceAnalysis.analysis}`; + mainContainer.appendChild(perfBanner); + } + // Breadcrumb Navigation if (breadcrumb) { const segments: BreadcrumbSegment[] = []; @@ -263,25 +277,31 @@ export const activate: ActivationFunction = context => { rightActions.appendChild(optimizeBtn); // Detect if this is an EXPLAIN query (either JSON or text format) - const isExplainQuery = json.explainPlan || - (query && /^\s*EXPLAIN/i.test(query)) || - command === 'EXPLAIN' || - (columns.length === 1 && columns[0] === 'QUERY PLAN'); - + const isExplainQuery = json.explainPlan || + (query && /^\s*EXPLAIN/i.test(query)) || + command === 'EXPLAIN' || + (columns.length === 1 && columns[0] === 'QUERY PLAN'); + if (isExplainQuery) { const explainPlanBtn = createButton('🧭 View Plan', true); - explainPlanBtn.title = json.explainPlan - ? 'Open EXPLAIN ANALYZE plan view' + explainPlanBtn.title = json.explainPlan + ? 'Open EXPLAIN ANALYZE plan view' : 'Convert to JSON format and open visual plan view'; - + explainPlanBtn.onclick = () => { if (json.explainPlan) { // Already have JSON plan, show it directly - context.postMessage?.({ - type: 'showExplainPlan', - plan: json.explainPlan, - query: query || '' - }); + // Now we prefer the in-renderer tab if available + if (explainTab) { + switchTab('explain'); + } else { + // Fallback / legacy external view + context.postMessage?.({ + type: 'showExplainPlan', + plan: json.explainPlan, + query: query || '' + }); + } } else { // Text format - request re-execution with FORMAT JSON // Log for debugging @@ -442,8 +462,14 @@ export const activate: ActivationFunction = context => { const tableTab = createTab('Table', 'table', true, () => switchTab('table')); const chartTab = createTab('Chart', 'chart', false, () => switchTab('chart')); + let explainTab: HTMLElement | null = null; + if (json.explainPlan) { + explainTab = createTab('Explain Plan', 'explain', false, () => switchTab('explain')); + } + tabs.appendChild(tableTab); tabs.appendChild(chartTab); + if (explainTab) tabs.appendChild(explainTab); if (!json.error) { contentContainer.appendChild(tabs); } @@ -472,14 +498,14 @@ export const activate: ActivationFunction = context => { updateActionsVisibility(); } }); - + // Store for cleanup on disposal tableInstances.set(element, tableRenderer); // CHART RENDERER const chartCanvas = document.createElement('canvas'); const chartRenderer = new ChartRenderer(chartCanvas); - + // Store for cleanup on disposal chartInstances.set(element, chartRenderer); @@ -627,10 +653,26 @@ export const activate: ActivationFunction = context => { initialSelectedIndices: selectedIndices, modifiedCells }); + } else if (mode === 'explain') { + // Explain Mode + if (tableTab) { tableTab.style.borderBottom = '2px solid transparent'; tableTab.style.opacity = '0.6'; } + if (chartTab) { chartTab.style.borderBottom = '2px solid transparent'; chartTab.style.opacity = '0.6'; } + if (explainTab) { explainTab.style.borderBottom = '2px solid var(--vscode-focusBorder)'; explainTab.style.opacity = '1'; } + + updateActionsVisibility(); // Should probably hide most actions + + const explainWrapper = document.createElement('div'); + explainWrapper.style.cssText = 'flex: 1; overflow: hidden; height: 100%; display: flex; flex-direction: column;'; + viewContainer.appendChild(explainWrapper); + + new ExplainVisualizer(explainWrapper, json.explainPlan).render(); + } else { // Hide table specific styles tableTab.style.borderBottom = '2px solid transparent'; tableTab.style.opacity = '0.6'; + if (explainTab) { explainTab.style.borderBottom = '2px solid transparent'; explainTab.style.opacity = '0.6'; } + chartTab.style.borderBottom = '2px solid var(--vscode-focusBorder)'; chartTab.style.opacity = '1'; updateActionsVisibility(); diff --git a/src/services/MessageHandler.ts b/src/services/MessageHandler.ts new file mode 100644 index 0000000..bc5ec81 --- /dev/null +++ b/src/services/MessageHandler.ts @@ -0,0 +1,50 @@ +import * as vscode from 'vscode'; + +export interface IMessageHandler { + handle(message: any, context: { + editor?: vscode.NotebookEditor | undefined; + webview?: vscode.Webview | undefined; + postMessage?: (message: any) => Thenable; + [key: string]: any; + }): Promise; +} + +export class MessageHandlerRegistry { + private static instance: MessageHandlerRegistry; + private handlers: Map = new Map(); + + private constructor() { } + + public static getInstance(): MessageHandlerRegistry { + if (!MessageHandlerRegistry.instance) { + MessageHandlerRegistry.instance = new MessageHandlerRegistry(); + } + return MessageHandlerRegistry.instance; + } + + public register(type: string, handler: IMessageHandler) { + if (this.handlers.has(type)) { + console.warn(`Overwriting handler for message type: ${type}`); + } + this.handlers.set(type, handler); + } + + public async handleMessage(message: any, context: { + editor?: vscode.NotebookEditor | undefined; + webview?: vscode.Webview | undefined; + postMessage?: (message: any) => Thenable; + [key: string]: any; + }) { + const handler = this.handlers.get(message.type); + if (handler) { + try { + await handler.handle(message, context); + } catch (error) { + console.error(`Error handling message ${message.type}:`, error); + vscode.window.showErrorMessage(`Error processing ${message.type}: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + console.warn(`No handler registered for message type: ${message.type}`); + } + } +} diff --git a/src/services/QueryAnalyzer.ts b/src/services/QueryAnalyzer.ts index 4f0e995..eb69323 100644 --- a/src/services/QueryAnalyzer.ts +++ b/src/services/QueryAnalyzer.ts @@ -71,7 +71,7 @@ export interface QueryAnalysis { export class QueryAnalyzer { private static instance: QueryAnalyzer; - private constructor() {} + private constructor() { } public static getInstance(): QueryAnalyzer { if (!QueryAnalyzer.instance) { @@ -337,8 +337,8 @@ export class QueryAnalyzer { connection?.environment === 'production' ? 'âš ī¸ PRODUCTION DATABASE âš ī¸\n\n' : connection?.environment === 'staging' - ? 'âš ī¸ STAGING DATABASE âš ī¸\n\n' - : ''; + ? 'âš ī¸ STAGING DATABASE âš ī¸\n\n' + : ''; const opMessages = operations.map((op) => { const objectList = @@ -498,7 +498,7 @@ export class QueryAnalyzer { .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++) { @@ -515,7 +515,7 @@ export class QueryAnalyzer { public analyzePerformanceAgainstBaseline( executionTime: number, baseline: QueryBaseline | null, - explainPlan: any + explainPlan?: any ): PerformanceAnalysis { const metrics = this.extractPlanMetrics(explainPlan); diff --git a/src/services/QueryPerformanceService.ts b/src/services/QueryPerformanceService.ts new file mode 100644 index 0000000..e28e2d4 --- /dev/null +++ b/src/services/QueryPerformanceService.ts @@ -0,0 +1,91 @@ +import * as vscode from 'vscode'; +import { QueryBaseline } from './QueryAnalyzer'; + +export class QueryPerformanceService { + private static instance: QueryPerformanceService; + private storage: vscode.Memento; + private readonly STORAGE_KEY = 'postgres-explorer.queryPerformanceBaselines'; + + // Cache in memory to avoid redundant reads + private cache: Map = new Map(); + + private constructor(storage: vscode.Memento) { + this.storage = storage; + this.loadCache(); + } + + public static initialize(storage: vscode.Memento): void { + if (!QueryPerformanceService.instance) { + QueryPerformanceService.instance = new QueryPerformanceService(storage); + } + } + + public static getInstance(): QueryPerformanceService { + if (!QueryPerformanceService.instance) { + throw new Error('QueryPerformanceService not initialized'); + } + return QueryPerformanceService.instance; + } + + private loadCache() { + const data = this.storage.get>(this.STORAGE_KEY, {}); + this.cache = new Map(Object.entries(data)); + } + + private async saveCache() { + const data = Object.fromEntries(this.cache); + await this.storage.update(this.STORAGE_KEY, data); + } + + public getBaseline(queryHash: string): QueryBaseline | null { + return this.cache.get(queryHash) || null; + } + + public async recordExecution(queryHash: string, executionTimeMs: number): Promise { + const existing = this.cache.get(queryHash); + const now = Date.now(); + + let baseline: QueryBaseline; + + if (existing) { + // Update rolling stats + const newCount = existing.sampleCount + 1; + + // Welford's online algorithm for variance (optional, but good for stdDev) + // For now, simpler rolling average is fine: + // avg_new = avg_old + (value - avg_old) / n + const newAvg = existing.avgExecutionTime + (executionTimeMs - existing.avgExecutionTime) / newCount; + + baseline = { + queryHash, + avgExecutionTime: newAvg, + minExecutionTime: Math.min(existing.minExecutionTime, executionTimeMs), + maxExecutionTime: Math.max(existing.maxExecutionTime, executionTimeMs), + stdDev: 0, // Placeholder for now unless we implement full Welford + sampleCount: newCount, + lastUpdated: now + }; + } else { + // Create new baseline + baseline = { + queryHash, + avgExecutionTime: executionTimeMs, + minExecutionTime: executionTimeMs, + maxExecutionTime: executionTimeMs, + stdDev: 0, + sampleCount: 1, + lastUpdated: now + }; + } + + this.cache.set(queryHash, baseline); + + // Persist every update (or could debounce if high traffic, but this is user-driven query execution) + await this.saveCache(); + } + + public async clear(): Promise { + this.cache.clear(); + await this.storage.update(this.STORAGE_KEY, {}); + } +} diff --git a/src/services/handlers/CoreHandlers.ts b/src/services/handlers/CoreHandlers.ts new file mode 100644 index 0000000..6b2d9a7 --- /dev/null +++ b/src/services/handlers/CoreHandlers.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { ConnectionUtils } from '../../utils/connectionUtils'; +import { PostgresMetadata } from '../../common/types'; + +export class ShowConnectionSwitcherHandler implements IMessageHandler { + constructor(private statusBar: any) { } + + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + // const metadata = context.editor.metadata as PostgresMetadata; // Not directly accessible on editor type sometimes, check usage + // Actually editor.notebook.metadata is read-only in API but we update via workspace edit or custom util + // ConnectionUtils.updateNotebookMetadata handles the edit. + + const selected = await ConnectionUtils.showConnectionPicker(message.connectionId); + + if (selected && selected.id !== message.connectionId) { + await ConnectionUtils.updateNotebookMetadata(context.editor.notebook, { + connectionId: selected.id, + databaseName: selected.database, + host: selected.host, + port: selected.port, + username: selected.username + }); + vscode.window.showInformationMessage(`Switched to: ${selected.name || selected.host}`); + this.statusBar.update(); + } + } +} + +export class ShowDatabaseSwitcherHandler implements IMessageHandler { + constructor(private statusBar: any) { } + + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + const connection = ConnectionUtils.findConnection(message.connectionId); + if (!connection) { + vscode.window.showErrorMessage('Connection not found'); + return; + } + + const selectedDb = await ConnectionUtils.showDatabasePicker(connection, message.currentDatabase); + + if (selectedDb && selectedDb !== message.currentDatabase) { + await ConnectionUtils.updateNotebookMetadata(context.editor.notebook, { databaseName: selectedDb }); + vscode.window.showInformationMessage(`Switched to database: ${selectedDb}`); + this.statusBar.update(); + } + } +} + +export class ExportRequestHandler implements IMessageHandler { + async handle(message: any) { + // This logic was in NotebookKernel previously + // It requires UI interaction so it fits here. + const { rows: displayRows, columns } = message; + + const selection = await vscode.window.showQuickPick(['Save as CSV', 'Save as JSON', 'Copy to Clipboard']); + if (!selection) return; + + const rowsToExport = displayRows; + + if (selection === 'Copy to Clipboard') { + const csv = this.rowsToCsv(rowsToExport, columns); + await vscode.env.clipboard.writeText(csv); + vscode.window.showInformationMessage('Copied to clipboard'); + } else if (selection === 'Save as CSV') { + const csv = this.rowsToCsv(rowsToExport, columns); + const uri = await vscode.window.showSaveDialog({ filters: { 'CSV': ['csv'] } }); + if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(csv)); + } else if (selection === 'Save as JSON') { + const json = JSON.stringify(rowsToExport, null, 2); + const uri = await vscode.window.showSaveDialog({ filters: { 'JSON': ['json'] } }); + if (uri) await vscode.workspace.fs.writeFile(uri, Buffer.from(json)); + } + } + + private rowsToCsv(rows: any[], columns: string[]): string { + const header = columns.map(c => `"${c.replace(/"/g, '""')}"`).join(','); + const body = rows.map(row => columns.map(col => { + const val = row[col]; + const str = String(val ?? ''); + return str.includes(',') || str.includes('\n') ? `"${str.replace(/"/g, '""')}"` : str; + }).join(',')).join('\n'); + return `${header}\n${body}`; + } +} + +export class ShowErrorMessageHandler implements IMessageHandler { + async handle(message: any) { + vscode.window.showErrorMessage(message.message); + } +} + diff --git a/src/services/handlers/ExplainHandlers.ts b/src/services/handlers/ExplainHandlers.ts new file mode 100644 index 0000000..fd80a70 --- /dev/null +++ b/src/services/handlers/ExplainHandlers.ts @@ -0,0 +1,148 @@ +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { ChatViewProvider } from '../../providers/ChatViewProvider'; +import { ExplainProvider } from '../../providers/ExplainProvider'; + +export class ExplainErrorHandler implements IMessageHandler { + constructor(private chatViewProvider: ChatViewProvider | undefined) { } + + async handle(message: any) { + if (this.chatViewProvider) { + await this.chatViewProvider.handleExplainError(message.error, message.query); + } + } +} + +export class FixQueryHandler implements IMessageHandler { + constructor(private chatViewProvider: ChatViewProvider | undefined) { } + + async handle(message: any) { + if (this.chatViewProvider) { + await this.chatViewProvider.handleFixQuery(message.error, message.query); + } + } +} + +export class AnalyzeDataHandler implements IMessageHandler { + constructor(private chatViewProvider: ChatViewProvider | undefined) { } + + async handle(message: any) { + if (this.chatViewProvider) { + await this.chatViewProvider.handleAnalyzeData(message.data, message.query, message.rowCount); + } + } +} + +export class OptimizeQueryHandler implements IMessageHandler { + constructor(private chatViewProvider: ChatViewProvider | undefined) { } + + async handle(message: any) { + if (this.chatViewProvider) { + await this.chatViewProvider.handleOptimizeQuery(message.query, message.executionTime); + } + } +} + +export class SendToChatHandler implements IMessageHandler { + constructor(private chatViewProvider: ChatViewProvider | undefined) { } + + async handle(message: any) { + if (this.chatViewProvider) { + await vscode.commands.executeCommand('postgresExplorer.chatView.focus'); + await this.chatViewProvider.sendToChat(message.data); + } + } +} + +export class ShowExplainPlanHandler implements IMessageHandler { + constructor(private extensionUri: vscode.Uri) { } + + async handle(message: any) { + ExplainProvider.show(this.extensionUri, message.plan, message.query); + } +} + +import { SecretStorageService } from '../../services/SecretStorageService'; +import { PostgresMetadata } from '../../common/types'; + +export class ConvertExplainHandler implements IMessageHandler { + constructor(private context: vscode.ExtensionContext) { } + + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + // Convert text EXPLAIN to FORMAT JSON and show visual plan + const originalQuery = message.query; + + if (!originalQuery) { + vscode.window.showErrorMessage('No query available to convert'); + return; + } + + // Extract the actual query from EXPLAIN statement + const explainMatch = originalQuery.match(/^\s*EXPLAIN\s*(?:\([^)]*\))?\s*(.+)$/is); + const innerQuery = explainMatch ? explainMatch[1].trim() : originalQuery; + + // Create new query with FORMAT JSON + const jsonQuery = `EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS, VERBOSE)\n${innerQuery}`; + + // Execute and show plan + try { + const notebook = context.editor.notebook; + const metadata = notebook.metadata as PostgresMetadata; + + // Get connection config from workspace settings + const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const connection = connections.find(c => c.id === metadata.connectionId); + + if (!connection) { + vscode.window.showErrorMessage('No active database connection'); + return; + } + + const password = await SecretStorageService.getInstance().getPassword(metadata.connectionId); + if (!password && connection.authMode === 'password') { + vscode.window.showErrorMessage('Password not found for connection'); + return; + } + + // Show progress + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: 'Converting EXPLAIN to JSON format...', + cancellable: false + }, async () => { + const { Pool } = await import('pg'); + const client = new Pool({ + host: connection.host, + port: connection.port, + user: connection.username, + password: password || undefined, + database: metadata.databaseName, + ssl: connection.ssl ? { rejectUnauthorized: false } : false + }); + + try { + const result = await client.query(jsonQuery); + + if (result.rows?.length) { + const planCell = result.rows[0]['QUERY PLAN'] ?? result.rows[0]['query_plan']; + if (planCell) { + const explainPlan = typeof planCell === 'string' ? JSON.parse(planCell) : planCell; + ExplainProvider.show(this.context.extensionUri, explainPlan, innerQuery); + } else { + vscode.window.showErrorMessage('No plan data returned from query'); + } + } else { + vscode.window.showErrorMessage('No results returned from EXPLAIN query'); + } + } finally { + await client.end(); + } + }); + } catch (error: any) { + vscode.window.showErrorMessage(`Failed to convert EXPLAIN query: ${error.message}`); + console.error('EXPLAIN conversion error:', error); + } + } +} diff --git a/src/services/handlers/QueryHandlers.ts b/src/services/handlers/QueryHandlers.ts new file mode 100644 index 0000000..38d365b --- /dev/null +++ b/src/services/handlers/QueryHandlers.ts @@ -0,0 +1,328 @@ +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { PostgresMetadata } from '../../common/types'; +import { ConnectionManager } from '../../services/ConnectionManager'; +import { ErrorHandlers } from '../../commands/helper'; +import { ConnectionUtils } from '../../utils/connectionUtils'; +import { SqlExecutor } from '../../providers/kernel/SqlExecutor'; + +export class ExecuteUpdateBackgroundHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + const { statements } = message; + let client; + try { + const notebook = context.editor.notebook; + const metadata = notebook.metadata as PostgresMetadata; + if (!metadata?.connectionId) { + throw new Error('No connection in notebook metadata'); + } + + const connectionConfig = { + id: metadata.connectionId, + name: metadata.host, + host: metadata.host, + port: metadata.port, + username: metadata.username, + database: metadata.databaseName + }; + + client = await ConnectionManager.getInstance().getPooledClient(connectionConfig); + + let successCount = 0; + let errorCount = 0; + for (const stmt of statements) { + try { + await client.query(stmt); + successCount++; + } catch (err: any) { + errorCount++; + await ErrorHandlers.handleCommandError(err, 'update statement'); + } + } + + if (successCount > 0) { + vscode.window.showInformationMessage(`Successfully updated ${successCount} row(s)${errorCount > 0 ? `, ${errorCount} failed` : ''}`); + } + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'background updates'); + } finally { + if (client) client.release(); + } + } +} + +export class ExecuteUpdateHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + const { statements, cellIndex } = message; + const notebook = context.editor.notebook; + try { + const query = statements.join('\n'); + await this.insertCell(notebook, cellIndex + 1, `-- Update statements generated\n${query}`); + vscode.window.showInformationMessage(`Generated ${statements.length} UPDATE statement(s).`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to generate update script: ${err.message}`); + } + } + + private async insertCell(notebook: vscode.NotebookDocument, index: number, content: string) { + const newCell = new vscode.NotebookCellData(vscode.NotebookCellKind.Code, content, 'sql'); + const edit = new vscode.NotebookEdit(new vscode.NotebookRange(index, index), [newCell]); + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(notebook.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); + } +} + +export class CancelQueryHandler implements IMessageHandler { + async handle(message: any, context: { executor?: SqlExecutor }) { + if (context.executor) { + await context.executor.cancelQuery(message); + } else { + console.warn('CancelQueryHandler: No executor provided in context'); + } + } +} + +export class DeleteRowsHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + console.log('[DeleteRowsHandler] Called', message); + const { tableInfo, rows, row } = message; // Support both 'rows' (array) and legacy 'row' (single) + const targets = rows || (row ? [row] : []); + + if (targets.length === 0) return; + + const { schema, table, primaryKeys } = tableInfo || message; + + if (!primaryKeys || primaryKeys.length === 0) { + vscode.window.showErrorMessage('Cannot delete: No primary keys defined for this table.'); + return; + } + + const notebook = context.editor.notebook; + const metadata = notebook.metadata as PostgresMetadata; + if (!metadata?.connectionId) return; + + try { + const connection = ConnectionUtils.findConnection(metadata.connectionId); + if (!connection) throw new Error('Connection not found'); + + const config = { + ...connection, + database: metadata.databaseName || connection.database + }; + + const client = await ConnectionManager.getInstance().getSessionClient(config, notebook.uri.toString()); + + const allValues: any[] = []; + const rowConditions: string[] = []; + let paramIndex = 1; + + for (const targetRow of targets) { + const conditions: string[] = []; + for (const pk of primaryKeys) { + conditions.push(`$${paramIndex++}`); + allValues.push(targetRow[pk]); + } + if (primaryKeys.length > 1) { + rowConditions.push(`(${conditions.join(', ')})`); + } else { + rowConditions.push(conditions[0]); + } + } + + const pkCols = primaryKeys.map((pk: string) => `"${pk}"`).join(', '); + const whereClause = primaryKeys.length > 1 + ? `(${pkCols}) IN (${rowConditions.join(', ')})` + : `${pkCols} IN (${rowConditions.join(', ')})`; + + const query = `DELETE FROM "${schema}"."${table}" WHERE ${whereClause}`; + + const result = await client.query(query, allValues); + + vscode.window.showInformationMessage(`Deleted ${result.rowCount} row(s) from ${schema}.${table}`); + + if (context.editor.selection) { + const range = context.editor.selection; + await vscode.commands.executeCommand('notebook.cell.execute', { ranges: [range], document: context.editor.notebook.uri }); + } + + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to delete rows: ${err.message}`); + } + } +} + +export class ScriptDeleteHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + + const { schema, table, primaryKeys, rows, cellIndex } = message; + const notebook = context.editor.notebook; + + try { + // Construct DELETE query + let query = ''; + for (const row of rows) { + const conditions: string[] = []; + + for (const pk of primaryKeys) { + const val = row[pk]; + const valStr = typeof val === 'string' ? `'${val.replace(/'/g, "''")}'` : val; + conditions.push(`"${pk}" = ${valStr}`); + } + query += `DELETE FROM "${schema}"."${table}" WHERE ${conditions.join(' AND ')};\n`; + } + + // Insert new cell with the query + const targetIndex = cellIndex + 1; + const newCell = new vscode.NotebookCellData( + vscode.NotebookCellKind.Code, + query, + 'sql' + ); + + const edit = new vscode.NotebookEdit( + new vscode.NotebookRange(targetIndex, targetIndex), + [newCell] + ); + + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(notebook.uri, [edit]); + await vscode.workspace.applyEdit(workspaceEdit); + } catch (err: any) { + await ErrorHandlers.handleCommandError(err, 'generate delete script'); + } + } +} + +export class SaveChangesHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor; postMessage?: (msg: any) => Thenable }) { + if (!context.editor) return; + + const { updates, deletions, tableInfo } = message; + const { schema, table } = tableInfo; + let client; + + try { + const notebook = context.editor.notebook; + const metadata = notebook.metadata as PostgresMetadata; + if (!metadata?.connectionId) { + vscode.window.showErrorMessage('Cannot save changes: No connection in notebook metadata'); + return; + } + + // Use ConnectionManager to get a pooled client + const connectionConfig = { + id: metadata.connectionId, + name: metadata.host, + host: metadata.host, + port: metadata.port, + username: metadata.username, + database: metadata.databaseName + }; + + client = await ConnectionManager.getInstance().getPooledClient(connectionConfig); + + let successCount = 0; + let errorCount = 0; + + for (const update of updates) { + const { keys, column, value } = update; + + // Format value for SQL + let valueStr = 'NULL'; + if (value !== null && value !== undefined) { + if (typeof value === 'boolean') { + valueStr = value ? 'TRUE' : 'FALSE'; + } else if (typeof value === 'number') { + valueStr = String(value); + } else if (typeof value === 'object') { + valueStr = `'${JSON.stringify(value).replace(/'/g, "''")}'`; + } else { + valueStr = `'${String(value).replace(/'/g, "''")}'`; + } + } + + // Format conditions + const conditions: string[] = []; + for (const [pk, pkVal] of Object.entries(keys)) { + let pkValStr = 'NULL'; + if (pkVal !== null && pkVal !== undefined) { + if (typeof pkVal === 'number' || typeof pkVal === 'boolean') { + pkValStr = String(pkVal); + } else { + pkValStr = `'${String(pkVal).replace(/'/g, "''")}'`; + } + } + conditions.push(`"${pk}" = ${pkValStr}`); + } + + const query = `UPDATE "${schema}"."${table}" SET "${column}" = ${valueStr} WHERE ${conditions.join(' AND ')}`; + + try { + await client.query(query); + successCount++; + } catch (err: any) { + errorCount++; + console.error('Update failed:', query, err); + } + } + + // Process DELETE queries + let deletedCount = 0; + for (const deletion of deletions || []) { + const { keys } = deletion; + + // Build WHERE clause + const conditions: string[] = []; + for (const [pk, pkVal] of Object.entries(keys)) { + let pkValStr = 'NULL'; + if (pkVal !== null && pkVal !== undefined) { + if (typeof pkVal === 'number' || typeof pkVal === 'boolean') { + pkValStr = String(pkVal); + } else { + pkValStr = `'${String(pkVal).replace(/'/g, "''")}'`; + } + } + conditions.push(`"${pk}" = ${pkValStr}`); + } + + const query = `DELETE FROM "${schema}"."${table}" WHERE ${conditions.join(' AND ')}`; + + try { + await client.query(query); + deletedCount++; + successCount++; + } catch (err: any) { + errorCount++; + console.error('Delete failed:', query, err); + } + } + + if (successCount > 0) { + const parts = []; + const updateCount = (updates?.length || 0); + if (updateCount > 0) parts.push(`${updateCount} edit(s)`); + if (deletedCount > 0) parts.push(`${deletedCount} deletion(s)`); + + vscode.window.showInformationMessage(`✅ Successfully saved ${parts.join(', ')}${errorCount > 0 ? `, ${errorCount} failed` : ''}`); + // Notify renderer to clear modified cells and remove deleted rows + if (context.postMessage) { + context.postMessage({ type: 'saveSuccess', successCount, errorCount, deletedCount }); + } + } else if (errorCount > 0) { + vscode.window.showErrorMessage(`Failed to save changes: ${errorCount} error(s)`); + } + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to save changes: ${err.message}`); + } finally { + if (client) client.release(); + } + } +} diff --git a/src/services/handlers/TransactionHandlers.ts b/src/services/handlers/TransactionHandlers.ts new file mode 100644 index 0000000..22f3911 --- /dev/null +++ b/src/services/handlers/TransactionHandlers.ts @@ -0,0 +1,130 @@ +import * as vscode from 'vscode'; +import { IMessageHandler } from '../MessageHandler'; +import { getTransactionManager, IsolationLevel } from '../../services/TransactionManager'; +import { ConnectionManager } from '../../services/ConnectionManager'; +import { ConnectionUtils } from '../../utils/connectionUtils'; +import { PostgresMetadata } from '../../common/types'; + +async function getSessionClient(notebook: vscode.NotebookDocument): Promise { + const metadata = notebook.metadata as PostgresMetadata; + if (!metadata?.connectionId) throw new Error('No connection found'); + + const connection = ConnectionUtils.findConnection(metadata.connectionId); + if (!connection) throw new Error('Connection not found'); + + return await ConnectionManager.getInstance().getSessionClient({ + id: connection.id, + host: connection.host, + port: connection.port, + username: connection.username, + database: metadata.databaseName || connection.database, + name: connection.name + }, notebook.uri.toString()); +} + +export class TransactionBeginHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + const { isolationLevel = 'READ COMMITTED', readOnly = false, deferrable = false } = message; + + await txManager.beginTransaction(client, sessionId, isolationLevel as IsolationLevel, readOnly, deferrable); + + const summary = txManager.getTransactionSummary(sessionId); + vscode.window.showInformationMessage(summary); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to begin transaction: ${err.message}`); + } + } +} + +export class TransactionCommitHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + + await txManager.commitTransaction(client, sessionId); + vscode.window.showInformationMessage('✅ Transaction committed'); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to commit transaction: ${err.message}`); + } + } +} + +export class TransactionRollbackHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + + await txManager.rollbackTransaction(client, sessionId); + vscode.window.showInformationMessage('âŽī¸ Transaction rolled back'); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to rollback transaction: ${err.message}`); + } + } +} + +export class SavepointCreateHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + + const savepointName = await txManager.createSavepoint(client, sessionId); + vscode.window.showInformationMessage(`📍 Savepoint created: ${savepointName}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to create savepoint: ${err.message}`); + } + } +} + +export class SavepointReleaseHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + const { savepointName } = message; + + await txManager.releaseSavepoint(client, sessionId, savepointName); + vscode.window.showInformationMessage(`✓ Savepoint released: ${savepointName || 'latest'}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to release savepoint: ${err.message}`); + } + } +} + +export class SavepointRollbackHandler implements IMessageHandler { + async handle(message: any, context: { editor: vscode.NotebookEditor }) { + if (!context.editor) return; + try { + const notebook = context.editor.notebook; + const client = await getSessionClient(notebook); + const sessionId = notebook.uri.toString(); + const txManager = getTransactionManager(); + const { savepointName } = message; + + await txManager.rollbackToSavepoint(client, sessionId, savepointName); + vscode.window.showInformationMessage(`âŽī¸ Rolled back to savepoint: ${savepointName || 'latest'}`); + } catch (err: any) { + vscode.window.showErrorMessage(`Failed to rollback savepoint: ${err.message}`); + } + } +} diff --git a/templates/chat/scripts.js b/templates/chat/scripts.js index 1ab9922..bb9ed27 100644 --- a/templates/chat/scripts.js +++ b/templates/chat/scripts.js @@ -37,93 +37,93 @@ let mentionPickerVisible = false; let selectedMentionIndex = -1; let searchDebounceTimer = null; let currentHierarchyPath = { - connection: null, - database: null, - schema: null + connection: null, + database: null, + schema: null }; // Hierarchy Navigation function navigateToRoot() { - currentHierarchyPath = { connection: null, database: null, schema: null }; - vscode.postMessage({ type: 'getDbHierarchy', path: {} }); - renderBreadcrumbs(); - mentionList.innerHTML = '
Loading connections...
'; + currentHierarchyPath = { connection: null, database: null, schema: null }; + vscode.postMessage({ type: 'getDbHierarchy', path: {} }); + renderBreadcrumbs(); + mentionList.innerHTML = '
Loading connections...
'; } function navigateToConnection(id, name) { - currentHierarchyPath = { - connection: { id, name }, - database: null, - schema: null - }; - vscode.postMessage({ type: 'getDbHierarchy', path: { connectionId: id } }); - renderBreadcrumbs(); - mentionList.innerHTML = '
Loading databases...
'; + currentHierarchyPath = { + connection: { id, name }, + database: null, + schema: null + }; + vscode.postMessage({ type: 'getDbHierarchy', path: { connectionId: id } }); + renderBreadcrumbs(); + mentionList.innerHTML = '
Loading databases...
'; } function navigateToDatabase(dbName) { - if (!currentHierarchyPath.connection) return; - currentHierarchyPath.database = dbName; - currentHierarchyPath.schema = null; - vscode.postMessage({ - type: 'getDbHierarchy', - path: { - connectionId: currentHierarchyPath.connection.id, - database: dbName - } - }); - renderBreadcrumbs(); - mentionList.innerHTML = '
Loading schemas...
'; + if (!currentHierarchyPath.connection) return; + currentHierarchyPath.database = dbName; + currentHierarchyPath.schema = null; + vscode.postMessage({ + type: 'getDbHierarchy', + path: { + connectionId: currentHierarchyPath.connection.id, + database: dbName + } + }); + renderBreadcrumbs(); + mentionList.innerHTML = '
Loading schemas...
'; } function navigateToSchema(schemaName) { - if (!currentHierarchyPath.connection || !currentHierarchyPath.database) return; - currentHierarchyPath.schema = schemaName; - vscode.postMessage({ - type: 'getDbHierarchy', - path: { - connectionId: currentHierarchyPath.connection.id, - database: currentHierarchyPath.database, - schema: schemaName - } - }); - renderBreadcrumbs(); - mentionList.innerHTML = '
Loading objects...
'; + if (!currentHierarchyPath.connection || !currentHierarchyPath.database) return; + currentHierarchyPath.schema = schemaName; + vscode.postMessage({ + type: 'getDbHierarchy', + path: { + connectionId: currentHierarchyPath.connection.id, + database: currentHierarchyPath.database, + schema: schemaName + } + }); + renderBreadcrumbs(); + mentionList.innerHTML = '
Loading objects...
'; } function renderBreadcrumbs() { - const container = document.getElementById('mentionBreadcrumbs'); - if (!container) return; - - let html = `
Home
`; - - if (currentHierarchyPath.connection) { - html += `/`; - html += `
${escapeHtml(currentHierarchyPath.connection.name)}
`; - } - - if (currentHierarchyPath.database) { - html += `/`; - html += `
${escapeHtml(currentHierarchyPath.database)}
`; - } - - if (currentHierarchyPath.schema) { - html += `/`; - html += `
${escapeHtml(currentHierarchyPath.schema)}
`; - } - - container.innerHTML = html; + const container = document.getElementById('mentionBreadcrumbs'); + if (!container) return; + + let html = `
Home
`; + + if (currentHierarchyPath.connection) { + html += `/`; + html += `
${escapeHtml(currentHierarchyPath.connection.name)}
`; + } + + if (currentHierarchyPath.database) { + html += `/`; + html += `
${escapeHtml(currentHierarchyPath.database)}
`; + } + + if (currentHierarchyPath.schema) { + html += `/`; + html += `
${escapeHtml(currentHierarchyPath.schema)}
`; + } + + container.innerHTML = html; } function handleContainerClick(index) { - const obj = dbObjects[index]; - if (obj.type === 'connection') { - navigateToConnection(obj.connectionId, obj.name); - } else if (obj.type === 'database') { - navigateToDatabase(obj.name); - } else if (obj.type === 'schema') { - navigateToSchema(obj.name); - } + const obj = dbObjects[index]; + if (obj.type === 'connection') { + navigateToConnection(obj.connectionId, obj.name); + } else if (obj.type === 'database') { + navigateToDatabase(obj.name); + } else if (obj.type === 'schema') { + navigateToSchema(obj.name); + } } // History functions @@ -282,18 +282,18 @@ function hideMentionPicker() { function searchMentions(query) { console.log('[WebView] searchMentions:', query); if (!query) { - const path = {}; - if (currentHierarchyPath.connection) { - path.connectionId = currentHierarchyPath.connection.id; - if (currentHierarchyPath.database) { - path.database = currentHierarchyPath.database; - if (currentHierarchyPath.schema) { - path.schema = currentHierarchyPath.schema; - } - } - } - vscode.postMessage({ type: 'getDbHierarchy', path }); - return; + const path = {}; + if (currentHierarchyPath.connection) { + path.connectionId = currentHierarchyPath.connection.id; + if (currentHierarchyPath.database) { + path.database = currentHierarchyPath.database; + if (currentHierarchyPath.schema) { + path.schema = currentHierarchyPath.schema; + } + } + } + vscode.postMessage({ type: 'getDbHierarchy', path }); + return; } vscode.postMessage({ type: 'searchDbObjects', query: query }); } @@ -315,51 +315,51 @@ function getDbTypeIcon(type) { function renderHierarchyItems(items) { console.log('[WebView] renderHierarchyItems called with', items.length, 'items'); - dbObjects = items; - + dbObjects = items; + if (items.length === 0) { - mentionList.innerHTML = '
No items found.
'; - return; + mentionList.innerHTML = '
No items found.
'; + return; } - + let html = ''; - + items.sort((a, b) => { - const aContainer = !!a.isContainer; - const bContainer = !!b.isContainer; - - if (aContainer && !bContainer) return -1; - if (!aContainer && bContainer) return 1; - return (a.name || '').localeCompare(b.name || ''); + const aContainer = !!a.isContainer; + const bContainer = !!b.isContainer; + + if (aContainer && !bContainer) return -1; + if (!aContainer && bContainer) return 1; + return (a.name || '').localeCompare(b.name || ''); }); - + items.forEach((obj, idx) => { - const isContainer = !!obj.isContainer; - let icon = getDbTypeIcon(obj.type); - - const clickHandler = isContainer - ? `handleContainerClick(${idx})` - : `selectMention(${idx})`; - - const displayName = isContainer ? obj.name : (obj.schema ? obj.schema + '.' + obj.name : obj.name); - - let metaHtml = ''; - if (obj.type !== 'connection') { - const metaParts = []; - - if (obj.connectionName) { - metaParts.push(obj.connectionName); - } - if (obj.database && obj.type !== 'database') { - metaParts.push(obj.database); - } - - if (metaParts.length > 0) { - metaHtml = `
${escapeHtml(metaParts.join(' â€ĸ '))}
`; - } + const isContainer = !!obj.isContainer; + let icon = getDbTypeIcon(obj.type); + + const clickHandler = isContainer + ? `handleContainerClick(${idx})` + : `selectMention(${idx})`; + + const displayName = isContainer ? obj.name : (obj.schema ? obj.schema + '.' + obj.name : obj.name); + + let metaHtml = ''; + if (obj.type !== 'connection') { + const metaParts = []; + + if (obj.connectionName) { + metaParts.push(obj.connectionName); + } + if (obj.database && obj.type !== 'database') { + metaParts.push(obj.database); } - html += `
+ if (metaParts.length > 0) { + metaHtml = `
${escapeHtml(metaParts.join(' â€ĸ '))}
`; + } + } + + html += `
${icon} ${escapeHtml(displayName)} @@ -367,7 +367,7 @@ function renderHierarchyItems(items) { ${metaHtml}
`; }); - + mentionList.innerHTML = html; } @@ -416,10 +416,10 @@ function renderDbObjects(objects) { const metaParts = []; if (obj.connectionName) metaParts.push(obj.connectionName); if (obj.database) metaParts.push(obj.database); - - const metaHtml = metaParts.length > 0 - ? `
${escapeHtml(metaParts.join(' â€ĸ '))}
` - : ''; + + const metaHtml = metaParts.length > 0 + ? `
${escapeHtml(metaParts.join(' â€ĸ '))}
` + : ''; return `
@@ -445,7 +445,7 @@ function renderDbObjects(objects) { if (!typeOrder.includes(type) && grouped[type].length > 0) { html += '
' + (typeLabels[type] || type) + ' (' + grouped[type].length + ')
'; grouped[type].forEach(obj => { - html += renderItem(obj); + html += renderItem(obj); }); } }); @@ -576,7 +576,7 @@ function renderMentionChips() { selectedMentions.forEach((mention, index) => { const chip = document.createElement('div'); chip.className = 'mention-chip'; - + // Prepare metadata text const metaParts = []; if (mention.connectionName) metaParts.push(mention.connectionName); @@ -1251,9 +1251,9 @@ window.addEventListener('message', event => { break; case 'dbHierarchyData': if (message.error) { - mentionList.innerHTML = '
' + escapeHtml(message.error) + '
'; + mentionList.innerHTML = '
' + escapeHtml(message.error) + '
'; } else { - renderHierarchyItems(message.items); + renderHierarchyItems(message.items); } break; case 'dbObjectsResult': @@ -1293,14 +1293,14 @@ window.addEventListener('message', event => { // Always ensure the text reference exists or append it const mentionText = '@' + mention.schema + '.' + mention.name; if (!chatInput.value.includes(mentionText)) { - const prefix = chatInput.value.length > 0 && !chatInput.value.endsWith(' ') ? ' ' : ''; - chatInput.value += prefix + mentionText; + const prefix = chatInput.value.length > 0 && !chatInput.value.endsWith(' ') ? ' ' : ''; + chatInput.value += prefix + mentionText; } chatInput.focus(); // Move cursor to end chatInput.selectionStart = chatInput.selectionEnd = chatInput.value.length; - + showToast('✅ Attached ' + mention.schema + '.' + mention.name + ' to chat', 'info'); } break; @@ -1314,6 +1314,7 @@ window.addEventListener('message', event => { aiModelNameEl.textContent = message.modelName || 'Unknown'; } break; + case 'notebookResult': handleNotebookResult(message.success, message.error); break; @@ -1462,9 +1463,10 @@ function renderMessages(messages, animate = false) { messagesContainer.insertBefore(messageDiv, typingIndicator); }); - // Scroll to bottom smoothly messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' }); } + + diff --git a/templates/chat/styles.css b/templates/chat/styles.css index 39381fd..4707f31 100644 --- a/templates/chat/styles.css +++ b/templates/chat/styles.css @@ -1,1072 +1,1118 @@ :root { - --chat-spacing: 12px; - --chat-radius: 12px; - --chat-radius-sm: 6px; - --transition-fast: 0.15s ease; - --transition-normal: 0.25s ease; + --chat-spacing: 12px; + --chat-radius: 12px; + --chat-radius-sm: 6px; + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; } * { - box-sizing: border-box; - margin: 0; - padding: 0; + box-sizing: border-box; + margin: 0; + padding: 0; } body { - font-family: var(--vscode-font-family); - font-size: var(--vscode-font-size); - color: var(--vscode-foreground); - background: transparent; - height: 100vh; - display: flex; - flex-direction: column; - overflow: hidden; + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + color: var(--vscode-foreground); + background: transparent; + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; } /* Custom scrollbar */ ::-webkit-scrollbar { - width: 6px; + width: 6px; } + ::-webkit-scrollbar-track { - background: transparent; + background: transparent; } + ::-webkit-scrollbar-thumb { - background: var(--vscode-scrollbarSlider-background); - border-radius: 3px; + background: var(--vscode-scrollbarSlider-background); + border-radius: 3px; } + ::-webkit-scrollbar-thumb:hover { - background: var(--vscode-scrollbarSlider-hoverBackground); + background: var(--vscode-scrollbarSlider-hoverBackground); } /* Main layout */ .main-container { - display: flex; - flex-direction: column; - height: 100vh; - position: relative; + display: flex; + flex-direction: column; + height: 100vh; + position: relative; } /* History Panel Overlay */ .history-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 100; - opacity: 0; - visibility: hidden; - transition: all var(--transition-normal); + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; + opacity: 0; + visibility: hidden; + transition: all var(--transition-normal); } .history-overlay.visible { - opacity: 1; - visibility: visible; + opacity: 1; + visibility: visible; } .history-panel { - position: fixed; - top: 0; - left: 0; - bottom: 0; - width: 85%; - max-width: 300px; - background: var(--vscode-sideBar-background); - border-right: 1px solid var(--vscode-widget-border); - z-index: 101; - display: flex; - flex-direction: column; - transform: translateX(-100%); - transition: transform var(--transition-normal); + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: 85%; + max-width: 300px; + background: var(--vscode-sideBar-background); + border-right: 1px solid var(--vscode-widget-border); + z-index: 101; + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform var(--transition-normal); } .history-overlay.visible .history-panel { - transform: translateX(0); + transform: translateX(0); } .history-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px; - border-bottom: 1px solid var(--vscode-widget-border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-bottom: 1px solid var(--vscode-widget-border); } .history-header h3 { - font-size: 13px; - font-weight: 600; - display: flex; - align-items: center; - gap: 6px; + font-size: 13px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; } .history-close-btn { - background: transparent; - border: none; - color: var(--vscode-foreground); - cursor: pointer; - padding: 4px; - border-radius: 4px; - display: flex; - align-items: center; - justify-content: center; + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; } .history-close-btn:hover { - background: var(--vscode-toolbar-hoverBackground); + background: var(--vscode-toolbar-hoverBackground); } .history-search { - padding: 8px 12px; - border-bottom: 1px solid var(--vscode-widget-border); + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-widget-border); } .history-search input { - width: 100%; - padding: 6px 10px; - border: 1px solid var(--vscode-input-border); - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: var(--chat-radius-sm); - font-size: 12px; - outline: none; + width: 100%; + padding: 6px 10px; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: var(--chat-radius-sm); + font-size: 12px; + outline: none; } .history-search input:focus { - border-color: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); } .history-search input::placeholder { - color: var(--vscode-input-placeholderForeground); + color: var(--vscode-input-placeholderForeground); } .history-list { - flex: 1; - overflow-y: auto; - padding: 8px; + flex: 1; + overflow-y: auto; + padding: 8px; } .history-item { - padding: 10px 12px; - border-radius: var(--chat-radius-sm); - cursor: pointer; - margin-bottom: 4px; - transition: all var(--transition-fast); - position: relative; + padding: 10px 12px; + border-radius: var(--chat-radius-sm); + cursor: pointer; + margin-bottom: 4px; + transition: all var(--transition-fast); + position: relative; } .history-item:hover { - background: var(--vscode-list-hoverBackground); + background: var(--vscode-list-hoverBackground); } .history-item.active { - background: var(--vscode-list-activeSelectionBackground); - color: var(--vscode-list-activeSelectionForeground); + background: var(--vscode-list-activeSelectionBackground); + color: var(--vscode-list-activeSelectionForeground); } .history-item-title { - font-size: 12px; - font-weight: 500; - margin-bottom: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - padding-right: 24px; + font-size: 12px; + font-weight: 500; + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-right: 24px; } .history-item-meta { - font-size: 10px; - color: var(--vscode-descriptionForeground); - display: flex; - align-items: center; - gap: 8px; + font-size: 10px; + color: var(--vscode-descriptionForeground); + display: flex; + align-items: center; + gap: 8px; } .history-item-delete { - position: absolute; - top: 8px; - right: 8px; - background: transparent; - border: none; - color: var(--vscode-descriptionForeground); - cursor: pointer; - padding: 4px; - border-radius: 4px; - opacity: 0; - transition: all var(--transition-fast); - z-index: 10; + position: absolute; + top: 8px; + right: 8px; + background: transparent; + border: none; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + opacity: 0; + transition: all var(--transition-fast); + z-index: 10; } .history-item-delete svg { - pointer-events: none; - display: block; + pointer-events: none; + display: block; } .history-item:hover .history-item-delete { - opacity: 0.7; + opacity: 0.7; } .history-item-delete:hover { - opacity: 1 !important; - background: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-errorForeground); + opacity: 1 !important; + background: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-errorForeground); } .history-item-delete.confirm-delete { - opacity: 1 !important; - background: var(--vscode-inputValidation-errorBackground, #5a1d1d); - color: var(--vscode-errorForeground, #f48771); - animation: pulse-delete 0.5s ease-in-out infinite alternate; + opacity: 1 !important; + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + color: var(--vscode-errorForeground, #f48771); + animation: pulse-delete 0.5s ease-in-out infinite alternate; } @keyframes pulse-delete { - from { transform: scale(1); } - to { transform: scale(1.1); } + from { + transform: scale(1); + } + + to { + transform: scale(1.1); + } } .history-empty { - text-align: center; - padding: 24px; - color: var(--vscode-descriptionForeground); - font-size: 12px; + text-align: center; + padding: 24px; + color: var(--vscode-descriptionForeground); + font-size: 12px; } .chat-container { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - padding: var(--chat-spacing); - gap: var(--chat-spacing); + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: var(--chat-spacing); + gap: var(--chat-spacing); } .chat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; } .chat-header-left { - display: flex; - align-items: center; - gap: 8px; + display: flex; + align-items: center; + gap: 8px; } .chat-header-right { - display: flex; - align-items: center; - gap: 4px; + display: flex; + align-items: center; + gap: 4px; } .header-btn { - background: transparent; - border: 1px solid transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - padding: 4px 6px; - border-radius: var(--chat-radius-sm); - transition: all var(--transition-fast); - opacity: 0.7; - display: flex; - align-items: center; - justify-content: center; + background: transparent; + border: 1px solid transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + padding: 4px 6px; + border-radius: var(--chat-radius-sm); + transition: all var(--transition-fast); + opacity: 0.7; + display: flex; + align-items: center; + justify-content: center; } .header-btn:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); } .header-btn svg { - width: 14px; - height: 14px; + width: 14px; + height: 14px; } .chat-header h3 { - font-size: 11px; - font-weight: 500; - letter-spacing: 0.5px; - text-transform: uppercase; - color: var(--vscode-descriptionForeground); - display: flex; - align-items: center; - gap: 6px; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--vscode-descriptionForeground); + display: flex; + align-items: center; + gap: 6px; } .ai-model-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 10px; - font-size: 10px; - font-weight: 500; - cursor: pointer; - transition: all var(--transition-fast); - border: 1px solid var(--vscode-widget-border); - opacity: 0.9; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 10px; + font-size: 10px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + border: 1px solid var(--vscode-widget-border); + opacity: 0.9; } .ai-model-badge:hover { - background: var(--vscode-inputOption-hoverBackground); - border-color: var(--vscode-focusBorder); - transform: translateY(-1px); - opacity: 1; + background: var(--vscode-inputOption-hoverBackground); + border-color: var(--vscode-focusBorder); + transform: translateY(-1px); + opacity: 1; } .ai-model-badge .sparkle-icon { - font-size: 11px; + font-size: 11px; } .header-icon { - font-size: 14px; + font-size: 14px; } .clear-btn { - background: transparent; - border: 1px solid transparent; - color: var(--vscode-descriptionForeground); - cursor: pointer; - font-size: 11px; - padding: 4px 8px; - border-radius: var(--chat-radius-sm); - transition: all var(--transition-fast); - opacity: 0.7; + background: transparent; + border: 1px solid transparent; + color: var(--vscode-descriptionForeground); + cursor: pointer; + font-size: 11px; + padding: 4px 8px; + border-radius: var(--chat-radius-sm); + transition: all var(--transition-fast); + opacity: 0.7; } .clear-btn:hover { - opacity: 1; - background-color: var(--vscode-toolbar-hoverBackground); - border-color: var(--vscode-contrastBorder, transparent); + opacity: 1; + background-color: var(--vscode-toolbar-hoverBackground); + border-color: var(--vscode-contrastBorder, transparent); } .messages-container { - flex: 1; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; - padding: 4px 0; + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; + padding: 4px 0; } .message { - display: flex; - flex-direction: column; - gap: 6px; - animation: messageIn 0.3s ease; + display: flex; + flex-direction: column; + gap: 6px; + animation: messageIn 0.3s ease; } @keyframes messageIn { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .message.user { - align-items: flex-end; + align-items: flex-end; } .message.assistant { - align-items: flex-start; + align-items: flex-start; } .message-bubble { - padding: 10px 14px; - border-radius: var(--chat-radius); - max-width: 92%; - word-wrap: break-word; - line-height: 1.5; + padding: 10px 14px; + border-radius: var(--chat-radius); + max-width: 92%; + word-wrap: break-word; + line-height: 1.5; } .message.user .message-bubble { - background: transparent; - color: var(--vscode-foreground); - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.3)); - border-bottom-right-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.3)); + border-bottom-right-radius: 4px; } .message.assistant .message-bubble { - background-color: color-mix(in srgb, var(--vscode-editor-background) 50%, var(--vscode-sideBar-background) 50%); - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - border-bottom-left-radius: 4px; + background-color: color-mix(in srgb, var(--vscode-editor-background) 50%, var(--vscode-sideBar-background) 50%); + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + border-bottom-left-radius: 4px; } .message-role { - font-size: 10px; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.3px; - color: var(--vscode-descriptionForeground); - padding: 0 4px; + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.3px; + color: var(--vscode-descriptionForeground); + padding: 0 4px; } .message-content { - font-size: 13px; - line-height: 1.6; + font-size: 13px; + line-height: 1.6; } .message-usage { - font-size: 10px; - color: var(--vscode-descriptionForeground); - margin-top: 4px; - text-align: right; - opacity: 0.7; - padding: 0 4px; + font-size: 10px; + color: var(--vscode-descriptionForeground); + margin-top: 4px; + text-align: right; + opacity: 0.7; + padding: 0 4px; } .message-content pre { - background-color: var(--vscode-textCodeBlock-background); - padding: 12px; - border-radius: var(--chat-radius-sm); - overflow-x: auto; - margin: 10px 0; - font-family: var(--vscode-editor-font-family); - font-size: 12px; - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); + background-color: var(--vscode-textCodeBlock-background); + padding: 12px; + border-radius: var(--chat-radius-sm); + overflow-x: auto; + margin: 10px 0; + font-family: var(--vscode-editor-font-family); + font-size: 12px; + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); } .message-content code { - background-color: var(--vscode-textCodeBlock-background); - padding: 2px 6px; - border-radius: 4px; - font-family: var(--vscode-editor-font-family); - font-size: 12px; + background-color: var(--vscode-textCodeBlock-background); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--vscode-editor-font-family); + font-size: 12px; } .message-content pre code { - background: none; - padding: 0; - border-radius: 0; + background: none; + padding: 0; + border-radius: 0; } /* Code block wrapper with copy button */ .code-block-wrapper { - position: relative; - margin: 10px 0; + position: relative; + margin: 10px 0; } .code-block-wrapper pre { - margin: 0; - padding-top: 32px; + margin: 0; + padding-top: 32px; } .code-block-header { - position: absolute; - top: 0; - left: 0; - right: 0; - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 12px; - background: rgba(0, 0, 0, 0.15); - border-radius: var(--chat-radius-sm) var(--chat-radius-sm) 0 0; - border-bottom: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 12px; + background: rgba(0, 0, 0, 0.15); + border-radius: var(--chat-radius-sm) var(--chat-radius-sm) 0 0; + border-bottom: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); } .code-language { - font-size: 11px; - font-weight: 500; - color: var(--vscode-descriptionForeground); - text-transform: uppercase; - letter-spacing: 0.5px; + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; } .copy-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - font-size: 11px; - color: var(--vscode-descriptionForeground); - background: transparent; - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + background: transparent; + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; } .copy-btn:hover { - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - border-color: var(--vscode-button-secondaryBackground); + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border-color: var(--vscode-button-secondaryBackground); } .copy-btn.copied { - background: var(--vscode-charts-green, #4caf50); - color: white; - border-color: var(--vscode-charts-green, #4caf50); + background: var(--vscode-charts-green, #4caf50); + color: white; + border-color: var(--vscode-charts-green, #4caf50); } .copy-btn svg { - width: 12px; - height: 12px; + width: 12px; + height: 12px; } .code-block-actions { - display: flex; - gap: 4px; + display: flex; + gap: 4px; } .notebook-btn { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - font-size: 11px; - color: var(--vscode-descriptionForeground); - background: transparent; - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + background: transparent; + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; } .notebook-btn:hover { - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - border-color: var(--vscode-button-secondaryBackground); + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border-color: var(--vscode-button-secondaryBackground); } .notebook-btn.added { - background: var(--vscode-charts-green, #4caf50); - color: white; - border-color: var(--vscode-charts-green, #4caf50); + background: var(--vscode-charts-green, #4caf50); + color: white; + border-color: var(--vscode-charts-green, #4caf50); } .notebook-btn svg { - width: 12px; - height: 12px; + width: 12px; + height: 12px; } .notebook-btn.error { - background: var(--vscode-inputValidation-errorBackground, #5a1d1d); - color: var(--vscode-errorForeground, #f48771); - border-color: var(--vscode-inputValidation-errorBorder, #be1100); + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + color: var(--vscode-errorForeground, #f48771); + border-color: var(--vscode-inputValidation-errorBorder, #be1100); } /* SQL Syntax Highlighting */ .sql-keyword { - color: var(--vscode-symbolIcon-keywordForeground, #569cd6); - font-weight: 600; + color: var(--vscode-symbolIcon-keywordForeground, #569cd6); + font-weight: 600; } .sql-function { - color: var(--vscode-symbolIcon-functionForeground, #dcdcaa); + color: var(--vscode-symbolIcon-functionForeground, #dcdcaa); } .sql-string { - color: var(--vscode-symbolIcon-stringForeground, #ce9178); + color: var(--vscode-symbolIcon-stringForeground, #ce9178); } .sql-number { - color: var(--vscode-symbolIcon-numberForeground, #b5cea8); + color: var(--vscode-symbolIcon-numberForeground, #b5cea8); } .sql-comment { - color: var(--vscode-symbolIcon-commentForeground, #6a9955); - font-style: italic; + color: var(--vscode-symbolIcon-commentForeground, #6a9955); + font-style: italic; } .sql-operator { - color: var(--vscode-symbolIcon-operatorForeground, #d4d4d4); + color: var(--vscode-symbolIcon-operatorForeground, #d4d4d4); } .sql-identifier { - color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); + color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); } .sql-punctuation { - color: var(--vscode-symbolIcon-punctuationForeground, #d4d4d4); + color: var(--vscode-symbolIcon-punctuationForeground, #d4d4d4); } .sql-type { - color: var(--vscode-symbolIcon-typeParameterForeground, #4ec9b0); + color: var(--vscode-symbolIcon-typeParameterForeground, #4ec9b0); } .sql-special { - color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); + color: var(--vscode-symbolIcon-variableForeground, #9cdcfe); } .message-content p { - margin: 8px 0; + margin: 8px 0; } .message-content p:first-child { - margin-top: 0; + margin-top: 0; } .message-content p:last-child { - margin-bottom: 0; + margin-bottom: 0; } - .message-content ul, .message-content ol { - margin: 8px 0; - padding-left: 18px; + .message-content ul, + .message-content ol { + margin: 8px 0; + padding-left: 18px; } .message-content li { - margin: 4px 0; - line-height: 1.5; + margin: 4px 0; + line-height: 1.5; } .message-content li::marker { - color: var(--vscode-descriptionForeground); + color: var(--vscode-descriptionForeground); } .message-content strong { - font-weight: 600; - color: var(--vscode-foreground); + font-weight: 600; + color: var(--vscode-foreground); + } + + .message-content h1, + .message-content h2, + .message-content h3 { + margin: 14px 0 8px 0; + font-weight: 600; + color: var(--vscode-foreground); } - .message-content h1, .message-content h2, .message-content h3 { - margin: 14px 0 8px 0; - font-weight: 600; - color: var(--vscode-foreground); + .message-content h1 { + font-size: 1.25em; } - .message-content h1 { font-size: 1.25em; } - .message-content h2 { font-size: 1.15em; } - .message-content h3 { font-size: 1.05em; } + .message-content h2 { + font-size: 1.15em; + } + + .message-content h3 { + font-size: 1.05em; + } .message-content table { - display: block; - overflow-x: auto; - width: 100%; - border-collapse: collapse; - margin: 10px 0; + display: block; + overflow-x: auto; + width: 100%; + border-collapse: collapse; + margin: 10px 0; } .message-content th, .message-content td { - padding: 6px 10px; - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - text-align: left; + padding: 6px 10px; + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + text-align: left; } .message-content th { - background-color: var(--vscode-keybindingTable-headerBackground, rgba(128, 128, 128, 0.1)); - font-weight: 600; + background-color: var(--vscode-keybindingTable-headerBackground, rgba(128, 128, 128, 0.1)); + font-weight: 600; } .message-content tr:nth-child(even) { - background-color: var(--vscode-keybindingTable-rowsBackground, rgba(128, 128, 128, 0.04)); + background-color: var(--vscode-keybindingTable-rowsBackground, rgba(128, 128, 128, 0.04)); } .input-container { - display: flex; - align-items: flex-end; - gap: 6px; - padding: 10px 12px; - background: var(--vscode-input-background); - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - border-radius: var(--chat-radius); - transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + display: flex; + align-items: flex-end; + gap: 6px; + padding: 10px 12px; + background: var(--vscode-input-background); + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + border-radius: var(--chat-radius); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } .input-container:focus-within { - border-color: var(--vscode-focusBorder); - box-shadow: 0 0 0 1px var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); + box-shadow: 0 0 0 1px var(--vscode-focusBorder); } .chat-input { - flex: 1; - padding: 4px 0; - border: none; - background: transparent; - color: var(--vscode-input-foreground); - font-family: var(--vscode-font-family), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 13px; - outline: none; - resize: none; - min-height: 20px; - max-height: 120px; - line-height: 1.5; - overflow-y: auto; - scrollbar-width: none; - -ms-overflow-style: none; + flex: 1; + padding: 4px 0; + border: none; + background: transparent; + color: var(--vscode-input-foreground); + font-family: var(--vscode-font-family), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 13px; + outline: none; + resize: none; + min-height: 20px; + max-height: 120px; + line-height: 1.5; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; } .chat-input::-webkit-scrollbar { - display: none; + display: none; } .chat-input::placeholder { - color: var(--vscode-input-placeholderForeground); + color: var(--vscode-input-placeholderForeground); } .chat-input:disabled { - opacity: 0.5; - cursor: not-allowed; + opacity: 0.5; + cursor: not-allowed; } .send-btn { - flex-shrink: 0; - width: 28px; - height: 28px; - padding: 0; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - border-radius: var(--chat-radius-sm); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast); - opacity: 0.9; + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: var(--chat-radius-sm); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); + opacity: 0.9; } .send-btn:hover:not(:disabled) { - opacity: 1; - background: var(--vscode-button-hoverBackground); - transform: scale(1.05); + opacity: 1; + background: var(--vscode-button-hoverBackground); + transform: scale(1.05); } .send-btn:active:not(:disabled) { - transform: scale(0.95); + transform: scale(0.95); } .send-btn:disabled { - opacity: 0.4; - cursor: not-allowed; + opacity: 0.4; + cursor: not-allowed; } .send-btn svg { - width: 14px; - height: 14px; + width: 14px; + height: 14px; } .stop-btn { - flex-shrink: 0; - width: 28px; - height: 28px; - padding: 0; - background: var(--vscode-errorForeground); - color: var(--vscode-editor-background); - border: none; - border-radius: var(--chat-radius-sm); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast); + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + background: var(--vscode-errorForeground); + color: var(--vscode-editor-background); + border: none; + border-radius: var(--chat-radius-sm); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); } .stop-btn:hover { - opacity: 0.85; - transform: scale(1.05); + opacity: 0.85; + transform: scale(1.05); } .stop-btn:active { - transform: scale(0.95); + transform: scale(0.95); } .stop-btn svg { - width: 12px; - height: 12px; + width: 12px; + height: 12px; } .attach-btn { - flex-shrink: 0; - width: 28px; - height: 28px; - padding: 0; - background: rgba(128, 128, 128, 0.15); - color: var(--vscode-descriptionForeground); - border: none; - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast); + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + background: rgba(128, 128, 128, 0.15); + color: var(--vscode-descriptionForeground); + border: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); } .attach-btn:hover:not(:disabled) { - background: rgba(128, 128, 128, 0.25); - color: var(--vscode-foreground); + background: rgba(128, 128, 128, 0.25); + color: var(--vscode-foreground); } .attach-btn:disabled { - opacity: 0.3; - cursor: not-allowed; + opacity: 0.3; + cursor: not-allowed; } .attach-btn svg { - width: 16px; - height: 16px; + width: 16px; + height: 16px; } .attachments-container { - display: none; - flex-wrap: wrap; - gap: 6px; - padding: 8px 12px; - background-color: color-mix(in srgb, var(--vscode-input-background) 60%, transparent); - border: 1px solid var(--vscode-input-border); - border-bottom: none; - border-radius: var(--chat-radius) var(--chat-radius) 0 0; - margin-bottom: -1px; + display: none; + flex-wrap: wrap; + gap: 6px; + padding: 8px 12px; + background-color: color-mix(in srgb, var(--vscode-input-background) 60%, transparent); + border: 1px solid var(--vscode-input-border); + border-bottom: none; + border-radius: var(--chat-radius) var(--chat-radius) 0 0; + margin-bottom: -1px; } .attachments-container.has-files { - display: flex; + display: flex; } .attachment-chip { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px 4px 10px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - border-radius: 16px; - font-size: 11px; - animation: chipIn 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 10px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + border-radius: 16px; + font-size: 11px; + animation: chipIn 0.2s ease; } @keyframes chipIn { - from { - opacity: 0; - transform: scale(0.8); - } - to { - opacity: 1; - transform: scale(1); - } + from { + opacity: 0; + transform: scale(0.8); + } + + to { + opacity: 1; + transform: scale(1); + } } .attachment-chip .file-icon { - font-size: 12px; + font-size: 12px; } .attachment-chip .file-name { - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .attachment-chip .remove-btn { - background: transparent; - border: none; - color: inherit; - cursor: pointer; - padding: 2px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - opacity: 0.7; - transition: all var(--transition-fast); + background: transparent; + border: none; + color: inherit; + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + opacity: 0.7; + transition: all var(--transition-fast); } .attachment-chip .remove-btn:hover { - opacity: 1; - background: rgba(255, 255, 255, 0.2); + opacity: 1; + background: rgba(255, 255, 255, 0.2); } .attachment-chip .remove-btn svg { - width: 12px; - height: 12px; + width: 12px; + height: 12px; } .empty-state-text { - font-size: 12px; - line-height: 1.6; - max-width: 220px; - opacity: 0.8; + font-size: 12px; + line-height: 1.6; + max-width: 220px; + opacity: 0.8; } .file-preview { - background: var(--vscode-textCodeBlock-background); - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); - border-radius: var(--chat-radius-sm); - margin: 8px 0; - overflow: hidden; + background: var(--vscode-textCodeBlock-background); + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.15)); + border-radius: var(--chat-radius-sm); + margin: 8px 0; + overflow: hidden; } .file-preview-header { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: rgba(128, 128, 128, 0.1); - font-size: 11px; - font-weight: 500; - color: var(--vscode-descriptionForeground); + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: rgba(128, 128, 128, 0.1); + font-size: 11px; + font-weight: 500; + color: var(--vscode-descriptionForeground); } .file-preview-content { - padding: 8px 10px; - font-family: var(--vscode-editor-font-family); - font-size: 11px; - max-height: 150px; - overflow: auto; - white-space: pre-wrap; - word-break: break-word; + padding: 8px 10px; + font-family: var(--vscode-editor-font-family); + font-size: 11px; + max-height: 150px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; } .empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - text-align: center; - color: var(--vscode-descriptionForeground); - padding: 24px 16px; - gap: 12px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + text-align: center; + color: var(--vscode-descriptionForeground); + padding: 24px 16px; + gap: 12px; } .empty-state-icon { - font-size: 36px; - opacity: 0.6; - filter: grayscale(0.3); + font-size: 36px; + opacity: 0.6; + filter: grayscale(0.3); } .empty-state-text { - font-size: 12px; - line-height: 1.6; - max-width: 220px; - opacity: 0.8; + font-size: 12px; + line-height: 1.6; + max-width: 220px; + opacity: 0.8; } .empty-state-hint { - font-size: 10px; - opacity: 0.5; - display: flex; - align-items: center; - gap: 4px; + font-size: 10px; + opacity: 0.5; + display: flex; + align-items: center; + gap: 4px; } .suggestions { - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 6px; - margin-top: 8px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + margin-top: 8px; } .suggestion-btn { - padding: 5px 10px; - font-size: 11px; - background: transparent; - color: var(--vscode-textLink-foreground); - border: 1px solid var(--vscode-textLink-foreground); - border-radius: 20px; - cursor: pointer; - transition: all var(--transition-fast); - opacity: 0.7; + padding: 5px 10px; + font-size: 11px; + background: transparent; + color: var(--vscode-textLink-foreground); + border: 1px solid var(--vscode-textLink-foreground); + border-radius: 20px; + cursor: pointer; + transition: all var(--transition-fast); + opacity: 0.7; } .suggestion-btn:hover { - opacity: 1; - background: var(--vscode-textLink-foreground); - color: var(--vscode-button-foreground); - transform: translateY(-1px); + opacity: 1; + background: var(--vscode-textLink-foreground); + color: var(--vscode-button-foreground); + transform: translateY(-1px); } .typing-indicator { - display: none; - padding: 12px 16px; - background-color: color-mix(in srgb, var(--vscode-editor-background) 50%, var(--vscode-sideBar-background) 50%); - border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); - border-radius: var(--chat-radius); - border-bottom-left-radius: 4px; - width: fit-content; - animation: messageIn 0.3s ease; + display: none; + padding: 12px 16px; + background-color: color-mix(in srgb, var(--vscode-editor-background) 50%, var(--vscode-sideBar-background) 50%); + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + border-radius: var(--chat-radius); + border-bottom-left-radius: 4px; + width: fit-content; + animation: messageIn 0.3s ease; } .typing-indicator.visible { - display: block; + display: block; } .typing-dots { - display: flex; - gap: 5px; - align-items: center; + display: flex; + gap: 5px; + align-items: center; } .typing-dots span { - width: 6px; - height: 6px; - background-color: var(--vscode-descriptionForeground); - border-radius: 50%; - animation: pulse 1.4s infinite ease-in-out; + width: 6px; + height: 6px; + background-color: var(--vscode-descriptionForeground); + border-radius: 50%; + animation: pulse 1.4s infinite ease-in-out; + } + + .typing-dots span:nth-child(1) { + animation-delay: 0s; } - .typing-dots span:nth-child(1) { animation-delay: 0s; } - .typing-dots span:nth-child(2) { animation-delay: 0.15s; } - .typing-dots span:nth-child(3) { animation-delay: 0.3s; } + .typing-dots span:nth-child(2) { + animation-delay: 0.15s; + } + + .typing-dots span:nth-child(3) { + animation-delay: 0.3s; + } @keyframes pulse { - 0%, 80%, 100% { - opacity: 0.3; - transform: scale(0.8); - } - 40% { - opacity: 1; - transform: scale(1); - } + + 0%, + 80%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + + 40% { + opacity: 1; + transform: scale(1); + } } .loading-text { - font-size: 11px; - color: var(--vscode-descriptionForeground); - font-style: italic; - margin-top: 6px; - animation: fadeInOut 0.3s ease; + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; + margin-top: 6px; + animation: fadeInOut 0.3s ease; } .cancel-btn { - display: inline-flex; - align-items: center; - gap: 4px; - margin-top: 8px; - padding: 4px 10px; - font-size: 11px; - color: var(--vscode-errorForeground); - background: transparent; - border: 1px solid var(--vscode-errorForeground); - border-radius: 4px; - cursor: pointer; - transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 8px; + padding: 4px 10px; + font-size: 11px; + color: var(--vscode-errorForeground); + background: transparent; + border: 1px solid var(--vscode-errorForeground); + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; } .cancel-btn:hover { - background: var(--vscode-errorForeground); - color: var(--vscode-editor-background); + background: var(--vscode-errorForeground); + color: var(--vscode-editor-background); } @keyframes fadeInOut { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + + to { + opacity: 1; + } } .typing-cursor { - display: inline-block; - width: 2px; - height: 1em; - background-color: var(--vscode-foreground); - margin-left: 1px; - animation: blink 0.8s infinite; - vertical-align: text-bottom; + display: inline-block; + width: 2px; + height: 1em; + background-color: var(--vscode-foreground); + margin-left: 1px; + animation: blink 0.8s infinite; + vertical-align: text-bottom; } @keyframes blink { - 0%, 50% { opacity: 1; } - 51%, 100% { opacity: 0; } + + 0%, + 50% { + opacity: 1; + } + + 51%, + 100% { + opacity: 0; + } } /* Focus styles for accessibility */ @@ -1075,349 +1121,351 @@ .send-btn:focus-visible, .attach-btn:focus-visible, .header-btn:focus-visible { - outline: 2px solid var(--vscode-focusBorder); - outline-offset: 2px; + outline: 2px solid var(--vscode-focusBorder); + outline-offset: 2px; } .input-wrapper { - display: flex; - flex-direction: column; - position: relative; + display: flex; + flex-direction: column; + position: relative; } .input-wrapper.has-attachments .input-container { - border-top-left-radius: 0; - border-top-right-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; } /* @ Mention styles */ .mention-btn { - flex-shrink: 0; - width: 28px; - height: 28px; - padding: 0; - background: rgba(128, 128, 128, 0.15); - color: var(--vscode-descriptionForeground); - border: none; - border-radius: 8px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all var(--transition-fast); - font-weight: bold; - font-size: 14px; + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + background: rgba(128, 128, 128, 0.15); + color: var(--vscode-descriptionForeground); + border: none; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); + font-weight: bold; + font-size: 14px; } .mention-btn:hover:not(:disabled) { - background: rgba(128, 128, 128, 0.25); - color: var(--vscode-foreground); - } + background: rgba(128, 128, 128, 0.25); + color: var(--vscode-foreground); } .mention-btn:disabled { - opacity: 0.3; - cursor: not-allowed; + opacity: 0.3; + cursor: not-allowed; } .mention-picker { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - max-height: 250px; - background: var(--vscode-dropdown-background); - border: 1px solid var(--vscode-dropdown-border); - border-radius: var(--chat-radius); - margin-bottom: 4px; - display: none; - flex-direction: column; - overflow: hidden; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - z-index: 1000; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + max-height: 250px; + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); + border-radius: var(--chat-radius); + margin-bottom: 4px; + display: none; + flex-direction: column; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 1000; } .mention-picker.visible { - display: flex; + display: flex; } .mention-picker-header { - padding: 8px 12px; - border-bottom: 1px solid var(--vscode-widget-border); - display: flex; - align-items: center; - gap: 8px; - font-size: 11px; - color: var(--vscode-descriptionForeground); + padding: 8px 12px; + border-bottom: 1px solid var(--vscode-widget-border); + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); } .mention-breadcrumbs { - display: flex; - align-items: center; - gap: 4px; - padding: 4px 8px; - border-bottom: 1px solid var(--vscode-widget-border); - font-size: 11px; - overflow-x: auto; - white-space: nowrap; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-bottom: 1px solid var(--vscode-widget-border); + font-size: 11px; + overflow-x: auto; + white-space: nowrap; } .mention-breadcrumb-item { - cursor: pointer; - color: var(--vscode-textLink-foreground); - padding: 2px 4px; - border-radius: 3px; + cursor: pointer; + color: var(--vscode-textLink-foreground); + padding: 2px 4px; + border-radius: 3px; } .mention-breadcrumb-item:hover { - background: var(--vscode-list-hoverBackground); + background: var(--vscode-list-hoverBackground); } .mention-breadcrumb-separator { - color: var(--vscode-descriptionForeground); + color: var(--vscode-descriptionForeground); } .mention-picker-search { - flex: 1; - padding: 6px 10px; - border: 1px solid var(--vscode-input-border); - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: var(--chat-radius-sm); - font-size: 12px; - outline: none; + flex: 1; + padding: 6px 10px; + border: 1px solid var(--vscode-input-border); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: var(--chat-radius-sm); + font-size: 12px; + outline: none; } .mention-picker-search:focus { - border-color: var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); } .mention-picker-search::placeholder { - color: var(--vscode-input-placeholderForeground); + color: var(--vscode-input-placeholderForeground); } .mention-picker-list { - flex: 1; - overflow-y: auto; - padding: 4px; + flex: 1; + overflow-y: auto; + padding: 4px; } .mention-item { - padding: 8px 10px; - border-radius: var(--chat-radius-sm); - cursor: pointer; - display: flex; - flex-direction: column; - gap: 2px; - transition: all var(--transition-fast); + padding: 8px 10px; + border-radius: var(--chat-radius-sm); + cursor: pointer; + display: flex; + flex-direction: column; + gap: 2px; + transition: all var(--transition-fast); } - .mention-item:hover, .mention-item.selected { - background: var(--vscode-list-hoverBackground); + .mention-item:hover, + .mention-item.selected { + background: var(--vscode-list-hoverBackground); } .mention-item-name { - font-size: 12px; - font-weight: 500; - display: flex; - align-items: center; - gap: 6px; + font-size: 12px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; } .mention-item-meta { - font-size: 10px; - color: var(--vscode-descriptionForeground); - margin-left: 20px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - opacity: 0.8; + font-size: 10px; + color: var(--vscode-descriptionForeground); + margin-left: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0.8; } .mention-item-type { - font-size: 9px; - padding: 1px 5px; - border-radius: 8px; - background: var(--vscode-badge-background); - color: var(--vscode-badge-foreground); - text-transform: uppercase; + font-size: 9px; + padding: 1px 5px; + border-radius: 8px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + text-transform: uppercase; } .mention-item-breadcrumb { - font-size: 10px; - color: var(--vscode-descriptionForeground); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + font-size: 10px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .mention-picker-empty { - padding: 16px; - text-align: center; - color: var(--vscode-descriptionForeground); - font-size: 12px; + padding: 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 12px; } .mention-group-header { - padding: 6px 12px 4px; - font-size: 10px; - font-weight: 600; - color: var(--vscode-descriptionForeground); - text-transform: uppercase; - letter-spacing: 0.5px; - background: var(--vscode-sideBar-background); - border-top: 1px solid var(--vscode-panel-border); - position: sticky; - top: 0; + padding: 6px 12px 4px; + font-size: 10px; + font-weight: 600; + color: var(--vscode-descriptionForeground); + text-transform: uppercase; + letter-spacing: 0.5px; + background: var(--vscode-sideBar-background); + border-top: 1px solid var(--vscode-panel-border); + position: sticky; + top: 0; } .mention-group-header:first-child { - border-top: none; + border-top: none; } .mention-picker-more { - padding: 8px 12px; - font-size: 11px; - color: var(--vscode-textLink-foreground); - text-align: center; - font-style: italic; - border-top: 1px solid var(--vscode-panel-border); + padding: 8px 12px; + font-size: 11px; + color: var(--vscode-textLink-foreground); + text-align: center; + font-style: italic; + border-top: 1px solid var(--vscode-panel-border); } .mention-item-label { - font-family: var(--vscode-font-family); + font-family: var(--vscode-font-family); } .mention-picker-loading { - padding: 16px; - text-align: center; - color: var(--vscode-descriptionForeground); - font-size: 12px; + padding: 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 12px; } /* Mention chips in attachments area */ .mention-chip { - display: flex; - align-items: center; - gap: 6px; - padding: 4px 8px 4px 10px; - background: linear-gradient(135deg, #3b82f6, #2563eb); - color: #ffffff; - border-radius: 16px; - font-size: 11px; - font-weight: 500; - animation: chipIn 0.2s ease; - box-shadow: 0 1px 3px rgba(37, 99, 235, 0.3); + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px 4px 10px; + background: linear-gradient(135deg, #3b82f6, #2563eb); + color: #ffffff; + border-radius: 16px; + font-size: 11px; + font-weight: 500; + animation: chipIn 0.2s ease; + box-shadow: 0 1px 3px rgba(37, 99, 235, 0.3); } .mention-chip .mention-icon { - font-size: 10px; + font-size: 10px; } .mention-chip .mention-name { - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.2; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; } .mention-chip-content { - display: flex; - flex-direction: column; - justify-content: center; + display: flex; + flex-direction: column; + justify-content: center; } .mention-chip-meta { - font-size: 9px; - opacity: 0.85; - max-width: 120px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.1; + font-size: 9px; + opacity: 0.85; + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; } .mention-chip .remove-btn { - background: transparent; - border: none; - color: inherit; - cursor: pointer; - padding: 2px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - opacity: 0.7; - transition: all var(--transition-fast); + background: transparent; + border: none; + color: inherit; + cursor: pointer; + padding: 2px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + opacity: 0.7; + transition: all var(--transition-fast); } .mention-chip .remove-btn:hover { - opacity: 1; - background: rgba(255, 255, 255, 0.2); + opacity: 1; + background: rgba(255, 255, 255, 0.2); } .mention-chip .remove-btn svg { - width: 12px; - height: 12px; + width: 12px; + height: 12px; } /* Mentions container - shared with attachments */ .attachments-container.has-mentions { - display: flex; + display: flex; } /* Type icons for database objects */ .db-type-icon { - font-size: 11px; + font-size: 11px; } /* Inline @mention highlight in messages */ .mention-inline { - background: var(--vscode-textLink-foreground); - color: var(--vscode-button-foreground); - padding: 1px 6px; - border-radius: 10px; - font-size: 0.9em; - font-weight: 500; - white-space: nowrap; + background: var(--vscode-textLink-foreground); + color: var(--vscode-button-foreground); + padding: 1px 6px; + border-radius: 10px; + font-size: 0.9em; + font-weight: 500; + white-space: nowrap; } /* Toast notifications */ .toast { - position: fixed; - bottom: 80px; - left: 50%; - transform: translateX(-50%) translateY(20px); - padding: 10px 16px; - border-radius: 8px; - font-size: 12px; - max-width: 90%; - z-index: 1000; - opacity: 0; - transition: all 0.3s ease; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%) translateY(20px); + padding: 10px 16px; + border-radius: 8px; + font-size: 12px; + max-width: 90%; + z-index: 1000; + opacity: 0; + transition: all 0.3s ease; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .toast.visible { - opacity: 1; - transform: translateX(-50%) translateY(0); + opacity: 1; + transform: translateX(-50%) translateY(0); } .toast-info { - background: var(--vscode-notifications-background); - color: var(--vscode-notifications-foreground); - border: 1px solid var(--vscode-notifications-border); + background: var(--vscode-notifications-background); + color: var(--vscode-notifications-foreground); + border: 1px solid var(--vscode-notifications-border); } .toast-warning { - background: var(--vscode-inputValidation-warningBackground, #5a4a00); - color: var(--vscode-inputValidation-warningForeground, #fff); - border: 1px solid var(--vscode-inputValidation-warningBorder, #f0a800); + background: var(--vscode-inputValidation-warningBackground, #5a4a00); + color: var(--vscode-inputValidation-warningForeground, #fff); + border: 1px solid var(--vscode-inputValidation-warningBorder, #f0a800); } .toast-error { - background: var(--vscode-inputValidation-errorBackground, #5a1d1d); - color: var(--vscode-inputValidation-errorForeground, #fff); - border: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c); + background: var(--vscode-inputValidation-errorBackground, #5a1d1d); + color: var(--vscode-inputValidation-errorForeground, #fff); + border: 1px solid var(--vscode-inputValidation-errorBorder, #f14c4c); } + + /* Auto-context styles */ \ No newline at end of file