From 583c4ad1abeaabb2c1ff42162b929ea099b928de Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:43:21 -0400 Subject: [PATCH] Confirm before executing a plan file's query against a server "Get Actual Plan" on a loaded plan file prompted for a connection and then ran the query. The query-session path already confirmed first; this one 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. The dialog names the target server and database, so "which server was that?" is answered before the query runs rather than after. Extracts the dialog into Dialogs/ConfirmationDialog.cs instead of copying it. The two paths having separate implementations is how one of them ended up with no confirmation at all; DdlScripter has the same history of a duplicated helper drifting. QuerySessionControl now delegates to the shared version, so its behavior is unchanged. Verified: clean build 0 warnings, 202/202 tests, app launches and paints. AppButton resolves from Application.Resources, so looking it up from the owner window works the same as from the control. Co-Authored-By: Claude Fable 5 --- .../Controls/QuerySessionControl.Execution.cs | 74 +--------------- .../Dialogs/ConfirmationDialog.cs | 88 +++++++++++++++++++ src/PlanViewer.App/MainWindow.PlanViewer.cs | 19 ++++ 3 files changed, 109 insertions(+), 72 deletions(-) create mode 100644 src/PlanViewer.App/Dialogs/ConfirmationDialog.cs 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) ||