From 0c19ef831496c7d5854147f6d7a4dab357d930a5 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 03:28:36 +0200
Subject: [PATCH 01/24] - Add FetchHistoryByHashAsync method to
QueryStoreService - Update QueryStoreHistoryWindow constructor to accept
query hash, metric tag, and time range parameters - Update
QueryStoreHistoryWindow.axaml.cs LoadHistoryAsync to call new hash-based
service method - Update QueryStoreHistoryWindow.axaml UI layout - Add average
line to chart in UpdateChart method - Update QueryStoreGridControl
ViewHistory_Click to pass queryHash, metric, and time range
---
.../Controls/QueryStoreGridControl.axaml.cs | 11 +-
.../Dialogs/QueryStoreHistoryWindow.axaml | 52 ++++---
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 137 ++++++++++++++++--
.../Services/QueryStoreService.cs | 113 +++++++++++++++
4 files changed, 278 insertions(+), 35 deletions(-)
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index a192af6..08f3ee1 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -587,12 +587,19 @@ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
{
if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+ if (string.IsNullOrEmpty(row.QueryHash)) return;
+
+ var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
var window = new QueryStoreHistoryWindow(
_connectionString,
- row.QueryId,
+ row.QueryHash,
row.FullQueryText,
- _database);
+ _database,
+ initialMetricTag: metricTag,
+ slicerStartUtc: _slicerStartUtc,
+ slicerEndUtc: _slicerEndUtc,
+ slicerDaysBack: _slicerDaysBack);
var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
if (topLevel is Window parentWindow)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 08e58fb..8d541c6 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -9,19 +9,37 @@
Icon="avares://PlanViewer.App/EDD.ico"
Background="{DynamicResource BackgroundBrush}">
-
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -45,12 +63,10 @@
-
-
-
+
+
-
-
+
-
-
+
-
-
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index f3b51af..71b654b 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -17,8 +17,13 @@ namespace PlanViewer.App.Dialogs;
public partial class QueryStoreHistoryWindow : Window
{
private readonly string _connectionString;
- private readonly long _queryId;
+ private readonly string _queryHash;
private readonly string _database;
+ private readonly string _queryText;
+ private readonly DateTime? _slicerStartUtc;
+ private readonly DateTime? _slicerEndUtc;
+ private readonly int _maxHoursBack;
+ private bool _useFullHistory;
private CancellationTokenSource? _fetchCts;
private List _historyData = new();
private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
@@ -39,21 +44,56 @@ public partial class QueryStoreHistoryWindow : Window
ScottPlot.Color.FromHex("#A1887F"),
};
- public QueryStoreHistoryWindow(string connectionString, long queryId,
- string queryText, string database, int hoursBack = 24)
+ // Map grid orderBy tags to history metric tags
+ private static readonly Dictionary OrderByToMetricTag = new()
+ {
+ ["cpu"] = "TotalCpuMs",
+ ["avg-cpu"] = "AvgCpuMs",
+ ["duration"] = "TotalDurationMs",
+ ["avg-duration"] = "AvgDurationMs",
+ ["reads"] = "TotalLogicalReads",
+ ["avg-reads"] = "AvgLogicalReads",
+ ["writes"] = "TotalLogicalWrites",
+ ["avg-writes"] = "AvgLogicalWrites",
+ ["physical-reads"] = "TotalPhysicalReads",
+ ["avg-physical-reads"] = "AvgPhysicalReads",
+ ["memory"] = "TotalCpuMs", // no total memory metric in history, fallback
+ ["avg-memory"] = "AvgMemoryMb",
+ ["executions"] = "CountExecutions",
+ };
+
+ public QueryStoreHistoryWindow(string connectionString, string queryHash,
+ string queryText, string database,
+ string initialMetricTag = "AvgCpuMs",
+ DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null,
+ int slicerDaysBack = 30)
{
_connectionString = connectionString;
- _queryId = queryId;
+ _queryHash = queryHash;
_database = database;
+ _queryText = queryText;
+ _slicerStartUtc = slicerStartUtc;
+ _slicerEndUtc = slicerEndUtc;
+ _maxHoursBack = slicerDaysBack * 24;
InitializeComponent();
- HoursBackBox.Value = hoursBack;
+ QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
+ QueryTextBox.Text = queryText;
- var preview = queryText.Length > 120
- ? queryText[..120].Replace("\n", " ").Replace("\r", "") + "..."
- : queryText.Replace("\n", " ").Replace("\r", "");
- QueryIdentifierText.Text = $"Query Store History: Query {queryId} in [{database}]";
- SummaryText.Text = preview;
+ // Select initial metric in the combo box
+ var metricTag = initialMetricTag;
+ foreach (ComboBoxItem item in MetricSelector.Items)
+ {
+ if (item.Tag?.ToString() == metricTag)
+ {
+ MetricSelector.SelectedItem = item;
+ break;
+ }
+ }
+
+ // Default to range period mode when slicer range is available
+ _useFullHistory = !(_slicerStartUtc.HasValue && _slicerEndUtc.HasValue);
+ UpdateRangeToggleButton();
// Build hover tooltip
_tooltipText = new TextBlock
@@ -85,6 +125,16 @@ public QueryStoreHistoryWindow(string connectionString, long queryId,
Opened += async (_, _) => await LoadHistoryAsync();
}
+ ///
+ /// Maps a grid orderBy tag (e.g. "cpu", "avg-duration") to the history metric tag.
+ ///
+ public static string MapOrderByToMetricTag(string orderBy)
+ {
+ return OrderByToMetricTag.TryGetValue(orderBy?.ToLowerInvariant() ?? "", out var tag)
+ ? tag
+ : "AvgCpuMs";
+ }
+
private async System.Threading.Tasks.Task LoadHistoryAsync()
{
_fetchCts?.Cancel();
@@ -92,14 +142,27 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;
- var hoursBack = (int)(HoursBackBox.Value ?? 24);
RefreshButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
- _historyData = await QueryStoreService.FetchHistoryAsync(
- _connectionString, _queryId, hoursBack, ct);
+ if (_useFullHistory)
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
+ else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, ct: ct,
+ startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value);
+ }
+ else
+ {
+ _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _connectionString, _queryHash, _maxHoursBack, ct);
+ }
HistoryDataGrid.ItemsSource = _historyData;
@@ -171,6 +234,23 @@ private void UpdateChart()
colorIndex++;
}
+ // Add average line
+ var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
+ if (allValues.Length > 0)
+ {
+ var avg = allValues.Average();
+ var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F");
+ hLine.LineWidth = 1.5f;
+ hLine.LinePattern = LinePattern.Dashed;
+ hLine.LegendText = $"avg: {avg:N2} {label}";
+ }
+
+ // Show legend when multiple plans exist
+ HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
+ ? Alignment.UpperRight
+ : Alignment.UpperRight);
+
HistoryChart.Plot.Axes.DateTimeTicksBottom();
HistoryChart.Plot.YLabel(label);
ApplyDarkTheme();
@@ -195,7 +275,7 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
string bestLabel = "";
bool found = false;
- foreach (var (scatter, label) in _scatters)
+ foreach (var (scatter, chartLabel) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
if (!nearest.IsReal) continue;
@@ -209,7 +289,7 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
bestDist = dy;
bestPoint = nearest;
- bestLabel = label;
+ bestLabel = chartLabel;
found = true;
}
}
@@ -268,6 +348,25 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
+ private void UpdateRangeToggleButton()
+ {
+ if (_useFullHistory)
+ {
+ RangeToggleButton.Content = "Full History";
+ }
+ else
+ {
+ RangeToggleButton.Content = "Range Period";
+ }
+ }
+
+ private async void RangeToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _useFullHistory = !_useFullHistory;
+ UpdateRangeToggleButton();
+ await LoadHistoryAsync();
+ }
+
private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (IsVisible && _historyData.Count > 0)
@@ -279,5 +378,13 @@ private async void Refresh_Click(object? sender, RoutedEventArgs e)
await LoadHistoryAsync();
}
+ private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_queryText)) return;
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard != null)
+ await clipboard.SetTextAsync(_queryText);
+ }
+
private void Close_Click(object? sender, RoutedEventArgs e) => Close();
}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 7692981..e133725 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -432,6 +432,119 @@ JOIN sys.query_store_plan p
return rows;
}
+ ///
+ /// Fetches interval-level history rows for all queries sharing the given query_hash.
+ /// When / are provided they define the
+ /// time window (slicer range); otherwise falls back to .
+ ///
+ public static async Task> FetchHistoryByHashAsync(
+ string connectionString, string queryHash, int hoursBack = 24,
+ CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var parameters = new List();
+ parameters.Add(new SqlParameter("@queryHash", queryHash.Trim()));
+
+ string timeFilter;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ timeFilter = "AND rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ timeFilter = "AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE())";
+ parameters.Add(new SqlParameter("@hoursBack", hoursBack));
+ }
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ p.plan_id,
+ CONVERT(varchar(18), MAX(p.query_plan_hash), 1),
+ rsi.start_time,
+ SUM(rs.count_executions),
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ SUM(rs.avg_duration * rs.count_executions) / 1000.0,
+ SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0,
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ MIN(rs.min_dop),
+ MAX(rs.max_dop),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats rs
+JOIN sys.query_store_runtime_stats_interval rsi
+ ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
+JOIN sys.query_store_plan p
+ ON rs.plan_id = p.plan_id
+JOIN sys.query_store_query q
+ ON p.query_id = q.query_id
+WHERE q.query_hash = CONVERT(binary(8), @queryHash, 1)
+{timeFilter}
+GROUP BY p.plan_id, rsi.start_time
+ORDER BY rsi.start_time, p.plan_id;";
+
+ var rows = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters)
+ cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new QueryStoreHistoryRow
+ {
+ PlanId = reader.GetInt64(0),
+ QueryPlanHash = reader.IsDBNull(1) ? "" : reader.GetString(1),
+ IntervalStartUtc = ((DateTimeOffset)reader.GetValue(2)).UtcDateTime,
+ CountExecutions = reader.GetInt64(3),
+ AvgDurationMs = reader.GetDouble(4),
+ AvgCpuMs = reader.GetDouble(5),
+ AvgLogicalReads = reader.GetDouble(6),
+ AvgLogicalWrites = reader.GetDouble(7),
+ AvgPhysicalReads = reader.GetDouble(8),
+ AvgMemoryMb = reader.GetDouble(9),
+ AvgRowcount = reader.GetDouble(10),
+ TotalDurationMs = reader.GetDouble(11),
+ TotalCpuMs = reader.GetDouble(12),
+ TotalLogicalReads = reader.GetDouble(13),
+ TotalLogicalWrites = reader.GetDouble(14),
+ TotalPhysicalReads = reader.GetDouble(15),
+ MinDop = (int)reader.GetInt64(16),
+ MaxDop = (int)reader.GetInt64(17),
+ LastExecutionUtc = reader.IsDBNull(18) ? null : ((DateTimeOffset)reader.GetValue(18)).UtcDateTime,
+ });
+ }
+
+ return rows;
+ }
+
///
/// Fetches hourly-aggregated metric data for the time-range slicer.
/// Limits data to the last days (default 30).
From 581f8516cd523aa69468e48ec42f283b515ab625 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 29 Mar 2026 10:05:25 -0400
Subject: [PATCH 02/24] README: add shields.io badge flair (repo + social)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds two centered rows of for-the-badge shields — repo stats (stars,
forks, license, release, issues, last commit, CI) and social links
(X/Twitter, YouTube, LinkedIn, blog).
Co-Authored-By: Claude Sonnet 4.6
---
README.md | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/README.md b/README.md
index e5a3b71..19a5174 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,21 @@
# Performance Studio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI.
Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS.
From fd9c6f7f36b31b2b96aa2f6add83f7f591824d04 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 17:03:00 +0200
Subject: [PATCH 03/24] =?UTF-8?q?Query=20History=20improvment=20step=202?=
=?UTF-8?q?=20:=20=E2=80=A2=20Generate=20a=20new=20query=20to=20get=20quer?=
=?UTF-8?q?y=20history=20data=20Group=20data=20by=20PlanHash=20and=20Inter?=
=?UTF-8?q?valStart.=20Get=20inspired=20by=20the=20existing=20FetchHistory?=
=?UTF-8?q?Async=20but=20create=20a=20new=20FetchAgregateHistoryAsync.=20U?=
=?UTF-8?q?se=20smart=20agregation=20for=20other=20fields=20=20=E2=80=A2?=
=?UTF-8?q?=20sum(field)=20for=20Totals%=20fields=20and=20Executions=20=20?=
=?UTF-8?q?=E2=80=A2=20avg(field)=20for=20Avg%=20fields=20=20=E2=80=A2=20m?=
=?UTF-8?q?ax(last=5Fexecution)=20=E2=80=A2=20Allow=20the=20use=20to=20sel?=
=?UTF-8?q?ect=20several=20dots=20in=20the=20charts=20using=20a=20box=20se?=
=?UTF-8?q?lector=20=E2=80=A2=20When=20the=20user=20finish=20the=20selecti?=
=?UTF-8?q?on=20with=20the=20box=20(or=20a=20single=20dot=20selection)=20h?=
=?UTF-8?q?ightligh=20the=20corresponding=20rows=20in=20the=20query=20hist?=
=?UTF-8?q?ory=20grid=20=E2=80=A2=20Add=20a=20thin=20color=20column=20in?=
=?UTF-8?q?=20the=20query=20history=20grid=20like=20the=20legend=20in=20ch?=
=?UTF-8?q?art=20=E2=80=A2=20add=20a=20small=20light=20grey=20border=20for?=
=?UTF-8?q?=20dots=20=E2=80=A2=20the=20label=20of=20avg=20in=20the=20chart?=
=?UTF-8?q?=20should=20only=20have=20"avg:".=20Dont=20insert=20the?=
=?UTF-8?q?=20metric=20inside=20this=20label.=20=E2=80=A2=20The=20avg=20la?=
=?UTF-8?q?bel=20should=20have=20a=20light=20grey=20transparent=20backgrou?=
=?UTF-8?q?nd=20and=20be=20just=20above=20the=20avg=20horizontal=20line=20?=
=?UTF-8?q?=E2=80=A2=20make=20the=20avg=20horizontal=20line=20a=20little?=
=?UTF-8?q?=20more=20transparent=20=E2=80=A2=20in=20the=20chart=20the=20Y-?=
=?UTF-8?q?axis=20should=20always=20include=200=20as=20origin=20=E2=80=A2?=
=?UTF-8?q?=20make=202=20buttons=20"Range=20Period"=20and=20"Full=20Histor?=
=?UTF-8?q?y"=20and=20highlight=20the=20selected=20and=20current=20one=20?=
=?UTF-8?q?=E2=80=A2=20remove=20the=20refresh=20button=20=E2=80=A2=20align?=
=?UTF-8?q?=20the=20informatial=20text=20(number=20of=20interval,=20number?=
=?UTF-8?q?=20of=20plans...|=20period)=20to=20the=20right=20=E2=80=A2=20re?=
=?UTF-8?q?move=20the=20word=20metric=20on=20top=20of=20the=20metric=20sel?=
=?UTF-8?q?ector=20=E2=80=A2=20make=20the=20X-axis=20label=20more=20smart?=
=?UTF-8?q?=20:=20hours=20first=20(top)=20and=20the=20date=20and=20"agrega?=
=?UTF-8?q?te=20date".=20Still=20use=20an=20adaptative=20X-axis=20display?=
=?UTF-8?q?=20if=20the=20interval=20is=20large?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 38 +-
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 431 ++++++++++++++++--
.../Services/QueryStoreService.cs | 113 +++++
3 files changed, 528 insertions(+), 54 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 8d541c6..1df1d37 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -42,11 +42,10 @@
-
-
-
+
+
@@ -62,20 +61,19 @@
+
+
+
+
-
-
-
-
-
-
-
-
+
@@ -96,7 +94,13 @@
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Auto">
-
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 71b654b..6dfb472 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -1,4 +1,5 @@
using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -8,6 +9,7 @@
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
+using Avalonia.VisualTree;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
using ScottPlot;
@@ -26,12 +28,26 @@ public partial class QueryStoreHistoryWindow : Window
private bool _useFullHistory;
private CancellationTokenSource? _fetchCts;
private List _historyData = new();
- private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label)> _scatters = new();
+ private readonly List<(ScottPlot.Plottables.Scatter Scatter, string Label, string PlanHash)> _scatters = new();
// Hover tooltip
private readonly Popup _tooltip;
private readonly TextBlock _tooltipText;
+ // Box selection state
+ private bool _isDragging;
+ private Point _dragStartPoint;
+ private ScottPlot.Plottables.Rectangle? _selectionRect;
+ private readonly HashSet _selectedRowIndices = new();
+
+ // Color mapping: plan hash -> color
+ private readonly Dictionary _planHashColorMap = new();
+
+ // Active button highlight brush
+ private static readonly SolidColorBrush ActiveButtonBg = new(Avalonia.Media.Color.FromRgb(0x4F, 0xC3, 0xF7));
+ private static readonly SolidColorBrush ActiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x11, 0x12, 0x17));
+ private static readonly SolidColorBrush InactiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x9D, 0xA5, 0xB4));
+
private static readonly ScottPlot.Color[] PlanColors =
{
ScottPlot.Color.FromHex("#4FC3F7"),
@@ -57,7 +73,7 @@ public partial class QueryStoreHistoryWindow : Window
["avg-writes"] = "AvgLogicalWrites",
["physical-reads"] = "TotalPhysicalReads",
["avg-physical-reads"] = "AvgPhysicalReads",
- ["memory"] = "TotalCpuMs", // no total memory metric in history, fallback
+ ["memory"] = "TotalCpuMs",
["avg-memory"] = "AvgMemoryMb",
["executions"] = "CountExecutions",
};
@@ -93,7 +109,7 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
// Default to range period mode when slicer range is available
_useFullHistory = !(_slicerStartUtc.HasValue && _slicerEndUtc.HasValue);
- UpdateRangeToggleButton();
+ UpdateRangeButtons();
// Build hover tooltip
_tooltipText = new TextBlock
@@ -121,6 +137,11 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash,
HistoryChart.PointerMoved += OnChartPointerMoved;
HistoryChart.PointerExited += (_, _) => _tooltip.IsOpen = false;
+ HistoryChart.PointerPressed += OnChartPointerPressed;
+ HistoryChart.PointerReleased += OnChartPointerReleased;
+
+ // Disable ScottPlot's built-in left-click-drag pan so our box selection works
+ HistoryChart.UserInputProcessor.LeftClickDragPan(enable: false);
Opened += async (_, _) => await LoadHistoryAsync();
}
@@ -142,33 +163,34 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
_fetchCts = new CancellationTokenSource();
var ct = _fetchCts.Token;
- RefreshButton.IsEnabled = false;
StatusText.Text = "Loading...";
try
{
if (_useFullHistory)
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, _maxHoursBack, ct);
}
else if (_slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, ct: ct,
startUtc: _slicerStartUtc.Value, endUtc: _slicerEndUtc.Value);
}
else
{
- _historyData = await QueryStoreService.FetchHistoryByHashAsync(
+ _historyData = await QueryStoreService.FetchAggregateHistoryAsync(
_connectionString, _queryHash, _maxHoursBack, ct);
}
+ BuildColorMap();
HistoryDataGrid.ItemsSource = _historyData;
+ ApplyColorIndicators();
if (_historyData.Count > 0)
{
- var planCount = _historyData.Select(r => r.PlanId).Distinct().Count();
+ var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count();
var totalExec = _historyData.Sum(r => r.CountExecutions);
var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc));
var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc));
@@ -191,16 +213,79 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
{
StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
}
- finally
+ }
+
+ private void BuildColorMap()
+ {
+ _planHashColorMap.Clear();
+ var hashes = _historyData.Select(r => r.QueryPlanHash).Distinct().OrderBy(h => h).ToList();
+ for (int i = 0; i < hashes.Count; i++)
+ _planHashColorMap[hashes[i]] = PlanColors[i % PlanColors.Length];
+ }
+
+ private void ApplyColorIndicators()
+ {
+ HistoryDataGrid.LoadingRow -= OnDataGridLoadingRow;
+ HistoryDataGrid.LoadingRow += OnDataGridLoadingRow;
+ }
+
+ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ if (e.Row.DataContext is QueryStoreHistoryRow row &&
+ _planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
- RefreshButton.IsEnabled = true;
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ // Find the ColorIndicator border in the first cell
+ e.Row.Tag = new SolidColorBrush(avColor);
}
+
+ // Defer color application after visual tree is built
+ e.Row.Loaded -= OnRowLoaded;
+ e.Row.Loaded += OnRowLoaded;
+ }
+
+ private void OnRowLoaded(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not DataGridRow dgRow) return;
+ dgRow.Loaded -= OnRowLoaded;
+
+ if (dgRow.Tag is not SolidColorBrush brush) return;
+
+ // Walk the visual tree to find the Border named ColorIndicator
+ var presenter = FindVisualChild(dgRow);
+ if (presenter == null) return;
+
+ // First column cell (index 0) contains our color indicator
+ var cell = presenter.Children.OfType().FirstOrDefault();
+ if (cell == null) return;
+
+ var border = FindVisualChild(cell, "ColorIndicator");
+ if (border != null)
+ border.Background = brush;
+ }
+
+ private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual
+ {
+ if (parent is T t && (name == null || (t is Control c && c.Name == name)))
+ return t;
+
+ var children = parent.GetVisualChildren();
+ foreach (var child in children)
+ {
+ if (child is Avalonia.Visual vc)
+ {
+ var found = FindVisualChild(vc, name);
+ if (found != null) return found;
+ }
+ }
+ return null;
}
private void UpdateChart()
{
HistoryChart.Plot.Clear();
_scatters.Clear();
+ _selectionRect = null;
if (_historyData.Count == 0)
{
@@ -213,54 +298,316 @@ private void UpdateChart()
var label = selected?.Content?.ToString() ?? "Avg CPU (ms)";
var planGroups = _historyData
- .GroupBy(r => r.PlanId)
+ .GroupBy(r => r.QueryPlanHash)
.OrderBy(g => g.Key)
.ToList();
- int colorIndex = 0;
foreach (var group in planGroups)
{
+ var planHash = group.Key;
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+
var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList();
var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
- scatter.Color = PlanColors[colorIndex % PlanColors.Length];
- scatter.LegendText = $"Plan {group.Key}";
+ scatter.Color = color;
+ scatter.LegendText = planHash.Length > 10 ? planHash[..10] : planHash;
scatter.LineWidth = 2;
- scatter.MarkerSize = ordered.Count <= 2 ? 8 : 4;
+ scatter.MarkerSize = ordered.Count <= 2 ? 8 : 5;
+ scatter.MarkerLineColor = ScottPlot.Color.FromHex("#888888");
+ scatter.MarkerLineWidth = 0.5f;
- _scatters.Add((scatter, $"Plan {group.Key}"));
- colorIndex++;
+ _scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash));
}
- // Add average line
+ // Add average line with built-in label
var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
if (allValues.Length > 0)
{
var avg = allValues.Average();
var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F");
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(80);
hLine.LineWidth = 1.5f;
hLine.LinePattern = LinePattern.Dashed;
- hLine.LegendText = $"avg: {avg:N2} {label}";
+ hLine.LegendText = $"avg:{avg:N2}";
+ hLine.Text = $"avg:{avg:N2}";
+ hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
+ hLine.LabelFontSize = 11;
+ hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#22252b").WithAlpha(180);
+ hLine.LabelOppositeAxis = true;
}
+ // Y-axis always includes 0 as origin
+ HistoryChart.Plot.Axes.AutoScale();
+ var yLimits = HistoryChart.Plot.Axes.GetLimits();
+ HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1);
+
// Show legend when multiple plans exist
HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
? Alignment.UpperRight
: Alignment.UpperRight);
- HistoryChart.Plot.Axes.DateTimeTicksBottom();
+ // Smart X-axis labels
+ ConfigureSmartXAxis();
+
HistoryChart.Plot.YLabel(label);
ApplyDarkTheme();
HistoryChart.Refresh();
}
+ private void ConfigureSmartXAxis()
+ {
+ if (_historyData.Count == 0) return;
+
+ var minTime = _historyData.Min(r => r.IntervalStartUtc);
+ var maxTime = _historyData.Max(r => r.IntervalStartUtc);
+ var span = maxTime - minTime;
+
+ // Use ScottPlot's DateTime tick generator with smart formatting
+ HistoryChart.Plot.Axes.DateTimeTicksBottom();
+
+ // Customize tick label format based on span
+ if (span.TotalHours <= 48)
+ {
+ // Short range: show HH:mm on top line, MM/dd below
+ HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#9DA5B4");
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else if (span.TotalDays <= 14)
+ {
+ // Medium range: show HH:mm on top, MM/dd below
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
+ };
+ }
+ else
+ {
+ // Large range: show MM/dd on top, yyyy below
+ HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
+ {
+ LabelFormatter = dt => dt.ToString("MM/dd\nyyyy")
+ };
+ }
+ }
+
+ // ── Box selection ────────────────────────────────────────────────────
+
+ private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(HistoryChart).Properties.IsLeftButtonPressed) return;
+
+ _isDragging = true;
+ _dragStartPoint = e.GetPosition(HistoryChart);
+
+ // Remove old selection rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
+ e.Handled = true;
+ }
+
+ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isDragging) return;
+ _isDragging = false;
+
+ var endPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var endCoords = PixelToCoordinates(endPoint);
+
+ // Determine if this was a click (small drag) or a box selection
+ var dx = Math.Abs(endPoint.X - _dragStartPoint.X);
+ var dy = Math.Abs(endPoint.Y - _dragStartPoint.Y);
+
+ if (dx < 5 && dy < 5)
+ {
+ // Single click: find nearest dot and select it
+ HandleSingleClickSelection(endPoint);
+ }
+ else
+ {
+ // Box selection
+ HandleBoxSelection(startCoords, endCoords);
+ }
+
+ e.Handled = true;
+ }
+
+ private ScottPlot.Coordinates PixelToCoordinates(Point pos)
+ {
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(pos.X * scaling), (float)(pos.Y * scaling));
+ return HistoryChart.Plot.GetCoordinates(pixel);
+ }
+
+ private void HandleSingleClickSelection(Point clickPoint)
+ {
+ if (_scatters.Count == 0) return;
+
+ var scaling = HistoryChart.Bounds.Width > 0
+ ? (float)(HistoryChart.Plot.RenderManager.LastRender.FigureRect.Width / HistoryChart.Bounds.Width)
+ : 1f;
+ var pixel = new ScottPlot.Pixel((float)(clickPoint.X * scaling), (float)(clickPoint.Y * scaling));
+ var mouseCoords = HistoryChart.Plot.GetCoordinates(pixel);
+
+ double bestDist = double.MaxValue;
+ ScottPlot.DataPoint bestPoint = default;
+ string bestPlanHash = "";
+ bool found = false;
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
+ if (!nearest.IsReal) continue;
+
+ var nearestPixel = HistoryChart.Plot.GetPixel(
+ new ScottPlot.Coordinates(nearest.X, nearest.Y));
+ var d = Math.Sqrt(Math.Pow(nearestPixel.X - pixel.X, 2) + Math.Pow(nearestPixel.Y - pixel.Y, 2));
+
+ if (d < 30 && d < bestDist)
+ {
+ bestDist = d;
+ bestPoint = nearest;
+ bestPlanHash = planHash;
+ found = true;
+ }
+ }
+
+ _selectedRowIndices.Clear();
+
+ if (found)
+ {
+ var clickedTime = DateTime.FromOADate(bestPoint.X);
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc);
+ if (row.QueryPlanHash == bestPlanHash &&
+ Math.Abs((displayTime - clickedTime).TotalMinutes) < 1)
+ {
+ _selectedRowIndices.Add(i);
+ }
+ }
+ }
+
+ HighlightGridRows();
+ }
+
+ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinates end)
+ {
+ var x1 = Math.Min(start.X, end.X);
+ var x2 = Math.Max(start.X, end.X);
+ var y1 = Math.Min(start.Y, end.Y);
+ var y2 = Math.Max(start.Y, end.Y);
+
+ // Draw selection rectangle on chart
+ if (_selectionRect != null)
+ HistoryChart.Plot.Remove(_selectionRect);
+
+ _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
+ _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
+ _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
+ _selectionRect.LineWidth = 1;
+ HistoryChart.Refresh();
+
+ // Find all data points inside the box
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+ _selectedRowIndices.Clear();
+
+ for (int i = 0; i < _historyData.Count; i++)
+ {
+ var row = _historyData[i];
+ var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate();
+ var yVal = GetMetricValue(row, tag);
+
+ if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2)
+ _selectedRowIndices.Add(i);
+ }
+
+ HighlightGridRows();
+ }
+
+ private void HighlightGridRows()
+ {
+ // Clear all row highlighting first, then apply selection
+ var highlightBrush = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
+ var transparentBrush = Brushes.Transparent;
+
+ // Scroll to first selected row if any
+ if (_selectedRowIndices.Count > 0)
+ {
+ var firstIdx = _selectedRowIndices.Min();
+ if (firstIdx < _historyData.Count)
+ HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null);
+ }
+
+ // Use LoadingRow event + force refresh to highlight
+ HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow;
+ HistoryDataGrid.LoadingRow += OnHighlightLoadingRow;
+
+ // Force grid to re-render rows
+ var source = _historyData;
+ HistoryDataGrid.ItemsSource = null;
+ HistoryDataGrid.ItemsSource = source;
+ }
+
+ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
+ {
+ var idx = e.Row.GetIndex();
+ if (_selectedRowIndices.Contains(idx))
+ {
+ e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
+ }
+ else if (_selectedRowIndices.Count > 0)
+ {
+ e.Row.Background = Brushes.Transparent;
+ }
+ else
+ {
+ e.Row.Background = Brushes.Transparent;
+ }
+ }
+
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
if (_scatters.Count == 0) { _tooltip.IsOpen = false; return; }
+ // If dragging, update selection rectangle preview
+ if (_isDragging)
+ {
+ var currentPoint = e.GetPosition(HistoryChart);
+ var startCoords = PixelToCoordinates(_dragStartPoint);
+ var currentCoords = PixelToCoordinates(currentPoint);
+
+ if (_selectionRect != null)
+ HistoryChart.Plot.Remove(_selectionRect);
+
+ var x1 = Math.Min(startCoords.X, currentCoords.X);
+ var x2 = Math.Max(startCoords.X, currentCoords.X);
+ var y1 = Math.Min(startCoords.Y, currentCoords.Y);
+ var y2 = Math.Max(startCoords.Y, currentCoords.Y);
+
+ _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
+ _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
+ _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
+ _selectionRect.LineWidth = 1;
+ HistoryChart.Refresh();
+
+ _tooltip.IsOpen = false;
+ return;
+ }
+
try
{
var pos = e.GetPosition(HistoryChart);
@@ -275,19 +622,19 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
string bestLabel = "";
bool found = false;
- foreach (var (scatter, chartLabel) in _scatters)
+ foreach (var (scatter, chartLabel, _) in _scatters)
{
var nearest = scatter.Data.GetNearest(mouseCoords, HistoryChart.Plot.LastRender);
if (!nearest.IsReal) continue;
var nearestPixel = HistoryChart.Plot.GetPixel(
new ScottPlot.Coordinates(nearest.X, nearest.Y));
- double dx = Math.Abs(nearestPixel.X - pixel.X);
- double dy = Math.Abs(nearestPixel.Y - pixel.Y);
+ double ddx = Math.Abs(nearestPixel.X - pixel.X);
+ double ddy = Math.Abs(nearestPixel.Y - pixel.Y);
- if (dx < 80 && dy < bestDist)
+ if (ddx < 80 && ddy < bestDist)
{
- bestDist = dy;
+ bestDist = ddy;
bestPoint = nearest;
bestLabel = chartLabel;
found = true;
@@ -348,34 +695,44 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
- private void UpdateRangeToggleButton()
+ private void UpdateRangeButtons()
{
if (_useFullHistory)
{
- RangeToggleButton.Content = "Full History";
+ FullHistoryButton.Background = ActiveButtonBg;
+ FullHistoryButton.Foreground = ActiveButtonFg;
+ RangePeriodButton.Background = Brushes.Transparent;
+ RangePeriodButton.Foreground = InactiveButtonFg;
}
else
{
- RangeToggleButton.Content = "Range Period";
+ RangePeriodButton.Background = ActiveButtonBg;
+ RangePeriodButton.Foreground = ActiveButtonFg;
+ FullHistoryButton.Background = Brushes.Transparent;
+ FullHistoryButton.Foreground = InactiveButtonFg;
}
}
- private async void RangeToggle_Click(object? sender, RoutedEventArgs e)
+ private async void RangePeriod_Click(object? sender, RoutedEventArgs e)
{
- _useFullHistory = !_useFullHistory;
- UpdateRangeToggleButton();
+ if (!_useFullHistory) return;
+ _useFullHistory = false;
+ UpdateRangeButtons();
await LoadHistoryAsync();
}
- private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ private async void FullHistory_Click(object? sender, RoutedEventArgs e)
{
- if (IsVisible && _historyData.Count > 0)
- UpdateChart();
+ if (_useFullHistory) return;
+ _useFullHistory = true;
+ UpdateRangeButtons();
+ await LoadHistoryAsync();
}
- private async void Refresh_Click(object? sender, RoutedEventArgs e)
+ private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
- await LoadHistoryAsync();
+ if (IsVisible && _historyData.Count > 0)
+ UpdateChart();
}
private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index e133725..42ed986 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -545,6 +545,119 @@ JOIN sys.query_store_query q
return rows;
}
+ ///
+ /// Fetches interval-level history rows for all queries sharing the given query_hash,
+ /// grouped by query_plan_hash and interval start.
+ /// Smart aggregation: SUM for totals/executions, weighted AVG for averages, MAX for last_execution.
+ /// When / are provided they define the
+ /// time window; otherwise falls back to .
+ ///
+ public static async Task> FetchAggregateHistoryAsync(
+ string connectionString, string queryHash, int hoursBack = 24,
+ CancellationToken ct = default,
+ DateTime? startUtc = null, DateTime? endUtc = null)
+ {
+ var parameters = new List();
+ parameters.Add(new SqlParameter("@queryHash", queryHash.Trim()));
+
+ string timeFilter;
+ if (startUtc.HasValue && endUtc.HasValue)
+ {
+ timeFilter = "AND rsi.start_time >= @rangeStart AND rsi.start_time < @rangeEnd";
+ parameters.Add(new SqlParameter("@rangeStart", startUtc.Value));
+ parameters.Add(new SqlParameter("@rangeEnd", endUtc.Value));
+ }
+ else
+ {
+ timeFilter = "AND rsi.start_time >= DATEADD(HOUR, -@hoursBack, GETUTCDATE())";
+ parameters.Add(new SqlParameter("@hoursBack", hoursBack));
+ }
+
+ var sql = $@"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
+
+SELECT
+ CONVERT(varchar(18), p.query_plan_hash, 1),
+ rsi.start_time,
+ SUM(rs.count_executions),
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_duration * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_cpu_time * rs.count_executions) / SUM(rs.count_executions) / 1000.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_logical_io_writes * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_physical_io_reads * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_query_max_used_memory * rs.count_executions) / SUM(rs.count_executions) * 8.0 / 1024.0
+ ELSE 0 END,
+ CASE WHEN SUM(rs.count_executions) > 0
+ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
+ ELSE 0 END,
+ SUM(rs.avg_duration * rs.count_executions) / 1000.0,
+ SUM(rs.avg_cpu_time * rs.count_executions) / 1000.0,
+ SUM(rs.avg_logical_io_reads * rs.count_executions),
+ SUM(rs.avg_logical_io_writes * rs.count_executions),
+ SUM(rs.avg_physical_io_reads * rs.count_executions),
+ MIN(rs.min_dop),
+ MAX(rs.max_dop),
+ MAX(rs.last_execution_time)
+FROM sys.query_store_runtime_stats rs
+JOIN sys.query_store_runtime_stats_interval rsi
+ ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
+JOIN sys.query_store_plan p
+ ON rs.plan_id = p.plan_id
+JOIN sys.query_store_query q
+ ON p.query_id = q.query_id
+WHERE q.query_hash = CONVERT(binary(8), @queryHash, 1)
+{timeFilter}
+GROUP BY p.query_plan_hash, rsi.start_time
+ORDER BY rsi.start_time, p.query_plan_hash;";
+
+ var rows = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+ await using var cmd = new SqlCommand(sql, conn) { CommandTimeout = 120 };
+ foreach (var p in parameters)
+ cmd.Parameters.Add(p);
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ rows.Add(new QueryStoreHistoryRow
+ {
+ QueryPlanHash = reader.IsDBNull(0) ? "" : reader.GetString(0),
+ IntervalStartUtc = ((DateTimeOffset)reader.GetValue(1)).UtcDateTime,
+ CountExecutions = reader.GetInt64(2),
+ AvgDurationMs = reader.GetDouble(3),
+ AvgCpuMs = reader.GetDouble(4),
+ AvgLogicalReads = reader.GetDouble(5),
+ AvgLogicalWrites = reader.GetDouble(6),
+ AvgPhysicalReads = reader.GetDouble(7),
+ AvgMemoryMb = reader.GetDouble(8),
+ AvgRowcount = reader.GetDouble(9),
+ TotalDurationMs = reader.GetDouble(10),
+ TotalCpuMs = reader.GetDouble(11),
+ TotalLogicalReads = reader.GetDouble(12),
+ TotalLogicalWrites = reader.GetDouble(13),
+ TotalPhysicalReads = reader.GetDouble(14),
+ MinDop = (int)reader.GetInt64(15),
+ MaxDop = (int)reader.GetInt64(16),
+ LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime,
+ });
+ }
+
+ return rows;
+ }
+
///
/// Fetches hourly-aggregated metric data for the time-range slicer.
/// Limits data to the last days (default 30).
From 7ff48fd19f744e5454a22ad5bb7c43fbc5f48457 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:35:56 +0200
Subject: [PATCH 04/24] polish average line and label in query history
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 61 ++++--
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 193 +++++++++++++-----
.../Models/QueryStoreHistoryRow.cs | 14 ++
3 files changed, 201 insertions(+), 67 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 1df1d37..24cc0f3 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -76,12 +76,36 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -104,18 +129,18 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 6dfb472..be1924a 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -8,6 +8,7 @@
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
+using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.VisualTree;
using PlanViewer.Core.Models;
@@ -40,9 +41,16 @@ public partial class QueryStoreHistoryWindow : Window
private ScottPlot.Plottables.Rectangle? _selectionRect;
private readonly HashSet _selectedRowIndices = new();
+ // Highlight markers for selected dots
+ private readonly List _highlightMarkers = new();
+
// Color mapping: plan hash -> color
private readonly Dictionary _planHashColorMap = new();
+ // Legend state
+ private bool _legendExpanded;
+ private bool _suppressGridSelectionEvent;
+
// Active button highlight brush
private static readonly SolidColorBrush ActiveButtonBg = new(Avalonia.Media.Color.FromRgb(0x4F, 0xC3, 0xF7));
private static readonly SolidColorBrush ActiveButtonFg = new(Avalonia.Media.Color.FromRgb(0x11, 0x12, 0x17));
@@ -204,6 +212,7 @@ private async System.Threading.Tasks.Task LoadHistoryAsync()
}
UpdateChart();
+ PopulateLegendPanel();
}
catch (OperationCanceledException)
{
@@ -235,11 +244,9 @@ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
_planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- // Find the ColorIndicator border in the first cell
e.Row.Tag = new SolidColorBrush(avColor);
}
- // Defer color application after visual tree is built
e.Row.Loaded -= OnRowLoaded;
e.Row.Loaded += OnRowLoaded;
}
@@ -251,11 +258,9 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
if (dgRow.Tag is not SolidColorBrush brush) return;
- // Walk the visual tree to find the Border named ColorIndicator
var presenter = FindVisualChild(dgRow);
if (presenter == null) return;
- // First column cell (index 0) contains our color indicator
var cell = presenter.Children.OfType().FirstOrDefault();
if (cell == null) return;
@@ -281,11 +286,48 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
return null;
}
+ // ── Legend ────────────────────────────────────────────────────────────
+
+ private void PopulateLegendPanel()
+ {
+ LegendItemsPanel.Children.Clear();
+ foreach (var (hash, color) in _planHashColorMap.OrderBy(kv => kv.Key))
+ {
+ var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
+ var item = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 };
+ item.Children.Add(new Border
+ {
+ Width = 12, Height = 12,
+ CornerRadius = new CornerRadius(2),
+ Background = new SolidColorBrush(avColor),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
+ });
+ item.Children.Add(new TextBlock
+ {
+ Text = hash,
+ FontSize = 11,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0))
+ });
+ LegendItemsPanel.Children.Add(item);
+ }
+ }
+
+ private void LegendToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _legendExpanded = !_legendExpanded;
+ LegendPanel.IsVisible = _legendExpanded;
+ LegendArrow.Text = _legendExpanded ? "\u25b2" : "\u25bc";
+ }
+
+ // ── Chart ────────────────────────────────────────────────────────────
+
private void UpdateChart()
{
HistoryChart.Plot.Clear();
_scatters.Clear();
_selectionRect = null;
+ _highlightMarkers.Clear();
if (_historyData.Count == 0)
{
@@ -312,31 +354,35 @@ private void UpdateChart()
var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray();
var scatter = HistoryChart.Plot.Add.Scatter(xs, ys);
- scatter.Color = color;
- scatter.LegendText = planHash.Length > 10 ? planHash[..10] : planHash;
+ scatter.Color = color.WithAlpha(140);
+ scatter.LegendText = "";
scatter.LineWidth = 2;
- scatter.MarkerSize = ordered.Count <= 2 ? 8 : 5;
- scatter.MarkerLineColor = ScottPlot.Color.FromHex("#888888");
- scatter.MarkerLineWidth = 0.5f;
+ scatter.MarkerSize = 8;
+ scatter.MarkerShape = MarkerShape.FilledCircle;
+ scatter.MarkerLineColor = ScottPlot.Color.FromHex("#AAAAAA");
+ scatter.MarkerLineWidth = 1f;
_scatters.Add((scatter, planHash.Length > 10 ? planHash[..10] : planHash, planHash));
}
- // Add average line with built-in label
+ // Add average line with label positioned just above the line
var allValues = _historyData.Select(r => GetMetricValue(r, tag)).ToArray();
if (allValues.Length > 0)
{
var avg = allValues.Average();
var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(80);
- hLine.LineWidth = 1.5f;
- hLine.LinePattern = LinePattern.Dashed;
- hLine.LegendText = $"avg:{avg:N2}";
- hLine.Text = $"avg:{avg:N2}";
+ hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
+ hLine.LineWidth = 2f;
+ hLine.LinePattern = LinePattern.DenselyDashed;
+ hLine.Text = $"avg: {avg:N0}";
hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
hLine.LabelFontSize = 11;
- hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#22252b").WithAlpha(180);
- hLine.LabelOppositeAxis = true;
+ hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ hLine.LabelOppositeAxis = false;
+ hLine.LabelRotation = 0;
+ hLine.LabelAlignment = Alignment.LowerLeft;
+ hLine.LabelOffsetX = 38;
+ hLine.LabelOffsetY = -8;
}
// Y-axis always includes 0 as origin
@@ -344,10 +390,8 @@ private void UpdateChart()
var yLimits = HistoryChart.Plot.Axes.GetLimits();
HistoryChart.Plot.Axes.SetLimitsY(0, yLimits.Top * 1.1);
- // Show legend when multiple plans exist
- HistoryChart.Plot.ShowLegend(planGroups.Count > 1 || allValues.Length > 0
- ? Alignment.UpperRight
- : Alignment.UpperRight);
+ // Disable ScottPlot's built-in legend — we use our custom overlay
+ HistoryChart.Plot.HideLegend();
// Smart X-axis labels
ConfigureSmartXAxis();
@@ -365,13 +409,10 @@ private void ConfigureSmartXAxis()
var maxTime = _historyData.Max(r => r.IntervalStartUtc);
var span = maxTime - minTime;
- // Use ScottPlot's DateTime tick generator with smart formatting
HistoryChart.Plot.Axes.DateTimeTicksBottom();
- // Customize tick label format based on span
if (span.TotalHours <= 48)
{
- // Short range: show HH:mm on top line, MM/dd below
HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = ScottPlot.Color.FromHex("#9DA5B4");
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
@@ -380,7 +421,6 @@ private void ConfigureSmartXAxis()
}
else if (span.TotalDays <= 14)
{
- // Medium range: show HH:mm on top, MM/dd below
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
LabelFormatter = dt => dt.ToString("HH:mm\nMM/dd")
@@ -388,7 +428,6 @@ private void ConfigureSmartXAxis()
}
else
{
- // Large range: show MM/dd on top, yyyy below
HistoryChart.Plot.Axes.Bottom.TickGenerator = new ScottPlot.TickGenerators.DateTimeAutomatic
{
LabelFormatter = dt => dt.ToString("MM/dd\nyyyy")
@@ -396,6 +435,52 @@ private void ConfigureSmartXAxis()
}
}
+ // ── Dot highlighting on chart ────────────────────────────────────────
+
+ private void ClearHighlightMarkers()
+ {
+ foreach (var m in _highlightMarkers)
+ HistoryChart.Plot.Remove(m);
+ _highlightMarkers.Clear();
+ }
+
+ private void HighlightDotsOnChart(HashSet rowIndices)
+ {
+ ClearHighlightMarkers();
+ if (rowIndices.Count == 0)
+ {
+ HistoryChart.Refresh();
+ return;
+ }
+
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ // Group selected rows by plan hash for coloring
+ var groups = rowIndices
+ .Where(i => i >= 0 && i < _historyData.Count)
+ .Select(i => _historyData[i])
+ .GroupBy(r => r.QueryPlanHash);
+
+ foreach (var group in groups)
+ {
+ var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]);
+ var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
+ var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
+
+ var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
+ highlight.LineWidth = 0;
+ highlight.MarkerSize = 12;
+ highlight.MarkerShape = MarkerShape.OpenCircle;
+ highlight.MarkerLineColor = ScottPlot.Colors.White;
+ highlight.MarkerLineWidth = 2f;
+ highlight.Color = ScottPlot.Colors.Transparent;
+
+ _highlightMarkers.Add(highlight);
+ }
+
+ HistoryChart.Refresh();
+ }
+
// ── Box selection ────────────────────────────────────────────────────
private void OnChartPointerPressed(object? sender, PointerPressedEventArgs e)
@@ -420,6 +505,13 @@ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
if (!_isDragging) return;
_isDragging = false;
+ // Remove the drag preview rect
+ if (_selectionRect != null)
+ {
+ HistoryChart.Plot.Remove(_selectionRect);
+ _selectionRect = null;
+ }
+
var endPoint = e.GetPosition(HistoryChart);
var startCoords = PixelToCoordinates(_dragStartPoint);
var endCoords = PixelToCoordinates(endPoint);
@@ -430,12 +522,10 @@ private void OnChartPointerReleased(object? sender, PointerReleasedEventArgs e)
if (dx < 5 && dy < 5)
{
- // Single click: find nearest dot and select it
HandleSingleClickSelection(endPoint);
}
else
{
- // Box selection
HandleBoxSelection(startCoords, endCoords);
}
@@ -501,6 +591,7 @@ private void HandleSingleClickSelection(Point clickPoint)
}
}
+ HighlightDotsOnChart(_selectedRowIndices);
HighlightGridRows();
}
@@ -511,16 +602,6 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
var y1 = Math.Min(start.Y, end.Y);
var y2 = Math.Max(start.Y, end.Y);
- // Draw selection rectangle on chart
- if (_selectionRect != null)
- HistoryChart.Plot.Remove(_selectionRect);
-
- _selectionRect = HistoryChart.Plot.Add.Rectangle(x1, x2, y1, y2);
- _selectionRect.FillColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(30);
- _selectionRect.LineColor = ScottPlot.Color.FromHex("#4FC3F7").WithAlpha(120);
- _selectionRect.LineWidth = 1;
- HistoryChart.Refresh();
-
// Find all data points inside the box
var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
_selectedRowIndices.Clear();
@@ -535,15 +616,12 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat
_selectedRowIndices.Add(i);
}
+ HighlightDotsOnChart(_selectedRowIndices);
HighlightGridRows();
}
private void HighlightGridRows()
{
- // Clear all row highlighting first, then apply selection
- var highlightBrush = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
- var transparentBrush = Brushes.Transparent;
-
// Scroll to first selected row if any
if (_selectedRowIndices.Count > 0)
{
@@ -552,14 +630,15 @@ private void HighlightGridRows()
HistoryDataGrid.ScrollIntoView(_historyData[firstIdx], null);
}
- // Use LoadingRow event + force refresh to highlight
HistoryDataGrid.LoadingRow -= OnHighlightLoadingRow;
HistoryDataGrid.LoadingRow += OnHighlightLoadingRow;
// Force grid to re-render rows
+ _suppressGridSelectionEvent = true;
var source = _historyData;
HistoryDataGrid.ItemsSource = null;
HistoryDataGrid.ItemsSource = source;
+ _suppressGridSelectionEvent = false;
}
private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
@@ -569,16 +648,35 @@ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
{
e.Row.Background = new SolidColorBrush(Avalonia.Media.Color.FromArgb(60, 79, 195, 247));
}
- else if (_selectedRowIndices.Count > 0)
+ else
{
e.Row.Background = Brushes.Transparent;
}
- else
+ }
+
+ // ── Grid row click → chart highlight ─────────────────────────────────
+
+ private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressGridSelectionEvent) return;
+ if (HistoryDataGrid.SelectedItems == null || HistoryDataGrid.SelectedItems.Count == 0) return;
+
+ _selectedRowIndices.Clear();
+ foreach (var item in HistoryDataGrid.SelectedItems)
{
- e.Row.Background = Brushes.Transparent;
+ if (item is QueryStoreHistoryRow row)
+ {
+ var idx = _historyData.IndexOf(row);
+ if (idx >= 0)
+ _selectedRowIndices.Add(idx);
+ }
}
+
+ HighlightDotsOnChart(_selectedRowIndices);
}
+ // ── Hover tooltip ────────────────────────────────────────────────────
+
private void OnChartPointerMoved(object? sender, PointerEventArgs e)
{
if (_scatters.Count == 0) { _tooltip.IsOpen = false; return; }
@@ -690,9 +788,6 @@ private void ApplyDarkTheme()
HistoryChart.Plot.Grid.MajorLineColor = grid;
HistoryChart.Plot.Axes.Bottom.TickLabelStyle.ForeColor = text;
HistoryChart.Plot.Axes.Left.TickLabelStyle.ForeColor = text;
- HistoryChart.Plot.Legend.BackgroundColor = fig;
- HistoryChart.Plot.Legend.FontColor = ScottPlot.Color.FromHex("#E4E6EB");
- HistoryChart.Plot.Legend.OutlineColor = ScottPlot.Color.FromHex("#3A3D45");
}
private void UpdateRangeButtons()
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index b38c6ba..77e160c 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -28,6 +28,20 @@ public class QueryStoreHistoryRow
public int MaxDop { get; set; }
public DateTime? LastExecutionUtc { get; set; }
+ // Display-formatted properties (2 decimal places)
+ public string AvgDurationMsDisplay => AvgDurationMs.ToString("N2");
+ public string AvgCpuMsDisplay => AvgCpuMs.ToString("N2");
+ public string AvgLogicalReadsDisplay => AvgLogicalReads.ToString("N2");
+ public string AvgLogicalWritesDisplay => AvgLogicalWrites.ToString("N2");
+ public string AvgPhysicalReadsDisplay => AvgPhysicalReads.ToString("N2");
+ public string AvgMemoryMbDisplay => AvgMemoryMb.ToString("N2");
+ public string AvgRowcountDisplay => AvgRowcount.ToString("N2");
+ public string TotalDurationMsDisplay => TotalDurationMs.ToString("N2");
+ public string TotalCpuMsDisplay => TotalCpuMs.ToString("N2");
+ public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
+ public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
+ public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+
public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
From ecc960e7f8ce6d3e167be896abed248680403161 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:46:26 +0200
Subject: [PATCH 05/24] Fix grid color column correctly in query history
---
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 24 ++++++++++++++-----
1 file changed, 18 insertions(+), 6 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index be1924a..e5c6314 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -244,9 +244,15 @@ private void OnDataGridLoadingRow(object? sender, DataGridRowEventArgs e)
_planHashColorMap.TryGetValue(row.QueryPlanHash, out var color))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- e.Row.Tag = new SolidColorBrush(avColor);
+ var brush = new SolidColorBrush(avColor);
+ e.Row.Tag = brush;
+
+ // Try to apply immediately (works for recycled rows whose visual tree already exists)
+ if (TryApplyColorIndicator(e.Row, brush))
+ return;
}
+ // Visual tree not ready yet (first load) — defer to Loaded
e.Row.Loaded -= OnRowLoaded;
e.Row.Loaded += OnRowLoaded;
}
@@ -256,17 +262,23 @@ private void OnRowLoaded(object? sender, RoutedEventArgs e)
if (sender is not DataGridRow dgRow) return;
dgRow.Loaded -= OnRowLoaded;
- if (dgRow.Tag is not SolidColorBrush brush) return;
+ if (dgRow.Tag is SolidColorBrush brush)
+ TryApplyColorIndicator(dgRow, brush);
+ }
+ private bool TryApplyColorIndicator(DataGridRow dgRow, SolidColorBrush brush)
+ {
var presenter = FindVisualChild(dgRow);
- if (presenter == null) return;
+ if (presenter == null) return false;
var cell = presenter.Children.OfType().FirstOrDefault();
- if (cell == null) return;
+ if (cell == null) return false;
var border = FindVisualChild(cell, "ColorIndicator");
- if (border != null)
- border.Background = brush;
+ if (border == null) return false;
+
+ border.Background = brush;
+ return true;
}
private static T? FindVisualChild(Avalonia.Visual parent, string? name = null) where T : Avalonia.Visual
From 223ba4129d0f1b720c2b761ff127912a961f484f Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sun, 29 Mar 2026 22:58:11 +0200
Subject: [PATCH 06/24] in the query history : - emphasize selected dots :
bigger and white border - allow the user to select multiples rows in the grid
using standard CRLT+Click or SHIFT+Click for multiselection - emphasize the
dots of a manually selected rows - when the user click on a planHash in the
legend : highlight all dots and the line (thicker and less transparency).
also - - recompute the avg in the chart using selected/highlighted dots
---
.../Dialogs/QueryStoreHistoryWindow.axaml | 1 +
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 143 +++++++++++++++---
2 files changed, 120 insertions(+), 24 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 24cc0f3..35bfd20 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -110,6 +110,7 @@
kv.Key))
{
var avColor = Avalonia.Media.Color.FromRgb(color.R, color.G, color.B);
- var item = new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, Spacing = 6 };
+ var item = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ Spacing = 6,
+ Tag = hash,
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ };
item.Children.Add(new Border
{
Width = 12, Height = 12,
@@ -321,10 +331,90 @@ private void PopulateLegendPanel()
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
Foreground = new SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE0, 0xE0, 0xE0))
});
+ item.PointerPressed += OnLegendItemClicked;
LegendItemsPanel.Children.Add(item);
}
}
+ private void OnLegendItemClicked(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is not StackPanel panel || panel.Tag is not string planHash) return;
+
+ // Toggle: click again to deselect
+ if (_highlightedPlanHash == planHash)
+ _highlightedPlanHash = null;
+ else
+ _highlightedPlanHash = planHash;
+
+ ApplyPlanHighlight();
+ UpdateLegendVisuals();
+ }
+
+ private void UpdateLegendVisuals()
+ {
+ foreach (var child in LegendItemsPanel.Children)
+ {
+ if (child is not StackPanel panel || panel.Tag is not string hash) continue;
+ var isActive = _highlightedPlanHash == null || _highlightedPlanHash == hash;
+ panel.Opacity = isActive ? 1.0 : 0.4;
+ }
+ }
+
+ private void ApplyPlanHighlight()
+ {
+ var tag = (MetricSelector.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "AvgCpuMs";
+
+ foreach (var (scatter, _, planHash) in _scatters)
+ {
+ if (_highlightedPlanHash == null)
+ {
+ // No highlight: restore normal appearance
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(140);
+ scatter.LineWidth = 2;
+ scatter.MarkerSize = 8;
+ }
+ else if (planHash == _highlightedPlanHash)
+ {
+ // Highlighted plan: emphasized
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(220);
+ scatter.LineWidth = 4;
+ scatter.MarkerSize = 10;
+ }
+ else
+ {
+ // Other plans: dimmed
+ var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]);
+ scatter.Color = color.WithAlpha(40);
+ scatter.LineWidth = 1;
+ scatter.MarkerSize = 5;
+ }
+ }
+
+ // Recompute average line based on highlighted plan or all data
+ if (_avgLine != null)
+ {
+ var relevantRows = _highlightedPlanHash != null
+ ? _historyData.Where(r => r.QueryPlanHash == _highlightedPlanHash).ToList()
+ : _historyData;
+
+ if (relevantRows.Count > 0)
+ {
+ var avg = relevantRows.Select(r => GetMetricValue(r, tag)).Average();
+ _avgLine.Y = avg;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.IsVisible = true;
+ }
+ else
+ {
+ _avgLine.IsVisible = false;
+ }
+ }
+
+ HistoryChart.Refresh();
+ }
+
private void LegendToggle_Click(object? sender, RoutedEventArgs e)
{
_legendExpanded = !_legendExpanded;
@@ -340,6 +430,8 @@ private void UpdateChart()
_scatters.Clear();
_selectionRect = null;
_highlightMarkers.Clear();
+ _avgLine = null;
+ _highlightedPlanHash = null;
if (_historyData.Count == 0)
{
@@ -382,19 +474,19 @@ private void UpdateChart()
if (allValues.Length > 0)
{
var avg = allValues.Average();
- var hLine = HistoryChart.Plot.Add.HorizontalLine(avg);
- hLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
- hLine.LineWidth = 2f;
- hLine.LinePattern = LinePattern.DenselyDashed;
- hLine.Text = $"avg: {avg:N0}";
- hLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
- hLine.LabelFontSize = 11;
- hLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
- hLine.LabelOppositeAxis = false;
- hLine.LabelRotation = 0;
- hLine.LabelAlignment = Alignment.LowerLeft;
- hLine.LabelOffsetX = 38;
- hLine.LabelOffsetY = -8;
+ _avgLine = HistoryChart.Plot.Add.HorizontalLine(avg);
+ _avgLine.Color = ScottPlot.Color.FromHex("#FFD54F").WithAlpha(150);
+ _avgLine.LineWidth = 2f;
+ _avgLine.LinePattern = LinePattern.DenselyDashed;
+ _avgLine.Text = $"avg: {avg:N0}";
+ _avgLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
+ _avgLine.LabelFontSize = 11;
+ _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ _avgLine.LabelOppositeAxis = false;
+ _avgLine.LabelRotation = 0;
+ _avgLine.LabelAlignment = Alignment.LowerLeft;
+ _avgLine.LabelOffsetX = 38;
+ _avgLine.LabelOffsetY = -8;
}
// Y-axis always includes 0 as origin
@@ -479,13 +571,14 @@ private void HighlightDotsOnChart(HashSet rowIndices)
var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray();
var ys = group.Select(r => GetMetricValue(r, tag)).ToArray();
+ // Bigger filled dot with white border for emphasis
var highlight = HistoryChart.Plot.Add.Scatter(xs, ys);
highlight.LineWidth = 0;
- highlight.MarkerSize = 12;
- highlight.MarkerShape = MarkerShape.OpenCircle;
+ highlight.MarkerSize = 14;
+ highlight.MarkerShape = MarkerShape.FilledCircle;
+ highlight.Color = color;
highlight.MarkerLineColor = ScottPlot.Colors.White;
- highlight.MarkerLineWidth = 2f;
- highlight.Color = ScottPlot.Colors.Transparent;
+ highlight.MarkerLineWidth = 2.5f;
_highlightMarkers.Add(highlight);
}
@@ -671,16 +764,18 @@ private void OnHighlightLoadingRow(object? sender, DataGridRowEventArgs e)
private void HistoryDataGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressGridSelectionEvent) return;
- if (HistoryDataGrid.SelectedItems == null || HistoryDataGrid.SelectedItems.Count == 0) return;
_selectedRowIndices.Clear();
- foreach (var item in HistoryDataGrid.SelectedItems)
+ if (HistoryDataGrid.SelectedItems != null)
{
- if (item is QueryStoreHistoryRow row)
+ foreach (var item in HistoryDataGrid.SelectedItems)
{
- var idx = _historyData.IndexOf(row);
- if (idx >= 0)
- _selectedRowIndices.Add(idx);
+ if (item is QueryStoreHistoryRow row)
+ {
+ var idx = _historyData.IndexOf(row);
+ if (idx >= 0)
+ _selectedRowIndices.Add(idx);
+ }
}
}
From 53a4861dcc97ac0ee64392bf31ef367336b221c2 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:46:08 +0200
Subject: [PATCH 07/24] remove double using system
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index d1270af..010f8ac 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -1,5 +1,4 @@
using System;
-using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
From 6d525e70a374fd4b2159d2325fafce0a7e91d3bc Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:48:09 +0200
Subject: [PATCH 08/24] avgline LabelBackgroundColor Alpha 270 ==> 170
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index 010f8ac..b94732e 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -480,7 +480,7 @@ private void UpdateChart()
_avgLine.Text = $"avg: {avg:N0}";
_avgLine.LabelFontColor = ScottPlot.Color.FromHex("#9DA5B4");
_avgLine.LabelFontSize = 11;
- _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(270);
+ _avgLine.LabelBackgroundColor = ScottPlot.Color.FromHex("#333333").WithAlpha(170);
_avgLine.LabelOppositeAxis = false;
_avgLine.LabelRotation = 0;
_avgLine.LabelAlignment = Alignment.LowerLeft;
From 77ec4a43faf9d7f1baa589f0cb4614919ba9f85f Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:11:38 +0200
Subject: [PATCH 09/24] add TotalMemoryMB metric for query history
---
src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml | 2 ++
.../Dialogs/QueryStoreHistoryWindow.axaml.cs | 5 +++--
src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs | 7 ++++---
src/PlanViewer.Core/Services/QueryStoreService.cs | 6 ++++--
4 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
index 35bfd20..04d9188 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml
@@ -59,6 +59,7 @@
+
@@ -142,6 +143,7 @@
+
diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
index b94732e..b71aef8 100644
--- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
@@ -84,7 +84,7 @@ public partial class QueryStoreHistoryWindow : Window
["avg-writes"] = "AvgLogicalWrites",
["physical-reads"] = "TotalPhysicalReads",
["avg-physical-reads"] = "AvgPhysicalReads",
- ["memory"] = "TotalCpuMs",
+ ["memory"] = "TotalMemoryMb",
["avg-memory"] = "AvgMemoryMb",
["executions"] = "CountExecutions",
};
@@ -877,7 +877,8 @@ private void OnChartPointerMoved(object? sender, PointerEventArgs e)
"TotalLogicalReads" => row.TotalLogicalReads,
"TotalLogicalWrites" => row.TotalLogicalWrites,
"TotalPhysicalReads" => row.TotalPhysicalReads,
- "CountExecutions" => row.CountExecutions,
+ "TotalMemoryMb" => row.TotalMemoryMb,
+ "CountExecutions" => row.CountExecutions,
_ => row.AvgCpuMs,
};
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index 77e160c..62127f2 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -23,8 +23,8 @@ public class QueryStoreHistoryRow
public double TotalLogicalReads { get; set; }
public double TotalLogicalWrites { get; set; }
public double TotalPhysicalReads { get; set; }
-
- public int MinDop { get; set; }
+ public double TotalMemoryMb { get; set; }
+ public int MinDop { get; set; }
public int MaxDop { get; set; }
public DateTime? LastExecutionUtc { get; set; }
@@ -41,7 +41,8 @@ public class QueryStoreHistoryRow
public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+ public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
- public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
+ public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs
index 42ed986..8b96fbd 100644
--- a/src/PlanViewer.Core/Services/QueryStoreService.cs
+++ b/src/PlanViewer.Core/Services/QueryStoreService.cs
@@ -608,7 +608,8 @@ THEN SUM(rs.avg_rowcount * rs.count_executions) / SUM(rs.count_executions)
SUM(rs.avg_physical_io_reads * rs.count_executions),
MIN(rs.min_dop),
MAX(rs.max_dop),
- MAX(rs.last_execution_time)
+ MAX(rs.last_execution_time),
+ SUM(rs.avg_query_max_used_memory * rs.count_executions)
FROM sys.query_store_runtime_stats rs
JOIN sys.query_store_runtime_stats_interval rsi
ON rs.runtime_stats_interval_id = rsi.runtime_stats_interval_id
@@ -652,7 +653,8 @@ JOIN sys.query_store_query q
MinDop = (int)reader.GetInt64(15),
MaxDop = (int)reader.GetInt64(16),
LastExecutionUtc = reader.IsDBNull(17) ? null : ((DateTimeOffset)reader.GetValue(17)).UtcDateTime,
- });
+ TotalMemoryMb = reader.GetDouble(18),
+ });
}
return rows;
From e88096bf2cea4013cfb7276b69572d066a482d90 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 30 Mar 2026 14:14:45 +0200
Subject: [PATCH 10/24] update TotalLogicalReads, TotalLogicalWrites,
TotalPhysicalReads format to no decimals (N0)
---
src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
index 62127f2..b275acf 100644
--- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
+++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs
@@ -38,11 +38,10 @@ public class QueryStoreHistoryRow
public string AvgRowcountDisplay => AvgRowcount.ToString("N2");
public string TotalDurationMsDisplay => TotalDurationMs.ToString("N2");
public string TotalCpuMsDisplay => TotalCpuMs.ToString("N2");
- public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N2");
- public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N2");
- public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N2");
+ public string TotalLogicalReadsDisplay => TotalLogicalReads.ToString("N0");
+ public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N0");
+ public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N0");
public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2");
-
public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc);
public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : "";
}
From d3acba7c154b0e1d77b421ed35aab4e4d074cf83 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 30 Mar 2026 16:27:38 -0400
Subject: [PATCH 11/24] Add SignPath code signing to release workflow
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds Windows binary signing via SignPath, gated on SIGNPATH_API_TOKEN
secret — releases unsigned binaries until signing is configured.
Velopack now packs from signed binaries when available.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.github/workflows/release.yml | 341 +++++++++++++++++++---------------
1 file changed, 193 insertions(+), 148 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 870464a..2c094a6 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,148 +1,193 @@
-name: Release
-
-on:
- pull_request:
- branches: [main]
- types: [closed]
-
-permissions:
- contents: write
-
-jobs:
- release:
- if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
- runs-on: windows-latest
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Get version
- id: version
- shell: pwsh
- run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- echo "VERSION=$version" >> $env:GITHUB_OUTPUT
-
- - name: Check if release already exists
- id: check
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
- echo "EXISTS=true" >> $GITHUB_OUTPUT
- else
- echo "EXISTS=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Create release
- if: steps.check.outputs.EXISTS == 'false'
- shell: bash
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
-
- - name: Setup .NET 8.0
- if: steps.check.outputs.EXISTS == 'false'
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
-
- - name: Build and test
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet restore
- dotnet build -c Release
- dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
-
- - name: Publish App (all platforms)
- if: steps.check.outputs.EXISTS == 'false'
- run: |
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
-
- - name: Create Velopack release (Windows)
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- dotnet tool install -g vpk
- New-Item -ItemType Directory -Force -Path releases/velopack
-
- # Download previous release for delta generation
- vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
-
- # Pack Windows release
- vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
-
- - name: Package and upload
- if: steps.check.outputs.EXISTS == 'false'
- shell: pwsh
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- New-Item -ItemType Directory -Force -Path releases
-
- # Package Windows and Linux as flat zips
- foreach ($rid in @('win-x64', 'linux-x64')) {
- if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
- Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Package macOS as proper .app bundles
- foreach ($rid in @('osx-x64', 'osx-arm64')) {
- $appName = "PerformanceStudio.app"
- $bundleDir = "publish/$rid-bundle/$appName"
-
- # Create .app bundle structure
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
-
- # Copy all published files into Contents/MacOS
- Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
-
- # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
- if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
- Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
- }
-
- # Update version in Info.plist to match csproj
- $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
- $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
- Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
-
- # Move icon to Contents/Resources
- if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
- Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
- }
-
- # Add README and LICENSE alongside the .app bundle
- $wrapperDir = "publish/$rid-bundle"
- if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
-
- Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
- }
-
- # Checksums (zips only, Velopack has its own checksums)
- $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
- $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
- "$hash $($_.Name)"
- }
- $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
- Write-Host "Checksums:"
- $checksums | ForEach-Object { Write-Host $_ }
-
- # Upload zips + checksums
- gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
-
- # Upload Velopack artifacts
- vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
+name: Release
+
+on:
+ pull_request:
+ branches: [main]
+ types: [closed]
+
+permissions:
+ contents: write
+ id-token: write
+ actions: read
+
+jobs:
+ release:
+ if: github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev'
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Get version
+ id: version
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+
+ - name: Check if release already exists
+ id: check
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if gh release view "v${{ steps.version.outputs.VERSION }}" > /dev/null 2>&1; then
+ echo "EXISTS=true" >> $GITHUB_OUTPUT
+ else
+ echo "EXISTS=false" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Create release
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
+
+ - name: Setup .NET 8.0
+ if: steps.check.outputs.EXISTS == 'false'
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Build and test
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet restore
+ dotnet build -c Release
+ dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
+
+ - name: Publish App (all platforms)
+ if: steps.check.outputs.EXISTS == 'false'
+ run: |
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
+
+ # ── SignPath code signing (Windows only, skipped if secret not configured) ──
+ - name: Check if signing is configured
+ if: steps.check.outputs.EXISTS == 'false'
+ id: signing
+ shell: bash
+ run: |
+ if [ -n "${{ secrets.SIGNPATH_API_TOKEN }}" ]; then
+ echo "ENABLED=true" >> $GITHUB_OUTPUT
+ else
+ echo "ENABLED=false" >> $GITHUB_OUTPUT
+ echo "::warning::SIGNPATH_API_TOKEN not configured — releasing unsigned binaries"
+ fi
+
+ - name: Upload Windows build for signing
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ id: upload-unsigned
+ uses: actions/upload-artifact@v4
+ with:
+ name: App-unsigned
+ path: publish/win-x64/
+
+ - name: Sign Windows build
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ uses: signpath/github-action-submit-signing-request@v1
+ with:
+ api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
+ organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
+ project-slug: 'PerformanceStudio'
+ signing-policy-slug: 'test-signing'
+ artifact-configuration-slug: 'App'
+ github-artifact-id: '${{ steps.upload-unsigned.outputs.artifact-id }}'
+ wait-for-completion: true
+ output-artifact-directory: 'signed/win-x64'
+
+ - name: Replace unsigned Windows build with signed
+ if: steps.check.outputs.EXISTS == 'false' && steps.signing.outputs.ENABLED == 'true'
+ shell: pwsh
+ run: |
+ Remove-Item -Recurse -Force publish/win-x64
+ Copy-Item -Recurse signed/win-x64 publish/win-x64
+
+ # ── Velopack (uses signed Windows binaries) ───────────────────────
+ - name: Create Velopack release (Windows)
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ dotnet tool install -g vpk
+ New-Item -ItemType Directory -Force -Path releases/velopack
+
+ # Download previous release for delta generation
+ vpk download github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --token $env:GH_TOKEN
+
+ # Pack Windows release (now signed)
+ vpk pack -u PerformanceStudio -v $env:VERSION -p publish/win-x64 -e PlanViewer.App.exe -o releases/velopack --channel win
+
+ # ── Package and upload ────────────────────────────────────────────
+ - name: Package and upload
+ if: steps.check.outputs.EXISTS == 'false'
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ New-Item -ItemType Directory -Force -Path releases
+
+ # Package Windows (signed) and Linux as flat zips
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
+ Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Package macOS as proper .app bundles
+ foreach ($rid in @('osx-x64', 'osx-arm64')) {
+ $appName = "PerformanceStudio.app"
+ $bundleDir = "publish/$rid-bundle/$appName"
+
+ # Create .app bundle structure
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
+
+ # Copy all published files into Contents/MacOS
+ Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
+
+ # Move Info.plist to Contents/ (it was copied to MacOS/ with the publish output)
+ if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
+ }
+
+ # Update version in Info.plist to match csproj
+ $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
+ $plist = $plist -replace '(CFBundleVersion\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ $plist = $plist -replace '(CFBundleShortVersionString\s*)[^<]*()', "`${1}$env:VERSION`${2}"
+ Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
+
+ # Move icon to Contents/Resources
+ if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
+ }
+
+ # Add README and LICENSE alongside the .app bundle
+ $wrapperDir = "publish/$rid-bundle"
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
+
+ Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid.zip" -Force
+ }
+
+ # Checksums (zips only, Velopack has its own checksums)
+ $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
+ $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$hash $($_.Name)"
+ }
+ $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
+ Write-Host "Checksums:"
+ $checksums | ForEach-Object { Write-Host $_ }
+
+ # Upload zips + checksums
+ gh release upload "v$env:VERSION" releases/*.zip releases/SHA256SUMS.txt --clobber
+
+ # Upload Velopack artifacts
+ vpk upload github --repoUrl https://github.com/${{ github.repository }} --channel win -o releases/velopack --releaseName "v$env:VERSION" --tag "v$env:VERSION" --merge --token $env:GH_TOKEN
From f03ea276f30cf6579619320c7aa7336cfbf1b1e3 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 30 Mar 2026 17:18:46 -0400
Subject: [PATCH 12/24] Fix Rule 2 false positive: eager table spools
misidentified as eager index spools
The condition checked PhysicalOp.Contains("Spool") which matched both
"Index Spool" and "Table Spool". Now checks for "Index" specifically.
Added negative test with an eager table spool plan.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/PlanViewer.Core/Services/PlanAnalyzer.cs | 2 +-
.../PlanAnalyzerTests.cs | 11 +
.../Plans/eager_table_spool_plan.sqlplan | 1283 +++++++++++++++++
3 files changed, 1295 insertions(+), 1 deletion(-)
create mode 100644 tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan
diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
index 0d577b3..f4c829d 100644
--- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs
+++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs
@@ -460,7 +460,7 @@ private static void AnalyzeNode(PlanNode node, PlanStatement stmt, AnalyzerConfi
// Rule 2: Eager Index Spools — optimizer building temporary indexes on the fly
if (!cfg.IsRuleDisabled(2) && node.LogicalOp == "Eager Spool" &&
- node.PhysicalOp.Contains("Spool", StringComparison.OrdinalIgnoreCase))
+ node.PhysicalOp.Contains("Index", StringComparison.OrdinalIgnoreCase))
{
var message = "SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely.";
if (!string.IsNullOrEmpty(node.SuggestedIndex))
diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
index e8815ab..28ad002 100644
--- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
+++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs
@@ -33,6 +33,17 @@ public void Rule02_EagerIndexSpool_DetectedInLazySpoolPlan()
Assert.Contains(warnings, w => w.Message.Contains("temporary index in TempDB"));
}
+ [Fact]
+ public void Rule02_EagerIndexSpool_NotFiredForEagerTableSpool()
+ {
+ // Plan with Eager Table Spool (PhysicalOp="Table Spool", LogicalOp="Eager Spool")
+ // should NOT trigger the Eager Index Spool warning
+ var plan = PlanTestHelper.LoadAndAnalyze("eager_table_spool_plan.sqlplan");
+ var warnings = PlanTestHelper.WarningsOfType(plan, "Eager Index Spool");
+
+ Assert.Empty(warnings);
+ }
+
// ---------------------------------------------------------------
// Rule 3: Serial Plan
// ---------------------------------------------------------------
diff --git a/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan
new file mode 100644
index 0000000..40c0a52
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/Plans/eager_table_spool_plan.sqlplan
@@ -0,0 +1,1283 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
From 638025c953b2363f14c9fc163b6911830089105c Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sat, 4 Apr 2026 22:59:00 +0200
Subject: [PATCH 13/24] - Allow to zoom in/out in humanadvices using mousewheel
- Make the zoom on planviewer focused/centered on the mouse pointer
---
.../Controls/PlanViewerControl.axaml.cs | 29 ++++++++++++++++++-
.../Controls/QuerySessionControl.axaml.cs | 22 +++++++++++++-
src/PlanViewer.App/MainWindow.axaml.cs | 22 +++++++++++++-
3 files changed, 70 insertions(+), 3 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 6851fdb..6b12332 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -2870,7 +2870,34 @@ private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEv
if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
{
e.Handled = true;
- SetZoom(_zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep));
+
+ var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom,
+ _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep)));
+
+ if (Math.Abs(newZoom - _zoomLevel) < 0.001)
+ return;
+
+ // Mouse position relative to the ScrollViewer viewport
+ var mouseInView = e.GetPosition(PlanScrollViewer);
+
+ // Content point under the mouse at the current zoom level
+ var contentX = (PlanScrollViewer.Offset.X + mouseInView.X) / _zoomLevel;
+ var contentY = (PlanScrollViewer.Offset.Y + mouseInView.Y) / _zoomLevel;
+
+ // Apply the new zoom
+ _zoomLevel = newZoom;
+ _zoomTransform.ScaleX = _zoomLevel;
+ _zoomTransform.ScaleY = _zoomLevel;
+ ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
+
+ // Adjust offset so the same content point stays under the mouse
+ var newOffsetX = Math.Max(0, contentX * _zoomLevel - mouseInView.X);
+ var newOffsetY = Math.Max(0, contentY * _zoomLevel - mouseInView.Y);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
+ });
}
}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index b277089..b51549e 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -741,10 +741,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
buttonPanel.Children.Add(copyBtn);
buttonPanel.Children.Add(closeBtn);
+ var scaleTransform = new ScaleTransform(1, 1);
+ var layoutTransform = new LayoutTransformControl
+ {
+ LayoutTransform = scaleTransform,
+ Child = scrollViewer
+ };
+
var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
DockPanel.SetDock(buttonPanel, Dock.Bottom);
panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
+ panel.Children.Add(layoutTransform);
var window = new Window
{
@@ -759,6 +766,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
Content = panel
};
+ double adviceZoom = 1.0;
+ window.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, (_, args) =>
+ {
+ if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ args.Handled = true;
+ adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
+ adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
+ scaleTransform.ScaleX = adviceZoom;
+ scaleTransform.ScaleY = adviceZoom;
+ }
+ }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
copyBtn.Click += async (_, _) =>
{
var clipboard = window.Clipboard;
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 81706bf..40519ce 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -637,10 +637,17 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
buttonPanel.Children.Add(copyBtn);
buttonPanel.Children.Add(closeBtn);
+ var scaleTransform = new ScaleTransform(1, 1);
+ var layoutTransform = new LayoutTransformControl
+ {
+ LayoutTransform = scaleTransform,
+ Child = scrollViewer
+ };
+
var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
DockPanel.SetDock(buttonPanel, Dock.Bottom);
panel.Children.Add(buttonPanel);
- panel.Children.Add(scrollViewer);
+ panel.Children.Add(layoutTransform);
var window = new Window
{
@@ -655,6 +662,19 @@ private void ShowAdviceWindow(string title, string content, AnalysisResult? anal
Content = panel
};
+ double adviceZoom = 1.0;
+ window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) =>
+ {
+ if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ args.Handled = true;
+ adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
+ adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
+ scaleTransform.ScaleX = adviceZoom;
+ scaleTransform.ScaleY = adviceZoom;
+ }
+ }, RoutingStrategies.Tunnel);
+
copyBtn.Click += async (_, _) =>
{
var clipboard = window.Clipboard;
From 1dada02b441a92215ce3705a60b1998590c5d2fe Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Sat, 4 Apr 2026 23:36:16 +0200
Subject: [PATCH 14/24] make the node quoted in the human advices window
clickable and navigate to the node. The plan become centered and focus
(zoomed) on the selected node
---
.../Controls/PlanViewerControl.axaml.cs | 55 ++++
.../Controls/QuerySessionControl.axaml.cs | 22 +-
src/PlanViewer.App/MainWindow.axaml.cs | 9 +-
.../Services/AdviceContentBuilder.cs | 263 +++++++++++++++++-
4 files changed, 338 insertions(+), 11 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 6b12332..106b4b3 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -172,6 +172,61 @@ public ServerMetadata? Metadata
public event EventHandler? RobotAdviceRequested;
public event EventHandler? CopyReproRequested;
+ ///
+ /// Navigates to a specific plan node by ID: selects it, zooms to show it,
+ /// and scrolls to center it in the viewport.
+ ///
+ public void NavigateToNode(int nodeId)
+ {
+ // Find the Border for this node
+ Border? targetBorder = null;
+ PlanNode? targetNode = null;
+ foreach (var (border, node) in _nodeBorderMap)
+ {
+ if (node.NodeId == nodeId)
+ {
+ targetBorder = border;
+ targetNode = node;
+ break;
+ }
+ }
+
+ if (targetBorder == null || targetNode == null)
+ return;
+
+ // Activate the parent window so the plan viewer becomes visible
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window parentWindow)
+ parentWindow.Activate();
+
+ // Select the node (highlights it and shows properties)
+ SelectNode(targetBorder, targetNode);
+
+ // Ensure zoom level makes the node comfortably visible
+ var viewWidth = PlanScrollViewer.Bounds.Width;
+ var viewHeight = PlanScrollViewer.Bounds.Height;
+ if (viewWidth <= 0 || viewHeight <= 0)
+ return;
+
+ // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode);
+ var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4));
+ if (_zoomLevel < minVisibleZoom)
+ SetZoom(Math.Min(minVisibleZoom, 1.0));
+
+ // Scroll to center the node in the viewport
+ var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2;
+ var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2;
+ centerX = Math.Max(0, centerX);
+ centerY = Math.Max(0, centerY);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(centerX, centerY);
+ });
+ }
+
public void LoadPlan(string planXml, string label, string? queryText = null)
{
_label = label;
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index b51549e..6af2ad9 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -659,12 +659,17 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
}
private AnalysisResult? GetCurrentAnalysis()
+ {
+ return GetCurrentAnalysisWithViewer().Analysis;
+ }
+
+ private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer()
{
// Find the currently selected plan tab's PlanViewerControl
if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
&& viewer.CurrentPlan != null)
{
- return ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata);
+ return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer);
}
// Fallback: find the most recent plan tab
@@ -673,20 +678,20 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v
&& v.CurrentPlan != null)
{
- return ResultMapper.Map(v.CurrentPlan, "query editor");
+ return (ResultMapper.Map(v.CurrentPlan, "query editor"), v);
}
}
- return null;
+ return (null, null);
}
private void HumanAdvice_Click(object? sender, RoutedEventArgs e)
{
- var analysis = GetCurrentAnalysis();
+ var (analysis, viewer) = GetCurrentAnalysisWithViewer();
if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }
var text = TextFormatter.Format(analysis);
- ShowAdviceWindow("Advice for Humans", text, analysis);
+ ShowAdviceWindow("Advice for Humans", text, analysis, viewer);
}
private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
@@ -698,9 +703,12 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
ShowAdviceWindow("Advice for Robots", json);
}
- private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- var styledContent = AdviceContentBuilder.Build(content, analysis);
+ Action? onNodeClick = sourceViewer != null
+ ? nodeId => sourceViewer.NavigateToNode(nodeId)
+ : null;
+ var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
var scrollViewer = new ScrollViewer
{
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 40519ce..8ec91d5 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -458,7 +458,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
{
if (viewer.CurrentPlan == null) return;
var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
- ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis);
+ ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer);
};
Action showRobotAdvice = () =>
@@ -594,9 +594,12 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
return panel;
}
- private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null)
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- var styledContent = AdviceContentBuilder.Build(content, analysis);
+ Action? onNodeClick = sourceViewer != null
+ ? nodeId => sourceViewer.NavigateToNode(nodeId)
+ : null;
+ var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
var scrollViewer = new ScrollViewer
{
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index eeff7d6..a1b6b9e 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -66,12 +66,22 @@ internal static class AdviceContentBuilder
private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled);
+ // Matches "Node N" or "(Node N)" references in text
+ private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled);
+
+ private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7"));
+
public static StackPanel Build(string content)
{
- return Build(content, null);
+ return Build(content, null, null);
}
public static StackPanel Build(string content, AnalysisResult? analysis)
+ {
+ return Build(content, analysis, null);
+ }
+
+ public static StackPanel Build(string content, AnalysisResult? analysis, Action? onNodeClick)
{
var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) };
var lines = content.Split('\n');
@@ -410,9 +420,260 @@ public static StackPanel Build(string content, AnalysisResult? analysis)
});
}
+ // Post-process: make "Node N" references clickable
+ if (onNodeClick != null)
+ MakeNodeRefsClickable(panel, onNodeClick);
+
return panel;
}
+ ///
+ /// Walks all children recursively and replaces "Node N" text with clickable inline links.
+ ///
+ private static void MakeNodeRefsClickable(Panel panel, Action onNodeClick)
+ {
+ for (int i = 0; i < panel.Children.Count; i++)
+ {
+ var child = panel.Children[i];
+
+ // Recurse into containers
+ if (child is Panel innerPanel)
+ {
+ MakeNodeRefsClickable(innerPanel, onNodeClick);
+ continue;
+ }
+ if (child is Border border)
+ {
+ if (border.Child is Panel borderPanel)
+ {
+ MakeNodeRefsClickable(borderPanel, onNodeClick);
+ continue;
+ }
+ if (border.Child is SelectableTextBlock borderStb)
+ {
+ if (borderStb.Inlines?.Count > 0)
+ ProcessInlines(borderStb, onNodeClick);
+ else if (!string.IsNullOrEmpty(borderStb.Text) && NodeRefRegex.IsMatch(borderStb.Text))
+ {
+ var bText = borderStb.Text;
+ var bFg = borderStb.Foreground;
+ borderStb.Text = null;
+ AddRunsWithNodeLinks(borderStb.Inlines!, bText, bFg, onNodeClick);
+ WireNodeClickHandler(borderStb, onNodeClick);
+ }
+ continue;
+ }
+ }
+ if (child is Expander expander && expander.Content is Panel expanderPanel)
+ {
+ MakeNodeRefsClickable(expanderPanel, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with Inlines
+ if (child is SelectableTextBlock stb && stb.Inlines?.Count > 0)
+ {
+ ProcessInlines(stb, onNodeClick);
+ continue;
+ }
+
+ // Process SelectableTextBlock with plain Text
+ if (child is SelectableTextBlock stbPlain && stbPlain.Inlines?.Count == 0
+ && !string.IsNullOrEmpty(stbPlain.Text) && NodeRefRegex.IsMatch(stbPlain.Text))
+ {
+ var text = stbPlain.Text;
+ var fg = stbPlain.Foreground;
+ stbPlain.Text = null;
+ AddRunsWithNodeLinks(stbPlain.Inlines!, text, fg, onNodeClick);
+ WireNodeClickHandler(stbPlain, onNodeClick);
+ }
+ }
+ }
+
+ ///
+ /// Processes existing Inlines in a SelectableTextBlock, splitting any Run that
+ /// contains "Node N" into segments with clickable links.
+ ///
+ private static void ProcessInlines(SelectableTextBlock stb, Action onNodeClick)
+ {
+ var inlines = stb.Inlines!;
+ var snapshot = inlines.ToList();
+ var changed = false;
+
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ changed = true;
+ break;
+ }
+ }
+
+ if (!changed) return;
+
+ // Rebuild inlines
+ var newInlines = new List();
+ foreach (var inline in snapshot)
+ {
+ if (inline is Run run && !string.IsNullOrEmpty(run.Text) && NodeRefRegex.IsMatch(run.Text))
+ {
+ var text = run.Text;
+ int pos = 0;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ newInlines.Add(new Run(text[pos..m.Index]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+
+ if (int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ var linkRun = new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline,
+ FontWeight = run.FontWeight,
+ FontSize = run.FontSize > 0 ? run.FontSize : double.NaN
+ };
+ newInlines.Add(linkRun);
+ }
+ else
+ {
+ newInlines.Add(new Run(m.Value) { Foreground = run.Foreground, FontWeight = run.FontWeight });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ newInlines.Add(new Run(text[pos..]) { Foreground = run.Foreground, FontWeight = run.FontWeight, FontSize = run.FontSize > 0 ? run.FontSize : double.NaN });
+ }
+ else
+ {
+ newInlines.Add(inline);
+ }
+ }
+
+ inlines.Clear();
+ foreach (var ni in newInlines)
+ inlines.Add(ni);
+
+ // Wire up PointerPressed on the TextBlock to detect clicks on link runs
+ WireNodeClickHandler(stb, onNodeClick);
+ }
+
+ ///
+ /// Splits plain text into Runs, making "Node N" references clickable.
+ ///
+ private static void AddRunsWithNodeLinks(InlineCollection inlines, string text, IBrush? defaultFg, Action onNodeClick)
+ {
+ int pos = 0;
+ var stb = inlines.FirstOrDefault()?.Parent as SelectableTextBlock;
+ foreach (System.Text.RegularExpressions.Match m in NodeRefRegex.Matches(text))
+ {
+ if (m.Index > pos)
+ inlines.Add(new Run(text[pos..m.Index]) { Foreground = defaultFg });
+
+ if (int.TryParse(m.Groups[1].Value, out _))
+ {
+ inlines.Add(new Run(m.Value)
+ {
+ Foreground = LinkBrush,
+ TextDecorations = Avalonia.Media.TextDecorations.Underline
+ });
+ }
+ else
+ {
+ inlines.Add(new Run(m.Value) { Foreground = defaultFg });
+ }
+ pos = m.Index + m.Length;
+ }
+ if (pos < text.Length)
+ inlines.Add(new Run(text[pos..]) { Foreground = defaultFg });
+
+ // Find the parent SelectableTextBlock to attach click handler
+ // The inlines collection is owned by the SelectableTextBlock that called us
+ // We need to wire it up after — caller should call WireNodeClickHandler separately
+ }
+
+ ///
+ /// Attaches a PointerPressed handler to a SelectableTextBlock that detects clicks
+ /// on underlined "Node N" text and invokes the callback.
+ /// Uses Tunnel routing so the handler fires before SelectableTextBlock's
+ /// built-in text selection consumes the event.
+ ///
+ private static void WireNodeClickHandler(SelectableTextBlock stb, Action onNodeClick)
+ {
+ stb.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside) return;
+
+ var charIndex = hit.TextPosition;
+
+ // Walk through inlines to find which Run the charIndex falls in
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ if (run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush)
+ {
+ var m = NodeRefRegex.Match(run.Text);
+ if (m.Success && int.TryParse(m.Groups[1].Value, out var nodeId))
+ {
+ e.Handled = true;
+
+ // Clear any text selection and release pointer capture
+ // to prevent SelectableTextBlock from starting a selection drag
+ stb.SelectionStart = 0;
+ stb.SelectionEnd = 0;
+ e.Pointer.Capture(null);
+
+ onNodeClick(nodeId);
+ }
+ }
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
+ // Change cursor on hover over link runs
+ stb.PointerMoved += (_, e) =>
+ {
+ var point = e.GetPosition(stb);
+ var hit = stb.TextLayout.HitTestPoint(point);
+ if (!hit.IsInside)
+ {
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ return;
+ }
+
+ var charIndex = hit.TextPosition;
+ int runStart = 0;
+ foreach (var inline in stb.Inlines!)
+ {
+ if (inline is Run run && run.Text != null)
+ {
+ var runEnd = runStart + run.Text.Length;
+ if (charIndex >= runStart && charIndex < runEnd)
+ {
+ stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline
+ && run.Foreground == LinkBrush
+ ? new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ : Avalonia.Input.Cursor.Default;
+ return;
+ }
+ runStart = runEnd;
+ }
+ }
+ stb.Cursor = Avalonia.Input.Cursor.Default;
+ };
+ }
+
private static bool IsSubSectionLabel(string trimmed)
{
// "Warnings:", "Parameters:", "Wait stats:", "Operator warnings:",
From 7293283ce869b0070f6fcf3f98757025f8184239 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Sun, 5 Apr 2026 11:40:24 -0400
Subject: [PATCH 15/24] Add contextual schema lookup from query editor (issue
#1)
Right-click a table, procedure, or function name in the query editor to
view indexes, table definitions, or object definitions from the connected
server. Uses Microsoft.SqlServer.TransactSql.ScriptDom to parse SQL and
resolve the object under the cursor.
- SqlObjectResolver: AST-based object resolution at cursor position
- SchemaQueryService: DMV queries for indexes, columns, object definitions
- CREATE TABLE output with PK constraints, identity, computed columns,
partition schemes, and non-default index options
- CREATE INDEX output with fill factor, compression, lock settings,
partition schemes, and proper columnstore syntax
- Right-click moves caret to click position for intuitive context menus
- Copy/Copy All/Select All context menu on schema result tabs
- 11 unit tests for SqlObjectResolver
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../Controls/QuerySessionControl.axaml.cs | 437 +++++++++++++++++-
src/PlanViewer.Core/PlanViewer.Core.csproj | 1 +
.../Services/SchemaQueryService.cs | 237 ++++++++++
.../Services/SqlObjectResolver.cs | 248 ++++++++++
.../SqlObjectResolverTests.cs | 143 ++++++
5 files changed, 1064 insertions(+), 2 deletions(-)
create mode 100644 src/PlanViewer.Core/Services/SchemaQueryService.cs
create mode 100644 src/PlanViewer.Core/Services/SqlObjectResolver.cs
create mode 100644 tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index b277089..afaf91b 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -95,6 +95,13 @@ private void SetupSyntaxHighlighting()
_textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
}
+ // Schema context menu items — stored as fields so we can toggle visibility on menu open
+ private MenuItem? _showIndexesItem;
+ private MenuItem? _showTableDefItem;
+ private MenuItem? _showObjectDefItem;
+ private Separator? _schemaSeparator;
+ private ResolvedSqlObject? _contextMenuObject;
+
private void SetupEditorContextMenu()
{
var cutItem = new MenuItem { Header = "Cut" };
@@ -151,10 +158,436 @@ private void SetupEditorContextMenu()
await CaptureAndShowPlan(estimated: false, queryTextOverride: text);
};
- QueryEditor.TextArea.ContextMenu = new ContextMenu
+ // Schema lookup items
+ _schemaSeparator = new Separator();
+
+ _showIndexesItem = new MenuItem { Header = "Show Indexes" };
+ _showIndexesItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.Indexes);
+
+ _showTableDefItem = new MenuItem { Header = "Show Table Definition" };
+ _showTableDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.TableDefinition);
+
+ _showObjectDefItem = new MenuItem { Header = "Show Object Definition" };
+ _showObjectDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.ObjectDefinition);
+
+ var contextMenu = new ContextMenu
+ {
+ Items =
+ {
+ cutItem, copyItem, pasteItem,
+ new Separator(), selectAllItem,
+ new Separator(), executeFromCursorItem, executeCurrentBatchItem,
+ _schemaSeparator,
+ _showIndexesItem, _showTableDefItem, _showObjectDefItem
+ }
+ };
+
+ contextMenu.Opening += OnContextMenuOpening;
+ QueryEditor.TextArea.ContextMenu = contextMenu;
+
+ // Move caret to right-click position so schema lookup resolves the clicked object
+ QueryEditor.TextArea.PointerPressed += OnEditorPointerPressed;
+ }
+
+ private void OnEditorPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(QueryEditor.TextArea).Properties.IsRightButtonPressed)
+ return;
+
+ var pos = QueryEditor.GetPositionFromPoint(e.GetPosition(QueryEditor));
+ if (pos == null) return;
+
+ QueryEditor.TextArea.Caret.Position = pos.Value;
+ }
+
+ private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ // Resolve what object is under the cursor
+ var sqlText = QueryEditor.Text;
+ var offset = QueryEditor.CaretOffset;
+ _contextMenuObject = SqlObjectResolver.Resolve(sqlText, offset);
+
+ var hasConnection = _connectionString != null;
+ var hasObject = _contextMenuObject != null && hasConnection;
+
+ _schemaSeparator!.IsVisible = hasObject;
+ _showIndexesItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown;
+ _showTableDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown;
+ _showObjectDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Function or SqlObjectKind.Procedure;
+
+ // Update headers to show the object name
+ if (hasObject)
+ {
+ var name = _contextMenuObject!.FullName;
+ _showIndexesItem.Header = $"Show Indexes — {name}";
+ _showTableDefItem.Header = $"Show Table Definition — {name}";
+ _showObjectDefItem.Header = $"Show Object Definition — {name}";
+ }
+ }
+
+ private enum SchemaInfoKind { Indexes, TableDefinition, ObjectDefinition }
+
+ private async Task ShowSchemaInfoAsync(SchemaInfoKind kind)
+ {
+ if (_contextMenuObject == null || _connectionString == null) return;
+
+ var objectName = _contextMenuObject.FullName;
+ SetStatus($"Fetching {kind} for {objectName}...", autoClear: false);
+
+ try
+ {
+ string content;
+ string tabLabel;
+
+ switch (kind)
+ {
+ case SchemaInfoKind.Indexes:
+ var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
+ content = FormatIndexes(objectName, indexes);
+ tabLabel = $"Indexes — {objectName}";
+ break;
+
+ case SchemaInfoKind.TableDefinition:
+ var columns = await SchemaQueryService.FetchColumnsAsync(_connectionString, objectName);
+ var tableIndexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
+ content = FormatColumns(objectName, columns, tableIndexes);
+ tabLabel = $"Table — {objectName}";
+ break;
+
+ case SchemaInfoKind.ObjectDefinition:
+ var definition = await SchemaQueryService.FetchObjectDefinitionAsync(_connectionString, objectName);
+ content = definition ?? $"-- No definition found for {objectName}";
+ tabLabel = $"Definition — {objectName}";
+ break;
+
+ default:
+ return;
+ }
+
+ AddSchemaTab(tabLabel, content, isSql: true);
+ SetStatus($"Loaded {kind} for {objectName}");
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Error: {ex.Message}", autoClear: false);
+ Debug.WriteLine($"Schema lookup error: {ex}");
+ }
+ }
+
+ private void AddSchemaTab(string label, string content, bool isSql)
+ {
+ var editor = new TextEditor
+ {
+ Text = content,
+ IsReadOnly = true,
+ FontFamily = new FontFamily("Consolas, Menlo, monospace"),
+ FontSize = 13,
+ ShowLineNumbers = true,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ Padding = new Avalonia.Thickness(4)
+ };
+
+ if (isSql)
+ {
+ var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
+ var tm = editor.InstallTextMate(registryOptions);
+ tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
+ }
+
+ // Context menu for read-only schema tabs
+ var schemaCopy = new MenuItem { Header = "Copy" };
+ schemaCopy.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ var sel = editor.TextArea.Selection;
+ if (!sel.IsEmpty)
+ await clipboard.SetTextAsync(sel.GetText());
+ };
+ var schemaCopyAll = new MenuItem { Header = "Copy All" };
+ schemaCopyAll.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ await clipboard.SetTextAsync(editor.Text);
+ };
+ var schemaSelectAll = new MenuItem { Header = "Select All" };
+ schemaSelectAll.Click += (_, _) => editor.SelectAll();
+ editor.TextArea.ContextMenu = new ContextMenu
+ {
+ Items = { schemaCopy, schemaCopyAll, new Separator(), schemaSelectAll }
+ };
+
+ var headerText = new TextBlock
+ {
+ Text = label,
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 12
+ };
+
+ var closeBtn = new Button
+ {
+ Content = "\u2715",
+ MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
+ Padding = new Avalonia.Thickness(0),
+ FontSize = 11,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+
+ var header = new StackPanel
{
- Items = { cutItem, copyItem, pasteItem, new Separator(), selectAllItem, new Separator(), executeFromCursorItem, executeCurrentBatchItem }
+ Orientation = Orientation.Horizontal,
+ Children = { headerText, closeBtn }
+ };
+
+ var tab = new TabItem { Header = header, Content = editor };
+ closeBtn.Tag = tab;
+ closeBtn.Click += (s, _) =>
+ {
+ if (s is Button btn && btn.Tag is TabItem t)
+ SubTabControl.Items.Remove(t);
};
+
+ SubTabControl.Items.Add(tab);
+ SubTabControl.SelectedItem = tab;
+ }
+
+ private static string FormatIndexes(string objectName, IReadOnlyList indexes)
+ {
+ if (indexes.Count == 0)
+ return $"-- No indexes found on {objectName}";
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"-- Indexes on {objectName}");
+ sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows");
+ sb.AppendLine();
+
+ foreach (var ix in indexes)
+ {
+ if (ix.IsDisabled)
+ sb.AppendLine("-- ** DISABLED **");
+
+ // Usage stats as a comment
+ sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}");
+
+ var withOptions = BuildWithOptions(ix);
+
+ var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null
+ ? $"ON {BracketName(ix.PartitionScheme)}({BracketName(ix.PartitionColumn)})"
+ : null;
+
+ if (ix.IsPrimaryKey)
+ {
+ var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED" : "NONCLUSTERED";
+ sb.AppendLine($"ALTER TABLE {objectName}");
+ sb.AppendLine($"ADD CONSTRAINT {BracketName(ix.IndexName)}");
+ sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})");
+ if (withOptions.Count > 0)
+ {
+ sb.AppendLine();
+ sb.Append($" WITH ({string.Join(", ", withOptions)})");
+ }
+ if (onPartition != null)
+ {
+ sb.AppendLine();
+ sb.Append($" {onPartition}");
+ }
+ sb.AppendLine(";");
+ }
+ else if (IsColumnstore(ix))
+ {
+ // Columnstore indexes: no key columns, no INCLUDE, no row/page lock or compression options
+ var clustered = ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "NONCLUSTERED " : "CLUSTERED ";
+ sb.Append($"CREATE {clustered}COLUMNSTORE INDEX {BracketName(ix.IndexName)}");
+ sb.AppendLine($" ON {objectName}");
+
+ // Nonclustered columnstore can have a column list
+ if (ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrEmpty(ix.KeyColumns))
+ {
+ sb.AppendLine($"({ix.KeyColumns})");
+ }
+
+ // Only emit non-default options that aren't inherent to columnstore
+ var csOptions = BuildColumnstoreWithOptions(ix);
+ if (csOptions.Count > 0)
+ sb.AppendLine($"WITH ({string.Join(", ", csOptions)})");
+
+ if (onPartition != null)
+ sb.AppendLine(onPartition);
+
+ // Remove trailing newline before semicolon
+ if (sb[sb.Length - 1] == '\n') sb.Length--;
+ if (sb[sb.Length - 1] == '\r') sb.Length--;
+ sb.AppendLine(";");
+ }
+ else
+ {
+ var unique = ix.IsUnique ? "UNIQUE " : "";
+ var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.Append($"CREATE {unique}{clustered}INDEX {BracketName(ix.IndexName)}");
+ sb.AppendLine($" ON {objectName}");
+ sb.Append($"(");
+ sb.Append(ix.KeyColumns);
+ sb.AppendLine(")");
+
+ if (!string.IsNullOrEmpty(ix.IncludeColumns))
+ sb.AppendLine($"INCLUDE ({ix.IncludeColumns})");
+
+ if (!string.IsNullOrEmpty(ix.FilterDefinition))
+ sb.AppendLine($"WHERE {ix.FilterDefinition}");
+
+ if (withOptions.Count > 0)
+ sb.AppendLine($"WITH ({string.Join(", ", withOptions)})");
+
+ if (onPartition != null)
+ sb.AppendLine(onPartition);
+
+ // Remove trailing newline before semicolon
+ if (sb[sb.Length - 1] == '\n') sb.Length--;
+ if (sb[sb.Length - 1] == '\r') sb.Length--;
+ sb.AppendLine(";");
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool IsColumnstore(IndexInfo ix) =>
+ ix.IndexType.Contains("COLUMNSTORE", System.StringComparison.OrdinalIgnoreCase);
+
+ private static List BuildWithOptions(IndexInfo ix)
+ {
+ var options = new List();
+
+ if (ix.FillFactor > 0 && ix.FillFactor != 100)
+ options.Add($"FILLFACTOR = {ix.FillFactor}");
+ if (ix.IsPadded)
+ options.Add("PAD_INDEX = ON");
+ if (!ix.AllowRowLocks)
+ options.Add("ALLOW_ROW_LOCKS = OFF");
+ if (!ix.AllowPageLocks)
+ options.Add("ALLOW_PAGE_LOCKS = OFF");
+ if (!string.Equals(ix.DataCompression, "NONE", System.StringComparison.OrdinalIgnoreCase))
+ options.Add($"DATA_COMPRESSION = {ix.DataCompression}");
+
+ return options;
+ }
+
+ ///
+ /// For columnstore indexes, skip options that are inherent to the storage format
+ /// (row/page locks are always OFF, compression is always COLUMNSTORE).
+ /// Only emit fill factor and pad index if non-default.
+ ///
+ private static List BuildColumnstoreWithOptions(IndexInfo ix)
+ {
+ var options = new List();
+
+ if (ix.FillFactor > 0 && ix.FillFactor != 100)
+ options.Add($"FILLFACTOR = {ix.FillFactor}");
+ if (ix.IsPadded)
+ options.Add("PAD_INDEX = ON");
+
+ return options;
+ }
+
+ private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes)
+ {
+ if (columns.Count == 0)
+ return $"-- No columns found for {objectName}";
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"CREATE TABLE {objectName}");
+ sb.AppendLine("(");
+
+ for (int i = 0; i < columns.Count; i++)
+ {
+ var col = columns[i];
+ var isLast = i == columns.Count - 1;
+
+ sb.Append($" {BracketName(col.ColumnName)} ");
+
+ if (col.IsComputed && col.ComputedDefinition != null)
+ {
+ sb.Append($"AS {col.ComputedDefinition}");
+ }
+ else
+ {
+ sb.Append(col.DataType);
+
+ if (col.IsIdentity)
+ sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})");
+
+ sb.Append(col.IsNullable ? " NULL" : " NOT NULL");
+
+ if (col.DefaultValue != null)
+ sb.Append($" DEFAULT {col.DefaultValue}");
+ }
+
+ // Check if we need a PK constraint after all columns
+ var pk = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
+ var needsTrailingComma = !isLast || pk != null;
+
+ sb.AppendLine(needsTrailingComma ? "," : "");
+ }
+
+ // Add PK constraint
+ var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
+ if (pkIndex != null)
+ {
+ var clustered = pkIndex.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !pkIndex.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.AppendLine($" CONSTRAINT {BracketName(pkIndex.IndexName)}");
+ sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})");
+ var pkOptions = BuildWithOptions(pkIndex);
+ if (pkOptions.Count > 0)
+ {
+ sb.AppendLine();
+ sb.Append($" WITH ({string.Join(", ", pkOptions)})");
+ }
+ sb.AppendLine();
+ }
+
+ sb.Append(")");
+
+ // Add partition scheme from the clustered index (determines table storage)
+ var clusteredIx = indexes.FirstOrDefault(ix =>
+ ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase));
+ if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null)
+ {
+ sb.AppendLine();
+ sb.Append($"ON {BracketName(clusteredIx.PartitionScheme)}({BracketName(clusteredIx.PartitionColumn)})");
+ }
+
+ sb.AppendLine(";");
+
+ return sb.ToString();
+ }
+
+ private static string BracketName(string name)
+ {
+ // Already bracketed
+ if (name.StartsWith('['))
+ return name;
+ return $"[{name}]";
}
private void OnKeyDown(object? sender, KeyEventArgs e)
diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj
index 1a3c43a..9363afb 100644
--- a/src/PlanViewer.Core/PlanViewer.Core.csproj
+++ b/src/PlanViewer.Core/PlanViewer.Core.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/PlanViewer.Core/Services/SchemaQueryService.cs b/src/PlanViewer.Core/Services/SchemaQueryService.cs
new file mode 100644
index 0000000..2b146e4
--- /dev/null
+++ b/src/PlanViewer.Core/Services/SchemaQueryService.cs
@@ -0,0 +1,237 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Data.SqlClient;
+
+namespace PlanViewer.Core.Services;
+
+public sealed class IndexInfo
+{
+ public required string IndexName { get; init; }
+ public required string IndexType { get; init; }
+ public required bool IsUnique { get; init; }
+ public required bool IsPrimaryKey { get; init; }
+ public required string KeyColumns { get; init; }
+ public required string IncludeColumns { get; init; }
+ public required string? FilterDefinition { get; init; }
+ public required long RowCount { get; init; }
+ public required double SizeMB { get; init; }
+ public long UserSeeks { get; init; }
+ public long UserScans { get; init; }
+ public long UserLookups { get; init; }
+ public long UserUpdates { get; init; }
+ public int FillFactor { get; init; }
+ public bool IsPadded { get; init; }
+ public bool AllowRowLocks { get; init; } = true;
+ public bool AllowPageLocks { get; init; } = true;
+ public bool IsDisabled { get; init; }
+ public required string DataCompression { get; init; }
+ public string? PartitionScheme { get; init; }
+ public string? PartitionColumn { get; init; }
+}
+
+public sealed class ColumnInfo
+{
+ public required int OrdinalPosition { get; init; }
+ public required string ColumnName { get; init; }
+ public required string DataType { get; init; }
+ public required bool IsNullable { get; init; }
+ public required bool IsIdentity { get; init; }
+ public required bool IsComputed { get; init; }
+ public required string? DefaultValue { get; init; }
+ public required string? ComputedDefinition { get; init; }
+ public required long IdentitySeed { get; init; }
+ public required long IdentityIncrement { get; init; }
+}
+
+///
+/// Fetches schema information (indexes, columns, object definitions) from a connected SQL Server.
+///
+public static class SchemaQueryService
+{
+ private const string IndexQuery = @"
+SELECT
+ i.name AS index_name,
+ i.type_desc AS index_type,
+ i.is_unique,
+ i.is_primary_key,
+ STUFF((
+ SELECT ', ' + c.name + CASE WHEN ic2.is_descending_key = 1 THEN ' DESC' ELSE '' END
+ FROM sys.index_columns AS ic2
+ JOIN sys.columns AS c ON c.object_id = ic2.object_id AND c.column_id = ic2.column_id
+ WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 0
+ ORDER BY ic2.key_ordinal
+ FOR XML PATH('')
+ ), 1, 2, '') AS key_columns,
+ ISNULL(STUFF((
+ SELECT ', ' + c.name
+ FROM sys.index_columns AS ic2
+ JOIN sys.columns AS c ON c.object_id = ic2.object_id AND c.column_id = ic2.column_id
+ WHERE ic2.object_id = i.object_id AND ic2.index_id = i.index_id AND ic2.is_included_column = 1
+ ORDER BY c.name
+ FOR XML PATH('')
+ ), 1, 2, ''), '') AS include_columns,
+ i.filter_definition,
+ p.row_count,
+ CAST(ROUND(p.reserved_page_count * 8.0 / 1024, 2) AS float) AS size_mb,
+ ISNULL(us.user_seeks, 0) AS user_seeks,
+ ISNULL(us.user_scans, 0) AS user_scans,
+ ISNULL(us.user_lookups, 0) AS user_lookups,
+ ISNULL(us.user_updates, 0) AS user_updates,
+ CAST(i.fill_factor AS int),
+ i.is_padded,
+ i.allow_row_locks,
+ i.allow_page_locks,
+ i.is_disabled,
+ ISNULL(p.data_compression_desc, 'NONE') AS data_compression,
+ psch.name AS partition_scheme,
+ pc.name AS partition_column
+FROM sys.indexes AS i
+CROSS APPLY (
+ SELECT SUM(ps.row_count) AS row_count,
+ SUM(ps.reserved_page_count) AS reserved_page_count,
+ MAX(pt.data_compression_desc) AS data_compression_desc
+ FROM sys.dm_db_partition_stats AS ps
+ JOIN sys.partitions AS pt ON pt.partition_id = ps.partition_id
+ WHERE ps.object_id = i.object_id AND ps.index_id = i.index_id
+) AS p
+LEFT JOIN sys.dm_db_index_usage_stats AS us
+ ON us.object_id = i.object_id AND us.index_id = i.index_id AND us.database_id = DB_ID()
+LEFT JOIN sys.partition_schemes AS psch
+ ON psch.data_space_id = i.data_space_id
+LEFT JOIN sys.index_columns AS pic
+ ON pic.object_id = i.object_id AND pic.index_id = i.index_id AND pic.partition_ordinal > 0
+LEFT JOIN sys.columns AS pc
+ ON pc.object_id = pic.object_id AND pc.column_id = pic.column_id
+WHERE i.object_id = OBJECT_ID(@objectName)
+ AND i.type > 0
+ORDER BY i.index_id;";
+
+ private const string ColumnQuery = @"
+SELECT
+ c.column_id AS ordinal_position,
+ c.name AS column_name,
+ tp.name +
+ CASE
+ WHEN tp.name IN ('varchar','nvarchar','char','nchar','binary','varbinary')
+ THEN '(' + CASE WHEN c.max_length = -1 THEN 'max' ELSE CAST(
+ CASE WHEN tp.name IN ('nvarchar','nchar') THEN c.max_length / 2 ELSE c.max_length END
+ AS varchar) END + ')'
+ WHEN tp.name IN ('decimal','numeric')
+ THEN '(' + CAST(c.precision AS varchar) + ',' + CAST(c.scale AS varchar) + ')'
+ WHEN tp.name IN ('datetime2','datetimeoffset','time')
+ THEN '(' + CAST(c.scale AS varchar) + ')'
+ ELSE ''
+ END AS data_type,
+ c.is_nullable,
+ c.is_identity,
+ c.is_computed,
+ dc.definition AS default_value,
+ cc.definition AS computed_definition,
+ CAST(ISNULL(ic.seed_value, 0) AS bigint) AS identity_seed,
+ CAST(ISNULL(ic.increment_value, 0) AS bigint) AS identity_increment
+FROM sys.columns AS c
+JOIN sys.types AS tp ON tp.user_type_id = c.user_type_id
+LEFT JOIN sys.default_constraints AS dc ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
+LEFT JOIN sys.computed_columns AS cc ON cc.object_id = c.object_id AND cc.column_id = c.column_id
+LEFT JOIN sys.identity_columns AS ic ON ic.object_id = c.object_id AND ic.column_id = c.column_id
+WHERE c.object_id = OBJECT_ID(@objectName)
+ORDER BY c.column_id;";
+
+ private const string ObjectDefinitionQuery = @"
+SELECT OBJECT_DEFINITION(OBJECT_ID(@objectName));";
+
+ public static async Task> FetchIndexesAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ var results = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(IndexQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ results.Add(new IndexInfo
+ {
+ IndexName = reader.GetString(0),
+ IndexType = reader.GetString(1),
+ IsUnique = reader.GetBoolean(2),
+ IsPrimaryKey = reader.GetBoolean(3),
+ KeyColumns = reader.IsDBNull(4) ? "" : reader.GetString(4),
+ IncludeColumns = reader.IsDBNull(5) ? "" : reader.GetString(5),
+ FilterDefinition = reader.IsDBNull(6) ? null : reader.GetString(6),
+ RowCount = reader.GetInt64(7),
+ SizeMB = reader.GetDouble(8),
+ UserSeeks = reader.GetInt64(9),
+ UserScans = reader.GetInt64(10),
+ UserLookups = reader.GetInt64(11),
+ UserUpdates = reader.GetInt64(12),
+ FillFactor = reader.GetInt32(13),
+ IsPadded = reader.GetBoolean(14),
+ AllowRowLocks = reader.GetBoolean(15),
+ AllowPageLocks = reader.GetBoolean(16),
+ IsDisabled = reader.GetBoolean(17),
+ DataCompression = reader.GetString(18),
+ PartitionScheme = reader.IsDBNull(19) ? null : reader.GetString(19),
+ PartitionColumn = reader.IsDBNull(20) ? null : reader.GetString(20)
+ });
+ }
+
+ return results;
+ }
+
+ public static async Task> FetchColumnsAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ var results = new List();
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(ColumnQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+
+ while (await reader.ReadAsync(ct))
+ {
+ results.Add(new ColumnInfo
+ {
+ OrdinalPosition = reader.GetInt32(0),
+ ColumnName = reader.GetString(1),
+ DataType = reader.GetString(2),
+ IsNullable = reader.GetBoolean(3),
+ IsIdentity = reader.GetBoolean(4),
+ IsComputed = reader.GetBoolean(5),
+ DefaultValue = reader.IsDBNull(6) ? null : reader.GetString(6),
+ ComputedDefinition = reader.IsDBNull(7) ? null : reader.GetString(7),
+ IdentitySeed = reader.GetInt64(8),
+ IdentityIncrement = reader.GetInt64(9)
+ });
+ }
+
+ return results;
+ }
+
+ public static async Task FetchObjectDefinitionAsync(
+ string connectionString, string objectName, CancellationToken ct = default)
+ {
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync(ct);
+
+ await using var cmd = new SqlCommand(ObjectDefinitionQuery, conn);
+ cmd.CommandTimeout = 10;
+ cmd.Parameters.AddWithValue("@objectName", objectName);
+
+ var result = await cmd.ExecuteScalarAsync(ct);
+
+ return result as string;
+ }
+}
diff --git a/src/PlanViewer.Core/Services/SqlObjectResolver.cs b/src/PlanViewer.Core/Services/SqlObjectResolver.cs
new file mode 100644
index 0000000..07b402d
--- /dev/null
+++ b/src/PlanViewer.Core/Services/SqlObjectResolver.cs
@@ -0,0 +1,248 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Microsoft.SqlServer.TransactSql.ScriptDom;
+
+namespace PlanViewer.Core.Services;
+
+///
+/// The kind of SQL object found at a cursor position.
+///
+public enum SqlObjectKind
+{
+ Table,
+ View,
+ Function,
+ Procedure,
+ Unknown
+}
+
+///
+/// Represents a resolved SQL object at a specific cursor position.
+///
+public sealed class ResolvedSqlObject
+{
+ public required string SchemaName { get; init; }
+ public required string ObjectName { get; init; }
+ public required SqlObjectKind Kind { get; init; }
+
+ ///
+ /// Fully qualified [schema].[object] name.
+ ///
+ public string FullName => string.IsNullOrEmpty(SchemaName)
+ ? ObjectName
+ : $"{SchemaName}.{ObjectName}";
+}
+
+///
+/// Parses T-SQL text using ScriptDom and resolves what object is at a given cursor offset.
+///
+public static class SqlObjectResolver
+{
+ ///
+ /// Resolve the SQL object at the given zero-based character offset in the SQL text.
+ /// Returns null if no recognizable object is at that position.
+ ///
+ public static ResolvedSqlObject? Resolve(string sqlText, int offset)
+ {
+ if (string.IsNullOrWhiteSpace(sqlText) || offset < 0 || offset >= sqlText.Length)
+ return null;
+
+ var parser = new TSql160Parser(initialQuotedIdentifiers: false);
+
+ using var reader = new StringReader(sqlText);
+ var fragment = parser.Parse(reader, out var errors);
+
+ if (fragment == null)
+ return null;
+
+ var visitor = new ObjectAtOffsetVisitor(offset);
+ fragment.Accept(visitor);
+
+ return visitor.Result;
+ }
+
+ ///
+ /// Resolve all distinct SQL objects referenced in the text.
+ ///
+ public static IReadOnlyList ResolveAll(string sqlText)
+ {
+ if (string.IsNullOrWhiteSpace(sqlText))
+ return Array.Empty();
+
+ var parser = new TSql160Parser(initialQuotedIdentifiers: false);
+
+ using var reader = new StringReader(sqlText);
+ var fragment = parser.Parse(reader, out var errors);
+
+ if (fragment == null)
+ return Array.Empty();
+
+ var visitor = new AllObjectsVisitor();
+ fragment.Accept(visitor);
+
+ return visitor.Results;
+ }
+
+ ///
+ /// Walks the AST to find the object reference whose token span covers the given offset.
+ ///
+ private sealed class ObjectAtOffsetVisitor : TSqlFragmentVisitor
+ {
+ private readonly int _offset;
+
+ public ResolvedSqlObject? Result { get; private set; }
+
+ public ObjectAtOffsetVisitor(int offset) => _offset = offset;
+
+ public override void Visit(NamedTableReference node)
+ {
+ if (Covers(node.SchemaObject, _offset))
+ {
+ Result = FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Table);
+ }
+ }
+
+ public override void Visit(FunctionCall node)
+ {
+ if (node.CallTarget is MultiPartIdentifierCallTarget target)
+ {
+ if (CoversIdentifier(target.MultiPartIdentifier, _offset) ||
+ CoversToken(node.FunctionName, _offset))
+ {
+ Result = FromFunctionCall(target.MultiPartIdentifier, node.FunctionName.Value, SqlObjectKind.Function);
+ }
+ }
+ else if (CoversToken(node.FunctionName, _offset))
+ {
+ // Standalone function name — could be a scalar UDF or built-in
+ Result = new ResolvedSqlObject
+ {
+ SchemaName = "",
+ ObjectName = node.FunctionName.Value,
+ Kind = SqlObjectKind.Function
+ };
+ }
+ }
+
+ public override void Visit(SchemaObjectFunctionTableReference node)
+ {
+ if (Covers(node.SchemaObject, _offset))
+ {
+ Result = FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Function);
+ }
+ }
+
+ public override void Visit(ExecutableProcedureReference node)
+ {
+ if (node.ProcedureReference?.ProcedureReference != null &&
+ Covers(node.ProcedureReference.ProcedureReference.Name, _offset))
+ {
+ Result = FromSchemaObjectName(
+ node.ProcedureReference.ProcedureReference.Name,
+ SqlObjectKind.Procedure);
+ }
+ }
+ }
+
+ ///
+ /// Collects all distinct object references in the SQL text.
+ ///
+ private sealed class AllObjectsVisitor : TSqlFragmentVisitor
+ {
+ private readonly HashSet _seen = new(StringComparer.OrdinalIgnoreCase);
+ private readonly List _results = new();
+
+ public IReadOnlyList Results => _results;
+
+ public override void Visit(NamedTableReference node)
+ {
+ Add(FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Table));
+ }
+
+ public override void Visit(SchemaObjectFunctionTableReference node)
+ {
+ Add(FromSchemaObjectName(node.SchemaObject, SqlObjectKind.Function));
+ }
+
+ public override void Visit(ExecutableProcedureReference node)
+ {
+ if (node.ProcedureReference?.ProcedureReference?.Name != null)
+ {
+ Add(FromSchemaObjectName(
+ node.ProcedureReference.ProcedureReference.Name,
+ SqlObjectKind.Procedure));
+ }
+ }
+
+ private void Add(ResolvedSqlObject obj)
+ {
+ if (_seen.Add(obj.FullName))
+ _results.Add(obj);
+ }
+ }
+
+ // --- Helpers ---
+
+ private static ResolvedSqlObject FromSchemaObjectName(SchemaObjectName name, SqlObjectKind kind)
+ {
+ return new ResolvedSqlObject
+ {
+ SchemaName = name.SchemaIdentifier?.Value ?? "",
+ ObjectName = name.BaseIdentifier?.Value ?? "",
+ Kind = kind
+ };
+ }
+
+ private static ResolvedSqlObject FromFunctionCall(
+ MultiPartIdentifier identifier, string functionName, SqlObjectKind kind)
+ {
+ // e.g., dbo.MyFunction() → schema = "dbo", object = "MyFunction"
+ var parts = identifier.Identifiers;
+ var schema = parts.Count > 0 ? parts[parts.Count - 1].Value : "";
+
+ return new ResolvedSqlObject
+ {
+ SchemaName = schema,
+ ObjectName = functionName,
+ Kind = kind
+ };
+ }
+
+ ///
+ /// Check if a SchemaObjectName's token range covers the given offset.
+ ///
+ private static bool Covers(SchemaObjectName name, int offset)
+ {
+ if (name == null) return false;
+
+ int start = name.StartOffset;
+ int end = start + name.FragmentLength;
+ return offset >= start && offset < end;
+ }
+
+ ///
+ /// Check if a MultiPartIdentifier's token range covers the given offset.
+ ///
+ private static bool CoversIdentifier(MultiPartIdentifier identifier, int offset)
+ {
+ if (identifier == null) return false;
+
+ int start = identifier.StartOffset;
+ int end = start + identifier.FragmentLength;
+ return offset >= start && offset < end;
+ }
+
+ ///
+ /// Check if a single Identifier token covers the given offset.
+ ///
+ private static bool CoversToken(Identifier token, int offset)
+ {
+ if (token == null) return false;
+
+ int start = token.StartOffset;
+ int end = start + token.FragmentLength;
+ return offset >= start && offset < end;
+ }
+}
diff --git a/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs b/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs
new file mode 100644
index 0000000..adcfa83
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/SqlObjectResolverTests.cs
@@ -0,0 +1,143 @@
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.Core.Tests;
+
+public class SqlObjectResolverTests
+{
+ [Fact]
+ public void Resolve_TableInSelect_ReturnsTable()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ // ^--- offset 14 = start of "dbo.Posts"
+ var offset = sql.IndexOf("dbo.Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("dbo", result.SchemaName);
+ Assert.Equal("Posts", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_TableInJoin_ReturnsTable()
+ {
+ var sql = "SELECT p.Id FROM dbo.Posts AS p JOIN dbo.Users AS u ON p.OwnerUserId = u.Id";
+ var offset = sql.IndexOf("dbo.Users");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("Users", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_ClickOnObjectName_ReturnsTable()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ // Click on "Posts" part specifically
+ var offset = sql.IndexOf("Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("Posts", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Table, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_UnqualifiedTable_ReturnsEmptySchema()
+ {
+ var sql = "SELECT * FROM Posts";
+ var offset = sql.IndexOf("Posts");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("", result.SchemaName);
+ Assert.Equal("Posts", result.ObjectName);
+ }
+
+ [Fact]
+ public void Resolve_ClickOnKeyword_ReturnsNull()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ var offset = sql.IndexOf("SELECT");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Resolve_Procedure_ReturnsProcedure()
+ {
+ var sql = "EXEC dbo.sp_MyProc @param = 1";
+ var offset = sql.IndexOf("dbo.sp_MyProc");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("sp_MyProc", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Procedure, result.Kind);
+ }
+
+ [Fact]
+ public void Resolve_TableValuedFunction_ReturnsFunction()
+ {
+ var sql = "SELECT * FROM dbo.MyFunction(1, 2)";
+ var offset = sql.IndexOf("dbo.MyFunction");
+
+ var result = SqlObjectResolver.Resolve(sql, offset);
+
+ Assert.NotNull(result);
+ Assert.Equal("MyFunction", result.ObjectName);
+ Assert.Equal(SqlObjectKind.Function, result.Kind);
+ }
+
+ [Fact]
+ public void ResolveAll_MultipleObjects_ReturnsAll()
+ {
+ var sql = @"
+SELECT p.Id, u.DisplayName
+FROM dbo.Posts AS p
+JOIN dbo.Users AS u ON p.OwnerUserId = u.Id
+WHERE p.PostTypeId = 1";
+
+ var results = SqlObjectResolver.ResolveAll(sql);
+
+ Assert.Equal(2, results.Count);
+ Assert.Contains(results, r => r.ObjectName == "Posts");
+ Assert.Contains(results, r => r.ObjectName == "Users");
+ }
+
+ [Fact]
+ public void ResolveAll_DuplicateTable_ReturnsDistinct()
+ {
+ var sql = @"
+SELECT * FROM dbo.Posts
+UNION ALL
+SELECT * FROM dbo.Posts";
+
+ var results = SqlObjectResolver.ResolveAll(sql);
+
+ Assert.Single(results);
+ Assert.Equal("Posts", results[0].ObjectName);
+ }
+
+ [Fact]
+ public void Resolve_EmptyText_ReturnsNull()
+ {
+ Assert.Null(SqlObjectResolver.Resolve("", 0));
+ Assert.Null(SqlObjectResolver.Resolve(" ", 0));
+ }
+
+ [Fact]
+ public void Resolve_InvalidOffset_ReturnsNull()
+ {
+ var sql = "SELECT * FROM dbo.Posts";
+ Assert.Null(SqlObjectResolver.Resolve(sql, -1));
+ Assert.Null(SqlObjectResolver.Resolve(sql, sql.Length));
+ }
+}
From 1a849ddb8217a0d14a082238046d6869c2bf7efc Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 6 Apr 2026 08:51:20 +0200
Subject: [PATCH 16/24] =?UTF-8?q?PlanScrollViewer=5FPointerWheelChanged=20?=
=?UTF-8?q?=E2=80=94=20now=20a=204-line=20method=20that=20just=20calls=20S?=
=?UTF-8?q?etZoomAtPoint=20with=20the=20mouse=20position.=20No=20duplicate?=
=?UTF-8?q?d=20zoom=20logic.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controls/PlanViewerControl.axaml.cs | 55 ++++++++++---------
1 file changed, 28 insertions(+), 27 deletions(-)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 106b4b3..a475b2d 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -2920,39 +2920,40 @@ private void SetZoom(double level)
ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
}
- private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ ///
+ /// Sets the zoom level and adjusts the scroll offset so that the content point
+ /// under stays fixed in the viewport.
+ ///
+ private void SetZoomAtPoint(double level, Point viewportAnchor)
{
- if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
- {
- e.Handled = true;
-
- var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom,
- _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep)));
-
- if (Math.Abs(newZoom - _zoomLevel) < 0.001)
- return;
+ var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level));
+ if (Math.Abs(newZoom - _zoomLevel) < 0.001)
+ return;
- // Mouse position relative to the ScrollViewer viewport
- var mouseInView = e.GetPosition(PlanScrollViewer);
+ // Content point under the anchor at the current zoom level
+ var contentX = (PlanScrollViewer.Offset.X + viewportAnchor.X) / _zoomLevel;
+ var contentY = (PlanScrollViewer.Offset.Y + viewportAnchor.Y) / _zoomLevel;
- // Content point under the mouse at the current zoom level
- var contentX = (PlanScrollViewer.Offset.X + mouseInView.X) / _zoomLevel;
- var contentY = (PlanScrollViewer.Offset.Y + mouseInView.Y) / _zoomLevel;
+ // Apply the new zoom
+ SetZoom(newZoom);
- // Apply the new zoom
- _zoomLevel = newZoom;
- _zoomTransform.ScaleX = _zoomLevel;
- _zoomTransform.ScaleY = _zoomLevel;
- ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
+ // Adjust offset so the same content point stays under the anchor
+ var newOffsetX = Math.Max(0, contentX * _zoomLevel - viewportAnchor.X);
+ var newOffsetY = Math.Max(0, contentY * _zoomLevel - viewportAnchor.Y);
- // Adjust offset so the same content point stays under the mouse
- var newOffsetX = Math.Max(0, contentX * _zoomLevel - mouseInView.X);
- var newOffsetY = Math.Max(0, contentY * _zoomLevel - mouseInView.Y);
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
+ });
+ }
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
- });
+ private void PlanScrollViewer_PointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ if (e.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ e.Handled = true;
+ var newLevel = _zoomLevel + (e.Delta.Y > 0 ? ZoomStep : -ZoomStep);
+ SetZoomAtPoint(newLevel, e.GetPosition(PlanScrollViewer));
}
}
From f1e94dc02e410f33083f12f51f303fa87ca263b9 Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 6 Apr 2026 08:57:42 +0200
Subject: [PATCH 17/24] Future changes to the advice window (styling, behavior,
new features) now only need to touch AdviceWindowHelper.cs.
---
.../Controls/QuerySessionControl.axaml.cs | 98 +-------------
src/PlanViewer.App/MainWindow.axaml.cs | 98 +-------------
.../Services/AdviceWindowHelper.cs | 128 ++++++++++++++++++
3 files changed, 130 insertions(+), 194 deletions(-)
create mode 100644 src/PlanViewer.App/Services/AdviceWindowHelper.cs
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index 6af2ad9..9b2eb1f 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -705,103 +705,7 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- Action? onNodeClick = sourceViewer != null
- ? nodeId => sourceViewer.NavigateToNode(nodeId)
- : null;
- var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
-
- var scrollViewer = new ScrollViewer
- {
- Content = styledContent,
- HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
- VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
- };
-
- var copyBtn = new Button
- {
- Content = "Copy to Clipboard",
- Height = 32,
- Padding = new Avalonia.Thickness(16, 0),
- FontSize = 12,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- VerticalContentAlignment = VerticalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- var closeBtn = new Button
- {
- Content = "Close",
- Height = 32,
- 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,
- Margin = new Avalonia.Thickness(0, 8, 0, 0)
- };
- buttonPanel.Children.Add(copyBtn);
- buttonPanel.Children.Add(closeBtn);
-
- var scaleTransform = new ScaleTransform(1, 1);
- var layoutTransform = new LayoutTransformControl
- {
- LayoutTransform = scaleTransform,
- Child = scrollViewer
- };
-
- var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(layoutTransform);
-
- var window = new Window
- {
- Title = $"Performance Studio — {title}",
- Width = 700,
- Height = 600,
- MinWidth = 400,
- MinHeight = 300,
- Icon = GetParentWindow().Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = panel
- };
-
- double adviceZoom = 1.0;
- window.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, (_, args) =>
- {
- if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
- {
- args.Handled = true;
- adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
- adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
- scaleTransform.ScaleX = adviceZoom;
- scaleTransform.ScaleY = adviceZoom;
- }
- }, Avalonia.Interactivity.RoutingStrategies.Tunnel);
-
- copyBtn.Click += async (_, _) =>
- {
- var clipboard = window.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(content);
- copyBtn.Content = "Copied!";
- await Task.Delay(1500);
- copyBtn.Content = "Copy to Clipboard";
- }
- };
-
- closeBtn.Click += (_, _) => window.Close();
-
- window.Show(GetParentWindow());
+ AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer);
}
private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null)
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 8ec91d5..0a055f9 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -596,103 +596,7 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
{
- Action? onNodeClick = sourceViewer != null
- ? nodeId => sourceViewer.NavigateToNode(nodeId)
- : null;
- var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
-
- var scrollViewer = new ScrollViewer
- {
- Content = styledContent,
- HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
- VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
- };
-
- var copyBtn = new Button
- {
- Content = "Copy to Clipboard",
- Height = 32,
- Padding = new Avalonia.Thickness(16, 0),
- FontSize = 12,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- VerticalContentAlignment = VerticalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- var closeBtn = new Button
- {
- Content = "Close",
- Height = 32,
- 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,
- Margin = new Avalonia.Thickness(0, 8, 0, 0)
- };
- buttonPanel.Children.Add(copyBtn);
- buttonPanel.Children.Add(closeBtn);
-
- var scaleTransform = new ScaleTransform(1, 1);
- var layoutTransform = new LayoutTransformControl
- {
- LayoutTransform = scaleTransform,
- Child = scrollViewer
- };
-
- var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
- DockPanel.SetDock(buttonPanel, Dock.Bottom);
- panel.Children.Add(buttonPanel);
- panel.Children.Add(layoutTransform);
-
- var window = new Window
- {
- Title = $"Performance Studio — {title}",
- Width = 700,
- Height = 600,
- MinWidth = 400,
- MinHeight = 300,
- Icon = this.Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = panel
- };
-
- double adviceZoom = 1.0;
- window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) =>
- {
- if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
- {
- args.Handled = true;
- adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
- adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
- scaleTransform.ScaleX = adviceZoom;
- scaleTransform.ScaleY = adviceZoom;
- }
- }, RoutingStrategies.Tunnel);
-
- copyBtn.Click += async (_, _) =>
- {
- var clipboard = window.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(content);
- copyBtn.Content = "Copied!";
- await Task.Delay(1500);
- copyBtn.Content = "Copy to Clipboard";
- }
- };
-
- closeBtn.Click += (_, _) => window.Close();
-
- window.Show(this);
+ AdviceWindowHelper.Show(this, title, content, analysis, sourceViewer);
}
private List<(string label, PlanViewerControl viewer)> CollectAllPlanTabs()
diff --git a/src/PlanViewer.App/Services/AdviceWindowHelper.cs b/src/PlanViewer.App/Services/AdviceWindowHelper.cs
new file mode 100644
index 0000000..372fd15
--- /dev/null
+++ b/src/PlanViewer.App/Services/AdviceWindowHelper.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Controls;
+using PlanViewer.Core.Output;
+
+namespace PlanViewer.App.Services;
+
+///
+/// Creates and shows the Advice for Humans / Robots popup window.
+/// Shared between MainWindow (file mode) and QuerySessionControl (query mode).
+///
+internal static class AdviceWindowHelper
+{
+ public static void Show(
+ Window owner,
+ string title,
+ string content,
+ AnalysisResult? analysis = null,
+ PlanViewerControl? sourceViewer = null)
+ {
+ Action? onNodeClick = sourceViewer != null
+ ? nodeId => sourceViewer.NavigateToNode(nodeId)
+ : null;
+ var styledContent = AdviceContentBuilder.Build(content, analysis, onNodeClick);
+
+ var scrollViewer = new ScrollViewer
+ {
+ Content = styledContent,
+ HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto
+ };
+
+ var buttonTheme = (Avalonia.Styling.ControlTheme)owner.FindResource("AppButton")!;
+
+ var copyBtn = new Button
+ {
+ Content = "Copy to Clipboard",
+ Height = 32,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = buttonTheme
+ };
+
+ var closeBtn = new Button
+ {
+ Content = "Close",
+ Height = 32,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = buttonTheme
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Avalonia.Thickness(0, 8, 0, 0)
+ };
+ buttonPanel.Children.Add(copyBtn);
+ buttonPanel.Children.Add(closeBtn);
+
+ // Wrap in LayoutTransformControl for Ctrl+Wheel font scaling
+ var scaleTransform = new ScaleTransform(1, 1);
+ var layoutTransform = new LayoutTransformControl
+ {
+ LayoutTransform = scaleTransform,
+ Child = scrollViewer
+ };
+
+ var panel = new DockPanel { Margin = new Avalonia.Thickness(12) };
+ DockPanel.SetDock(buttonPanel, Dock.Bottom);
+ panel.Children.Add(buttonPanel);
+ panel.Children.Add(layoutTransform);
+
+ var window = new Window
+ {
+ Title = $"Performance Studio \u2014 {title}",
+ Width = 700,
+ Height = 600,
+ MinWidth = 400,
+ MinHeight = 300,
+ Icon = owner.Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = panel
+ };
+
+ // Ctrl+MouseWheel to increase/decrease font size
+ double adviceZoom = 1.0;
+ window.AddHandler(InputElement.PointerWheelChangedEvent, (_, args) =>
+ {
+ if (args.KeyModifiers.HasFlag(KeyModifiers.Control))
+ {
+ args.Handled = true;
+ adviceZoom += args.Delta.Y > 0 ? 0.1 : -0.1;
+ adviceZoom = Math.Max(0.5, Math.Min(3.0, adviceZoom));
+ scaleTransform.ScaleX = adviceZoom;
+ scaleTransform.ScaleY = adviceZoom;
+ }
+ }, RoutingStrategies.Tunnel);
+
+ copyBtn.Click += async (_, _) =>
+ {
+ var clipboard = window.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(content);
+ copyBtn.Content = "Copied!";
+ await Task.Delay(1500);
+ copyBtn.Content = "Copy to Clipboard";
+ }
+ };
+
+ closeBtn.Click += (_, _) => window.Close();
+
+ window.Show(owner);
+ }
+}
From 3c715e37bd37892933d25596d91176c42e4f1bde Mon Sep 17 00:00:00 2001
From: rferraton <16419423+rferraton@users.noreply.github.com>
Date: Mon, 6 Apr 2026 09:11:08 +0200
Subject: [PATCH 18/24] The Hand cursor is now a single cached static field
(HandCursor) allocated once, used on every PointerMoved hover instead of
creating a new Cursor object per frame.
---
src/PlanViewer.App/Services/AdviceContentBuilder.cs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index a1b6b9e..60c971a 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -70,6 +70,7 @@ internal static class AdviceContentBuilder
private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled);
private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7"));
+ private static readonly Avalonia.Input.Cursor HandCursor = new(Avalonia.Input.StandardCursorType.Hand);
public static StackPanel Build(string content)
{
@@ -663,7 +664,7 @@ private static void WireNodeClickHandler(SelectableTextBlock stb, Action on
{
stb.Cursor = run.TextDecorations == Avalonia.Media.TextDecorations.Underline
&& run.Foreground == LinkBrush
- ? new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand)
+ ? HandCursor
: Avalonia.Input.Cursor.Default;
return;
}
From 931aadf8eb8c2815a1fd0a66e7a14e0a55521d73 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 08:10:02 -0400
Subject: [PATCH 19/24] Add READ UNCOMMITTED isolation level to schema queries
Prevents schema lookup queries from blocking on busy servers.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/PlanViewer.Core/Services/SchemaQueryService.cs | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/PlanViewer.Core/Services/SchemaQueryService.cs b/src/PlanViewer.Core/Services/SchemaQueryService.cs
index 2b146e4..2105654 100644
--- a/src/PlanViewer.Core/Services/SchemaQueryService.cs
+++ b/src/PlanViewer.Core/Services/SchemaQueryService.cs
@@ -50,6 +50,7 @@ public sealed class ColumnInfo
public static class SchemaQueryService
{
private const string IndexQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
i.name AS index_name,
i.type_desc AS index_type,
@@ -108,6 +109,7 @@ AND i.type > 0
ORDER BY i.index_id;";
private const string ColumnQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT
c.column_id AS ordinal_position,
c.name AS column_name,
@@ -139,6 +141,7 @@ FROM sys.columns AS c
ORDER BY c.column_id;";
private const string ObjectDefinitionQuery = @"
+SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT OBJECT_DEFINITION(OBJECT_ID(@objectName));";
public static async Task> FetchIndexesAsync(
From 8a62e61ff6b544b47cdd970c7071c55deccb7278 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 09:37:55 -0400
Subject: [PATCH 20/24] Show nonclustered index count on modification operators
(#167)
Display a badge on INSERT/UPDATE/DELETE operators showing how many
nonclustered indexes are maintained. The clustered index or heap is
implicit; the NC count surfaces the hidden maintenance overhead.
- Parse Object elements with IndexKind="NonClustered" inside
Update/SimpleUpdate/CreateIndex operator elements
- Show "+N NC" badge on the operator node
- List index names in tooltip and properties panel
- Only counts on modification operators, not read operators
- 4 unit tests with INSERT, UPDATE, DELETE, and read-only plans
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../Controls/PlanViewerControl.axaml.cs | 33 ++++++++++-
src/PlanViewer.Core/Models/PlanModels.cs | 4 ++
.../Services/ShowPlanParser.cs | 16 ++++++
.../NonClusteredIndexCountTests.cs | 54 ++++++++++++++++++
.../Plans/multi_index_delete_plan.sqlplan | Bin 0 -> 9768 bytes
.../Plans/multi_index_insert_plan.sqlplan | Bin 0 -> 41956 bytes
.../Plans/multi_index_update_plan.sqlplan | Bin 0 -> 56612 bytes
7 files changed, 106 insertions(+), 1 deletion(-)
create mode 100644 tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs
create mode 100644 tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan
create mode 100644 tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan
create mode 100644 tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index a475b2d..debb21c 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -485,6 +485,27 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
iconRow.Children.Add(parBadge);
}
+ // Nonclustered index count badge (modification operators maintaining multiple NC indexes)
+ if (node.NonClusteredIndexCount > 0)
+ {
+ var ncBadge = new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x6C, 0x75, 0x7D)),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(4, 1),
+ Margin = new Thickness(4, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center,
+ Child = new TextBlock
+ {
+ Text = $"+{node.NonClusteredIndexCount} NC",
+ FontSize = 10,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = Brushes.White
+ }
+ };
+ iconRow.Children.Add(ncBadge);
+ }
+
stack.Children.Add(iconRow);
// Operator name
@@ -961,7 +982,7 @@ private void ShowPropertiesPanel(PlanNode node)
|| node.SortDistinct || node.StartupExpression
|| node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
|| node.WithTies || node.Remoting || node.LocalParallelism
- || node.SpoolStack || node.DMLRequestSort
+ || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0
|| !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0
|| !string.IsNullOrEmpty(node.ConstantScanValues)
|| !string.IsNullOrEmpty(node.UdxUsedColumns);
@@ -1010,6 +1031,12 @@ private void ShowPropertiesPanel(PlanNode node)
AddPropertyRow("Primary Node Id", $"{node.PrimaryNodeId}");
if (node.DMLRequestSort)
AddPropertyRow("DML Request Sort", "True");
+ if (node.NonClusteredIndexCount > 0)
+ {
+ AddPropertyRow("NC Indexes Maintained", $"{node.NonClusteredIndexCount}");
+ foreach (var ixName in node.NonClusteredIndexNames)
+ AddPropertyRow("", ixName, isCode: true);
+ }
if (!string.IsNullOrEmpty(node.ActionColumn))
AddPropertyRow("Action Column", node.ActionColumn, isCode: true);
if (!string.IsNullOrEmpty(node.SegmentColumn))
@@ -2025,6 +2052,10 @@ private object BuildNodeTooltipContent(PlanNode node, List? allWarn
AddTooltipRow(stack, "Scan Direction", node.ScanDirection);
}
+ // NC index maintenance count
+ if (node.NonClusteredIndexCount > 0)
+ AddTooltipRow(stack, "NC Indexes Maintained", string.Join(", ", node.NonClusteredIndexNames));
+
// Operator details (key items only in tooltip)
var hasTooltipDetails = !string.IsNullOrEmpty(node.OrderBy)
|| !string.IsNullOrEmpty(node.TopExpression)
diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs
index 0f15fbd..ec0201b 100644
--- a/src/PlanViewer.Core/Models/PlanModels.cs
+++ b/src/PlanViewer.Core/Models/PlanModels.cs
@@ -338,6 +338,10 @@ public class PlanNode
public int NoMatchingIndexCount { get; set; }
public int PartialMatchingIndexCount { get; set; }
+ // Modification operator: nonclustered indexes maintained
+ public int NonClusteredIndexCount { get; set; }
+ public List NonClusteredIndexNames { get; set; } = new();
+
// ConstantScan Values (parsed rows as displayable string)
public string? ConstantScanValues { get; set; }
diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs
index 637df45..5cf9f11 100644
--- a/src/PlanViewer.Core/Services/ShowPlanParser.cs
+++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs
@@ -691,6 +691,22 @@ private static PlanNode ParseRelOp(XElement relOpEl)
node.TableReferenceId = (int)ParseDouble(objEl.Attribute("TableReferenceId")?.Value);
}
+ // Nonclustered indexes maintained by modification operators (Update/SimpleUpdate)
+ var opName = physicalOpEl.Name.LocalName;
+ if (opName is "Update" or "SimpleUpdate" or "CreateIndex")
+ {
+ var ncObjects = ScopedDescendants(physicalOpEl, Ns + "Object")
+ .Where(o => string.Equals(o.Attribute("IndexKind")?.Value, "NonClustered", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+ node.NonClusteredIndexCount = ncObjects.Count;
+ foreach (var ncObj in ncObjects)
+ {
+ var ixName = ncObj.Attribute("Index")?.Value?.Replace("[", "").Replace("]", "");
+ if (!string.IsNullOrEmpty(ixName))
+ node.NonClusteredIndexNames.Add(ixName);
+ }
+ }
+
// Hash keys for hash match operators
var hashKeysProbeEl = physicalOpEl.Element(Ns + "HashKeysProbe");
if (hashKeysProbeEl != null)
diff --git a/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs
new file mode 100644
index 0000000..5df2bd3
--- /dev/null
+++ b/tests/PlanViewer.Core.Tests/NonClusteredIndexCountTests.cs
@@ -0,0 +1,54 @@
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.Core.Tests;
+
+public class NonClusteredIndexCountTests
+{
+ [Fact]
+ public void Update_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_update_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var updateNode = PlanTestHelper.FindNode(stmt.RootNode!, 1)!;
+
+ Assert.Contains("Update", updateNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, updateNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void Insert_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_insert_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var insertNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!;
+
+ Assert.Contains("Insert", insertNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, insertNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void Delete_WithFiveNonClusteredIndexes_CountIsFive()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("multi_index_delete_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+ var deleteNode = PlanTestHelper.FindNode(stmt.RootNode!, 0)!;
+
+ Assert.Contains("Delete", deleteNode.PhysicalOp, StringComparison.OrdinalIgnoreCase);
+ Assert.Equal(5, deleteNode.NonClusteredIndexCount);
+ }
+
+ [Fact]
+ public void ReadOperator_HasZeroNonClusteredIndexCount()
+ {
+ var plan = PlanTestHelper.LoadAndAnalyze("key_lookup_plan.sqlplan");
+ var stmt = PlanTestHelper.FirstStatement(plan);
+
+ void AssertZero(PlanNode node)
+ {
+ Assert.Equal(0, node.NonClusteredIndexCount);
+ foreach (var child in node.Children)
+ AssertZero(child);
+ }
+ AssertZero(stmt.RootNode!);
+ }
+}
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_delete_plan.sqlplan
new file mode 100644
index 0000000000000000000000000000000000000000..744493746f6064270aa2835ff0bc72a886714983
GIT binary patch
literal 9768
zcmeI2TTdHD7>4J%QvZXMt5#|_g+PdCQe%_E3IrES(h8vnY?IJpLu?ZuKfdkr&gWrg
z4?aL>+6!cvU3R|nd1n3hU#sr9yL26Q;!5tbYrCH7yJL6iy6$K9z|FfEH^;TaO4l`c
z-eR@uKKRvhH*iPpAy7-eJa^CB_wGBl>UP{mATL}Uss}*-<~H31G>lId4o|u7LrXk9
zLS^92cs9%D!hdG?`v~5r$n$}x3rH~spV=X;9c1~4OdaS;x-8}6JE8p0>)J;;Y0-qA
zc)cyO(qn9}=-1cWId~^S?PMF-aE8?d|C!-$TIReJ`jLKpa63?JVvAF(c8bjhJd-y%
zSfuYh!L9G5G7koOEwVc2mOYGlZoMwUT@1UIDc|gp|eh;rq)NK37<+?PZJkNUC3bJoT{6D{RsyZ%u_(PfUiMedeZOJj#>lBH6`2J?JZpf`_2Do`nV+blWBA}ZU+Ho%U@
zT;s>@dG``7trkyZysQ7QN>
zDEz^Fllzccqaxby4zRnZE8>pb7cYlpN`oi~aTGFLl+Qh+@~e*5lxt1y%fPB~$g9>%
zj-X|=Fs^W+LWOHlr80dj_f3{QHY&R}#6iuy=ekSwZ17(;P7;_p@KruFpkI!Gdk5S$
zFa}pdQMIN)zLbfgEnpitY89TpA#%#BCDOfjzk{hfuOeykJS(%b*#u5}D!^?+Z5Mjg
z49D1>%H&<(R6nZV)cCs&PJ_D_p6hl_%GnY1-+Dk&8A5I=D%*5?t9V;4#L3`;O%In8de-6;5k
z>u8uwaTuz0e#6Rd6ny`rB+GmJU{i%de}u-tsG2u
zRLNAPh99u5>GII~Gqz5mqxoi@>ZnuM*6q`DoVD36<$W%>xV
z$TWM8kFaI>+6sCsphuW9Z{vf|)B4`Orgiaz7HXw=GvF!vk$jVVJBw8{r>c&H3=6iH-n*;ObQWr2LPb^c410u?#3t)L
z_s-B&`IujyEWhg8iPu40_A_?1**&h^E8bVxoJ(r+61gmks`lpdx(0QV*k~6mTshx&&4ei+rPfbeR4ni|JS_Np|P50rm%IC_c?nqM+)c4yNfz
zblw1^`k9`*vRzY{>0VxE4!=U@Ed2`9&wUIl%H`dX&vfy)avCxlztck&
zM|zsNdUmKAossfdj*nhXYhgJX^GWAUu$gJ{$mdNAoy~nLAiXb6>Vr
zO`DVPJ~Y3(71eJwlc=^>y)>@lCXod8HP#ZIaPn1H)k2+FHnO=*aG__~bL$Axc-cJt
z|FzfwU47r@X|a9O^}6>~q66GpDVgxIwor!bku3}NR*LpPqpTG6WA_=S8E%x77IQ0c
z-Tsz!Nhg+VcGo&J(VQN3vTgPR+O162H?0!{TCc&>u3&mx!#5eUo7Ns7OlR?F?m9co
zNhf5YmHn*J<20}8d`5G&b|)SFzp0FY?rMsPCXge(vY@@;`)?vzWzVe>a-HvNk_{VND|{R72M;@NKhXvB?rNXAIrrnO;xLg{KI#rnv<65RPQT)4
zmy84NIn8WWe#b6<+Fqh|eV)`rLYsJ#H&3^-VyWoqgH)nf&$jbd7c#
z|3$eOugjj{jpS|eBUmh4l`nU%o2dRf9uehqEZ)uwcQ2tRCY9$ds)Z=VJ!X1MO*`w9
zH|uUO=Iwi|Q7PUBkJmz&$^kq3n*n<=j`@|uElRHgrm3d%Jd(Y;`R1JzYI_XFA)#w3GusRJGaoetE?~ye^Skm{qsB~
yTUB`ZH=D&?u;|GD5+)BFAk`U1|Gq|0$$4NxCH-8}o}Ihc%wurIq3D-e&)h#@E|p{e
literal 0
HcmV?d00001
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_insert_plan.sqlplan
new file mode 100644
index 0000000000000000000000000000000000000000..647304746e375c425e88ac9eec2fabedfbcfd82f
GIT binary patch
literal 41956
zcmeHQ`A-{37OtOH+W!INmt~|RV8W5CygT8_EE*sTIGI^RXhb*?X0ai*8OZ$cyZe3h
z{L4d}8NA
zJ`V!^?j;^~6@JjMSw&Pghaoc@ha
zw-1QNpv5Jqb_tq~agW^SgCZmI39XGRR>DE0*CL+In-yC|IC3@N$fF6bX3(FmSwsKE
zw#5TL`2_rh+ow(`zohV*clATQlTu^U?pp|BV3jNJ{+OcIcj}2f(QFVN{A-y0IL%VDNS~k!J~9c55S4{riWHe@gXJHNA`{KG!Mz5
zR*=IYdC9)?Q15n9XUpt@8k^`x57_fp&%DOfJK**nunz(Aoq2;c#0ngmS^QqL%
zxPNFK+j}3rT(4W~qP}iSBtS}TfC3}S-6POu7tbge`i#69fTov#eu{E(f78;A8uTxe
zA3@LM{8oxTiXVtW81#aUFz+l>=h^}vgtl~gZqB#Z-Mctl_9D9Gk(2C
zOXTh;s51m!@y<5|46+J7GkE%_2^t)b2Wt~VjuOQ^kot4+u=p^$51P@CMK7M`F5
z+P}WlJF))6ie+f8SSKm}2i7*S{Q)4*#%NFG(Y|QUE3~@;Nm@g{)^NRw>yCZ8jO%6F
zvn4#~;O{wp^O`hRu{{tN&HsOd{)x07K_00WVi72NqT{sP^rYgSM^+-)Td{L{s6#u#
z@~;8mH(Vd%TCHK04U&4Iw`_?V`NK-CXp$>OYMTl9+Lok6+ymEW9glIn3Ynv=Cs)P$
zp8=M5FV`ojPtor*-y;1LYc05pK%-UY*#Ua>>lb}HusnC=Br3Cq@*DV21AfRU_cO}v
zql}bGgGletgT7FPURxXMOXoW7??QT4Q7*#nSMzVw+=PYL0N&Akzc%%G2j$q#I?C+>
z+8e;z@LF`~Nlku(ay``BK%Fi8-$b1ru3p(z_Y-}d%kbFZwIj=*x=pD(MT{ms>NR2$
zm6!{wZ{+m|S{q3s{if*e$(IO`QNSO7!%~aTvPa;gdVYF>Q{YUWs`{IJz7AM#Y<-dB
zcy0QwKID)#jcqUzqt8r_P9mvu>-~V*v?J7+0s47qEskgeWtbL=vD3Mw#fkNoZkwuM
zQ4La#o`tl_Dj&6)U1kS(-JGXoi8oT4!haay%rk({LUB0tk9D^k+2ww3X;NJ0`GWsF{Q)?;*r^P9QCeu8FgZCg=x?
zj3)9eqgo8oRbuOH;7&YeFxo6qcOU&4e350fs6$xkXe==uj^Gi8MZJ`moO~IH~V?K7ov@BJ8`X`UXs4jlV?^V+|s_3$IB<7>+@Bc>xm#IOA?Rwh@o5^
z)!JFSk~(E^l3EHo9kXH@FbDe+LI=9={8ym5j3yb6G22EA8I4d@nPp+@Be?_D@)XrD
zcf^n!hfo*Zp$*!ZWAh(addWkb!Rk2DS-J;kL
zxRFO6`mjMuB}i;brAV)QT2MpYL0cADDmm6Ci%M$yBJ1T^q*miZmKN`fEJ<#Oqg%$@
z5|uLsFXk`B14!(|IBaM~R*XXFqv>VnZ)7CFr}RC{tLeO2F<;7Ty?RbRbI;#?=6(j=
zkJ)}ko>yqE13xfty)tw9MZEX)7G+)_y(MxtR7=^j4$}5MD9!N*M`C9fm0cqexv;T=
zj+QvL5vRlw@M9)E3@x%|KC0ul92h@FX7SmVGPrT3A|$=iW_{A-XeFQ=<8JKZ!$&`{$&ORt@HenVC3_f;6jD4sH@Q
zGpnxuFAq;(?pRMoJ0L42RwuZA0It!
z5ELXvUGRb09=4x^+6;FHIgAc<1Hln0y8KGxr@x{=M)gI;0^~GlsjKj$rjAGWbXq3Q
z#Z7dPqX*V(buiC<`gDuyV6mA-SL?H`TOB;dj9(qUdjE7VYg~O_w34ecB$Fa@sWVvT
z!WfnFdJ@s;8bn&0=~;qlD@3w%$G_inRG{rO868AzxE3hQtGLfe=eUJh3S#3G8-6!q
zskDXWq6W7GW`^UkTRomvnzXUb&_bL+(KePHQEng;6`$j1Eo@ST%iMX`7M62fAtcD9cxzQ%YRGkKRqt}n
zJULSvLMNKGEKQD#yI3ukKj+~-PmYLX-&}}Ttr=_5>f>G3$GkNR_eC@P+((954PFZMO`OnRgN)41n8
zPewknr!a&~X(OM9H(N<-;jdA=FB;iKJmJ^(s8!PM@(~Z$tcH*v#em<k+x5JOl+Fu1BQD4!d7HYz*K-Q|{N>|L;SswgX(C@I3FTXApJm
z-ur21x-Q~m+NFu4Z{jQg?vj%eIC;9bo~Xro$1Oqw>-0_>PM&Y
z7SAwtabi{SY}exT#p-%CM}B+<>>>DJ}zipWtmY^dNF-Z#hU}
zakHS6hZVwtSF@#TSz34L>c>&}Cn)OMZ|dyd7cTPzZ=PySUnbWPkC@4fTB?3-Ntl>X
z=_a(Bvu|rildSTx&Xcq;#qySIIYm
z)w5jTSAvxH(~-s0ku_t4jvZ~5w?Rr}{m!R$efVsL^ELh|jr^S{aXwZVNofDv@9_S?
zhn{bbt5cJhhm*zdbojRIS+$Xt%0|A1<@-r_*V$WSgZQ>dzLQ}G`HyXUuj8+W_aYAL
z8=;2w1Ua68J&R1#{N#jIz6+73hVpkkJ190t-bTSFPWO_yZ>{q&q1J9A8s}-;W8`cY
zbgTdZ_jMpnxUMO
za6KQ|gcZ$?HznJmXQ0y4W{oA7VJa_qN@e|S+RL(?b}S+1e^=)g?L|0QZ2hFap3K!r
z>YO`j=i9RDL}vLKmOqw=GOTrEC$b=IE;oI~p&E6|NBOQ?Rh=~|X1QCrQaYu5f6MV#
zr{+kqwO9J4vhr3ToLuEW8kLOuimiuIx$)QBv}>V?t%Z^`lXc#A-PBsBh01H8)OLng
z6BWU7qpd%4Z7OxD-s*>_4`JS4tnl1#YODKhWKv7sHN|tW+IM=NAm^O*UhA}XdRN&;
zKuL*nfwN&};6#zLAcQD@Ab#G|Pb3fz|N8dWLDEfrl3x!1*HQ|-Y0q9grha{Wj>y(Y{&
zJ6s()A&2!}c@V4m*eBKn_)8g~3j>M;pgQ~CMrCsyKEukXjxXp}5-j?+_T=S#d^5gD)2f7wHj?9W5#f84)EQuk2$AJ0@jy#B{`
zE$8>&Um+`Nb?0+*JKrkon!n+bZcp;8LR4>>RiN!n>h^St%6dQ9mAgY^sP=4Bk#zzI
z%W^$%AAbjS-~5%efl2?TB{`>dYArl>75HNymRhr(g>Ti4pv3PV0KzAWp}U?&TKI^p
P>dhCdf5DpTZNT|IufEAA
literal 0
HcmV?d00001
diff --git a/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan b/tests/PlanViewer.Core.Tests/Plans/multi_index_update_plan.sqlplan
new file mode 100644
index 0000000000000000000000000000000000000000..0201133a3968d2b85de5b26bc18e53bb2bd13dd0
GIT binary patch
literal 56612
zcmeHQYj4{|7M;%p_CFZ?)CD&2D~aR6yIaS0S|fhdmYXgNBd{$waqCwlrB3?eclVsR
zy5Vq0Q4%>rDG4Fiq{!iYzvd1p{_nqQ=HKRnxiQyf&irXQrfYiU%G{Y-^H1}wSu~H$
z0=9EFb89Yeyp6NB=B+*Z-t^6-d4y5VFy`OpU*>P-8?$DP%sY&HFb9BohS9&8U9$ri
zg69?(zQ=wKu!!Rwp!Cf>j?LrqU_X!Xe;K&%K+jtoT>=#gz%xI9bp%@8fukSWN`j};-m=!xlG;%-C$hQMs&4Hga^8)vPtp(WI(EYYq#~DK2
z0I%JFKb&FI6|VYdgbwmVNr)%yf~vNKlxI87prd?BkD!Tj(*l+bK9mG`ByY5*1z6TH
zZ0WgK#Wo*83)g)ZTyqS6+ywP4&^TvKK+!oMo&wT|IR<3${ZpLde&3E+hxc_bW)4{A
z%n$HDYVWoA2`~-^a464Hod029*I9|SAeVc}R=r1@uSZC#A`2@>nUtA)Q0@Rz5S
zvn^2l1b>^b(=Bkj1-_RC94A+6tnr~d#dVALtOCXgWXS7Z;`1EGR`5p}4lTc3XiGHS
z#(DbMjn%P48dBBAQTj%{tdFc868|kELVH$D7J!}c>lxs!V4wIGEmuqUFGtsL{;54f
zKKX3}T(08$a~ywxZ<`hd;l%!szfEhuXRr}!MXc`@5)gZ0oM6_YxKkm?61S-7+0uO
z$uyUMC6UsNe;U7Zn-|A~Ux^GtOAj(yH+zUL+vW|n$A}6$_~zS5GUfo|X~o+Z{bRwn
z*BEz*F)}U>A|p`?{<03c*vHsb2HFOW@4;s1?Fn^n%ztp@7Uadug#AW~iJWk{u*NWPO`f*+E2-c({)|
zs1|c={f)Zr!FtG#SR!Mz_;34DHNuSZ7vOLdv-Y>pq-GiB?G9*W{8s|{
zVm-{g&!J(d{Ib_(X&K*w1*XWm*uqwj4OhX(68O+6w2TfkbN8%%h3jIg6>aKu^ge3l
z0^7uH{Y*m1oxqw|D_)r25i_J}y@akSkAe14A19L
zCL<5bD@3VeJR=tC7+xjy+7dN=I
zBsM<-9h9gV0#<(^p`hc;lScr$S
zY@HjB7U40U5`SK^gR9>H2JKTMMF_5C5zZb)F@9?0<<@7sj@eG;H|bn{fI9pmc=q9M
zVDx)y!?bd0cg}h`aeTnOkKdORt>F&56SvH6SW}NPyBQ}A?OVChCj0O;iAJNnMPk??
zuDbnXs~Ww!@Snv0N7Il8D~TGES#J;_T97a~KeIA)zll{+d+X3{A9P8^_bniqwRck1
z>0Y&EZI_i2<0vZ%cW#^>>09RbBlB%I4V~FoBf`y4D#Uw-pHFik-ss{|_nBuY
zBdNkTPBWk!moCIOnicKNuEfreqffz~n@L>xFo)eXQbP6DNGYk;eOb^$PT(y|jg%07
zS$VV87$zm1UFAol#^c16o@C@Vt+>SAPalndDTL*XP~A8b#sih!>i$Cd!g_O9j(uohqN5MBoK^%%l`OVqBh#p^;PSxA
z{mxU;7hD|l1y`&$_~VkkAbrI@OF#R(I)aP>>`${NCU;mQd7d2l9YR@G_1D!HKiqlx
z9BPqa9l~OKzCy@s6ZUn~rysn~lZrj^|&XcT|&Z;~mrXj>)?Q
z>KUTDaJggk3-pd^(*3%;gL9%`Gniq|D7v=j?l3gL8`%=?)J+kIwpE%vANmqV(
z>k#rpa5k9S(9;;`Pv3fPfPO_e^vh=s?
z&c7_#r^>=&BlR%xN92@kKhU?6_*$Br&tn7V=D5Qu+mE7r42A*
z$}_>qbK3G=hteBAsVV;w9HPu{W7aeS64&Tw~&
z=E=f87Ir1ihd%6k_!YllnDUM{zA(wke8{_S^R(d}``WCL{H8NToZM8uzbR+2v;7*X
zOa87}l_wKi3-tG=6)Ut>;`bU0Z;BavW5nA!W9+zbHHM$mr~R<;Ge*puMV+&|
z6goE6r}JzXWyU5knV6?6)_P6xvrvEf`UdK4^Skp!R2DN&%
zkLIVQi#$w@aV6wpE%ur6k;cW5w=WzvUF586nl7^Z{$l#~uUWJBcM&vQ1j1{~X@wQH%88@!R@RNFcgIJm_a@y;}
z8>W#<>(hC-7)Y9{$Gv!*6Y|&7MXq))z4}3MjBUEeDyK5r=3eAHsfCn^3tuyMoIZOR
zQ~X-Z#i#pC7g;6YZ)dx6*4&F6_BUF6?fc*3RTw^|yzj*sHD1q9X_;+bXu8P$^Iq{R
zf_^}GC+m)JaMkZ`%2|v~hWZh=F}fOQ_|vQ;M&DZ+5&LD;!x
zNU6A5i7TNrrfJUolAb{Gix#St+3uV*UF4>VtnW$;8%x#x-R*4qLeoWVy2!4zmfL5g
zFLwaNsfU!R7+np8&261^R~oNKZ&X*X=_0#Q8@(T+BjRG6b$e;L$gZ}E_rr8kTpaN_
zD?XN3u{TD%t%Kx@?+6Rg{
zVoev>mDQyCA^WA8pW@7#>dmy(SEBMX5m!Pk#yX{y#>;%wbdg<|$o_11&YCWA(?xE&
z$QSs1+3YW@G{0yOZ(V_NjC96`gR7poj2rcpv*uo8S1RN0Yppb@+tGB9$CoKbHeKZK
zzy0ZIU^(hSIMRqka&0Jca&cnq((p)|6#p9fi
z!`_Qr1aZ>0(5mH)_cq!^BX7^!vTwb(Lhp$4&<k_7ITH@s2iD;k^;#9>y5g1}z@|^TNFU^zOG^biCR)#`km_V*g?Qk7lXQRrQUtpC{L0ey^7CYTx?cz5Txf-{nlPeD{FS
z>OJB_3y^ML`Ps5h=ub9(^3NR^nv5>w^9RyJhV{?>mFv^F5i#H}OLL3h+82
zP#@RQVpk}W(ODA
z0jkH3wyKkClOCUiuIJb7OoDk!AZJICj&oB)&9G}GrAaUqQ&weOkl23g`9Xpy$?U4{
zIm~XE0X6%xCSXs7_la`8pyM`?BfB`p*`U-)i&i1Rcux_AP*H$0
zigWYZAS%xx0&_)!bDJ~dPkpZ!
z7op@&5KYf9PR=HCCOLO(F|d~?=hLzN1Rcg#<_*?WNC!u~K{jY-#=f%AT4t%)*&yvf
zqjOQzobZZ*#oYJK>Q^3uJ~R7IxDj<@>2N1lgR86
zGLz1MRxJNt}1j9TWRC0E~U!U%301bigj(Qdu#n*1LOD1FYt#$bW|A|cM&mnu-(94%Y0|Y
z_RT*Kg?~VtKE<9qZ5hXRREv85hUAGp;;-fu*NfRL>ko1U`(^B;(Ys~lwT%pNk7JCS
z?5FWeGNXt~zgS_BUgF~y8kp_sz0+THDEQq7$T{q#wN6qr?B4SwtI<%I)TJhGpY_pn
zWE6LeB#&437_n0~w}V=&%1W{eH*bGBoDysOQJGlX`Xj8LizH-qqC^bl0Rtn2e^fw1l8x
zQrsMW44&JzI_iw5|0jXI2A^m3bq1MstVFx0qQ<>LXLR-MW>MBL`h->%YjK^F9j|t9
zWviz)YC+y%!>%jw@J@tftX|6#1lg-)Wrm$t@?CW8IQhJc9Hd&vQkEx&9;hUDtO>Pp
z%w9Q4B1@=)xgz_lq*VOtM@fcun5A;Gb@95NttPvujSs*zXDIpJ&9R&t+~DuXYLk_7
wo`>j2xo4GHrzHJ|%~8qfRf02poV|cQEQL}H$~eLB&US{B>R&N0*#YhU2Pob({{R30
literal 0
HcmV?d00001
From a1428c803e97701dda4c2e167048bb86142968ea Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 09:52:15 -0400
Subject: [PATCH 21/24] Add 'Open in Query Editor' to plan viewer statements
grid (#165)
Right-click a statement in the plan viewer grid to open its query text
in the editor. From file mode, creates a new query tab. From editor
mode, loads the text into the existing editor and switches to it.
Closes #165.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/PlanViewer.App/Controls/PlanViewerControl.axaml | 1 +
src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs | 10 ++++++++++
.../Controls/QuerySessionControl.axaml.cs | 10 ++++++++++
src/PlanViewer.App/MainWindow.axaml.cs | 10 ++++++++++
4 files changed, 31 insertions(+)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index db9d492..ef2ed43 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -228,6 +228,7 @@
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index debb21c..0adcfd2 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -171,6 +171,7 @@ public ServerMetadata? Metadata
public event EventHandler? HumanAdviceRequested;
public event EventHandler? RobotAdviceRequested;
public event EventHandler? CopyReproRequested;
+ public event EventHandler? OpenInEditorRequested;
///
/// Navigates to a specific plan node by ID: selects it, zooms to show it,
@@ -3269,6 +3270,15 @@ private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
await topLevel.Clipboard.SetTextAsync(text);
}
+ private void OpenInEditor_Click(object? sender, RoutedEventArgs e)
+ {
+ if (StatementsGrid.SelectedItem is not StatementRow row) return;
+ var text = row.Statement.StatementText;
+ if (string.IsNullOrEmpty(text)) return;
+
+ OpenInEditorRequested?.Invoke(this, text);
+ }
+
private static void CollectNodeWarnings(PlanNode node, List warnings)
{
warnings.AddRange(node.Warnings);
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index 49c0c57..ef556d4 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -590,6 +590,13 @@ private static string BracketName(string name)
return $"[{name}]";
}
+ private void OnOpenInEditorRequested(object? sender, string queryText)
+ {
+ QueryEditor.Text = queryText;
+ SubTabControl.SelectedIndex = 0; // Switch to the editor tab
+ QueryEditor.Focus();
+ }
+
private void OnKeyDown(object? sender, KeyEventArgs e)
{
// F5 or Ctrl+E → Execute (actual plan)
@@ -1067,6 +1074,7 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
+ viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, tabLabel, queryText);
loadingTab.Content = viewer;
HumanAdviceButton.IsEnabled = true;
@@ -1148,6 +1156,7 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
+ viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, label, queryText);
// Build tab header with close button and right-click rename
@@ -1836,6 +1845,7 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
var actualViewer = new PlanViewerControl();
actualViewer.Metadata = _serverMetadata;
+ actualViewer.OpenInEditorRequested += OnOpenInEditorRequested;
actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText);
loadingTab.Content = actualViewer;
}
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 0a055f9..92de531 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -530,6 +530,16 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
viewer.HumanAdviceRequested += (_, _) => showHumanAdvice();
viewer.RobotAdviceRequested += (_, _) => showRobotAdvice();
viewer.CopyReproRequested += async (_, _) => await copyRepro();
+ viewer.OpenInEditorRequested += (_, queryText) =>
+ {
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ session.QueryEditor.Text = queryText;
+ var tab = CreateTab($"Query {_queryCounter}", session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ };
var getActualPlanBtn = new Button
{
From 62b05d55b03cd9360d6253bbf26b9e79ac4ae99e Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 10:06:24 -0400
Subject: [PATCH 22/24] Add schema lookup to plan viewer operator right-click
menu (#166)
Right-click a data access operator (scan, seek, lookup, insert, update,
delete) in the plan viewer to see Show Indexes and Show Table Definition.
Uses the connection from the query editor session.
- Pass connection string from QuerySessionControl to PlanViewerControl
- Add schema items to existing node context menu
- Filter by operator type (data access only)
- Exclude temp objects (#temp, @table_var, worktables)
- Items disabled when no connection is available
- Results shown in a popup window with SQL syntax highlighting
- Copy/Copy All/Select All context menu on results
Partial fix for #166 (plan viewer side). Connection toolbar for file
mode plans is not yet implemented.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../Controls/PlanViewerControl.axaml.cs | 342 ++++++++++++++++++
.../Controls/QuerySessionControl.axaml.cs | 3 +
2 files changed, 345 insertions(+)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 0adcfd2..3eef9f4 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -15,6 +15,7 @@
using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Platform.Storage;
+using AvaloniaEdit.TextMate;
using PlanViewer.App.Helpers;
using PlanViewer.App.Mcp;
using PlanViewer.Core.Models;
@@ -167,6 +168,11 @@ public ServerMetadata? Metadata
}
}
+ ///
+ /// Connection string for schema lookups. Set when the plan was loaded from a connected session.
+ ///
+ public string? ConnectionString { get; set; }
+
// Events for MainWindow to wire up advice/repro actions
public event EventHandler? HumanAdviceRequested;
public event EventHandler? RobotAdviceRequested;
@@ -841,6 +847,9 @@ private ContextMenu BuildNodeContextMenu(PlanNode node)
menu.Items.Add(copySeekItem);
}
+ // Schema lookup items (Show Indexes, Show Table Definition)
+ AddSchemaMenuItems(menu, node);
+
return menu;
}
@@ -3338,6 +3347,339 @@ private IBrush FindBrushResource(string key)
}
#endregion
+
+ #region Schema Lookup
+
+ private static bool IsTempObject(string objectName)
+ {
+ // #temp tables, ##global temp, @table variables, internal worktables
+ return objectName.Contains('#') || objectName.Contains('@')
+ || objectName.Contains("worktable", StringComparison.OrdinalIgnoreCase)
+ || objectName.Contains("worksort", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsDataAccessOperator(PlanNode node)
+ {
+ var op = node.PhysicalOp;
+ if (string.IsNullOrEmpty(op)) return false;
+
+ // Modification operators and data access operators reference objects
+ return op.Contains("Scan", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Seek", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Lookup", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Insert", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Update", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Delete", StringComparison.OrdinalIgnoreCase)
+ || op.Contains("Spool", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private void AddSchemaMenuItems(ContextMenu menu, PlanNode node)
+ {
+ if (string.IsNullOrEmpty(node.ObjectName) || IsTempObject(node.ObjectName))
+ return;
+ if (!IsDataAccessOperator(node))
+ return;
+
+ var objectName = node.ObjectName;
+
+ menu.Items.Add(new Separator());
+
+ var showIndexes = new MenuItem { Header = $"Show Indexes — {objectName}" };
+ showIndexes.Click += async (_, _) => await FetchAndShowSchemaAsync("Indexes", objectName,
+ async cs => FormatIndexes(objectName, await SchemaQueryService.FetchIndexesAsync(cs, objectName)));
+ menu.Items.Add(showIndexes);
+
+ var showTableDef = new MenuItem { Header = $"Show Table Definition — {objectName}" };
+ showTableDef.Click += async (_, _) => await FetchAndShowSchemaAsync("Table", objectName,
+ async cs =>
+ {
+ var columns = await SchemaQueryService.FetchColumnsAsync(cs, objectName);
+ var indexes = await SchemaQueryService.FetchIndexesAsync(cs, objectName);
+ return FormatColumns(objectName, columns, indexes);
+ });
+ menu.Items.Add(showTableDef);
+
+ // Disable schema items when no connection
+ menu.Opening += (_, _) =>
+ {
+ var enabled = ConnectionString != null;
+ showIndexes.IsEnabled = enabled;
+ showTableDef.IsEnabled = enabled;
+ };
+ }
+
+ private async System.Threading.Tasks.Task FetchAndShowSchemaAsync(
+ string kind, string objectName, Func> fetch)
+ {
+ if (ConnectionString == null) return;
+
+ try
+ {
+ var content = await fetch(ConnectionString);
+ ShowSchemaResult($"{kind} — {objectName}", content);
+ }
+ catch (Exception ex)
+ {
+ ShowSchemaResult($"Error — {objectName}", $"-- Error: {ex.Message}");
+ }
+ }
+
+ private void ShowSchemaResult(string title, string content)
+ {
+ var editor = new AvaloniaEdit.TextEditor
+ {
+ Text = content,
+ IsReadOnly = true,
+ FontFamily = new FontFamily("Consolas, Menlo, monospace"),
+ FontSize = 13,
+ ShowLineNumbers = true,
+ Background = FindBrushResource("BackgroundBrush"),
+ Foreground = FindBrushResource("ForegroundBrush"),
+ HorizontalScrollBarVisibility = ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
+ 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"));
+
+ // Context menu
+ var copyItem = new MenuItem { Header = "Copy" };
+ copyItem.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ var sel = editor.TextArea.Selection;
+ if (!sel.IsEmpty)
+ await clipboard.SetTextAsync(sel.GetText());
+ };
+ var copyAllItem = new MenuItem { Header = "Copy All" };
+ copyAllItem.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ await clipboard.SetTextAsync(editor.Text);
+ };
+ var selectAllItem = new MenuItem { Header = "Select All" };
+ selectAllItem.Click += (_, _) => editor.SelectAll();
+ editor.TextArea.ContextMenu = new ContextMenu
+ {
+ Items = { copyItem, copyAllItem, new Separator(), selectAllItem }
+ };
+
+ // Show in a popup window
+ var window = new Window
+ {
+ Title = $"Performance Studio — {title}",
+ Width = 700,
+ Height = 500,
+ MinWidth = 400,
+ MinHeight = 200,
+ Background = FindBrushResource("BackgroundBrush"),
+ Foreground = FindBrushResource("ForegroundBrush"),
+ Content = editor
+ };
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window parentWindow)
+ {
+ window.Icon = parentWindow.Icon;
+ window.Show(parentWindow);
+ }
+ else
+ {
+ window.Show();
+ }
+ }
+
+ // --- Formatters (same logic as QuerySessionControl) ---
+
+ private static string FormatIndexes(string objectName, IReadOnlyList indexes)
+ {
+ if (indexes.Count == 0)
+ return $"-- No indexes found on {objectName}";
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"-- Indexes on {objectName}");
+ sb.AppendLine($"-- {indexes.Count} index(es), {indexes[0].RowCount:N0} rows");
+ sb.AppendLine();
+
+ foreach (var ix in indexes)
+ {
+ if (ix.IsDisabled)
+ sb.AppendLine("-- ** DISABLED **");
+
+ sb.AppendLine($"-- {ix.SizeMB:N1} MB | Seeks: {ix.UserSeeks:N0} | Scans: {ix.UserScans:N0} | Lookups: {ix.UserLookups:N0} | Updates: {ix.UserUpdates:N0}");
+
+ var withOptions = BuildWithOptions(ix);
+ var onPartition = ix.PartitionScheme != null && ix.PartitionColumn != null
+ ? $"ON [{ix.PartitionScheme}]([{ix.PartitionColumn}])"
+ : null;
+
+ if (ix.IsPrimaryKey)
+ {
+ var clustered = IsClusteredType(ix) ? "CLUSTERED" : "NONCLUSTERED";
+ sb.AppendLine($"ALTER TABLE {objectName}");
+ sb.AppendLine($"ADD CONSTRAINT [{ix.IndexName}]");
+ sb.Append($" PRIMARY KEY {clustered} ({ix.KeyColumns})");
+ if (withOptions.Count > 0)
+ {
+ sb.AppendLine();
+ sb.Append($" WITH ({string.Join(", ", withOptions)})");
+ }
+ if (onPartition != null)
+ {
+ sb.AppendLine();
+ sb.Append($" {onPartition}");
+ }
+ sb.AppendLine(";");
+ }
+ else if (IsColumnstore(ix))
+ {
+ var clustered = ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase)
+ ? "NONCLUSTERED " : "CLUSTERED ";
+ sb.Append($"CREATE {clustered}COLUMNSTORE INDEX [{ix.IndexName}]");
+ sb.AppendLine($" ON {objectName}");
+ if (ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrEmpty(ix.KeyColumns))
+ sb.AppendLine($"({ix.KeyColumns})");
+ var csOptions = BuildColumnstoreWithOptions(ix);
+ if (csOptions.Count > 0)
+ sb.AppendLine($"WITH ({string.Join(", ", csOptions)})");
+ if (onPartition != null)
+ sb.AppendLine(onPartition);
+ TrimTrailingNewline(sb);
+ sb.AppendLine(";");
+ }
+ else
+ {
+ var unique = ix.IsUnique ? "UNIQUE " : "";
+ var clustered = IsClusteredType(ix) ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.Append($"CREATE {unique}{clustered}INDEX [{ix.IndexName}]");
+ sb.AppendLine($" ON {objectName}");
+ sb.AppendLine($"({ix.KeyColumns})");
+ if (!string.IsNullOrEmpty(ix.IncludeColumns))
+ sb.AppendLine($"INCLUDE ({ix.IncludeColumns})");
+ if (!string.IsNullOrEmpty(ix.FilterDefinition))
+ sb.AppendLine($"WHERE {ix.FilterDefinition}");
+ if (withOptions.Count > 0)
+ sb.AppendLine($"WITH ({string.Join(", ", withOptions)})");
+ if (onPartition != null)
+ sb.AppendLine(onPartition);
+ TrimTrailingNewline(sb);
+ sb.AppendLine(";");
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ private static string FormatColumns(string objectName, IReadOnlyList columns, IReadOnlyList indexes)
+ {
+ if (columns.Count == 0)
+ return $"-- No columns found for {objectName}";
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine($"CREATE TABLE {objectName}");
+ sb.AppendLine("(");
+
+ var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
+
+ for (int i = 0; i < columns.Count; i++)
+ {
+ var col = columns[i];
+ var isLast = i == columns.Count - 1;
+
+ sb.Append($" [{col.ColumnName}] ");
+
+ if (col.IsComputed && col.ComputedDefinition != null)
+ {
+ sb.Append($"AS {col.ComputedDefinition}");
+ }
+ else
+ {
+ sb.Append(col.DataType);
+ if (col.IsIdentity)
+ sb.Append($" IDENTITY({col.IdentitySeed}, {col.IdentityIncrement})");
+ sb.Append(col.IsNullable ? " NULL" : " NOT NULL");
+ if (col.DefaultValue != null)
+ sb.Append($" DEFAULT {col.DefaultValue}");
+ }
+
+ sb.AppendLine(!isLast || pkIndex != null ? "," : "");
+ }
+
+ if (pkIndex != null)
+ {
+ var clustered = IsClusteredType(pkIndex) ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.AppendLine($" CONSTRAINT [{pkIndex.IndexName}]");
+ sb.Append($" PRIMARY KEY {clustered}({pkIndex.KeyColumns})");
+ var pkOptions = BuildWithOptions(pkIndex);
+ if (pkOptions.Count > 0)
+ {
+ sb.AppendLine();
+ sb.Append($" WITH ({string.Join(", ", pkOptions)})");
+ }
+ sb.AppendLine();
+ }
+
+ sb.Append(")");
+
+ var clusteredIx = indexes.FirstOrDefault(ix => IsClusteredType(ix) && !IsColumnstore(ix));
+ if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null)
+ {
+ sb.AppendLine();
+ sb.Append($"ON [{clusteredIx.PartitionScheme}]([{clusteredIx.PartitionColumn}])");
+ }
+
+ sb.AppendLine(";");
+ return sb.ToString();
+ }
+
+ private static bool IsClusteredType(IndexInfo ix) =>
+ ix.IndexType.Contains("CLUSTERED", StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsColumnstore(IndexInfo ix) =>
+ ix.IndexType.Contains("COLUMNSTORE", StringComparison.OrdinalIgnoreCase);
+
+ private static List BuildWithOptions(IndexInfo ix)
+ {
+ var options = new List();
+ if (ix.FillFactor > 0 && ix.FillFactor != 100)
+ options.Add($"FILLFACTOR = {ix.FillFactor}");
+ if (ix.IsPadded)
+ options.Add("PAD_INDEX = ON");
+ if (!ix.AllowRowLocks)
+ options.Add("ALLOW_ROW_LOCKS = OFF");
+ if (!ix.AllowPageLocks)
+ options.Add("ALLOW_PAGE_LOCKS = OFF");
+ if (!string.Equals(ix.DataCompression, "NONE", StringComparison.OrdinalIgnoreCase))
+ options.Add($"DATA_COMPRESSION = {ix.DataCompression}");
+ return options;
+ }
+
+ private static List BuildColumnstoreWithOptions(IndexInfo ix)
+ {
+ var options = new List();
+ if (ix.FillFactor > 0 && ix.FillFactor != 100)
+ options.Add($"FILLFACTOR = {ix.FillFactor}");
+ if (ix.IsPadded)
+ options.Add("PAD_INDEX = ON");
+ return options;
+ }
+
+ private static void TrimTrailingNewline(System.Text.StringBuilder sb)
+ {
+ if (sb.Length > 0 && sb[sb.Length - 1] == '\n') sb.Length--;
+ if (sb.Length > 0 && sb[sb.Length - 1] == '\r') sb.Length--;
+ }
+
+ #endregion
}
/// Sort DataGrid column by a long property on StatementRow.
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index ef556d4..f396c26 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -1074,6 +1074,7 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
+ viewer.ConnectionString = _connectionString;
viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, tabLabel, queryText);
loadingTab.Content = viewer;
@@ -1156,6 +1157,7 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
+ viewer.ConnectionString = _connectionString;
viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, label, queryText);
@@ -1845,6 +1847,7 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
var actualViewer = new PlanViewerControl();
actualViewer.Metadata = _serverMetadata;
+ actualViewer.ConnectionString = _connectionString;
actualViewer.OpenInEditorRequested += OnOpenInEditorRequested;
actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText);
loadingTab.Content = actualViewer;
From 5bdb4202e3340b4fb6a613965141dec77c52c8e7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 10:22:29 -0400
Subject: [PATCH 23/24] Add connection toolbar to plan viewer for schema
lookups (#166)
Plan viewer now has Connect button + server status + database dropdown
in the toolbar, enabling schema lookups on plans opened from file.
Plans loaded from connected sessions inherit the connection and show
the server name and database pre-populated.
- Connect button shows connection dialog, populates database dropdown
- Database switching updates the connection string for schema lookups
- Connection services passed to PlanViewerControl from both MainWindow
and QuerySessionControl
- Schema lookup items disabled when no connection, enabled after connect
Closes #166.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../Controls/PlanViewerControl.axaml | 14 +++
.../Controls/PlanViewerControl.axaml.cs | 97 +++++++++++++++++++
.../Controls/QuerySessionControl.axaml.cs | 9 ++
src/PlanViewer.App/MainWindow.axaml.cs | 2 +
4 files changed, 122 insertions(+)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index ef2ed43..1eec781 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -14,6 +14,20 @@
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,0,1">
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 3eef9f4..1051b23 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -16,7 +16,11 @@
using Avalonia.Controls.Templates;
using Avalonia.Platform.Storage;
using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.Core.Interfaces;
using PlanViewer.App.Helpers;
+using PlanViewer.App.Services;
using PlanViewer.App.Mcp;
using PlanViewer.Core.Models;
using PlanViewer.Core.Output;
@@ -173,6 +177,33 @@ public ServerMetadata? Metadata
///
public string? ConnectionString { get; set; }
+ // Connection state for plans that connect via the toolbar
+ private ServerConnection? _planConnection;
+ private ICredentialService? _planCredentialService;
+ private ConnectionStore? _planConnectionStore;
+ private string? _planSelectedDatabase;
+
+ ///
+ /// Provide credential service and connection store so the plan viewer can show a connection dialog.
+ ///
+ public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore)
+ {
+ _planCredentialService = credentialService;
+ _planConnectionStore = connectionStore;
+ }
+
+ ///
+ /// Update the connection UI to reflect an active connection (used when connection is inherited).
+ ///
+ public void SetConnectionStatus(string serverName, string? database)
+ {
+ PlanServerLabel.Text = serverName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "Reconnect";
+ if (database != null)
+ _planSelectedDatabase = database;
+ }
+
// Events for MainWindow to wire up advice/repro actions
public event EventHandler? HumanAdviceRequested;
public event EventHandler? RobotAdviceRequested;
@@ -3348,6 +3379,72 @@ private IBrush FindBrushResource(string key)
#endregion
+ #region Plan Viewer Connection
+
+ private async void PlanConnect_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_planCredentialService == null || _planConnectionStore == null) return;
+
+ var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore);
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is not Window parentWindow) return;
+
+ var result = await dialog.ShowDialog(parentWindow);
+ if (result != true || dialog.ResultConnection == null) return;
+
+ _planConnection = dialog.ResultConnection;
+ _planSelectedDatabase = dialog.ResultDatabase;
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+
+ PlanServerLabel.Text = _planConnection.ServerName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "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));
+
+ PlanDatabaseBox.ItemsSource = databases;
+ PlanDatabaseBox.IsEnabled = true;
+
+ if (_planSelectedDatabase != null)
+ {
+ for (int i = 0; i < PlanDatabaseBox.Items.Count; i++)
+ {
+ if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase)
+ {
+ PlanDatabaseBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ catch
+ {
+ PlanDatabaseBox.IsEnabled = false;
+ }
+ }
+
+ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return;
+
+ _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString();
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+ }
+
+ #endregion
+
#region Schema Lookup
private static bool IsTempObject(string objectName)
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index f396c26..7da0deb 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -1075,6 +1075,9 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
viewer.ConnectionString = _connectionString;
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, tabLabel, queryText);
loadingTab.Content = viewer;
@@ -1158,6 +1161,9 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string
var viewer = new PlanViewerControl();
viewer.Metadata = _serverMetadata;
viewer.ConnectionString = _connectionString;
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
viewer.OpenInEditorRequested += OnOpenInEditorRequested;
viewer.LoadPlan(planXml, label, queryText);
@@ -1848,6 +1854,9 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
var actualViewer = new PlanViewerControl();
actualViewer.Metadata = _serverMetadata;
actualViewer.ConnectionString = _connectionString;
+ actualViewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ actualViewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
actualViewer.OpenInEditorRequested += OnOpenInEditorRequested;
actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText);
loadingTab.Content = actualViewer;
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 92de531..457c9d5 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -361,6 +361,7 @@ private void LoadPlanFile(string filePath)
return;
var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
viewer.LoadPlan(xml, fileName);
viewer.SourceFilePath = filePath;
@@ -399,6 +400,7 @@ private async Task PasteXmlAsync()
return;
var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
viewer.LoadPlan(xml, "Pasted Plan");
var content = CreatePlanTabContent(viewer);
From 3d7177330d588f7cf0eb4981ffc1f7ae78fcafa7 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Mon, 6 Apr 2026 10:28:12 -0400
Subject: [PATCH 24/24] Bump version to 1.4.0
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/PlanViewer.App/PlanViewer.App.csproj | 2 +-
src/PlanViewer.Core/PlanViewer.Core.csproj | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 956357d..81fac66 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -6,7 +6,7 @@
app.manifest
EDD.ico
true
- 1.3.0
+ 1.4.0
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj
index 9363afb..b91d4dc 100644
--- a/src/PlanViewer.Core/PlanViewer.Core.csproj
+++ b/src/PlanViewer.Core/PlanViewer.Core.csproj
@@ -5,7 +5,7 @@
enable
enable
PlanViewer.Core
- 1.3.0
+ 1.4.0
Erik Darling
Darling Data LLC
SQL Performance Studio