diff --git a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs index 136c63b..922e0a7 100644 --- a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs +++ b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs @@ -429,4 +429,35 @@ public async Task SaveRecentConnectionAsync( throw; } } + + /// + /// Deletes a recent connection by its unique identifier. + /// + /// + /// If no row with the given Id exists the operation + /// completes silently — SQLite DELETE is a no-op when no rows match. + /// + [JsonRpcMethod("DeleteRecentConnectionAsync", UseSingleObjectParameterDeserialization = true)] + public async Task DeleteRecentConnectionAsync( + DeleteRecentConnectionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await _connectionRepository.Delete(request.Id).ConfigureAwait(false); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Recent connection deleted: Id={Id}", request.Id); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete recent connection: {Id}", request.Id); + throw; + } + } } diff --git a/src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs b/src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs new file mode 100644 index 0000000..f458279 --- /dev/null +++ b/src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs @@ -0,0 +1,10 @@ +namespace LightQueryProfiler.JsonRpc.Models; + +/// +/// Request model for deleting a recent connection by its unique identifier. +/// +public record DeleteRecentConnectionRequest +{ + /// Gets the unique identifier of the connection to delete. + public required int Id { get; init; } +} diff --git a/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs b/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs index ff628a7..149a2f1 100644 --- a/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs +++ b/tests/LightQueryProfiler.JsonRpc.Tests/JsonRpcServerTests.cs @@ -373,6 +373,51 @@ public async Task GetRecentConnectionsAsync_WhenConnectionStringModeRow_ReturnsD Assert.Equal("mydb", dto.InitialCatalog); } + // ─── DeleteRecentConnectionAsync ──────────────────────────────────────────── + + [Fact] + public async Task DeleteRecentConnectionAsync_WhenRequestIsNull_ThrowsArgumentNullException() + { + // Arrange + var mockRepo = new Mock(); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + + // Act & Assert + await Assert.ThrowsAsync(() => + server.DeleteRecentConnectionAsync(null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteRecentConnectionAsync_WhenValidId_CallsRepositoryDelete() + { + // Arrange + var mockRepo = new Mock(); + mockRepo.Setup(r => r.Delete(It.IsAny())).Returns(Task.CompletedTask); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + var request = new DeleteRecentConnectionRequest { Id = 42 }; + + // Act + await server.DeleteRecentConnectionAsync(request, TestContext.Current.CancellationToken); + + // Assert + mockRepo.Verify(r => r.Delete(42), Times.Once); + } + + [Fact] + public async Task DeleteRecentConnectionAsync_WhenRepositoryThrows_PropagatesException() + { + // Arrange + var mockRepo = new Mock(); + mockRepo.Setup(r => r.Delete(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("DB error")); + var server = new JsonRpcServer(_mockLogger.Object, mockRepo.Object); + var request = new DeleteRecentConnectionRequest { Id = 99 }; + + // Act & Assert + await Assert.ThrowsAsync(() => + server.DeleteRecentConnectionAsync(request, TestContext.Current.CancellationToken)); + } + [Fact] public async Task StartProfilingAsync_WhenEngineTypeIsZero_IsValidInput() { diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index ce0c7b3..471d199 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to the Light Query Profiler extension will be documented in The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] - 2026-04-21 + +### Added + +- **Recent Connections – Start Profiling button**: Each connection row in the Recent + Connections panel now has a **▶ Start** button. Clicking it opens the profiler panel, + fills the connection form, and starts profiling automatically — no extra click required. +- **Recent Connections – Delete button**: Each connection row now has a **✕ Delete** button + that permanently removes the entry from the local database and refreshes the list in place. +- New `DeleteRecentConnectionAsync` JSON-RPC endpoint in the .NET backend that delegates to + the existing `ConnectionRepository.Delete(int id)` implementation. +- New `deleteRecentConnection(id)` method on `ProfilerClient` for TypeScript consumers. +- New `startProfilingWithConnection(connection)` public method on `ProfilerPanelProvider` + to support programmatic connection-fill-and-start from external providers. + ## [1.3.0] - 2026-04-xx ### Added diff --git a/vscode-extension/package.json b/vscode-extension/package.json index 32259db..e08da71 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "light-query-profiler", "displayName": "Light Query Profiler", "description": "SQL Server and Azure SQL Database query profiler for VS Code", - "version": "1.3.0", + "version": "1.4.0", "publisher": "brandochn", "author": { "name": "Hildebrando Chávez", diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 9fef1d0..4170f42 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -1,10 +1,10 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import * as fs from "fs"; -import { ProfilerPanelProvider } from "./views/profiler-panel-provider"; -import { RecentConnectionsPanelProvider } from "./views/recent-connections-panel-provider"; -import { ProfilerClient } from "./services/profiler-client"; -import { RecentConnection } from "./models/recent-connection"; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { ProfilerPanelProvider } from './views/profiler-panel-provider'; +import { RecentConnectionsPanelProvider } from './views/recent-connections-panel-provider'; +import { ProfilerClient } from './services/profiler-client'; +import { RecentConnection } from './models/recent-connection'; /** * Logger interface for structured logging @@ -46,11 +46,11 @@ export async function activate( ): Promise { // Create output channel first for logging state.outputChannel = vscode.window.createOutputChannel( - "Light Query Profiler", + 'Light Query Profiler', ); const log = createLogger(state.outputChannel); - log.info("Activating Light Query Profiler extension..."); + log.info('Activating Light Query Profiler extension...'); // IMPORTANT: Register the command IMMEDIATELY — before any awaits. // VS Code may dispatch the command while activate() is still running its @@ -61,14 +61,14 @@ export async function activate( // panel or queues a retry once initialization completes. let activationReady = false; const exportEventsCommand = vscode.commands.registerCommand( - "lightQueryProfiler.exportEvents", + 'lightQueryProfiler.exportEvents', () => { - log.info("Export Events command executed"); + log.info('Export Events command executed'); if (state.profilerPanelProvider) { void state.profilerPanelProvider.exportEvents(); } else { void vscode.window.showErrorMessage( - "Light Query Profiler: Extension is not initialized.", + 'Light Query Profiler: Extension is not initialized.', ); } }, @@ -76,14 +76,14 @@ export async function activate( context.subscriptions.push(exportEventsCommand); const importEventsCommand = vscode.commands.registerCommand( - "lightQueryProfiler.importEvents", + 'lightQueryProfiler.importEvents', () => { - log.info("Import Events command executed"); + log.info('Import Events command executed'); if (state.profilerPanelProvider) { void state.profilerPanelProvider.importEvents(); } else { void vscode.window.showErrorMessage( - "Light Query Profiler: Extension is not initialized.", + 'Light Query Profiler: Extension is not initialized.', ); } }, @@ -91,14 +91,14 @@ export async function activate( context.subscriptions.push(importEventsCommand); const showRecentConnectionsCommand = vscode.commands.registerCommand( - "lightQueryProfiler.showRecentConnections", + 'lightQueryProfiler.showRecentConnections', () => { - log.info("Show Recent Connections command executed"); + log.info('Show Recent Connections command executed'); if (state.recentConnectionsPanelProvider) { void state.recentConnectionsPanelProvider.show(); } else { void vscode.window.showErrorMessage( - "Light Query Profiler: Extension is not initialized.", + 'Light Query Profiler: Extension is not initialized.', ); } }, @@ -106,19 +106,19 @@ export async function activate( context.subscriptions.push(showRecentConnectionsCommand); const showProfilerCommand = vscode.commands.registerCommand( - "lightQueryProfiler.showProfiler", + 'lightQueryProfiler.showProfiler', () => { - log.info("Show SQL Profiler command executed"); + log.info('Show SQL Profiler command executed'); if (state.profilerPanelProvider) { state.profilerPanelProvider.showPanel(); } else if (!activationReady) { // Extension is still initializing — wait for it then open the panel - log.info("Provider not ready yet, deferring panel open..."); + log.info('Provider not ready yet, deferring panel open...'); const deferredInterval = setInterval(() => { if (state.profilerPanelProvider) { clearInterval(deferredInterval); clearTimeout(deferredTimeout); - log.info("Provider ready, opening deferred panel"); + log.info('Provider ready, opening deferred panel'); state.profilerPanelProvider.showPanel(); } }, 50); @@ -137,9 +137,9 @@ export async function activate( }, }); } else { - log.error("Profiler panel provider not initialized"); + log.error('Profiler panel provider not initialized'); void vscode.window.showErrorMessage( - "Failed to open SQL Profiler. Please reload the window.", + 'Failed to open SQL Profiler. Please reload the window.', ); } }, @@ -150,10 +150,10 @@ export async function activate( // Get server DLL path and dotnet path in parallel (no duplicate dotnet check) const serverDllPath = getServerDllPath(context, log); if (!serverDllPath) { - const message = "Light Query Profiler server not found."; + const message = 'Light Query Profiler server not found.'; log.error(message); activationReady = true; - await vscode.window.showErrorMessage(message, "Error"); + await vscode.window.showErrorMessage(message, 'Error'); return; } @@ -183,10 +183,17 @@ export async function activate( state.profilerClient, state.outputChannel, (connection: RecentConnection) => { + // Double-click / Enter: fill connection fields only (no auto-start). // NOTE: method is showPanel(), not show() state.profilerPanelProvider?.showPanel(); state.profilerPanelProvider?.fillConnectionFields(connection); }, + (connection: RecentConnection) => { + // "Start Profiling" button: fill connection fields and start profiling automatically. + void state.profilerPanelProvider?.startProfilingWithConnection( + connection, + ); + }, ); // Wire the "Recent Connections" toolbar button inside the profiler webview @@ -200,7 +207,7 @@ export async function activate( { dispose: async () => { if (state.profilerPanelProvider) { - log.info("Disposing profiler panel provider..."); + log.info('Disposing profiler panel provider...'); await state.profilerPanelProvider.dispose(); } }, @@ -208,7 +215,7 @@ export async function activate( { dispose: () => { if (state.recentConnectionsPanelProvider) { - log.info("Disposing recent connections panel provider..."); + log.info('Disposing recent connections panel provider...'); state.recentConnectionsPanelProvider.dispose(); } }, @@ -216,7 +223,7 @@ export async function activate( { dispose: () => { if (state.profilerClient) { - log.info("Disposing profiler client..."); + log.info('Disposing profiler client...'); state.profilerClient.dispose(); } }, @@ -224,11 +231,11 @@ export async function activate( ); activationReady = true; - log.info("Light Query Profiler extension activated successfully"); + log.info('Light Query Profiler extension activated successfully'); // Show welcome message only on first activation const hasShownWelcomeMessage = context.globalState.get( - "hasShownWelcomeMessage", + 'hasShownWelcomeMessage', false, ); if (!hasShownWelcomeMessage) { @@ -238,7 +245,7 @@ export async function activate( ) .then(() => { // Mark as shown after user dismisses or acknowledges the message - void context.globalState.update("hasShownWelcomeMessage", true); + void context.globalState.update('hasShownWelcomeMessage', true); }); } } catch (error) { @@ -254,10 +261,10 @@ export async function activate( await vscode.window .showErrorMessage( `Failed to activate Light Query Profiler: ${errorMessage}`, - "View Logs", + 'View Logs', ) .then((selection) => { - if (selection === "View Logs" && state.outputChannel) { + if (selection === 'View Logs' && state.outputChannel) { state.outputChannel.show(); } }); @@ -285,7 +292,7 @@ export async function deactivate(): Promise { }, }; - log.info("Deactivating Light Query Profiler extension..."); + log.info('Deactivating Light Query Profiler extension...'); // Cleanup is primarily handled by context.subscriptions dispose // But we ensure proper cleanup order here @@ -304,7 +311,7 @@ export async function deactivate(): Promise { } if (state.outputChannel) { - log.info("Light Query Profiler extension deactivated"); + log.info('Light Query Profiler extension deactivated'); state.outputChannel.dispose(); state.outputChannel = undefined; } @@ -322,21 +329,21 @@ function getServerDllPath( log: Logger, ): string | undefined { const possiblePaths: ReadonlyArray = [ - path.join(context.extensionPath, "bin", "LightQueryProfiler.JsonRpc.dll"), + path.join(context.extensionPath, 'bin', 'LightQueryProfiler.JsonRpc.dll'), path.join( context.extensionPath, - "server", - "LightQueryProfiler.JsonRpc.dll", + 'server', + 'LightQueryProfiler.JsonRpc.dll', ), path.join( context.extensionPath, - "dist", - "server", - "LightQueryProfiler.JsonRpc.dll", + 'dist', + 'server', + 'LightQueryProfiler.JsonRpc.dll', ), ]; - log.info("Searching for server DLL in the following paths:"); + log.info('Searching for server DLL in the following paths:'); for (const dllPath of possiblePaths) { log.info(` - ${dllPath}`); try { @@ -349,7 +356,7 @@ function getServerDllPath( } } - log.error("Server DLL not found in any expected location"); + log.error('Server DLL not found in any expected location'); return undefined; } @@ -368,7 +375,7 @@ async function getDotnetPath(log: Logger): Promise { // Default to 'dotnet' and let the OS resolve it log.warn("Could not verify dotnet installation, using 'dotnet' as default"); - return "dotnet"; + return 'dotnet'; } /** @@ -379,15 +386,15 @@ async function getDotnetPath(log: Logger): Promise { */ async function findDotnetInPath(log: Logger): Promise { try { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); + const { exec } = await import('child_process'); + const { promisify } = await import('util'); const execAsync = promisify(exec); - log.info("Checking for dotnet installation..."); - const { stdout } = await execAsync("dotnet --version"); + log.info('Checking for dotnet installation...'); + const { stdout } = await execAsync('dotnet --version'); const version = stdout.trim(); log.info(`Found dotnet version: ${version}`); - return "dotnet"; + return 'dotnet'; } catch (error) { log.warn(`dotnet not found in PATH: ${String(error)}`); return undefined; diff --git a/vscode-extension/src/services/profiler-client.ts b/vscode-extension/src/services/profiler-client.ts index 5cc444e..50c5722 100644 --- a/vscode-extension/src/services/profiler-client.ts +++ b/vscode-extension/src/services/profiler-client.ts @@ -73,6 +73,19 @@ const saveRecentConnectionRequestType = new RequestType< void >('SaveRecentConnectionAsync'); +/** + * Request parameters for deleting a recent connection + */ +interface DeleteRecentConnectionRequest { + readonly id: number; +} + +const deleteRecentConnectionRequestType = new RequestType< + DeleteRecentConnectionRequest, + void, + void +>('DeleteRecentConnectionAsync'); + /** * Client state enum for tracking lifecycle * @remarks Forms a discriminated union for state machine implementation @@ -358,6 +371,25 @@ export class ProfilerClient { } } + /** + * Deletes a recent connection from the backend store by its identifier. + * @param id - Unique identifier of the connection to delete. + * @throws Error if the server is not running or the request fails. + */ + public async deleteRecentConnection(id: number): Promise { + this.ensureRunning(); + + try { + await this.connection!.sendRequest(deleteRecentConnectionRequestType, { + id, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logError(`Failed to delete recent connection: ${message}`); + throw new Error(`Failed to delete recent connection: ${message}`); + } + } + /** * Checks if the server is running * @returns True if server is running diff --git a/vscode-extension/src/views/profiler-panel-provider.ts b/vscode-extension/src/views/profiler-panel-provider.ts index 89b84f0..1ce446d 100644 --- a/vscode-extension/src/views/profiler-panel-provider.ts +++ b/vscode-extension/src/views/profiler-panel-provider.ts @@ -1,17 +1,17 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import { ProfilerClient } from "../services/profiler-client"; -import { RecentConnection } from "../models/recent-connection"; +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ProfilerClient } from '../services/profiler-client'; +import { RecentConnection } from '../models/recent-connection'; import { EventExportImportService, DisplayEvent, -} from "../services/event-export-import.service"; +} from '../services/event-export-import.service'; import { AuthenticationMode, getAllAuthenticationModes, -} from "../models/authentication-mode"; -import { validateConnectionSettings } from "../models/connection-settings"; -import { ProfilerEvent } from "../models/profiler-event"; +} from '../models/authentication-mode'; +import { validateConnectionSettings } from '../models/connection-settings'; +import { ProfilerEvent } from '../models/profiler-event'; /** * Connection settings for SQL Server/Azure SQL @@ -29,9 +29,9 @@ interface ConnectionSettings { * Profiler state enumeration */ enum ProfilerState { - Stopped = "stopped", - Running = "running", - Paused = "paused", + Stopped = 'stopped', + Running = 'running', + Paused = 'paused', } /** @@ -52,17 +52,17 @@ interface EventFilter { */ interface WebviewIncomingMessage { command: - | "start" - | "stop" - | "pause" - | "resume" - | "clear" - | "applyFilters" - | "clearFilters" - | "exportEvents" - | "importEvents" - | "showRecentConnections" - | "webviewReady"; + | 'start' + | 'stop' + | 'pause' + | 'resume' + | 'clear' + | 'applyFilters' + | 'clearFilters' + | 'exportEvents' + | 'importEvents' + | 'showRecentConnections' + | 'webviewReady'; data?: ConnectionSettings | EventFilter; } @@ -71,15 +71,15 @@ interface WebviewIncomingMessage { */ interface WebviewOutgoingMessage { command: - | "updateState" - | "updateEventCount" - | "addEvents" - | "clearEvents" - | "updateFilter" - | "error" - | "setConnectionFieldsEnabled" - | "loadImportedEvents" - | "setConnectionFields"; + | 'updateState' + | 'updateEventCount' + | 'addEvents' + | 'clearEvents' + | 'updateFilter' + | 'error' + | 'setConnectionFieldsEnabled' + | 'loadImportedEvents' + | 'setConnectionFields'; data?: unknown; } @@ -97,19 +97,19 @@ export class ProfilerPanelProvider { private readonly profilerClient: ProfilerClient; private readonly extensionUri: vscode.Uri; private readonly outputChannel: vscode.OutputChannel; - private sessionName = "VSCodeProfilerSession"; + private sessionName = 'VSCodeProfilerSession'; private state: ProfilerState = ProfilerState.Stopped; private pollingInterval: NodeJS.Timeout | null = null; private readonly pollingIntervalMs = 900; // Match WinForms implementation private eventCount = 0; private readonly sessionEventKeys = new Set(); private eventFilter: EventFilter = { - eventClass: "", - textData: "", - applicationName: "", - ntUserName: "", - loginName: "", - databaseName: "", + eventClass: '', + textData: '', + applicationName: '', + ntUserName: '', + loginName: '', + databaseName: '', }; /** @@ -173,8 +173,8 @@ export class ProfilerPanelProvider { // Create new panel this.panel = vscode.window.createWebviewPanel( - "lightQueryProfiler", - "Light Query Profiler", + 'lightQueryProfiler', + 'Light Query Profiler', column, { enableScripts: true, @@ -192,8 +192,8 @@ export class ProfilerPanelProvider { // issue where the detailed 128×128 design becomes unrecognisable when // VS Code renders it at ~16 px in the editor tab strip. this.panel.iconPath = { - light: vscode.Uri.joinPath(this.extensionUri, "media", "icon-small.svg"), - dark: vscode.Uri.joinPath(this.extensionUri, "media", "icon-small.svg"), + light: vscode.Uri.joinPath(this.extensionUri, 'media', 'icon-small.svg'), + dark: vscode.Uri.joinPath(this.extensionUri, 'media', 'icon-small.svg'), }; // Handle messages from webview @@ -206,7 +206,7 @@ export class ProfilerPanelProvider { // Handle panel disposal this.panel.onDidDispose(() => { - this.log("Panel disposed"); + this.log('Panel disposed'); // Set panel to undefined first so postMessage becomes a no-op during cleanup. this.panel = undefined; if (this.state !== ProfilerState.Stopped) { @@ -222,7 +222,7 @@ export class ProfilerPanelProvider { } }, undefined); - this.log("Panel created and shown"); + this.log('Panel created and shown'); } /** @@ -235,52 +235,52 @@ export class ProfilerPanelProvider { try { switch (message.command) { - case "start": + case 'start': if (message.data && this.isConnectionSettings(message.data)) { await this.handleStart(message.data); } else { - await this.showError("Invalid connection settings"); + await this.showError('Invalid connection settings'); } break; - case "stop": + case 'stop': await this.handleStop(); break; - case "pause": + case 'pause': await this.handlePause(); break; - case "resume": + case 'resume': await this.handleResume(); break; - case "clear": + case 'clear': await this.handleClear(); break; - case "applyFilters": + case 'applyFilters': if (message.data && this.isEventFilter(message.data)) { await this.handleApplyFilters(message.data); } break; - case "clearFilters": + case 'clearFilters': await this.handleClearFilters(); break; - case "exportEvents": + case 'exportEvents': await this.exportEvents(); break; - case "importEvents": + case 'importEvents': await this.importEvents(); break; - case "showRecentConnections": + case 'showRecentConnections': if (this.onShowRecentConnectionsCallback) { this.onShowRecentConnectionsCallback(); } break; - case "webviewReady": + case 'webviewReady': // If importEvents() stored pending data while the panel was opening, // forward it now that the webview has signalled it is ready. if (this.pendingImportEvents) { const pending = this.pendingImportEvents; this.pendingImportEvents = null; await this.postMessage({ - command: "loadImportedEvents", + command: 'loadImportedEvents', data: pending, }); } @@ -304,7 +304,7 @@ export class ProfilerPanelProvider { * @remarks Validates connection, starts server session, and begins polling */ private async handleStart(settings: ConnectionSettings): Promise { - this.log("Starting profiling session..."); + this.log('Starting profiling session...'); this.currentConnectionSettings = settings; // Validate connection settings before attempting to connect. @@ -319,7 +319,7 @@ export class ProfilerPanelProvider { try { // Ensure the .NET server process is running before calling startProfiling if (!this.profilerClient.isRunning()) { - this.log("Server not running, starting server process..."); + this.log('Server not running, starting server process...'); await this.profilerClient.start(); } @@ -330,7 +330,7 @@ export class ProfilerPanelProvider { this.eventCount = 0; this.sessionEventKeys.clear(); this.capturedEvents = []; - await this.postMessage({ command: "clearEvents" }); + await this.postMessage({ command: 'clearEvents' }); // Update state and disable connection fields while profiling is active this.state = ProfilerState.Running; @@ -340,8 +340,8 @@ export class ProfilerPanelProvider { // Start polling for events this.startPolling(); - this.log("Profiling started successfully"); - await vscode.window.showInformationMessage("Profiling started"); + this.log('Profiling started successfully'); + await vscode.window.showInformationMessage('Profiling started'); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -359,7 +359,7 @@ export class ProfilerPanelProvider { */ private async updateState(): Promise { await this.postMessage({ - command: "updateState", + command: 'updateState', data: { state: this.state, eventCount: this.eventCount, @@ -375,7 +375,7 @@ export class ProfilerPanelProvider { */ private async setConnectionFieldsEnabled(enabled: boolean): Promise { await this.postMessage({ - command: "setConnectionFieldsEnabled", + command: 'setConnectionFieldsEnabled', data: enabled, }); } @@ -387,15 +387,15 @@ export class ProfilerPanelProvider { * @remarks Validates required properties: server, database, authenticationMode */ private isConnectionSettings(data: unknown): data is ConnectionSettings { - if (typeof data !== "object" || data === null) { + if (typeof data !== 'object' || data === null) { return false; } const obj = data as Record; return ( - typeof obj.server === "string" && - typeof obj.database === "string" && - typeof obj.authenticationMode === "number" + typeof obj.server === 'string' && + typeof obj.database === 'string' && + typeof obj.authenticationMode === 'number' ); } @@ -404,7 +404,7 @@ export class ProfilerPanelProvider { * @remarks Stops polling, terminates server session, and resets state */ private async handleStop(): Promise { - this.log("Stopping profiling session..."); + this.log('Stopping profiling session...'); this.stopPolling(); if (this.profilerClient.isRunning()) { @@ -437,7 +437,7 @@ export class ProfilerPanelProvider { engineType: undefined, connectionString: settings.connectionString, // ← NEW }); - this.log("Recent connection saved"); + this.log('Recent connection saved'); } catch (saveError) { const saveMessage = saveError instanceof Error ? saveError.message : String(saveError); @@ -448,8 +448,8 @@ export class ProfilerPanelProvider { } } - this.log("Profiling stopped"); - await vscode.window.showInformationMessage("Profiling stopped"); + this.log('Profiling stopped'); + await vscode.window.showInformationMessage('Profiling stopped'); } /** @@ -477,13 +477,13 @@ export class ProfilerPanelProvider { * @remarks Clears local event cache and resets event count without stopping profiling */ private async handleClear(): Promise { - this.log("Clearing events"); + this.log('Clearing events'); this.eventCount = 0; this.capturedEvents = []; // sessionEventKeys intentionally NOT cleared — session cache must survive Clear // so that already-seen ring_buffer events cannot re-appear after a clear. await this.postMessage({ - command: "clearEvents", + command: 'clearEvents', }); } @@ -495,7 +495,7 @@ export class ProfilerPanelProvider { private async handleApplyFilters(filter: EventFilter): Promise { this.eventFilter = filter; this.log(`Filters applied: ${JSON.stringify(filter)}`); - await this.postMessage({ command: "updateFilter", data: filter }); + await this.postMessage({ command: 'updateFilter', data: filter }); } /** @@ -504,15 +504,15 @@ export class ProfilerPanelProvider { */ private async handleClearFilters(): Promise { this.eventFilter = { - eventClass: "", - textData: "", - applicationName: "", - ntUserName: "", - loginName: "", - databaseName: "", + eventClass: '', + textData: '', + applicationName: '', + ntUserName: '', + loginName: '', + databaseName: '', }; - this.log("Filters cleared"); - await this.postMessage({ command: "updateFilter", data: this.eventFilter }); + this.log('Filters cleared'); + await this.postMessage({ command: 'updateFilter', data: this.eventFilter }); } /** @@ -520,14 +520,14 @@ export class ProfilerPanelProvider { */ private isEventFilter(data: unknown): data is EventFilter { return ( - typeof data === "object" && + typeof data === 'object' && data !== null && - "eventClass" in data && - "textData" in data && - "applicationName" in data && - "ntUserName" in data && - "loginName" in data && - "databaseName" in data + 'eventClass' in data && + 'textData' in data && + 'applicationName' in data && + 'ntUserName' in data && + 'loginName' in data && + 'databaseName' in data ); } @@ -538,7 +538,7 @@ export class ProfilerPanelProvider { * @remarks Called via the onServerStopped callback registered in the constructor. */ private async handleServerCrash(): Promise { - this.logError("Server stopped unexpectedly — resetting profiler state"); + this.logError('Server stopped unexpectedly — resetting profiler state'); this.stopPolling(); this.state = ProfilerState.Stopped; // Clear dedup cache: after a server restart sequence numbers start from 1 again, @@ -604,7 +604,7 @@ export class ProfilerPanelProvider { ...keys: string[] ): string => { if (!obj) { - return ""; + return ''; } for (const k of keys) { const v = obj[k]; @@ -612,7 +612,7 @@ export class ProfilerPanelProvider { return String(v); } } - return ""; + return ''; }; for (const event of events) { @@ -620,38 +620,38 @@ export class ProfilerPanelProvider { const a = event.actions; // TextData: options_text (login/logout), batch_text (sql_batch_*), statement (rpc_*) - const textData = str(f, "options_text", "batch_text", "statement"); + const textData = str(f, 'options_text', 'batch_text', 'statement'); const displayEvent = { - eventClass: event.name ?? "Unknown", + eventClass: event.name ?? 'Unknown', textData, - applicationName: str(a, "client_app_name"), - hostName: str(a, "client_hostname"), - ntUserName: str(a, "nt_username"), - loginName: str(a, "server_principal_name", "username"), - clientProcessId: str(a, "client_pid"), - spid: str(a, "session_id"), - startTime: event.timestamp ?? "", - cpu: str(f, "cpu_time"), - reads: str(f, "logical_reads"), - writes: str(f, "writes"), - duration: str(f, "duration"), - databaseId: str(f, "database_id"), - databaseName: str(a, "database_name"), + applicationName: str(a, 'client_app_name'), + hostName: str(a, 'client_hostname'), + ntUserName: str(a, 'nt_username'), + loginName: str(a, 'server_principal_name', 'username'), + clientProcessId: str(a, 'client_pid'), + spid: str(a, 'session_id'), + startTime: event.timestamp ?? '', + cpu: str(f, 'cpu_time'), + reads: str(f, 'logical_reads'), + writes: str(f, 'writes'), + duration: str(f, 'duration'), + databaseId: str(f, 'database_id'), + databaseName: str(a, 'database_name'), }; // Dedup key — mirrors ProfilerEvent.GetEventKey() priority exactly: // 1. event_sequence (unique counter per session, most reliable) // 2. attach_activity_id (GUID, unique per activity) // 3. timestamp|name|session_id (weakest, same format as C# fallback) - const seqKey = str(a, "event_sequence"); - const activityKey = str(a, "attach_activity_id"); - const sessionId = str(a, "session_id"); + const seqKey = str(a, 'event_sequence'); + const activityKey = str(a, 'attach_activity_id'); + const sessionId = str(a, 'session_id'); const eventKey = seqKey ? `seq:${seqKey}` : activityKey ? `activity:${activityKey}` - : `${event.timestamp ?? ""}|${event.name ?? ""}|${sessionId}`; + : `${event.timestamp ?? ''}|${event.name ?? ''}|${sessionId}`; if (this.sessionEventKeys.has(eventKey)) { continue; @@ -682,12 +682,12 @@ export class ProfilerPanelProvider { this.eventCount += newEvents.length; await this.postMessage({ - command: "addEvents", + command: 'addEvents', data: newEvents, }); await this.postMessage({ - command: "updateEventCount", + command: 'updateEventCount', data: this.eventCount, }); @@ -717,7 +717,7 @@ export class ProfilerPanelProvider { private async showError(message: string): Promise { this.logError(message); await this.postMessage({ - command: "error", + command: 'error', data: message, }); await vscode.window.showErrorMessage(`Light Query Profiler: ${message}`); @@ -740,11 +740,52 @@ export class ProfilerPanelProvider { */ public fillConnectionFields(connection: RecentConnection): void { void this.panel?.webview.postMessage({ - command: "setConnectionFields", + command: 'setConnectionFields', data: connection, }); } + /** + * Opens the profiler panel, fills the connection form, and automatically + * starts profiling using the given recent connection. + * Called when the user clicks "Start Profiling" in the Recent Connections panel. + * @param connection - The recent connection to use for profiling. + * @remarks Converts `RecentConnection` to the local `ConnectionSettings` shape + * before delegating to `handleStart`. + */ + public async startProfilingWithConnection( + connection: RecentConnection, + ): Promise { + // Guard: if a session is already running, show an error and abort. + // handleStart has no state check of its own — it relies on the webview UI + // disabling the Start button. Calling it while Running would corrupt state. + if (this.state === ProfilerState.Running) { + await this.showError( + 'A profiling session is already running. Please stop it first.', + ); + return; + } + + // Ensure the panel is open so the user sees feedback immediately. + this.showPanel(); + + // Populate the connection form fields for visual confirmation. + this.fillConnectionFields(connection); + + // Map RecentConnection → local ConnectionSettings. + const settings: ConnectionSettings = { + server: connection.dataSource, + database: connection.initialCatalog, + authenticationMode: (connection.authenticationMode ?? + 0) as AuthenticationMode, + username: connection.userId, + password: connection.password, + connectionString: connection.connectionString, + }; + + await this.handleStart(settings); + } + /** * Exports captured events to a JSON file chosen via a VS Code save dialog. * @@ -757,7 +798,7 @@ export class ProfilerPanelProvider { public async exportEvents(): Promise { if (this.capturedEvents.length === 0) { await vscode.window.showInformationMessage( - "Light Query Profiler: No events to export.", + 'Light Query Profiler: No events to export.', ); return; } @@ -769,9 +810,9 @@ export class ProfilerPanelProvider { const uri = await vscode.window.showSaveDialog({ defaultUri, // eslint-disable-next-line @typescript-eslint/naming-convention - filters: { "JSON Files": ["json"], "All Files": ["*"] }, - title: "Export Profiler Events", - saveLabel: "Export", + filters: { 'JSON Files': ['json'], 'All Files': ['*'] }, + title: 'Export Profiler Events', + saveLabel: 'Export', }); if (!uri) { @@ -814,9 +855,9 @@ export class ProfilerPanelProvider { canSelectFolders: false, canSelectMany: false, // eslint-disable-next-line @typescript-eslint/naming-convention - filters: { "JSON Files": ["json"], "All Files": ["*"] }, - title: "Import Profiler Events", - openLabel: "Import", + filters: { 'JSON Files': ['json'], 'All Files': ['*'] }, + title: 'Import Profiler Events', + openLabel: 'Import', }); if (!uris || uris.length === 0) { @@ -833,9 +874,9 @@ export class ProfilerPanelProvider { const answer = await vscode.window.showWarningMessage( `This will replace ${this.capturedEvents.length} existing event(s). Continue?`, { modal: true }, - "Replace", + 'Replace', ); - if (answer !== "Replace") { + if (answer !== 'Replace') { return; } } @@ -853,7 +894,7 @@ export class ProfilerPanelProvider { if (this.panel) { // Panel is already open — send directly await this.postMessage({ - command: "loadImportedEvents", + command: 'loadImportedEvents', data: imported, }); } else { @@ -891,7 +932,7 @@ export class ProfilerPanelProvider { * @remarks Stops polling and profiling session if active */ public async dispose(): Promise { - this.log("Disposing profiler panel provider..."); + this.log('Disposing profiler panel provider...'); this.stopPolling(); if (this.state !== ProfilerState.Stopped) { @@ -909,7 +950,7 @@ export class ProfilerPanelProvider { this.panel = undefined; } - this.log("Profiler panel provider disposed"); + this.log('Profiler panel provider disposed'); } /** @@ -947,20 +988,20 @@ export class ProfilerPanelProvider { const hlJsUri = webview .asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "media", "highlight.min.js"), + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight.min.js'), ) .toString(); const hlSqlUri = webview .asWebviewUri( - vscode.Uri.joinPath(this.extensionUri, "media", "highlight-sql.min.js"), + vscode.Uri.joinPath(this.extensionUri, 'media', 'highlight-sql.min.js'), ) .toString(); const hlCssUri = webview .asWebviewUri( vscode.Uri.joinPath( this.extensionUri, - "media", - "highlight-vs2015.min.css", + 'media', + 'highlight-vs2015.min.css', ), ) .toString(); @@ -1863,7 +1904,7 @@ export class ProfilerPanelProvider {
diff --git a/vscode-extension/src/views/recent-connections-panel-provider.ts b/vscode-extension/src/views/recent-connections-panel-provider.ts index 5afeef6..259c3cb 100644 --- a/vscode-extension/src/views/recent-connections-panel-provider.ts +++ b/vscode-extension/src/views/recent-connections-panel-provider.ts @@ -1,19 +1,21 @@ -import * as crypto from "crypto"; -import * as vscode from "vscode"; -import { ProfilerClient } from "../services/profiler-client"; -import { RecentConnection } from "../models/recent-connection"; +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import { ProfilerClient } from '../services/profiler-client'; +import { RecentConnection } from '../models/recent-connection'; // Messages the extension HOST receives FROM the webview type WebviewIncomingMessage = - | { command: "webviewReady" } - | { command: "refresh" } - | { command: "connectionSelected"; data: RecentConnection } - | { command: "error"; data: string }; + | { command: 'webviewReady' } + | { command: 'refresh' } + | { command: 'connectionSelected'; data: RecentConnection } + | { command: 'startProfiling'; data: RecentConnection } + | { command: 'deleteConnection'; data: number } + | { command: 'error'; data: string }; // Messages the extension HOST sends TO the webview type WebviewOutgoingMessage = - | { command: "updateConnections"; data: RecentConnection[] } - | { command: "error"; data: string }; + | { command: 'updateConnections'; data: RecentConnection[] } + | { command: 'error'; data: string }; /** * Manages the "Recent Connections" webview panel. @@ -30,6 +32,7 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { private readonly onConnectionSelected: ( connection: RecentConnection, ) => void, + private readonly onStartProfiling: (connection: RecentConnection) => void, ) {} /** @@ -46,8 +49,8 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { } this.panel = vscode.window.createWebviewPanel( - "recentConnections", - "Recent Connections", + 'recentConnections', + 'Recent Connections', vscode.ViewColumn.One, { enableScripts: true, @@ -75,20 +78,20 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { public async loadConnections(): Promise { try { if (!this.profilerClient.isRunning()) { - this.log("Server not running, starting server process..."); + this.log('Server not running, starting server process...'); await this.profilerClient.start(); } const connections = await this.profilerClient.getRecentConnections(); await this.postMessage({ - command: "updateConnections", + command: 'updateConnections', data: connections, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.logError(`Failed to load recent connections: ${errorMessage}`); - await this.postMessage({ command: "error", data: errorMessage }); + await this.postMessage({ command: 'error', data: errorMessage }); } } @@ -102,20 +105,29 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { private async handleMessage(message: WebviewIncomingMessage): Promise { switch (message.command) { - case "webviewReady": + case 'webviewReady': await this.loadConnections(); break; - case "refresh": + case 'refresh': await this.loadConnections(); break; - case "connectionSelected": + case 'connectionSelected': this.onConnectionSelected(message.data); this.panel?.dispose(); break; - case "error": + case 'startProfiling': + this.onStartProfiling(message.data); + this.panel?.dispose(); + break; + + case 'deleteConnection': + await this.deleteConnection(message.data); + break; + + case 'error': this.logError(`Webview error: ${message.data}`); break; @@ -124,6 +136,27 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { } } + /** + * Deletes a recent connection by its id and refreshes the list. + * Starts the JSON-RPC server first if it is not yet running. + */ + private async deleteConnection(id: number): Promise { + try { + if (!this.profilerClient.isRunning()) { + this.log('Server not running, starting server process...'); + await this.profilerClient.start(); + } + + await this.profilerClient.deleteRecentConnection(id); + await this.loadConnections(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logError(`Failed to delete connection ${id}: ${errorMessage}`); + await this.postMessage({ command: 'error', data: errorMessage }); + } + } + private async postMessage(message: WebviewOutgoingMessage): Promise { if (this.panel) { await this.panel.webview.postMessage(message); @@ -145,7 +178,7 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { } private getHtmlContent(_webview: vscode.Webview): string { - const nonce = crypto.randomBytes(16).toString("hex"); + const nonce = crypto.randomBytes(16).toString('hex'); return ` @@ -220,7 +253,7 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { .connection-item { display: grid; - grid-template-columns: 1fr 1fr auto; + grid-template-columns: 1fr 1fr auto auto; align-items: center; gap: 8px; padding: 6px 12px; @@ -229,6 +262,48 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { outline: none; } + .item-actions { + display: flex; + gap: 4px; + align-items: center; + } + + .action-btn { + padding: 2px 8px; + border: 1px solid var(--vscode-input-border, #555); + cursor: pointer; + font-size: 0.8em; + border-radius: 2px; + white-space: nowrap; + line-height: 1.5; + font-family: inherit; + } + + .action-btn:focus { + outline: 1px solid var(--vscode-focusBorder, #007fd4); + outline-offset: 1px; + } + + .start-btn { + background: var(--vscode-button-background, #0e639c); + color: var(--vscode-button-foreground, #fff); + } + + .start-btn:hover { + background: var(--vscode-button-hoverBackground, #1177bb); + } + + .delete-btn { + background: var(--vscode-button-secondaryBackground, transparent); + color: var(--vscode-button-secondaryForeground, var(--vscode-editor-foreground)); + } + + .delete-btn:hover { + background: var(--vscode-button-secondaryHoverBackground, var(--vscode-list-hoverBackground)); + color: var(--vscode-errorForeground, #f48771); + border-color: var(--vscode-errorForeground, #f48771); + } + .connection-item:hover { background-color: var(--vscode-list-hoverBackground); } @@ -253,11 +328,12 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { } .auth-badge { - background-color: var(--vscode-badge-background, #4d4d4d); - color: var(--vscode-badge-foreground, #fff); - border-radius: 3px; - padding: 2px 6px; - font-size: 0.85em; + background-color: transparent; + color: var(--vscode-terminal-ansiCyan, #11a8cd); + border: 1px solid var(--vscode-terminal-ansiCyan, #11a8cd); + border-radius: 10px; + padding: 1px 8px; + font-size: 0.8em; white-space: nowrap; } @@ -303,10 +379,22 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { list.innerHTML = connections .map( (conn, i) => - '
' + + '
' + ' ' + escapeHtml(conn.dataSource) + '' + ' ' + escapeHtml(conn.initialCatalog) + '' + ' ' + escapeHtml(getAuthLabel(conn.authenticationMode)) + '' + + '
' + + ' ' + + ' ' + + '
' + '
' ) .join(''); @@ -332,6 +420,34 @@ export class RecentConnectionsPanelProvider implements vscode.Disposable { moveFocus(-1); } }); + + // --- action buttons --- + var startBtn = item.querySelector('.start-btn'); + if (startBtn) { + startBtn.addEventListener('click', function (e) { + e.stopPropagation(); + var idx = parseInt(startBtn.getAttribute('data-index') || '0', 10); + var conn = connections[idx]; + if (conn) { + vscode.postMessage({ command: 'startProfiling', data: conn }); + } + }); + startBtn.addEventListener('dblclick', function (e) { + e.stopPropagation(); + }); + } + + var deleteBtn = item.querySelector('.delete-btn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', function (e) { + e.stopPropagation(); + var id = parseInt(deleteBtn.getAttribute('data-id') || '0', 10); + vscode.postMessage({ command: 'deleteConnection', data: id }); + }); + deleteBtn.addEventListener('dblclick', function (e) { + e.stopPropagation(); + }); + } }); }