diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 45f0bd7..4145b51 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -404,78 +404,8 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) /// /// Shows a modal confirmation dialog and returns true if the user clicked OK. /// - private async Task ShowConfirmationDialog(string title, string message) - { - var result = false; - - var messageText = new TextBlock - { - Text = message, - TextWrapping = TextWrapping.Wrap, - FontSize = 13, - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Margin = new Avalonia.Thickness(0, 0, 0, 16) - }; - - var okBtn = new Button - { - Content = "OK", - Height = 32, - Width = 80, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var cancelBtn = new Button - { - Content = "Cancel", - Height = 32, - Width = 80, - Padding = new Avalonia.Thickness(16, 0), - FontSize = 12, - Margin = new Avalonia.Thickness(8, 0, 0, 0), - HorizontalContentAlignment = HorizontalAlignment.Center, - VerticalContentAlignment = VerticalAlignment.Center, - Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! - }; - - var buttonPanel = new StackPanel - { - Orientation = Avalonia.Layout.Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right - }; - buttonPanel.Children.Add(okBtn); - buttonPanel.Children.Add(cancelBtn); - - var content = new StackPanel - { - Margin = new Avalonia.Thickness(20), - Children = { messageText, buttonPanel } - }; - - var dialog = new Window - { - Title = title, - Width = 420, - Height = 200, - MinWidth = 420, - MinHeight = 200, - Icon = GetParentWindow().Icon, - Background = new SolidColorBrush(Color.Parse("#1A1D23")), - Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), - Content = content, - WindowStartupLocation = WindowStartupLocation.CenterOwner - }; - - okBtn.Click += (_, _) => { result = true; dialog.Close(); }; - cancelBtn.Click += (_, _) => dialog.Close(); - - await dialog.ShowDialog(GetParentWindow()); - return result; - } + private Task ShowConfirmationDialog(string title, string message) + => Dialogs.ConfirmationDialog.ShowAsync(GetParentWindow(), title, message); /// /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute. diff --git a/src/PlanViewer.App/Dialogs/ConfirmationDialog.cs b/src/PlanViewer.App/Dialogs/ConfirmationDialog.cs new file mode 100644 index 0000000..ae6dba6 --- /dev/null +++ b/src/PlanViewer.App/Dialogs/ConfirmationDialog.cs @@ -0,0 +1,88 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace PlanViewer.App.Dialogs; + +/// +/// Modal yes/no confirmation. Shared rather than per-control: this dialog gates +/// executing SQL against a live server, and it is reached from both the query +/// session and a loaded plan file. Two copies would be free to drift, which is +/// exactly how one of those two paths ended up with no confirmation at all. +/// +public static class ConfirmationDialog +{ + public static async Task ShowAsync(Window owner, string title, string message) + { + var result = false; + + var messageText = new TextBlock + { + Text = message, + TextWrapping = TextWrapping.Wrap, + FontSize = 13, + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Margin = new Avalonia.Thickness(0, 0, 0, 16) + }; + + var okBtn = new Button + { + Content = "OK", + Height = 32, + Width = 80, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)owner.FindResource("AppButton")! + }; + + var cancelBtn = new Button + { + Content = "Cancel", + Height = 32, + Width = 80, + Padding = new Avalonia.Thickness(16, 0), + FontSize = 12, + Margin = new Avalonia.Thickness(8, 0, 0, 0), + HorizontalContentAlignment = HorizontalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center, + Theme = (Avalonia.Styling.ControlTheme)owner.FindResource("AppButton")! + }; + + var buttonPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Right + }; + buttonPanel.Children.Add(okBtn); + buttonPanel.Children.Add(cancelBtn); + + var content = new StackPanel + { + Margin = new Avalonia.Thickness(20), + Children = { messageText, buttonPanel } + }; + + var dialog = new Window + { + Title = title, + Width = 460, + Height = 260, + MinWidth = 460, + MinHeight = 260, + Icon = owner.Icon, + Background = new SolidColorBrush(Color.Parse("#1A1D23")), + Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")), + Content = content, + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + + okBtn.Click += (_, _) => { result = true; dialog.Close(); }; + cancelBtn.Click += (_, _) => dialog.Close(); + + await dialog.ShowDialog(owner); + return result; + } +} diff --git a/src/PlanViewer.App/MainWindow.PlanViewer.cs b/src/PlanViewer.App/MainWindow.PlanViewer.cs index c8f5d47..dc44089 100644 --- a/src/PlanViewer.App/MainWindow.PlanViewer.cs +++ b/src/PlanViewer.App/MainWindow.PlanViewer.cs @@ -388,6 +388,25 @@ private async Task GetActualPlanFromFile(PlanViewerControl viewer) return; var database = dialog.ResultDatabase ?? ExtractDatabaseFromPlanXml(viewer.RawXml); + + /* Confirm before running it. The query session asks first; this path did + not, and it is the riskier of the two: the SQL comes out of a .sqlplan + the user opened rather than something they typed, and plan files get + emailed around. Name the target so "which server was that?" is answered + before the query runs, not after. */ + var target = string.IsNullOrEmpty(database) + ? dialog.ResultConnection.ServerName + : $"{dialog.ResultConnection.ServerName}, database [{database}]"; + + var confirmed = await Dialogs.ConfirmationDialog.ShowAsync( + this, + "Get Actual Plan", + $"This will execute the query stored in this plan file against:\n\n{target}\n\n" + + "The query text comes from the plan file, not from you. Review it first if the file did not originate on this machine.\n\n" + + "It runs with SET STATISTICS XML ON and all data results are discarded.\n\nContinue?"); + + if (!confirmed) return; + var connectionString = dialog.ResultConnection.GetConnectionString(_credentialService, database); var isAzure = dialog.ResultConnection.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) ||