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.