diff --git a/src/PlanViewer.App/AboutWindow.axaml b/src/PlanViewer.App/AboutWindow.axaml
index 8f5b51e..97d2223 100644
--- a/src/PlanViewer.App/AboutWindow.axaml
+++ b/src/PlanViewer.App/AboutWindow.axaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PlanViewer.App.AboutWindow"
Title="About Performance Studio"
- Width="450" Height="420"
+ Width="450" Height="460"
CanResize="False"
WindowStartupLocation="CenterOwner"
Icon="avares://PlanViewer.App/EDD.ico"
@@ -63,6 +63,12 @@
+
+
+
+
diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs
index c47485c..66a487e 100644
--- a/src/PlanViewer.App/AboutWindow.axaml.cs
+++ b/src/PlanViewer.App/AboutWindow.axaml.cs
@@ -12,6 +12,7 @@
using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using PlanViewer.App.Mcp;
@@ -59,6 +60,18 @@ private void SaveMcpSettings()
private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);
private void ReportIssueLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(IssuesUrl);
private void DarlingDataLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(DarlingDataUrl);
+ private async void CopyMcpCommand_Click(object? sender, RoutedEventArgs e)
+ {
+ var port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
+ var command = $"claude mcp add --transport streamable-http --scope user performance-studio http://localhost:{port}/";
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(command);
+ McpCopyStatus.Text = "Copied to clipboard!";
+ }
+ }
+
private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
private static void OpenUrl(string url)
diff --git a/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml
new file mode 100644
index 0000000..5a54155
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs
new file mode 100644
index 0000000..b2b7f32
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterPopup.axaml.cs
@@ -0,0 +1,113 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+
+namespace PlanViewer.App.Controls;
+
+public partial class ColumnFilterPopup : UserControl
+{
+ public event EventHandler? FilterApplied;
+ public event EventHandler? FilterCleared;
+
+ private string _currentColumnName = "";
+
+ private static readonly (string Display, FilterOperator Op)[] Operators =
+ [
+ ("Contains", FilterOperator.Contains),
+ ("Equals (=)", FilterOperator.Equals),
+ ("Not Equals (!=)", FilterOperator.NotEquals),
+ ("Starts With", FilterOperator.StartsWith),
+ ("Ends With", FilterOperator.EndsWith),
+ ("Greater Than (>)", FilterOperator.GreaterThan),
+ ("Greater or Equal (>=)", FilterOperator.GreaterThanOrEqual),
+ ("Less Than (<)", FilterOperator.LessThan),
+ ("Less or Equal (<=)", FilterOperator.LessThanOrEqual),
+ ("Is Empty", FilterOperator.IsEmpty),
+ ("Is Not Empty", FilterOperator.IsNotEmpty),
+ ];
+
+ public ColumnFilterPopup()
+ {
+ InitializeComponent();
+ foreach (var (display, _) in Operators)
+ OperatorComboBox.Items.Add(display);
+ OperatorComboBox.SelectedIndex = 0;
+ }
+
+ public void Initialize(string columnName, ColumnFilterState? existingFilter)
+ {
+ _currentColumnName = columnName;
+ HeaderText.Text = $"Filter: {columnName}";
+
+ if (existingFilter?.IsActive == true)
+ {
+ var idx = Array.FindIndex(Operators, o => o.Op == existingFilter.Operator);
+ OperatorComboBox.SelectedIndex = idx >= 0 ? idx : 0;
+ ValueTextBox.Text = existingFilter.Value;
+ }
+ else
+ {
+ OperatorComboBox.SelectedIndex = 0;
+ ValueTextBox.Text = "";
+ }
+
+ UpdateValueVisibility();
+ ValueTextBox.Focus();
+ }
+
+ private void UpdateValueVisibility()
+ {
+ var idx = OperatorComboBox.SelectedIndex;
+ var op = (idx >= 0 && idx < Operators.Length) ? Operators[idx].Op : FilterOperator.Contains;
+ var showValue = op != FilterOperator.IsEmpty && op != FilterOperator.IsNotEmpty;
+ ValueLabel.IsVisible = showValue;
+ ValueTextBox.IsVisible = showValue;
+ }
+
+ private void OperatorComboBox_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ UpdateValueVisibility();
+ }
+
+ private void ApplyFilter()
+ {
+ var idx = OperatorComboBox.SelectedIndex;
+ if (idx < 0 || idx >= Operators.Length) return;
+
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs
+ {
+ FilterState = new ColumnFilterState
+ {
+ ColumnName = _currentColumnName,
+ Operator = Operators[idx].Op,
+ Value = ValueTextBox.Text ?? "",
+ }
+ });
+ }
+
+ private void ApplyButton_Click(object? sender, RoutedEventArgs e) => ApplyFilter();
+
+ private void ClearButton_Click(object? sender, RoutedEventArgs e)
+ {
+ FilterApplied?.Invoke(this, new FilterAppliedEventArgs
+ {
+ FilterState = new ColumnFilterState { ColumnName = _currentColumnName }
+ });
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void ValueTextBox_KeyDown(object? sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ ApplyFilter();
+ e.Handled = true;
+ }
+ else if (e.Key == Key.Escape)
+ {
+ FilterCleared?.Invoke(this, EventArgs.Empty);
+ e.Handled = true;
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Controls/ColumnFilterState.cs b/src/PlanViewer.App/Controls/ColumnFilterState.cs
new file mode 100644
index 0000000..1cc8176
--- /dev/null
+++ b/src/PlanViewer.App/Controls/ColumnFilterState.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace PlanViewer.App.Controls;
+
+public enum FilterOperator
+{
+ Contains,
+ Equals,
+ NotEquals,
+ StartsWith,
+ EndsWith,
+ GreaterThan,
+ GreaterThanOrEqual,
+ LessThan,
+ LessThanOrEqual,
+ IsEmpty,
+ IsNotEmpty,
+}
+
+public class ColumnFilterState
+{
+ public string ColumnName { get; set; } = string.Empty;
+ public FilterOperator Operator { get; set; } = FilterOperator.Contains;
+ public string Value { get; set; } = string.Empty;
+
+ public bool IsActive =>
+ !string.IsNullOrEmpty(Value) ||
+ Operator == FilterOperator.IsEmpty ||
+ Operator == FilterOperator.IsNotEmpty;
+
+ public string DisplayText
+ {
+ get
+ {
+ if (!IsActive) return string.Empty;
+
+ return Operator switch
+ {
+ FilterOperator.Contains => $"Contains '{Value}'",
+ FilterOperator.Equals => $"= '{Value}'",
+ FilterOperator.NotEquals => $"!= '{Value}'",
+ FilterOperator.GreaterThan => $"> {Value}",
+ FilterOperator.GreaterThanOrEqual => $">= {Value}",
+ FilterOperator.LessThan => $"< {Value}",
+ FilterOperator.LessThanOrEqual => $"<= {Value}",
+ FilterOperator.StartsWith => $"Starts with '{Value}'",
+ FilterOperator.EndsWith => $"Ends with '{Value}'",
+ FilterOperator.IsEmpty => "Is Empty",
+ FilterOperator.IsNotEmpty => "Is Not Empty",
+ _ => Value,
+ };
+ }
+ }
+}
+
+public class FilterAppliedEventArgs : EventArgs
+{
+ public ColumnFilterState FilterState { get; set; } = new ColumnFilterState();
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 9366b28..b050af6 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -60,14 +60,33 @@
+
-
-
+
+
+
+
+
+
+
+
+
+
+
@@ -81,7 +100,7 @@
-
-
-
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 2b4afb1..90d0b97 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -57,6 +57,7 @@ public partial class PlanViewerControl : UserControl
private ParsedPlan? _currentPlan;
private PlanStatement? _currentStatement;
private string? _queryText;
+ private ServerMetadata? _serverMetadata;
private double _zoomLevel = 1.0;
private const double ZoomStep = 0.15;
private const double MinZoom = 0.1;
@@ -146,6 +147,20 @@ public PlanViewerControl()
///
public string? QueryText => _queryText;
+ ///
+ /// Server metadata for advice generation and Plan Insights display.
+ ///
+ public ServerMetadata? Metadata
+ {
+ get => _serverMetadata;
+ set
+ {
+ _serverMetadata = value;
+ if (_currentStatement != null)
+ ShowServerContext();
+ }
+ }
+
public void LoadPlan(string planXml, string label, string? queryText = null)
{
_label = label;
@@ -307,8 +322,17 @@ private Border CreateNodeVisual(PlanNode node, int totalWarningCount = -1)
// Map border to node (replaces WPF Tag)
_nodeBorderMap[border] = node;
- // Tooltip
- ToolTip.SetTip(border, BuildNodeTooltipContent(node));
+ // Tooltip — root node gets all collected warnings so the tooltip shows them
+ if (totalWarningCount > 0)
+ {
+ var allWarnings = new List();
+ CollectWarnings(node, allWarnings);
+ ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings));
+ }
+ else
+ {
+ ToolTip.SetTip(border, BuildNodeTooltipContent(node));
+ }
// Click to select + show properties
border.PointerPressed += Node_Click;
@@ -1441,7 +1465,7 @@ private void ShowPropertiesPanel(PlanNode node)
// === Plan-Level Warnings ===
if (s.PlanWarnings.Count > 0)
{
- AddPropertySection("Plan Warnings");
+ var planWarningsPanel = new StackPanel();
foreach (var w in s.PlanWarnings)
{
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
@@ -1462,8 +1486,30 @@ private void ShowPropertiesPanel(PlanNode node)
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(16, 0, 0, 0)
});
- (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel);
+ planWarningsPanel.Children.Add(warnPanel);
}
+
+ var planWarningsExpander = new Expander
+ {
+ IsExpanded = true,
+ Header = new TextBlock
+ {
+ Text = "Plan Warnings",
+ FontWeight = FontWeight.SemiBold,
+ FontSize = 11,
+ Foreground = SectionHeaderBrush
+ },
+ Content = planWarningsPanel,
+ Margin = new Thickness(0, 2, 0, 0),
+ Padding = new Thickness(0),
+ Foreground = SectionHeaderBrush,
+ Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
+ BorderBrush = PropSeparatorBrush,
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch
+ };
+ PropertiesContent.Children.Add(planWarningsExpander);
}
// === Missing Indexes ===
@@ -1482,7 +1528,7 @@ private void ShowPropertiesPanel(PlanNode node)
// === Warnings ===
if (node.HasWarnings)
{
- AddPropertySection("Warnings");
+ var warningsPanel = new StackPanel();
foreach (var w in node.Warnings)
{
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
@@ -1503,8 +1549,30 @@ private void ShowPropertiesPanel(PlanNode node)
TextWrapping = TextWrapping.Wrap,
Margin = new Thickness(16, 0, 0, 0)
});
- PropertiesContent.Children.Add(warnPanel);
+ warningsPanel.Children.Add(warnPanel);
}
+
+ var warningsExpander = new Expander
+ {
+ IsExpanded = true,
+ Header = new TextBlock
+ {
+ Text = "Warnings",
+ FontWeight = FontWeight.SemiBold,
+ FontSize = 11,
+ Foreground = SectionHeaderBrush
+ },
+ Content = warningsPanel,
+ Margin = new Thickness(0, 2, 0, 0),
+ Padding = new Thickness(0),
+ Foreground = SectionHeaderBrush,
+ Background = new SolidColorBrush(Color.FromArgb(0x18, 0x4F, 0xA3, 0xFF)),
+ BorderBrush = PropSeparatorBrush,
+ BorderThickness = new Thickness(0, 0, 0, 1),
+ HorizontalAlignment = HorizontalAlignment.Stretch,
+ HorizontalContentAlignment = HorizontalAlignment.Stretch
+ };
+ PropertiesContent.Children.Add(warningsExpander);
}
// Show the panel
@@ -1649,7 +1717,7 @@ private void ClosePropertiesPanel()
#region Tooltips
- private object BuildNodeTooltipContent(PlanNode node)
+ private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null)
{
var tipBorder = new Border
{
@@ -1790,11 +1858,12 @@ private object BuildNodeTooltipContent(PlanNode node)
AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true);
}
- // Warnings
- if (node.HasWarnings)
+ // Warnings — use allWarnings (all nodes) for root, node.Warnings for others
+ var warnings = allWarnings ?? (node.HasWarnings ? node.Warnings : null);
+ if (warnings != null && warnings.Count > 0)
{
stack.Children.Add(new Separator { Margin = new Thickness(0, 6, 0, 6) });
- foreach (var w in node.Warnings)
+ foreach (var w in warnings)
{
var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
@@ -2317,6 +2386,77 @@ void AddRow(string label, string value)
AddRow("Early abort", statement.StatementOptmEarlyAbortReason);
RuntimeSummaryContent.Children.Add(grid);
+ ShowServerContext();
+ }
+
+ private void ShowServerContext()
+ {
+ ServerContextContent.Children.Clear();
+ if (_serverMetadata == null)
+ {
+ ServerContextBorder.IsVisible = false;
+ return;
+ }
+
+ var m = _serverMetadata;
+ var fgColor = "#E4E6EB";
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") };
+ int rowIndex = 0;
+
+ void AddRow(string label, string value)
+ {
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ var lb = new TextBlock
+ {
+ Text = label, FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(fgColor)),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 1, 8, 1)
+ };
+ Grid.SetRow(lb, rowIndex);
+ Grid.SetColumn(lb, 0);
+ grid.Children.Add(lb);
+
+ var vb = new TextBlock
+ {
+ Text = value, FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(fgColor)),
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetRow(vb, rowIndex);
+ Grid.SetColumn(vb, 1);
+ grid.Children.Add(vb);
+ rowIndex++;
+ }
+
+ // Server name + edition
+ var edition = m.Edition;
+ if (edition != null)
+ {
+ var idx = edition.IndexOf(" (64-bit)");
+ if (idx > 0) edition = edition[..idx];
+ }
+ var serverLine = m.ServerName ?? "Unknown";
+ if (edition != null) serverLine += $" ({edition})";
+ if (m.ProductVersion != null) serverLine += $", {m.ProductVersion}";
+ AddRow("Server", serverLine);
+
+ // Hardware
+ if (m.CpuCount > 0)
+ AddRow("Hardware", $"{m.CpuCount} CPUs, {m.PhysicalMemoryMB:N0} MB RAM");
+
+ // Instance settings
+ AddRow("MAXDOP", m.MaxDop.ToString());
+ AddRow("Cost threshold", m.CostThresholdForParallelism.ToString());
+ AddRow("Max memory", $"{m.MaxServerMemoryMB:N0} MB");
+
+ // Database
+ if (m.Database != null)
+ AddRow("Database", $"{m.Database.Name} (compat {m.Database.CompatibilityLevel})");
+
+ ServerContextContent.Children.Add(grid);
+ ServerContextBorder.IsVisible = true;
}
private void UpdateInsightsHeader()
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index 2dcb52f..7311a70 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -8,6 +8,7 @@
using System.Xml.Linq;
using Avalonia.Controls;
using Avalonia.Input;
+using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
@@ -120,7 +121,7 @@ private void SetupEditorContextMenu()
{
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard == null) return;
- var text = await clipboard.GetTextAsync();
+ var text = await clipboard.TryGetTextAsync();
if (string.IsNullOrEmpty(text)) return;
QueryEditor.TextArea.PerformTextInput(text);
};
@@ -641,6 +642,7 @@ private void AddPlanTab(string planXml, string queryText, bool estimated, string
var label = labelOverride ?? (estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}");
var viewer = new PlanViewerControl();
+ viewer.Metadata = _serverMetadata;
viewer.LoadPlan(planXml, label, queryText);
// Build tab header with close button and right-click rename
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 74e346d..6fdc840 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -70,7 +70,7 @@
+ BorderThickness="0"
+ ScrollViewer.HorizontalScrollBarVisibility="Auto">
@@ -95,20 +96,20 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
_rows = new();
+ private ObservableCollection _filteredRows = new();
+ private readonly Dictionary _activeFilters = new();
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
public event EventHandler>? PlansSelected;
@@ -26,7 +33,9 @@ public QueryStoreGridControl(string connectionString, string database)
_connectionString = connectionString;
_database = database;
InitializeComponent();
- ResultsGrid.ItemsSource = _rows;
+ ResultsGrid.ItemsSource = _filteredRows;
+ EnsureFilterPopup();
+ SetupColumnHeaders();
}
private async void Fetch_Click(object? sender, RoutedEventArgs e)
@@ -43,6 +52,7 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
LoadButton.IsEnabled = false;
StatusText.Text = "Fetching...";
_rows.Clear();
+ _filteredRows.Clear();
try
{
@@ -58,7 +68,7 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
foreach (var plan in plans)
_rows.Add(new QueryStoreRow(plan));
- StatusText.Text = $"{plans.Count} plans";
+ ApplyFilters();
LoadButton.IsEnabled = true;
}
catch (OperationCanceledException)
@@ -77,19 +87,19 @@ private async void Fetch_Click(object? sender, RoutedEventArgs e)
private void SelectAll_Click(object? sender, RoutedEventArgs e)
{
- foreach (var row in _rows)
+ foreach (var row in _filteredRows)
row.IsSelected = true;
}
private void SelectNone_Click(object? sender, RoutedEventArgs e)
{
- foreach (var row in _rows)
+ foreach (var row in _filteredRows)
row.IsSelected = false;
}
private void LoadSelected_Click(object? sender, RoutedEventArgs e)
{
- var selected = _rows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ var selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
if (selected.Count > 0)
PlansSelected?.Invoke(this, selected);
}
@@ -99,6 +109,229 @@ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
if (ResultsGrid.SelectedItem is QueryStoreRow row)
PlansSelected?.Invoke(this, new List { row.Plan });
}
+
+ // ── Column filter infrastructure ───────────────────────────────────────
+
+ private static readonly Dictionary> TextAccessors = new()
+ {
+ ["LastExecuted"] = r => r.LastExecutedLocal,
+ ["QueryText"] = r => r.FullQueryText,
+ };
+
+ private static readonly Dictionary> NumericAccessors = new()
+ {
+ ["QueryId"] = r => r.QueryId,
+ ["PlanId"] = r => r.PlanId,
+ ["Executions"] = r => r.ExecsSort,
+ ["TotalCpu"] = r => r.TotalCpuSort / 1000.0, // µs → ms (matches display)
+ ["AvgCpu"] = r => r.AvgCpuSort / 1000.0, // µs → ms
+ ["TotalDuration"] = r => r.TotalDurSort / 1000.0, // µs → ms
+ ["AvgDuration"] = r => r.AvgDurSort / 1000.0, // µs → ms
+ ["TotalReads"] = r => r.TotalReadsSort,
+ ["AvgReads"] = r => r.AvgReadsSort,
+ ["TotalWrites"] = r => r.TotalWritesSort,
+ ["AvgWrites"] = r => r.AvgWritesSort,
+ ["TotalPhysReads"] = r => r.TotalPhysReadsSort,
+ ["AvgPhysReads"] = r => r.AvgPhysReadsSort,
+ ["TotalMemory"] = r => r.TotalMemSort * 8.0 / 1024.0, // pages → MB (matches display)
+ ["AvgMemory"] = r => r.AvgMemSort * 8.0 / 1024.0, // pages → MB
+ };
+
+ private void SetupColumnHeaders()
+ {
+ var cols = ResultsGrid.Columns;
+ SetColumnFilterButton(cols[1], "QueryId", "Query ID");
+ SetColumnFilterButton(cols[2], "PlanId", "Plan ID");
+ SetColumnFilterButton(cols[3], "LastExecuted", "Last Executed (Local)");
+ SetColumnFilterButton(cols[4], "Executions", "Executions");
+ SetColumnFilterButton(cols[5], "TotalCpu", "Total CPU (ms)");
+ SetColumnFilterButton(cols[6], "AvgCpu", "Avg CPU (ms)");
+ SetColumnFilterButton(cols[7], "TotalDuration", "Total Duration (ms)");
+ SetColumnFilterButton(cols[8], "AvgDuration", "Avg Duration (ms)");
+ SetColumnFilterButton(cols[9], "TotalReads", "Total Reads");
+ SetColumnFilterButton(cols[10], "AvgReads", "Avg Reads");
+ SetColumnFilterButton(cols[11], "TotalWrites", "Total Writes");
+ SetColumnFilterButton(cols[12], "AvgWrites", "Avg Writes");
+ SetColumnFilterButton(cols[13], "TotalPhysReads", "Total Physical Reads");
+ SetColumnFilterButton(cols[14], "AvgPhysReads", "Avg Physical Reads");
+ SetColumnFilterButton(cols[15], "TotalMemory", "Total Memory (MB)");
+ SetColumnFilterButton(cols[16], "AvgMemory", "Avg Memory (MB)");
+ SetColumnFilterButton(cols[17], "QueryText", "Query Text");
+ }
+
+ private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
+ {
+ var icon = new TextBlock
+ {
+ Text = "▽",
+ FontSize = 12,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ };
+ var btn = new Button
+ {
+ Content = icon,
+ Tag = columnId,
+ Width = 16,
+ Height = 16,
+ Padding = new Avalonia.Thickness(0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ };
+ btn.Click += ColumnFilter_Click;
+ ToolTip.SetTip(btn, "Click to filter");
+
+ var text = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Margin = new Avalonia.Thickness(4, 0, 0, 0),
+ };
+
+ var header = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ };
+ header.Children.Add(btn);
+ header.Children.Add(text);
+ col.Header = header;
+ }
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup != null) return;
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ IsLightDismissEnabled = true,
+ Placement = PlacementMode.Bottom,
+ };
+ // Add to visual tree so DynamicResources resolve inside the popup
+ ((Grid)Content!).Children.Add(_filterPopup);
+ _filterPopupContent.FilterApplied += OnFilterApplied;
+ _filterPopupContent.FilterCleared += OnFilterCleared;
+ }
+
+ private void ColumnFilter_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnId) return;
+ EnsureFilterPopup();
+ _activeFilters.TryGetValue(columnId, out var existing);
+ _filterPopupContent!.Initialize(columnId, existing);
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void OnFilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ if (e.FilterState.IsActive)
+ _activeFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _activeFilters.Remove(e.FilterState.ColumnName);
+ ApplyFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private void OnFilterCleared(object? sender, EventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ }
+
+ private void UpdateFilterButtonStyles()
+ {
+ foreach (var col in ResultsGrid.Columns)
+ {
+ if (col.Header is not StackPanel sp) continue;
+ var btn = sp.Children.OfType