diff --git a/Darling/Darling.Tests/Viewer4574Tests.cs b/Darling/Darling.Tests/Viewer4574Tests.cs new file mode 100644 index 0000000000..d9bb25840a --- /dev/null +++ b/Darling/Darling.Tests/Viewer4574Tests.cs @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System.Collections.Generic; +using PerformanceMonitor.PlanAnalysis; +using Xunit; + +namespace Darling.Tests; + +/// +/// #4574: pulls the properties panel's row model, filter matching and +/// "Copy all properties" text out of PlanViewerControl.Properties.cs (WPF, can't run in a +/// unit test on macOS) so this suite can pin them directly. New type, so every assertion here is +/// a compile-only RED against dev: PropertyRows doesn't exist on dev. +/// +public sealed class Viewer4574Tests +{ + private static PropertySection Section(string title, params PropertyRow[] rows) + { + var section = new PropertySection { Title = title }; + section.Rows.AddRange(rows); + return section; + } + + [Fact] + public void SectionTitleMatches_EmptyFilter_AlwaysTrue() + { + Assert.True(PropertyRows.SectionTitleMatches("General", "")); + } + + [Fact] + public void SectionTitleMatches_IsCaseInsensitive() + { + Assert.True(PropertyRows.SectionTitleMatches("General", "GENERAL")); + Assert.True(PropertyRows.SectionTitleMatches("General", "general")); + } + + [Fact] + public void SectionTitleMatches_NoMatch_False() + { + Assert.False(PropertyRows.SectionTitleMatches("General", "zzz")); + } + + [Fact] + public void RowMatches_SectionTitleMatch_KeepsEveryRowRegardlessOfOwnText() + { + var row = new PropertyRow { Label = "Node ID", Value = "3", SearchText = "Node ID 3" }; + + // Filter text "general" matches the section title, not this row's own text. + Assert.True(PropertyRows.RowMatches(row, "general", sectionTitleMatches: true)); + } + + [Fact] + public void RowMatches_OwnTextMatch_CaseInsensitiveOnLabelAndValue() + { + var labelRow = new PropertyRow { Label = "Physical Operation", Value = "Hash Match", SearchText = "Physical Operation Hash Match" }; + var valueRow = new PropertyRow { Label = "Ordered", Value = "False", SearchText = "Ordered False" }; + + Assert.True(PropertyRows.RowMatches(labelRow, "PHYSICAL", sectionTitleMatches: false)); + Assert.True(PropertyRows.RowMatches(valueRow, "false", sectionTitleMatches: false)); + } + + [Fact] + public void RowMatches_NoMatch_False() + { + var row = new PropertyRow { Label = "Ordered", Value = "False", SearchText = "Ordered False" }; + + Assert.False(PropertyRows.RowMatches(row, "zzz", sectionTitleMatches: false)); + } + + [Fact] + public void SectionVisible_NoVisibleRows_False() + { + Assert.False(PropertyRows.SectionVisible(0)); + } + + [Fact] + public void SectionVisible_AtLeastOneVisibleRow_True() + { + Assert.True(PropertyRows.SectionVisible(1)); + } + + [Fact] + public void ClampWidth_BelowMin_ClampsUp() + { + Assert.Equal(PropertyRows.MinPropertiesWidth, PropertyRows.ClampWidth(50)); + } + + [Fact] + public void ClampWidth_AboveMax_ClampsDown() + { + Assert.Equal(PropertyRows.MaxPropertiesWidth, PropertyRows.ClampWidth(5000)); + } + + [Fact] + public void ClampWidth_WithinBounds_Unchanged() + { + Assert.Equal(400, PropertyRows.ClampWidth(400)); + } + + [Fact] + public void CopyValue_OrdinaryRow_ReturnsValue() + { + var row = new PropertyRow { Label = "Node ID", Value = "3" }; + + Assert.Equal("3", row.CopyValue); + } + + [Fact] + public void CopyLabelAndValue_OrdinaryRow_FormatsLabelColonValue() + { + var row = new PropertyRow { Label = "Node ID", Value = "3" }; + + Assert.Equal("Node ID: 3", row.CopyLabelAndValue); + } + + [Fact] + public void CopyValue_BlockRow_ReturnsBlockTextNotValue() + { + var row = new PropertyRow { Label = "Thread 1", Value = "ignored", BlockText = "Thread 1: 500 ms" }; + + Assert.Equal("Thread 1: 500 ms", row.CopyValue); + Assert.Equal("Thread 1: 500 ms", row.CopyLabelAndValue); + } + + [Fact] + public void BuildPropertiesText_OrdinaryRow_FormatsAsIndentedLabelColonValue() + { + var sections = new List + { + Section("General", + new PropertyRow { Label = "Physical Operation", Value = "Hash Match" }) + }; + + var text = PropertyRows.BuildPropertiesText("Hash Match", "Node ID: 3", sections); + + Assert.Equal( + "Hash Match\nNode ID: 3\n\nGeneral\n Physical Operation: Hash Match", + text.Replace("\r\n", "\n")); + } + + [Fact] + public void BuildPropertiesText_CodeRow_PutsValueOnItsOwnLineVerbatim() + { + var sections = new List + { + Section("Object", + new PropertyRow { Label = "Full Name", Value = "[Db].[dbo].[Table]", IsCode = true }) + }; + + var text = PropertyRows.BuildPropertiesText("Clustered Index Scan", "", sections); + + Assert.Equal( + "Clustered Index Scan\n\nObject\n Full Name:\n[Db].[dbo].[Table]", + text.Replace("\r\n", "\n")); + } + + [Fact] + public void BuildPropertiesText_BlockRow_EmitsBlockTextVerbatim() + { + var sections = new List + { + Section("Actual CPU", + new PropertyRow { BlockText = "CPU\n Thread 0: 10 ms\n Thread 1: 20 ms" }) + }; + + var text = PropertyRows.BuildPropertiesText("Hash Match", "", sections); + + Assert.Equal( + "Hash Match\n\nActual CPU\nCPU\n Thread 0: 10 ms\n Thread 1: 20 ms", + text.Replace("\r\n", "\n")); + } + + [Fact] + public void BuildPropertiesText_SectionWithNoRows_IsSkipped() + { + var sections = new List + { + Section("Empty"), + Section("General", new PropertyRow { Label = "Node ID", Value = "3" }) + }; + + var text = PropertyRows.BuildPropertiesText("Hash Match", "", sections); + + Assert.DoesNotContain("Empty", text); + Assert.Contains("General", text); + } +} diff --git a/PerformanceMonitor.PlanAnalysis/PropertyRows.cs b/PerformanceMonitor.PlanAnalysis/PropertyRows.cs new file mode 100644 index 0000000000..e12825955b --- /dev/null +++ b/PerformanceMonitor.PlanAnalysis/PropertyRows.cs @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using System.Collections.Generic; +using System.Text; + +namespace PerformanceMonitor.PlanAnalysis; + +/// +/// One row of the properties panel: what it says, and what the filter box and the copy menu work +/// from. The viewer's panel is raw WPF controls with no bindings behind them, so once a row is in +/// the visual tree its text is the only thing left to read back, which is why this is recorded +/// separately while the panel builds rather than reconstructed later. Kept here (not in +/// PerformanceMonitor.Ui, which is net10.0-windows-only) so a unit test can pin the filter +/// match and the copy text without needing WPF. +/// +public sealed class PropertyRow +{ + public string Label { get; init; } = ""; + public string Value { get; init; } = ""; + public bool IsCode { get; init; } + + /// + /// Plain text for rows that are not a label/value pair (the per-thread breakdown, a warning + /// entry). Null for ordinary rows, which the copy menu renders from and + /// . + /// + public string? BlockText { get; init; } + + /// What the filter box matches against, case-insensitively. + public string SearchText { get; init; } = ""; + + /// What "Copy value" hands back. + public string CopyValue => BlockText ?? Value; + + /// What "Copy name and value" hands back. + public string CopyLabelAndValue => BlockText + ?? (string.IsNullOrEmpty(Label) ? Value : $"{Label}: {Value}"); +} + +/// One section of the properties panel: a title and the rows filed under it. +public sealed class PropertySection +{ + public string Title { get; init; } = ""; + public List Rows { get; } = new(); +} + +/// +/// Pure filter-match and copy-text logic for the properties panel, shared by the Darling Viewer +/// and Lite (both host PerformanceMonitor.Ui.PlanViewerControl). Mirrors +/// PerformanceStudio's Avalonia properties panel: same filter behaviour (case-insensitive on +/// label+value, a section whose title matches keeps every row, an empty section always hides), +/// same "Copy all properties" text layout, same width default/clamp. +/// +public static class PropertyRows +{ + /// The width the panel opens to when nothing has been remembered yet. + public const double DefaultPropertiesWidth = 380; + + /// The narrowest the panel can be dragged to while open. + public const double MinPropertiesWidth = 280; + + /// The widest the panel can be dragged to while open. + public const double MaxPropertiesWidth = 800; + + /// Clamps a remembered/dragged width to the panel's open-state bounds. + public static double ClampWidth(double width) => + Math.Clamp(width, MinPropertiesWidth, MaxPropertiesWidth); + + /// + /// True when a section's own title matches the filter (case-insensitive), which is what lets + /// typing a section name jump to that section rather than empty it: every row under a + /// title-matching section stays visible regardless of its own text. + /// + public static bool SectionTitleMatches(string sectionTitle, string filter) => + filter.Length == 0 || sectionTitle.Contains(filter, StringComparison.OrdinalIgnoreCase); + + /// + /// True when a row should stay visible: either its section's title already matched (see + /// ), or the row's own label+value text does. + /// + public static bool RowMatches(PropertyRow row, string filter, bool sectionTitleMatches) => + sectionTitleMatches || row.SearchText.Contains(filter, StringComparison.OrdinalIgnoreCase); + + /// + /// True when a section with the given visible-row count should itself stay visible. An + /// empty section (no rows at all, filtered or not) is always hidden — a few sections build + /// with zero rows depending on the node, and an expander with nothing inside it is noise + /// whether or not anyone is filtering. + /// + public static bool SectionVisible(int visibleRowCount) => visibleRowCount > 0; + + /// + /// The whole panel as plain text, for "Copy all properties": the operator header, then a + /// line per section title with " Label: Value" beneath it. Code values and block rows + /// (the per-thread breakdown, a warning entry) go out verbatim on their own lines, so a + /// predicate or a CREATE INDEX statement comes back pasteable rather than needing to be + /// re-indented first. Sections with no rows are skipped. + /// + public static string BuildPropertiesText( + string header, string subHeader, IReadOnlyList sections) + { + var text = new StringBuilder(); + text.AppendLine(header); + if (!string.IsNullOrEmpty(subHeader)) + text.AppendLine(subHeader); + + foreach (var section in sections) + { + if (section.Rows.Count == 0) continue; + + text.AppendLine(); + text.AppendLine(section.Title); + foreach (var row in section.Rows) + { + if (row.BlockText != null) + { + text.AppendLine(row.BlockText); + } + else if (row.IsCode) + { + if (!string.IsNullOrEmpty(row.Label)) + text.Append(" ").Append(row.Label).AppendLine(":"); + text.AppendLine(row.Value); + } + else + { + text.Append(" ").Append(row.Label).Append(": ").AppendLine(row.Value); + } + } + } + + return text.ToString().TrimEnd(); + } +} diff --git a/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs b/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs index 3e4fdcbec9..ed70265fb5 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs +++ b/PerformanceMonitor.Ui/PlanViewerControl.Properties.cs @@ -21,10 +21,32 @@ public partial class PlanViewerControl { #region Properties Panel + /// + /// One row of the properties panel in WPF terms: the pure the + /// filter and copy menu work from, plus every control the row occupies (so the filter can + /// hide them) and the shared copy menu. + /// + private sealed class PropertyPanelRow + { + public PropertyRow Model { get; init; } = new(); + public List Controls { get; } = new(); + public ContextMenu? Menu { get; set; } + } + + private sealed class PropertyPanelSection + { + public string Title { get; init; } = ""; + public Expander Expander { get; init; } = null!; + public List Rows { get; } = new(); + public PropertySection Model { get; init; } = null!; + } + private void ShowPropertiesPanel(PlanNode node) { PropertiesContent.Children.Clear(); _currentPropertySection = null; + _propertySections.Clear(); + _currentSection = null; // Header var headerText = node.PhysicalOp; @@ -835,6 +857,7 @@ private void ShowPropertiesPanel(PlanNode node) Margin = new Thickness(16, 0, 0, 0) }); (_currentPropertySection ?? PropertiesContent).Children.Add(warnPanel); + RegisterBuiltWarningRow(planWarnHeaderText, w.Message, warnPanel); } } @@ -879,13 +902,61 @@ private void ShowPropertiesPanel(PlanNode node) Margin = new Thickness(16, 0, 0, 0) }); PropertiesContent.Children.Add(warnPanel); + RegisterBuiltWarningRow(opWarnHeaderText, w.Message, warnPanel); } } - // Show the panel - PropertiesColumn.Width = new GridLength(320); - PropertiesSplitter.Visibility = Visibility.Visible; - PropertiesPanel.Visibility = Visibility.Visible; + // The filter box keeps its text across selections, so a rebuilt panel has to re-apply it. + ApplyPropertiesFilter(); + + // Show the panel. The width is set only when the panel is opening: setting it on every + // selection would throw away whatever width the user had dragged out, on every click. + if (PropertiesPanel.Visibility != Visibility.Visible) + { + PropertiesColumn.MinWidth = MinPropertiesWidth; + PropertiesColumn.MaxWidth = MaxPropertiesWidth; + PropertiesColumn.Width = new GridLength( + PerformanceMonitor.PlanAnalysis.PropertyRows.ClampWidth(_propertiesPanelWidth)); + PropertiesSplitter.Visibility = Visibility.Visible; + PropertiesPanel.Visibility = Visibility.Visible; + } + } + + private void PropertiesFilter_TextChanged(object sender, TextChangedEventArgs e) + => ApplyPropertiesFilter(); + + private void PropertiesFilter_KeyDown(object sender, System.Windows.Input.KeyEventArgs e) + { + if (e.Key != System.Windows.Input.Key.Escape) return; + PropertiesFilterBox.Text = ""; + e.Handled = true; + } + + /// + /// Hides every row whose label and value miss the filter text, and hides a section outright + /// once nothing in it is left showing. A section whose own title matches the filter keeps + /// all of its rows, so typing a section name jumps to that section rather than emptying it. + /// + private void ApplyPropertiesFilter() + { + var filter = PropertiesFilterBox.Text?.Trim() ?? ""; + + foreach (var section in _propertySections) + { + var sectionMatches = PerformanceMonitor.PlanAnalysis.PropertyRows.SectionTitleMatches(section.Title, filter); + + var visibleCount = 0; + foreach (var row in section.Rows) + { + var visible = PerformanceMonitor.PlanAnalysis.PropertyRows.RowMatches(row.Model, filter, sectionMatches); + foreach (var control in row.Controls) + control.Visibility = visible ? Visibility.Visible : Visibility.Collapsed; + if (visible) visibleCount++; + } + + section.Expander.Visibility = PerformanceMonitor.PlanAnalysis.PropertyRows.SectionVisible(visibleCount) + ? Visibility.Visible : Visibility.Collapsed; + } } private void AddPropertySection(string title) @@ -911,6 +982,8 @@ private void AddPropertySection(string title) }; PropertiesContent.Children.Add(expander); _currentPropertySection = contentPanel; + _currentSection = new PropertyPanelSection { Title = title, Expander = expander, Model = new PropertySection { Title = title } }; + _propertySections.Add(_currentSection); } private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) @@ -949,6 +1022,96 @@ private void AddPropertyRow(string label, string value, bool isCode = false, boo var target = _currentPropertySection ?? PropertiesContent; target.Children.Add(grid); + + if (_currentSection != null) + { + var entry = new PropertyPanelRow + { + Model = new PropertyRow { Label = label, Value = value, IsCode = isCode, SearchText = $"{label} {value}" } + }; + entry.Controls.Add(grid); + AttachPropertyRowMenu(valueBox, entry); + _currentSection.Rows.Add(entry); + _currentSection.Model.Rows.Add(entry.Model); + } + } + + /// + /// Wraps a warning panel built directly under (the plan-level + /// and per-operator warning lists, which are prose panels rather than label/value grids) as a + /// filterable, copyable row on the section most recently opened by . + /// + private void RegisterBuiltWarningRow(string header, string body, FrameworkElement panel) + { + if (_currentSection == null) return; + + var text = $" {header}\n {body}".TrimEnd(); + var entry = new PropertyPanelRow + { + Model = new PropertyRow { Label = header, Value = body, BlockText = text, SearchText = $"{header} {body}" } + }; + entry.Controls.Add(panel); + AttachPropertyRowMenu(panel, entry); + _currentSection.Rows.Add(entry); + _currentSection.Model.Rows.Add(entry.Model); + } + + /// + /// Gives a control the row's copy menu ('Copy value' / 'Copy name and value' / 'Copy all + /// properties'), replacing the stock read-only TextBox menu (greyed-out Cut/Copy, live Paste + /// on data nobody can edit). + /// + private void AttachPropertyRowMenu(FrameworkElement control, PropertyPanelRow entry) + { + control.ContextMenu = entry.Menu ??= BuildPropertyRowMenu(entry); + } + + private ContextMenu BuildPropertyRowMenu(PropertyPanelRow entry) + { + var menu = new ContextMenu(); + + var copyValueItem = new MenuItem { Header = "Copy value" }; + copyValueItem.Click += (_, _) => TrySetClipboardText(entry.Model.CopyValue); + menu.Items.Add(copyValueItem); + + var copyRowItem = new MenuItem { Header = "Copy name and value" }; + copyRowItem.Click += (_, _) => TrySetClipboardText(entry.Model.CopyLabelAndValue); + menu.Items.Add(copyRowItem); + + menu.Items.Add(new Separator()); + + var copyAllItem = new MenuItem { Header = "Copy all properties" }; + copyAllItem.Click += (_, _) => TrySetClipboardText(BuildPropertiesText()); + menu.Items.Add(copyAllItem); + + return menu; + } + + /// + /// The whole panel as plain text, for "Copy all properties": rendered from the row model + /// (), not the + /// visual tree, so a code value pastes back out verbatim. + /// + private string BuildPropertiesText() => + PerformanceMonitor.PlanAnalysis.PropertyRows.BuildPropertiesText( + PropertiesHeader.Text, PropertiesSubHeader.Text, _propertySections.ConvertAll(s => s.Model)); + + /// + /// Guarded clipboard write for the copy menu: a bare + /// throws when another process momentarily holds the clipboard (the same transient + /// CLIPBRD_E_CANT_OPEN condition guards on the read side). + /// + private static void TrySetClipboardText(string text) + { + try + { + Clipboard.SetText(text ?? ""); + } + catch (System.Runtime.InteropServices.ExternalException) + { + // Transient CLIPBRD_E_CANT_OPEN: another process holds the clipboard. No-op; the + // user can retry the copy. + } } private void CloseProperties_Click(object sender, RoutedEventArgs e) @@ -960,6 +1123,10 @@ private void ClosePropertiesPanel() { PropertiesPanel.Visibility = Visibility.Collapsed; PropertiesSplitter.Visibility = Visibility.Collapsed; + // Clear the open-state bounds first: MinWidth clamps the column whatever its Width says, + // so leaving it set would hold a strip open on a closed panel. + PropertiesColumn.MinWidth = 0; + PropertiesColumn.MaxWidth = double.PositiveInfinity; PropertiesColumn.Width = new GridLength(0); // Deselect node diff --git a/PerformanceMonitor.Ui/PlanViewerControl.xaml b/PerformanceMonitor.Ui/PlanViewerControl.xaml index 4899baa688..036d9112c2 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.xaml +++ b/PerformanceMonitor.Ui/PlanViewerControl.xaml @@ -260,6 +260,18 @@ + + + + + diff --git a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs index d7ada0dabc..3d16b66cdf 100644 --- a/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs +++ b/PerformanceMonitor.Ui/PlanViewerControl.xaml.cs @@ -56,6 +56,15 @@ public partial class PlanViewerControl : UserControl // Current property section for collapsible groups private StackPanel? _currentPropertySection; + // Properties panel row model, filter and remembered width (#4574). The width is static so a + // width the user drags out survives closing the panel and switching plan tabs. + private const double DefaultPropertiesWidth = PerformanceMonitor.PlanAnalysis.PropertyRows.DefaultPropertiesWidth; + private const double MinPropertiesWidth = PerformanceMonitor.PlanAnalysis.PropertyRows.MinPropertiesWidth; + private const double MaxPropertiesWidth = PerformanceMonitor.PlanAnalysis.PropertyRows.MaxPropertiesWidth; + private static double _propertiesPanelWidth = DefaultPropertiesWidth; + private readonly List _propertySections = new(); + private PropertyPanelSection? _currentSection; + // Canvas panning private bool _isPanning; private Point _panStart; @@ -69,6 +78,18 @@ public PlanViewerControl() fires Unloaded when you switch tabs, which would permanently detach this handler. Hosts call Cleanup() when the plan tab/window is actually closed. */ ThemeManager.ThemeChanged += OnThemeChanged; + + // The splitter writes the dragged size straight onto the column, so that is where the + // remembered width (#4574) comes from - no drag tracking of our own. + var widthDescriptor = System.ComponentModel.DependencyPropertyDescriptor.FromProperty( + System.Windows.Controls.ColumnDefinition.WidthProperty, typeof(ColumnDefinition)); + widthDescriptor?.AddValueChanged(PropertiesColumn, (_, _) => + { + if (PropertiesPanel.Visibility != Visibility.Visible) return; + var width = PropertiesColumn.Width; + if (width.IsAbsolute && width.Value > 0) + _propertiesPanelWidth = width.Value; + }); } /// Unsubscribes from theme changes. Hosts must call this when the plan tab/window is closed.