From 741ab10fd6b0f7a388d6e0f6189437134b6434d5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:40:41 -0400 Subject: [PATCH 01/13] Add macOS Gatekeeper workaround to README download section Co-Authored-By: Claude Opus 4.6 --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index aff80fe..e5a3b71 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ Pre-built binaries are available on the [Releases](https://github.com/erikdarlin These are self-contained — no .NET SDK required. Extract the zip and run. +**macOS note:** macOS may block the app because it isn't signed with an Apple Developer certificate. If you see a warning that the app "can't be opened," run this after extracting: + +```bash +xattr -cr PerformanceStudio.app +``` + +Then open the app normally. + ## Build from Source Clone and build: From fd53dc4992c481e7650f275e78f16304b0b63084 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:07:49 -0500 Subject: [PATCH 02/13] Fix Rule 22 table variable warnings on modification operators (#80) - Node-level: Insert/Update/Delete on table variables now shows Critical "forces serial" warning instead of generic "lacks statistics" message - Statement-level: stats warning only fires when reading from a table variable, not modifying it - Parameters pane: exclude table variable names from local variable detection so @t is not misreported as an unresolved local variable Co-authored-by: Claude Opus 4.6 --- .../Controls/PlanViewerControl.axaml.cs | 27 ++++++++++++++++--- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 12 ++++++--- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 363d967..3ef8327 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -2187,7 +2187,7 @@ private void ShowParameters(PlanStatement statement) if (parameters.Count == 0) { - var localVars = FindUnresolvedVariables(statement.StatementText, parameters); + var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); if (localVars.Count > 0) { ParametersHeader.Text = "Parameters"; @@ -2303,7 +2303,7 @@ private void ShowParameters(PlanStatement statement) } } - var unresolved = FindUnresolvedVariables(statement.StatementText, parameters); + var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); if (unresolved.Count > 0) { AddParameterAnnotation( @@ -2350,7 +2350,8 @@ private void AddParameterAnnotation(string text, string color) }); } - private static List FindUnresolvedVariables(string queryText, List parameters) + private static List FindUnresolvedVariables(string queryText, List parameters, + PlanNode? rootNode = null) { var unresolved = new List(); if (string.IsNullOrEmpty(queryText)) @@ -2359,6 +2360,11 @@ private static List FindUnresolvedVariables(string queryText, List( parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase); + // Collect table variable names from the plan tree so we don't misreport them as local variables + var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (rootNode != null) + CollectTableVariableNames(rootNode, tableVarNames); + var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase); var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -2369,6 +2375,8 @@ private static List FindUnresolvedVariables(string queryText, List FindUnresolvedVariables(string queryText, List names) + { + if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) + { + // ObjectName is like "@t.c" — extract the table variable name "@t" + var dotIdx = node.ObjectName.IndexOf('.'); + var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName; + names.Add(tvName); + } + foreach (var child in node.Children) + CollectTableVariableNames(child, names); + } + private static void CollectWarnings(PlanNode node, List warnings) { warnings.AddRange(node.Warnings); diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index f16e976..0d577b3 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -388,7 +388,7 @@ private static void AnalyzeStatement(PlanStatement stmt, AnalyzerConfig cfg) var modifiesTableVar = false; CheckForTableVariables(stmt.RootNode, isModification, ref hasTableVar, ref modifiesTableVar); - if (hasTableVar) + if (hasTableVar && !modifiesTableVar) { stmt.PlanWarnings.Add(new PlanWarning { @@ -865,11 +865,17 @@ _ when nonSargableReason.StartsWith("Function call") => if (!cfg.IsRuleDisabled(22) && !string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@")) { + var isModificationOp = node.PhysicalOp.Contains("Insert", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Update", StringComparison.OrdinalIgnoreCase) + || node.PhysicalOp.Contains("Delete", StringComparison.OrdinalIgnoreCase); + node.Warnings.Add(new PlanWarning { WarningType = "Table Variable", - Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", - Severity = PlanWarningSeverity.Warning + Message = isModificationOp + ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." + : "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", + Severity = isModificationOp ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning }); } From 53dd47c7b9848b009dd6dabb48d7760bfca68917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A1udio=20Silva?= Date: Wed, 11 Mar 2026 18:18:46 +0000 Subject: [PATCH 03/13] fix #81 (#82) --- src/PlanViewer.App/MainWindow.axaml.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index dc61e5f..1adb87d 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -451,14 +451,14 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! }; - humanBtn.Click += (_, _) => + Action showHumanAdvice = () => { if (viewer.CurrentPlan == null) return; var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata); ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis); }; - robotBtn.Click += (_, _) => + Action showRobotAdvice = () => { if (viewer.CurrentPlan == null) return; var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata); @@ -466,6 +466,9 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) ShowAdviceWindow("Advice for Robots", json); }; + humanBtn.Click += (_, _) => showHumanAdvice(); + robotBtn.Click += (_, _) => showRobotAdvice(); + var compareBtn = new Button { Content = "\u2194 Compare Plans", @@ -500,7 +503,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! }; - copyReproBtn.Click += async (_, _) => + Func copyRepro = async () => { if (viewer.CurrentPlan == null) return; var queryText = GetQueryTextFromPlan(viewer); @@ -518,10 +521,12 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer) } }; + copyReproBtn.Click += async (_, _) => await copyRepro(); + // Wire up context menu events from PlanViewerControl - viewer.HumanAdviceRequested += (_, _) => humanBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); - viewer.RobotAdviceRequested += (_, _) => robotBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); - viewer.CopyReproRequested += async (_, _) => copyReproBtn.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); + viewer.HumanAdviceRequested += (_, _) => showHumanAdvice(); + viewer.RobotAdviceRequested += (_, _) => showRobotAdvice(); + viewer.CopyReproRequested += async (_, _) => await copyRepro(); var getActualPlanBtn = new Button { From c5e9cae8848dd9a77d65691a80a73bbe4fd6c197 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:24:07 -0500 Subject: [PATCH 04/13] Fix Ctrl+scroll-down not zooming out (#83) (#84) PointerWheelChanged was attached with bubble routing, so the ScrollViewer consumed scroll-down events before the Ctrl+zoom handler could intercept them. Switch to Tunnel routing via AddHandler so the zoom handler fires first, matching how the pan handlers are already wired up. Closes #83 Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs | 3 ++- src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 3ef8327..60045d0 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -118,7 +118,8 @@ public partial class PlanViewerControl : UserControl public PlanViewerControl() { InitializeComponent(); - PlanScrollViewer.PointerWheelChanged += PlanScrollViewer_PointerWheelChanged; + // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event + PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel); // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel); diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index fadeed6..ffda5b6 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -60,8 +60,8 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore // Keybindings: F5/Ctrl+E for Execute, Ctrl+L for Estimated Plan KeyDown += OnKeyDown; - // Ctrl+mousewheel for font zoom - QueryEditor.PointerWheelChanged += OnEditorPointerWheel; + // Ctrl+mousewheel for font zoom — use Tunnel so it fires before ScrollViewer consumes scroll-down + QueryEditor.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, OnEditorPointerWheel, Avalonia.Interactivity.RoutingStrategies.Tunnel); // Code completion QueryEditor.TextArea.TextEntering += OnTextEntering; From 0e4cf5176d2a6a46376831d8d50f702e25077776 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:00:52 -0500 Subject: [PATCH 05/13] Embed application icon in exe for taskbar pinning (#85) (#86) Add to csproj so the icon is embedded in the PE resources. Without this, Windows falls back to the default app icon when the application is pinned to the taskbar. Closes #85 Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.App/PlanViewer.App.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 4bdf48c..46f6770 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -4,6 +4,7 @@ net8.0 enable app.manifest + EDD.ico true 1.1.0 Erik Darling From bc174ff6d2ebc292ac86cddf7256e34fdd7cdcde Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:44:58 -0500 Subject: [PATCH 06/13] Fix RetrievedFromCache always showing False (#88) (#89) RetrievedFromCache is an attribute on the StmtSimple XML element, but the parser was reading it from the child QueryPlan element where it never exists. Changed to read from stmtEl instead of queryPlanEl. Co-authored-by: Claude Opus 4.6 --- src/PlanViewer.Core/Services/ShowPlanParser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 01bfcd8..0f04675 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -409,7 +409,7 @@ private static void ParseQueryPlanElements(PlanStatement stmt, XElement stmtEl, stmt.CachedPlanSizeKB = ParseLong(queryPlanEl.Attribute("CachedPlanSize")?.Value); stmt.DegreeOfParallelism = (int)ParseDouble(queryPlanEl.Attribute("DegreeOfParallelism")?.Value); stmt.NonParallelPlanReason = queryPlanEl.Attribute("NonParallelPlanReason")?.Value; - stmt.RetrievedFromCache = queryPlanEl.Attribute("RetrievedFromCache")?.Value is "true" or "1"; + stmt.RetrievedFromCache = stmtEl.Attribute("RetrievedFromCache")?.Value is "true" or "1"; stmt.CompileTimeMs = ParseLong(queryPlanEl.Attribute("CompileTime")?.Value); stmt.CompileMemoryKB = ParseLong(queryPlanEl.Attribute("CompileMemory")?.Value); stmt.CompileCPUMs = ParseLong(queryPlanEl.Attribute("CompileCPU")?.Value); From a9abb99d59cb8ded56d49b300010fcc99441954d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 17 Mar 2026 07:05:55 -0500 Subject: [PATCH 07/13] Add Query Store search/filter by identifier and UX improvements (#90) (#93) - Server-side fetch by query_id, plan_id, query_hash, query_plan_hash, module name - Schema-qualified module names (OBJECT_SCHEMA_NAME + OBJECT_NAME) - Wildcard support for module filter (dbo.%Order%) - New columns: Query Hash, Plan Hash, Module with column-level filtering - Search bar in toolbar with Enter-to-fetch - Database picker on Query Store tab - Select All/None toggle button - Second QS button click shows connection dialog for fresh tab - CLI: --query-id, --plan-id, --query-hash, --plan-hash, --module options - MCP: query_id, plan_id, query_hash, plan_hash, module parameters Co-authored-by: Claude Opus 4.6 (1M context) --- .../Controls/QuerySessionControl.axaml.cs | 22 ++- .../Controls/QueryStoreGridControl.axaml | 60 +++++-- .../Controls/QueryStoreGridControl.axaml.cs | 164 +++++++++++++++--- .../Dialogs/QueryStoreDialog.axaml.cs | 2 +- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 34 +++- .../Commands/QueryStoreCommand.cs | 61 +++++-- src/PlanViewer.Core/Models/QueryStorePlan.cs | 16 ++ .../Services/QueryStoreService.cs | 67 ++++++- 8 files changed, 356 insertions(+), 70 deletions(-) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index ffda5b6..07cd369 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -980,11 +980,17 @@ private static string GetTabLabel(TabItem tab) return "Plan"; } + private bool HasQueryStoreTab() + { + return SubTabControl.Items.OfType() + .Any(t => t.Content is QueryStoreGridControl); + } + private async void QueryStore_Click(object? sender, RoutedEventArgs e) { - if (_connectionString == null || _selectedDatabase == null) + // If a QS tab already exists, always show connection dialog for a fresh tab + if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null) { - // No connection — open the connection dialog and wait for it await ShowConnectionDialogAsync(); if (_connectionString == null || _selectedDatabase == null) return; @@ -1009,7 +1015,11 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) SetStatus(""); - var grid = new QueryStoreGridControl(_connectionString, _selectedDatabase!); + // Build database list from the current DatabaseBox + var databases = DatabaseBox.Items.OfType().ToList(); + + var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, + _selectedDatabase!, databases); grid.PlansSelected += OnQueryStorePlansSelected; var headerText = new TextBlock @@ -1019,6 +1029,12 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) FontSize = 12 }; + // Update tab header when database is changed via the grid's picker + grid.DatabaseChanged += (_, db) => + { + headerText.Text = $"Query Store — {db}"; + }; + var closeBtn = new Button { Content = "\u2715", diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index 6fdc840..d0b241c 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -8,23 +8,19 @@ - -