diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c3a52b0..d2b044f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,21 +3,13 @@ name: CI on: push: branches: [main] + # No paths-ignore here, deliberately. A required status check has to REPORT on every + # PR, and a workflow skipped by a path filter reports nothing at all -- GitHub then + # shows the check as forever pending and the PR can never merge. A docs-only PR would + # deadlock. The filtering moved into the job below, where the steps are skipped but the + # job still finishes and reports success. Same shape PerformanceMonitor's build.yml uses. pull_request: branches: [main, dev] - paths-ignore: - - '**.md' - - 'LICENSE' - - '.gitattributes' - - '.gitignore' - - 'CITATION.cff' - - 'llms.txt' - - '.github/ISSUE_TEMPLATE/**' - - 'docs/**' - - 'screenshots/**' - - 'server/**' - - 'src/PlanViewer.Ssms/**' - - 'src/PlanViewer.Ssms.Installer/**' permissions: contents: read @@ -29,7 +21,31 @@ jobs: steps: - uses: actions/checkout@v7 + # What used to be the workflow's paths-ignore list. Anything NOT matched here is + # code, and only then is there anything to build. + - name: Classify changed paths + uses: dorny/paths-filter@v4 + id: filter + with: + # Positive list, not negations. paths-filter ORs the patterns in a filter, so a + # stack of '!' patterns matches whenever a file fails ANY one of them, which for + # a docs-only change is always true. Listing what IS code keeps the OR honest. + # PlanViewer.Ssms and PlanViewer.Ssms.Installer stay out: they are not in the + # solution and ci.yml never built them. + filters: | + code: + - 'src/PlanViewer.App/**' + - 'src/PlanViewer.Cli/**' + - 'src/PlanViewer.Core/**' + - 'src/PlanViewer.Web/**' + - 'src/Directory.Build.props' + - 'tests/**' + - 'PlanViewer.sln' + - 'global.json' + - '.github/workflows/ci.yml' + - name: Setup .NET 10.0 + if: steps.filter.outputs.code == 'true' uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x @@ -37,13 +53,17 @@ jobs: cache-dependency-path: '**/*.csproj' - name: Install WASM workload + if: steps.filter.outputs.code == 'true' run: dotnet workload install wasm-tools - name: Restore solution + if: steps.filter.outputs.code == 'true' run: dotnet restore PlanViewer.sln - name: Build solution + if: steps.filter.outputs.code == 'true' run: dotnet build PlanViewer.sln -c Release --no-restore - name: Run tests + if: steps.filter.outputs.code == 'true' run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal -- --hangdump --hangdump-timeout 5m --hangdump-type none diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92ff73f7..30d517fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,7 @@ jobs: path: publish/win-x64/ - name: Sign Windows build - uses: signpath/github-action-submit-signing-request@v2 + uses: signpath/github-action-submit-signing-request@v3 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' diff --git a/CITATION.cff b/CITATION.cff index 6e18b56e..72cc0fe1 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.26.0" -date-released: "2026-09-16" +version: "1.27.0" +date-released: "2026-09-24" keywords: - sql-server - execution-plan diff --git a/README.md b/README.md index d93af5a0..847bbeb1 100644 --- a/README.md +++ b/README.md @@ -38,19 +38,19 @@ Navigate stored procedures and batches with multiple statements. Click any state ![Navigate Stored Procedure Statements and Plans](screenshots/Navigate%20Stored%20Procedure%20Statements%20and%20Plans.png) ### Operator Tooltip and Properties -Hover over any operator for a detailed tooltip with costs, rows, I/O, timing, parallelism, and warnings. Click to open the full properties panel with per-thread timing, predicates, and more. +Hover any operator for a grouped tooltip with costs, rows, timing, and parallelism. Click for the full properties panel, which has a filter box and folds per-thread stats into one expander per section instead of hundreds of rows. ![Operator Tooltip](screenshots/Actual%20Execution%20Plan%20With%20Warning%20Tool%20Tip.png) ![Operator Properties](screenshots/Operator%20Properties.png) ### Advice for Humans -One-click text report with server context, warnings, wait stats, and expensive operators — ready to read or share. +Severity-scored cards for each statement: the warnings that fired, wait stats, memory grant, and missing indexes. Copy to clipboard gives you the plain-text version to paste into a ticket. ![Advice for Humans](screenshots/Advice%20For%20Humans.png) ### Plan Comparison -Side-by-side comparison of two plans showing cost, runtime, I/O, memory, and wait stat differences. +A real metric diff. Each statement is scored as regressed or improved, every metric carries its own delta chip, and the direction that counts as better is declared per metric rather than guessed from the sign. ![Plan Comparison](screenshots/Plan%20Comparison.png) diff --git a/screenshots/Actual Execution Plan With Warning Tool Tip.png b/screenshots/Actual Execution Plan With Warning Tool Tip.png index ba4b4596..65ccc48e 100644 Binary files a/screenshots/Actual Execution Plan With Warning Tool Tip.png and b/screenshots/Actual Execution Plan With Warning Tool Tip.png differ diff --git a/screenshots/Actual Execution Plan.png b/screenshots/Actual Execution Plan.png index 21d01f30..fe7dd620 100644 Binary files a/screenshots/Actual Execution Plan.png and b/screenshots/Actual Execution Plan.png differ diff --git a/screenshots/Advice For Humans.png b/screenshots/Advice For Humans.png index 4fefa7ce..bb9937c9 100644 Binary files a/screenshots/Advice For Humans.png and b/screenshots/Advice For Humans.png differ diff --git a/screenshots/Navigate Stored Procedure Statements and Plans.png b/screenshots/Navigate Stored Procedure Statements and Plans.png index effab0cd..b7661215 100644 Binary files a/screenshots/Navigate Stored Procedure Statements and Plans.png and b/screenshots/Navigate Stored Procedure Statements and Plans.png differ diff --git a/screenshots/Operator Properties.png b/screenshots/Operator Properties.png index 523aedf1..9ce33e57 100644 Binary files a/screenshots/Operator Properties.png and b/screenshots/Operator Properties.png differ diff --git a/screenshots/Plan Comparison.png b/screenshots/Plan Comparison.png index 777abcc9..08f6686c 100644 Binary files a/screenshots/Plan Comparison.png and b/screenshots/Plan Comparison.png differ diff --git a/screenshots/Query Editor.png b/screenshots/Query Editor.png index 7f2beffa..18e863b6 100644 Binary files a/screenshots/Query Editor.png and b/screenshots/Query Editor.png differ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 224e9118..f07613cb 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.26.0 + 1.27.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.App/App.axaml b/src/PlanViewer.App/App.axaml index 03f3a9c2..d6c41328 100644 --- a/src/PlanViewer.App/App.axaml +++ b/src/PlanViewer.App/App.axaml @@ -43,11 +43,11 @@ - + diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs index e772bb50..5237aacb 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs @@ -109,10 +109,24 @@ private void ShowSchemaResult(string title, string content) Padding = new Thickness(4) }; - // SQL syntax highlighting - var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); - var tm = editor.InstallTextMate(registryOptions); - tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + /* SQL syntax highlighting, on the query editor's own lifecycle: installed on attach, + disposed on detach (#546). An installation owns a tokenization model with its own + thread, and a thread roots itself against GC — so installing once and never disposing + leaked a live thread for every schema tab ever closed. Detach also fires on plain tab + switches, which is why attach re-installs. */ + TextMate.Installation? tm = null; + editor.AttachedToVisualTree += (_, _) => + { + if (tm != null) return; + var registryOptions = new TextMateSharp.Grammars.RegistryOptions(TextMateSharp.Grammars.ThemeName.DarkPlus); + tm = editor.InstallTextMate(registryOptions); + tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + }; + editor.DetachedFromVisualTree += (_, _) => + { + tm?.Dispose(); + tm = null; + }; // Context menu var copyItem = new MenuItem { Header = "Copy" }; diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml index 8df78fab..f7b1b85c 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml @@ -525,7 +525,7 @@ Background="{DynamicResource BackgroundBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1"> - /// Update the connection UI to reflect an active connection (used when connection is inherited). + /// Update the connection UI to reflect an active connection (used when connection is + /// inherited). Label and button only — the session-hosted viewers that call this hide the + /// whole connection toolbar, so there is no picker to feed. A standalone tab with a live + /// toolbar wants instead. /// public void SetConnectionStatus(string serverName, string? database) { @@ -340,6 +342,27 @@ public void SetConnectionStatus(string serverName, string? database) _planSelectedDatabase = database; } + /// + /// Takes over a connection the ConnectionDialog just validated, for a standalone tab whose + /// toolbar is visible: paints the status AND fills, enables and pre-selects the database + /// picker, with set so changing the picker actually switches + /// . Status alone left a green label over a disabled, empty + /// picker — the same lie the connect handlers used to tell (#540 follow-up). + /// + /// Call first: the picker's SelectionChanged + /// rebuilds the connection string through the credential service. + /// + public void AdoptConnection(ServerConnection connection, string? database, + IReadOnlyList databases) + { + _planConnection = connection; + SetConnectionStatus(connection.ServerName, database); + + PlanDatabaseBox.ItemsSource = databases; + PlanDatabaseBox.IsEnabled = true; + SelectPlanDatabase(); + } + // Events for MainWindow to wire up advice/repro actions public event EventHandler? HumanAdviceRequested; public event EventHandler? RobotAdviceRequested; @@ -591,39 +614,34 @@ private async void PlanConnect_Click(object? sender, RoutedEventArgs e) PlanServerLabel.Foreground = FindBrushResource("SuccessBrush"); PlanConnectButton.Content = AppIcons.MakeContent(AppIcons.Connect, "Reconnect"); - // Populate database dropdown - try - { - var connStr = _planConnection.GetConnectionString(_planCredentialService, "master"); - await using var conn = new SqlConnection(connStr); - await conn.OpenAsync(); - - var databases = new List(); - using var cmd = new SqlCommand( - "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - databases.Add(reader.GetString(0)); + /* The dialog only closes with true after it opened this connection and enumerated + these databases — through the database the user named, which is the one some + logins (Azure SQL DB, JIT access) can open when master is off limits. Asking + again here through a second, hardcoded-master connection was a wasted round trip + whose swallowed failure left a green toolbar over a dead database picker. Same + hand-over QuerySessionControl's connect block takes. */ + PlanDatabaseBox.ItemsSource = dialog.ResultDatabases; + PlanDatabaseBox.IsEnabled = true; + SelectPlanDatabase(); + } - PlanDatabaseBox.ItemsSource = databases; - PlanDatabaseBox.IsEnabled = true; + /// + /// Points the picker at when the list holds it. The + /// selection this raises recomputes the same ConnectionString the caller already set, which + /// is idempotent on purpose — the handler is the one place the string is derived. + /// + private void SelectPlanDatabase() + { + if (_planSelectedDatabase == null) return; - if (_planSelectedDatabase != null) + for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) + { + if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) { - for (int i = 0; i < PlanDatabaseBox.Items.Count; i++) - { - if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase) - { - PlanDatabaseBox.SelectedIndex = i; - break; - } - } + PlanDatabaseBox.SelectedIndex = i; + break; } } - catch - { - PlanDatabaseBox.IsEnabled = false; - } } private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs index 5d379d1a..4512eba6 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs @@ -57,7 +57,22 @@ the rest of it has to live. */ ServerLabel.Foreground = Token("SuccessBrush", Brushes.LimeGreen); ConnectButton.Content = Helpers.AppIcons.MakeContent(Helpers.AppIcons.Connect, "Reconnect"); - await PopulateDatabases(); + /* Connecting is the one way a fresh session stops being empty without a keystroke + or a document, so neither of the overlay's other triggers will fire — without + this, the "Get started" panel keeps covering the editor and offering "Connect to + a server" on a session that just did (#540). Before the awaits below, so the + editor appears the moment the dialog closes rather than after three round trips + to the server. */ + RefreshEmptyState(); + + /* The dialog only closes with true after it opened this connection and enumerated + these databases — through the database the user named, which is the one some + logins (Azure SQL DB, JIT access) can open when master is off limits. Asking + again here through a second, hardcoded-master connection was a wasted round trip + whose swallowed failure left a green toolbar over a dead database picker. */ + DatabaseBox.ItemsSource = dialog.ResultDatabases; + DatabaseBox.IsEnabled = true; + await FetchServerMetadataAsync(); await FetchServerUtcOffset(); @@ -86,32 +101,6 @@ the rest of it has to live. */ } } - private async Task PopulateDatabases() - { - if (_serverConnection == null) return; - - try - { - var connStr = _serverConnection.GetConnectionString(_credentialService, "master"); - await using var conn = new SqlConnection(connStr); - await conn.OpenAsync(); - - var databases = new List(); - using var cmd = new SqlCommand( - "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn); - using var reader = await cmd.ExecuteReaderAsync(); - while (await reader.ReadAsync()) - databases.Add(reader.GetString(0)); - - DatabaseBox.ItemsSource = databases; - DatabaseBox.IsEnabled = true; - } - catch - { - DatabaseBox.IsEnabled = false; - } - } - private async void Database_SelectionChanged(object? sender, SelectionChangedEventArgs e) { if (_serverConnection == null || DatabaseBox.SelectedItem == null) return; diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.EmptyState.cs b/src/PlanViewer.App/Controls/QuerySessionControl.EmptyState.cs index e06279d6..7c89d207 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.EmptyState.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.EmptyState.cs @@ -21,17 +21,21 @@ public partial class QuerySessionControl : UserControl /// /// Decides whether the editor's empty state is showing, and rebuilds it when it is. /// - /// Shown only when this session holds nothing at all: no text, and no sub-tab beyond - /// the Query Editor. Both halves matter — a session whose editor is empty because the user - /// is reading the plan they just ran must not have an overlay waiting behind that plan. + /// Shown only when this session holds nothing at all: no text, no sub-tab beyond + /// the Query Editor, and no server connection. All three halves matter — a session whose + /// editor is empty because the user is reading the plan they just ran must not have an + /// overlay waiting behind that plan, and a session that just connected is in use even + /// though nothing has been typed yet: the editor IS the offer now, and a panel still + /// suggesting "Connect to a server" over it reads as the connection having failed (#540). /// - /// Called from the editor's TextChanged and from the sub-tab watcher, so it re-decides - /// in both directions: delete every character with no plan open and the panel comes back, - /// which is the same state a fresh tab is in and deserves the same offer. + /// Called from the editor's TextChanged, from the sub-tab watcher, and from the + /// connect block, so it re-decides in both directions: delete every character with no plan + /// open and no connection and the panel comes back, which is the same state a fresh tab is + /// in and deserves the same offer. /// private void RefreshEmptyState() { - var empty = QueryEditor.Text.Length == 0 && !HasDocuments; + var empty = QueryEditor.Text.Length == 0 && !HasDocuments && _serverConnection == null; if (empty) { diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 56f8746a..71bb358f 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -82,6 +82,8 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride Height = 4, Margin = new Avalonia.Thickness(0, 0, 0, 12) }; + // This overlay lives in tab content the user switches away from mid-capture. + Helpers.ProgressBarBehaviors.SetRestartOnReattach(progressBar, true); /* #448: SelectableTextBlock and wrapping, because this label doubles as the place a query failure is reported. A SQL error is the one string in this app a user most needs to copy @@ -314,6 +316,8 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) Height = 4, Margin = new Avalonia.Thickness(0, 0, 0, 12) }; + // This overlay lives in tab content the user switches away from mid-capture. + Helpers.ProgressBarBehaviors.SetRestartOnReattach(progressBar, true); /* #448: see the note on the estimated-plan path — this label reports failures too. */ var statusLabel = new SelectableTextBlock @@ -446,7 +450,10 @@ private Task ShowConfirmationDialog(string title, string message, string c private Window GetParentWindow() { - var parent = this.VisualRoot; + /* GetTopLevel rather than VisualRoot because Avalonia 12 hosts a Window inside a + TopLevelHost, so the visual root is no longer the Window and casting it to one always + misses — silently, since VisualRoot still compiles and still returns something. */ + var parent = TopLevel.GetTopLevel(this); return parent as Window ?? throw new InvalidOperationException("No parent window"); } } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs index 2afe020e..f91351c1 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs @@ -95,9 +95,22 @@ private void AddSchemaTab(string label, string content, bool isSql) if (isSql) { - var registryOptions = new RegistryOptions(ThemeName.DarkPlus); - var tm = editor.InstallTextMate(registryOptions); - tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + /* Same lifecycle as the query editor and the plan viewer's schema tabs (#546): + install on attach, dispose on detach. The installation's tokenization model runs + a thread that roots itself, so a tab closed without disposing leaked it. */ + TextMate.Installation? tm = null; + editor.AttachedToVisualTree += (_, _) => + { + if (tm != null) return; + var registryOptions = new RegistryOptions(ThemeName.DarkPlus); + tm = editor.InstallTextMate(registryOptions); + tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql")); + }; + editor.DetachedFromVisualTree += (_, _) => + { + tm?.Dispose(); + tm = null; + }; } // Context menu for read-only schema tabs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml b/src/PlanViewer.App/Controls/QuerySessionControl.axaml index 90818874..5673e1e8 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml @@ -338,11 +338,11 @@ Padding="4"/> + empty — nothing typed, no document open, no server connected — so a + new Query tab offers its primary actions instead of a blank wall. It + never traps typing: the editor keeps focus underneath, a click on the + background hands focus back to it, and the overlay leaves the moment + there is any text, any document, or a connection (RefreshEmptyState). --> @@ -94,7 +95,7 @@ @@ -170,14 +171,14 @@ - + - + - @@ -186,22 +187,22 @@ - + - + - + - + @@ -487,7 +488,8 @@ Background="#80000000" CornerRadius="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> - + diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml index adda15b0..b59a90c1 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml @@ -1,6 +1,7 @@ @@ -71,7 +72,8 @@ FontSize="12" Foreground="{DynamicResource ForegroundBrush}"/> - +