diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 7311a70..9a15199 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -154,6 +154,12 @@ private void OnKeyDown(object? sender, KeyEventArgs e) ExecuteEstimated_Click(this, new RoutedEventArgs()); e.Handled = true; } + // Escape → Cancel running query + else if (e.Key == Key.Escape && _executionCts != null && !_executionCts.IsCancellationRequested) + { + _executionCts.Cancel(); + e.Handled = true; + } } private void OnEditorPointerWheel(object? sender, PointerWheelEventArgs e) @@ -448,10 +454,96 @@ private async Task CaptureAndShowPlan(bool estimated) _executionCts = new CancellationTokenSource(); var ct = _executionCts.Token; - var planType = estimated ? "estimated" : "actual"; - SetStatus($"Capturing {planType} plan..."); - ExecuteButton.IsEnabled = false; - ExecuteEstButton.IsEnabled = false; + var planType = estimated ? "Estimated" : "Actual"; + + // Create loading tab with cancel button + var loadingPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + Width = 300 + }; + + var progressBar = new ProgressBar + { + IsIndeterminate = true, + Height = 4, + Margin = new Avalonia.Thickness(0, 0, 0, 12) + }; + + var statusLabel = new TextBlock + { + Text = $"Capturing {planType.ToLower()} plan...", + FontSize = 14, + Foreground = new SolidColorBrush(Color.Parse("#B0B6C0")), + HorizontalAlignment = HorizontalAlignment.Center + }; + + var cancelBtn = new Button + { + Content = "\u25A0 Cancel", + Height = 32, + Width = 120, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 13, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + + loadingPanel.Children.Add(progressBar); + loadingPanel.Children.Add(statusLabel); + loadingPanel.Children.Add(cancelBtn); + + var loadingContainer = new Grid + { + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Focusable = true, + Children = { loadingPanel } + }; + loadingContainer.KeyDown += (_, ke) => + { + if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + }; + + // Add loading tab and switch to it + _planCounter++; + var tabLabel = estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"; + var headerText = new TextBlock + { + Text = tabLabel, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + var loadingTab = new TabItem { Header = header, Content = loadingContainer }; + closeBtn.Tag = loadingTab; + closeBtn.Click += ClosePlanTab_Click; + + SubTabControl.Items.Add(loadingTab); + SubTabControl.SelectedItem = loadingTab; + loadingContainer.Focus(); try { @@ -466,45 +558,51 @@ private async Task CaptureAndShowPlan(bool estimated) if (estimated) { planXml = await EstimatedPlanExecutor.GetEstimatedPlanAsync( - _connectionString, _selectedDatabase, queryText, timeoutSeconds: 60, ct); + _connectionString, _selectedDatabase, queryText, timeoutSeconds: 0, ct); } else { planXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( _connectionString, _selectedDatabase, queryText, planXml: null, isolationLevel: null, - isAzureSqlDb: isAzure, timeoutSeconds: 60, ct); + isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); } sw.Stop(); if (string.IsNullOrEmpty(planXml)) { - SetStatus($"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)"); + statusLabel.Text = $"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; return; } + // Replace loading content with the plan viewer SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); - AddPlanTab(planXml, queryText, estimated); + var viewer = new PlanViewerControl(); + viewer.Metadata = _serverMetadata; + viewer.LoadPlan(planXml, tabLabel, queryText); + loadingTab.Content = viewer; HumanAdviceButton.IsEnabled = true; RobotAdviceButton.IsEnabled = true; } catch (OperationCanceledException) { SetStatus("Cancelled"); + SubTabControl.Items.Remove(loadingTab); } catch (SqlException ex) { - SetStatus(ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message, autoClear: false); + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; } catch (Exception ex) { - SetStatus(ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message, autoClear: false); - } - finally - { - ExecuteButton.IsEnabled = true; - ExecuteEstButton.IsEnabled = true; + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; } } @@ -1174,8 +1272,93 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) _executionCts = new CancellationTokenSource(); var ct = _executionCts.Token; - SetStatus("Capturing actual plan..."); - GetActualPlanButton.IsEnabled = false; + // Create loading tab with cancel button + var loadingPanel = new StackPanel + { + VerticalAlignment = VerticalAlignment.Center, + HorizontalAlignment = HorizontalAlignment.Center, + Width = 300 + }; + + var progressBar = new ProgressBar + { + IsIndeterminate = true, + Height = 4, + Margin = new Avalonia.Thickness(0, 0, 0, 12) + }; + + var statusLabel = new TextBlock + { + Text = "Capturing actual plan...", + FontSize = 14, + Foreground = new SolidColorBrush(Color.Parse("#B0B6C0")), + HorizontalAlignment = HorizontalAlignment.Center + }; + + var cancelBtn = new Button + { + Content = "\u25A0 Cancel", + Height = 32, + Width = 120, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 13, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + + loadingPanel.Children.Add(progressBar); + loadingPanel.Children.Add(statusLabel); + loadingPanel.Children.Add(cancelBtn); + + var loadingContainer = new Grid + { + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Focusable = true, + Children = { loadingPanel } + }; + loadingContainer.KeyDown += (_, ke) => + { + if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + }; + + _planCounter++; + var tabLabel = $"Plan {_planCounter}"; + var headerText = new TextBlock + { + Text = tabLabel, + VerticalAlignment = VerticalAlignment.Center, + FontSize = 12 + }; + var closeBtn = new Button + { + Content = "\u2715", + MinWidth = 22, MinHeight = 22, Width = 22, Height = 22, + Padding = new Avalonia.Thickness(0), + FontSize = 11, + Margin = new Avalonia.Thickness(6, 0, 0, 0), + Background = Brushes.Transparent, + BorderThickness = new Avalonia.Thickness(0), + Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)), + VerticalAlignment = VerticalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + var header = new StackPanel + { + Orientation = Orientation.Horizontal, + Children = { headerText, closeBtn } + }; + var loadingTab = new TabItem { Header = header, Content = loadingContainer }; + closeBtn.Tag = loadingTab; + closeBtn.Click += ClosePlanTab_Click; + + SubTabControl.Items.Add(loadingTab); + SubTabControl.SelectedItem = loadingTab; + loadingContainer.Focus(); try { @@ -1185,30 +1368,40 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( _connectionString, _selectedDatabase, queryText, planXml, isolationLevel: null, - isAzureSqlDb: isAzure, timeoutSeconds: 60, ct); + isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); sw.Stop(); if (string.IsNullOrEmpty(actualPlanXml)) { - SetStatus($"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)"); + statusLabel.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; return; } SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); - AddPlanTab(actualPlanXml, queryText, estimated: false); + var actualViewer = new PlanViewerControl(); + actualViewer.Metadata = _serverMetadata; + actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText); + loadingTab.Content = actualViewer; } catch (OperationCanceledException) { SetStatus("Cancelled"); + SubTabControl.Items.Remove(loadingTab); } catch (SqlException ex) { - SetStatus(ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message, autoClear: false); + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; } catch (Exception ex) { - SetStatus(ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message, autoClear: false); + statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message; + progressBar.IsVisible = false; + cancelBtn.IsVisible = false; } finally { diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index a834a5a..8f18151 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -1035,19 +1035,43 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer) HorizontalAlignment = HorizontalAlignment.Center }; + var cancelBtn = new Button + { + Content = "\u25A0 Cancel", + Height = 32, + Width = 120, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 13, + Margin = new Avalonia.Thickness(0, 16, 0, 0), + HorizontalAlignment = HorizontalAlignment.Center, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! + }; + loadingPanel.Children.Add(progressBar); loadingPanel.Children.Add(statusText); + loadingPanel.Children.Add(cancelBtn); + + var cts = new System.Threading.CancellationTokenSource(); + cancelBtn.Click += (_, _) => cts.Cancel(); var loadingContainer = new Grid { Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Focusable = true, Children = { loadingPanel } }; + loadingContainer.KeyDown += (_, ke) => + { + if (ke.Key == Avalonia.Input.Key.Escape) { cts.Cancel(); ke.Handled = true; } + }; var tab = CreateTab("Actual Plan", loadingContainer); MainTabControl.Items.Add(tab); MainTabControl.SelectedItem = tab; UpdateEmptyOverlay(); + loadingContainer.Focus(); try { @@ -1064,13 +1088,12 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer) statusText.Text = "Capturing actual plan..."; - var cts = new System.Threading.CancellationTokenSource(); var sw = System.Diagnostics.Stopwatch.StartNew(); var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( connectionString, database, queryText, viewer.RawXml, isolationLevel: null, - isAzureSqlDb: isAzure, timeoutSeconds: 60, cts.Token); + isAzureSqlDb: isAzure, timeoutSeconds: 0, cts.Token); sw.Stop(); diff --git a/src/PlanViewer.Core/Services/CredentialServiceFactory.cs b/src/PlanViewer.Core/Services/CredentialServiceFactory.cs index e99a654..648ff53 100644 --- a/src/PlanViewer.Core/Services/CredentialServiceFactory.cs +++ b/src/PlanViewer.Core/Services/CredentialServiceFactory.cs @@ -13,8 +13,7 @@ public static ICredentialService Create() if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return new KeychainCredentialService(); - throw new PlatformNotSupportedException( - "Credential storage is not yet supported on this platform. " + - "Windows and macOS are supported."); + // Linux and other platforms: use in-memory storage (credentials not persisted across sessions) + return new InMemoryCredentialService(); } } diff --git a/src/PlanViewer.Core/Services/InMemoryCredentialService.cs b/src/PlanViewer.Core/Services/InMemoryCredentialService.cs new file mode 100644 index 0000000..733dc54 --- /dev/null +++ b/src/PlanViewer.Core/Services/InMemoryCredentialService.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using PlanViewer.Core.Interfaces; + +namespace PlanViewer.Core.Services; + +/// +/// In-memory credential service for platforms without native credential storage (e.g. Linux). +/// Credentials are held for the lifetime of the app but not persisted to disk. +/// +public class InMemoryCredentialService : ICredentialService +{ + private readonly ConcurrentDictionary _store = new(); + + public bool SaveCredential(string serverId, string username, string password) + { + _store[serverId] = (username, password); + return true; + } + + public (string Username, string Password)? GetCredential(string serverId) + { + return _store.TryGetValue(serverId, out var cred) ? cred : null; + } + + public bool DeleteCredential(string serverId) + { + return _store.TryRemove(serverId, out _); + } + + public bool CredentialExists(string serverId) + { + return _store.ContainsKey(serverId); + } + + public bool UpdateCredential(string serverId, string username, string password) + { + return SaveCredential(serverId, username, password); + } +}