diff --git a/CITATION.cff b/CITATION.cff index 5bcbb8da..c8e61707 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,8 +9,8 @@ authors: website: "https://erikdarling.com" repository-code: "https://github.com/erikdarlingdata/PerformanceStudio" license: MIT -version: "1.21.0" -date-released: "2026-08-21" +version: "1.22.0" +date-released: "2026-08-31" keywords: - sql-server - execution-plan diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 3142b1c2..17a0f0af 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.21.0 + 1.22.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 3c5f2902..37d0759c 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -32,6 +32,11 @@ public partial class QuerySessionControl : UserControl private readonly ICredentialService _credentialService; private readonly ConnectionStore _connectionStore; + /// + /// Full path on disk when the query was loaded from, or last saved to, a file. + /// + public string? SourceFilePath { get; set; } + private ServerConnection? _serverConnection; private string? _connectionString; private string? _selectedDatabase; diff --git a/src/PlanViewer.App/MainWindow.FileOps.cs b/src/PlanViewer.App/MainWindow.FileOps.cs index f2909ecf..99c56d6e 100644 --- a/src/PlanViewer.App/MainWindow.FileOps.cs +++ b/src/PlanViewer.App/MainWindow.FileOps.cs @@ -72,6 +72,97 @@ private async void OpenFile_Click(object? sender, RoutedEventArgs e) } } + private async void OpenQuery_Click(object? sender, RoutedEventArgs e) + { + var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Open Query", + AllowMultiple = true, + FileTypeFilter = new[] + { + new FilePickerFileType("SQL Scripts") + { + Patterns = new[] { "*.sql" } + }, + FilePickerFileTypes.All + } + }); + + foreach (var file in files) + { + var path = file.TryGetLocalPath(); + if (path != null) + LoadSqlFile(path); + } + } + + private async void SaveQuery_Click(object? sender, RoutedEventArgs e) + { + await SaveQueryAsync(); + } + + /// + /// Writes the active query tab's text to disk. Always prompts, but defaults to the + /// file the query came from so saving back over it is the path of least resistance. + /// Silently does nothing when the active tab is a plan rather than a query - the menu + /// item is reachable from anywhere and there is nothing to save. + /// + private async Task SaveQueryAsync() + { + if (MainTabControl.SelectedItem is not TabItem { Content: QuerySessionControl session } tab) + return; + + var existing = session.SourceFilePath; + + var options = new FilePickerSaveOptions + { + Title = "Save Query", + SuggestedFileName = existing != null ? Path.GetFileName(existing) : "Query.sql", + DefaultExtension = "sql", + FileTypeChoices = new[] + { + new FilePickerFileType("SQL Scripts") + { + Patterns = new[] { "*.sql" } + }, + FilePickerFileTypes.All + } + }; + + if (existing != null) + { + var directory = Path.GetDirectoryName(existing); + if (!string.IsNullOrEmpty(directory)) + options.SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(directory); + } + + var file = await StorageProvider.SaveFilePickerAsync(options); + + var path = file?.TryGetLocalPath(); + if (path != null) + SaveQueryToPath(tab, session, path); + } + + /// + /// The half of saving that does not need a human: write the text, remember where it went, + /// and retitle the tab to match. Split out from the picker so it can be tested. + /// + internal bool SaveQueryToPath(TabItem tab, QuerySessionControl session, string path) + { + try + { + File.WriteAllText(path, session.QueryEditor.Text); + session.SourceFilePath = path; + SetTabLabel(tab, Path.GetFileName(path)); + return true; + } + catch (Exception ex) + { + ShowFileError("Error Saving File", $"Failed to save: {Path.GetFileName(path)}", ex.Message); + return false; + } + } + private async void PasteXml_Click(object? sender, RoutedEventArgs e) { await PasteXmlAsync(); @@ -141,7 +232,7 @@ private void OpenFileByExtension(string filePath) LoadPlanFile(filePath); } - private void LoadSqlFile(string filePath) + internal void LoadSqlFile(string filePath) { try { @@ -151,6 +242,7 @@ private void LoadSqlFile(string filePath) _queryCounter++; var session = new QuerySessionControl(_credentialService, _connectionStore); session.QueryEditor.Text = text; + session.SourceFilePath = filePath; var tab = CreateTab(fileName, session); MainTabControl.Items.Add(tab); @@ -159,33 +251,52 @@ private void LoadSqlFile(string filePath) } catch (Exception ex) { - var dialog = new Window + ShowFileError("Error Opening File", $"Failed to open: {Path.GetFileName(filePath)}", ex.Message); + } + } + + /// + /// One modal for every file operation that can fail, so opening and saving report + /// trouble the same way. + /// + /// Shown ownerless until this window is visible. Both the command-line open and the + /// restore of the previous session's tabs run from the constructor, so a file that is + /// missing or unreadable at startup reaches here before there is anything to be modal + /// over, and ShowDialog against a window that has not been shown throws rather than + /// reporting the problem it was called about. + /// + private void ShowFileError(string title, string headline, string detail) + { + var dialog = new Window + { + Title = title, + Width = 450, + Height = 200, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = new StackPanel { - Title = "Error Opening File", - Width = 450, - Height = 200, - WindowStartupLocation = WindowStartupLocation.CenterOwner, - Content = new StackPanel + Margin = new Avalonia.Thickness(20), + Children = { - Margin = new Avalonia.Thickness(20), - Children = + new TextBlock + { + Text = headline, + FontWeight = FontWeight.Bold, + Margin = new Avalonia.Thickness(0, 0, 0, 10) + }, + new TextBlock { - new TextBlock - { - Text = $"Failed to open: {Path.GetFileName(filePath)}", - FontWeight = FontWeight.Bold, - Margin = new Avalonia.Thickness(0, 0, 0, 10) - }, - new TextBlock - { - Text = ex.Message, - TextWrapping = TextWrapping.Wrap - } + Text = detail, + TextWrapping = TextWrapping.Wrap } } - }; + } + }; + + if (IsVisible) dialog.ShowDialog(this); - } + else + dialog.Show(); } internal void LoadPlanFile(string filePath) diff --git a/src/PlanViewer.App/MainWindow.Tabs.cs b/src/PlanViewer.App/MainWindow.Tabs.cs index 3c642e08..029e8549 100644 --- a/src/PlanViewer.App/MainWindow.Tabs.cs +++ b/src/PlanViewer.App/MainWindow.Tabs.cs @@ -177,6 +177,16 @@ private void TabContextMenu_Click(object? sender, RoutedEventArgs e) } } + /// + /// Retitles a tab. The header is a StackPanel whose first child carries the text, + /// which is also what StartRename edits. + /// + private static void SetTabLabel(TabItem tab, string label) + { + if (tab.Header is StackPanel header && header.Children.Count > 0 && header.Children[0] is TextBlock text) + text.Text = label; + } + private static string? GetTabFilePath(TabItem tab) { // Plans opened from file are wrapped in a DockPanel with the viewer as the last child diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml index 19e16032..fc63ed8e 100644 --- a/src/PlanViewer.App/MainWindow.axaml +++ b/src/PlanViewer.App/MainWindow.axaml @@ -16,6 +16,11 @@ + + + - +