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);
+ }
+}