diff --git a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs index 922e0a7..7aeebef 100644 --- a/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs +++ b/src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs @@ -302,12 +302,12 @@ await Task.Run(() => profilerService.PauseProfiling(request.SessionName), cancel } /// - /// Returns all saved recent connections sorted by most recent first. + /// Returns all saved connections sorted by most recent first. /// Passwords in the returned DTOs are already decrypted by the repository layer. /// - [JsonRpcMethod("GetRecentConnectionsAsync", UseSingleObjectParameterDeserialization = true)] - public async Task> GetRecentConnectionsAsync( - GetRecentConnectionsRequest request, + [JsonRpcMethod("GetConnectionsAsync", UseSingleObjectParameterDeserialization = true)] + public async Task> GetConnectionsAsync( + GetConnectionsRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -319,7 +319,7 @@ public async Task> GetRecentConnectionsAsync( return connections .OrderByDescending(c => c.CreationDate) - .Select(c => new RecentConnectionDto + .Select(c => new ConnectionDto { Id = c.Id, DataSource = c.DataSource, @@ -330,24 +330,25 @@ public async Task> GetRecentConnectionsAsync( EngineType = c.EngineType.HasValue ? (int)c.EngineType.Value : null, AuthenticationMode = (int)c.AuthenticationMode, ConnectionString = c.ConnectionString, + ProfileName = c.ProfileName, }) .ToList(); } catch (Exception ex) { - _logger.LogError(ex, "Failed to retrieve recent connections"); + _logger.LogError(ex, "Failed to retrieve connections"); throw; } } /// - /// Saves (upserts) a connection. If the same DataSource+UserId+InitialCatalog already + /// Saves (upserts) a connection profile. If the same DataSource+UserId+InitialCatalog already /// exists the row is updated; otherwise a new row is inserted. /// Passwords are encrypted by the repository layer before storage. /// - [JsonRpcMethod("SaveRecentConnectionAsync", UseSingleObjectParameterDeserialization = true)] - public async Task SaveRecentConnectionAsync( - SaveRecentConnectionRequest request, + [JsonRpcMethod("SaveConnectionAsync", UseSingleObjectParameterDeserialization = true)] + public async Task SaveConnectionAsync( + SaveConnectionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -361,7 +362,7 @@ public async Task SaveRecentConnectionAsync( var builder = new SqlConnectionStringBuilder(request.ConnectionString); var connection = new Connection( - id: 0, + id: request.Id.HasValue ? request.Id.Value : 0, initialCatalog: builder.InitialCatalog, creationDate: DateTime.UtcNow, dataSource: builder.DataSource, @@ -370,14 +371,15 @@ public async Task SaveRecentConnectionAsync( userId: string.IsNullOrEmpty(builder.UserID) ? null : builder.UserID, engineType: null, authenticationMode: AuthenticationMode.ConnectionString, - connectionString: request.ConnectionString); + connectionString: request.ConnectionString, + profileName: request.ProfileName); await _connectionRepository.UpsertAsync(connection).ConfigureAwait(false); if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation( - "Recent connection saved (ConnString mode): DataSource={DataSource}, InitialCatalog={InitialCatalog}", + "Connection saved (ConnString mode): DataSource={DataSource}, InitialCatalog={InitialCatalog}", builder.DataSource, builder.InitialCatalog); } @@ -399,7 +401,7 @@ public async Task SaveRecentConnectionAsync( try { var connection = new Connection( - id: 0, + id: request.Id.HasValue ? request.Id.Value : 0, initialCatalog: request.InitialCatalog, creationDate: DateTime.UtcNow, dataSource: request.DataSource, @@ -411,35 +413,36 @@ public async Task SaveRecentConnectionAsync( : null, authenticationMode: request.AuthenticationMode.HasValue ? (AuthenticationMode)request.AuthenticationMode.Value - : AuthenticationMode.WindowsAuth); + : AuthenticationMode.WindowsAuth, + profileName: request.ProfileName); await _connectionRepository.UpsertAsync(connection).ConfigureAwait(false); if (_logger.IsEnabled(LogLevel.Information)) { _logger.LogInformation( - "Recent connection saved: DataSource={DataSource}, InitialCatalog={InitialCatalog}", + "Connection saved: DataSource={DataSource}, InitialCatalog={InitialCatalog}", request.DataSource, request.InitialCatalog); } } catch (Exception ex) { - _logger.LogError(ex, "Failed to save recent connection: {DataSource}", request.DataSource); + _logger.LogError(ex, "Failed to save connection: {DataSource}", request.DataSource); throw; } } /// - /// Deletes a recent connection by its unique identifier. + /// Deletes a 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, + [JsonRpcMethod("DeleteConnectionAsync", UseSingleObjectParameterDeserialization = true)] + public async Task DeleteConnectionAsync( + DeleteConnectionRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); @@ -451,12 +454,12 @@ public async Task DeleteRecentConnectionAsync( if (_logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Recent connection deleted: Id={Id}", request.Id); + _logger.LogInformation("Connection deleted: Id={Id}", request.Id); } } catch (Exception ex) { - _logger.LogError(ex, "Failed to delete recent connection: {Id}", request.Id); + _logger.LogError(ex, "Failed to delete connection: {Id}", request.Id); throw; } } diff --git a/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs b/src/LightQueryProfiler.JsonRpc/Models/ConnectionDto.cs similarity index 82% rename from src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs rename to src/LightQueryProfiler.JsonRpc/Models/ConnectionDto.cs index d1e98b1..0680997 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/ConnectionDto.cs @@ -1,10 +1,10 @@ namespace LightQueryProfiler.JsonRpc.Models { /// - /// Data transfer object representing a recent connection entry. + /// Data transfer object representing a saved connection profile. /// Passwords are returned in plain-text — decryption has already been applied by the repository. /// - public record RecentConnectionDto + public record ConnectionDto { public required int Id { get; init; } public required string DataSource { get; init; } @@ -25,5 +25,8 @@ public record RecentConnectionDto /// Never log this value — it may contain credentials. /// public string? ConnectionString { get; init; } + + /// User-assigned descriptive name for this connection profile. + public string? ProfileName { get; init; } } } diff --git a/src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs b/src/LightQueryProfiler.JsonRpc/Models/DeleteConnectionRequest.cs similarity index 62% rename from src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs rename to src/LightQueryProfiler.JsonRpc/Models/DeleteConnectionRequest.cs index f458279..0f3b287 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/DeleteRecentConnectionRequest.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/DeleteConnectionRequest.cs @@ -1,9 +1,9 @@ namespace LightQueryProfiler.JsonRpc.Models; /// -/// Request model for deleting a recent connection by its unique identifier. +/// Request model for deleting a connection by its unique identifier. /// -public record DeleteRecentConnectionRequest +public record DeleteConnectionRequest { /// Gets the unique identifier of the connection to delete. public required int Id { get; init; } diff --git a/src/LightQueryProfiler.JsonRpc/Models/GetRecentConnectionsRequest.cs b/src/LightQueryProfiler.JsonRpc/Models/GetConnectionsRequest.cs similarity index 72% rename from src/LightQueryProfiler.JsonRpc/Models/GetRecentConnectionsRequest.cs rename to src/LightQueryProfiler.JsonRpc/Models/GetConnectionsRequest.cs index faafc43..6741376 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/GetRecentConnectionsRequest.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/GetConnectionsRequest.cs @@ -1,11 +1,11 @@ namespace LightQueryProfiler.JsonRpc.Models { /// - /// Request model for retrieving all recent connections. + /// Request model for retrieving all saved connections. /// No parameters needed — returns all connections sorted by most recent first. /// Kept as a record for consistency with the existing pattern and future extensibility. /// - public record GetRecentConnectionsRequest + public record GetConnectionsRequest { } } diff --git a/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs b/src/LightQueryProfiler.JsonRpc/Models/SaveConnectionRequest.cs similarity index 69% rename from src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs rename to src/LightQueryProfiler.JsonRpc/Models/SaveConnectionRequest.cs index d6de8d5..f079541 100644 --- a/src/LightQueryProfiler.JsonRpc/Models/SaveRecentConnectionRequest.cs +++ b/src/LightQueryProfiler.JsonRpc/Models/SaveConnectionRequest.cs @@ -1,11 +1,17 @@ namespace LightQueryProfiler.JsonRpc.Models { /// - /// Request model for saving (upserting) a recent connection. + /// Request model for saving (upserting) a connection profile. /// Passwords are accepted in plain-text and encrypted by the repository layer. /// - public record SaveRecentConnectionRequest + public record SaveConnectionRequest { + /// + /// Gets the unique identifier of the connection to update. + /// When null or 0, a new connection is created. + /// + public int? Id { get; init; } + public required string DataSource { get; init; } public required string InitialCatalog { get; init; } public string? UserId { get; init; } @@ -24,5 +30,8 @@ public record SaveRecentConnectionRequest /// The repository layer encrypts this value before storage. Never log this value. /// public string? ConnectionString { get; init; } + + /// User-assigned descriptive name for this connection profile. + public string? ProfileName { get; init; } } } diff --git a/src/LightQueryProfiler.Shared/Data/SqliteContext.cs b/src/LightQueryProfiler.Shared/Data/SqliteContext.cs index 7c71938..e78d50b 100644 --- a/src/LightQueryProfiler.Shared/Data/SqliteContext.cs +++ b/src/LightQueryProfiler.Shared/Data/SqliteContext.cs @@ -91,6 +91,22 @@ FROM pragma_table_info('Connections') SqliteCommand alterTableCmd = new(alterTableConnString, db); await alterTableCmd.ExecuteNonQueryAsync(); } + + // Migration: Add ProfileName column if it doesn't exist (for existing databases) + const string addProfileNameColumnCheck = @" + SELECT COUNT(*) as ColumnExists + FROM pragma_table_info('Connections') + WHERE name='ProfileName'"; + + SqliteCommand checkProfileNameColumn = new(addProfileNameColumnCheck, db); + var profileNameResult = await checkProfileNameColumn.ExecuteScalarAsync(); + + if (profileNameResult != null && Convert.ToInt32(profileNameResult) == 0) + { + const string alterTableProfileName = "ALTER TABLE Connections ADD COLUMN ProfileName NVARCHAR(200) NULL"; + SqliteCommand alterTableCmd = new(alterTableProfileName, db); + await alterTableCmd.ExecuteNonQueryAsync(); + } } } } diff --git a/src/LightQueryProfiler.Shared/Models/Connection.cs b/src/LightQueryProfiler.Shared/Models/Connection.cs index 733a134..aadc450 100644 --- a/src/LightQueryProfiler.Shared/Models/Connection.cs +++ b/src/LightQueryProfiler.Shared/Models/Connection.cs @@ -17,7 +17,8 @@ public class Connection /// Detected or specified engine type. when using (detected at start-profiling time). /// Authentication method used. /// Raw ADO.NET connection string. Only set when is . Plain-text — the repository layer decrypts it before passing here. Never log this value. - public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth, string? connectionString = null) + /// User-assigned descriptive name for this connection profile (e.g., "Production", "Dev Local"). + public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth, string? connectionString = null, string? profileName = null) { Id = id; InitialCatalog = initialCatalog; @@ -29,6 +30,7 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d EngineType = engineType; AuthenticationMode = authenticationMode; ConnectionString = connectionString; + ProfileName = profileName; } public int Id { get; } @@ -59,5 +61,10 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d /// /// Never log this value — it may contain credentials. public string? ConnectionString { get; } + + /// + /// Gets the user-assigned descriptive name for this connection profile (e.g., "Production", "Dev Local"). + /// + public string? ProfileName { get; } } } diff --git a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs index 515cd59..1293861 100644 --- a/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs +++ b/src/LightQueryProfiler.Shared/Repositories/ConnectionRepository.cs @@ -65,11 +65,11 @@ public async Task AddAsync(Connection entity) string? encryptedPassword = EncryptPassword(entity.Password); - // Try with ConnectionString column first + // Try with ConnectionString and ProfileName columns first try { - const string sqlWithConnString = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode, ConnectionString) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode, @ConnectionString)"; + const string sqlWithConnString = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode, ConnectionString, ProfileName) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode, @ConnectionString, @ProfileName)"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -81,15 +81,16 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); sqliteCommand.Parameters.AddWithValue("@ConnectionString", (object?)EncryptConnectionString(entity.ConnectionString) ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without ConnectionString column + // Fallback for databases without ConnectionString or ProfileName column try { - const string sqlWithAuthMode = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode)"; + const string sqlWithAuthMode = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, AuthenticationMode, ProfileName) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @AuthenticationMode, @ProfileName)"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -100,6 +101,7 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) @@ -107,8 +109,8 @@ public async Task AddAsync(Connection entity) // Fallback for databases without AuthenticationMode column try { - const string sqlWithEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType)"; + const string sqlWithEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, EngineType, ProfileName) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @EngineType, @ProfileName)"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -118,13 +120,14 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { // Fallback for databases without EngineType or AuthenticationMode column - const string sqlWithoutEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate) - VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate)"; + const string sqlWithoutEngineType = @"INSERT INTO Connections (DataSource, InitialCatalog, UserId, Password, IntegratedSecurity, CreationDate, ProfileName) + VALUES (@DataSource, @InitialCatalog, @UserId, @Password, @IntegratedSecurity, @CreationDate, @ProfileName)"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -133,6 +136,7 @@ public async Task AddAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@CreationDate", entity.CreationDate); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } } @@ -154,26 +158,51 @@ public async Task UpsertAsync(Connection entity) { ArgumentNullException.ThrowIfNull(entity); - // Normalise empty UserId to null so that "" and null are treated as the - // same key — preventing duplicate rows when Windows-Auth sessions send - // an empty string instead of null. - var normalizedUserId = string.IsNullOrEmpty(entity.UserId) ? null : entity.UserId; + // Step 1: If entity has an explicit Id, try lookup by Id first + Connection? existing = null; + if (entity.Id > 0) + { + existing = await Find(f => f.Id == entity.Id); + } - var existing = await Find(f => - string.Equals(f.DataSource, entity.DataSource, StringComparison.OrdinalIgnoreCase) - && string.Equals( - string.IsNullOrEmpty(f.UserId) ? null : f.UserId, - normalizedUserId, - StringComparison.OrdinalIgnoreCase) - && string.Equals(f.InitialCatalog, entity.InitialCatalog, StringComparison.OrdinalIgnoreCase)); + // Step 2: Fall back to DataSource + UserId + InitialCatalog match + if (existing == null) + { + // Normalise empty UserId to null so that "" and null are treated as the + // same key — preventing duplicate rows when Windows-Auth sessions send + // an empty string instead of null. + var normalizedUserId = string.IsNullOrEmpty(entity.UserId) ? null : entity.UserId; + + existing = await Find(f => + string.Equals(f.DataSource, entity.DataSource, StringComparison.OrdinalIgnoreCase) + && string.Equals( + string.IsNullOrEmpty(f.UserId) ? null : f.UserId, + normalizedUserId, + StringComparison.OrdinalIgnoreCase) + && string.Equals(f.InitialCatalog, entity.InitialCatalog, StringComparison.OrdinalIgnoreCase)); + } if (existing == null) { - await AddAsync(entity); + // INSERT: new connection (always use Id=0 to let SQLite autoincrement) + await AddAsync(new Connection( + id: 0, + entity.InitialCatalog, + DateTime.UtcNow, + entity.DataSource, + entity.IntegratedSecurity, + entity.Password, + entity.UserId, + entity.EngineType, + entity.AuthenticationMode, + entity.ConnectionString, + entity.ProfileName)); } else { - // Connection is immutable — reconstruct with the existing Id + // UPDATE: preserve existing ProfileName if incoming value is null (auto-save case) + var resolvedProfileName = entity.ProfileName ?? existing.ProfileName; + var updated = new Connection( existing.Id, entity.InitialCatalog, @@ -184,7 +213,8 @@ public async Task UpsertAsync(Connection entity) entity.UserId, entity.EngineType, entity.AuthenticationMode, - entity.ConnectionString); + entity.ConnectionString, + resolvedProfileName); await UpdateAsync(updated); } } @@ -204,15 +234,15 @@ public async Task> GetAllAsync() { // SELECT column ordinals: // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, - // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString]]] + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString, [10=ProfileName]]]] List connections = new List(); await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with ConnectionString column first + // Try with ConnectionString and ProfileName columns first try { - const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString FROM Connections"; + const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString, ProfileName FROM Connections"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -222,6 +252,7 @@ public async Task> GetAllAsync() var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); var storedConnectionString = query.IsDBNull(9) ? null : query.GetString(9); + var profileName = query.IsDBNull(10) ? null : query.GetString(10); connections.Add(new Connection( query.GetInt32(0), query.GetString(1), @@ -232,15 +263,16 @@ public async Task> GetAllAsync() query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, authModeValue, - connectionString: DecryptConnectionString(storedConnectionString))); + connectionString: DecryptConnectionString(storedConnectionString), + profileName: profileName)); } } catch (SqliteException) { - // Fallback for databases without ConnectionString column + // Fallback for databases without ConnectionString or ProfileName column try { - const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections"; + const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ProfileName FROM Connections"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -249,6 +281,7 @@ public async Task> GetAllAsync() var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var profileName = query.IsDBNull(9) ? null : query.GetString(9); connections.Add(new Connection( query.GetInt32(0), query.GetString(1), @@ -258,15 +291,16 @@ public async Task> GetAllAsync() DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, - authModeValue)); + authModeValue, + profileName: profileName)); } } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without AuthenticationMode or ProfileName column try { - const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections"; + const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, ProfileName FROM Connections"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -274,6 +308,7 @@ public async Task> GetAllAsync() { var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var profileName = query.IsDBNull(8) ? null : query.GetString(8); connections.Add(new Connection( query.GetInt32(0), query.GetString(1), @@ -282,12 +317,13 @@ public async Task> GetAllAsync() query.GetBoolean(4), DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), - engineTypeValue)); + engineTypeValue, + profileName: profileName)); } } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column + // Fallback for databases without EngineType, AuthenticationMode, or ProfileName column const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -316,15 +352,15 @@ public async Task GetByIdAsync(int id) { // SELECT column ordinals: // 0=Id, 1=InitialCatalog, 2=CreationDate, 3=DataSource, - // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString]]] + // 4=IntegratedSecurity, 5=Password, 6=UserId, [7=EngineType, [8=AuthenticationMode, [9=ConnectionString, [10=ProfileName]]]] Connection? connection = null; await using var db = _context.GetConnection() as SqliteConnection ?? throw new Exception("db cannot be null or empty"); await db.OpenAsync(); - // Try with ConnectionString column first + // Try with ConnectionString and ProfileName columns first try { - const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString FROM Connections WHERE Id = @Id"; + const string sqlWithConnString = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ConnectionString, ProfileName FROM Connections WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -335,6 +371,7 @@ public async Task GetByIdAsync(int id) var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); var storedConnectionString = query.IsDBNull(9) ? null : query.GetString(9); + var profileName = query.IsDBNull(10) ? null : query.GetString(10); connection = new Connection( query.GetInt32(0), query.GetString(1), @@ -345,15 +382,16 @@ public async Task GetByIdAsync(int id) query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, authModeValue, - connectionString: DecryptConnectionString(storedConnectionString)); + connectionString: DecryptConnectionString(storedConnectionString), + profileName: profileName); } } catch (SqliteException) { - // Fallback for databases without ConnectionString column + // Fallback for databases without ConnectionString or ProfileName column try { - const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode FROM Connections WHERE Id = @Id"; + const string sqlWithAuthMode = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, AuthenticationMode, ProfileName FROM Connections WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -363,6 +401,7 @@ public async Task GetByIdAsync(int id) var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var authModeValue = query.IsDBNull(8) ? AuthenticationMode.WindowsAuth : (AuthenticationMode)query.GetInt32(8); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var profileName = query.IsDBNull(9) ? null : query.GetString(9); connection = new Connection( query.GetInt32(0), query.GetString(1), @@ -372,15 +411,16 @@ public async Task GetByIdAsync(int id) DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), engineTypeValue, - authModeValue); + authModeValue, + profileName: profileName); } } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without AuthenticationMode or ProfileName column try { - const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType FROM Connections WHERE Id = @Id"; + const string sqlWithEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId, EngineType, ProfileName FROM Connections WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); sqliteCommand.Parameters.AddWithValue("@Id", id); await using var query = await sqliteCommand.ExecuteReaderAsync(); @@ -389,6 +429,7 @@ public async Task GetByIdAsync(int id) { var engineTypeValue = query.IsDBNull(7) ? null : (DatabaseEngineType?)query.GetInt32(7); var storedPassword = query.IsDBNull(5) ? null : query.GetString(5); + var profileName = query.IsDBNull(8) ? null : query.GetString(8); connection = new Connection( query.GetInt32(0), query.GetString(1), @@ -397,12 +438,13 @@ public async Task GetByIdAsync(int id) query.GetBoolean(4), DecryptPassword(storedPassword), query.IsDBNull(6) ? null : query.GetString(6), - engineTypeValue); + engineTypeValue, + profileName: profileName); } } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column + // Fallback for databases without EngineType, AuthenticationMode, or ProfileName column const string sqlWithoutEngineType = "SELECT Id, InitialCatalog, CreationDate, DataSource, IntegratedSecurity, Password, UserId FROM Connections WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); sqliteCommand.Parameters.AddWithValue("@Id", id); @@ -440,10 +482,10 @@ public async Task UpdateAsync(Connection entity) string? encryptedPassword = EncryptPassword(entity.Password); - // Try with ConnectionString column first + // Try with ConnectionString and ProfileName columns first try { - const string sqlWithConnString = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode, ConnectionString=@ConnectionString WHERE Id = @Id"; + const string sqlWithConnString = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode, ConnectionString=@ConnectionString, ProfileName=@ProfileName WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithConnString, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -454,14 +496,15 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); sqliteCommand.Parameters.AddWithValue("@ConnectionString", (object?)EncryptConnectionString(entity.ConnectionString) ?? DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without ConnectionString column + // Fallback for databases without ConnectionString or ProfileName column try { - const string sqlWithAuthMode = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode WHERE Id = @Id"; + const string sqlWithAuthMode = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, AuthenticationMode=@AuthenticationMode, ProfileName=@ProfileName WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithAuthMode, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -471,14 +514,15 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); sqliteCommand.Parameters.AddWithValue("@AuthenticationMode", (int)entity.AuthenticationMode); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without AuthenticationMode column + // Fallback for databases without AuthenticationMode or ProfileName column try { - const string sqlWithEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType WHERE Id = @Id"; + const string sqlWithEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity, EngineType=@EngineType, ProfileName=@ProfileName WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithEngineType, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); sqliteCommand.Parameters.AddWithValue("@DataSource", entity.DataSource); @@ -487,11 +531,12 @@ public async Task UpdateAsync(Connection entity) sqliteCommand.Parameters.AddWithValue("@Password", (object?)encryptedPassword ?? DBNull.Value); sqliteCommand.Parameters.AddWithValue("@IntegratedSecurity", entity.IntegratedSecurity); sqliteCommand.Parameters.AddWithValue("@EngineType", entity.EngineType.HasValue ? (int)entity.EngineType.Value : DBNull.Value); + sqliteCommand.Parameters.AddWithValue("@ProfileName", (object?)entity.ProfileName ?? DBNull.Value); await sqliteCommand.ExecuteNonQueryAsync(); } catch (SqliteException) { - // Fallback for databases without EngineType or AuthenticationMode column + // Fallback for databases without EngineType, AuthenticationMode, or ProfileName column const string sqlWithoutEngineType = "UPDATE Connections SET DataSource=@DataSource, InitialCatalog=@InitialCatalog, UserId=@UserId, Password=@Password, IntegratedSecurity=@IntegratedSecurity WHERE Id = @Id"; await using SqliteCommand sqliteCommand = new SqliteCommand(sqlWithoutEngineType, db); sqliteCommand.Parameters.AddWithValue("@Id", entity.Id); diff --git a/src/LightQueryProfiler.Shared/Repositories/Interfaces/IConnectionRepository.cs b/src/LightQueryProfiler.Shared/Repositories/Interfaces/IConnectionRepository.cs index c12a8cb..1b9d554 100644 --- a/src/LightQueryProfiler.Shared/Repositories/Interfaces/IConnectionRepository.cs +++ b/src/LightQueryProfiler.Shared/Repositories/Interfaces/IConnectionRepository.cs @@ -10,8 +10,9 @@ public interface IConnectionRepository : IRepository { /// /// Inserts if no row with the same - /// DataSource, UserId, and InitialCatalog exists; + /// DataSource, UserId, and InitialCatalog (or matching Id) exists; /// otherwise updates the existing row (bumping CreationDate to now). + /// When ProfileName is on update, the existing value is preserved. /// /// The connection to persist. Task UpsertAsync(Connection entity); diff --git a/vscode-extension/CHANGELOG.md b/vscode-extension/CHANGELOG.md index 471d199..b3c7201 100644 --- a/vscode-extension/CHANGELOG.md +++ b/vscode-extension/CHANGELOG.md @@ -5,6 +5,32 @@ 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.5.0] - 2026-05-XX + +### Added + +- **Connection Profiles**: Connections can now be saved with a custom descriptive name + (e.g., "Production", "Dev Local"). The profile name is displayed prominently in the + Connections panel. +- **Add Connection dialog**: New "Add" button in the Connections panel toolbar opens a + modal dialog to create a new connection profile with all connection fields and a + `Profile Name` field. +- **Edit Connection**: Each connection row now has an "Edit" button that opens the same + dialog pre-filled with the connection's current values, allowing modifications. + +### Changed + +- **Renamed "Recent Connections" → "Connections"**: The panel, command, and all related + UI labels now use the simpler "Connections" naming to reflect the profile-based model. +- The `lightQueryProfiler.showRecentConnections` command is now + `lightQueryProfiler.showConnections`. +- The profiler toolbar button label changed from "Recent…" to "Connections". +- Backend JSON-RPC methods renamed: + - `GetRecentConnectionsAsync` → `GetConnectionsAsync` + - `SaveRecentConnectionAsync` → `SaveConnectionAsync` + - `DeleteRecentConnectionAsync` → `DeleteConnectionAsync` +- `localStorage.db` schema migrated: new `ProfileName` column added to the `Connections` table. + ## [1.4.0] - 2026-04-21 ### Added diff --git a/vscode-extension/package.json b/vscode-extension/package.json index e08da71..560f138 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.4.0", + "version": "1.5.0", "publisher": "brandochn", "author": { "name": "Hildebrando Chávez", @@ -54,8 +54,8 @@ "icon": "$(arrow-down)" }, { - "command": "lightQueryProfiler.showRecentConnections", - "title": "Show Recent Connections", + "command": "lightQueryProfiler.showConnections", + "title": "Show Connections", "category": "Light Query Profiler", "icon": "$(history)" } @@ -63,7 +63,7 @@ "menus": { "editor/title": [ { - "command": "lightQueryProfiler.showRecentConnections", + "command": "lightQueryProfiler.showConnections", "when": "activeWebviewPanelId == 'lightQueryProfiler'", "group": "navigation" } diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts index 4170f42..37b37a9 100644 --- a/vscode-extension/src/extension.ts +++ b/vscode-extension/src/extension.ts @@ -2,9 +2,9 @@ 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 { ConnectionsPanelProvider } from './views/connections-panel-provider'; import { ProfilerClient } from './services/profiler-client'; -import { RecentConnection } from './models/recent-connection'; +import { ConnectionProfile } from './models/connection-profile'; /** * Logger interface for structured logging @@ -22,7 +22,7 @@ interface Logger { interface ExtensionState { profilerClient: ProfilerClient | undefined; profilerPanelProvider: ProfilerPanelProvider | undefined; - recentConnectionsPanelProvider: RecentConnectionsPanelProvider | undefined; + connectionsPanelProvider: ConnectionsPanelProvider | undefined; outputChannel: vscode.OutputChannel | undefined; } @@ -32,7 +32,7 @@ interface ExtensionState { const state: ExtensionState = { profilerClient: undefined, profilerPanelProvider: undefined, - recentConnectionsPanelProvider: undefined, + connectionsPanelProvider: undefined, outputChannel: undefined, }; @@ -90,12 +90,12 @@ export async function activate( ); context.subscriptions.push(importEventsCommand); - const showRecentConnectionsCommand = vscode.commands.registerCommand( - 'lightQueryProfiler.showRecentConnections', + const showConnectionsCommand = vscode.commands.registerCommand( + 'lightQueryProfiler.showConnections', () => { - log.info('Show Recent Connections command executed'); - if (state.recentConnectionsPanelProvider) { - void state.recentConnectionsPanelProvider.show(); + log.info('Show Connections command executed'); + if (state.connectionsPanelProvider) { + void state.connectionsPanelProvider.show(); } else { void vscode.window.showErrorMessage( 'Light Query Profiler: Extension is not initialized.', @@ -103,7 +103,7 @@ export async function activate( } }, ); - context.subscriptions.push(showRecentConnectionsCommand); + context.subscriptions.push(showConnectionsCommand); const showProfilerCommand = vscode.commands.registerCommand( 'lightQueryProfiler.showProfiler', @@ -177,18 +177,18 @@ export async function activate( state.outputChannel, ); - // Create recent connections provider - state.recentConnectionsPanelProvider = new RecentConnectionsPanelProvider( + // Create connections provider + state.connectionsPanelProvider = new ConnectionsPanelProvider( context.extensionUri, state.profilerClient, state.outputChannel, - (connection: RecentConnection) => { + (connection: ConnectionProfile) => { // 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) => { + (connection: ConnectionProfile) => { // "Start Profiling" button: fill connection fields and start profiling automatically. void state.profilerPanelProvider?.startProfilingWithConnection( connection, @@ -196,9 +196,9 @@ export async function activate( }, ); - // Wire the "Recent Connections" toolbar button inside the profiler webview - state.profilerPanelProvider.setOnShowRecentConnections(() => { - state.recentConnectionsPanelProvider?.show(); + // Wire the "Connections" toolbar button inside the profiler webview + state.profilerPanelProvider.setOnShowConnections(() => { + state.connectionsPanelProvider?.show(); }); // Register remaining disposables @@ -214,9 +214,9 @@ export async function activate( }, { dispose: () => { - if (state.recentConnectionsPanelProvider) { - log.info('Disposing recent connections panel provider...'); - state.recentConnectionsPanelProvider.dispose(); + if (state.connectionsPanelProvider) { + log.info('Disposing connections panel provider...'); + state.connectionsPanelProvider.dispose(); } }, }, diff --git a/vscode-extension/src/models/recent-connection.ts b/vscode-extension/src/models/connection-profile.ts similarity index 71% rename from vscode-extension/src/models/recent-connection.ts rename to vscode-extension/src/models/connection-profile.ts index 51c06b8..0d941e4 100644 --- a/vscode-extension/src/models/recent-connection.ts +++ b/vscode-extension/src/models/connection-profile.ts @@ -1,8 +1,8 @@ /** - * Represents a saved recent connection entry returned by the backend. + * Represents a saved connection profile returned by the backend. * Password is plain-text — decrypted by the backend repository layer. */ -export interface RecentConnection { +export interface ConnectionProfile { id: number; dataSource: string; initialCatalog: string; @@ -18,4 +18,6 @@ export interface RecentConnection { * @remarks Never log this value. */ connectionString?: string; + /** User-assigned descriptive name for this connection profile (e.g., "Production", "Dev Local"). */ + profileName?: string; } diff --git a/vscode-extension/src/services/profiler-client.ts b/vscode-extension/src/services/profiler-client.ts index 50c5722..75be803 100644 --- a/vscode-extension/src/services/profiler-client.ts +++ b/vscode-extension/src/services/profiler-client.ts @@ -13,7 +13,7 @@ import { toConnectionString, getEngineType, } from '../models/connection-settings'; -import { RecentConnection } from '../models/recent-connection'; +import { ConnectionProfile } from '../models/connection-profile'; /** * Request parameters for starting a profiling session @@ -61,30 +61,30 @@ const getEventsRequestType = new RequestType< void >('GetLastEventsAsync'); -const getRecentConnectionsRequestType = new RequestType< +const getConnectionsRequestType = new RequestType< Record, - RecentConnection[], + ConnectionProfile[], void ->('GetRecentConnectionsAsync'); +>('GetConnectionsAsync'); -const saveRecentConnectionRequestType = new RequestType< - Omit, +const saveConnectionRequestType = new RequestType< + ConnectionProfile, void, void ->('SaveRecentConnectionAsync'); +>('SaveConnectionAsync'); /** - * Request parameters for deleting a recent connection + * Request parameters for deleting a connection */ -interface DeleteRecentConnectionRequest { +interface DeleteConnectionRequest { readonly id: number; } -const deleteRecentConnectionRequestType = new RequestType< - DeleteRecentConnectionRequest, +const deleteConnectionRequestType = new RequestType< + DeleteConnectionRequest, void, void ->('DeleteRecentConnectionAsync'); +>('DeleteConnectionAsync'); /** * Client state enum for tracking lifecycle @@ -329,64 +329,78 @@ export class ProfilerClient { } /** - * Retrieves all saved recent connections from the backend, sorted most-recent first. - * @returns Array of recent connections (passwords already decrypted by backend). + * Retrieves all saved connections from the backend, sorted most-recent first. + * @returns Array of connection profiles (passwords already decrypted by backend). * @throws Error if the connection is not established or request fails. */ - public async getRecentConnections(): Promise { + public async getConnections(): Promise { this.ensureRunning(); try { const connections = await this.connection!.sendRequest( - getRecentConnectionsRequestType, + getConnectionsRequestType, {}, ); return connections; } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.logError(`Failed to get recent connections: ${message}`); - throw new Error(`Failed to get recent connections: ${message}`); + this.logError(`Failed to get connections: ${message}`); + throw new Error(`Failed to get connections: ${message}`); } } /** - * Saves (upserts) a connection to the backend recent-connections store. - * @param connection - Connection details to save (without id). + * Saves (upserts) a connection to the backend connections store. + * @param connection - Connection details to save (without id for new connections). * @throws Error if the connection is not established or request fails. */ - public async saveRecentConnection( - connection: Omit, + public async saveConnection( + connection: Omit, ): Promise { this.ensureRunning(); try { - await this.connection!.sendRequest( - saveRecentConnectionRequestType, - connection, - ); + await this.connection!.sendRequest(saveConnectionRequestType, connection); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logError(`Failed to save connection: ${message}`); + throw new Error(`Failed to save connection: ${message}`); + } + } + + /** + * Updates an existing connection profile by its identifier. + * @param connection - Connection profile with id set. + * @throws Error if the server is not running or the request fails. + */ + public async updateConnection(connection: ConnectionProfile): Promise { + this.ensureRunning(); + + try { + await this.connection!.sendRequest(saveConnectionRequestType, connection); } catch (error) { const message = error instanceof Error ? error.message : String(error); - this.logError(`Failed to save recent connection: ${message}`); - throw new Error(`Failed to save recent connection: ${message}`); + this.logError(`Failed to update connection: ${message}`); + throw new Error(`Failed to update connection: ${message}`); } } /** - * Deletes a recent connection from the backend store by its identifier. + * Deletes a 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 { + public async deleteConnection(id: number): Promise { this.ensureRunning(); try { - await this.connection!.sendRequest(deleteRecentConnectionRequestType, { + await this.connection!.sendRequest(deleteConnectionRequestType, { 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}`); + this.logError(`Failed to delete connection: ${message}`); + throw new Error(`Failed to delete connection: ${message}`); } } diff --git a/vscode-extension/src/views/connections-panel-provider.ts b/vscode-extension/src/views/connections-panel-provider.ts new file mode 100644 index 0000000..6bf4bd8 --- /dev/null +++ b/vscode-extension/src/views/connections-panel-provider.ts @@ -0,0 +1,919 @@ +import * as crypto from "crypto"; +import * as vscode from "vscode"; +import { ProfilerClient } from "../services/profiler-client"; +import { ConnectionProfile } from "../models/connection-profile"; + +// Messages the extension HOST receives FROM the webview +type WebviewIncomingMessage = + | { command: "webviewReady" } + | { command: "refresh" } + | { command: "connectionSelected"; data: ConnectionProfile } + | { command: "startProfiling"; data: ConnectionProfile } + | { command: "deleteConnection"; data: { id: number; name: string } } + | { command: "addConnection"; data: ConnectionProfile } + | { command: "updateConnection"; data: ConnectionProfile } + | { command: "error"; data: string }; + +// Messages the extension HOST sends TO the webview +type WebviewOutgoingMessage = + | { command: "updateConnections"; data: ConnectionProfile[] } + | { command: "error"; data: string }; + +/** + * Manages the "Connections" webview panel. + * Shows a searchable list of saved connection profiles with Add, Edit, and Delete. + * Double-clicking a row fires the `onConnectionSelected` callback and closes the panel. + */ +export class ConnectionsPanelProvider implements vscode.Disposable { + private panel: vscode.WebviewPanel | undefined; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly profilerClient: ProfilerClient, + private readonly outputChannel: vscode.OutputChannel, + private readonly onConnectionSelected: ( + connection: ConnectionProfile, + ) => void, + private readonly onStartProfiling: (connection: ConnectionProfile) => void, + ) {} + + /** + * Opens (or reveals) the Connections panel. + * Data is loaded once the webview signals it is ready via `webviewReady`. + */ + public show(): void { + if (this.panel) { + this.panel.reveal(vscode.ViewColumn.One); + void this.loadConnections(); + return; + } + + this.panel = vscode.window.createWebviewPanel( + "connections", + "Connections", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [this.extensionUri], + }, + ); + + this.panel.webview.html = this.getHtmlContent(this.panel.webview); + + this.panel.webview.onDidReceiveMessage( + async (message: WebviewIncomingMessage) => { + await this.handleMessage(message); + }, + ); + + this.panel.onDidDispose(() => { + this.panel = undefined; + }); + } + + /** + * Loads connections from the backend and posts them to the webview. + * Starts the JSON-RPC server first if it is not yet running. + */ + public async loadConnections(): Promise { + try { + if (!this.profilerClient.isRunning()) { + this.log("Server not running, starting server process..."); + await this.profilerClient.start(); + } + + const connections = await this.profilerClient.getConnections(); + await this.postMessage({ + command: "updateConnections", + data: connections, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logError(`Failed to load connections: ${errorMessage}`); + await this.postMessage({ command: "error", data: errorMessage }); + } + } + + /** Disposes the panel if it is open. */ + public dispose(): void { + this.panel?.dispose(); + this.panel = undefined; + } + + // ─── Private helpers ────────────────────────────────────────────────────── + + private async handleMessage(message: WebviewIncomingMessage): Promise { + switch (message.command) { + case "webviewReady": + await this.loadConnections(); + break; + + case "refresh": + await this.loadConnections(); + break; + + case "connectionSelected": + this.onConnectionSelected(message.data); + this.panel?.dispose(); + break; + + case "startProfiling": + this.onStartProfiling(message.data); + this.panel?.dispose(); + break; + + case "deleteConnection": + await this.deleteConnection(message.data.id, message.data.name); + break; + + case "addConnection": + await this.saveConnection(message.data); + break; + + case "updateConnection": + await this.updateConnection(message.data); + break; + + case "error": + this.logError(`Webview error: ${message.data}`); + break; + + default: + break; + } + } + + /** + * Saves a new connection profile and refreshes the list. + */ + private async saveConnection(profile: ConnectionProfile): Promise { + try { + if (!this.profilerClient.isRunning()) { + this.log("Server not running, starting server process..."); + await this.profilerClient.start(); + } + await this.profilerClient.saveConnection(profile); + await this.loadConnections(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logError(`Failed to save connection: ${errorMessage}`); + await this.postMessage({ command: "error", data: errorMessage }); + } + } + + /** + * Updates an existing connection profile and refreshes the list. + */ + private async updateConnection(profile: ConnectionProfile): Promise { + try { + if (!this.profilerClient.isRunning()) { + this.log("Server not running, starting server process..."); + await this.profilerClient.start(); + } + await this.profilerClient.updateConnection(profile); + await this.loadConnections(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + this.logError(`Failed to update connection: ${errorMessage}`); + await this.postMessage({ command: "error", data: errorMessage }); + } + } + + /** + * Deletes a connection by its id after user confirmation, then refreshes the list. + */ + private async deleteConnection(id: number, name: string): Promise { + const confirm = await vscode.window.showWarningMessage( + `Are you sure you want to delete the connection "${name}"?`, + { modal: false }, + "Delete", + ); + + if (confirm !== "Delete") { + return; + } + + try { + if (!this.profilerClient.isRunning()) { + this.log("Server not running, starting server process..."); + await this.profilerClient.start(); + } + + await this.profilerClient.deleteConnection(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); + } + } + + private log(message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine( + `[${timestamp}] [ConnectionsPanelProvider] ${message}`, + ); + } + + private logError(message: string): void { + const timestamp = new Date().toISOString(); + this.outputChannel.appendLine( + `[${timestamp}] [ConnectionsPanelProvider] ERROR: ${message}`, + ); + } + + private getHtmlContent(_webview: vscode.Webview): string { + const nonce = crypto.randomBytes(16).toString("hex"); + + return ` + + + + + + Connections + + + +
+ + + +
+
+ + + + + + + + + + + + + +
ProfileServerDatabaseAuthActions
Loading...
+
+ + + + + + +`; + } +} diff --git a/vscode-extension/src/views/profiler-panel-provider.ts b/vscode-extension/src/views/profiler-panel-provider.ts index 1ce446d..e3a9ac0 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 { ConnectionProfile } from "../models/connection-profile"; 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,10 @@ interface ConnectionSettings { * Profiler state enumeration */ enum ProfilerState { - Stopped = 'stopped', - Running = 'running', - Paused = 'paused', + Stopped = "stopped", + Starting = "starting", + Running = "running", + Paused = "paused", } /** @@ -52,17 +53,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" + | "showConnections" + | "webviewReady"; data?: ConnectionSettings | EventFilter; } @@ -71,15 +72,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 +98,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: "", }; /** @@ -133,8 +134,8 @@ export class ProfilerPanelProvider { */ private pendingImportEvents: DisplayEvent[] | null = null; - /** Callback invoked when the webview toolbar "Recent Connections" button is clicked. */ - private onShowRecentConnectionsCallback: (() => void) | null = null; + /** Callback invoked when the webview toolbar "Connections" button is clicked. */ + private onShowConnectionsCallback: (() => void) | null = null; /** * The connection settings used in the most recent (or current) profiling session. @@ -173,8 +174,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 +193,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 +207,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 +223,7 @@ export class ProfilerPanelProvider { } }, undefined); - this.log('Panel created and shown'); + this.log("Panel created and shown"); } /** @@ -235,52 +236,62 @@ 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': - if (this.onShowRecentConnectionsCallback) { - this.onShowRecentConnectionsCallback(); + case "showConnections": + if (this.onShowConnectionsCallback) { + this.onShowConnectionsCallback(); } break; - case 'webviewReady': + case "webviewReady": + // Synchronise filter state with the webview on every handshake. + // The webview initialises activeFilter to empty, but the host may + // still hold filters from a previous session (e.g. when the panel + // was disposed and is now being recreated). Sending the current + // filter guarantees both sides agree on the active filter set. + await this.postMessage({ + command: "updateFilter", + data: this.eventFilter, + }); + // 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 +315,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. @@ -316,10 +327,20 @@ export class ProfilerPanelProvider { return; } + // ── Immediately update UI to reflect the starting state ────────── + // This ensures the user sees immediate feedback (disabled inputs, + // updated status badge, spinner on Start button) BEFORE the backend + // attempts to connect. If the connection is invalid and eventually + // times out, the UI has already been reflecting the "Starting" state + // the whole time, avoiding the perception that nothing happened. + this.state = ProfilerState.Starting; + await this.setConnectionFieldsEnabled(false); + await this.updateState(); + 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,22 +351,22 @@ 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 + // Transition from Starting → Running this.state = ProfilerState.Running; - await this.setConnectionFieldsEnabled(false); await this.updateState(); // 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); this.logError(`Failed to start profiling: ${errorMessage}`); + // Transition from Starting → Stopped (error recovery) this.state = ProfilerState.Stopped; await this.setConnectionFieldsEnabled(true); await this.updateState(); @@ -359,7 +380,7 @@ export class ProfilerPanelProvider { */ private async updateState(): Promise { await this.postMessage({ - command: 'updateState', + command: "updateState", data: { state: this.state, eventCount: this.eventCount, @@ -375,7 +396,7 @@ export class ProfilerPanelProvider { */ private async setConnectionFieldsEnabled(enabled: boolean): Promise { await this.postMessage({ - command: 'setConnectionFieldsEnabled', + command: "setConnectionFieldsEnabled", data: enabled, }); } @@ -387,15 +408,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 +425,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()) { @@ -426,7 +447,7 @@ export class ProfilerPanelProvider { if (this.currentConnectionSettings) { const settings = this.currentConnectionSettings; try { - await this.profilerClient.saveRecentConnection({ + await this.profilerClient.saveConnection({ dataSource: settings.server, initialCatalog: settings.database, userId: settings.username, @@ -435,9 +456,10 @@ export class ProfilerPanelProvider { settings.authenticationMode === AuthenticationMode.WindowsAuth, authenticationMode: settings.authenticationMode, engineType: undefined, - connectionString: settings.connectionString, // ← NEW + connectionString: settings.connectionString, + // profileName intentionally omitted — auto-save must not overwrite custom names }); - this.log('Recent connection saved'); + this.log("Recent connection saved"); } catch (saveError) { const saveMessage = saveError instanceof Error ? saveError.message : String(saveError); @@ -448,8 +470,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 +499,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 +517,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 +526,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 +542,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 +560,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 +626,7 @@ export class ProfilerPanelProvider { ...keys: string[] ): string => { if (!obj) { - return ''; + return ""; } for (const k of keys) { const v = obj[k]; @@ -612,7 +634,7 @@ export class ProfilerPanelProvider { return String(v); } } - return ''; + return ""; }; for (const event of events) { @@ -620,38 +642,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 +704,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,20 +739,20 @@ 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}`); } /** - * Registers a callback invoked when the user clicks "Recent Connections" + * Registers a callback invoked when the user clicks "Connections" * inside the webview toolbar. Extension.ts uses this to open the - * `RecentConnectionsPanelProvider` without creating a direct dependency + * `ConnectionsPanelProvider` without creating a direct dependency * between the two providers. */ - public setOnShowRecentConnections(callback: () => void): void { - this.onShowRecentConnectionsCallback = callback; + public setOnShowConnections(callback: () => void): void { + this.onShowConnectionsCallback = callback; } /** @@ -738,9 +760,9 @@ export class ProfilerPanelProvider { * Called by `extension.ts` when the user double-clicks a recent connection. * @param connection - The connection whose fields should be populated in the form. */ - public fillConnectionFields(connection: RecentConnection): void { + public fillConnectionFields(connection: ConnectionProfile): void { void this.panel?.webview.postMessage({ - command: 'setConnectionFields', + command: "setConnectionFields", data: connection, }); } @@ -754,14 +776,17 @@ export class ProfilerPanelProvider { * before delegating to `handleStart`. */ public async startProfilingWithConnection( - connection: RecentConnection, + connection: ConnectionProfile, ): Promise { - // Guard: if a session is already running, show an error and abort. + // Guard: if a session is already running or starting, 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) { + // disabling the Start button. Calling it while Running or Starting would corrupt state. + if ( + this.state === ProfilerState.Running || + this.state === ProfilerState.Starting + ) { await this.showError( - 'A profiling session is already running. Please stop it first.', + "A profiling session is already in progress. Please stop it first.", ); return; } @@ -798,7 +823,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; } @@ -810,9 +835,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) { @@ -855,9 +880,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) { @@ -874,9 +899,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; } } @@ -894,7 +919,7 @@ export class ProfilerPanelProvider { if (this.panel) { // Panel is already open — send directly await this.postMessage({ - command: 'loadImportedEvents', + command: "loadImportedEvents", data: imported, }); } else { @@ -932,7 +957,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) { @@ -950,7 +975,7 @@ export class ProfilerPanelProvider { this.panel = undefined; } - this.log('Profiler panel provider disposed'); + this.log("Profiler panel provider disposed"); } /** @@ -988,20 +1013,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(); @@ -1112,6 +1137,12 @@ export class ProfilerPanelProvider { border-color: rgba(var(--vscode-notificationsWarningIcon-foreground-rgb, 200,160,0), 0.3); } + .status-badge.starting { + background-color: rgba(var(--vscode-progressBar-background-rgb, 0,122,204), 0.12); + color: var(--vscode-progressBar-background, #007acc); + border-color: rgba(var(--vscode-progressBar-background-rgb, 0,122,204), 0.3); + } + .status-dot { width: 7px; height: 7px; @@ -1904,7 +1935,7 @@ export class ProfilerPanelProvider {
@@ -1977,8 +2008,8 @@ export class ProfilerPanelProvider { Import...
- @@ -2183,7 +2214,7 @@ export class ProfilerPanelProvider { const clearFilterBtn = document.getElementById('clearFilterBtn'); const exportBtn = document.getElementById('exportBtn'); const importBtn = document.getElementById('importBtn'); - const recentConnectionsBtn = document.getElementById('recentConnectionsBtn'); + const connectionsBtn = document.getElementById('connectionsBtn'); const filterModalOverlay = document.getElementById('filterModalOverlay'); const filterCloseBtn = document.getElementById('filterCloseBtn'); const filterApplyBtn = document.getElementById('filterApplyBtn'); @@ -2362,7 +2393,7 @@ export class ProfilerPanelProvider { clearBtn.addEventListener('click', () => vscode.postMessage({ command: 'clear' })); exportBtn.addEventListener('click', () => vscode.postMessage({ command: 'exportEvents' })); importBtn.addEventListener('click', () => vscode.postMessage({ command: 'importEvents' })); - recentConnectionsBtn.addEventListener('click', () => vscode.postMessage({ command: 'showRecentConnections' })); + connectionsBtn.addEventListener('click', () => vscode.postMessage({ command: 'showConnections' })); errorClose.addEventListener('click', () => errorContainer.classList.add('hidden')); queryPanelClose.addEventListener('click', () => { @@ -2649,22 +2680,24 @@ export class ProfilerPanelProvider { // Badge statusBadge.className = 'status-badge ' + state; - const labels = { stopped: 'Stopped', running: 'Running', paused: 'Paused' }; + const labels = { stopped: 'Stopped', running: 'Running', paused: 'Paused', starting: 'Starting\u2026' }; statusText.textContent = labels[state] || state; // Buttons const isRunning = state === 'running'; const isPaused = state === 'paused'; const isStopped = state === 'stopped'; + const isStarting = state === 'starting'; startBtn.disabled = !isStopped; if (isStopped) { setStarting(false); } + else if (isStarting) { setStarting(true); } pauseBtn.disabled = !isRunning; - pauseBtn.classList.toggle('hidden', isPaused); - resumeBtn.classList.toggle('hidden', !isPaused); + pauseBtn.classList.toggle('hidden', isPaused || isStarting); + resumeBtn.classList.toggle('hidden', !isPaused || isStarting); resumeBtn.disabled = !isPaused; - stopBtn.disabled = isStopped; + stopBtn.disabled = isStopped || isStarting; // Export and Import are only available when stopped — running would // mix live events with exported/imported data, producing a confusing result. exportBtn.disabled = !isStopped; @@ -2687,7 +2720,7 @@ export class ProfilerPanelProvider { timerInterval = null; timerEl.className = 'session-timer'; } else { - // Stopped — clear everything + // Stopped or Starting — clear everything clearInterval(timerInterval); timerInterval = null; sessionStartTime = null; diff --git a/vscode-extension/src/views/profiler-view-provider.ts b/vscode-extension/src/views/profiler-view-provider.ts index e38d9ee..077e7e5 100644 --- a/vscode-extension/src/views/profiler-view-provider.ts +++ b/vscode-extension/src/views/profiler-view-provider.ts @@ -1,20 +1,21 @@ -import * as vscode from 'vscode'; -import { ProfilerClient } from '../services/profiler-client'; +import * as vscode from "vscode"; +import { ProfilerClient } from "../services/profiler-client"; import { ConnectionSettings, validateConnectionSettings, -} from '../models/connection-settings'; -import { getAllAuthenticationModes } from '../models/authentication-mode'; -import { ProfilerEvent, toEventRow } from '../models/profiler-event'; +} from "../models/connection-settings"; +import { getAllAuthenticationModes } from "../models/authentication-mode"; +import { ProfilerEvent, toEventRow } from "../models/profiler-event"; /** * Profiler state discriminated union * @remarks Used for state machine implementation in the view provider */ enum ProfilerState { - Stopped = 'stopped', - Running = 'running', - Paused = 'paused', + Stopped = "stopped", + Starting = "starting", + Running = "running", + Paused = "paused", } /** @@ -29,7 +30,7 @@ interface WebviewMessage { * Message to update state in the webview */ interface UpdateStateMessage { - readonly command: 'updateState'; + readonly command: "updateState"; readonly data: { readonly state: ProfilerState; readonly eventCount: number; @@ -40,7 +41,7 @@ interface UpdateStateMessage { * Message to add events to the webview */ interface AddEventsMessage { - readonly command: 'addEvents'; + readonly command: "addEvents"; readonly data: ReadonlyArray>; } @@ -48,14 +49,14 @@ interface AddEventsMessage { * Message to clear events in the webview */ interface ClearEventsMessage { - readonly command: 'clearEvents'; + readonly command: "clearEvents"; } /** * Message to show error in the webview */ interface ErrorMessage { - readonly command: 'error'; + readonly command: "error"; readonly data: string; } @@ -63,7 +64,7 @@ interface ErrorMessage { * Message to update event count */ interface UpdateEventCountMessage { - readonly command: 'updateEventCount'; + readonly command: "updateEventCount"; readonly data: number; } @@ -91,7 +92,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { 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 @@ -146,14 +147,24 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { [], ); - // Update state when view becomes visible. + // Transition from Starting to Running when view becomes visible. // Pass the cancellation token so this listener is unregistered if VS Code // cancels the view before it is ever shown. - webviewView.onDidChangeVisibility(() => { - if (webviewView.visible) { - void this.updateState(); - } - }, undefined, [{ dispose: () => { /* no-op — listener lifetime tied to webviewView */ } }]); + webviewView.onDidChangeVisibility( + () => { + if (webviewView.visible) { + void this.updateState(); + } + }, + undefined, + [ + { + dispose: () => { + /* no-op — listener lifetime tied to webviewView */ + }, + }, + ], + ); } /** @@ -163,7 +174,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { */ private async handleMessage(message: unknown): Promise { if (!this.isValidMessage(message)) { - this.log('Received invalid message from webview'); + this.log("Received invalid message from webview"); return; } @@ -171,22 +182,22 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { try { switch (message.command) { - case 'start': + case "start": await this.handleStart(message.data); 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 'ready': + case "ready": await this.updateState(); break; default: @@ -196,6 +207,11 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { const errorMessage = error instanceof Error ? error.message : String(error); this.logError(`Command '${message.command}' failed: ${errorMessage}`); + // If start failed after transitioning to Starting, reset to Stopped + if (message.command === "start") { + this.state = ProfilerState.Stopped; + await this.updateState(); + } await this.showError(errorMessage); } } @@ -208,10 +224,10 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { */ private isValidMessage(message: unknown): message is WebviewMessage { return ( - typeof message === 'object' && + typeof message === "object" && message !== null && - 'command' in message && - typeof (message as { command: unknown }).command === 'string' + "command" in message && + typeof (message as { command: unknown }).command === "string" ); } @@ -222,10 +238,10 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * @remarks Validates settings, starts server if needed, and begins polling */ private async handleStart(data: unknown): Promise { - this.log('Starting profiling session...'); + this.log("Starting profiling session..."); if (!this.isConnectionSettings(data)) { - throw new Error('Invalid connection settings'); + throw new Error("Invalid connection settings"); } const validationError = validateConnectionSettings(data); @@ -233,9 +249,14 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { throw new Error(validationError); } + // Immediately update UI to reflect the starting state before any async calls. + // This ensures the user sees immediate feedback even if the backend times out. + this.state = ProfilerState.Starting; + await this.updateState(); + // Start the server if not running if (!this.profilerClient.isRunning()) { - this.log('Starting profiler client...'); + this.log("Starting profiler client..."); await this.profilerClient.start(); } @@ -252,9 +273,9 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { // Start polling for events this.startPolling(); - this.log('Profiling started successfully'); + this.log("Profiling started successfully"); await vscode.window.showInformationMessage( - 'Profiling started successfully', + "Profiling started successfully", ); } @@ -265,15 +286,15 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * @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" ); } @@ -282,7 +303,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * @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()) { @@ -294,8 +315,8 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { this.seenEventKeys.clear(); await this.updateState(); - this.log('Profiling stopped'); - await vscode.window.showInformationMessage('Profiling stopped'); + this.log("Profiling stopped"); + await vscode.window.showInformationMessage("Profiling stopped"); } /** @@ -327,11 +348,11 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * @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.seenEventKeys.clear(); await this.postMessage({ - command: 'clearEvents', + command: "clearEvents", }); } @@ -390,7 +411,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { const rows = newEvents.map((event) => toEventRow(event)); await this.postMessage({ - command: 'addEvents', + command: "addEvents", data: rows, }); @@ -414,25 +435,25 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { // Option 1: Use event_sequence if ( - typeof actions['event_sequence'] === 'number' || - typeof actions['event_sequence'] === 'string' + typeof actions["event_sequence"] === "number" || + typeof actions["event_sequence"] === "string" ) { - return `seq:${actions['event_sequence']}`; + return `seq:${actions["event_sequence"]}`; } // Option 2: Use attach_activity_id - if (typeof actions['attach_activity_id'] === 'string') { - return `activity:${actions['attach_activity_id']}`; + if (typeof actions["attach_activity_id"] === "string") { + return `activity:${actions["attach_activity_id"]}`; } // Option 3: Fallback - const timestamp = event.timestamp ?? ''; - const name = event.name ?? ''; - const sessionIdRaw = actions['session_id']; + const timestamp = event.timestamp ?? ""; + const name = event.name ?? ""; + const sessionIdRaw = actions["session_id"]; const sessionId = - typeof sessionIdRaw === 'string' || typeof sessionIdRaw === 'number' + typeof sessionIdRaw === "string" || typeof sessionIdRaw === "number" ? String(sessionIdRaw) - : ''; + : ""; return `${timestamp}|${name}|${sessionId}`; } @@ -443,7 +464,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { */ private async updateState(): Promise { await this.postMessage({ - command: 'updateState', + command: "updateState", data: { state: this.state, eventCount: this.eventCount, @@ -457,7 +478,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { */ private async updateEventCount(): Promise { await this.postMessage({ - command: 'updateEventCount', + command: "updateEventCount", data: this.eventCount, }); } @@ -470,7 +491,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { 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}`); @@ -492,7 +513,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { * @remarks Stops polling and profiling session if active */ public async dispose(): Promise { - this.log('Disposing profiler view provider...'); + this.log("Disposing profiler view provider..."); this.stopPolling(); if (this.state !== ProfilerState.Stopped) { @@ -505,7 +526,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { } } - this.log('Profiler view provider disposed'); + this.log("Profiler view provider disposed"); } /** @@ -656,6 +677,10 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { background-color: var(--vscode-testing-iconQueued); } + .status-indicator.starting { + background-color: var(--vscode-progressBar-background, #007acc); + } + .events-table { width: 100%; border-collapse: collapse; @@ -739,7 +764,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider {
@@ -907,6 +932,7 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { clearEvents(); break; case 'error': + updateState('stopped'); showError(message.data); break; } @@ -924,6 +950,13 @@ export class ProfilerViewProvider implements vscode.WebviewViewProvider { resumeBtn.classList.add('hidden'); stopBtn.disabled = true; break; + case 'starting': + statusText.textContent = 'Starting…'; + startBtn.disabled = true; + pauseBtn.disabled = true; + resumeBtn.classList.add('hidden'); + stopBtn.disabled = true; + break; case 'running': statusText.textContent = 'Running'; startBtn.disabled = true; diff --git a/vscode-extension/src/views/recent-connections-panel-provider.ts b/vscode-extension/src/views/recent-connections-panel-provider.ts deleted file mode 100644 index 259c3cb..0000000 --- a/vscode-extension/src/views/recent-connections-panel-provider.ts +++ /dev/null @@ -1,542 +0,0 @@ -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: '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 }; - -/** - * Manages the "Recent Connections" webview panel. - * Shows a searchable list of saved connections. Double-clicking a row fires - * the `onConnectionSelected` callback and closes the panel. - */ -export class RecentConnectionsPanelProvider implements vscode.Disposable { - private panel: vscode.WebviewPanel | undefined; - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly profilerClient: ProfilerClient, - private readonly outputChannel: vscode.OutputChannel, - private readonly onConnectionSelected: ( - connection: RecentConnection, - ) => void, - private readonly onStartProfiling: (connection: RecentConnection) => void, - ) {} - - /** - * Opens (or reveals) the Recent Connections panel. - * Data is loaded once the webview signals it is ready via `webviewReady`. - */ - public show(): void { - if (this.panel) { - this.panel.reveal(vscode.ViewColumn.One); - // Always refresh the list — new connections may have been saved since - // the panel was last opened without being closed. - void this.loadConnections(); - return; - } - - this.panel = vscode.window.createWebviewPanel( - 'recentConnections', - 'Recent Connections', - vscode.ViewColumn.One, - { - enableScripts: true, - localResourceRoots: [this.extensionUri], - }, - ); - - this.panel.webview.html = this.getHtmlContent(this.panel.webview); - - this.panel.webview.onDidReceiveMessage( - async (message: WebviewIncomingMessage) => { - await this.handleMessage(message); - }, - ); - - this.panel.onDidDispose(() => { - this.panel = undefined; - }); - } - - /** - * Loads connections from the backend and posts them to the webview. - * Starts the JSON-RPC server first if it is not yet running. - */ - public async loadConnections(): Promise { - try { - if (!this.profilerClient.isRunning()) { - this.log('Server not running, starting server process...'); - await this.profilerClient.start(); - } - - const connections = await this.profilerClient.getRecentConnections(); - await this.postMessage({ - 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 }); - } - } - - /** Disposes the panel if it is open. */ - public dispose(): void { - this.panel?.dispose(); - this.panel = undefined; - } - - // ─── Private helpers ────────────────────────────────────────────────────── - - private async handleMessage(message: WebviewIncomingMessage): Promise { - switch (message.command) { - case 'webviewReady': - await this.loadConnections(); - break; - - case 'refresh': - await this.loadConnections(); - break; - - case 'connectionSelected': - this.onConnectionSelected(message.data); - this.panel?.dispose(); - break; - - 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; - - default: - break; - } - } - - /** - * 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); - } - } - - private log(message: string): void { - const timestamp = new Date().toISOString(); - this.outputChannel.appendLine( - `[${timestamp}] [RecentConnectionsPanelProvider] ${message}`, - ); - } - - private logError(message: string): void { - const timestamp = new Date().toISOString(); - this.outputChannel.appendLine( - `[${timestamp}] [RecentConnectionsPanelProvider] ERROR: ${message}`, - ); - } - - private getHtmlContent(_webview: vscode.Webview): string { - const nonce = crypto.randomBytes(16).toString('hex'); - - return ` - - - - - - Recent Connections - - - -
- - -
-
-
Loading...
-
- - - -`; - } -}