diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
new file mode 100644
index 0000000..0848235
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
@@ -0,0 +1,327 @@
+using System;
+using System.IO;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private void Node_Click(object? sender, PointerPressedEventArgs e)
+ {
+ if (sender is Border border
+ && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed
+ && _nodeBorderMap.TryGetValue(border, out var node))
+ {
+ SelectNode(border, node);
+ e.Handled = true;
+ }
+ }
+
+ private void SelectNode(Border border, PlanNode node)
+ {
+ // Deselect previous
+ if (_selectedNodeBorder != null)
+ {
+ _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder;
+ _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
+ }
+
+ // Select new
+ _selectedNodeOriginalBorder = border.BorderBrush;
+ _selectedNodeOriginalThickness = border.BorderThickness;
+ _selectedNodeBorder = border;
+ border.BorderBrush = SelectionBrush;
+ border.BorderThickness = new Thickness(2);
+
+ _selectedNode = node;
+ ShowPropertiesPanel(node);
+ UpdateMinimapSelection(node);
+ }
+
+ private ContextMenu BuildNodeContextMenu(PlanNode node)
+ {
+ var menu = new ContextMenu();
+
+ var propsItem = new MenuItem { Header = "Properties" };
+ propsItem.Click += (_, _) =>
+ {
+ foreach (var child in PlanCanvas.Children)
+ {
+ if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node)
+ {
+ SelectNode(b, node);
+ break;
+ }
+ }
+ };
+ menu.Items.Add(propsItem);
+
+ menu.Items.Add(new Separator());
+
+ var copyOpItem = new MenuItem { Header = "Copy Operator Name" };
+ copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp);
+ menu.Items.Add(copyOpItem);
+
+ if (!string.IsNullOrEmpty(node.FullObjectName))
+ {
+ var copyObjItem = new MenuItem { Header = "Copy Object Name" };
+ copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!);
+ menu.Items.Add(copyObjItem);
+ }
+
+ if (!string.IsNullOrEmpty(node.Predicate))
+ {
+ var copyPredItem = new MenuItem { Header = "Copy Predicate" };
+ copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!);
+ menu.Items.Add(copyPredItem);
+ }
+
+ if (!string.IsNullOrEmpty(node.SeekPredicates))
+ {
+ var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" };
+ copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!);
+ menu.Items.Add(copySeekItem);
+ }
+
+ // Schema lookup items (Show Indexes, Show Table Definition)
+ AddSchemaMenuItems(menu, node);
+
+ return menu;
+ }
+
+ private ContextMenu BuildCanvasContextMenu()
+ {
+ var menu = new ContextMenu();
+
+ // Zoom
+ var zoomInItem = new MenuItem { Header = "Zoom In" };
+ zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep);
+ menu.Items.Add(zoomInItem);
+
+ var zoomOutItem = new MenuItem { Header = "Zoom Out" };
+ zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep);
+ menu.Items.Add(zoomOutItem);
+
+ var fitItem = new MenuItem { Header = "Fit to View" };
+ fitItem.Click += ZoomFit_Click;
+ menu.Items.Add(fitItem);
+
+ menu.Items.Add(new Separator());
+
+ // Advice
+ var humanAdviceItem = new MenuItem { Header = "Human Advice" };
+ humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(humanAdviceItem);
+
+ var robotAdviceItem = new MenuItem { Header = "Robot Advice" };
+ robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(robotAdviceItem);
+
+ menu.Items.Add(new Separator());
+
+ // Repro & Save
+ var copyReproItem = new MenuItem { Header = "Copy Repro Script" };
+ copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty);
+ menu.Items.Add(copyReproItem);
+
+ var saveItem = new MenuItem { Header = "Save .sqlplan" };
+ saveItem.Click += SavePlan_Click;
+ menu.Items.Add(saveItem);
+
+ return menu;
+ }
+
+ private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.Clipboard != null)
+ await topLevel.Clipboard.SetTextAsync(text);
+ }
+
+ private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep);
+
+ private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep);
+
+ private void ZoomFit_Click(object? sender, RoutedEventArgs e)
+ {
+ if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return;
+
+ var viewWidth = PlanScrollViewer.Bounds.Width;
+ var viewHeight = PlanScrollViewer.Bounds.Height;
+ if (viewWidth <= 0 || viewHeight <= 0) return;
+
+ var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height);
+ SetZoom(Math.Min(fitZoom, 1.0));
+ PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
+ }
+
+ private void SetZoom(double level)
+ {
+ _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level));
+ _zoomTransform.ScaleX = _zoomLevel;
+ _zoomTransform.ScaleY = _zoomLevel;
+ ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
+ UpdateMinimapViewportBox();
+ }
+
+ ///
+ /// 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)
+ {
+ var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level));
+ if (Math.Abs(newZoom - _zoomLevel) < 0.001)
+ return;
+
+ // 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;
+
+ // Apply the new zoom
+ SetZoom(newZoom);
+
+ // 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);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
+ UpdateMinimapViewportBox();
+ });
+ }
+
+ 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));
+ }
+ }
+
+ private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ // Don't intercept scrollbar interactions
+ if (IsScrollBarAtPoint(e))
+ return;
+
+ var point = e.GetCurrentPoint(PlanScrollViewer);
+ var isMiddle = point.Properties.IsMiddleButtonPressed;
+ var isLeft = point.Properties.IsLeftButtonPressed;
+
+ // Middle mouse always pans; left-click pans only on empty canvas (not on nodes)
+ if (isMiddle || (isLeft && !IsNodeAtPoint(e)))
+ {
+ _isPanning = true;
+ _panStart = point.Position;
+ _panStartOffsetX = PlanScrollViewer.Offset.X;
+ _panStartOffsetY = PlanScrollViewer.Offset.Y;
+ PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll);
+ e.Pointer.Capture(PlanScrollViewer);
+ e.Handled = true;
+ }
+ }
+
+ private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_isPanning) return;
+
+ var current = e.GetPosition(PlanScrollViewer);
+ var dx = current.X - _panStart.X;
+ var dy = current.Y - _panStart.Y;
+
+ var newX = Math.Max(0, _panStartOffsetX - dx);
+ var newY = Math.Max(0, _panStartOffsetY - dy);
+
+ // Defer offset change so the ScrollViewer doesn't overwrite it during layout
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(newX, newY);
+ UpdateMinimapViewportBox();
+ });
+
+ e.Handled = true;
+ }
+
+ private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_isPanning) return;
+ _isPanning = false;
+ PlanScrollViewer.Cursor = Cursor.Default;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ /// Check if the pointer event originated from a node Border.
+ private bool IsNodeAtPoint(PointerPressedEventArgs e)
+ {
+ // Walk up the visual tree from the source to see if we hit a node border
+ var source = e.Source as Control;
+ while (source != null && source != PlanCanvas)
+ {
+ if (source is Border b && _nodeBorderMap.ContainsKey(b))
+ return true;
+ source = source.Parent as Control;
+ }
+ return false;
+ }
+
+ /// Check if the pointer event originated from a ScrollBar.
+ private bool IsScrollBarAtPoint(PointerPressedEventArgs e)
+ {
+ var source = e.Source as Control;
+ while (source != null && source != PlanScrollViewer)
+ {
+ if (source is ScrollBar)
+ return true;
+ source = source.Parent as Control;
+ }
+ return false;
+ }
+
+ private async void SavePlan_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel == null) return;
+
+ var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
+ {
+ Title = "Save Plan",
+ DefaultExtension = "sqlplan",
+ SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan",
+ FileTypeChoices = new[]
+ {
+ new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } },
+ new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } },
+ new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
+ }
+ });
+
+ if (file != null)
+ {
+ try
+ {
+ await using var stream = await file.OpenWriteAsync();
+ await using var writer = new StreamWriter(stream);
+ await writer.WriteAsync(_currentPlan.RawXml);
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}");
+ CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}";
+ }
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs
new file mode 100644
index 0000000..0e4424c
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Minimap.cs
@@ -0,0 +1,502 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Helpers;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+using AvaloniaPath = Avalonia.Controls.Shapes.Path;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private void MinimapToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ if (MinimapPanel.IsVisible)
+ CloseMinimapPanel();
+ else
+ OpenMinimapPanel();
+ }
+
+ private void MinimapClose_Click(object? sender, RoutedEventArgs e)
+ {
+ CloseMinimapPanel();
+ }
+
+ private void OpenMinimapPanel()
+ {
+ MinimapPanel.Width = _minimapWidth;
+ MinimapPanel.Height = _minimapHeight;
+ MinimapPanel.IsVisible = true;
+ RenderMinimap();
+ }
+
+ private void CloseMinimapPanel()
+ {
+ MinimapPanel.IsVisible = false;
+ _minimapDragging = false;
+ _minimapResizing = false;
+ }
+
+ private void RenderMinimap()
+ {
+ MinimapCanvas.Children.Clear();
+ _minimapNodeMap.Clear();
+ _minimapViewportBox = null;
+ _minimapSelectedNode = null;
+
+ // Guard: don't render if the panel was closed between a deferred post and execution
+ if (!MinimapPanel.IsVisible) return;
+
+ if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0)
+ return;
+
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0)
+ {
+ // Defer until layout is ready
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
+ return;
+ }
+
+ var scaleX = canvasW / PlanCanvas.Width;
+ var scaleY = canvasH / PlanCanvas.Height;
+ var scale = Math.Min(scaleX, scaleY);
+
+ // Cache the non-expensive node border brush for this render cycle
+ _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg
+ ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B))
+ : FindBrushResource("BorderBrush");
+
+ // Render branch areas with transparent colored backgrounds
+ RenderMinimapBranches(_currentStatement.RootNode, scale);
+
+ // Render edges
+ var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit);
+ RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit);
+
+ // Render nodes
+ RenderMinimapNodes(_currentStatement.RootNode, scale);
+
+ // Render viewport indicator
+ RenderMinimapViewportBox(scale);
+
+ // Re-apply selection highlight if a node is selected
+ if (_selectedNode != null)
+ UpdateMinimapSelection(_selectedNode);
+ }
+
+ private void RenderMinimapBranches(PlanNode root, double scale)
+ {
+
+ for (int i = 0; i < root.Children.Count; i++)
+ {
+ var child = root.Children[i];
+ var color = MinimapBranchColors[i % MinimapBranchColors.Length];
+
+ // Collect bounds of all nodes in this subtree
+ double minX = double.MaxValue, minY = double.MaxValue;
+ double maxX = double.MinValue, maxY = double.MinValue;
+ CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
+
+ var rect = new Avalonia.Controls.Shapes.Rectangle
+ {
+ Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4,
+ Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4,
+ Fill = new SolidColorBrush(color),
+ RadiusX = 2,
+ RadiusY = 2
+ };
+ Canvas.SetLeft(rect, minX * scale - 2);
+ Canvas.SetTop(rect, minY * scale - 2);
+ MinimapCanvas.Children.Add(rect);
+ }
+ }
+
+ private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY)
+ {
+ if (node.X < minX) minX = node.X;
+ if (node.Y < minY) minY = node.Y;
+ if (node.X > maxX) maxX = node.X;
+ var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node);
+ if (bottom > maxY) maxY = bottom;
+
+ foreach (var child in node.Children)
+ CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
+ }
+
+ private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit)
+ {
+ foreach (var child in node.Children)
+ {
+ var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale;
+ var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale;
+ var childLeft = child.X * scale;
+ var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale;
+ var midX = (parentRight + childLeft) / 2;
+
+ // Proportional thickness matching the plan viewer (logarithmic, scaled down)
+ var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows;
+ var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12));
+ var thickness = Math.Max(0.5, fullThickness * scale);
+
+ var geometry = new PathGeometry();
+ var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false };
+ figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
+ geometry.Figures!.Add(figure);
+
+ var linkBrush = GetLinkColorBrush(child, divergenceLimit);
+
+ var path = new AvaloniaPath
+ {
+ Data = geometry,
+ Stroke = linkBrush,
+ StrokeThickness = thickness,
+ StrokeJoin = PenLineJoin.Round
+ };
+ MinimapCanvas.Children.Add(path);
+
+ RenderMinimapEdges(child, scale, divergenceLimit);
+ }
+ }
+
+ private void RenderMinimapNodes(PlanNode node, double scale)
+ {
+ var w = PlanLayoutEngine.NodeWidth * scale;
+ var h = PlanLayoutEngine.GetNodeHeight(node) * scale;
+ // Use theme background colors with transparency
+ var bgBrush = node.IsExpensive
+ ? MinimapExpensiveNodeBgBrush
+ : FindBrushResource("BackgroundLightBrush");
+ var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache;
+
+ var border = new Border
+ {
+ Width = Math.Max(4, w),
+ Height = Math.Max(4, h),
+ Background = bgBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(0.5),
+ CornerRadius = new CornerRadius(1)
+ };
+
+ // Show a small icon inside the node if space allows
+ var iconBitmap = IconHelper.LoadIcon(node.IconName);
+ if (iconBitmap != null)
+ {
+ var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16);
+ if (iconSize >= 6)
+ {
+ border.Child = new Image
+ {
+ Source = iconBitmap,
+ Width = iconSize,
+ Height = iconSize,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ }
+ }
+
+ Canvas.SetLeft(border, node.X * scale);
+ Canvas.SetTop(border, node.Y * scale);
+ MinimapCanvas.Children.Add(border);
+
+ _minimapNodeMap[border] = node;
+
+ foreach (var child in node.Children)
+ RenderMinimapNodes(child, scale);
+ }
+
+ private void RenderMinimapViewportBox(double scale)
+ {
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var contentW = PlanCanvas.Width * _zoomLevel;
+ var contentH = PlanCanvas.Height * _zoomLevel;
+
+ var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale;
+ var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale;
+ var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale;
+ var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale;
+
+ var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab
+ ? ab.Color
+ : Color.FromRgb(0x2E, 0xAE, 0xF1);
+ var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B));
+ var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B));
+
+ _minimapViewportBox = new Border
+ {
+ Width = Math.Max(4, boxW),
+ Height = Math.Max(4, boxH),
+ Background = themeBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(1.5),
+ CornerRadius = new CornerRadius(1),
+ Cursor = new Cursor(StandardCursorType.SizeAll)
+ };
+ Canvas.SetLeft(_minimapViewportBox, boxX);
+ Canvas.SetTop(_minimapViewportBox, boxY);
+ MinimapCanvas.Children.Add(_minimapViewportBox);
+ }
+
+ private void UpdateMinimapViewportBox()
+ {
+ if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null)
+ return;
+ if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return;
+
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0) return;
+
+ var scaleX = canvasW / PlanCanvas.Width;
+ var scaleY = canvasH / PlanCanvas.Height;
+ var scale = Math.Min(scaleX, scaleY);
+
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var contentW = PlanCanvas.Width * _zoomLevel;
+ var contentH = PlanCanvas.Height * _zoomLevel;
+
+ _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale);
+ _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale);
+ Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale);
+ Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale);
+ }
+
+ private double GetMinimapScale()
+ {
+ if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1;
+ var canvasW = MinimapCanvas.Bounds.Width;
+ var canvasH = MinimapCanvas.Bounds.Height;
+ if (canvasW <= 0 || canvasH <= 0) return 1;
+ return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height);
+ }
+
+ private void UpdateMinimapSelection(PlanNode node)
+ {
+ if (!MinimapPanel.IsVisible) return;
+
+ // Reset previous selection highlight
+ if (_minimapSelectedNode != null)
+ {
+ var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode);
+ _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true }
+ ? OrangeRedBrush
+ : _minimapNodeBorderBrushCache;
+ _minimapSelectedNode.BorderThickness = new Thickness(0.5);
+ _minimapSelectedNode = null;
+ }
+
+ // Find and highlight the new node
+ foreach (var (border, n) in _minimapNodeMap)
+ {
+ if (n == node)
+ {
+ border.BorderBrush = SelectionBrush;
+ border.BorderThickness = new Thickness(2);
+ _minimapSelectedNode = border;
+ break;
+ }
+ }
+ }
+
+ private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(MinimapCanvas);
+ if (!point.Properties.IsLeftButtonPressed) return;
+
+ var pos = point.Position;
+ var scale = GetMinimapScale();
+
+ // Check if clicking on a node (single click = center, double click = zoom)
+ if (e.ClickCount == 2)
+ {
+ // Double click: find node under pointer and zoom to it
+ var node = FindMinimapNodeAt(pos);
+ if (node != null)
+ {
+ ZoomToNode(node);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ if (e.ClickCount == 1)
+ {
+ // Check if over a minimap node for single-click centering
+ var node = FindMinimapNodeAt(pos);
+ if (node != null)
+ {
+ CenterOnNode(node);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ // Start viewport box drag
+ _minimapDragging = true;
+
+ // Move viewport center to click position
+ ScrollPlanViewerToMinimapPoint(pos, scale);
+
+ e.Pointer.Capture(MinimapCanvas);
+ e.Handled = true;
+ }
+
+ private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_minimapDragging) return;
+
+ var pos = e.GetPosition(MinimapCanvas);
+ var scale = GetMinimapScale();
+ ScrollPlanViewerToMinimapPoint(pos, scale);
+ e.Handled = true;
+ }
+
+ private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_minimapDragging) return;
+ _minimapDragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale)
+ {
+ if (scale <= 0) return;
+ // Convert minimap coords to plan content coords
+ var contentX = minimapPoint.X / scale;
+ var contentY = minimapPoint.Y / scale;
+
+ // Center the viewport on this content point
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2);
+ var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(offsetX, offsetY);
+ });
+ }
+
+ private PlanNode? FindMinimapNodeAt(Point pos)
+ {
+ foreach (var (border, node) in _minimapNodeMap)
+ {
+ var left = Canvas.GetLeft(border);
+ var top = Canvas.GetTop(border);
+ if (pos.X >= left && pos.X <= left + border.Width &&
+ pos.Y >= top && pos.Y <= top + border.Height)
+ return node;
+ }
+ return null;
+ }
+
+ private void CenterOnNode(PlanNode node)
+ {
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(node);
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
+ var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
+ centerX = Math.Max(0, centerX);
+ centerY = Math.Max(0, centerY);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(centerX, centerY);
+ });
+ }
+
+ private void ZoomToNode(PlanNode node)
+ {
+ var viewW = PlanScrollViewer.Bounds.Width;
+ var viewH = PlanScrollViewer.Bounds.Height;
+ if (viewW <= 0 || viewH <= 0) return;
+
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(node);
+
+ // Zoom so the node takes about 1/3 of the viewport
+ var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3));
+ fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom));
+ SetZoom(fitZoom);
+
+ // Center on the node
+ var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
+ var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY));
+ });
+
+ // Also select the node in the plan
+ foreach (var (border, n) in _nodeBorderMap)
+ {
+ if (n == node)
+ {
+ SelectNode(border, node);
+ break;
+ }
+ }
+ }
+
+ private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ var point = e.GetCurrentPoint(MinimapPanel);
+ if (!point.Properties.IsLeftButtonPressed) return;
+ _minimapResizing = true;
+ _minimapResizeStart = point.Position;
+ _minimapResizeStartW = MinimapPanel.Width;
+ _minimapResizeStartH = MinimapPanel.Height;
+ e.Pointer.Capture((Control)sender!);
+ e.Handled = true;
+ }
+
+ private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_minimapResizing) return;
+ var current = e.GetPosition(MinimapPanel);
+ var dx = current.X - _minimapResizeStart.X;
+ var dy = current.Y - _minimapResizeStart.Y;
+ var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx));
+ var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy));
+ MinimapPanel.Width = newW;
+ MinimapPanel.Height = newH;
+ _minimapWidth = newW;
+ _minimapHeight = newH;
+ e.Handled = true;
+
+ // Re-render after resize
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background);
+ }
+
+ private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_minimapResizing) return;
+ _minimapResizing = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ RenderMinimap();
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs
new file mode 100644
index 0000000..24a05d4
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs
@@ -0,0 +1,1860 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private void ShowPropertiesPanel(PlanNode node)
+ {
+ PropertiesContent.Children.Clear();
+ _sectionLabelColumns.Clear();
+ _currentSectionGrid = null;
+ _currentSectionRowIndex = 0;
+
+ // Header
+ var headerText = node.PhysicalOp;
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
+ headerText += $" ({node.LogicalOp})";
+ PropertiesHeader.Text = headerText;
+ PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
+
+ // === General Section ===
+ AddPropertySection("General");
+ AddPropertyRow("Physical Operation", node.PhysicalOp);
+ AddPropertyRow("Logical Operation", node.LogicalOp);
+ AddPropertyRow("Node ID", $"{node.NodeId}");
+ if (!string.IsNullOrEmpty(node.ExecutionMode))
+ AddPropertyRow("Execution Mode", node.ExecutionMode);
+ if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode)
+ AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode);
+ AddPropertyRow("Parallel", node.Parallel ? "True" : "False");
+ if (node.Partitioned)
+ AddPropertyRow("Partitioned", "True");
+ if (node.EstimatedDOP > 0)
+ AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}");
+
+ // Scan/seek-related properties
+ if (!string.IsNullOrEmpty(node.FullObjectName))
+ {
+ AddPropertyRow("Ordered", node.Ordered ? "True" : "False");
+ if (!string.IsNullOrEmpty(node.ScanDirection))
+ AddPropertyRow("Scan Direction", node.ScanDirection);
+ AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False");
+ AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False");
+ AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False");
+ AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False");
+ if (node.Lookup)
+ AddPropertyRow("Lookup", "True");
+ if (node.DynamicSeek)
+ AddPropertyRow("Dynamic Seek", "True");
+ }
+
+ if (!string.IsNullOrEmpty(node.StorageType))
+ AddPropertyRow("Storage", node.StorageType);
+ if (node.IsAdaptive)
+ AddPropertyRow("Adaptive", "True");
+ if (node.SpillOccurredDetail)
+ AddPropertyRow("Spill Occurred", "True");
+
+ // === Object Section ===
+ if (!string.IsNullOrEmpty(node.FullObjectName))
+ {
+ AddPropertySection("Object");
+ AddPropertyRow("Full Name", node.FullObjectName, isCode: true);
+ if (!string.IsNullOrEmpty(node.ServerName))
+ AddPropertyRow("Server", node.ServerName);
+ if (!string.IsNullOrEmpty(node.DatabaseName))
+ AddPropertyRow("Database", node.DatabaseName);
+ if (!string.IsNullOrEmpty(node.ObjectAlias))
+ AddPropertyRow("Alias", node.ObjectAlias);
+ if (!string.IsNullOrEmpty(node.IndexName))
+ AddPropertyRow("Index", node.IndexName);
+ if (!string.IsNullOrEmpty(node.IndexKind))
+ AddPropertyRow("Index Kind", node.IndexKind);
+ if (node.FilteredIndex)
+ AddPropertyRow("Filtered Index", "True");
+ if (node.TableReferenceId > 0)
+ AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}");
+ }
+
+ // === Operator Details Section ===
+ var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy)
+ || !string.IsNullOrEmpty(node.TopExpression)
+ || !string.IsNullOrEmpty(node.GroupBy)
+ || !string.IsNullOrEmpty(node.PartitionColumns)
+ || !string.IsNullOrEmpty(node.HashKeys)
+ || !string.IsNullOrEmpty(node.SegmentColumn)
+ || !string.IsNullOrEmpty(node.DefinedValues)
+ || !string.IsNullOrEmpty(node.OuterReferences)
+ || !string.IsNullOrEmpty(node.InnerSideJoinColumns)
+ || !string.IsNullOrEmpty(node.OuterSideJoinColumns)
+ || !string.IsNullOrEmpty(node.ActionColumn)
+ || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator
+ || node.SortDistinct || node.StartupExpression
+ || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
+ || node.WithTies || node.Remoting || node.LocalParallelism
+ || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0
+ || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0
+ || !string.IsNullOrEmpty(node.ConstantScanValues)
+ || !string.IsNullOrEmpty(node.UdxUsedColumns);
+
+ if (hasOperatorDetails)
+ {
+ AddPropertySection("Operator Details");
+ if (!string.IsNullOrEmpty(node.OrderBy))
+ AddPropertyRow("Order By", node.OrderBy, isCode: true);
+ if (!string.IsNullOrEmpty(node.TopExpression))
+ {
+ var topText = node.TopExpression;
+ if (node.IsPercent) topText += " PERCENT";
+ if (node.WithTies) topText += " WITH TIES";
+ AddPropertyRow("Top", topText);
+ }
+ if (node.SortDistinct)
+ AddPropertyRow("Distinct Sort", "True");
+ if (node.StartupExpression)
+ AddPropertyRow("Startup Expression", "True");
+ if (node.NLOptimized)
+ AddPropertyRow("Optimized", "True");
+ if (node.WithOrderedPrefetch)
+ AddPropertyRow("Ordered Prefetch", "True");
+ if (node.WithUnorderedPrefetch)
+ AddPropertyRow("Unordered Prefetch", "True");
+ if (node.BitmapCreator)
+ AddPropertyRow("Bitmap Creator", "True");
+ if (node.Remoting)
+ AddPropertyRow("Remoting", "True");
+ if (node.LocalParallelism)
+ AddPropertyRow("Local Parallelism", "True");
+ if (!string.IsNullOrEmpty(node.GroupBy))
+ AddPropertyRow("Group By", node.GroupBy, isCode: true);
+ if (!string.IsNullOrEmpty(node.PartitionColumns))
+ AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true);
+ if (!string.IsNullOrEmpty(node.HashKeys))
+ AddPropertyRow("Hash Keys", node.HashKeys, isCode: true);
+ if (!string.IsNullOrEmpty(node.OffsetExpression))
+ AddPropertyRow("Offset", node.OffsetExpression);
+ if (node.TopRows > 0)
+ AddPropertyRow("Rows", $"{node.TopRows}");
+ if (node.SpoolStack)
+ AddPropertyRow("Stack Spool", "True");
+ if (node.PrimaryNodeId > 0)
+ 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))
+ AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true);
+ if (!string.IsNullOrEmpty(node.DefinedValues))
+ AddPropertyRow("Defined Values", node.DefinedValues, isCode: true);
+ if (!string.IsNullOrEmpty(node.OuterReferences))
+ AddPropertyRow("Outer References", node.OuterReferences, isCode: true);
+ if (!string.IsNullOrEmpty(node.InnerSideJoinColumns))
+ AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true);
+ if (!string.IsNullOrEmpty(node.OuterSideJoinColumns))
+ AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true);
+ if (node.PhysicalOp == "Merge Join")
+ AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No");
+ else if (node.ManyToMany)
+ AddPropertyRow("Many to Many", "Yes");
+ if (!string.IsNullOrEmpty(node.ConstantScanValues))
+ AddPropertyRow("Values", node.ConstantScanValues, isCode: true);
+ if (!string.IsNullOrEmpty(node.UdxUsedColumns))
+ AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true);
+ if (node.RowCount)
+ AddPropertyRow("Row Count", "True");
+ if (node.ForceSeekColumnCount > 0)
+ AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}");
+ if (!string.IsNullOrEmpty(node.PartitionId))
+ AddPropertyRow("Partition Id", node.PartitionId, isCode: true);
+ if (node.IsStarJoin)
+ AddPropertyRow("Star Join Root", "True");
+ if (!string.IsNullOrEmpty(node.StarJoinOperationType))
+ AddPropertyRow("Star Join Type", node.StarJoinOperationType);
+ if (!string.IsNullOrEmpty(node.ProbeColumn))
+ AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true);
+ if (node.InRow)
+ AddPropertyRow("In-Row", "True");
+ if (node.ComputeSequence)
+ AddPropertyRow("Compute Sequence", "True");
+ if (node.RollupHighestLevel > 0)
+ AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}");
+ if (node.RollupLevels.Count > 0)
+ AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels));
+ if (!string.IsNullOrEmpty(node.TvfParameters))
+ AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true);
+ if (!string.IsNullOrEmpty(node.OriginalActionColumn))
+ AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true);
+ if (!string.IsNullOrEmpty(node.TieColumns))
+ AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true);
+ if (!string.IsNullOrEmpty(node.UdxName))
+ AddPropertyRow("UDX Name", node.UdxName);
+ if (node.GroupExecuted)
+ AddPropertyRow("Group Executed", "True");
+ if (node.RemoteDataAccess)
+ AddPropertyRow("Remote Data Access", "True");
+ if (node.OptimizedHalloweenProtectionUsed)
+ AddPropertyRow("Halloween Protection", "True");
+ if (node.StatsCollectionId > 0)
+ AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}");
+ }
+
+ // === Scalar UDFs ===
+ if (node.ScalarUdfs.Count > 0)
+ {
+ AddPropertySection("Scalar UDFs");
+ foreach (var udf in node.ScalarUdfs)
+ {
+ var udfDetail = udf.FunctionName;
+ if (udf.IsClrFunction)
+ {
+ udfDetail += " (CLR)";
+ if (!string.IsNullOrEmpty(udf.ClrAssembly))
+ udfDetail += $"\n Assembly: {udf.ClrAssembly}";
+ if (!string.IsNullOrEmpty(udf.ClrClass))
+ udfDetail += $"\n Class: {udf.ClrClass}";
+ if (!string.IsNullOrEmpty(udf.ClrMethod))
+ udfDetail += $"\n Method: {udf.ClrMethod}";
+ }
+ AddPropertyRow("UDF", udfDetail, isCode: true);
+ }
+ }
+
+ // === Named Parameters (IndexScan) ===
+ if (node.NamedParameters.Count > 0)
+ {
+ AddPropertySection("Named Parameters");
+ foreach (var np in node.NamedParameters)
+ AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true);
+ }
+
+ // === Per-Operator Indexed Views ===
+ if (node.OperatorIndexedViews.Count > 0)
+ {
+ AddPropertySection("Operator Indexed Views");
+ foreach (var iv in node.OperatorIndexedViews)
+ AddPropertyRow("View", iv, isCode: true);
+ }
+
+ // === Suggested Index (Eager Spool) ===
+ if (!string.IsNullOrEmpty(node.SuggestedIndex))
+ {
+ AddPropertySection("Suggested Index");
+ AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true);
+ }
+
+ // === Remote Operator ===
+ if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource)
+ || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery))
+ {
+ AddPropertySection("Remote Operator");
+ if (!string.IsNullOrEmpty(node.RemoteDestination))
+ AddPropertyRow("Destination", node.RemoteDestination);
+ if (!string.IsNullOrEmpty(node.RemoteSource))
+ AddPropertyRow("Source", node.RemoteSource);
+ if (!string.IsNullOrEmpty(node.RemoteObject))
+ AddPropertyRow("Object", node.RemoteObject, isCode: true);
+ if (!string.IsNullOrEmpty(node.RemoteQuery))
+ AddPropertyRow("Query", node.RemoteQuery, isCode: true);
+ }
+
+ // === Foreign Key References Section ===
+ if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0)
+ {
+ AddPropertySection("Foreign Key References");
+ if (node.ForeignKeyReferencesCount > 0)
+ AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}");
+ if (node.NoMatchingIndexCount > 0)
+ AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}");
+ if (node.PartialMatchingIndexCount > 0)
+ AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}");
+ }
+
+ // === Adaptive Join Section ===
+ if (node.IsAdaptive)
+ {
+ AddPropertySection("Adaptive Join");
+ if (!string.IsNullOrEmpty(node.EstimatedJoinType))
+ AddPropertyRow("Est. Join Type", node.EstimatedJoinType);
+ if (!string.IsNullOrEmpty(node.ActualJoinType))
+ AddPropertyRow("Actual Join Type", node.ActualJoinType);
+ if (node.AdaptiveThresholdRows > 0)
+ AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}");
+ }
+
+ // === Estimated Costs Section ===
+ AddPropertySection("Estimated Costs");
+ AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)");
+ AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}");
+ AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}");
+ AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}");
+
+ // === Estimated Rows Section ===
+ AddPropertySection("Estimated Rows");
+ var estExecs = 1 + node.EstimateRebinds;
+ AddPropertyRow("Est. Executions", $"{estExecs:N0}");
+ AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}");
+ AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}");
+ if (node.EstimatedRowsRead > 0)
+ AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}");
+ if (node.EstimateRowsWithoutRowGoal > 0)
+ AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}");
+ if (node.TableCardinality > 0)
+ AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}");
+ AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B");
+ AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}");
+ AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}");
+
+ // === Actual Stats Section (if actual plan) ===
+ if (node.HasActualStats)
+ {
+ AddPropertySection("Actual Statistics");
+ AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats)
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true);
+ if (node.ActualRowsRead > 0)
+ {
+ AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true);
+ }
+ AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats)
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true);
+ if (node.ActualRebinds > 0)
+ AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}");
+ if (node.ActualRewinds > 0)
+ AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}");
+
+ // Runtime partition summary
+ if (node.PartitionsAccessed > 0)
+ {
+ AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}");
+ if (!string.IsNullOrEmpty(node.PartitionRanges))
+ AddPropertyRow("Partition Ranges", node.PartitionRanges);
+ }
+
+ // Timing
+ if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0
+ || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)
+ {
+ AddPropertySection("Actual Timing");
+ if (node.ActualElapsedMs > 0)
+ {
+ AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true);
+ }
+ if (node.ActualCPUMs > 0)
+ {
+ AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true);
+ }
+ if (node.UdfElapsedTimeMs > 0)
+ AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms");
+ if (node.UdfCpuTimeMs > 0)
+ AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms");
+ }
+
+ // I/O
+ var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0
+ || node.ActualScans > 0 || node.ActualReadAheads > 0
+ || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0;
+ if (hasIo)
+ {
+ AddPropertySection("Actual I/O");
+ AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true);
+ if (node.ActualPhysicalReads > 0)
+ {
+ AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true);
+ }
+ if (node.ActualScans > 0)
+ {
+ AddPropertyRow("Scans", $"{node.ActualScans:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true);
+ }
+ if (node.ActualReadAheads > 0)
+ {
+ AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}");
+ if (node.PerThreadStats.Count > 1)
+ foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0))
+ AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true);
+ }
+ if (node.ActualSegmentReads > 0)
+ AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}");
+ if (node.ActualSegmentSkips > 0)
+ AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}");
+ }
+
+ // LOB I/O
+ var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0
+ || node.ActualLobReadAheads > 0;
+ if (hasLobIo)
+ {
+ AddPropertySection("Actual LOB I/O");
+ if (node.ActualLobLogicalReads > 0)
+ AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}");
+ if (node.ActualLobPhysicalReads > 0)
+ AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}");
+ if (node.ActualLobReadAheads > 0)
+ AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}");
+ }
+ }
+
+ // === Predicates Section ===
+ var hasPredicates = !string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)
+ || !string.IsNullOrEmpty(node.HashKeysProbe) || !string.IsNullOrEmpty(node.HashKeysBuild)
+ || !string.IsNullOrEmpty(node.BuildResidual) || !string.IsNullOrEmpty(node.ProbeResidual)
+ || !string.IsNullOrEmpty(node.MergeResidual) || !string.IsNullOrEmpty(node.PassThru)
+ || !string.IsNullOrEmpty(node.SetPredicate)
+ || node.GuessedSelectivity;
+ if (hasPredicates)
+ {
+ AddPropertySection("Predicates");
+ if (!string.IsNullOrEmpty(node.SeekPredicates))
+ AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true);
+ if (!string.IsNullOrEmpty(node.Predicate))
+ AddPropertyRow("Predicate", node.Predicate, isCode: true);
+ if (!string.IsNullOrEmpty(node.HashKeysBuild))
+ AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true);
+ if (!string.IsNullOrEmpty(node.HashKeysProbe))
+ AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true);
+ if (!string.IsNullOrEmpty(node.BuildResidual))
+ AddPropertyRow("Build Residual", node.BuildResidual, isCode: true);
+ if (!string.IsNullOrEmpty(node.ProbeResidual))
+ AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true);
+ if (!string.IsNullOrEmpty(node.MergeResidual))
+ AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true);
+ if (!string.IsNullOrEmpty(node.PassThru))
+ AddPropertyRow("Pass Through", node.PassThru, isCode: true);
+ if (!string.IsNullOrEmpty(node.SetPredicate))
+ AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true);
+ if (node.GuessedSelectivity)
+ AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)");
+ }
+
+ // === Output Columns ===
+ if (!string.IsNullOrEmpty(node.OutputColumns))
+ {
+ AddPropertySection("Output");
+ AddPropertyRow("Columns", node.OutputColumns, isCode: true);
+ }
+
+ // === Memory ===
+ if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0
+ || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0
+ || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0)
+ {
+ AddPropertySection("Memory");
+ if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB");
+ if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB");
+ if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB");
+ if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB");
+ if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB");
+ if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB");
+ if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}");
+ if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}");
+ }
+
+ // === Root node only: statement-level sections ===
+ if (node.Parent == null && _currentStatement != null)
+ {
+ var s = _currentStatement;
+
+ // === Statement Text ===
+ if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName))
+ {
+ AddPropertySection("Statement");
+ if (!string.IsNullOrEmpty(s.StatementText))
+ AddPropertyRow("Text", s.StatementText, isCode: true);
+ if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText)
+ AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true);
+ if (!string.IsNullOrEmpty(s.StmtUseDatabaseName))
+ AddPropertyRow("USE Database", s.StmtUseDatabaseName);
+ }
+
+ // === Cursor Info ===
+ if (!string.IsNullOrEmpty(s.CursorName))
+ {
+ AddPropertySection("Cursor Info");
+ AddPropertyRow("Cursor Name", s.CursorName);
+ if (!string.IsNullOrEmpty(s.CursorActualType))
+ AddPropertyRow("Actual Type", s.CursorActualType);
+ if (!string.IsNullOrEmpty(s.CursorRequestedType))
+ AddPropertyRow("Requested Type", s.CursorRequestedType);
+ if (!string.IsNullOrEmpty(s.CursorConcurrency))
+ AddPropertyRow("Concurrency", s.CursorConcurrency);
+ AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False");
+ }
+
+ // === Statement Memory Grant ===
+ if (s.MemoryGrant != null)
+ {
+ var mg = s.MemoryGrant;
+ AddPropertySection("Memory Grant Info");
+ AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB");
+ AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB");
+ AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB");
+ AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB");
+ AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB");
+ AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB");
+ AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB");
+ if (mg.GrantWaitTimeMs > 0)
+ AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms");
+ if (mg.LastRequestedMemoryKB > 0)
+ AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB");
+ if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted))
+ AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted);
+ }
+
+ // === Statement Info ===
+ AddPropertySection("Statement Info");
+ if (!string.IsNullOrEmpty(s.StatementOptmLevel))
+ AddPropertyRow("Optimization Level", s.StatementOptmLevel);
+ if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason))
+ AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason);
+ if (s.CardinalityEstimationModelVersion > 0)
+ AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}");
+ if (s.DegreeOfParallelism > 0)
+ AddPropertyRow("DOP", $"{s.DegreeOfParallelism}");
+ if (s.EffectiveDOP > 0)
+ AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}");
+ if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted))
+ AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted);
+ if (!string.IsNullOrEmpty(s.NonParallelPlanReason))
+ AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason);
+ if (s.MaxQueryMemoryKB > 0)
+ AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB");
+ if (s.QueryPlanMemoryGrantKB > 0)
+ AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB");
+ AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms");
+ AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms");
+ AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB");
+ if (s.CachedPlanSizeKB > 0)
+ AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB");
+ AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False");
+ AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False");
+ AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False");
+ AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}");
+ if (!string.IsNullOrEmpty(s.QueryHash))
+ AddPropertyRow("Query Hash", s.QueryHash, isCode: true);
+ if (!string.IsNullOrEmpty(s.QueryPlanHash))
+ AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true);
+ if (!string.IsNullOrEmpty(s.StatementSqlHandle))
+ AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true);
+ AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}");
+ AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}");
+
+ // Plan Guide
+ if (!string.IsNullOrEmpty(s.PlanGuideName))
+ {
+ AddPropertyRow("Plan Guide", s.PlanGuideName);
+ if (!string.IsNullOrEmpty(s.PlanGuideDB))
+ AddPropertyRow("Plan Guide DB", s.PlanGuideDB);
+ }
+ if (s.UsePlan)
+ AddPropertyRow("USE PLAN", "True");
+
+ // Query Store Hints
+ if (s.QueryStoreStatementHintId > 0)
+ {
+ AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}");
+ if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText))
+ AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true);
+ if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource))
+ AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource);
+ }
+
+ // === Feature Flags ===
+ if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs
+ || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0
+ || s.QueryVariantID > 0)
+ {
+ AddPropertySection("Feature Flags");
+ if (s.ContainsInterleavedExecutionCandidates)
+ AddPropertyRow("Interleaved Execution", "True");
+ if (s.ContainsInlineScalarTsqlUdfs)
+ AddPropertyRow("Inline Scalar UDFs", "True");
+ if (s.ContainsLedgerTables)
+ AddPropertyRow("Ledger Tables", "True");
+ if (s.ExclusiveProfileTimeActive)
+ AddPropertyRow("Exclusive Profile Time", "True");
+ if (s.QueryCompilationReplay > 0)
+ AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}");
+ if (s.QueryVariantID > 0)
+ AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}");
+ }
+
+ // === PSP Dispatcher ===
+ if (s.Dispatcher != null)
+ {
+ AddPropertySection("PSP Dispatcher");
+ if (!string.IsNullOrEmpty(s.DispatcherPlanHandle))
+ AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true);
+ foreach (var psp in s.Dispatcher.ParameterSensitivePredicates)
+ {
+ var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]";
+ var predText = psp.PredicateText ?? "";
+ AddPropertyRow("Predicate", $"{predText} {range}", isCode: true);
+ foreach (var stat in psp.Statistics)
+ {
+ var statLabel = !string.IsNullOrEmpty(stat.TableName)
+ ? $" {stat.TableName}.{stat.StatisticsName}"
+ : $" {stat.StatisticsName}";
+ AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true);
+ }
+ }
+ foreach (var opt in s.Dispatcher.OptionalParameterPredicates)
+ {
+ if (!string.IsNullOrEmpty(opt.PredicateText))
+ AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true);
+ }
+ }
+
+ // === Cardinality Feedback ===
+ if (s.CardinalityFeedback.Count > 0)
+ {
+ AddPropertySection("Cardinality Feedback");
+ foreach (var cf in s.CardinalityFeedback)
+ AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}");
+ }
+
+ // === Optimization Replay ===
+ if (!string.IsNullOrEmpty(s.OptimizationReplayScript))
+ {
+ AddPropertySection("Optimization Replay");
+ AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true);
+ }
+
+ // === Template Plan Guide ===
+ if (!string.IsNullOrEmpty(s.TemplatePlanGuideName))
+ {
+ AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName);
+ if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB))
+ AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB);
+ }
+
+ // === Handles ===
+ if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle))
+ {
+ AddPropertySection("Handles");
+ if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle))
+ AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true);
+ if (!string.IsNullOrEmpty(s.BatchSqlHandle))
+ AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true);
+ }
+
+ // === Set Options ===
+ if (s.SetOptions != null)
+ {
+ var so = s.SetOptions;
+ AddPropertySection("Set Options");
+ AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False");
+ AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False");
+ AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False");
+ AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False");
+ AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False");
+ AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False");
+ AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False");
+ }
+
+ // === Optimizer Hardware Properties ===
+ if (s.HardwareProperties != null)
+ {
+ var hw = s.HardwareProperties;
+ AddPropertySection("Hardware Properties");
+ AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB");
+ AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}");
+ AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}");
+ if (hw.MaxCompileMemory > 0)
+ AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB");
+ }
+
+ // === Plan Version ===
+ if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build)))
+ {
+ AddPropertySection("Plan Version");
+ if (!string.IsNullOrEmpty(_currentPlan.BuildVersion))
+ AddPropertyRow("Build Version", _currentPlan.BuildVersion);
+ if (!string.IsNullOrEmpty(_currentPlan.Build))
+ AddPropertyRow("Build", _currentPlan.Build);
+ if (_currentPlan.ClusteredMode)
+ AddPropertyRow("Clustered Mode", "True");
+ }
+
+ // === Optimizer Stats Usage ===
+ if (s.StatsUsage.Count > 0)
+ {
+ AddPropertySection("Statistics Used");
+ foreach (var stat in s.StatsUsage)
+ {
+ var statLabel = !string.IsNullOrEmpty(stat.TableName)
+ ? $"{stat.TableName}.{stat.StatisticsName}"
+ : stat.StatisticsName;
+ var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%";
+ if (!string.IsNullOrEmpty(stat.LastUpdate))
+ statDetail += $", Updated: {stat.LastUpdate}";
+ AddPropertyRow(statLabel, statDetail);
+ }
+ }
+
+ // === Parameters ===
+ if (s.Parameters.Count > 0)
+ {
+ AddPropertySection("Parameters");
+ foreach (var p in s.Parameters)
+ {
+ var paramText = p.DataType;
+ if (!string.IsNullOrEmpty(p.CompiledValue))
+ paramText += $", Compiled: {p.CompiledValue}";
+ if (!string.IsNullOrEmpty(p.RuntimeValue))
+ paramText += $", Runtime: {p.RuntimeValue}";
+ AddPropertyRow(p.Name, paramText);
+ }
+ }
+
+ // === Query Time Stats (actual plans) ===
+ if (s.QueryTimeStats != null)
+ {
+ AddPropertySection("Query Time Stats");
+ AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms");
+ AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms");
+ if (s.QueryUdfCpuTimeMs > 0)
+ AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms");
+ if (s.QueryUdfElapsedTimeMs > 0)
+ AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms");
+ }
+
+ // === Thread Stats (actual plans) ===
+ if (s.ThreadStats != null)
+ {
+ AddPropertySection("Thread Stats");
+ AddPropertyRow("Branches", $"{s.ThreadStats.Branches}");
+ AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}");
+ var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads);
+ if (totalReserved > 0)
+ {
+ AddPropertyRow("Reserved Threads", $"{totalReserved}");
+ if (totalReserved > s.ThreadStats.UsedThreads)
+ AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}");
+ }
+ foreach (var res in s.ThreadStats.Reservations)
+ AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved");
+ }
+
+ // === Wait Stats (actual plans) ===
+ if (s.WaitStats.Count > 0)
+ {
+ AddPropertySection("Wait Stats");
+ foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs))
+ AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)");
+ }
+
+ // === Trace Flags ===
+ if (s.TraceFlags.Count > 0)
+ {
+ AddPropertySection("Trace Flags");
+ foreach (var tf in s.TraceFlags)
+ {
+ var tfLabel = $"TF {tf.Value}";
+ var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}";
+ AddPropertyRow(tfLabel, tfDetail);
+ }
+ }
+
+ // === Indexed Views ===
+ if (s.IndexedViews.Count > 0)
+ {
+ AddPropertySection("Indexed Views");
+ foreach (var iv in s.IndexedViews)
+ AddPropertyRow("View", iv, isCode: true);
+ }
+
+ // === Plan-Level Warnings ===
+ if (s.PlanWarnings.Count > 0)
+ {
+ var planWarningsPanel = new StackPanel();
+ var sortedPlanWarnings = s.PlanWarnings
+ .OrderByDescending(w => w.MaxBenefitPercent ?? -1)
+ .ThenByDescending(w => w.Severity)
+ .ThenBy(w => w.WarningType);
+ foreach (var w in sortedPlanWarnings)
+ {
+ var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
+ : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
+ var legacyTag = w.IsLegacy ? " [legacy]" : "";
+ var planWarnHeader = w.MaxBenefitPercent.HasValue
+ ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
+ : $"\u26A0 {w.WarningType}{legacyTag}";
+ warnPanel.Children.Add(new TextBlock
+ {
+ Text = planWarnHeader,
+ FontWeight = FontWeight.SemiBold,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(warnColor))
+ });
+ warnPanel.Children.Add(new TextBlock
+ {
+ Text = w.Message,
+ FontSize = 11,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(16, 0, 0, 0)
+ });
+ if (!string.IsNullOrEmpty(w.ActionableFix))
+ {
+ warnPanel.Children.Add(new TextBlock
+ {
+ Text = w.ActionableFix,
+ FontSize = 11,
+ FontStyle = FontStyle.Italic,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(16, 2, 0, 0)
+ });
+ }
+ 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 ===
+ if (s.MissingIndexes.Count > 0)
+ {
+ AddPropertySection("Missing Indexes");
+ foreach (var mi in s.MissingIndexes)
+ {
+ AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%");
+ if (!string.IsNullOrEmpty(mi.CreateStatement))
+ AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true);
+ }
+ }
+ }
+
+ // === Warnings ===
+ if (node.HasWarnings)
+ {
+ var warningsPanel = new StackPanel();
+ var sortedNodeWarnings = node.Warnings
+ .OrderByDescending(w => w.MaxBenefitPercent ?? -1)
+ .ThenByDescending(w => w.Severity)
+ .ThenBy(w => w.WarningType);
+ foreach (var w in sortedNodeWarnings)
+ {
+ var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
+ : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
+ var nodeLegacyTag = w.IsLegacy ? " [legacy]" : "";
+ var nodeWarnHeader = w.MaxBenefitPercent.HasValue
+ ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
+ : $"\u26A0 {w.WarningType}{nodeLegacyTag}";
+ warnPanel.Children.Add(new TextBlock
+ {
+ Text = nodeWarnHeader,
+ FontWeight = FontWeight.SemiBold,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(warnColor))
+ });
+ warnPanel.Children.Add(new TextBlock
+ {
+ Text = w.Message,
+ FontSize = 11,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(16, 0, 0, 0)
+ });
+ 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
+ _propertiesColumn.Width = new GridLength(320);
+ _splitterColumn.Width = new GridLength(5);
+ PropertiesSplitter.IsVisible = true;
+ PropertiesPanel.IsVisible = true;
+ }
+
+ private void AddPropertySection(string title)
+ {
+ var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) };
+ _sectionLabelColumns.Add(labelCol);
+
+ // Sync column widths across sections when user drags the GridSplitter
+ labelCol.PropertyChanged += (_, args) =>
+ {
+ if (args.Property.Name != "Width" || _isSyncingColumnWidth) return;
+ _isSyncingColumnWidth = true;
+ _propertyLabelWidth = labelCol.Width.Value;
+ foreach (var col in _sectionLabelColumns)
+ {
+ if (col != labelCol)
+ col.Width = labelCol.Width;
+ }
+ _isSyncingColumnWidth = false;
+ };
+
+ var sectionGrid = new Grid
+ {
+ Margin = new Thickness(6, 0, 6, 0)
+ };
+ sectionGrid.ColumnDefinitions.Add(labelCol);
+ sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) });
+ sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
+
+ _currentSectionGrid = sectionGrid;
+ _currentSectionRowIndex = 0;
+
+ var expander = new Expander
+ {
+ IsExpanded = true,
+ Header = new TextBlock
+ {
+ Text = title,
+ FontWeight = FontWeight.SemiBold,
+ FontSize = 11,
+ Foreground = SectionHeaderBrush
+ },
+ Content = sectionGrid,
+ 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(expander);
+ }
+
+ private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false)
+ {
+ if (_currentSectionGrid == null) return;
+
+ var row = _currentSectionRowIndex++;
+ _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ var labelBlock = new TextBlock
+ {
+ Text = label,
+ FontSize = indent ? 10 : 11,
+ Foreground = TooltipFgBrush,
+ VerticalAlignment = VerticalAlignment.Top,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(indent ? 16 : 4, 2, 0, 2)
+ };
+ Grid.SetColumn(labelBlock, 0);
+ Grid.SetRow(labelBlock, row);
+ _currentSectionGrid.Children.Add(labelBlock);
+
+ // GridSplitter in column 1 (only in first row per section)
+ if (row == 0)
+ {
+ var splitter = new GridSplitter
+ {
+ Width = 4,
+ Background = Brushes.Transparent,
+ Foreground = Brushes.Transparent,
+ BorderThickness = new Thickness(0),
+ Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast)
+ };
+ Grid.SetColumn(splitter, 1);
+ Grid.SetRow(splitter, 0);
+ Grid.SetRowSpan(splitter, 100); // span all rows
+ _currentSectionGrid.Children.Add(splitter);
+ }
+
+ var valueBox = new TextBox
+ {
+ Text = value,
+ FontSize = indent ? 10 : 11,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap,
+ IsReadOnly = true,
+ BorderThickness = new Thickness(0),
+ Background = Brushes.Transparent,
+ Padding = new Thickness(0),
+ Margin = new Thickness(0, 2, 4, 2),
+ VerticalAlignment = VerticalAlignment.Top
+ };
+ if (isCode) valueBox.FontFamily = new FontFamily("Consolas");
+ Grid.SetColumn(valueBox, 2);
+ Grid.SetRow(valueBox, row);
+ _currentSectionGrid.Children.Add(valueBox);
+ }
+
+ private void CloseProperties_Click(object? sender, RoutedEventArgs e)
+ {
+ ClosePropertiesPanel();
+ }
+
+ private void ClosePropertiesPanel()
+ {
+ PropertiesPanel.IsVisible = false;
+ PropertiesSplitter.IsVisible = false;
+ _propertiesColumn.Width = new GridLength(0);
+ _splitterColumn.Width = new GridLength(0);
+
+ // Deselect node
+ if (_selectedNodeBorder != null)
+ {
+ _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder;
+ _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
+ _selectedNodeBorder = null;
+ }
+ }
+
+ private void ShowMissingIndexes(List indexes)
+ {
+ MissingIndexContent.Children.Clear();
+
+ if (indexes.Count > 0)
+ {
+ // Update expander header with count
+ MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})";
+
+ // Build each missing index row manually (no ItemsControl template binding)
+ foreach (var mi in indexes)
+ {
+ var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
+
+ var headerRow = new StackPanel { Orientation = Orientation.Horizontal };
+ headerRow.Children.Add(new TextBlock
+ {
+ Text = mi.Table,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ FontSize = 12
+ });
+ headerRow.Children.Add(new TextBlock
+ {
+ Text = $" \u2014 Impact: ",
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ FontSize = 12
+ });
+ headerRow.Children.Add(new TextBlock
+ {
+ Text = $"{mi.Impact:F1}%",
+ Foreground = new SolidColorBrush(Color.Parse("#FFB347")),
+ FontSize = 12
+ });
+ itemPanel.Children.Add(headerRow);
+
+ if (!string.IsNullOrEmpty(mi.CreateStatement))
+ {
+ itemPanel.Children.Add(new SelectableTextBlock
+ {
+ Text = mi.CreateStatement,
+ FontFamily = new FontFamily("Consolas"),
+ FontSize = 11,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(12, 2, 0, 0)
+ });
+ }
+
+ MissingIndexContent.Children.Add(itemPanel);
+ }
+
+ MissingIndexEmpty.IsVisible = false;
+ }
+ else
+ {
+ MissingIndexHeader.Text = "Missing Index Suggestions";
+ MissingIndexEmpty.IsVisible = true;
+ }
+ }
+
+ private void ShowParameters(PlanStatement statement)
+ {
+ ParametersContent.Children.Clear();
+ ParametersEmpty.IsVisible = false;
+
+ var parameters = statement.Parameters;
+
+ if (parameters.Count == 0)
+ {
+ var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode);
+ if (localVars.Count > 0)
+ {
+ ParametersHeader.Text = "Parameters";
+ AddParameterAnnotation(
+ $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML",
+ "#FFB347");
+ }
+ else
+ {
+ ParametersHeader.Text = "Parameters";
+ ParametersEmpty.IsVisible = true;
+ }
+ return;
+ }
+
+ ParametersHeader.Text = $"Parameters ({parameters.Count})";
+
+ var allCompiledNull = parameters.All(p => p.CompiledValue == null);
+ var hasCompiled = parameters.Any(p => p.CompiledValue != null);
+ var hasRuntime = parameters.Any(p => p.RuntimeValue != null);
+
+ // Build a 4-column grid: Name | Data Type | Compiled | Runtime
+ // Only show Compiled/Runtime columns if at least one param has that value
+ var colDef = "Auto,Auto"; // Name, DataType always shown
+ int compiledCol = -1, runtimeCol = -1;
+ int nextCol = 2;
+ if (hasCompiled)
+ {
+ colDef += ",*";
+ compiledCol = nextCol++;
+ }
+ if (hasRuntime)
+ {
+ colDef += ",*";
+ runtimeCol = nextCol++;
+ }
+ // If neither compiled nor runtime, still add one value column for "?"
+ if (!hasCompiled && !hasRuntime)
+ {
+ colDef += ",*";
+ compiledCol = nextCol++;
+ }
+
+ var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) };
+ int rowIndex = 0;
+
+ // Header row
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+ AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold);
+ AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold);
+ if (compiledCol >= 0)
+ AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold);
+ if (runtimeCol >= 0)
+ AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold);
+ rowIndex++;
+
+ foreach (var param in parameters)
+ {
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ // Name
+ AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold);
+
+ // Data type
+ AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB");
+
+ // Compiled value
+ if (compiledCol >= 0)
+ {
+ var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?");
+ var compiledColor = param.CompiledValue != null ? "#E4E6EB"
+ : allCompiledNull ? "#E4E6EB" : "#E57373";
+ AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor);
+ }
+
+ // Runtime value — amber if it differs from compiled
+ if (runtimeCol >= 0)
+ {
+ var runtimeText = param.RuntimeValue ?? "";
+ var sniffed = param.RuntimeValue != null
+ && param.CompiledValue != null
+ && param.RuntimeValue != param.CompiledValue;
+ var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB";
+ var tooltip = sniffed
+ ? "Runtime value differs from compiled — possible parameter sniffing"
+ : null;
+ AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip);
+ }
+
+ rowIndex++;
+ }
+
+ ParametersContent.Children.Add(grid);
+
+ // Annotations
+ if (allCompiledNull && parameters.Count > 0)
+ {
+ var hasOptimizeForUnknown = statement.StatementText
+ .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase)
+ && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase);
+
+ if (hasOptimizeForUnknown)
+ {
+ AddParameterAnnotation(
+ "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values",
+ "#6BB5FF");
+ }
+ else
+ {
+ AddParameterAnnotation(
+ "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed",
+ "#FFB347");
+ }
+ }
+
+ var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode);
+ if (unresolved.Count > 0)
+ {
+ AddParameterAnnotation(
+ $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list",
+ "#FFB347");
+ }
+ }
+
+ private static void AddParamCell(Grid grid, int row, int col, string text, string color,
+ FontWeight fontWeight = default, string? tooltip = null)
+ {
+ var tb = new TextBlock
+ {
+ Text = text,
+ FontSize = 11,
+ FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight,
+ Foreground = new SolidColorBrush(Color.Parse(color)),
+ Margin = new Thickness(0, 2, 10, 2),
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ MaxWidth = 200
+ };
+ // Name and DataType columns are short — no need for max width
+ if (col <= 1)
+ tb.MaxWidth = double.PositiveInfinity;
+ if (tooltip != null)
+ ToolTip.SetTip(tb, tooltip);
+ else if (text.Length > 30)
+ ToolTip.SetTip(tb, text);
+ Grid.SetRow(tb, row);
+ Grid.SetColumn(tb, col);
+ grid.Children.Add(tb);
+ }
+
+ private void AddParameterAnnotation(string text, string color)
+ {
+ ParametersContent.Children.Add(new TextBlock
+ {
+ Text = text,
+ FontSize = 11,
+ FontStyle = FontStyle.Italic,
+ Foreground = new SolidColorBrush(Color.Parse(color)),
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 6, 0, 0)
+ });
+ }
+
+ private static List FindUnresolvedVariables(string queryText, List parameters,
+ PlanNode? rootNode = null)
+ {
+ var unresolved = new List();
+ if (string.IsNullOrEmpty(queryText))
+ return unresolved;
+
+ var extractedNames = new HashSet(
+ parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase);
+
+ // Collect table variable names from the plan tree so we don't misreport them as local variables
+ var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase);
+ if (rootNode != null)
+ CollectTableVariableNames(rootNode, tableVarNames);
+
+ var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase);
+ var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (Match match in matches)
+ {
+ var varName = match.Value;
+ if (seenVars.Contains(varName) || extractedNames.Contains(varName))
+ continue;
+ if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (tableVarNames.Contains(varName))
+ continue;
+
+ seenVars.Add(varName);
+ unresolved.Add(varName);
+ }
+
+ return unresolved;
+ }
+
+ private static void CollectTableVariableNames(PlanNode node, HashSet names)
+ {
+ if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
+ {
+ // ObjectName is like "@t.c" — extract the table variable name "@t"
+ var dotIdx = node.ObjectName.IndexOf('.');
+ var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName;
+ names.Add(tvName);
+ }
+ foreach (var child in node.Children)
+ CollectTableVariableNames(child, names);
+ }
+
+ ///
+ /// Computes own CPU time for a node by subtracting child times in row mode.
+ /// Batch mode reports own time directly; row mode is cumulative from leaves up.
+ ///
+ private static long GetOwnCpuMs(PlanNode node)
+ {
+ if (node.ActualCPUMs <= 0) return 0;
+ var mode = node.ActualExecutionMode ?? node.ExecutionMode;
+ if (mode == "Batch") return node.ActualCPUMs;
+ var childSum = GetChildCpuMsSum(node);
+ return Math.Max(0, node.ActualCPUMs - childSum);
+ }
+
+ ///
+ /// Computes own elapsed time for a node by subtracting child times in row mode.
+ ///
+ private static long GetOwnElapsedMs(PlanNode node)
+ {
+ if (node.ActualElapsedMs <= 0) return 0;
+ var mode = node.ActualExecutionMode ?? node.ExecutionMode;
+ if (mode == "Batch") return node.ActualElapsedMs;
+
+ // Exchange operators: Thread 0 is the coordinator whose elapsed time is the
+ // wall clock for the entire parallel branch — not the operator's own work.
+ if (IsExchangeOperator(node))
+ {
+ // If we have worker thread data, use max of worker threads
+ var workerMax = node.PerThreadStats
+ .Where(t => t.ThreadId > 0)
+ .Select(t => t.ActualElapsedMs)
+ .DefaultIfEmpty(0)
+ .Max();
+ if (workerMax > 0)
+ {
+ var childSum = GetChildElapsedMsSum(node);
+ return Math.Max(0, workerMax - childSum);
+ }
+ // Thread 0 only (coordinator) — exchange does negligible own work
+ return 0;
+ }
+
+ var childElapsedSum = GetChildElapsedMsSum(node);
+ return Math.Max(0, node.ActualElapsedMs - childElapsedSum);
+ }
+
+ private static bool IsExchangeOperator(PlanNode node) =>
+ node.PhysicalOp == "Parallelism"
+ || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams";
+
+ private static long GetChildCpuMsSum(PlanNode node)
+ {
+ long sum = 0;
+ foreach (var child in node.Children)
+ {
+ if (child.ActualCPUMs > 0)
+ sum += child.ActualCPUMs;
+ else
+ sum += GetChildCpuMsSum(child); // skip through transparent operators
+ }
+ return sum;
+ }
+
+ private static long GetChildElapsedMsSum(PlanNode node)
+ {
+ long sum = 0;
+ foreach (var child in node.Children)
+ {
+ if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
+ {
+ // Exchange: take max of children (parallel branches)
+ sum += child.Children
+ .Where(c => c.ActualElapsedMs > 0)
+ .Select(c => c.ActualElapsedMs)
+ .DefaultIfEmpty(0)
+ .Max();
+ }
+ else if (child.ActualElapsedMs > 0)
+ {
+ sum += child.ActualElapsedMs;
+ }
+ else
+ {
+ sum += GetChildElapsedMsSum(child); // skip through transparent operators
+ }
+ }
+ return sum;
+ }
+
+ private void ShowWaitStats(List waits, List benefits, bool isActualPlan)
+ {
+ WaitStatsContent.Children.Clear();
+
+ if (waits.Count == 0)
+ {
+ WaitStatsHeader.Text = "Wait Stats";
+ WaitStatsEmpty.Text = isActualPlan
+ ? "No wait stats recorded"
+ : "No wait stats (estimated plan)";
+ WaitStatsEmpty.IsVisible = true;
+ return;
+ }
+
+ WaitStatsEmpty.IsVisible = false;
+
+ // Build benefit lookup
+ var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var wb in benefits)
+ benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;
+
+ var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList();
+ var maxWait = sorted[0].WaitTimeMs;
+ var totalWait = sorted.Sum(w => w.WaitTimeMs);
+
+ // Update expander header with total
+ WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total";
+
+ // Build a single Grid for all rows so columns align
+ // Name, bar, duration, and benefit columns
+ var grid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto")
+ };
+ for (int i = 0; i < sorted.Count; i++)
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ for (int i = 0; i < sorted.Count; i++)
+ {
+ var w = sorted[i];
+ var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0;
+ var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType));
+
+ // Wait type name — colored by category
+ var nameText = new TextBlock
+ {
+ Text = w.WaitType,
+ FontSize = 12,
+ Foreground = new SolidColorBrush(Color.Parse(color)),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 2, 10, 2)
+ };
+ Grid.SetRow(nameText, i);
+ Grid.SetColumn(nameText, 0);
+ grid.Children.Add(nameText);
+
+ // Bar — semi-transparent category color, compact proportional indicator
+ var barColor = Color.Parse(color);
+ var colorBar = new Border
+ {
+ Width = Math.Max(4, barFraction * 60),
+ Height = 14,
+ Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)),
+ CornerRadius = new CornerRadius(2),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 2, 8, 2)
+ };
+ Grid.SetRow(colorBar, i);
+ Grid.SetColumn(colorBar, 1);
+ grid.Children.Add(colorBar);
+
+ // Duration text
+ var durationText = new TextBlock
+ {
+ Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)",
+ FontSize = 12,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 2, 8, 2)
+ };
+ Grid.SetRow(durationText, i);
+ Grid.SetColumn(durationText, 2);
+ grid.Children.Add(durationText);
+
+ // Benefit % (if available)
+ if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0)
+ {
+ var benefitText = new TextBlock
+ {
+ Text = $"up to {benefitPct:N0}%",
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse("#8b949e")),
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 2, 0, 2)
+ };
+ Grid.SetRow(benefitText, i);
+ Grid.SetColumn(benefitText, 3);
+ grid.Children.Add(benefitText);
+ }
+ }
+
+ WaitStatsContent.Children.Add(grid);
+
+ }
+
+ private void ShowRuntimeSummary(PlanStatement statement)
+ {
+ RuntimeSummaryContent.Children.Clear();
+
+ var labelColor = "#E4E6EB";
+ var valueColor = "#E4E6EB";
+
+ var grid = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*")
+ };
+ int rowIndex = 0;
+
+ void AddRow(string label, string value, string? color = null)
+ {
+ grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
+
+ var labelText = new TextBlock
+ {
+ Text = label,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(labelColor)),
+ HorizontalAlignment = HorizontalAlignment.Left,
+ Margin = new Thickness(0, 1, 8, 1)
+ };
+ Grid.SetRow(labelText, rowIndex);
+ Grid.SetColumn(labelText, 0);
+ grid.Children.Add(labelText);
+
+ var valueText = new TextBlock
+ {
+ Text = value,
+ FontSize = 11,
+ Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)),
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetRow(valueText, rowIndex);
+ Grid.SetColumn(valueText, 1);
+ grid.Children.Add(valueText);
+
+ rowIndex++;
+ }
+
+ // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%.
+ // Loosened per Joe's feedback (#215 C1): for memory grants, moderate
+ // utilization (e.g. 60%) is fine — operators can spill near their max,
+ // so we shouldn't flag anything above a real over-grant threshold.
+ static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB"
+ : pct >= 20 ? "#FFB347" : "#E57373";
+
+ // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red),
+ // any operator spilled (orange), otherwise tier by utilization.
+ static string MemoryGrantColor(double pctUsed, bool hasSpill)
+ {
+ if (pctUsed > 100) return "#E57373";
+ if (hasSpill) return "#FFB347";
+ if (pctUsed >= 40) return "#E4E6EB";
+ if (pctUsed >= 20) return "#FFB347";
+ return "#E57373";
+ }
+
+ // E7: rename the panel title for estimated plans
+ var isEstimated = statement.QueryTimeStats == null;
+ RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary";
+
+ var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode);
+
+ // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost.
+ // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors.
+
+ if (statement.QueryTimeStats != null)
+ {
+ AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms");
+ if (statement.QueryTimeStats.ElapsedTimeMs > 0)
+ {
+ long externalWaitMs = 0;
+ foreach (var w in statement.WaitStats)
+ if (BenefitScorer.IsExternalWait(w.WaitType))
+ externalWaitMs += w.WaitTimeMs;
+ var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs);
+ var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs;
+ AddRow("CPU:Elapsed", ratio.ToString("N2"));
+ }
+ }
+
+ // DOP + parallelism efficiency
+ if (statement.DegreeOfParallelism > 0)
+ {
+ var dopText = statement.DegreeOfParallelism.ToString();
+ string? dopColor = null;
+ if (statement.QueryTimeStats != null &&
+ statement.QueryTimeStats.ElapsedTimeMs > 0 &&
+ statement.QueryTimeStats.CpuTimeMs > 0 &&
+ statement.DegreeOfParallelism > 1)
+ {
+ long externalWaitMs = 0;
+ foreach (var w in statement.WaitStats)
+ if (BenefitScorer.IsExternalWait(w.WaitType))
+ externalWaitMs += w.WaitTimeMs;
+ var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs);
+ var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs;
+ var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0);
+ efficiency = Math.Max(0.0, efficiency);
+ dopText += $" ({efficiency:N0}% efficient)";
+ dopColor = EfficiencyColor(efficiency);
+ }
+ AddRow("DOP", dopText, dopColor);
+ }
+ else if (statement.NonParallelPlanReason != null)
+ AddRow("Serial", statement.NonParallelPlanReason);
+
+ if (statement.QueryTimeStats != null)
+ {
+ AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms");
+ if (statement.QueryUdfCpuTimeMs > 0)
+ AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms");
+ if (statement.QueryUdfElapsedTimeMs > 0)
+ AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms");
+ }
+
+ // Compile stats (category B plan-level property)
+ if (statement.CompileTimeMs > 0)
+ AddRow("Compile", $"{statement.CompileTimeMs:N0}ms");
+ if (statement.CachedPlanSizeKB > 0)
+ AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB");
+
+ // Memory grant — color per new tiers, spill indicator if any operator spilled
+ if (statement.MemoryGrant != null)
+ {
+ var mg = statement.MemoryGrant;
+ var grantPct = mg.GrantedMemoryKB > 0
+ ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100;
+ var grantColor = MemoryGrantColor(grantPct, hasSpillInTree);
+ var spillTag = hasSpillInTree ? " ⚠ spill" : "";
+ AddRow("Memory grant",
+ $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}",
+ grantColor);
+ if (mg.GrantWaitTimeMs > 0)
+ AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373");
+ }
+
+ // Thread stats
+ if (statement.ThreadStats != null)
+ {
+ var ts = statement.ThreadStats;
+ AddRow("Branches", ts.Branches.ToString());
+ var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads);
+ if (totalReserved > 0)
+ {
+ var threadPct = (double)ts.UsedThreads / totalReserved * 100;
+ var threadColor = EfficiencyColor(threadPct);
+ var threadText = ts.UsedThreads == totalReserved
+ ? $"{ts.UsedThreads} used ({totalReserved} reserved)"
+ : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)";
+ AddRow("Threads", threadText, threadColor);
+ }
+ else
+ {
+ AddRow("Threads", $"{ts.UsedThreads} used");
+ }
+ }
+
+ // Optimization + CE model
+ if (!string.IsNullOrEmpty(statement.StatementOptmLevel))
+ AddRow("Optimization", statement.StatementOptmLevel);
+ if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason))
+ AddRow("Early abort", statement.StatementOptmEarlyAbortReason);
+ if (statement.CardinalityEstimationModelVersion > 0)
+ AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString());
+
+ if (grid.Children.Count > 0)
+ {
+ RuntimeSummaryContent.Children.Add(grid);
+ RuntimeSummaryEmpty.IsVisible = false;
+ }
+ else
+ {
+ RuntimeSummaryEmpty.IsVisible = true;
+ }
+ ShowServerContext();
+ }
+
+ private void ShowServerContext()
+ {
+ ServerContextContent.Children.Clear();
+ if (_serverMetadata == null)
+ {
+ ServerContextEmpty.IsVisible = true;
+ ServerContextBorder.IsVisible = true;
+ return;
+ }
+
+ ServerContextEmpty.IsVisible = false;
+
+ 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()
+ {
+ InsightsPanel.IsVisible = true;
+ InsightsHeader.Text = " Plan Insights";
+ }
+
+ private static string GetWaitCategory(string waitType)
+ {
+ if (waitType.StartsWith("SOS_SCHEDULER_YIELD") ||
+ waitType.StartsWith("CXPACKET") ||
+ waitType.StartsWith("CXCONSUMER") ||
+ waitType.StartsWith("CXSYNC_PORT") ||
+ waitType.StartsWith("CXSYNC_CONSUMER"))
+ return "CPU";
+
+ if (waitType.StartsWith("PAGEIOLATCH") ||
+ waitType.StartsWith("WRITELOG") ||
+ waitType.StartsWith("IO_COMPLETION") ||
+ waitType.StartsWith("ASYNC_IO_COMPLETION"))
+ return "I/O";
+
+ if (waitType.StartsWith("LCK_M_"))
+ return "Lock";
+
+ if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD")
+ return "Memory";
+
+ if (waitType == "ASYNC_NETWORK_IO")
+ return "Network";
+
+ return "Other";
+ }
+
+ private static string GetWaitCategoryColor(string category)
+ {
+ return category switch
+ {
+ "CPU" => "#4FA3FF",
+ "I/O" => "#FFB347",
+ "Lock" => "#E57373",
+ "Memory" => "#9B59B6",
+ "Network" => "#2ECC71",
+ _ => "#6BB5FF"
+ };
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs
new file mode 100644
index 0000000..9956c40
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs
@@ -0,0 +1,550 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.App.Helpers;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+using AvaloniaPath = Avalonia.Controls.Shapes.Path;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical)
+ {
+ total += node.Warnings.Count;
+ critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
+ foreach (var child in node.Children)
+ CountNodeWarnings(child, ref total, ref critical);
+ }
+
+ private void RenderStatement(PlanStatement statement)
+ {
+ _currentStatement = statement;
+ PlanCanvas.Children.Clear();
+ _nodeBorderMap.Clear();
+ _selectedNodeBorder = null;
+ _selectedNode = null;
+
+ if (statement.RootNode == null) return;
+
+ // Layout
+ PlanLayoutEngine.Layout(statement);
+ var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode);
+ PlanCanvas.Width = width;
+ PlanCanvas.Height = height;
+
+ // Render edges first (behind nodes)
+ var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit);
+ RenderEdges(statement.RootNode, divergenceLimit);
+
+ // Render nodes — pass total warning count to root node for badge
+ var allWarnings = new List();
+ CollectWarnings(statement.RootNode, allWarnings);
+ RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count);
+
+ // Update banners
+ ShowMissingIndexes(statement.MissingIndexes);
+ ShowParameters(statement);
+ ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null);
+ ShowRuntimeSummary(statement);
+ UpdateInsightsHeader();
+
+ // Scroll to top-left so the plan root is immediately visible
+ PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
+
+ // Canvas-level context menu (zoom, advice, repro, save)
+ // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable
+ PlanScrollViewer.ContextMenu = BuildCanvasContextMenu();
+
+ CostText.Text = "";
+
+ // Update minimap if visible
+ if (MinimapPanel.IsVisible)
+ Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
+ }
+
+ private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1)
+ {
+ var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount);
+ Canvas.SetLeft(visual, node.X);
+ Canvas.SetTop(visual, node.Y);
+ PlanCanvas.Children.Add(visual);
+
+ foreach (var child in node.Children)
+ RenderNodes(child, divergenceLimit);
+ }
+
+ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1)
+ {
+ var isExpensive = node.IsExpensive;
+
+ var bgBrush = isExpensive
+ ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73))
+ : FindBrushResource("BackgroundLightBrush");
+
+ var borderBrush = isExpensive
+ ? OrangeRedBrush
+ : FindBrushResource("BorderBrush");
+
+ var border = new Border
+ {
+ Width = PlanLayoutEngine.NodeWidth,
+ MinHeight = PlanLayoutEngine.NodeHeightMin,
+ Background = bgBrush,
+ BorderBrush = borderBrush,
+ BorderThickness = new Thickness(isExpensive ? 2 : 1),
+ CornerRadius = new CornerRadius(4),
+ Padding = new Thickness(6, 4, 6, 4),
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+
+ // Map border to node (replaces WPF Tag)
+ _nodeBorderMap[border] = node;
+
+ // Tooltip — root node gets all collected warnings so the tooltip shows them
+ if (totalWarningCount > 0)
+ {
+ var allWarnings = new List();
+ if (_currentStatement != null)
+ allWarnings.AddRange(_currentStatement.PlanWarnings);
+ CollectWarnings(node, allWarnings);
+ ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings));
+ }
+ else
+ {
+ ToolTip.SetTip(border, BuildNodeTooltipContent(node));
+ }
+
+ // Click to select + show properties
+ border.PointerPressed += Node_Click;
+
+ // Right-click context menu
+ border.ContextMenu = BuildNodeContextMenu(node);
+
+ var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
+
+ // Icon row: icon + optional warning/parallel indicators
+ var iconRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+
+ var iconBitmap = IconHelper.LoadIcon(node.IconName);
+ if (iconBitmap != null)
+ {
+ iconRow.Children.Add(new Image
+ {
+ Source = iconBitmap,
+ Width = 32,
+ Height = 32,
+ Margin = new Thickness(0, 0, 0, 2)
+ });
+ }
+
+ // Warning indicator badge (orange triangle with !)
+ if (node.HasWarnings)
+ {
+ var warnBadge = new Grid
+ {
+ Width = 20, Height = 20,
+ Margin = new Thickness(4, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ warnBadge.Children.Add(new AvaloniaPath
+ {
+ Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"),
+ Fill = OrangeBrush
+ });
+ warnBadge.Children.Add(new TextBlock
+ {
+ Text = "!",
+ FontSize = 12,
+ FontWeight = FontWeight.ExtraBold,
+ Foreground = Brushes.White,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 3, 0, 0)
+ });
+ iconRow.Children.Add(warnBadge);
+ }
+
+ // Parallel indicator badge (amber circle with arrows)
+ if (node.Parallel)
+ {
+ var parBadge = new Grid
+ {
+ Width = 20, Height = 20,
+ Margin = new Thickness(4, 0, 0, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+ parBadge.Children.Add(new Ellipse
+ {
+ Width = 20, Height = 20,
+ Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07))
+ });
+ parBadge.Children.Add(new TextBlock
+ {
+ Text = "\u21C6",
+ FontSize = 12,
+ FontWeight = FontWeight.Bold,
+ Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ 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
+ var fgBrush = FindBrushResource("ForegroundBrush");
+
+ // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc.
+ var opLabel = node.PhysicalOp;
+ if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp)
+ && node.LogicalOp != "Parallelism")
+ {
+ opLabel = $"Parallelism\n({node.LogicalOp})";
+ }
+ stack.Children.Add(new TextBlock
+ {
+ Text = opLabel,
+ FontSize = 10,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = fgBrush,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = PlanLayoutEngine.NodeWidth - 16,
+ HorizontalAlignment = HorizontalAlignment.Center
+ });
+
+ // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors
+ IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush
+ : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush
+ : fgBrush;
+
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"Cost: {node.CostPercent}%",
+ FontSize = 10,
+ Foreground = costColor,
+ TextAlignment = TextAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ });
+
+ // Actual plan stats: elapsed time, CPU time, and row counts
+ if (node.HasActualStats)
+ {
+ // Compute own time (subtract children in row mode)
+ var ownElapsedMs = GetOwnElapsedMs(node);
+ var ownCpuMs = GetOwnCpuMs(node);
+
+ // Elapsed time -- color based on own time, not cumulative
+ var ownElapsedSec = ownElapsedMs / 1000.0;
+ IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush
+ : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush;
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"{ownElapsedSec:F3}s",
+ FontSize = 10,
+ Foreground = elapsedBrush,
+ TextAlignment = TextAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ });
+
+ // CPU time -- color based on own time
+ var ownCpuSec = ownCpuMs / 1000.0;
+ IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush
+ : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush;
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"CPU: {ownCpuSec:F3}s",
+ FontSize = 10,
+ Foreground = cpuBrush,
+ TextAlignment = TextAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center
+ });
+
+ // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit
+ var estRows = node.EstimateRows;
+ var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0);
+ IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush;
+ var accuracy = estRows > 0
+ ? $" ({accuracyRatio * 100:F0}%)"
+ : "";
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}",
+ FontSize = 10,
+ Foreground = rowBrush,
+ TextAlignment = TextAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ TextTrimming = TextTrimming.CharacterEllipsis,
+ MaxWidth = PlanLayoutEngine.NodeWidth - 16
+ });
+ }
+
+ // Object name -- show full object name, wrap if needed
+ if (!string.IsNullOrEmpty(node.ObjectName))
+ {
+ var objBlock = new TextBlock
+ {
+ Text = node.FullObjectName ?? node.ObjectName,
+ FontSize = 10,
+ Foreground = fgBrush,
+ TextAlignment = TextAlignment.Center,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = PlanLayoutEngine.NodeWidth - 16,
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+ stack.Children.Add(objBlock);
+ }
+
+ // Total warning count badge on root node
+ if (totalWarningCount > 0)
+ {
+ var badgeRow = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 2, 0, 0)
+ };
+ badgeRow.Children.Add(new TextBlock
+ {
+ Text = "\u26A0",
+ FontSize = 13,
+ Foreground = OrangeBrush,
+ VerticalAlignment = VerticalAlignment.Center,
+ Margin = new Thickness(0, 0, 4, 0)
+ });
+ badgeRow.Children.Add(new TextBlock
+ {
+ Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}",
+ FontSize = 12,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = OrangeBrush,
+ VerticalAlignment = VerticalAlignment.Center
+ });
+ stack.Children.Add(badgeRow);
+ }
+
+ border.Child = stack;
+ return border;
+ }
+
+ private void RenderEdges(PlanNode node, double divergenceLimit)
+ {
+ foreach (var child in node.Children)
+ {
+ var path = CreateElbowConnector(node, child, divergenceLimit);
+ PlanCanvas.Children.Add(path);
+
+ RenderEdges(child, divergenceLimit);
+ }
+ }
+
+ ///
+ /// Returns a color brush for a link based on the accuracy ratio of the child node.
+ /// Only applies to actual plans; estimated plans use the default edge brush.
+ ///
+ private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit)
+ {
+ if (!child.HasActualStats)
+ return EdgeBrush;
+
+ divergenceLimit = Math.Max(2.0, divergenceLimit);
+ var estRows = child.EstimateRows;
+ var accuracyRatio = estRows > 0
+ ? child.ActualRows / estRows
+ : (child.ActualRows > 0 ? double.MaxValue : 1.0);
+
+ // Within the neutral band — keep default color
+ if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit)
+ return EdgeBrush;
+
+ // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated)
+ if (accuracyRatio > divergenceLimit)
+ {
+ if (accuracyRatio >= divergenceLimit * 100)
+ return LinkFluoRedBrush;
+ if (accuracyRatio >= divergenceLimit * 10)
+ return LinkFluoOrangeBrush;
+ return LinkLightOrangeBrush;
+ }
+
+ // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated)
+ if (accuracyRatio < 1.0 / (divergenceLimit * 100))
+ return LinkFluoBlueBrush;
+ if (accuracyRatio < 1.0 / (divergenceLimit * 10))
+ return LinkLightBlueBrush;
+ return LinkBlueBrush;
+ }
+
+ private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit)
+ {
+ var parentRight = parent.X + PlanLayoutEngine.NodeWidth;
+ var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2;
+ var childLeft = child.X;
+ var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2;
+
+ // Arrow thickness based on row estimate (logarithmic)
+ var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows;
+ var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12));
+
+ var midX = (parentRight + childLeft) / 2;
+
+ var geometry = new PathGeometry();
+ var figure = new PathFigure
+ {
+ StartPoint = new Point(parentRight, parentCenterY),
+ IsClosed = false
+ };
+ figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) });
+ figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
+ geometry.Figures!.Add(figure);
+
+ var linkBrush = GetLinkColorBrush(child, divergenceLimit);
+
+ var path = new AvaloniaPath
+ {
+ Data = geometry,
+ Stroke = linkBrush,
+ StrokeThickness = thickness,
+ StrokeJoin = PenLineJoin.Round
+ };
+ ToolTip.SetTip(path, BuildEdgeTooltipContent(child));
+ return path;
+ }
+
+ private object BuildEdgeTooltipContent(PlanNode child)
+ {
+ var panel = new StackPanel { MinWidth = 240 };
+
+ void AddRow(string label, string value)
+ {
+ var row = new Grid();
+ row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
+ row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
+ var lbl = new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
+ FontSize = 12,
+ Margin = new Thickness(0, 1, 12, 1)
+ };
+ var val = new TextBlock
+ {
+ Text = value,
+ Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)),
+ FontSize = 12,
+ FontWeight = FontWeight.SemiBold,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ Grid.SetColumn(lbl, 0);
+ Grid.SetColumn(val, 1);
+ row.Children.Add(lbl);
+ row.Children.Add(val);
+ panel.Children.Add(row);
+ }
+
+ if (child.HasActualStats)
+ AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}");
+
+ AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}");
+
+ var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds;
+ var estimatedRowsAllExec = child.EstimateRows * executions;
+ AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}");
+
+ if (child.EstimatedRowSize > 0)
+ {
+ AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize));
+ var dataSize = estimatedRowsAllExec * child.EstimatedRowSize;
+ AddRow("Estimated Data Size", FormatBytes(dataSize));
+ }
+
+ return new Border
+ {
+ Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
+ BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(10, 6),
+ CornerRadius = new CornerRadius(4),
+ Child = panel
+ };
+ }
+
+ private static string FormatBytes(double bytes)
+ {
+ if (bytes < 1024) return $"{bytes:N0} B";
+ if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB";
+ if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB";
+ return $"{bytes / (1024L * 1024 * 1024):N1} GB";
+ }
+
+ private static string FormatBenefitPercent(double pct) =>
+ pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";
+
+ private static bool HasSpillInPlanTree(PlanNode node)
+ {
+ foreach (var w in node.Warnings)
+ if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true;
+ foreach (var child in node.Children)
+ if (HasSpillInPlanTree(child)) return true;
+ return false;
+ }
+
+ private static void CollectWarnings(PlanNode node, List warnings)
+ {
+ warnings.AddRange(node.Warnings);
+ foreach (var child in node.Children)
+ CollectWarnings(child, warnings);
+ }
+
+ private IBrush FindBrushResource(string key)
+ {
+ if (this.TryFindResource(key, out var resource) && resource is IBrush brush)
+ return brush;
+
+ // Fallback brushes in case resources are not found
+ return key switch
+ {
+ "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)),
+ "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)),
+ "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ _ => Brushes.White
+ };
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
new file mode 100644
index 0000000..a765092
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
@@ -0,0 +1,347 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ 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();
+ }
+ }
+
+ 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--;
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
new file mode 100644
index 0000000..a03c712
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
@@ -0,0 +1,222 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private void PopulateStatementsGrid(List statements)
+ {
+ StatementsHeader.Text = $"Statements ({statements.Count})";
+
+ var hasActualTimes = statements.Any(s => s.QueryTimeStats != null &&
+ (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0));
+ var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0);
+
+ // Build columns
+ StatementsGrid.Columns.Clear();
+
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "#",
+ Binding = new Avalonia.Data.Binding("Index"),
+ Width = new DataGridLength(40),
+ IsReadOnly = true
+ });
+
+ var queryTemplate = new FuncDataTemplate((row, _) =>
+ {
+ if (row == null) return new TextBlock();
+ var tb = new TextBlock
+ {
+ Text = row.QueryText,
+ TextWrapping = TextWrapping.Wrap,
+ MaxHeight = 80,
+ FontSize = 11,
+ Margin = new Thickness(4, 2)
+ };
+ ToolTip.SetTip(tb, new TextBlock
+ {
+ Text = row.FullQueryText,
+ TextWrapping = TextWrapping.Wrap,
+ MaxWidth = 600,
+ FontFamily = new FontFamily("Consolas"),
+ FontSize = 11
+ });
+ return tb;
+ }, supportsRecycling: false);
+
+ StatementsGrid.Columns.Add(new DataGridTemplateColumn
+ {
+ Header = "Query",
+ CellTemplate = queryTemplate,
+ Width = new DataGridLength(250),
+ IsReadOnly = true
+ });
+
+ if (hasActualTimes)
+ {
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "CPU",
+ Binding = new Avalonia.Data.Binding("CpuDisplay"),
+ Width = new DataGridLength(70),
+ IsReadOnly = true,
+ CustomSortComparer = new LongComparer(r => r.CpuMs)
+ });
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "Elapsed",
+ Binding = new Avalonia.Data.Binding("ElapsedDisplay"),
+ Width = new DataGridLength(70),
+ IsReadOnly = true,
+ CustomSortComparer = new LongComparer(r => r.ElapsedMs)
+ });
+ }
+
+ if (hasUdf)
+ {
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "UDF",
+ Binding = new Avalonia.Data.Binding("UdfDisplay"),
+ Width = new DataGridLength(70),
+ IsReadOnly = true,
+ CustomSortComparer = new LongComparer(r => r.UdfMs)
+ });
+ }
+
+ if (!hasActualTimes)
+ {
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "Est. Cost",
+ Binding = new Avalonia.Data.Binding("CostDisplay"),
+ Width = new DataGridLength(80),
+ IsReadOnly = true,
+ CustomSortComparer = new DoubleComparer(r => r.EstCost)
+ });
+ }
+
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "Critical",
+ Binding = new Avalonia.Data.Binding("Critical"),
+ Width = new DataGridLength(60),
+ IsReadOnly = true
+ });
+
+ StatementsGrid.Columns.Add(new DataGridTextColumn
+ {
+ Header = "Warnings",
+ Binding = new Avalonia.Data.Binding("Warnings"),
+ Width = new DataGridLength(70),
+ IsReadOnly = true
+ });
+
+ // Build rows
+ var rows = new List();
+ for (int i = 0; i < statements.Count; i++)
+ {
+ var stmt = statements[i];
+ var allWarnings = stmt.PlanWarnings.ToList();
+ if (stmt.RootNode != null)
+ CollectNodeWarnings(stmt.RootNode, allWarnings);
+
+ var fullText = stmt.StatementText;
+ if (string.IsNullOrWhiteSpace(fullText))
+ fullText = $"Statement {i + 1}";
+ var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText;
+
+ rows.Add(new StatementRow
+ {
+ Index = i + 1,
+ QueryText = displayText,
+ FullQueryText = fullText,
+ CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0,
+ ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0,
+ UdfMs = stmt.QueryUdfElapsedTimeMs,
+ EstCost = stmt.StatementSubTreeCost,
+ Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical),
+ Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning),
+ Statement = stmt
+ });
+ }
+
+ StatementsGrid.ItemsSource = rows;
+ }
+
+ private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (StatementsGrid.SelectedItem is StatementRow row)
+ RenderStatement(row.Statement);
+ }
+
+ private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
+ {
+ if (StatementsGrid.SelectedItem is not StatementRow row) return;
+ var text = row.Statement.StatementText;
+ if (string.IsNullOrEmpty(text)) return;
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.Clipboard != null)
+ 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);
+ foreach (var child in node.Children)
+ CollectNodeWarnings(child, warnings);
+ }
+
+ private void ToggleStatements_Click(object? sender, RoutedEventArgs e)
+ {
+ if (StatementsPanel.IsVisible)
+ CloseStatementsPanel();
+ else
+ ShowStatementsPanel();
+ }
+
+ private void CloseStatements_Click(object? sender, RoutedEventArgs e)
+ {
+ CloseStatementsPanel();
+ }
+
+ private void ShowStatementsPanel()
+ {
+ _statementsColumn.Width = new GridLength(450);
+ _statementsSplitterColumn.Width = new GridLength(5);
+ StatementsSplitter.IsVisible = true;
+ StatementsPanel.IsVisible = true;
+ StatementsButton.IsVisible = true;
+ StatementsButtonSeparator.IsVisible = true;
+ }
+
+ private void CloseStatementsPanel()
+ {
+ StatementsPanel.IsVisible = false;
+ StatementsSplitter.IsVisible = false;
+ _statementsColumn.Width = new GridLength(0);
+ _statementsSplitterColumn.Width = new GridLength(0);
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs
new file mode 100644
index 0000000..841283f
--- /dev/null
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Tooltips.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Layout;
+using Avalonia.Media;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Controls;
+
+public partial class PlanViewerControl : UserControl
+{
+ private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null)
+ {
+ var tipBorder = new Border
+ {
+ Background = TooltipBgBrush,
+ BorderBrush = TooltipBorderBrush,
+ BorderThickness = new Thickness(1),
+ Padding = new Thickness(12),
+ MaxWidth = 500
+ };
+
+ var stack = new StackPanel();
+
+ // Header
+ var headerText = node.PhysicalOp;
+ if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
+ && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
+ headerText += $" ({node.LogicalOp})";
+ stack.Children.Add(new TextBlock
+ {
+ Text = headerText,
+ FontWeight = FontWeight.Bold,
+ FontSize = 13,
+ Foreground = TooltipFgBrush,
+ Margin = new Thickness(0, 0, 0, 8)
+ });
+
+ // Cost
+ AddTooltipSection(stack, "Costs");
+ AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})");
+ AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}");
+
+ // Rows
+ AddTooltipSection(stack, "Rows");
+ AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}");
+ if (node.HasActualStats)
+ {
+ AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}");
+ if (node.ActualRowsRead > 0)
+ AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}");
+ AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}");
+ }
+
+ // Rebinds/Rewinds (spools and other operators with rebind/rewind data)
+ if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0
+ || node.ActualRebinds > 0 || node.ActualRewinds > 0)
+ {
+ AddTooltipSection(stack, "Rebinds / Rewinds");
+ // Always show both estimated values when section is visible
+ AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}");
+ AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}");
+ if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}");
+ if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}");
+ }
+
+ // I/O and CPU estimates
+ if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0)
+ {
+ AddTooltipSection(stack, "Estimates");
+ if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}");
+ if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}");
+ if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B");
+ }
+
+ // Actual I/O
+ if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0))
+ {
+ AddTooltipSection(stack, "Actual I/O");
+ AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}");
+ if (node.ActualPhysicalReads > 0)
+ AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}");
+ if (node.ActualScans > 0)
+ AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}");
+ if (node.ActualReadAheads > 0)
+ AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}");
+ }
+
+ // Actual timing
+ if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0))
+ {
+ AddTooltipSection(stack, "Timing");
+ if (node.ActualElapsedMs > 0)
+ AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms");
+ if (node.ActualCPUMs > 0)
+ AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms");
+ }
+
+ // Parallelism
+ if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType))
+ {
+ AddTooltipSection(stack, "Parallelism");
+ if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes");
+ if (!string.IsNullOrEmpty(node.ExecutionMode))
+ AddTooltipRow(stack, "Execution Mode", node.ExecutionMode);
+ if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode)
+ AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode);
+ if (!string.IsNullOrEmpty(node.PartitioningType))
+ AddTooltipRow(stack, "Partitioning", node.PartitioningType);
+ }
+
+ // Object
+ if (!string.IsNullOrEmpty(node.FullObjectName))
+ {
+ AddTooltipSection(stack, "Object");
+ AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true);
+ if (node.Ordered) AddTooltipRow(stack, "Ordered", "True");
+ if (!string.IsNullOrEmpty(node.ScanDirection))
+ AddTooltipRow(stack, "Scan Direction", node.ScanDirection);
+ }
+ else if (!string.IsNullOrEmpty(node.ObjectName))
+ {
+ AddTooltipSection(stack, "Object");
+ AddTooltipRow(stack, "Name", node.ObjectName, isCode: true);
+ if (node.Ordered) AddTooltipRow(stack, "Ordered", "True");
+ if (!string.IsNullOrEmpty(node.ScanDirection))
+ 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)
+ || !string.IsNullOrEmpty(node.GroupBy)
+ || !string.IsNullOrEmpty(node.OuterReferences);
+ if (hasTooltipDetails)
+ {
+ AddTooltipSection(stack, "Details");
+ if (!string.IsNullOrEmpty(node.OrderBy))
+ AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true);
+ if (!string.IsNullOrEmpty(node.TopExpression))
+ AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression);
+ if (!string.IsNullOrEmpty(node.GroupBy))
+ AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true);
+ if (!string.IsNullOrEmpty(node.OuterReferences))
+ AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true);
+ }
+
+ // Predicates
+ if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate))
+ {
+ AddTooltipSection(stack, "Predicates");
+ if (!string.IsNullOrEmpty(node.SeekPredicates))
+ AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true);
+ if (!string.IsNullOrEmpty(node.Predicate))
+ AddTooltipRow(stack, "Residual", node.Predicate, isCode: true);
+ }
+
+ // Output columns
+ if (!string.IsNullOrEmpty(node.OutputColumns))
+ {
+ AddTooltipSection(stack, "Output");
+ AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true);
+ }
+
+ // 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) });
+
+ if (allWarnings != null)
+ {
+ // Root node: show distinct warning type names only, sorted by max benefit
+ var distinct = warnings
+ .GroupBy(w => w.WarningType)
+ .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(),
+ MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1)))
+ .OrderByDescending(g => g.MaxBenefit)
+ .ThenByDescending(g => g.MaxSeverity)
+ .ThenBy(g => g.Type);
+
+ foreach (var (type, severity, count, maxBenefit) in distinct)
+ {
+ var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
+ : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : "";
+ var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}";
+ stack.Children.Add(new TextBlock
+ {
+ Text = label,
+ Foreground = new SolidColorBrush(Color.Parse(warnColor)),
+ FontSize = 11,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
+ }
+ else
+ {
+ // Individual node: show full warning messages
+ foreach (var w in warnings)
+ {
+ var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
+ : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
+ stack.Children.Add(new TextBlock
+ {
+ Text = $"\u26A0 {w.WarningType}: {w.Message}",
+ Foreground = new SolidColorBrush(Color.Parse(warnColor)),
+ FontSize = 11,
+ TextWrapping = TextWrapping.Wrap,
+ Margin = new Thickness(0, 2, 0, 0)
+ });
+ }
+ }
+ }
+
+ // Footer hint
+ stack.Children.Add(new TextBlock
+ {
+ Text = "Click to view full properties",
+ FontSize = 10,
+ FontStyle = FontStyle.Italic,
+ Foreground = TooltipFgBrush,
+ Margin = new Thickness(0, 8, 0, 0)
+ });
+
+ tipBorder.Child = stack;
+ return tipBorder;
+ }
+
+ private static void AddTooltipSection(StackPanel parent, string title)
+ {
+ parent.Children.Add(new TextBlock
+ {
+ Text = title,
+ FontSize = 10,
+ FontWeight = FontWeight.SemiBold,
+ Foreground = SectionHeaderBrush,
+ Margin = new Thickness(0, 6, 0, 2)
+ });
+ }
+
+ private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
+ {
+ var row = new Grid
+ {
+ ColumnDefinitions = new ColumnDefinitions("Auto,*"),
+ Margin = new Thickness(0, 1, 0, 1)
+ };
+ var labelBlock = new TextBlock
+ {
+ Text = $"{label}: ",
+ Foreground = TooltipFgBrush,
+ FontSize = 11,
+ MinWidth = 120,
+ VerticalAlignment = VerticalAlignment.Top
+ };
+ Grid.SetColumn(labelBlock, 0);
+ row.Children.Add(labelBlock);
+
+ var valueBlock = new TextBlock
+ {
+ Text = value,
+ FontSize = 11,
+ Foreground = TooltipFgBrush,
+ TextWrapping = TextWrapping.Wrap
+ };
+ if (isCode) valueBlock.FontFamily = new FontFamily("Consolas");
+ Grid.SetColumn(valueBlock, 1);
+ row.Children.Add(valueBlock);
+ parent.Children.Add(row);
+ }
+}
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index a2d77e3..f6925f4 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -378,3254 +378,9 @@ public void Clear()
CloseMinimapPanel();
}
- private static void CountNodeWarnings(PlanNode node, ref int total, ref int critical)
- {
- total += node.Warnings.Count;
- critical += node.Warnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
- foreach (var child in node.Children)
- CountNodeWarnings(child, ref total, ref critical);
- }
-
- private void RenderStatement(PlanStatement statement)
- {
- _currentStatement = statement;
- PlanCanvas.Children.Clear();
- _nodeBorderMap.Clear();
- _selectedNodeBorder = null;
- _selectedNode = null;
-
- if (statement.RootNode == null) return;
-
- // Layout
- PlanLayoutEngine.Layout(statement);
- var (width, height) = PlanLayoutEngine.GetExtents(statement.RootNode);
- PlanCanvas.Width = width;
- PlanCanvas.Height = height;
-
- // Render edges first (behind nodes)
- var divergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit);
- RenderEdges(statement.RootNode, divergenceLimit);
-
- // Render nodes — pass total warning count to root node for badge
- var allWarnings = new List();
- CollectWarnings(statement.RootNode, allWarnings);
- RenderNodes(statement.RootNode, divergenceLimit, allWarnings.Count);
-
- // Update banners
- ShowMissingIndexes(statement.MissingIndexes);
- ShowParameters(statement);
- ShowWaitStats(statement.WaitStats, statement.WaitBenefits, statement.QueryTimeStats != null);
- ShowRuntimeSummary(statement);
- UpdateInsightsHeader();
-
- // Scroll to top-left so the plan root is immediately visible
- PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
-
- // Canvas-level context menu (zoom, advice, repro, save)
- // Set on ScrollViewer, not Canvas — Canvas has no background so it's not hit-testable
- PlanScrollViewer.ContextMenu = BuildCanvasContextMenu();
-
- CostText.Text = "";
-
- // Update minimap if visible
- if (MinimapPanel.IsVisible)
- Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
- }
-
- #region Node Rendering
-
- private void RenderNodes(PlanNode node, double divergenceLimit, int totalWarningCount = -1)
- {
- var visual = CreateNodeVisual(node, divergenceLimit, totalWarningCount);
- Canvas.SetLeft(visual, node.X);
- Canvas.SetTop(visual, node.Y);
- PlanCanvas.Children.Add(visual);
-
- foreach (var child in node.Children)
- RenderNodes(child, divergenceLimit);
- }
-
- private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int totalWarningCount = -1)
- {
- var isExpensive = node.IsExpensive;
-
- var bgBrush = isExpensive
- ? new SolidColorBrush(Color.FromArgb(0x30, 0xE5, 0x73, 0x73))
- : FindBrushResource("BackgroundLightBrush");
-
- var borderBrush = isExpensive
- ? OrangeRedBrush
- : FindBrushResource("BorderBrush");
-
- var border = new Border
- {
- Width = PlanLayoutEngine.NodeWidth,
- MinHeight = PlanLayoutEngine.NodeHeightMin,
- Background = bgBrush,
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(isExpensive ? 2 : 1),
- CornerRadius = new CornerRadius(4),
- Padding = new Thickness(6, 4, 6, 4),
- Cursor = new Cursor(StandardCursorType.Hand)
- };
-
- // Map border to node (replaces WPF Tag)
- _nodeBorderMap[border] = node;
-
- // Tooltip — root node gets all collected warnings so the tooltip shows them
- if (totalWarningCount > 0)
- {
- var allWarnings = new List();
- if (_currentStatement != null)
- allWarnings.AddRange(_currentStatement.PlanWarnings);
- CollectWarnings(node, allWarnings);
- ToolTip.SetTip(border, BuildNodeTooltipContent(node, allWarnings));
- }
- else
- {
- ToolTip.SetTip(border, BuildNodeTooltipContent(node));
- }
-
- // Click to select + show properties
- border.PointerPressed += Node_Click;
-
- // Right-click context menu
- border.ContextMenu = BuildNodeContextMenu(node);
-
- var stack = new StackPanel { HorizontalAlignment = HorizontalAlignment.Center };
-
- // Icon row: icon + optional warning/parallel indicators
- var iconRow = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Center
- };
-
- var iconBitmap = IconHelper.LoadIcon(node.IconName);
- if (iconBitmap != null)
- {
- iconRow.Children.Add(new Image
- {
- Source = iconBitmap,
- Width = 32,
- Height = 32,
- Margin = new Thickness(0, 0, 0, 2)
- });
- }
-
- // Warning indicator badge (orange triangle with !)
- if (node.HasWarnings)
- {
- var warnBadge = new Grid
- {
- Width = 20, Height = 20,
- Margin = new Thickness(4, 0, 0, 0),
- VerticalAlignment = VerticalAlignment.Center
- };
- warnBadge.Children.Add(new AvaloniaPath
- {
- Data = StreamGeometry.Parse("M 10,0 L 20,18 L 0,18 Z"),
- Fill = OrangeBrush
- });
- warnBadge.Children.Add(new TextBlock
- {
- Text = "!",
- FontSize = 12,
- FontWeight = FontWeight.ExtraBold,
- Foreground = Brushes.White,
- HorizontalAlignment = HorizontalAlignment.Center,
- Margin = new Thickness(0, 3, 0, 0)
- });
- iconRow.Children.Add(warnBadge);
- }
-
- // Parallel indicator badge (amber circle with arrows)
- if (node.Parallel)
- {
- var parBadge = new Grid
- {
- Width = 20, Height = 20,
- Margin = new Thickness(4, 0, 0, 0),
- VerticalAlignment = VerticalAlignment.Center
- };
- parBadge.Children.Add(new Ellipse
- {
- Width = 20, Height = 20,
- Fill = new SolidColorBrush(Color.FromRgb(0xFF, 0xC1, 0x07))
- });
- parBadge.Children.Add(new TextBlock
- {
- Text = "\u21C6",
- FontSize = 12,
- FontWeight = FontWeight.Bold,
- Foreground = new SolidColorBrush(Color.FromRgb(0x33, 0x33, 0x33)),
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center
- });
- 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
- var fgBrush = FindBrushResource("ForegroundBrush");
-
- // Operator name — for exchanges, show "Parallelism" + "(Gather Streams)" etc.
- var opLabel = node.PhysicalOp;
- if (node.PhysicalOp == "Parallelism" && !string.IsNullOrEmpty(node.LogicalOp)
- && node.LogicalOp != "Parallelism")
- {
- opLabel = $"Parallelism\n({node.LogicalOp})";
- }
- stack.Children.Add(new TextBlock
- {
- Text = opLabel,
- FontSize = 10,
- FontWeight = FontWeight.SemiBold,
- Foreground = fgBrush,
- TextAlignment = TextAlignment.Center,
- TextWrapping = TextWrapping.Wrap,
- MaxWidth = PlanLayoutEngine.NodeWidth - 16,
- HorizontalAlignment = HorizontalAlignment.Center
- });
-
- // Cost percentage — only highlight in estimated plans; actual plans use duration/CPU colors
- IBrush costColor = !node.HasActualStats && node.CostPercent >= 50 ? OrangeRedBrush
- : !node.HasActualStats && node.CostPercent >= 25 ? OrangeBrush
- : fgBrush;
-
- stack.Children.Add(new TextBlock
- {
- Text = $"Cost: {node.CostPercent}%",
- FontSize = 10,
- Foreground = costColor,
- TextAlignment = TextAlignment.Center,
- HorizontalAlignment = HorizontalAlignment.Center
- });
-
- // Actual plan stats: elapsed time, CPU time, and row counts
- if (node.HasActualStats)
- {
- // Compute own time (subtract children in row mode)
- var ownElapsedMs = GetOwnElapsedMs(node);
- var ownCpuMs = GetOwnCpuMs(node);
-
- // Elapsed time -- color based on own time, not cumulative
- var ownElapsedSec = ownElapsedMs / 1000.0;
- IBrush elapsedBrush = ownElapsedSec >= 1.0 ? OrangeRedBrush
- : ownElapsedSec >= 0.1 ? OrangeBrush : fgBrush;
- stack.Children.Add(new TextBlock
- {
- Text = $"{ownElapsedSec:F3}s",
- FontSize = 10,
- Foreground = elapsedBrush,
- TextAlignment = TextAlignment.Center,
- HorizontalAlignment = HorizontalAlignment.Center
- });
-
- // CPU time -- color based on own time
- var ownCpuSec = ownCpuMs / 1000.0;
- IBrush cpuBrush = ownCpuSec >= 1.0 ? OrangeRedBrush
- : ownCpuSec >= 0.1 ? OrangeBrush : fgBrush;
- stack.Children.Add(new TextBlock
- {
- Text = $"CPU: {ownCpuSec:F3}s",
- FontSize = 10,
- Foreground = cpuBrush,
- TextAlignment = TextAlignment.Center,
- HorizontalAlignment = HorizontalAlignment.Center
- });
-
- // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit
- var estRows = node.EstimateRows;
- var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0);
- IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush;
- var accuracy = estRows > 0
- ? $" ({accuracyRatio * 100:F0}%)"
- : "";
- stack.Children.Add(new TextBlock
- {
- Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}",
- FontSize = 10,
- Foreground = rowBrush,
- TextAlignment = TextAlignment.Center,
- HorizontalAlignment = HorizontalAlignment.Center,
- TextTrimming = TextTrimming.CharacterEllipsis,
- MaxWidth = PlanLayoutEngine.NodeWidth - 16
- });
- }
-
- // Object name -- show full object name, wrap if needed
- if (!string.IsNullOrEmpty(node.ObjectName))
- {
- var objBlock = new TextBlock
- {
- Text = node.FullObjectName ?? node.ObjectName,
- FontSize = 10,
- Foreground = fgBrush,
- TextAlignment = TextAlignment.Center,
- TextWrapping = TextWrapping.Wrap,
- MaxWidth = PlanLayoutEngine.NodeWidth - 16,
- HorizontalAlignment = HorizontalAlignment.Center
- };
- stack.Children.Add(objBlock);
- }
-
- // Total warning count badge on root node
- if (totalWarningCount > 0)
- {
- var badgeRow = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Center,
- Margin = new Thickness(0, 2, 0, 0)
- };
- badgeRow.Children.Add(new TextBlock
- {
- Text = "\u26A0",
- FontSize = 13,
- Foreground = OrangeBrush,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 0, 4, 0)
- });
- badgeRow.Children.Add(new TextBlock
- {
- Text = $"{totalWarningCount} warning{(totalWarningCount == 1 ? "" : "s")}",
- FontSize = 12,
- FontWeight = FontWeight.SemiBold,
- Foreground = OrangeBrush,
- VerticalAlignment = VerticalAlignment.Center
- });
- stack.Children.Add(badgeRow);
- }
-
- border.Child = stack;
- return border;
- }
-
- #endregion
-
- #region Edge Rendering
-
- private void RenderEdges(PlanNode node, double divergenceLimit)
- {
- foreach (var child in node.Children)
- {
- var path = CreateElbowConnector(node, child, divergenceLimit);
- PlanCanvas.Children.Add(path);
-
- RenderEdges(child, divergenceLimit);
- }
- }
-
- ///
- /// Returns a color brush for a link based on the accuracy ratio of the child node.
- /// Only applies to actual plans; estimated plans use the default edge brush.
- ///
- private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit)
- {
- if (!child.HasActualStats)
- return EdgeBrush;
-
- divergenceLimit = Math.Max(2.0, divergenceLimit);
- var estRows = child.EstimateRows;
- var accuracyRatio = estRows > 0
- ? child.ActualRows / estRows
- : (child.ActualRows > 0 ? double.MaxValue : 1.0);
-
- // Within the neutral band — keep default color
- if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit)
- return EdgeBrush;
-
- // Underestimated bands (accuracyRatio > 1 means more actual rows than estimated)
- if (accuracyRatio > divergenceLimit)
- {
- if (accuracyRatio >= divergenceLimit * 100)
- return LinkFluoRedBrush;
- if (accuracyRatio >= divergenceLimit * 10)
- return LinkFluoOrangeBrush;
- return LinkLightOrangeBrush;
- }
-
- // Overestimated bands (accuracyRatio < 1 means fewer actual rows than estimated)
- if (accuracyRatio < 1.0 / (divergenceLimit * 100))
- return LinkFluoBlueBrush;
- if (accuracyRatio < 1.0 / (divergenceLimit * 10))
- return LinkLightBlueBrush;
- return LinkBlueBrush;
- }
-
- private AvaloniaPath CreateElbowConnector(PlanNode parent, PlanNode child, double divergenceLimit)
- {
- var parentRight = parent.X + PlanLayoutEngine.NodeWidth;
- var parentCenterY = parent.Y + PlanLayoutEngine.GetNodeHeight(parent) / 2;
- var childLeft = child.X;
- var childCenterY = child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2;
-
- // Arrow thickness based on row estimate (logarithmic)
- var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows;
- var thickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12));
-
- var midX = (parentRight + childLeft) / 2;
-
- var geometry = new PathGeometry();
- var figure = new PathFigure
- {
- StartPoint = new Point(parentRight, parentCenterY),
- IsClosed = false
- };
- figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) });
- figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) });
- figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
- geometry.Figures!.Add(figure);
-
- var linkBrush = GetLinkColorBrush(child, divergenceLimit);
-
- var path = new AvaloniaPath
- {
- Data = geometry,
- Stroke = linkBrush,
- StrokeThickness = thickness,
- StrokeJoin = PenLineJoin.Round
- };
- ToolTip.SetTip(path, BuildEdgeTooltipContent(child));
- return path;
- }
-
- private object BuildEdgeTooltipContent(PlanNode child)
- {
- var panel = new StackPanel { MinWidth = 240 };
-
- void AddRow(string label, string value)
- {
- var row = new Grid();
- row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star));
- row.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Auto));
- var lbl = new TextBlock
- {
- Text = label,
- Foreground = new SolidColorBrush(Color.FromRgb(0xE0, 0xE0, 0xE0)),
- FontSize = 12,
- Margin = new Thickness(0, 1, 12, 1)
- };
- var val = new TextBlock
- {
- Text = value,
- Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xFF, 0xFF)),
- FontSize = 12,
- FontWeight = FontWeight.SemiBold,
- HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
- Margin = new Thickness(0, 1, 0, 1)
- };
- Grid.SetColumn(lbl, 0);
- Grid.SetColumn(val, 1);
- row.Children.Add(lbl);
- row.Children.Add(val);
- panel.Children.Add(row);
- }
-
- if (child.HasActualStats)
- AddRow("Actual Number of Rows for All Executions", $"{child.ActualRows:N0}");
-
- AddRow("Estimated Number of Rows Per Execution", $"{child.EstimateRows:N0}");
-
- var executions = 1.0 + child.EstimateRebinds + child.EstimateRewinds;
- var estimatedRowsAllExec = child.EstimateRows * executions;
- AddRow("Estimated Number of Rows for All Executions", $"{estimatedRowsAllExec:N0}");
-
- if (child.EstimatedRowSize > 0)
- {
- AddRow("Estimated Row Size", FormatBytes(child.EstimatedRowSize));
- var dataSize = estimatedRowsAllExec * child.EstimatedRowSize;
- AddRow("Estimated Data Size", FormatBytes(dataSize));
- }
-
- return new Border
- {
- Background = new SolidColorBrush(Color.FromRgb(0x1E, 0x1E, 0x2E)),
- BorderBrush = new SolidColorBrush(Color.FromRgb(0x3A, 0x3A, 0x5A)),
- BorderThickness = new Thickness(1),
- Padding = new Thickness(10, 6),
- CornerRadius = new CornerRadius(4),
- Child = panel
- };
- }
-
- private static string FormatBytes(double bytes)
- {
- if (bytes < 1024) return $"{bytes:N0} B";
- if (bytes < 1024 * 1024) return $"{bytes / 1024:N0} KB";
- if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024 * 1024):N0} MB";
- return $"{bytes / (1024L * 1024 * 1024):N1} GB";
- }
-
- private static string FormatBenefitPercent(double pct) =>
- pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";
-
- private static bool HasSpillInPlanTree(PlanNode node)
- {
- foreach (var w in node.Warnings)
- if (w.WarningType.EndsWith(" Spill", StringComparison.Ordinal)) return true;
- foreach (var child in node.Children)
- if (HasSpillInPlanTree(child)) return true;
- return false;
- }
-
- #endregion
-
- #region Node Selection & Properties Panel
-
- private void Node_Click(object? sender, PointerPressedEventArgs e)
- {
- if (sender is Border border
- && e.GetCurrentPoint(border).Properties.IsLeftButtonPressed
- && _nodeBorderMap.TryGetValue(border, out var node))
- {
- SelectNode(border, node);
- e.Handled = true;
- }
- }
-
- private void SelectNode(Border border, PlanNode node)
- {
- // Deselect previous
- if (_selectedNodeBorder != null)
- {
- _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder;
- _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
- }
-
- // Select new
- _selectedNodeOriginalBorder = border.BorderBrush;
- _selectedNodeOriginalThickness = border.BorderThickness;
- _selectedNodeBorder = border;
- border.BorderBrush = SelectionBrush;
- border.BorderThickness = new Thickness(2);
-
- _selectedNode = node;
- ShowPropertiesPanel(node);
- UpdateMinimapSelection(node);
- }
-
- private ContextMenu BuildNodeContextMenu(PlanNode node)
- {
- var menu = new ContextMenu();
-
- var propsItem = new MenuItem { Header = "Properties" };
- propsItem.Click += (_, _) =>
- {
- foreach (var child in PlanCanvas.Children)
- {
- if (child is Border b && _nodeBorderMap.TryGetValue(b, out var n) && n == node)
- {
- SelectNode(b, node);
- break;
- }
- }
- };
- menu.Items.Add(propsItem);
-
- menu.Items.Add(new Separator());
-
- var copyOpItem = new MenuItem { Header = "Copy Operator Name" };
- copyOpItem.Click += async (_, _) => await SetClipboardTextAsync(node.PhysicalOp);
- menu.Items.Add(copyOpItem);
-
- if (!string.IsNullOrEmpty(node.FullObjectName))
- {
- var copyObjItem = new MenuItem { Header = "Copy Object Name" };
- copyObjItem.Click += async (_, _) => await SetClipboardTextAsync(node.FullObjectName!);
- menu.Items.Add(copyObjItem);
- }
-
- if (!string.IsNullOrEmpty(node.Predicate))
- {
- var copyPredItem = new MenuItem { Header = "Copy Predicate" };
- copyPredItem.Click += async (_, _) => await SetClipboardTextAsync(node.Predicate!);
- menu.Items.Add(copyPredItem);
- }
-
- if (!string.IsNullOrEmpty(node.SeekPredicates))
- {
- var copySeekItem = new MenuItem { Header = "Copy Seek Predicate" };
- copySeekItem.Click += async (_, _) => await SetClipboardTextAsync(node.SeekPredicates!);
- menu.Items.Add(copySeekItem);
- }
-
- // Schema lookup items (Show Indexes, Show Table Definition)
- AddSchemaMenuItems(menu, node);
-
- return menu;
- }
-
- private ContextMenu BuildCanvasContextMenu()
- {
- var menu = new ContextMenu();
-
- // Zoom
- var zoomInItem = new MenuItem { Header = "Zoom In" };
- zoomInItem.Click += (_, _) => SetZoom(_zoomLevel + ZoomStep);
- menu.Items.Add(zoomInItem);
-
- var zoomOutItem = new MenuItem { Header = "Zoom Out" };
- zoomOutItem.Click += (_, _) => SetZoom(_zoomLevel - ZoomStep);
- menu.Items.Add(zoomOutItem);
-
- var fitItem = new MenuItem { Header = "Fit to View" };
- fitItem.Click += ZoomFit_Click;
- menu.Items.Add(fitItem);
-
- menu.Items.Add(new Separator());
-
- // Advice
- var humanAdviceItem = new MenuItem { Header = "Human Advice" };
- humanAdviceItem.Click += (_, _) => HumanAdviceRequested?.Invoke(this, EventArgs.Empty);
- menu.Items.Add(humanAdviceItem);
-
- var robotAdviceItem = new MenuItem { Header = "Robot Advice" };
- robotAdviceItem.Click += (_, _) => RobotAdviceRequested?.Invoke(this, EventArgs.Empty);
- menu.Items.Add(robotAdviceItem);
-
- menu.Items.Add(new Separator());
-
- // Repro & Save
- var copyReproItem = new MenuItem { Header = "Copy Repro Script" };
- copyReproItem.Click += (_, _) => CopyReproRequested?.Invoke(this, EventArgs.Empty);
- menu.Items.Add(copyReproItem);
-
- var saveItem = new MenuItem { Header = "Save .sqlplan" };
- saveItem.Click += SavePlan_Click;
- menu.Items.Add(saveItem);
-
- return menu;
- }
-
- private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
- {
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel?.Clipboard != null)
- await topLevel.Clipboard.SetTextAsync(text);
- }
-
- private void ShowPropertiesPanel(PlanNode node)
- {
- PropertiesContent.Children.Clear();
- _sectionLabelColumns.Clear();
- _currentSectionGrid = null;
- _currentSectionRowIndex = 0;
-
- // Header
- var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
- && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
- headerText += $" ({node.LogicalOp})";
- PropertiesHeader.Text = headerText;
- PropertiesSubHeader.Text = $"Node ID: {node.NodeId}";
-
- // === General Section ===
- AddPropertySection("General");
- AddPropertyRow("Physical Operation", node.PhysicalOp);
- AddPropertyRow("Logical Operation", node.LogicalOp);
- AddPropertyRow("Node ID", $"{node.NodeId}");
- if (!string.IsNullOrEmpty(node.ExecutionMode))
- AddPropertyRow("Execution Mode", node.ExecutionMode);
- if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode)
- AddPropertyRow("Actual Exec Mode", node.ActualExecutionMode);
- AddPropertyRow("Parallel", node.Parallel ? "True" : "False");
- if (node.Partitioned)
- AddPropertyRow("Partitioned", "True");
- if (node.EstimatedDOP > 0)
- AddPropertyRow("Estimated DOP", $"{node.EstimatedDOP}");
-
- // Scan/seek-related properties
- if (!string.IsNullOrEmpty(node.FullObjectName))
- {
- AddPropertyRow("Ordered", node.Ordered ? "True" : "False");
- if (!string.IsNullOrEmpty(node.ScanDirection))
- AddPropertyRow("Scan Direction", node.ScanDirection);
- AddPropertyRow("Forced Index", node.ForcedIndex ? "True" : "False");
- AddPropertyRow("ForceScan", node.ForceScan ? "True" : "False");
- AddPropertyRow("ForceSeek", node.ForceSeek ? "True" : "False");
- AddPropertyRow("NoExpandHint", node.NoExpandHint ? "True" : "False");
- if (node.Lookup)
- AddPropertyRow("Lookup", "True");
- if (node.DynamicSeek)
- AddPropertyRow("Dynamic Seek", "True");
- }
-
- if (!string.IsNullOrEmpty(node.StorageType))
- AddPropertyRow("Storage", node.StorageType);
- if (node.IsAdaptive)
- AddPropertyRow("Adaptive", "True");
- if (node.SpillOccurredDetail)
- AddPropertyRow("Spill Occurred", "True");
-
- // === Object Section ===
- if (!string.IsNullOrEmpty(node.FullObjectName))
- {
- AddPropertySection("Object");
- AddPropertyRow("Full Name", node.FullObjectName, isCode: true);
- if (!string.IsNullOrEmpty(node.ServerName))
- AddPropertyRow("Server", node.ServerName);
- if (!string.IsNullOrEmpty(node.DatabaseName))
- AddPropertyRow("Database", node.DatabaseName);
- if (!string.IsNullOrEmpty(node.ObjectAlias))
- AddPropertyRow("Alias", node.ObjectAlias);
- if (!string.IsNullOrEmpty(node.IndexName))
- AddPropertyRow("Index", node.IndexName);
- if (!string.IsNullOrEmpty(node.IndexKind))
- AddPropertyRow("Index Kind", node.IndexKind);
- if (node.FilteredIndex)
- AddPropertyRow("Filtered Index", "True");
- if (node.TableReferenceId > 0)
- AddPropertyRow("Table Ref Id", $"{node.TableReferenceId}");
- }
-
- // === Operator Details Section ===
- var hasOperatorDetails = !string.IsNullOrEmpty(node.OrderBy)
- || !string.IsNullOrEmpty(node.TopExpression)
- || !string.IsNullOrEmpty(node.GroupBy)
- || !string.IsNullOrEmpty(node.PartitionColumns)
- || !string.IsNullOrEmpty(node.HashKeys)
- || !string.IsNullOrEmpty(node.SegmentColumn)
- || !string.IsNullOrEmpty(node.DefinedValues)
- || !string.IsNullOrEmpty(node.OuterReferences)
- || !string.IsNullOrEmpty(node.InnerSideJoinColumns)
- || !string.IsNullOrEmpty(node.OuterSideJoinColumns)
- || !string.IsNullOrEmpty(node.ActionColumn)
- || node.ManyToMany || node.PhysicalOp == "Merge Join" || node.BitmapCreator
- || node.SortDistinct || node.StartupExpression
- || node.NLOptimized || node.WithOrderedPrefetch || node.WithUnorderedPrefetch
- || node.WithTies || node.Remoting || node.LocalParallelism
- || node.SpoolStack || node.DMLRequestSort || node.NonClusteredIndexCount > 0
- || !string.IsNullOrEmpty(node.OffsetExpression) || node.TopRows > 0
- || !string.IsNullOrEmpty(node.ConstantScanValues)
- || !string.IsNullOrEmpty(node.UdxUsedColumns);
-
- if (hasOperatorDetails)
- {
- AddPropertySection("Operator Details");
- if (!string.IsNullOrEmpty(node.OrderBy))
- AddPropertyRow("Order By", node.OrderBy, isCode: true);
- if (!string.IsNullOrEmpty(node.TopExpression))
- {
- var topText = node.TopExpression;
- if (node.IsPercent) topText += " PERCENT";
- if (node.WithTies) topText += " WITH TIES";
- AddPropertyRow("Top", topText);
- }
- if (node.SortDistinct)
- AddPropertyRow("Distinct Sort", "True");
- if (node.StartupExpression)
- AddPropertyRow("Startup Expression", "True");
- if (node.NLOptimized)
- AddPropertyRow("Optimized", "True");
- if (node.WithOrderedPrefetch)
- AddPropertyRow("Ordered Prefetch", "True");
- if (node.WithUnorderedPrefetch)
- AddPropertyRow("Unordered Prefetch", "True");
- if (node.BitmapCreator)
- AddPropertyRow("Bitmap Creator", "True");
- if (node.Remoting)
- AddPropertyRow("Remoting", "True");
- if (node.LocalParallelism)
- AddPropertyRow("Local Parallelism", "True");
- if (!string.IsNullOrEmpty(node.GroupBy))
- AddPropertyRow("Group By", node.GroupBy, isCode: true);
- if (!string.IsNullOrEmpty(node.PartitionColumns))
- AddPropertyRow("Partition Columns", node.PartitionColumns, isCode: true);
- if (!string.IsNullOrEmpty(node.HashKeys))
- AddPropertyRow("Hash Keys", node.HashKeys, isCode: true);
- if (!string.IsNullOrEmpty(node.OffsetExpression))
- AddPropertyRow("Offset", node.OffsetExpression);
- if (node.TopRows > 0)
- AddPropertyRow("Rows", $"{node.TopRows}");
- if (node.SpoolStack)
- AddPropertyRow("Stack Spool", "True");
- if (node.PrimaryNodeId > 0)
- 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))
- AddPropertyRow("Segment Column", node.SegmentColumn, isCode: true);
- if (!string.IsNullOrEmpty(node.DefinedValues))
- AddPropertyRow("Defined Values", node.DefinedValues, isCode: true);
- if (!string.IsNullOrEmpty(node.OuterReferences))
- AddPropertyRow("Outer References", node.OuterReferences, isCode: true);
- if (!string.IsNullOrEmpty(node.InnerSideJoinColumns))
- AddPropertyRow("Inner Join Cols", node.InnerSideJoinColumns, isCode: true);
- if (!string.IsNullOrEmpty(node.OuterSideJoinColumns))
- AddPropertyRow("Outer Join Cols", node.OuterSideJoinColumns, isCode: true);
- if (node.PhysicalOp == "Merge Join")
- AddPropertyRow("Many to Many", node.ManyToMany ? "Yes" : "No");
- else if (node.ManyToMany)
- AddPropertyRow("Many to Many", "Yes");
- if (!string.IsNullOrEmpty(node.ConstantScanValues))
- AddPropertyRow("Values", node.ConstantScanValues, isCode: true);
- if (!string.IsNullOrEmpty(node.UdxUsedColumns))
- AddPropertyRow("UDX Columns", node.UdxUsedColumns, isCode: true);
- if (node.RowCount)
- AddPropertyRow("Row Count", "True");
- if (node.ForceSeekColumnCount > 0)
- AddPropertyRow("ForceSeek Columns", $"{node.ForceSeekColumnCount}");
- if (!string.IsNullOrEmpty(node.PartitionId))
- AddPropertyRow("Partition Id", node.PartitionId, isCode: true);
- if (node.IsStarJoin)
- AddPropertyRow("Star Join Root", "True");
- if (!string.IsNullOrEmpty(node.StarJoinOperationType))
- AddPropertyRow("Star Join Type", node.StarJoinOperationType);
- if (!string.IsNullOrEmpty(node.ProbeColumn))
- AddPropertyRow("Probe Column", node.ProbeColumn, isCode: true);
- if (node.InRow)
- AddPropertyRow("In-Row", "True");
- if (node.ComputeSequence)
- AddPropertyRow("Compute Sequence", "True");
- if (node.RollupHighestLevel > 0)
- AddPropertyRow("Rollup Highest Level", $"{node.RollupHighestLevel}");
- if (node.RollupLevels.Count > 0)
- AddPropertyRow("Rollup Levels", string.Join(", ", node.RollupLevels));
- if (!string.IsNullOrEmpty(node.TvfParameters))
- AddPropertyRow("TVF Parameters", node.TvfParameters, isCode: true);
- if (!string.IsNullOrEmpty(node.OriginalActionColumn))
- AddPropertyRow("Original Action Col", node.OriginalActionColumn, isCode: true);
- if (!string.IsNullOrEmpty(node.TieColumns))
- AddPropertyRow("WITH TIES Columns", node.TieColumns, isCode: true);
- if (!string.IsNullOrEmpty(node.UdxName))
- AddPropertyRow("UDX Name", node.UdxName);
- if (node.GroupExecuted)
- AddPropertyRow("Group Executed", "True");
- if (node.RemoteDataAccess)
- AddPropertyRow("Remote Data Access", "True");
- if (node.OptimizedHalloweenProtectionUsed)
- AddPropertyRow("Halloween Protection", "True");
- if (node.StatsCollectionId > 0)
- AddPropertyRow("Stats Collection Id", $"{node.StatsCollectionId}");
- }
-
- // === Scalar UDFs ===
- if (node.ScalarUdfs.Count > 0)
- {
- AddPropertySection("Scalar UDFs");
- foreach (var udf in node.ScalarUdfs)
- {
- var udfDetail = udf.FunctionName;
- if (udf.IsClrFunction)
- {
- udfDetail += " (CLR)";
- if (!string.IsNullOrEmpty(udf.ClrAssembly))
- udfDetail += $"\n Assembly: {udf.ClrAssembly}";
- if (!string.IsNullOrEmpty(udf.ClrClass))
- udfDetail += $"\n Class: {udf.ClrClass}";
- if (!string.IsNullOrEmpty(udf.ClrMethod))
- udfDetail += $"\n Method: {udf.ClrMethod}";
- }
- AddPropertyRow("UDF", udfDetail, isCode: true);
- }
- }
-
- // === Named Parameters (IndexScan) ===
- if (node.NamedParameters.Count > 0)
- {
- AddPropertySection("Named Parameters");
- foreach (var np in node.NamedParameters)
- AddPropertyRow(np.Name, np.ScalarString ?? "", isCode: true);
- }
-
- // === Per-Operator Indexed Views ===
- if (node.OperatorIndexedViews.Count > 0)
- {
- AddPropertySection("Operator Indexed Views");
- foreach (var iv in node.OperatorIndexedViews)
- AddPropertyRow("View", iv, isCode: true);
- }
-
- // === Suggested Index (Eager Spool) ===
- if (!string.IsNullOrEmpty(node.SuggestedIndex))
- {
- AddPropertySection("Suggested Index");
- AddPropertyRow("CREATE INDEX", node.SuggestedIndex, isCode: true);
- }
-
- // === Remote Operator ===
- if (!string.IsNullOrEmpty(node.RemoteDestination) || !string.IsNullOrEmpty(node.RemoteSource)
- || !string.IsNullOrEmpty(node.RemoteObject) || !string.IsNullOrEmpty(node.RemoteQuery))
- {
- AddPropertySection("Remote Operator");
- if (!string.IsNullOrEmpty(node.RemoteDestination))
- AddPropertyRow("Destination", node.RemoteDestination);
- if (!string.IsNullOrEmpty(node.RemoteSource))
- AddPropertyRow("Source", node.RemoteSource);
- if (!string.IsNullOrEmpty(node.RemoteObject))
- AddPropertyRow("Object", node.RemoteObject, isCode: true);
- if (!string.IsNullOrEmpty(node.RemoteQuery))
- AddPropertyRow("Query", node.RemoteQuery, isCode: true);
- }
-
- // === Foreign Key References Section ===
- if (node.ForeignKeyReferencesCount > 0 || node.NoMatchingIndexCount > 0 || node.PartialMatchingIndexCount > 0)
- {
- AddPropertySection("Foreign Key References");
- if (node.ForeignKeyReferencesCount > 0)
- AddPropertyRow("FK References", $"{node.ForeignKeyReferencesCount}");
- if (node.NoMatchingIndexCount > 0)
- AddPropertyRow("No Matching Index", $"{node.NoMatchingIndexCount}");
- if (node.PartialMatchingIndexCount > 0)
- AddPropertyRow("Partial Match Index", $"{node.PartialMatchingIndexCount}");
- }
-
- // === Adaptive Join Section ===
- if (node.IsAdaptive)
- {
- AddPropertySection("Adaptive Join");
- if (!string.IsNullOrEmpty(node.EstimatedJoinType))
- AddPropertyRow("Est. Join Type", node.EstimatedJoinType);
- if (!string.IsNullOrEmpty(node.ActualJoinType))
- AddPropertyRow("Actual Join Type", node.ActualJoinType);
- if (node.AdaptiveThresholdRows > 0)
- AddPropertyRow("Threshold Rows", $"{node.AdaptiveThresholdRows:N1}");
- }
-
- // === Estimated Costs Section ===
- AddPropertySection("Estimated Costs");
- AddPropertyRow("Operator Cost", $"{node.EstimatedOperatorCost:F6} ({node.CostPercent}%)");
- AddPropertyRow("Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}");
- AddPropertyRow("I/O Cost", $"{node.EstimateIO:F6}");
- AddPropertyRow("CPU Cost", $"{node.EstimateCPU:F6}");
-
- // === Estimated Rows Section ===
- AddPropertySection("Estimated Rows");
- var estExecs = 1 + node.EstimateRebinds;
- AddPropertyRow("Est. Executions", $"{estExecs:N0}");
- AddPropertyRow("Est. Rows Per Exec", $"{node.EstimateRows:N1}");
- AddPropertyRow("Est. Rows All Execs", $"{node.EstimateRows * Math.Max(1, estExecs):N1}");
- if (node.EstimatedRowsRead > 0)
- AddPropertyRow("Est. Rows to Read", $"{node.EstimatedRowsRead:N1}");
- if (node.EstimateRowsWithoutRowGoal > 0)
- AddPropertyRow("Est. Rows (No Row Goal)", $"{node.EstimateRowsWithoutRowGoal:N1}");
- if (node.TableCardinality > 0)
- AddPropertyRow("Table Cardinality", $"{node.TableCardinality:N0}");
- AddPropertyRow("Avg Row Size", $"{node.EstimatedRowSize} B");
- AddPropertyRow("Est. Rebinds", $"{node.EstimateRebinds:N1}");
- AddPropertyRow("Est. Rewinds", $"{node.EstimateRewinds:N1}");
-
- // === Actual Stats Section (if actual plan) ===
- if (node.HasActualStats)
- {
- AddPropertySection("Actual Statistics");
- AddPropertyRow("Actual Rows", $"{node.ActualRows:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats)
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRows:N0}", indent: true);
- if (node.ActualRowsRead > 0)
- {
- AddPropertyRow("Actual Rows Read", $"{node.ActualRowsRead:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualRowsRead > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualRowsRead:N0}", indent: true);
- }
- AddPropertyRow("Actual Executions", $"{node.ActualExecutions:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats)
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualExecutions:N0}", indent: true);
- if (node.ActualRebinds > 0)
- AddPropertyRow("Actual Rebinds", $"{node.ActualRebinds:N0}");
- if (node.ActualRewinds > 0)
- AddPropertyRow("Actual Rewinds", $"{node.ActualRewinds:N0}");
-
- // Runtime partition summary
- if (node.PartitionsAccessed > 0)
- {
- AddPropertyRow("Partitions Accessed", $"{node.PartitionsAccessed}");
- if (!string.IsNullOrEmpty(node.PartitionRanges))
- AddPropertyRow("Partition Ranges", node.PartitionRanges);
- }
-
- // Timing
- if (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0
- || node.UdfCpuTimeMs > 0 || node.UdfElapsedTimeMs > 0)
- {
- AddPropertySection("Actual Timing");
- if (node.ActualElapsedMs > 0)
- {
- AddPropertyRow("Elapsed Time", $"{node.ActualElapsedMs:N0} ms");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualElapsedMs > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualElapsedMs:N0} ms", indent: true);
- }
- if (node.ActualCPUMs > 0)
- {
- AddPropertyRow("CPU Time", $"{node.ActualCPUMs:N0} ms");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualCPUMs > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualCPUMs:N0} ms", indent: true);
- }
- if (node.UdfElapsedTimeMs > 0)
- AddPropertyRow("UDF Elapsed", $"{node.UdfElapsedTimeMs:N0} ms");
- if (node.UdfCpuTimeMs > 0)
- AddPropertyRow("UDF CPU", $"{node.UdfCpuTimeMs:N0} ms");
- }
-
- // I/O
- var hasIo = node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0
- || node.ActualScans > 0 || node.ActualReadAheads > 0
- || node.ActualSegmentReads > 0 || node.ActualSegmentSkips > 0;
- if (hasIo)
- {
- AddPropertySection("Actual I/O");
- AddPropertyRow("Logical Reads", $"{node.ActualLogicalReads:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualLogicalReads > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualLogicalReads:N0}", indent: true);
- if (node.ActualPhysicalReads > 0)
- {
- AddPropertyRow("Physical Reads", $"{node.ActualPhysicalReads:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualPhysicalReads > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualPhysicalReads:N0}", indent: true);
- }
- if (node.ActualScans > 0)
- {
- AddPropertyRow("Scans", $"{node.ActualScans:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualScans > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualScans:N0}", indent: true);
- }
- if (node.ActualReadAheads > 0)
- {
- AddPropertyRow("Read-Ahead Reads", $"{node.ActualReadAheads:N0}");
- if (node.PerThreadStats.Count > 1)
- foreach (var t in node.PerThreadStats.Where(t => t.ActualReadAheads > 0))
- AddPropertyRow($" Thread {t.ThreadId}", $"{t.ActualReadAheads:N0}", indent: true);
- }
- if (node.ActualSegmentReads > 0)
- AddPropertyRow("Segment Reads", $"{node.ActualSegmentReads:N0}");
- if (node.ActualSegmentSkips > 0)
- AddPropertyRow("Segment Skips", $"{node.ActualSegmentSkips:N0}");
- }
-
- // LOB I/O
- var hasLobIo = node.ActualLobLogicalReads > 0 || node.ActualLobPhysicalReads > 0
- || node.ActualLobReadAheads > 0;
- if (hasLobIo)
- {
- AddPropertySection("Actual LOB I/O");
- if (node.ActualLobLogicalReads > 0)
- AddPropertyRow("LOB Logical Reads", $"{node.ActualLobLogicalReads:N0}");
- if (node.ActualLobPhysicalReads > 0)
- AddPropertyRow("LOB Physical Reads", $"{node.ActualLobPhysicalReads:N0}");
- if (node.ActualLobReadAheads > 0)
- AddPropertyRow("LOB Read-Aheads", $"{node.ActualLobReadAheads:N0}");
- }
- }
-
- // === Predicates Section ===
- var hasPredicates = !string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate)
- || !string.IsNullOrEmpty(node.HashKeysProbe) || !string.IsNullOrEmpty(node.HashKeysBuild)
- || !string.IsNullOrEmpty(node.BuildResidual) || !string.IsNullOrEmpty(node.ProbeResidual)
- || !string.IsNullOrEmpty(node.MergeResidual) || !string.IsNullOrEmpty(node.PassThru)
- || !string.IsNullOrEmpty(node.SetPredicate)
- || node.GuessedSelectivity;
- if (hasPredicates)
- {
- AddPropertySection("Predicates");
- if (!string.IsNullOrEmpty(node.SeekPredicates))
- AddPropertyRow("Seek Predicate", node.SeekPredicates, isCode: true);
- if (!string.IsNullOrEmpty(node.Predicate))
- AddPropertyRow("Predicate", node.Predicate, isCode: true);
- if (!string.IsNullOrEmpty(node.HashKeysBuild))
- AddPropertyRow("Hash Keys (Build)", node.HashKeysBuild, isCode: true);
- if (!string.IsNullOrEmpty(node.HashKeysProbe))
- AddPropertyRow("Hash Keys (Probe)", node.HashKeysProbe, isCode: true);
- if (!string.IsNullOrEmpty(node.BuildResidual))
- AddPropertyRow("Build Residual", node.BuildResidual, isCode: true);
- if (!string.IsNullOrEmpty(node.ProbeResidual))
- AddPropertyRow("Probe Residual", node.ProbeResidual, isCode: true);
- if (!string.IsNullOrEmpty(node.MergeResidual))
- AddPropertyRow("Merge Residual", node.MergeResidual, isCode: true);
- if (!string.IsNullOrEmpty(node.PassThru))
- AddPropertyRow("Pass Through", node.PassThru, isCode: true);
- if (!string.IsNullOrEmpty(node.SetPredicate))
- AddPropertyRow("Set Predicate", node.SetPredicate, isCode: true);
- if (node.GuessedSelectivity)
- AddPropertyRow("Guessed Selectivity", "True (optimizer guessed, no statistics)");
- }
-
- // === Output Columns ===
- if (!string.IsNullOrEmpty(node.OutputColumns))
- {
- AddPropertySection("Output");
- AddPropertyRow("Columns", node.OutputColumns, isCode: true);
- }
-
- // === Memory ===
- if (node.MemoryGrantKB > 0 || node.DesiredMemoryKB > 0 || node.MaxUsedMemoryKB > 0
- || node.MemoryFractionInput > 0 || node.MemoryFractionOutput > 0
- || node.InputMemoryGrantKB > 0 || node.OutputMemoryGrantKB > 0 || node.UsedMemoryGrantKB > 0)
- {
- AddPropertySection("Memory");
- if (node.MemoryGrantKB > 0) AddPropertyRow("Granted", $"{node.MemoryGrantKB:N0} KB");
- if (node.DesiredMemoryKB > 0) AddPropertyRow("Desired", $"{node.DesiredMemoryKB:N0} KB");
- if (node.MaxUsedMemoryKB > 0) AddPropertyRow("Max Used", $"{node.MaxUsedMemoryKB:N0} KB");
- if (node.InputMemoryGrantKB > 0) AddPropertyRow("Input Grant", $"{node.InputMemoryGrantKB:N0} KB");
- if (node.OutputMemoryGrantKB > 0) AddPropertyRow("Output Grant", $"{node.OutputMemoryGrantKB:N0} KB");
- if (node.UsedMemoryGrantKB > 0) AddPropertyRow("Used Grant", $"{node.UsedMemoryGrantKB:N0} KB");
- if (node.MemoryFractionInput > 0) AddPropertyRow("Fraction Input", $"{node.MemoryFractionInput:F4}");
- if (node.MemoryFractionOutput > 0) AddPropertyRow("Fraction Output", $"{node.MemoryFractionOutput:F4}");
- }
-
- // === Root node only: statement-level sections ===
- if (node.Parent == null && _currentStatement != null)
- {
- var s = _currentStatement;
-
- // === Statement Text ===
- if (!string.IsNullOrEmpty(s.StatementText) || !string.IsNullOrEmpty(s.StmtUseDatabaseName))
- {
- AddPropertySection("Statement");
- if (!string.IsNullOrEmpty(s.StatementText))
- AddPropertyRow("Text", s.StatementText, isCode: true);
- if (!string.IsNullOrEmpty(s.ParameterizedText) && s.ParameterizedText != s.StatementText)
- AddPropertyRow("Parameterized", s.ParameterizedText, isCode: true);
- if (!string.IsNullOrEmpty(s.StmtUseDatabaseName))
- AddPropertyRow("USE Database", s.StmtUseDatabaseName);
- }
-
- // === Cursor Info ===
- if (!string.IsNullOrEmpty(s.CursorName))
- {
- AddPropertySection("Cursor Info");
- AddPropertyRow("Cursor Name", s.CursorName);
- if (!string.IsNullOrEmpty(s.CursorActualType))
- AddPropertyRow("Actual Type", s.CursorActualType);
- if (!string.IsNullOrEmpty(s.CursorRequestedType))
- AddPropertyRow("Requested Type", s.CursorRequestedType);
- if (!string.IsNullOrEmpty(s.CursorConcurrency))
- AddPropertyRow("Concurrency", s.CursorConcurrency);
- AddPropertyRow("Forward Only", s.CursorForwardOnly ? "True" : "False");
- }
-
- // === Statement Memory Grant ===
- if (s.MemoryGrant != null)
- {
- var mg = s.MemoryGrant;
- AddPropertySection("Memory Grant Info");
- AddPropertyRow("Granted", $"{mg.GrantedMemoryKB:N0} KB");
- AddPropertyRow("Max Used", $"{mg.MaxUsedMemoryKB:N0} KB");
- AddPropertyRow("Requested", $"{mg.RequestedMemoryKB:N0} KB");
- AddPropertyRow("Desired", $"{mg.DesiredMemoryKB:N0} KB");
- AddPropertyRow("Required", $"{mg.RequiredMemoryKB:N0} KB");
- AddPropertyRow("Serial Required", $"{mg.SerialRequiredMemoryKB:N0} KB");
- AddPropertyRow("Serial Desired", $"{mg.SerialDesiredMemoryKB:N0} KB");
- if (mg.GrantWaitTimeMs > 0)
- AddPropertyRow("Grant Wait Time", $"{mg.GrantWaitTimeMs:N0} ms");
- if (mg.LastRequestedMemoryKB > 0)
- AddPropertyRow("Last Requested", $"{mg.LastRequestedMemoryKB:N0} KB");
- if (!string.IsNullOrEmpty(mg.IsMemoryGrantFeedbackAdjusted))
- AddPropertyRow("Feedback Adjusted", mg.IsMemoryGrantFeedbackAdjusted);
- }
-
- // === Statement Info ===
- AddPropertySection("Statement Info");
- if (!string.IsNullOrEmpty(s.StatementOptmLevel))
- AddPropertyRow("Optimization Level", s.StatementOptmLevel);
- if (!string.IsNullOrEmpty(s.StatementOptmEarlyAbortReason))
- AddPropertyRow("Early Abort Reason", s.StatementOptmEarlyAbortReason);
- if (s.CardinalityEstimationModelVersion > 0)
- AddPropertyRow("CE Model Version", $"{s.CardinalityEstimationModelVersion}");
- if (s.DegreeOfParallelism > 0)
- AddPropertyRow("DOP", $"{s.DegreeOfParallelism}");
- if (s.EffectiveDOP > 0)
- AddPropertyRow("Effective DOP", $"{s.EffectiveDOP}");
- if (!string.IsNullOrEmpty(s.DOPFeedbackAdjusted))
- AddPropertyRow("DOP Feedback", s.DOPFeedbackAdjusted);
- if (!string.IsNullOrEmpty(s.NonParallelPlanReason))
- AddPropertyRow("Non-Parallel Reason", s.NonParallelPlanReason);
- if (s.MaxQueryMemoryKB > 0)
- AddPropertyRow("Max Query Memory", $"{s.MaxQueryMemoryKB:N0} KB");
- if (s.QueryPlanMemoryGrantKB > 0)
- AddPropertyRow("QueryPlan Memory Grant", $"{s.QueryPlanMemoryGrantKB:N0} KB");
- AddPropertyRow("Compile Time", $"{s.CompileTimeMs:N0} ms");
- AddPropertyRow("Compile CPU", $"{s.CompileCPUMs:N0} ms");
- AddPropertyRow("Compile Memory", $"{s.CompileMemoryKB:N0} KB");
- if (s.CachedPlanSizeKB > 0)
- AddPropertyRow("Cached Plan Size", $"{s.CachedPlanSizeKB:N0} KB");
- AddPropertyRow("Retrieved From Cache", s.RetrievedFromCache ? "True" : "False");
- AddPropertyRow("Batch Mode On RowStore", s.BatchModeOnRowStoreUsed ? "True" : "False");
- AddPropertyRow("Security Policy", s.SecurityPolicyApplied ? "True" : "False");
- AddPropertyRow("Parameterization Type", $"{s.StatementParameterizationType}");
- if (!string.IsNullOrEmpty(s.QueryHash))
- AddPropertyRow("Query Hash", s.QueryHash, isCode: true);
- if (!string.IsNullOrEmpty(s.QueryPlanHash))
- AddPropertyRow("Plan Hash", s.QueryPlanHash, isCode: true);
- if (!string.IsNullOrEmpty(s.StatementSqlHandle))
- AddPropertyRow("SQL Handle", s.StatementSqlHandle, isCode: true);
- AddPropertyRow("DB Settings Id", $"{s.DatabaseContextSettingsId}");
- AddPropertyRow("Parent Object Id", $"{s.ParentObjectId}");
-
- // Plan Guide
- if (!string.IsNullOrEmpty(s.PlanGuideName))
- {
- AddPropertyRow("Plan Guide", s.PlanGuideName);
- if (!string.IsNullOrEmpty(s.PlanGuideDB))
- AddPropertyRow("Plan Guide DB", s.PlanGuideDB);
- }
- if (s.UsePlan)
- AddPropertyRow("USE PLAN", "True");
-
- // Query Store Hints
- if (s.QueryStoreStatementHintId > 0)
- {
- AddPropertyRow("QS Hint Id", $"{s.QueryStoreStatementHintId}");
- if (!string.IsNullOrEmpty(s.QueryStoreStatementHintText))
- AddPropertyRow("QS Hint", s.QueryStoreStatementHintText, isCode: true);
- if (!string.IsNullOrEmpty(s.QueryStoreStatementHintSource))
- AddPropertyRow("QS Hint Source", s.QueryStoreStatementHintSource);
- }
-
- // === Feature Flags ===
- if (s.ContainsInterleavedExecutionCandidates || s.ContainsInlineScalarTsqlUdfs
- || s.ContainsLedgerTables || s.ExclusiveProfileTimeActive || s.QueryCompilationReplay > 0
- || s.QueryVariantID > 0)
- {
- AddPropertySection("Feature Flags");
- if (s.ContainsInterleavedExecutionCandidates)
- AddPropertyRow("Interleaved Execution", "True");
- if (s.ContainsInlineScalarTsqlUdfs)
- AddPropertyRow("Inline Scalar UDFs", "True");
- if (s.ContainsLedgerTables)
- AddPropertyRow("Ledger Tables", "True");
- if (s.ExclusiveProfileTimeActive)
- AddPropertyRow("Exclusive Profile Time", "True");
- if (s.QueryCompilationReplay > 0)
- AddPropertyRow("Compilation Replay", $"{s.QueryCompilationReplay}");
- if (s.QueryVariantID > 0)
- AddPropertyRow("Query Variant ID", $"{s.QueryVariantID}");
- }
-
- // === PSP Dispatcher ===
- if (s.Dispatcher != null)
- {
- AddPropertySection("PSP Dispatcher");
- if (!string.IsNullOrEmpty(s.DispatcherPlanHandle))
- AddPropertyRow("Plan Handle", s.DispatcherPlanHandle, isCode: true);
- foreach (var psp in s.Dispatcher.ParameterSensitivePredicates)
- {
- var range = $"[{psp.LowBoundary:N0} — {psp.HighBoundary:N0}]";
- var predText = psp.PredicateText ?? "";
- AddPropertyRow("Predicate", $"{predText} {range}", isCode: true);
- foreach (var stat in psp.Statistics)
- {
- var statLabel = !string.IsNullOrEmpty(stat.TableName)
- ? $" {stat.TableName}.{stat.StatisticsName}"
- : $" {stat.StatisticsName}";
- AddPropertyRow(statLabel, $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%", indent: true);
- }
- }
- foreach (var opt in s.Dispatcher.OptionalParameterPredicates)
- {
- if (!string.IsNullOrEmpty(opt.PredicateText))
- AddPropertyRow("Optional Predicate", opt.PredicateText, isCode: true);
- }
- }
-
- // === Cardinality Feedback ===
- if (s.CardinalityFeedback.Count > 0)
- {
- AddPropertySection("Cardinality Feedback");
- foreach (var cf in s.CardinalityFeedback)
- AddPropertyRow($"Node {cf.Key}", $"{cf.Value:N0}");
- }
-
- // === Optimization Replay ===
- if (!string.IsNullOrEmpty(s.OptimizationReplayScript))
- {
- AddPropertySection("Optimization Replay");
- AddPropertyRow("Script", s.OptimizationReplayScript, isCode: true);
- }
-
- // === Template Plan Guide ===
- if (!string.IsNullOrEmpty(s.TemplatePlanGuideName))
- {
- AddPropertyRow("Template Plan Guide", s.TemplatePlanGuideName);
- if (!string.IsNullOrEmpty(s.TemplatePlanGuideDB))
- AddPropertyRow("Template Guide DB", s.TemplatePlanGuideDB);
- }
-
- // === Handles ===
- if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle) || !string.IsNullOrEmpty(s.BatchSqlHandle))
- {
- AddPropertySection("Handles");
- if (!string.IsNullOrEmpty(s.ParameterizedPlanHandle))
- AddPropertyRow("Parameterized Plan", s.ParameterizedPlanHandle, isCode: true);
- if (!string.IsNullOrEmpty(s.BatchSqlHandle))
- AddPropertyRow("Batch SQL Handle", s.BatchSqlHandle, isCode: true);
- }
-
- // === Set Options ===
- if (s.SetOptions != null)
- {
- var so = s.SetOptions;
- AddPropertySection("Set Options");
- AddPropertyRow("ANSI_NULLS", so.AnsiNulls ? "True" : "False");
- AddPropertyRow("ANSI_PADDING", so.AnsiPadding ? "True" : "False");
- AddPropertyRow("ANSI_WARNINGS", so.AnsiWarnings ? "True" : "False");
- AddPropertyRow("ARITHABORT", so.ArithAbort ? "True" : "False");
- AddPropertyRow("CONCAT_NULL", so.ConcatNullYieldsNull ? "True" : "False");
- AddPropertyRow("NUMERIC_ROUNDABORT", so.NumericRoundAbort ? "True" : "False");
- AddPropertyRow("QUOTED_IDENTIFIER", so.QuotedIdentifier ? "True" : "False");
- }
-
- // === Optimizer Hardware Properties ===
- if (s.HardwareProperties != null)
- {
- var hw = s.HardwareProperties;
- AddPropertySection("Hardware Properties");
- AddPropertyRow("Available Memory", $"{hw.EstimatedAvailableMemoryGrant:N0} KB");
- AddPropertyRow("Pages Cached", $"{hw.EstimatedPagesCached:N0}");
- AddPropertyRow("Available DOP", $"{hw.EstimatedAvailableDOP}");
- if (hw.MaxCompileMemory > 0)
- AddPropertyRow("Max Compile Memory", $"{hw.MaxCompileMemory:N0} KB");
- }
-
- // === Plan Version ===
- if (_currentPlan != null && (!string.IsNullOrEmpty(_currentPlan.BuildVersion) || !string.IsNullOrEmpty(_currentPlan.Build)))
- {
- AddPropertySection("Plan Version");
- if (!string.IsNullOrEmpty(_currentPlan.BuildVersion))
- AddPropertyRow("Build Version", _currentPlan.BuildVersion);
- if (!string.IsNullOrEmpty(_currentPlan.Build))
- AddPropertyRow("Build", _currentPlan.Build);
- if (_currentPlan.ClusteredMode)
- AddPropertyRow("Clustered Mode", "True");
- }
-
- // === Optimizer Stats Usage ===
- if (s.StatsUsage.Count > 0)
- {
- AddPropertySection("Statistics Used");
- foreach (var stat in s.StatsUsage)
- {
- var statLabel = !string.IsNullOrEmpty(stat.TableName)
- ? $"{stat.TableName}.{stat.StatisticsName}"
- : stat.StatisticsName;
- var statDetail = $"Modified: {stat.ModificationCount:N0}, Sampled: {stat.SamplingPercent:F1}%";
- if (!string.IsNullOrEmpty(stat.LastUpdate))
- statDetail += $", Updated: {stat.LastUpdate}";
- AddPropertyRow(statLabel, statDetail);
- }
- }
-
- // === Parameters ===
- if (s.Parameters.Count > 0)
- {
- AddPropertySection("Parameters");
- foreach (var p in s.Parameters)
- {
- var paramText = p.DataType;
- if (!string.IsNullOrEmpty(p.CompiledValue))
- paramText += $", Compiled: {p.CompiledValue}";
- if (!string.IsNullOrEmpty(p.RuntimeValue))
- paramText += $", Runtime: {p.RuntimeValue}";
- AddPropertyRow(p.Name, paramText);
- }
- }
-
- // === Query Time Stats (actual plans) ===
- if (s.QueryTimeStats != null)
- {
- AddPropertySection("Query Time Stats");
- AddPropertyRow("CPU Time", $"{s.QueryTimeStats.CpuTimeMs:N0} ms");
- AddPropertyRow("Elapsed Time", $"{s.QueryTimeStats.ElapsedTimeMs:N0} ms");
- if (s.QueryUdfCpuTimeMs > 0)
- AddPropertyRow("UDF CPU Time", $"{s.QueryUdfCpuTimeMs:N0} ms");
- if (s.QueryUdfElapsedTimeMs > 0)
- AddPropertyRow("UDF Elapsed Time", $"{s.QueryUdfElapsedTimeMs:N0} ms");
- }
-
- // === Thread Stats (actual plans) ===
- if (s.ThreadStats != null)
- {
- AddPropertySection("Thread Stats");
- AddPropertyRow("Branches", $"{s.ThreadStats.Branches}");
- AddPropertyRow("Used Threads", $"{s.ThreadStats.UsedThreads}");
- var totalReserved = s.ThreadStats.Reservations.Sum(r => r.ReservedThreads);
- if (totalReserved > 0)
- {
- AddPropertyRow("Reserved Threads", $"{totalReserved}");
- if (totalReserved > s.ThreadStats.UsedThreads)
- AddPropertyRow("Inactive Threads", $"{totalReserved - s.ThreadStats.UsedThreads}");
- }
- foreach (var res in s.ThreadStats.Reservations)
- AddPropertyRow($" Node {res.NodeId}", $"{res.ReservedThreads} reserved");
- }
-
- // === Wait Stats (actual plans) ===
- if (s.WaitStats.Count > 0)
- {
- AddPropertySection("Wait Stats");
- foreach (var w in s.WaitStats.OrderByDescending(w => w.WaitTimeMs))
- AddPropertyRow(w.WaitType, $"{w.WaitTimeMs:N0} ms ({w.WaitCount:N0} waits)");
- }
-
- // === Trace Flags ===
- if (s.TraceFlags.Count > 0)
- {
- AddPropertySection("Trace Flags");
- foreach (var tf in s.TraceFlags)
- {
- var tfLabel = $"TF {tf.Value}";
- var tfDetail = $"{tf.Scope}{(tf.IsCompileTime ? ", Compile-time" : ", Runtime")}";
- AddPropertyRow(tfLabel, tfDetail);
- }
- }
-
- // === Indexed Views ===
- if (s.IndexedViews.Count > 0)
- {
- AddPropertySection("Indexed Views");
- foreach (var iv in s.IndexedViews)
- AddPropertyRow("View", iv, isCode: true);
- }
-
- // === Plan-Level Warnings ===
- if (s.PlanWarnings.Count > 0)
- {
- var planWarningsPanel = new StackPanel();
- var sortedPlanWarnings = s.PlanWarnings
- .OrderByDescending(w => w.MaxBenefitPercent ?? -1)
- .ThenByDescending(w => w.Severity)
- .ThenBy(w => w.WarningType);
- foreach (var w in sortedPlanWarnings)
- {
- var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
- : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
- var legacyTag = w.IsLegacy ? " [legacy]" : "";
- var planWarnHeader = w.MaxBenefitPercent.HasValue
- ? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
- : $"\u26A0 {w.WarningType}{legacyTag}";
- warnPanel.Children.Add(new TextBlock
- {
- Text = planWarnHeader,
- FontWeight = FontWeight.SemiBold,
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.Parse(warnColor))
- });
- warnPanel.Children.Add(new TextBlock
- {
- Text = w.Message,
- FontSize = 11,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(16, 0, 0, 0)
- });
- if (!string.IsNullOrEmpty(w.ActionableFix))
- {
- warnPanel.Children.Add(new TextBlock
- {
- Text = w.ActionableFix,
- FontSize = 11,
- FontStyle = FontStyle.Italic,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(16, 2, 0, 0)
- });
- }
- 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 ===
- if (s.MissingIndexes.Count > 0)
- {
- AddPropertySection("Missing Indexes");
- foreach (var mi in s.MissingIndexes)
- {
- AddPropertyRow($"{mi.Schema}.{mi.Table}", $"Impact: {mi.Impact:F1}%");
- if (!string.IsNullOrEmpty(mi.CreateStatement))
- AddPropertyRow("CREATE INDEX", mi.CreateStatement, isCode: true);
- }
- }
- }
-
- // === Warnings ===
- if (node.HasWarnings)
- {
- var warningsPanel = new StackPanel();
- var sortedNodeWarnings = node.Warnings
- .OrderByDescending(w => w.MaxBenefitPercent ?? -1)
- .ThenByDescending(w => w.Severity)
- .ThenBy(w => w.WarningType);
- foreach (var w in sortedNodeWarnings)
- {
- var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
- : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
- var nodeLegacyTag = w.IsLegacy ? " [legacy]" : "";
- var nodeWarnHeader = w.MaxBenefitPercent.HasValue
- ? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
- : $"\u26A0 {w.WarningType}{nodeLegacyTag}";
- warnPanel.Children.Add(new TextBlock
- {
- Text = nodeWarnHeader,
- FontWeight = FontWeight.SemiBold,
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.Parse(warnColor))
- });
- warnPanel.Children.Add(new TextBlock
- {
- Text = w.Message,
- FontSize = 11,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(16, 0, 0, 0)
- });
- 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
- _propertiesColumn.Width = new GridLength(320);
- _splitterColumn.Width = new GridLength(5);
- PropertiesSplitter.IsVisible = true;
- PropertiesPanel.IsVisible = true;
- }
-
- private void AddPropertySection(string title)
- {
- var labelCol = new ColumnDefinition { Width = new GridLength(_propertyLabelWidth) };
- _sectionLabelColumns.Add(labelCol);
-
- // Sync column widths across sections when user drags the GridSplitter
- labelCol.PropertyChanged += (_, args) =>
- {
- if (args.Property.Name != "Width" || _isSyncingColumnWidth) return;
- _isSyncingColumnWidth = true;
- _propertyLabelWidth = labelCol.Width.Value;
- foreach (var col in _sectionLabelColumns)
- {
- if (col != labelCol)
- col.Width = labelCol.Width;
- }
- _isSyncingColumnWidth = false;
- };
-
- var sectionGrid = new Grid
- {
- Margin = new Thickness(6, 0, 6, 0)
- };
- sectionGrid.ColumnDefinitions.Add(labelCol);
- sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(4) });
- sectionGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
-
- _currentSectionGrid = sectionGrid;
- _currentSectionRowIndex = 0;
-
- var expander = new Expander
- {
- IsExpanded = true,
- Header = new TextBlock
- {
- Text = title,
- FontWeight = FontWeight.SemiBold,
- FontSize = 11,
- Foreground = SectionHeaderBrush
- },
- Content = sectionGrid,
- 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(expander);
- }
-
- private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false)
- {
- if (_currentSectionGrid == null) return;
-
- var row = _currentSectionRowIndex++;
- _currentSectionGrid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
-
- var labelBlock = new TextBlock
- {
- Text = label,
- FontSize = indent ? 10 : 11,
- Foreground = TooltipFgBrush,
- VerticalAlignment = VerticalAlignment.Top,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(indent ? 16 : 4, 2, 0, 2)
- };
- Grid.SetColumn(labelBlock, 0);
- Grid.SetRow(labelBlock, row);
- _currentSectionGrid.Children.Add(labelBlock);
-
- // GridSplitter in column 1 (only in first row per section)
- if (row == 0)
- {
- var splitter = new GridSplitter
- {
- Width = 4,
- Background = Brushes.Transparent,
- Foreground = Brushes.Transparent,
- BorderThickness = new Thickness(0),
- Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.SizeWestEast)
- };
- Grid.SetColumn(splitter, 1);
- Grid.SetRow(splitter, 0);
- Grid.SetRowSpan(splitter, 100); // span all rows
- _currentSectionGrid.Children.Add(splitter);
- }
-
- var valueBox = new TextBox
- {
- Text = value,
- FontSize = indent ? 10 : 11,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap,
- IsReadOnly = true,
- BorderThickness = new Thickness(0),
- Background = Brushes.Transparent,
- Padding = new Thickness(0),
- Margin = new Thickness(0, 2, 4, 2),
- VerticalAlignment = VerticalAlignment.Top
- };
- if (isCode) valueBox.FontFamily = new FontFamily("Consolas");
- Grid.SetColumn(valueBox, 2);
- Grid.SetRow(valueBox, row);
- _currentSectionGrid.Children.Add(valueBox);
- }
-
- private void CloseProperties_Click(object? sender, RoutedEventArgs e)
- {
- ClosePropertiesPanel();
- }
-
- private void ClosePropertiesPanel()
- {
- PropertiesPanel.IsVisible = false;
- PropertiesSplitter.IsVisible = false;
- _propertiesColumn.Width = new GridLength(0);
- _splitterColumn.Width = new GridLength(0);
-
- // Deselect node
- if (_selectedNodeBorder != null)
- {
- _selectedNodeBorder.BorderBrush = _selectedNodeOriginalBorder;
- _selectedNodeBorder.BorderThickness = _selectedNodeOriginalThickness;
- _selectedNodeBorder = null;
- }
- }
-
- #endregion
-
- #region Tooltips
-
- private object BuildNodeTooltipContent(PlanNode node, List? allWarnings = null)
- {
- var tipBorder = new Border
- {
- Background = TooltipBgBrush,
- BorderBrush = TooltipBorderBrush,
- BorderThickness = new Thickness(1),
- Padding = new Thickness(12),
- MaxWidth = 500
- };
-
- var stack = new StackPanel();
-
- // Header
- var headerText = node.PhysicalOp;
- if (node.LogicalOp != node.PhysicalOp && !string.IsNullOrEmpty(node.LogicalOp)
- && !node.PhysicalOp.Contains(node.LogicalOp, StringComparison.OrdinalIgnoreCase))
- headerText += $" ({node.LogicalOp})";
- stack.Children.Add(new TextBlock
- {
- Text = headerText,
- FontWeight = FontWeight.Bold,
- FontSize = 13,
- Foreground = TooltipFgBrush,
- Margin = new Thickness(0, 0, 0, 8)
- });
-
- // Cost
- AddTooltipSection(stack, "Costs");
- AddTooltipRow(stack, "Cost", $"{node.CostPercent}% of statement ({node.EstimatedOperatorCost:F6})");
- AddTooltipRow(stack, "Subtree Cost", $"{node.EstimatedTotalSubtreeCost:F6}");
-
- // Rows
- AddTooltipSection(stack, "Rows");
- AddTooltipRow(stack, "Estimated Rows", $"{node.EstimateRows:N1}");
- if (node.HasActualStats)
- {
- AddTooltipRow(stack, "Actual Rows", $"{node.ActualRows:N0}");
- if (node.ActualRowsRead > 0)
- AddTooltipRow(stack, "Actual Rows Read", $"{node.ActualRowsRead:N0}");
- AddTooltipRow(stack, "Actual Executions", $"{node.ActualExecutions:N0}");
- }
-
- // Rebinds/Rewinds (spools and other operators with rebind/rewind data)
- if (node.EstimateRebinds > 0 || node.EstimateRewinds > 0
- || node.ActualRebinds > 0 || node.ActualRewinds > 0)
- {
- AddTooltipSection(stack, "Rebinds / Rewinds");
- // Always show both estimated values when section is visible
- AddTooltipRow(stack, "Est. Rebinds", $"{node.EstimateRebinds:N1}");
- AddTooltipRow(stack, "Est. Rewinds", $"{node.EstimateRewinds:N1}");
- if (node.ActualRebinds > 0) AddTooltipRow(stack, "Actual Rebinds", $"{node.ActualRebinds:N0}");
- if (node.ActualRewinds > 0) AddTooltipRow(stack, "Actual Rewinds", $"{node.ActualRewinds:N0}");
- }
-
- // I/O and CPU estimates
- if (node.EstimateIO > 0 || node.EstimateCPU > 0 || node.EstimatedRowSize > 0)
- {
- AddTooltipSection(stack, "Estimates");
- if (node.EstimateIO > 0) AddTooltipRow(stack, "I/O Cost", $"{node.EstimateIO:F6}");
- if (node.EstimateCPU > 0) AddTooltipRow(stack, "CPU Cost", $"{node.EstimateCPU:F6}");
- if (node.EstimatedRowSize > 0) AddTooltipRow(stack, "Avg Row Size", $"{node.EstimatedRowSize} B");
- }
-
- // Actual I/O
- if (node.HasActualStats && (node.ActualLogicalReads > 0 || node.ActualPhysicalReads > 0))
- {
- AddTooltipSection(stack, "Actual I/O");
- AddTooltipRow(stack, "Logical Reads", $"{node.ActualLogicalReads:N0}");
- if (node.ActualPhysicalReads > 0)
- AddTooltipRow(stack, "Physical Reads", $"{node.ActualPhysicalReads:N0}");
- if (node.ActualScans > 0)
- AddTooltipRow(stack, "Scans", $"{node.ActualScans:N0}");
- if (node.ActualReadAheads > 0)
- AddTooltipRow(stack, "Read-Aheads", $"{node.ActualReadAheads:N0}");
- }
-
- // Actual timing
- if (node.HasActualStats && (node.ActualElapsedMs > 0 || node.ActualCPUMs > 0))
- {
- AddTooltipSection(stack, "Timing");
- if (node.ActualElapsedMs > 0)
- AddTooltipRow(stack, "Elapsed Time", $"{node.ActualElapsedMs:N0} ms");
- if (node.ActualCPUMs > 0)
- AddTooltipRow(stack, "CPU Time", $"{node.ActualCPUMs:N0} ms");
- }
-
- // Parallelism
- if (node.Parallel || !string.IsNullOrEmpty(node.ExecutionMode) || !string.IsNullOrEmpty(node.PartitioningType))
- {
- AddTooltipSection(stack, "Parallelism");
- if (node.Parallel) AddTooltipRow(stack, "Parallel", "Yes");
- if (!string.IsNullOrEmpty(node.ExecutionMode))
- AddTooltipRow(stack, "Execution Mode", node.ExecutionMode);
- if (!string.IsNullOrEmpty(node.ActualExecutionMode) && node.ActualExecutionMode != node.ExecutionMode)
- AddTooltipRow(stack, "Actual Exec Mode", node.ActualExecutionMode);
- if (!string.IsNullOrEmpty(node.PartitioningType))
- AddTooltipRow(stack, "Partitioning", node.PartitioningType);
- }
-
- // Object
- if (!string.IsNullOrEmpty(node.FullObjectName))
- {
- AddTooltipSection(stack, "Object");
- AddTooltipRow(stack, "Name", node.FullObjectName, isCode: true);
- if (node.Ordered) AddTooltipRow(stack, "Ordered", "True");
- if (!string.IsNullOrEmpty(node.ScanDirection))
- AddTooltipRow(stack, "Scan Direction", node.ScanDirection);
- }
- else if (!string.IsNullOrEmpty(node.ObjectName))
- {
- AddTooltipSection(stack, "Object");
- AddTooltipRow(stack, "Name", node.ObjectName, isCode: true);
- if (node.Ordered) AddTooltipRow(stack, "Ordered", "True");
- if (!string.IsNullOrEmpty(node.ScanDirection))
- 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)
- || !string.IsNullOrEmpty(node.GroupBy)
- || !string.IsNullOrEmpty(node.OuterReferences);
- if (hasTooltipDetails)
- {
- AddTooltipSection(stack, "Details");
- if (!string.IsNullOrEmpty(node.OrderBy))
- AddTooltipRow(stack, "Order By", node.OrderBy, isCode: true);
- if (!string.IsNullOrEmpty(node.TopExpression))
- AddTooltipRow(stack, "Top", node.IsPercent ? $"{node.TopExpression} PERCENT" : node.TopExpression);
- if (!string.IsNullOrEmpty(node.GroupBy))
- AddTooltipRow(stack, "Group By", node.GroupBy, isCode: true);
- if (!string.IsNullOrEmpty(node.OuterReferences))
- AddTooltipRow(stack, "Outer References", node.OuterReferences, isCode: true);
- }
-
- // Predicates
- if (!string.IsNullOrEmpty(node.SeekPredicates) || !string.IsNullOrEmpty(node.Predicate))
- {
- AddTooltipSection(stack, "Predicates");
- if (!string.IsNullOrEmpty(node.SeekPredicates))
- AddTooltipRow(stack, "Seek", node.SeekPredicates, isCode: true);
- if (!string.IsNullOrEmpty(node.Predicate))
- AddTooltipRow(stack, "Residual", node.Predicate, isCode: true);
- }
-
- // Output columns
- if (!string.IsNullOrEmpty(node.OutputColumns))
- {
- AddTooltipSection(stack, "Output");
- AddTooltipRow(stack, "Columns", node.OutputColumns, isCode: true);
- }
-
- // 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) });
-
- if (allWarnings != null)
- {
- // Root node: show distinct warning type names only, sorted by max benefit
- var distinct = warnings
- .GroupBy(w => w.WarningType)
- .Select(g => (Type: g.Key, MaxSeverity: g.Max(w => w.Severity), Count: g.Count(),
- MaxBenefit: g.Max(w => w.MaxBenefitPercent ?? -1)))
- .OrderByDescending(g => g.MaxBenefit)
- .ThenByDescending(g => g.MaxSeverity)
- .ThenBy(g => g.Type);
-
- foreach (var (type, severity, count, maxBenefit) in distinct)
- {
- var warnColor = severity == PlanWarningSeverity.Critical ? "#E57373"
- : severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- var benefitSuffix = maxBenefit >= 0 ? $" \u2014 up to {maxBenefit:N0}%" : "";
- var label = count > 1 ? $"\u26A0 {type} ({count}){benefitSuffix}" : $"\u26A0 {type}{benefitSuffix}";
- stack.Children.Add(new TextBlock
- {
- Text = label,
- Foreground = new SolidColorBrush(Color.Parse(warnColor)),
- FontSize = 11,
- Margin = new Thickness(0, 2, 0, 0)
- });
- }
- }
- else
- {
- // Individual node: show full warning messages
- foreach (var w in warnings)
- {
- var warnColor = w.Severity == PlanWarningSeverity.Critical ? "#E57373"
- : w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
- stack.Children.Add(new TextBlock
- {
- Text = $"\u26A0 {w.WarningType}: {w.Message}",
- Foreground = new SolidColorBrush(Color.Parse(warnColor)),
- FontSize = 11,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 2, 0, 0)
- });
- }
- }
- }
-
- // Footer hint
- stack.Children.Add(new TextBlock
- {
- Text = "Click to view full properties",
- FontSize = 10,
- FontStyle = FontStyle.Italic,
- Foreground = TooltipFgBrush,
- Margin = new Thickness(0, 8, 0, 0)
- });
-
- tipBorder.Child = stack;
- return tipBorder;
- }
-
- private static void AddTooltipSection(StackPanel parent, string title)
- {
- parent.Children.Add(new TextBlock
- {
- Text = title,
- FontSize = 10,
- FontWeight = FontWeight.SemiBold,
- Foreground = SectionHeaderBrush,
- Margin = new Thickness(0, 6, 0, 2)
- });
- }
-
- private static void AddTooltipRow(StackPanel parent, string label, string value, bool isCode = false)
- {
- var row = new Grid
- {
- ColumnDefinitions = new ColumnDefinitions("Auto,*"),
- Margin = new Thickness(0, 1, 0, 1)
- };
- var labelBlock = new TextBlock
- {
- Text = $"{label}: ",
- Foreground = TooltipFgBrush,
- FontSize = 11,
- MinWidth = 120,
- VerticalAlignment = VerticalAlignment.Top
- };
- Grid.SetColumn(labelBlock, 0);
- row.Children.Add(labelBlock);
-
- var valueBlock = new TextBlock
- {
- Text = value,
- FontSize = 11,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap
- };
- if (isCode) valueBlock.FontFamily = new FontFamily("Consolas");
- Grid.SetColumn(valueBlock, 1);
- row.Children.Add(valueBlock);
- parent.Children.Add(row);
- }
-
- #endregion
-
- #region Banners
-
- private void ShowMissingIndexes(List indexes)
- {
- MissingIndexContent.Children.Clear();
-
- if (indexes.Count > 0)
- {
- // Update expander header with count
- MissingIndexHeader.Text = $" Missing Index Suggestions ({indexes.Count})";
-
- // Build each missing index row manually (no ItemsControl template binding)
- foreach (var mi in indexes)
- {
- var itemPanel = new StackPanel { Margin = new Thickness(0, 4, 0, 0) };
-
- var headerRow = new StackPanel { Orientation = Orientation.Horizontal };
- headerRow.Children.Add(new TextBlock
- {
- Text = mi.Table,
- FontWeight = FontWeight.SemiBold,
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- FontSize = 12
- });
- headerRow.Children.Add(new TextBlock
- {
- Text = $" \u2014 Impact: ",
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- FontSize = 12
- });
- headerRow.Children.Add(new TextBlock
- {
- Text = $"{mi.Impact:F1}%",
- Foreground = new SolidColorBrush(Color.Parse("#FFB347")),
- FontSize = 12
- });
- itemPanel.Children.Add(headerRow);
-
- if (!string.IsNullOrEmpty(mi.CreateStatement))
- {
- itemPanel.Children.Add(new SelectableTextBlock
- {
- Text = mi.CreateStatement,
- FontFamily = new FontFamily("Consolas"),
- FontSize = 11,
- Foreground = TooltipFgBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(12, 2, 0, 0)
- });
- }
-
- MissingIndexContent.Children.Add(itemPanel);
- }
-
- MissingIndexEmpty.IsVisible = false;
- }
- else
- {
- MissingIndexHeader.Text = "Missing Index Suggestions";
- MissingIndexEmpty.IsVisible = true;
- }
- }
-
- private void ShowParameters(PlanStatement statement)
- {
- ParametersContent.Children.Clear();
- ParametersEmpty.IsVisible = false;
-
- var parameters = statement.Parameters;
-
- if (parameters.Count == 0)
- {
- var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode);
- if (localVars.Count > 0)
- {
- ParametersHeader.Text = "Parameters";
- AddParameterAnnotation(
- $"Local variables detected ({string.Join(", ", localVars)}) — values not captured in plan XML",
- "#FFB347");
- }
- else
- {
- ParametersHeader.Text = "Parameters";
- ParametersEmpty.IsVisible = true;
- }
- return;
- }
-
- ParametersHeader.Text = $"Parameters ({parameters.Count})";
-
- var allCompiledNull = parameters.All(p => p.CompiledValue == null);
- var hasCompiled = parameters.Any(p => p.CompiledValue != null);
- var hasRuntime = parameters.Any(p => p.RuntimeValue != null);
-
- // Build a 4-column grid: Name | Data Type | Compiled | Runtime
- // Only show Compiled/Runtime columns if at least one param has that value
- var colDef = "Auto,Auto"; // Name, DataType always shown
- int compiledCol = -1, runtimeCol = -1;
- int nextCol = 2;
- if (hasCompiled)
- {
- colDef += ",*";
- compiledCol = nextCol++;
- }
- if (hasRuntime)
- {
- colDef += ",*";
- runtimeCol = nextCol++;
- }
- // If neither compiled nor runtime, still add one value column for "?"
- if (!hasCompiled && !hasRuntime)
- {
- colDef += ",*";
- compiledCol = nextCol++;
- }
-
- var grid = new Grid { ColumnDefinitions = new ColumnDefinitions(colDef) };
- int rowIndex = 0;
-
- // Header row
- grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
- AddParamCell(grid, rowIndex, 0, "Parameter", "#7BCF7B", FontWeight.SemiBold);
- AddParamCell(grid, rowIndex, 1, "Data Type", "#7BCF7B", FontWeight.SemiBold);
- if (compiledCol >= 0)
- AddParamCell(grid, rowIndex, compiledCol, hasCompiled ? "Compiled" : "Value", "#7BCF7B", FontWeight.SemiBold);
- if (runtimeCol >= 0)
- AddParamCell(grid, rowIndex, runtimeCol, "Runtime", "#7BCF7B", FontWeight.SemiBold);
- rowIndex++;
-
- foreach (var param in parameters)
- {
- grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
-
- // Name
- AddParamCell(grid, rowIndex, 0, param.Name, "#E4E6EB", FontWeight.SemiBold);
-
- // Data type
- AddParamCell(grid, rowIndex, 1, param.DataType, "#E4E6EB");
-
- // Compiled value
- if (compiledCol >= 0)
- {
- var compiledText = param.CompiledValue ?? (allCompiledNull ? "" : "?");
- var compiledColor = param.CompiledValue != null ? "#E4E6EB"
- : allCompiledNull ? "#E4E6EB" : "#E57373";
- AddParamCell(grid, rowIndex, compiledCol, compiledText, compiledColor);
- }
-
- // Runtime value — amber if it differs from compiled
- if (runtimeCol >= 0)
- {
- var runtimeText = param.RuntimeValue ?? "";
- var sniffed = param.RuntimeValue != null
- && param.CompiledValue != null
- && param.RuntimeValue != param.CompiledValue;
- var runtimeColor = sniffed ? "#FFB347" : "#E4E6EB";
- var tooltip = sniffed
- ? "Runtime value differs from compiled — possible parameter sniffing"
- : null;
- AddParamCell(grid, rowIndex, runtimeCol, runtimeText, runtimeColor, tooltip: tooltip);
- }
-
- rowIndex++;
- }
-
- ParametersContent.Children.Add(grid);
-
- // Annotations
- if (allCompiledNull && parameters.Count > 0)
- {
- var hasOptimizeForUnknown = statement.StatementText
- .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase)
- && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase);
-
- if (hasOptimizeForUnknown)
- {
- AddParameterAnnotation(
- "OPTIMIZE FOR UNKNOWN — optimizer used average density estimates instead of sniffed values",
- "#6BB5FF");
- }
- else
- {
- AddParameterAnnotation(
- "OPTION(RECOMPILE) — parameter values embedded as literals, not sniffed",
- "#FFB347");
- }
- }
-
- var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode);
- if (unresolved.Count > 0)
- {
- AddParameterAnnotation(
- $"Unresolved variables: {string.Join(", ", unresolved)} — not in parameter list",
- "#FFB347");
- }
- }
-
- private static void AddParamCell(Grid grid, int row, int col, string text, string color,
- FontWeight fontWeight = default, string? tooltip = null)
- {
- var tb = new TextBlock
- {
- Text = text,
- FontSize = 11,
- FontWeight = fontWeight == default ? FontWeight.Normal : fontWeight,
- Foreground = new SolidColorBrush(Color.Parse(color)),
- Margin = new Thickness(0, 2, 10, 2),
- TextTrimming = TextTrimming.CharacterEllipsis,
- MaxWidth = 200
- };
- // Name and DataType columns are short — no need for max width
- if (col <= 1)
- tb.MaxWidth = double.PositiveInfinity;
- if (tooltip != null)
- ToolTip.SetTip(tb, tooltip);
- else if (text.Length > 30)
- ToolTip.SetTip(tb, text);
- Grid.SetRow(tb, row);
- Grid.SetColumn(tb, col);
- grid.Children.Add(tb);
- }
-
- private void AddParameterAnnotation(string text, string color)
- {
- ParametersContent.Children.Add(new TextBlock
- {
- Text = text,
- FontSize = 11,
- FontStyle = FontStyle.Italic,
- Foreground = new SolidColorBrush(Color.Parse(color)),
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 6, 0, 0)
- });
- }
-
- private static List FindUnresolvedVariables(string queryText, List parameters,
- PlanNode? rootNode = null)
- {
- var unresolved = new List();
- if (string.IsNullOrEmpty(queryText))
- return unresolved;
-
- var extractedNames = new HashSet(
- parameters.Select(p => p.Name), StringComparer.OrdinalIgnoreCase);
-
- // Collect table variable names from the plan tree so we don't misreport them as local variables
- var tableVarNames = new HashSet(StringComparer.OrdinalIgnoreCase);
- if (rootNode != null)
- CollectTableVariableNames(rootNode, tableVarNames);
-
- var matches = Regex.Matches(queryText, @"@\w+", RegexOptions.IgnoreCase);
- var seenVars = new HashSet(StringComparer.OrdinalIgnoreCase);
-
- foreach (Match match in matches)
- {
- var varName = match.Value;
- if (seenVars.Contains(varName) || extractedNames.Contains(varName))
- continue;
- if (varName.StartsWith("@@", StringComparison.OrdinalIgnoreCase))
- continue;
- if (tableVarNames.Contains(varName))
- continue;
-
- seenVars.Add(varName);
- unresolved.Add(varName);
- }
-
- return unresolved;
- }
-
- private static void CollectTableVariableNames(PlanNode node, HashSet names)
- {
- if (!string.IsNullOrEmpty(node.ObjectName) && node.ObjectName.StartsWith("@"))
- {
- // ObjectName is like "@t.c" — extract the table variable name "@t"
- var dotIdx = node.ObjectName.IndexOf('.');
- var tvName = dotIdx > 0 ? node.ObjectName[..dotIdx] : node.ObjectName;
- names.Add(tvName);
- }
- foreach (var child in node.Children)
- CollectTableVariableNames(child, names);
- }
-
- private static void CollectWarnings(PlanNode node, List warnings)
- {
- warnings.AddRange(node.Warnings);
- foreach (var child in node.Children)
- CollectWarnings(child, warnings);
- }
-
- ///
- /// Computes own CPU time for a node by subtracting child times in row mode.
- /// Batch mode reports own time directly; row mode is cumulative from leaves up.
- ///
- private static long GetOwnCpuMs(PlanNode node)
- {
- if (node.ActualCPUMs <= 0) return 0;
- var mode = node.ActualExecutionMode ?? node.ExecutionMode;
- if (mode == "Batch") return node.ActualCPUMs;
- var childSum = GetChildCpuMsSum(node);
- return Math.Max(0, node.ActualCPUMs - childSum);
- }
-
- ///
- /// Computes own elapsed time for a node by subtracting child times in row mode.
- ///
- private static long GetOwnElapsedMs(PlanNode node)
- {
- if (node.ActualElapsedMs <= 0) return 0;
- var mode = node.ActualExecutionMode ?? node.ExecutionMode;
- if (mode == "Batch") return node.ActualElapsedMs;
-
- // Exchange operators: Thread 0 is the coordinator whose elapsed time is the
- // wall clock for the entire parallel branch — not the operator's own work.
- if (IsExchangeOperator(node))
- {
- // If we have worker thread data, use max of worker threads
- var workerMax = node.PerThreadStats
- .Where(t => t.ThreadId > 0)
- .Select(t => t.ActualElapsedMs)
- .DefaultIfEmpty(0)
- .Max();
- if (workerMax > 0)
- {
- var childSum = GetChildElapsedMsSum(node);
- return Math.Max(0, workerMax - childSum);
- }
- // Thread 0 only (coordinator) — exchange does negligible own work
- return 0;
- }
-
- var childElapsedSum = GetChildElapsedMsSum(node);
- return Math.Max(0, node.ActualElapsedMs - childElapsedSum);
- }
-
- private static bool IsExchangeOperator(PlanNode node) =>
- node.PhysicalOp == "Parallelism"
- || node.LogicalOp is "Gather Streams" or "Distribute Streams" or "Repartition Streams";
-
- private static long GetChildCpuMsSum(PlanNode node)
- {
- long sum = 0;
- foreach (var child in node.Children)
- {
- if (child.ActualCPUMs > 0)
- sum += child.ActualCPUMs;
- else
- sum += GetChildCpuMsSum(child); // skip through transparent operators
- }
- return sum;
- }
-
- private static long GetChildElapsedMsSum(PlanNode node)
- {
- long sum = 0;
- foreach (var child in node.Children)
- {
- if (child.PhysicalOp == "Parallelism" && child.Children.Count > 0)
- {
- // Exchange: take max of children (parallel branches)
- sum += child.Children
- .Where(c => c.ActualElapsedMs > 0)
- .Select(c => c.ActualElapsedMs)
- .DefaultIfEmpty(0)
- .Max();
- }
- else if (child.ActualElapsedMs > 0)
- {
- sum += child.ActualElapsedMs;
- }
- else
- {
- sum += GetChildElapsedMsSum(child); // skip through transparent operators
- }
- }
- return sum;
- }
-
- private void ShowWaitStats(List waits, List benefits, bool isActualPlan)
- {
- WaitStatsContent.Children.Clear();
-
- if (waits.Count == 0)
- {
- WaitStatsHeader.Text = "Wait Stats";
- WaitStatsEmpty.Text = isActualPlan
- ? "No wait stats recorded"
- : "No wait stats (estimated plan)";
- WaitStatsEmpty.IsVisible = true;
- return;
- }
-
- WaitStatsEmpty.IsVisible = false;
-
- // Build benefit lookup
- var benefitLookup = new Dictionary(StringComparer.OrdinalIgnoreCase);
- foreach (var wb in benefits)
- benefitLookup[wb.WaitType] = wb.MaxBenefitPercent;
-
- var sorted = waits.OrderByDescending(w => w.WaitTimeMs).ToList();
- var maxWait = sorted[0].WaitTimeMs;
- var totalWait = sorted.Sum(w => w.WaitTimeMs);
-
- // Update expander header with total
- WaitStatsHeader.Text = $" Wait Stats \u2014 {totalWait:N0}ms total";
-
- // Build a single Grid for all rows so columns align
- // Name, bar, duration, and benefit columns
- var grid = new Grid
- {
- ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto,Auto")
- };
- for (int i = 0; i < sorted.Count; i++)
- grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
-
- for (int i = 0; i < sorted.Count; i++)
- {
- var w = sorted[i];
- var barFraction = maxWait > 0 ? (double)w.WaitTimeMs / maxWait : 0;
- var color = GetWaitCategoryColor(GetWaitCategory(w.WaitType));
-
- // Wait type name — colored by category
- var nameText = new TextBlock
- {
- Text = w.WaitType,
- FontSize = 12,
- Foreground = new SolidColorBrush(Color.Parse(color)),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 2, 10, 2)
- };
- Grid.SetRow(nameText, i);
- Grid.SetColumn(nameText, 0);
- grid.Children.Add(nameText);
-
- // Bar — semi-transparent category color, compact proportional indicator
- var barColor = Color.Parse(color);
- var colorBar = new Border
- {
- Width = Math.Max(4, barFraction * 60),
- Height = 14,
- Background = new SolidColorBrush(Color.FromArgb(0x60, barColor.R, barColor.G, barColor.B)),
- CornerRadius = new CornerRadius(2),
- HorizontalAlignment = HorizontalAlignment.Left,
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 2, 8, 2)
- };
- Grid.SetRow(colorBar, i);
- Grid.SetColumn(colorBar, 1);
- grid.Children.Add(colorBar);
-
- // Duration text
- var durationText = new TextBlock
- {
- Text = $"{w.WaitTimeMs:N0}ms ({w.WaitCount:N0} waits)",
- FontSize = 12,
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 2, 8, 2)
- };
- Grid.SetRow(durationText, i);
- Grid.SetColumn(durationText, 2);
- grid.Children.Add(durationText);
-
- // Benefit % (if available)
- if (benefitLookup.TryGetValue(w.WaitType, out var benefitPct) && benefitPct > 0)
- {
- var benefitText = new TextBlock
- {
- Text = $"up to {benefitPct:N0}%",
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.Parse("#8b949e")),
- VerticalAlignment = VerticalAlignment.Center,
- Margin = new Thickness(0, 2, 0, 2)
- };
- Grid.SetRow(benefitText, i);
- Grid.SetColumn(benefitText, 3);
- grid.Children.Add(benefitText);
- }
- }
-
- WaitStatsContent.Children.Add(grid);
-
- }
-
- private void ShowRuntimeSummary(PlanStatement statement)
- {
- RuntimeSummaryContent.Children.Clear();
-
- var labelColor = "#E4E6EB";
- var valueColor = "#E4E6EB";
-
- var grid = new Grid
- {
- ColumnDefinitions = new ColumnDefinitions("Auto,*")
- };
- int rowIndex = 0;
-
- void AddRow(string label, string value, string? color = null)
- {
- grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto));
-
- var labelText = new TextBlock
- {
- Text = label,
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.Parse(labelColor)),
- HorizontalAlignment = HorizontalAlignment.Left,
- Margin = new Thickness(0, 1, 8, 1)
- };
- Grid.SetRow(labelText, rowIndex);
- Grid.SetColumn(labelText, 0);
- grid.Children.Add(labelText);
-
- var valueText = new TextBlock
- {
- Text = value,
- FontSize = 11,
- Foreground = new SolidColorBrush(Color.Parse(color ?? valueColor)),
- Margin = new Thickness(0, 1, 0, 1)
- };
- Grid.SetRow(valueText, rowIndex);
- Grid.SetColumn(valueText, 1);
- grid.Children.Add(valueText);
-
- rowIndex++;
- }
-
- // Efficiency thresholds: white >= 40%, orange >= 20%, red < 20%.
- // Loosened per Joe's feedback (#215 C1): for memory grants, moderate
- // utilization (e.g. 60%) is fine — operators can spill near their max,
- // so we shouldn't flag anything above a real over-grant threshold.
- static string EfficiencyColor(double pct) => pct >= 40 ? "#E4E6EB"
- : pct >= 20 ? "#FFB347" : "#E57373";
-
- // Memory grant color tiers (#215 C1 + E8 + E9): over-used grant (red),
- // any operator spilled (orange), otherwise tier by utilization.
- static string MemoryGrantColor(double pctUsed, bool hasSpill)
- {
- if (pctUsed > 100) return "#E57373";
- if (hasSpill) return "#FFB347";
- if (pctUsed >= 40) return "#E4E6EB";
- if (pctUsed >= 20) return "#FFB347";
- return "#E57373";
- }
-
- // E7: rename the panel title for estimated plans
- var isEstimated = statement.QueryTimeStats == null;
- RuntimeSummaryTitle.Text = isEstimated ? "Predicted Runtime" : "Runtime Summary";
-
- var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode);
-
- // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost.
- // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors.
-
- if (statement.QueryTimeStats != null)
- {
- AddRow("Elapsed", $"{statement.QueryTimeStats.ElapsedTimeMs:N0}ms");
- if (statement.QueryTimeStats.ElapsedTimeMs > 0)
- {
- long externalWaitMs = 0;
- foreach (var w in statement.WaitStats)
- if (BenefitScorer.IsExternalWait(w.WaitType))
- externalWaitMs += w.WaitTimeMs;
- var effectiveCpu = Math.Max(0L, statement.QueryTimeStats.CpuTimeMs - externalWaitMs);
- var ratio = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs;
- AddRow("CPU:Elapsed", ratio.ToString("N2"));
- }
- }
-
- // DOP + parallelism efficiency
- if (statement.DegreeOfParallelism > 0)
- {
- var dopText = statement.DegreeOfParallelism.ToString();
- string? dopColor = null;
- if (statement.QueryTimeStats != null &&
- statement.QueryTimeStats.ElapsedTimeMs > 0 &&
- statement.QueryTimeStats.CpuTimeMs > 0 &&
- statement.DegreeOfParallelism > 1)
- {
- long externalWaitMs = 0;
- foreach (var w in statement.WaitStats)
- if (BenefitScorer.IsExternalWait(w.WaitType))
- externalWaitMs += w.WaitTimeMs;
- var effectiveCpu = Math.Max(0, statement.QueryTimeStats.CpuTimeMs - externalWaitMs);
- var speedup = (double)effectiveCpu / statement.QueryTimeStats.ElapsedTimeMs;
- var efficiency = Math.Min(100.0, (speedup - 1.0) / (statement.DegreeOfParallelism - 1.0) * 100.0);
- efficiency = Math.Max(0.0, efficiency);
- dopText += $" ({efficiency:N0}% efficient)";
- dopColor = EfficiencyColor(efficiency);
- }
- AddRow("DOP", dopText, dopColor);
- }
- else if (statement.NonParallelPlanReason != null)
- AddRow("Serial", statement.NonParallelPlanReason);
-
- if (statement.QueryTimeStats != null)
- {
- AddRow("CPU", $"{statement.QueryTimeStats.CpuTimeMs:N0}ms");
- if (statement.QueryUdfCpuTimeMs > 0)
- AddRow("UDF CPU", $"{statement.QueryUdfCpuTimeMs:N0}ms");
- if (statement.QueryUdfElapsedTimeMs > 0)
- AddRow("UDF elapsed", $"{statement.QueryUdfElapsedTimeMs:N0}ms");
- }
-
- // Compile stats (category B plan-level property)
- if (statement.CompileTimeMs > 0)
- AddRow("Compile", $"{statement.CompileTimeMs:N0}ms");
- if (statement.CachedPlanSizeKB > 0)
- AddRow("Cached plan size", $"{statement.CachedPlanSizeKB:N0} KB");
-
- // Memory grant — color per new tiers, spill indicator if any operator spilled
- if (statement.MemoryGrant != null)
- {
- var mg = statement.MemoryGrant;
- var grantPct = mg.GrantedMemoryKB > 0
- ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100;
- var grantColor = MemoryGrantColor(grantPct, hasSpillInTree);
- var spillTag = hasSpillInTree ? " ⚠ spill" : "";
- AddRow("Memory grant",
- $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}",
- grantColor);
- if (mg.GrantWaitTimeMs > 0)
- AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "#E57373");
- }
-
- // Thread stats
- if (statement.ThreadStats != null)
- {
- var ts = statement.ThreadStats;
- AddRow("Branches", ts.Branches.ToString());
- var totalReserved = ts.Reservations.Sum(r => r.ReservedThreads);
- if (totalReserved > 0)
- {
- var threadPct = (double)ts.UsedThreads / totalReserved * 100;
- var threadColor = EfficiencyColor(threadPct);
- var threadText = ts.UsedThreads == totalReserved
- ? $"{ts.UsedThreads} used ({totalReserved} reserved)"
- : $"{ts.UsedThreads} used of {totalReserved} reserved ({totalReserved - ts.UsedThreads} inactive)";
- AddRow("Threads", threadText, threadColor);
- }
- else
- {
- AddRow("Threads", $"{ts.UsedThreads} used");
- }
- }
-
- // Optimization + CE model
- if (!string.IsNullOrEmpty(statement.StatementOptmLevel))
- AddRow("Optimization", statement.StatementOptmLevel);
- if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason))
- AddRow("Early abort", statement.StatementOptmEarlyAbortReason);
- if (statement.CardinalityEstimationModelVersion > 0)
- AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString());
-
- if (grid.Children.Count > 0)
- {
- RuntimeSummaryContent.Children.Add(grid);
- RuntimeSummaryEmpty.IsVisible = false;
- }
- else
- {
- RuntimeSummaryEmpty.IsVisible = true;
- }
- ShowServerContext();
- }
-
- private void ShowServerContext()
- {
- ServerContextContent.Children.Clear();
- if (_serverMetadata == null)
- {
- ServerContextEmpty.IsVisible = true;
- ServerContextBorder.IsVisible = true;
- return;
- }
-
- ServerContextEmpty.IsVisible = false;
-
- 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()
- {
- InsightsPanel.IsVisible = true;
- InsightsHeader.Text = " Plan Insights";
- }
-
- private static string GetWaitCategory(string waitType)
- {
- if (waitType.StartsWith("SOS_SCHEDULER_YIELD") ||
- waitType.StartsWith("CXPACKET") ||
- waitType.StartsWith("CXCONSUMER") ||
- waitType.StartsWith("CXSYNC_PORT") ||
- waitType.StartsWith("CXSYNC_CONSUMER"))
- return "CPU";
-
- if (waitType.StartsWith("PAGEIOLATCH") ||
- waitType.StartsWith("WRITELOG") ||
- waitType.StartsWith("IO_COMPLETION") ||
- waitType.StartsWith("ASYNC_IO_COMPLETION"))
- return "I/O";
-
- if (waitType.StartsWith("LCK_M_"))
- return "Lock";
-
- if (waitType == "RESOURCE_SEMAPHORE" || waitType == "CMEMTHREAD")
- return "Memory";
-
- if (waitType == "ASYNC_NETWORK_IO")
- return "Network";
-
- return "Other";
- }
-
- private static string GetWaitCategoryColor(string category)
- {
- return category switch
- {
- "CPU" => "#4FA3FF",
- "I/O" => "#FFB347",
- "Lock" => "#E57373",
- "Memory" => "#9B59B6",
- "Network" => "#2ECC71",
- _ => "#6BB5FF"
- };
- }
-
- #endregion
-
- #region Zoom
-
- private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep);
- private void ZoomOut_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel - ZoomStep);
-
- private void ZoomFit_Click(object? sender, RoutedEventArgs e)
- {
- if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return;
-
- var viewWidth = PlanScrollViewer.Bounds.Width;
- var viewHeight = PlanScrollViewer.Bounds.Height;
- if (viewWidth <= 0 || viewHeight <= 0) return;
-
- var fitZoom = Math.Min(viewWidth / PlanCanvas.Width, viewHeight / PlanCanvas.Height);
- SetZoom(Math.Min(fitZoom, 1.0));
- PlanScrollViewer.Offset = new Avalonia.Vector(0, 0);
- }
-
- private void SetZoom(double level)
- {
- _zoomLevel = Math.Max(MinZoom, Math.Min(MaxZoom, level));
- _zoomTransform.ScaleX = _zoomLevel;
- _zoomTransform.ScaleY = _zoomLevel;
- ZoomLevelText.Text = $"{(int)(_zoomLevel * 100)}%";
- UpdateMinimapViewportBox();
- }
-
- ///
- /// 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)
- {
- var newZoom = Math.Max(MinZoom, Math.Min(MaxZoom, level));
- if (Math.Abs(newZoom - _zoomLevel) < 0.001)
- return;
-
- // 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;
-
- // Apply the new zoom
- SetZoom(newZoom);
-
- // 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);
-
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(newOffsetX, newOffsetY);
- UpdateMinimapViewportBox();
- });
- }
-
- 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));
- }
- }
-
- private void PlanScrollViewer_PointerPressed(object? sender, PointerPressedEventArgs e)
- {
- // Don't intercept scrollbar interactions
- if (IsScrollBarAtPoint(e))
- return;
-
- var point = e.GetCurrentPoint(PlanScrollViewer);
- var isMiddle = point.Properties.IsMiddleButtonPressed;
- var isLeft = point.Properties.IsLeftButtonPressed;
-
- // Middle mouse always pans; left-click pans only on empty canvas (not on nodes)
- if (isMiddle || (isLeft && !IsNodeAtPoint(e)))
- {
- _isPanning = true;
- _panStart = point.Position;
- _panStartOffsetX = PlanScrollViewer.Offset.X;
- _panStartOffsetY = PlanScrollViewer.Offset.Y;
- PlanScrollViewer.Cursor = new Cursor(StandardCursorType.SizeAll);
- e.Pointer.Capture(PlanScrollViewer);
- e.Handled = true;
- }
- }
-
- private void PlanScrollViewer_PointerMoved(object? sender, PointerEventArgs e)
- {
- if (!_isPanning) return;
-
- var current = e.GetPosition(PlanScrollViewer);
- var dx = current.X - _panStart.X;
- var dy = current.Y - _panStart.Y;
-
- var newX = Math.Max(0, _panStartOffsetX - dx);
- var newY = Math.Max(0, _panStartOffsetY - dy);
-
- // Defer offset change so the ScrollViewer doesn't overwrite it during layout
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(newX, newY);
- UpdateMinimapViewportBox();
- });
-
- e.Handled = true;
- }
-
- private void PlanScrollViewer_PointerReleased(object? sender, PointerReleasedEventArgs e)
- {
- if (!_isPanning) return;
- _isPanning = false;
- PlanScrollViewer.Cursor = Cursor.Default;
- e.Pointer.Capture(null);
- e.Handled = true;
- }
-
- /// Check if the pointer event originated from a node Border.
- private bool IsNodeAtPoint(PointerPressedEventArgs e)
- {
- // Walk up the visual tree from the source to see if we hit a node border
- var source = e.Source as Control;
- while (source != null && source != PlanCanvas)
- {
- if (source is Border b && _nodeBorderMap.ContainsKey(b))
- return true;
- source = source.Parent as Control;
- }
- return false;
- }
-
- /// Check if the pointer event originated from a ScrollBar.
- private bool IsScrollBarAtPoint(PointerPressedEventArgs e)
- {
- var source = e.Source as Control;
- while (source != null && source != PlanScrollViewer)
- {
- if (source is ScrollBar)
- return true;
- source = source.Parent as Control;
- }
- return false;
- }
-
- #endregion
-
- #region Save & Statement Selection
-
- private async void SavePlan_Click(object? sender, RoutedEventArgs e)
- {
- if (_currentPlan == null || string.IsNullOrEmpty(_currentPlan.RawXml)) return;
-
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel == null) return;
-
- var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
- {
- Title = "Save Plan",
- DefaultExtension = "sqlplan",
- SuggestedFileName = $"plan_{DateTime.Now:yyyyMMdd_HHmmss}.sqlplan",
- FileTypeChoices = new[]
- {
- new FilePickerFileType("SQL Plan Files") { Patterns = new[] { "*.sqlplan" } },
- new FilePickerFileType("XML Files") { Patterns = new[] { "*.xml" } },
- new FilePickerFileType("All Files") { Patterns = new[] { "*.*" } }
- }
- });
-
- if (file != null)
- {
- try
- {
- await using var stream = await file.OpenWriteAsync();
- await using var writer = new StreamWriter(stream);
- await writer.WriteAsync(_currentPlan.RawXml);
- }
- catch (Exception ex)
- {
- System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}");
- CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}";
- }
- }
- }
-
- #endregion
-
- #region Statements Panel
-
- private void PopulateStatementsGrid(List statements)
- {
- StatementsHeader.Text = $"Statements ({statements.Count})";
-
- var hasActualTimes = statements.Any(s => s.QueryTimeStats != null &&
- (s.QueryTimeStats.CpuTimeMs > 0 || s.QueryTimeStats.ElapsedTimeMs > 0));
- var hasUdf = statements.Any(s => s.QueryUdfElapsedTimeMs > 0);
-
- // Build columns
- StatementsGrid.Columns.Clear();
-
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "#",
- Binding = new Avalonia.Data.Binding("Index"),
- Width = new DataGridLength(40),
- IsReadOnly = true
- });
-
- var queryTemplate = new FuncDataTemplate((row, _) =>
- {
- if (row == null) return new TextBlock();
- var tb = new TextBlock
- {
- Text = row.QueryText,
- TextWrapping = TextWrapping.Wrap,
- MaxHeight = 80,
- FontSize = 11,
- Margin = new Thickness(4, 2)
- };
- ToolTip.SetTip(tb, new TextBlock
- {
- Text = row.FullQueryText,
- TextWrapping = TextWrapping.Wrap,
- MaxWidth = 600,
- FontFamily = new FontFamily("Consolas"),
- FontSize = 11
- });
- return tb;
- }, supportsRecycling: false);
-
- StatementsGrid.Columns.Add(new DataGridTemplateColumn
- {
- Header = "Query",
- CellTemplate = queryTemplate,
- Width = new DataGridLength(250),
- IsReadOnly = true
- });
-
- if (hasActualTimes)
- {
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "CPU",
- Binding = new Avalonia.Data.Binding("CpuDisplay"),
- Width = new DataGridLength(70),
- IsReadOnly = true,
- CustomSortComparer = new LongComparer(r => r.CpuMs)
- });
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "Elapsed",
- Binding = new Avalonia.Data.Binding("ElapsedDisplay"),
- Width = new DataGridLength(70),
- IsReadOnly = true,
- CustomSortComparer = new LongComparer(r => r.ElapsedMs)
- });
- }
-
- if (hasUdf)
- {
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "UDF",
- Binding = new Avalonia.Data.Binding("UdfDisplay"),
- Width = new DataGridLength(70),
- IsReadOnly = true,
- CustomSortComparer = new LongComparer(r => r.UdfMs)
- });
- }
-
- if (!hasActualTimes)
- {
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "Est. Cost",
- Binding = new Avalonia.Data.Binding("CostDisplay"),
- Width = new DataGridLength(80),
- IsReadOnly = true,
- CustomSortComparer = new DoubleComparer(r => r.EstCost)
- });
- }
-
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "Critical",
- Binding = new Avalonia.Data.Binding("Critical"),
- Width = new DataGridLength(60),
- IsReadOnly = true
- });
-
- StatementsGrid.Columns.Add(new DataGridTextColumn
- {
- Header = "Warnings",
- Binding = new Avalonia.Data.Binding("Warnings"),
- Width = new DataGridLength(70),
- IsReadOnly = true
- });
-
- // Build rows
- var rows = new List();
- for (int i = 0; i < statements.Count; i++)
- {
- var stmt = statements[i];
- var allWarnings = stmt.PlanWarnings.ToList();
- if (stmt.RootNode != null)
- CollectNodeWarnings(stmt.RootNode, allWarnings);
-
- var fullText = stmt.StatementText;
- if (string.IsNullOrWhiteSpace(fullText))
- fullText = $"Statement {i + 1}";
- var displayText = fullText.Length > 120 ? fullText[..120] + "..." : fullText;
-
- rows.Add(new StatementRow
- {
- Index = i + 1,
- QueryText = displayText,
- FullQueryText = fullText,
- CpuMs = stmt.QueryTimeStats?.CpuTimeMs ?? 0,
- ElapsedMs = stmt.QueryTimeStats?.ElapsedTimeMs ?? 0,
- UdfMs = stmt.QueryUdfElapsedTimeMs,
- EstCost = stmt.StatementSubTreeCost,
- Critical = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical),
- Warnings = allWarnings.Count(w => w.Severity == PlanWarningSeverity.Warning),
- Statement = stmt
- });
- }
-
- StatementsGrid.ItemsSource = rows;
- }
-
- private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (StatementsGrid.SelectedItem is StatementRow row)
- RenderStatement(row.Statement);
- }
-
- private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
- {
- if (StatementsGrid.SelectedItem is not StatementRow row) return;
- var text = row.Statement.StatementText;
- if (string.IsNullOrEmpty(text)) return;
-
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel?.Clipboard != null)
- 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);
- foreach (var child in node.Children)
- CollectNodeWarnings(child, warnings);
- }
-
- private void ToggleStatements_Click(object? sender, RoutedEventArgs e)
- {
- if (StatementsPanel.IsVisible)
- CloseStatementsPanel();
- else
- ShowStatementsPanel();
- }
-
- private void CloseStatements_Click(object? sender, RoutedEventArgs e)
- {
- CloseStatementsPanel();
- }
-
- private void ShowStatementsPanel()
- {
- _statementsColumn.Width = new GridLength(450);
- _statementsSplitterColumn.Width = new GridLength(5);
- StatementsSplitter.IsVisible = true;
- StatementsPanel.IsVisible = true;
- StatementsButton.IsVisible = true;
- StatementsButtonSeparator.IsVisible = true;
- }
-
- private void CloseStatementsPanel()
- {
- StatementsPanel.IsVisible = false;
- StatementsSplitter.IsVisible = false;
- _statementsColumn.Width = new GridLength(0);
- _statementsSplitterColumn.Width = new GridLength(0);
- }
-
- #endregion
-
- #region Minimap
-
- private void MinimapToggle_Click(object? sender, RoutedEventArgs e)
- {
- if (MinimapPanel.IsVisible)
- CloseMinimapPanel();
- else
- OpenMinimapPanel();
- }
-
- private void MinimapClose_Click(object? sender, RoutedEventArgs e)
- {
- CloseMinimapPanel();
- }
-
- private void OpenMinimapPanel()
- {
- MinimapPanel.Width = _minimapWidth;
- MinimapPanel.Height = _minimapHeight;
- MinimapPanel.IsVisible = true;
- RenderMinimap();
- }
-
- private void CloseMinimapPanel()
- {
- MinimapPanel.IsVisible = false;
- _minimapDragging = false;
- _minimapResizing = false;
- }
-
- private void RenderMinimap()
- {
- MinimapCanvas.Children.Clear();
- _minimapNodeMap.Clear();
- _minimapViewportBox = null;
- _minimapSelectedNode = null;
-
- // Guard: don't render if the panel was closed between a deferred post and execution
- if (!MinimapPanel.IsVisible) return;
-
- if (_currentStatement?.RootNode == null || PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0)
- return;
-
- var canvasW = MinimapCanvas.Bounds.Width;
- var canvasH = MinimapCanvas.Bounds.Height;
- if (canvasW <= 0 || canvasH <= 0)
- {
- // Defer until layout is ready
- Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Loaded);
- return;
- }
-
- var scaleX = canvasW / PlanCanvas.Width;
- var scaleY = canvasH / PlanCanvas.Height;
- var scale = Math.Min(scaleX, scaleY);
-
- // Cache the non-expensive node border brush for this render cycle
- _minimapNodeBorderBrushCache = FindBrushResource("ForegroundBrush") is SolidColorBrush fg
- ? new SolidColorBrush(Color.FromArgb(0x80, fg.Color.R, fg.Color.G, fg.Color.B))
- : FindBrushResource("BorderBrush");
-
- // Render branch areas with transparent colored backgrounds
- RenderMinimapBranches(_currentStatement.RootNode, scale);
- // Render edges
- var minimapDivergenceLimit = Math.Max(2.0, AppSettingsService.Load().AccuracyRatioDivergenceLimit);
- RenderMinimapEdges(_currentStatement.RootNode, scale, minimapDivergenceLimit);
-
- // Render nodes
- RenderMinimapNodes(_currentStatement.RootNode, scale);
-
- // Render viewport indicator
- RenderMinimapViewportBox(scale);
+ #region Minimap
- // Re-apply selection highlight if a node is selected
- if (_selectedNode != null)
- UpdateMinimapSelection(_selectedNode);
- }
private static readonly Color[] MinimapBranchColors =
{
@@ -3639,436 +394,13 @@ private void RenderMinimap()
Color.FromArgb(0x30, 0xFF, 0x7B, 0xA5), // pink
};
- private void RenderMinimapBranches(PlanNode root, double scale)
- {
-
- for (int i = 0; i < root.Children.Count; i++)
- {
- var child = root.Children[i];
- var color = MinimapBranchColors[i % MinimapBranchColors.Length];
-
- // Collect bounds of all nodes in this subtree
- double minX = double.MaxValue, minY = double.MaxValue;
- double maxX = double.MinValue, maxY = double.MinValue;
- CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
-
- var rect = new Avalonia.Controls.Shapes.Rectangle
- {
- Width = (maxX - minX + PlanLayoutEngine.NodeWidth) * scale + 4,
- Height = (maxY - minY + PlanLayoutEngine.GetNodeHeight(child)) * scale + 4,
- Fill = new SolidColorBrush(color),
- RadiusX = 2,
- RadiusY = 2
- };
- Canvas.SetLeft(rect, minX * scale - 2);
- Canvas.SetTop(rect, minY * scale - 2);
- MinimapCanvas.Children.Add(rect);
- }
- }
-
- private static void CollectSubtreeBounds(PlanNode node, ref double minX, ref double minY, ref double maxX, ref double maxY)
- {
- if (node.X < minX) minX = node.X;
- if (node.Y < minY) minY = node.Y;
- if (node.X > maxX) maxX = node.X;
- var bottom = node.Y + PlanLayoutEngine.GetNodeHeight(node);
- if (bottom > maxY) maxY = bottom;
-
- foreach (var child in node.Children)
- CollectSubtreeBounds(child, ref minX, ref minY, ref maxX, ref maxY);
- }
-
- private void RenderMinimapEdges(PlanNode node, double scale, double divergenceLimit)
- {
- foreach (var child in node.Children)
- {
- var parentRight = (node.X + PlanLayoutEngine.NodeWidth) * scale;
- var parentCenterY = (node.Y + PlanLayoutEngine.GetNodeHeight(node) / 2) * scale;
- var childLeft = child.X * scale;
- var childCenterY = (child.Y + PlanLayoutEngine.GetNodeHeight(child) / 2) * scale;
- var midX = (parentRight + childLeft) / 2;
-
- // Proportional thickness matching the plan viewer (logarithmic, scaled down)
- var rows = child.HasActualStats ? child.ActualRows : child.EstimateRows;
- var fullThickness = Math.Max(2, Math.Min(Math.Floor(Math.Log(Math.Max(1, rows))), 12));
- var thickness = Math.Max(0.5, fullThickness * scale);
-
- var geometry = new PathGeometry();
- var figure = new PathFigure { StartPoint = new Point(parentRight, parentCenterY), IsClosed = false };
- figure.Segments!.Add(new LineSegment { Point = new Point(midX, parentCenterY) });
- figure.Segments.Add(new LineSegment { Point = new Point(midX, childCenterY) });
- figure.Segments.Add(new LineSegment { Point = new Point(childLeft, childCenterY) });
- geometry.Figures!.Add(figure);
-
- var linkBrush = GetLinkColorBrush(child, divergenceLimit);
-
- var path = new AvaloniaPath
- {
- Data = geometry,
- Stroke = linkBrush,
- StrokeThickness = thickness,
- StrokeJoin = PenLineJoin.Round
- };
- MinimapCanvas.Children.Add(path);
-
- RenderMinimapEdges(child, scale, divergenceLimit);
- }
- }
// Cached per render cycle in RenderMinimap() to avoid per-node brush creation
private IBrush _minimapNodeBorderBrushCache = Brushes.Gray;
- private void RenderMinimapNodes(PlanNode node, double scale)
- {
- var w = PlanLayoutEngine.NodeWidth * scale;
- var h = PlanLayoutEngine.GetNodeHeight(node) * scale;
- // Use theme background colors with transparency
- var bgBrush = node.IsExpensive
- ? MinimapExpensiveNodeBgBrush
- : FindBrushResource("BackgroundLightBrush");
- var borderBrush = node.IsExpensive ? OrangeRedBrush : _minimapNodeBorderBrushCache;
-
- var border = new Border
- {
- Width = Math.Max(4, w),
- Height = Math.Max(4, h),
- Background = bgBrush,
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(0.5),
- CornerRadius = new CornerRadius(1)
- };
-
- // Show a small icon inside the node if space allows
- var iconBitmap = IconHelper.LoadIcon(node.IconName);
- if (iconBitmap != null)
- {
- var iconSize = Math.Min(Math.Min(w * 0.7, h * 0.7), 16);
- if (iconSize >= 6)
- {
- border.Child = new Image
- {
- Source = iconBitmap,
- Width = iconSize,
- Height = iconSize,
- HorizontalAlignment = HorizontalAlignment.Center,
- VerticalAlignment = VerticalAlignment.Center
- };
- }
- }
-
- Canvas.SetLeft(border, node.X * scale);
- Canvas.SetTop(border, node.Y * scale);
- MinimapCanvas.Children.Add(border);
-
- _minimapNodeMap[border] = node;
-
- foreach (var child in node.Children)
- RenderMinimapNodes(child, scale);
- }
-
- private void RenderMinimapViewportBox(double scale)
- {
- var viewW = PlanScrollViewer.Bounds.Width;
- var viewH = PlanScrollViewer.Bounds.Height;
- if (viewW <= 0 || viewH <= 0) return;
-
- var contentW = PlanCanvas.Width * _zoomLevel;
- var contentH = PlanCanvas.Height * _zoomLevel;
-
- var boxW = Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale;
- var boxH = Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale;
- var boxX = (PlanScrollViewer.Offset.X / _zoomLevel) * scale;
- var boxY = (PlanScrollViewer.Offset.Y / _zoomLevel) * scale;
-
- var accentColor = FindBrushResource("AccentBrush") is SolidColorBrush ab
- ? ab.Color
- : Color.FromRgb(0x2E, 0xAE, 0xF1);
- var themeBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B));
- var borderBrush = new SolidColorBrush(Color.FromArgb(0xB0, accentColor.R, accentColor.G, accentColor.B));
-
- _minimapViewportBox = new Border
- {
- Width = Math.Max(4, boxW),
- Height = Math.Max(4, boxH),
- Background = themeBrush,
- BorderBrush = borderBrush,
- BorderThickness = new Thickness(1.5),
- CornerRadius = new CornerRadius(1),
- Cursor = new Cursor(StandardCursorType.SizeAll)
- };
- Canvas.SetLeft(_minimapViewportBox, boxX);
- Canvas.SetTop(_minimapViewportBox, boxY);
- MinimapCanvas.Children.Add(_minimapViewportBox);
- }
-
- private void UpdateMinimapViewportBox()
- {
- if (!MinimapPanel.IsVisible || _minimapViewportBox == null || _currentStatement?.RootNode == null)
- return;
- if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return;
-
- var canvasW = MinimapCanvas.Bounds.Width;
- var canvasH = MinimapCanvas.Bounds.Height;
- if (canvasW <= 0 || canvasH <= 0) return;
-
- var scaleX = canvasW / PlanCanvas.Width;
- var scaleY = canvasH / PlanCanvas.Height;
- var scale = Math.Min(scaleX, scaleY);
-
- var viewW = PlanScrollViewer.Bounds.Width;
- var viewH = PlanScrollViewer.Bounds.Height;
- if (viewW <= 0 || viewH <= 0) return;
-
- var contentW = PlanCanvas.Width * _zoomLevel;
- var contentH = PlanCanvas.Height * _zoomLevel;
-
- _minimapViewportBox.Width = Math.Max(4, Math.Min(viewW / contentW, 1.0) * PlanCanvas.Width * scale);
- _minimapViewportBox.Height = Math.Max(4, Math.Min(viewH / contentH, 1.0) * PlanCanvas.Height * scale);
- Canvas.SetLeft(_minimapViewportBox, (PlanScrollViewer.Offset.X / _zoomLevel) * scale);
- Canvas.SetTop(_minimapViewportBox, (PlanScrollViewer.Offset.Y / _zoomLevel) * scale);
- }
-
- private double GetMinimapScale()
- {
- if (PlanCanvas.Width <= 0 || PlanCanvas.Height <= 0) return 1;
- var canvasW = MinimapCanvas.Bounds.Width;
- var canvasH = MinimapCanvas.Bounds.Height;
- if (canvasW <= 0 || canvasH <= 0) return 1;
- return Math.Min(canvasW / PlanCanvas.Width, canvasH / PlanCanvas.Height);
- }
-
- private void UpdateMinimapSelection(PlanNode node)
- {
- if (!MinimapPanel.IsVisible) return;
-
- // Reset previous selection highlight
- if (_minimapSelectedNode != null)
- {
- var prevNode = _minimapNodeMap.GetValueOrDefault(_minimapSelectedNode);
- _minimapSelectedNode.BorderBrush = prevNode is { IsExpensive: true }
- ? OrangeRedBrush
- : _minimapNodeBorderBrushCache;
- _minimapSelectedNode.BorderThickness = new Thickness(0.5);
- _minimapSelectedNode = null;
- }
-
- // Find and highlight the new node
- foreach (var (border, n) in _minimapNodeMap)
- {
- if (n == node)
- {
- border.BorderBrush = SelectionBrush;
- border.BorderThickness = new Thickness(2);
- _minimapSelectedNode = border;
- break;
- }
- }
- }
-
- private void MinimapCanvas_PointerPressed(object? sender, PointerPressedEventArgs e)
- {
- var point = e.GetCurrentPoint(MinimapCanvas);
- if (!point.Properties.IsLeftButtonPressed) return;
-
- var pos = point.Position;
- var scale = GetMinimapScale();
-
- // Check if clicking on a node (single click = center, double click = zoom)
- if (e.ClickCount == 2)
- {
- // Double click: find node under pointer and zoom to it
- var node = FindMinimapNodeAt(pos);
- if (node != null)
- {
- ZoomToNode(node);
- e.Handled = true;
- return;
- }
- }
-
- if (e.ClickCount == 1)
- {
- // Check if over a minimap node for single-click centering
- var node = FindMinimapNodeAt(pos);
- if (node != null)
- {
- CenterOnNode(node);
- e.Handled = true;
- return;
- }
- }
-
- // Start viewport box drag
- _minimapDragging = true;
-
- // Move viewport center to click position
- ScrollPlanViewerToMinimapPoint(pos, scale);
-
- e.Pointer.Capture(MinimapCanvas);
- e.Handled = true;
- }
-
- private void MinimapCanvas_PointerMoved(object? sender, PointerEventArgs e)
- {
- if (!_minimapDragging) return;
-
- var pos = e.GetPosition(MinimapCanvas);
- var scale = GetMinimapScale();
- ScrollPlanViewerToMinimapPoint(pos, scale);
- e.Handled = true;
- }
-
- private void MinimapCanvas_PointerReleased(object? sender, PointerReleasedEventArgs e)
- {
- if (!_minimapDragging) return;
- _minimapDragging = false;
- e.Pointer.Capture(null);
- e.Handled = true;
- }
-
- private void ScrollPlanViewerToMinimapPoint(Point minimapPoint, double scale)
- {
- if (scale <= 0) return;
- // Convert minimap coords to plan content coords
- var contentX = minimapPoint.X / scale;
- var contentY = minimapPoint.Y / scale;
-
- // Center the viewport on this content point
- var viewW = PlanScrollViewer.Bounds.Width;
- var viewH = PlanScrollViewer.Bounds.Height;
- var offsetX = Math.Max(0, contentX * _zoomLevel - viewW / 2);
- var offsetY = Math.Max(0, contentY * _zoomLevel - viewH / 2);
-
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(offsetX, offsetY);
- });
- }
-
- private PlanNode? FindMinimapNodeAt(Point pos)
- {
- foreach (var (border, node) in _minimapNodeMap)
- {
- var left = Canvas.GetLeft(border);
- var top = Canvas.GetTop(border);
- if (pos.X >= left && pos.X <= left + border.Width &&
- pos.Y >= top && pos.Y <= top + border.Height)
- return node;
- }
- return null;
- }
-
- private void CenterOnNode(PlanNode node)
- {
- var nodeW = PlanLayoutEngine.NodeWidth;
- var nodeH = PlanLayoutEngine.GetNodeHeight(node);
- var viewW = PlanScrollViewer.Bounds.Width;
- var viewH = PlanScrollViewer.Bounds.Height;
- var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
- var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
- centerX = Math.Max(0, centerX);
- centerY = Math.Max(0, centerY);
-
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(centerX, centerY);
- });
- }
-
- private void ZoomToNode(PlanNode node)
- {
- var viewW = PlanScrollViewer.Bounds.Width;
- var viewH = PlanScrollViewer.Bounds.Height;
- if (viewW <= 0 || viewH <= 0) return;
-
- var nodeW = PlanLayoutEngine.NodeWidth;
- var nodeH = PlanLayoutEngine.GetNodeHeight(node);
-
- // Zoom so the node takes about 1/3 of the viewport
- var fitZoom = Math.Min(viewW / (nodeW * 3), viewH / (nodeH * 3));
- fitZoom = Math.Max(MinZoom, Math.Min(MaxZoom, fitZoom));
- SetZoom(fitZoom);
-
- // Center on the node
- var centerX = (node.X + nodeW / 2) * _zoomLevel - viewW / 2;
- var centerY = (node.Y + nodeH / 2) * _zoomLevel - viewH / 2;
-
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(Math.Max(0, centerX), Math.Max(0, centerY));
- });
-
- // Also select the node in the plan
- foreach (var (border, n) in _nodeBorderMap)
- {
- if (n == node)
- {
- SelectNode(border, node);
- break;
- }
- }
- }
-
- private void MinimapResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e)
- {
- var point = e.GetCurrentPoint(MinimapPanel);
- if (!point.Properties.IsLeftButtonPressed) return;
- _minimapResizing = true;
- _minimapResizeStart = point.Position;
- _minimapResizeStartW = MinimapPanel.Width;
- _minimapResizeStartH = MinimapPanel.Height;
- e.Pointer.Capture((Control)sender!);
- e.Handled = true;
- }
-
- private void MinimapResizeGrip_PointerMoved(object? sender, PointerEventArgs e)
- {
- if (!_minimapResizing) return;
- var current = e.GetPosition(MinimapPanel);
- var dx = current.X - _minimapResizeStart.X;
- var dy = current.Y - _minimapResizeStart.Y;
- var newW = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartW + dx));
- var newH = Math.Max(MinimapMinSize, Math.Min(MinimapMaxSize, _minimapResizeStartH + dy));
- MinimapPanel.Width = newW;
- MinimapPanel.Height = newH;
- _minimapWidth = newW;
- _minimapHeight = newH;
- e.Handled = true;
-
- // Re-render after resize
- Avalonia.Threading.Dispatcher.UIThread.Post(RenderMinimap, Avalonia.Threading.DispatcherPriority.Background);
- }
-
- private void MinimapResizeGrip_PointerReleased(object? sender, PointerReleasedEventArgs e)
- {
- if (!_minimapResizing) return;
- _minimapResizing = false;
- e.Pointer.Capture(null);
- e.Handled = true;
- RenderMinimap();
- }
#endregion
- #region Helpers
-
- private IBrush FindBrushResource(string key)
- {
- if (this.TryFindResource(key, out var resource) && resource is IBrush brush)
- return brush;
-
- // Fallback brushes in case resources are not found
- return key switch
- {
- "BackgroundLightBrush" => new SolidColorBrush(Color.FromRgb(0x23, 0x26, 0x2E)),
- "BorderBrush" => new SolidColorBrush(Color.FromRgb(0x3A, 0x3D, 0x45)),
- "ForegroundBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
- "ForegroundMutedBrush" => new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
- _ => Brushes.White
- };
- }
-
- #endregion
#region Plan Viewer Connection
@@ -4138,334 +470,9 @@ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEvent
#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
}