diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c3ac5da..f001a6d 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,5 +1,5 @@
name: Bug Report
-description: Report a problem with SQL Performance Studio
+description: Report a problem with Performance Studio
title: "[BUG] "
labels: ["bug"]
@@ -8,7 +8,7 @@ body:
id: component
attributes:
label: Component
- description: Which part of SQL Performance Studio is affected?
+ description: Which part of Performance Studio is affected?
options:
- Desktop App (Windows)
- Desktop App (macOS)
@@ -21,7 +21,7 @@ body:
- type: input
id: version
attributes:
- label: SQL Performance Studio Version
+ label: Performance Studio Version
description: Check the About dialog or the release you downloaded.
placeholder: "e.g., 0.7.0"
validations:
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 0e2121f..103b08c 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,4 +2,4 @@ blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/erikdarlingdata/PerformanceStudio/discussions
- about: Ask questions and discuss SQL Performance Studio with the community
+ about: Ask questions and discuss Performance Studio with the community
diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml
index b48de67..c722ea7 100644
--- a/.github/workflows/check-version-bump.yml
+++ b/.github/workflows/check-version-bump.yml
@@ -10,18 +10,23 @@ jobs:
steps:
- name: Checkout PR branch
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Get PR version
id: pr
shell: pwsh
run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ # Prefer Directory.Build.props; fall back to App.csproj for branches that
+ # predate the version-unification refactor (PR #315).
+ $ddb = 'src/Directory.Build.props'
+ $app = 'src/PlanViewer.App/PlanViewer.App.csproj'
+ $path = if (Test-Path $ddb) { $ddb } else { $app }
+ $version = ([xml](Get-Content $path)).Project.PropertyGroup.Version | Where-Object { $_ }
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- Write-Host "PR version: $version"
+ Write-Host "PR version: $version (from $path)"
- name: Checkout main
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
ref: main
path: main-branch
@@ -30,9 +35,12 @@ jobs:
id: main
shell: pwsh
run: |
- $version = ([xml](Get-Content main-branch/src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ $ddb = 'main-branch/src/Directory.Build.props'
+ $app = 'main-branch/src/PlanViewer.App/PlanViewer.App.csproj'
+ $path = if (Test-Path $ddb) { $ddb } else { $app }
+ $version = ([xml](Get-Content $path)).Project.PropertyGroup.Version | Where-Object { $_ }
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- Write-Host "Main version: $version"
+ Write-Host "Main version: $version (from $path)"
- name: Compare versions
env:
@@ -42,7 +50,7 @@ jobs:
echo "Main version: $MAIN_VERSION"
echo "PR version: $PR_VERSION"
if [ "$PR_VERSION" == "$MAIN_VERSION" ]; then
- echo "::error::Version in PlanViewer.App.csproj ($PR_VERSION) has not changed from main. Bump the version before merging to main."
+ echo "::error::Version ($PR_VERSION) has not changed from main. Bump the version before merging to main."
exit 1
fi
echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 19e94f2..b356ba7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,37 +5,42 @@ on:
branches: [main]
pull_request:
branches: [main, dev]
+ paths-ignore:
+ - '**.md'
+ - 'LICENSE'
+ - '.gitattributes'
+ - '.gitignore'
+ - 'CITATION.cff'
+ - 'llms.txt'
+ - '.github/ISSUE_TEMPLATE/**'
+ - 'docs/**'
+ - 'screenshots/**'
+ - 'server/**'
+ - 'src/PlanViewer.Ssms/**'
+ - 'src/PlanViewer.Ssms.Installer/**'
jobs:
build-and-test:
- runs-on: windows-latest
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
+ - name: Setup .NET 10.0
+ uses: actions/setup-dotnet@v5
with:
- dotnet-version: 8.0.x
+ dotnet-version: 10.0.x
+ cache: true
+ cache-dependency-path: '**/*.csproj'
- name: Install WASM workload
run: dotnet workload install wasm-tools
- - name: Restore dependencies
- run: |
- dotnet restore src/PlanViewer.Core/PlanViewer.Core.csproj
- dotnet restore src/PlanViewer.App/PlanViewer.App.csproj
- dotnet restore src/PlanViewer.Cli/PlanViewer.Cli.csproj
- dotnet restore src/PlanViewer.Web/PlanViewer.Web.csproj
- dotnet restore tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
-
- - name: Build all projects
- run: |
- dotnet build src/PlanViewer.Core/PlanViewer.Core.csproj -c Release --no-restore
- dotnet build src/PlanViewer.App/PlanViewer.App.csproj -c Release --no-restore
- dotnet build src/PlanViewer.Cli/PlanViewer.Cli.csproj -c Release --no-restore
- dotnet build src/PlanViewer.Web/PlanViewer.Web.csproj -c Release --no-restore
- dotnet build tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-restore
+ - name: Restore solution
+ run: dotnet restore PlanViewer.sln
+
+ - name: Build solution
+ run: dotnet build PlanViewer.sln -c Release --no-restore
- name: Run tests
run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index bbaed60..2c3ce82 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -6,7 +6,7 @@ on:
paths:
- 'src/PlanViewer.Core/**'
- 'src/PlanViewer.Web/**'
- - 'src/PlanViewer.App/PlanViewer.App.csproj'
+ - 'src/Directory.Build.props'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
@@ -27,27 +27,16 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
+ - name: Setup .NET 10.0
+ uses: actions/setup-dotnet@v5
with:
- dotnet-version: 8.0.x
+ dotnet-version: 10.0.x
- name: Install WASM workload
run: dotnet workload install wasm-tools
- - name: Sync web version with app version
- shell: pwsh
- run: |
- $appVersion = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- Write-Host "App version: $appVersion"
- $webCsproj = 'src/PlanViewer.Web/PlanViewer.Web.csproj'
- $content = Get-Content $webCsproj -Raw
- $content = [regex]::Replace($content, '[^<]+', "$appVersion")
- Set-Content -Path $webCsproj -Value $content -NoNewline
- Write-Host "Synced web csproj to $appVersion"
-
- name: Publish Blazor WASM
run: dotnet publish src/PlanViewer.Web/PlanViewer.Web.csproj -c Release -o publish
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index c035742..1580355 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -15,7 +15,7 @@ jobs:
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: dev
fetch-depth: 0
@@ -38,20 +38,20 @@ jobs:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
ref: dev
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
+ - name: Setup .NET 10.0
+ uses: actions/setup-dotnet@v5
with:
- dotnet-version: 8.0.x
+ dotnet-version: 10.0.x
- name: Set nightly version
id: version
shell: pwsh
run: |
- $base = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ $base = ([xml](Get-Content src/Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ }
$date = Get-Date -Format "yyyyMMdd"
$nightly = "$base-nightly.$date"
echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 2e269f5..9fbfa31 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -16,13 +16,13 @@ jobs:
runs-on: windows-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Get version
id: version
shell: pwsh
run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ $version = ([xml](Get-Content src/Directory.Build.props)).Project.PropertyGroup.Version | Where-Object { $_ }
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- name: Check if release already exists
@@ -45,10 +45,10 @@ jobs:
run: |
gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
+ - name: Setup .NET 10.0
+ uses: actions/setup-dotnet@v5
with:
- dotnet-version: 8.0.x
+ dotnet-version: 10.0.x
- name: Build and test
run: |
@@ -78,14 +78,14 @@ jobs:
- name: Upload Windows build for signing
if: steps.signing.outputs.ENABLED == 'true'
id: upload-unsigned
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: App-unsigned
path: publish/win-x64/
- name: Sign Windows build
if: steps.signing.outputs.ENABLED == 'true'
- uses: signpath/github-action-submit-signing-request@v1
+ uses: signpath/github-action-submit-signing-request@v2
with:
api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0'
diff --git a/.gitignore b/.gitignore
index e0ded08..3bcca1d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Project-specific
CLAUDE.md
examples/
+tools/
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index c21d7b4..83652dd 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -12,7 +12,7 @@ Thank you for your interest in contributing to Performance Studio! This guide wi
### Prerequisites
-- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
+- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- Git
### Build and Test
diff --git a/README.md b/README.md
index cbfed00..8b9574d 100644
--- a/README.md
+++ b/README.md
@@ -90,7 +90,7 @@ Each warning includes severity (Info, Warning, or Critical), the operator node I
## Prerequisites
-- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) (required to build and run)
+- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (required to build and run)
- SQL Server instance (optional — only needed for live plan capture; file analysis works without one)
- Docker (optional — macOS/Linux users can run SQL Server locally via Docker)
diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj
index 5e2bf89..a246dc0 100644
--- a/server/PlanShare/PlanShare.csproj
+++ b/server/PlanShare/PlanShare.csproj
@@ -1,7 +1,7 @@
- net8.0
+ net10.0
enable
enable
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
new file mode 100644
index 0000000..837fc13
--- /dev/null
+++ b/src/Directory.Build.props
@@ -0,0 +1,35 @@
+
+
+
+ 1.11.0
+ Erik Darling
+ Darling Data LLC
+ Performance Studio
+ Copyright (c) 2026 Erik Darling, Darling Data LLC
+
+
+
+
+ $(NoWarn);AVLN3001
+
+
diff --git a/src/PlanViewer.App/AboutWindow.axaml b/src/PlanViewer.App/AboutWindow.axaml
index 17f422d..1aa0790 100644
--- a/src/PlanViewer.App/AboutWindow.axaml
+++ b/src/PlanViewer.App/AboutWindow.axaml
@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PlanViewer.App.AboutWindow"
Title="About Performance Studio"
- Width="450" Height="500"
+ Width="480" Height="640"
CanResize="False"
WindowStartupLocation="CenterOwner"
Icon="avares://PlanViewer.App/EDD.ico"
@@ -47,14 +47,22 @@
-
+
+
@@ -80,6 +88,42 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs
index 699a2b7..1432a2e 100644
--- a/src/PlanViewer.App/AboutWindow.axaml.cs
+++ b/src/PlanViewer.App/AboutWindow.axaml.cs
@@ -6,10 +6,8 @@
using System;
using System.Diagnostics;
-using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
-using System.Text.Json;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
@@ -25,6 +23,7 @@ public partial class AboutWindow : Window
private const string GitHubUrl = "https://github.com/erikdarlingdata/PerformanceStudio";
private const string IssuesUrl = "https://github.com/erikdarlingdata/PerformanceStudio/issues";
private const string DarlingDataUrl = "https://www.erikdarling.com";
+ private const string ReleasesUrl = "https://github.com/erikdarlingdata/PerformanceStudio/releases/latest";
public AboutWindow()
{
@@ -34,29 +33,75 @@ public AboutWindow()
VersionText.Text = $"Version {version.Major}.{version.Minor}.{version.Build}";
// Load current MCP settings
- var settings = McpSettings.Load();
- McpEnabledCheckBox.IsChecked = settings.Enabled;
- McpPortInput.Text = settings.Port.ToString();
+ var mcp = McpSettings.Load();
+ McpEnabledCheckBox.IsChecked = mcp.Enabled;
+ McpPortInput.Text = mcp.Port.ToString();
// Save on change
McpEnabledCheckBox.IsCheckedChanged += (_, _) => SaveMcpSettings();
McpPortInput.LostFocus += (_, _) => SaveMcpSettings();
+
+ // Load proxy settings. The password is intentionally NOT round-tripped
+ // through the UI — TextBox.PasswordChar only masks the glyph, the cleartext
+ // still lives in the visual/accessibility tree. We surface "(saved — leave
+ // blank to keep)" via the watermark instead, and only update the credential
+ // when the user types a new value.
+ var proxy = ProxySettings.Load();
+ _hasStoredProxyPassword = !string.IsNullOrEmpty(proxy.Password);
+ ProxySystemRadio.IsChecked = proxy.Mode == ProxyMode.System;
+ ProxyManualRadio.IsChecked = proxy.Mode == ProxyMode.Manual;
+ ProxyAddressInput.Text = proxy.Address;
+ ProxyUsernameInput.Text = proxy.Username;
+ ProxyPasswordInput.Watermark = _hasStoredProxyPassword
+ ? "(saved — leave blank to keep)"
+ : "";
+ ProxyManualPanel.IsVisible = proxy.Mode == ProxyMode.Manual;
+
+ // Both radios fire IsCheckedChanged on every selection (one going false,
+ // one going true). Only the now-checked one should drive the save —
+ // otherwise the credential write races itself.
+ void OnProxyRadioChanged(object? sender, RoutedEventArgs _)
+ {
+ if (sender is RadioButton rb && rb.IsChecked == true)
+ {
+ ProxyManualPanel.IsVisible = ProxyManualRadio.IsChecked == true;
+ SaveProxySettings();
+ }
+ }
+ ProxySystemRadio.IsCheckedChanged += OnProxyRadioChanged;
+ ProxyManualRadio.IsCheckedChanged += OnProxyRadioChanged;
+ ProxyAddressInput.LostFocus += (_, _) => SaveProxySettings();
+ ProxyUsernameInput.LostFocus += (_, _) => SaveProxySettings();
+ ProxyPasswordInput.LostFocus += (_, _) => SaveProxySettings();
}
+ private bool _hasStoredProxyPassword;
+
private void SaveMcpSettings()
{
- var settingsDir = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".planview");
- var settingsFile = Path.Combine(settingsDir, "settings.json");
-
- var json = JsonSerializer.Serialize(new
+ Services.SettingsFile.Update(o =>
{
- mcp_enabled = McpEnabledCheckBox.IsChecked == true,
- mcp_port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152
- }, new JsonSerializerOptions { WriteIndented = true });
+ o["mcp_enabled"] = McpEnabledCheckBox.IsChecked == true;
+ o["mcp_port"] = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
+ });
+ }
- Directory.CreateDirectory(settingsDir);
- Services.AtomicFile.WriteAllText(settingsFile, json);
+ private void SaveProxySettings()
+ {
+ var typedPassword = ProxyPasswordInput.Text ?? "";
+ var settings = new ProxySettings
+ {
+ Mode = ProxyManualRadio.IsChecked == true ? ProxyMode.Manual : ProxyMode.System,
+ Address = ProxyAddressInput.Text ?? "",
+ Username = ProxyUsernameInput.Text ?? "",
+ Password = typedPassword
+ };
+ // Empty textbox + an existing stored password means "keep what's there".
+ // Save() signals "leave the credential alone" with TouchCredential=false.
+ settings.TouchCredential = !(typedPassword.Length == 0 && _hasStoredProxyPassword);
+ settings.Save();
+ if (settings.TouchCredential)
+ _hasStoredProxyPassword = !string.IsNullOrEmpty(typedPassword);
}
private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);
@@ -83,15 +128,20 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
CheckUpdateButton.IsEnabled = false;
UpdateStatusText.Text = "Checking...";
UpdateLink.IsVisible = false;
+ ReleasesPageLink.IsVisible = false;
- // Try Velopack first (Windows only, supports download + apply)
+ // Try Velopack first (Windows only, supports download + apply). The custom
+ // downloader routes through the user's proxy + Windows credentials so this
+ // works on corporate networks (issue #314).
+ string? velopackError = null;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try
{
_velopackMgr = new UpdateManager(
new Velopack.Sources.GithubSource(
- "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
+ "https://github.com/erikdarlingdata/PerformanceStudio",
+ null, false, new ProxyAwareDownloader()));
_velopackUpdate = await _velopackMgr.CheckForUpdatesAsync();
if (_velopackUpdate != null)
@@ -103,9 +153,12 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
return;
}
}
- catch
+ catch (Exception ex)
{
- // Velopack packages may not exist yet — fall through
+ // Velopack packages may not exist yet — fall through to API check.
+ // Hold onto the message in case the API check also fails (issue #314
+ // is exactly the case where the auth error here is the useful one).
+ velopackError = ex.Message;
}
}
@@ -115,7 +168,10 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
if (result.Error != null)
{
- UpdateStatusText.Text = $"Error: {result.Error}";
+ UpdateStatusText.Text = velopackError != null && velopackError != result.Error
+ ? $"Error: {result.Error} (installer check also failed: {velopackError})"
+ : $"Error: {result.Error}";
+ ReleasesPageLink.IsVisible = true;
}
else if (result.UpdateAvailable)
{
@@ -132,6 +188,8 @@ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
CheckUpdateButton.IsEnabled = true;
}
+ private void ReleasesPageLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(ReleasesUrl);
+
private bool _updateDownloaded;
private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
@@ -201,6 +259,7 @@ private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
{
UpdateStatusText.Text = $"Update failed: {ex.Message}";
UpdateLink.IsVisible = false;
+ ReleasesPageLink.IsVisible = true;
}
return;
}
diff --git a/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs b/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs
new file mode 100644
index 0000000..5080f40
--- /dev/null
+++ b/src/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cs
@@ -0,0 +1,17 @@
+using System;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Controls;
+
+///
+/// Event args for when a plan is loaded from the history context menu.
+///
+public class HistoryPlanLoadEventArgs : EventArgs
+{
+ public QueryStorePlan Plan { get; }
+
+ public HistoryPlanLoadEventArgs(QueryStorePlan plan)
+ {
+ Plan = plan;
+ }
+}
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
}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs
new file mode 100644
index 0000000..f5c96f1
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private AnalysisResult? GetCurrentAnalysis()
+ {
+ return GetCurrentAnalysisWithViewer().Analysis;
+ }
+
+ private void HumanAdvice_Click(object? sender, RoutedEventArgs e)
+ {
+ var (analysis, viewer) = GetCurrentAnalysisWithViewer();
+ if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }
+
+ var text = TextFormatter.Format(analysis);
+ ShowAdviceWindow("Advice for Humans", text, analysis, viewer);
+ }
+
+ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
+ {
+ var analysis = GetCurrentAnalysis();
+ if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }
+
+ var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true });
+ ShowAdviceWindow("Advice for Robots", json);
+ }
+
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
+ {
+ AdviceWindowHelper.Show(GetParentWindow(), title, content, analysis, sourceViewer);
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
new file mode 100644
index 0000000..85d4b71
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private async void Connect_Click(object? sender, RoutedEventArgs e)
+ {
+ await ShowConnectionDialogAsync();
+ }
+
+ private async Task ShowConnectionDialogAsync()
+ {
+ var dialog = new ConnectionDialog(_credentialService, _connectionStore);
+ var result = await dialog.ShowDialog(GetParentWindow());
+
+ if (result == true && dialog.ResultConnection != null)
+ {
+ _serverConnection = dialog.ResultConnection;
+ _selectedDatabase = dialog.ResultDatabase;
+ _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase);
+
+ ServerLabel.Text = _serverConnection.ApplicationIntentReadOnly
+ ? $"{_serverConnection.ServerName} (Read-only)"
+ : _serverConnection.ServerName;
+ ServerLabel.Foreground = Brushes.LimeGreen;
+ ConnectButton.Content = "Reconnect";
+
+ await PopulateDatabases();
+ await FetchServerMetadataAsync();
+ await FetchServerUtcOffset();
+
+ if (_selectedDatabase != null)
+ {
+ for (int i = 0; i < DatabaseBox.Items.Count; i++)
+ {
+ if (DatabaseBox.Items[i]?.ToString() == _selectedDatabase)
+ {
+ DatabaseBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+
+ await FetchDatabaseMetadataAsync();
+
+ ExecuteButton.IsEnabled = true;
+ ExecuteEstButton.IsEnabled = true;
+ }
+ }
+
+ private async Task PopulateDatabases()
+ {
+ if (_serverConnection == null) return;
+
+ try
+ {
+ var connStr = _serverConnection.GetConnectionString(_credentialService, "master");
+ await using var conn = new SqlConnection(connStr);
+ await conn.OpenAsync();
+
+ var databases = new List();
+ using var cmd = new SqlCommand(
+ "SELECT name FROM sys.databases WHERE state_desc = 'ONLINE' ORDER BY name", conn);
+ using var reader = await cmd.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ databases.Add(reader.GetString(0));
+
+ DatabaseBox.ItemsSource = databases;
+ DatabaseBox.IsEnabled = true;
+ }
+ catch
+ {
+ DatabaseBox.IsEnabled = false;
+ }
+ }
+
+ private async void Database_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_serverConnection == null || DatabaseBox.SelectedItem == null) return;
+
+ _selectedDatabase = DatabaseBox.SelectedItem.ToString();
+ _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase);
+
+ // Refresh database metadata for the new context
+ await FetchDatabaseMetadataAsync();
+ }
+
+ private async Task FetchServerMetadataAsync()
+ {
+ if (_connectionString == null) return;
+ try
+ {
+ _serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(
+ _connectionString, IsAzureConnection);
+ }
+ catch
+ {
+ // Non-fatal — advice will just lack server context
+ _serverMetadata = null;
+ }
+ }
+
+ private async Task FetchServerUtcOffset()
+ {
+ if (_connectionString == null) return;
+ try
+ {
+ await using var conn = new SqlConnection(_connectionString);
+ await conn.OpenAsync();
+ await using var cmd = new SqlCommand(
+ "SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn);
+ var offset = await cmd.ExecuteScalarAsync();
+ if (offset is int mins)
+ PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins;
+ }
+ catch { }
+ }
+
+ private async Task FetchDatabaseMetadataAsync()
+ {
+ if (_connectionString == null || _serverMetadata == null) return;
+ try
+ {
+ _serverMetadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync(
+ _connectionString, _serverMetadata.SupportsScopedConfigs);
+ }
+ catch
+ {
+ // Non-fatal — advice will just lack database context
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
new file mode 100644
index 0000000..883292d
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
@@ -0,0 +1,333 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private void SetupSyntaxHighlighting()
+ {
+ var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
+ _textMateInstallation = QueryEditor.InstallTextMate(registryOptions);
+ _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
+ }
+
+ private void SetupEditorContextMenu()
+ {
+ var cutItem = new MenuItem { Header = "Cut" };
+ cutItem.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ var selection = QueryEditor.TextArea.Selection;
+ if (selection.IsEmpty) return;
+ var text = selection.GetText();
+ await clipboard.SetTextAsync(text);
+ selection.ReplaceSelectionWithText("");
+ };
+
+ var copyItem = new MenuItem { Header = "Copy" };
+ copyItem.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ var selection = QueryEditor.TextArea.Selection;
+ if (selection.IsEmpty) return;
+ await clipboard.SetTextAsync(selection.GetText());
+ };
+
+ var pasteItem = new MenuItem { Header = "Paste" };
+ pasteItem.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ var text = await clipboard.TryGetTextAsync();
+ if (string.IsNullOrEmpty(text)) return;
+ QueryEditor.TextArea.PerformTextInput(text);
+ };
+
+ var selectAllItem = new MenuItem { Header = "Select All" };
+ selectAllItem.Click += (_, _) =>
+ {
+ QueryEditor.SelectAll();
+ };
+
+ var executeFromCursorItem = new MenuItem { Header = "Execute from Cursor" };
+ executeFromCursorItem.Click += async (_, _) =>
+ {
+ var text = GetTextFromCursor();
+ if (!string.IsNullOrWhiteSpace(text))
+ await CaptureAndShowPlan(estimated: false, queryTextOverride: text);
+ };
+
+ var executeCurrentBatchItem = new MenuItem { Header = "Execute Current Batch" };
+ executeCurrentBatchItem.Click += async (_, _) =>
+ {
+ var text = GetCurrentBatch();
+ if (!string.IsNullOrWhiteSpace(text))
+ await CaptureAndShowPlan(estimated: false, queryTextOverride: text);
+ };
+
+ // Schema lookup items
+ _schemaSeparator = new Separator();
+
+ _showIndexesItem = new MenuItem { Header = "Show Indexes" };
+ _showIndexesItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.Indexes);
+
+ _showTableDefItem = new MenuItem { Header = "Show Table Definition" };
+ _showTableDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.TableDefinition);
+
+ _showObjectDefItem = new MenuItem { Header = "Show Object Definition" };
+ _showObjectDefItem.Click += async (_, _) => await ShowSchemaInfoAsync(SchemaInfoKind.ObjectDefinition);
+
+ var contextMenu = new ContextMenu
+ {
+ Items =
+ {
+ cutItem, copyItem, pasteItem,
+ new Separator(), selectAllItem,
+ new Separator(), executeFromCursorItem, executeCurrentBatchItem,
+ _schemaSeparator,
+ _showIndexesItem, _showTableDefItem, _showObjectDefItem
+ }
+ };
+
+ contextMenu.Opening += OnContextMenuOpening;
+ QueryEditor.TextArea.ContextMenu = contextMenu;
+
+ // Move caret to right-click position so schema lookup resolves the clicked object
+ QueryEditor.TextArea.PointerPressed += OnEditorPointerPressed;
+ }
+
+ private void OnEditorPointerPressed(object? sender, Avalonia.Input.PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(QueryEditor.TextArea).Properties.IsRightButtonPressed)
+ return;
+
+ var pos = QueryEditor.GetPositionFromPoint(e.GetPosition(QueryEditor));
+ if (pos == null) return;
+
+ QueryEditor.TextArea.Caret.Position = pos.Value;
+ }
+
+ private void OnContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ // Resolve what object is under the cursor
+ var sqlText = QueryEditor.Text;
+ var offset = QueryEditor.CaretOffset;
+ _contextMenuObject = SqlObjectResolver.Resolve(sqlText, offset);
+
+ var hasConnection = _connectionString != null;
+ var hasObject = _contextMenuObject != null && hasConnection;
+
+ _schemaSeparator!.IsVisible = hasObject;
+ _showIndexesItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown;
+ _showTableDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Table or SqlObjectKind.Unknown;
+ _showObjectDefItem!.IsVisible = hasObject && _contextMenuObject!.Kind is SqlObjectKind.Function or SqlObjectKind.Procedure;
+
+ // Update headers to show the object name
+ if (hasObject)
+ {
+ var name = _contextMenuObject!.FullName;
+ _showIndexesItem.Header = $"Show Indexes — {name}";
+ _showTableDefItem.Header = $"Show Table Definition — {name}";
+ _showObjectDefItem.Header = $"Show Object Definition — {name}";
+ }
+ }
+
+ private void OnOpenInEditorRequested(object? sender, string queryText)
+ {
+ QueryEditor.Text = queryText;
+ SubTabControl.SelectedIndex = 0; // Switch to the editor tab
+ QueryEditor.Focus();
+ }
+
+ private void OnKeyDown(object? sender, KeyEventArgs e)
+ {
+ // F5 or Ctrl+E → Execute (actual plan)
+ if ((e.Key == Key.F5 || (e.Key == Key.E && e.KeyModifiers == KeyModifiers.Control))
+ && ExecuteButton.IsEnabled)
+ {
+ Execute_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ }
+ // Ctrl+L → Estimated plan
+ else if (e.Key == Key.L && e.KeyModifiers == KeyModifiers.Control
+ && ExecuteEstButton.IsEnabled)
+ {
+ ExecuteEstimated_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ }
+ // Escape → Cancel running query
+ else if (e.Key == Key.Escape && _executionCts != null && !_executionCts.IsCancellationRequested)
+ {
+ _executionCts.Cancel();
+ e.Handled = true;
+ }
+ }
+
+ private void OnEditorPointerWheel(object? sender, PointerWheelEventArgs e)
+ {
+ if (e.KeyModifiers != KeyModifiers.Control) return;
+
+ var delta = e.Delta.Y > 0 ? 1 : -1;
+ var newSize = QueryEditor.FontSize + delta;
+ QueryEditor.FontSize = Math.Clamp(newSize, 7, 52);
+ SyncZoomDropdown();
+ e.Handled = true;
+ }
+
+ private void Zoom_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (ZoomBox.SelectedItem is ComboBoxItem item && item.Tag is string tagStr
+ && int.TryParse(tagStr, out var size))
+ {
+ QueryEditor.FontSize = size;
+ }
+ }
+
+ private void SyncZoomDropdown()
+ {
+ // Find the closest matching zoom level
+ var fontSize = (int)Math.Round(QueryEditor.FontSize);
+ int bestIdx = 2; // default 100%
+ int bestDist = int.MaxValue;
+
+ for (int i = 0; i < ZoomBox.Items.Count; i++)
+ {
+ if (ZoomBox.Items[i] is ComboBoxItem item && item.Tag is string tagStr
+ && int.TryParse(tagStr, out var size))
+ {
+ var dist = Math.Abs(size - fontSize);
+ if (dist < bestDist) { bestDist = dist; bestIdx = i; }
+ }
+ }
+
+ ZoomBox.SelectionChanged -= Zoom_SelectionChanged;
+ ZoomBox.SelectedIndex = bestIdx;
+ ZoomBox.SelectionChanged += Zoom_SelectionChanged;
+ }
+
+ private void OnTextEntering(object? sender, TextInputEventArgs e)
+ {
+ if (_completionWindow == null || string.IsNullOrEmpty(e.Text)) return;
+
+ // If the user types a non-identifier character, let the completion window
+ // decide whether to commit (it handles Tab/Enter/Space automatically)
+ var ch = e.Text[0];
+ if (!char.IsLetterOrDigit(ch) && ch != '_')
+ {
+ _completionWindow.CompletionList.RequestInsertion(e);
+ }
+ }
+
+ private void OnTextEntered(object? sender, TextInputEventArgs e)
+ {
+ if (_completionWindow != null) return;
+ if (string.IsNullOrEmpty(e.Text) || !char.IsLetter(e.Text[0])) return;
+
+ var (prefix, wordStart) = GetWordBeforeCaret();
+ if (prefix.Length < 2) return;
+
+ var matches = SqlKeywords.All
+ .Where(k => k.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .ToArray();
+
+ if (matches.Length == 0) return;
+
+ _completionWindow = new CompletionWindow(QueryEditor.TextArea);
+ _completionWindow.StartOffset = wordStart;
+ _completionWindow.Closed += (_, _) => _completionWindow = null;
+
+ foreach (var kw in matches)
+ _completionWindow.CompletionList.CompletionData.Add(new SqlCompletionData(kw));
+
+ _completionWindow.Show();
+ }
+
+ private string? GetSelectedTextOrNull()
+ {
+ var selection = QueryEditor.TextArea.Selection;
+ if (selection.IsEmpty) return null;
+ return selection.GetText();
+ }
+
+ private string GetTextFromCursor()
+ {
+ var doc = QueryEditor.Document;
+ var offset = QueryEditor.CaretOffset;
+ return doc.GetText(offset, doc.TextLength - offset);
+ }
+
+ private string? GetCurrentBatch()
+ {
+ var doc = QueryEditor.Document;
+ var caretOffset = QueryEditor.CaretOffset;
+ var text = doc.Text;
+ var goPattern = new Regex(@"^\s*GO\s*$", RegexOptions.IgnoreCase | RegexOptions.Multiline);
+ var matches = goPattern.Matches(text);
+
+ int batchStart = 0;
+ int batchEnd = text.Length;
+
+ foreach (Match m in matches)
+ {
+ if (m.Index + m.Length <= caretOffset)
+ {
+ batchStart = m.Index + m.Length;
+ }
+ else if (m.Index >= caretOffset)
+ {
+ batchEnd = m.Index;
+ break;
+ }
+ }
+
+ return text[batchStart..batchEnd].Trim();
+ }
+
+ private void SetStatus(string text, bool autoClear = true)
+ {
+ var old = _statusClearCts;
+ _statusClearCts = null;
+ old?.Cancel();
+ old?.Dispose();
+
+ StatusText.Text = text;
+
+ if (autoClear && !string.IsNullOrEmpty(text))
+ {
+ var cts = new CancellationTokenSource();
+ _statusClearCts = cts;
+ _ = Task.Delay(3000, cts.Token).ContinueWith(_ =>
+ {
+ Avalonia.Threading.Dispatcher.UIThread.Post(() => StatusText.Text = "");
+ }, TaskContinuationOptions.OnlyOnRanToCompletion);
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs
new file mode 100644
index 0000000..45f0bd7
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs
@@ -0,0 +1,516 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private async void Execute_Click(object? sender, RoutedEventArgs e)
+ {
+ await CaptureAndShowPlan(estimated: false);
+ }
+
+ private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e)
+ {
+ await CaptureAndShowPlan(estimated: true);
+ }
+
+ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null)
+ {
+ if (_serverConnection == null || _selectedDatabase == null)
+ {
+ SetStatus("Connect to a server first", autoClear: false);
+ return;
+ }
+
+ // Always rebuild connection string from current database selection
+ // to guarantee the picker state is reflected at execution time
+ _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase);
+
+ var queryText = queryTextOverride?.Trim()
+ ?? GetSelectedTextOrNull()?.Trim()
+ ?? QueryEditor.Text?.Trim();
+ if (string.IsNullOrEmpty(queryText))
+ {
+ SetStatus("Enter a query", autoClear: false);
+ return;
+ }
+
+ _executionCts?.Cancel();
+ _executionCts?.Dispose();
+ _executionCts = new CancellationTokenSource();
+ var ct = _executionCts.Token;
+
+ var planType = estimated ? "Estimated" : "Actual";
+
+ // Create loading tab with cancel button
+ var loadingPanel = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Width = 300
+ };
+
+ var progressBar = new ProgressBar
+ {
+ IsIndeterminate = true,
+ Height = 4,
+ Margin = new Avalonia.Thickness(0, 0, 0, 12)
+ };
+
+ var statusLabel = new TextBlock
+ {
+ Text = $"Capturing {planType.ToLower()} plan...",
+ FontSize = 14,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+
+ var cancelBtn = new Button
+ {
+ Content = "\u25A0 Cancel",
+ Height = 32,
+ Width = 120,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 13,
+ Margin = new Avalonia.Thickness(0, 16, 0, 0),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+ cancelBtn.Click += (_, _) => _executionCts?.Cancel();
+
+ loadingPanel.Children.Add(progressBar);
+ loadingPanel.Children.Add(statusLabel);
+ loadingPanel.Children.Add(cancelBtn);
+
+ var loadingContainer = new Grid
+ {
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Focusable = true,
+ Children = { loadingPanel }
+ };
+ loadingContainer.KeyDown += (_, ke) =>
+ {
+ if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; }
+ };
+
+ // Add loading tab and switch to it
+ _planCounter++;
+ var tabLabel = estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}";
+ var headerText = new TextBlock
+ {
+ Text = tabLabel,
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 12
+ };
+ var closeBtn = new Button
+ {
+ Content = "\u2715",
+ MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
+ Padding = new Avalonia.Thickness(0),
+ FontSize = 11,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ var header = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children = { headerText, closeBtn }
+ };
+ var loadingTab = new TabItem { Header = header, Content = loadingContainer };
+ closeBtn.Tag = loadingTab;
+ closeBtn.Click += ClosePlanTab_Click;
+
+ SubTabControl.Items.Add(loadingTab);
+ SubTabControl.SelectedItem = loadingTab;
+ loadingContainer.Focus();
+
+ try
+ {
+ var sw = Stopwatch.StartNew();
+ string? planXml;
+
+ var isAzure = _serverConnection!.ServerName.Contains(".database.windows.net",
+ StringComparison.OrdinalIgnoreCase) ||
+ _serverConnection.ServerName.Contains(".database.azure.com",
+ StringComparison.OrdinalIgnoreCase);
+
+ if (estimated)
+ {
+ planXml = await EstimatedPlanExecutor.GetEstimatedPlanAsync(
+ _connectionString, _selectedDatabase, queryText, timeoutSeconds: 0, ct);
+ }
+ else
+ {
+ planXml = await ActualPlanExecutor.ExecuteForActualPlanAsync(
+ _connectionString, _selectedDatabase, queryText,
+ planXml: null, isolationLevel: null,
+ isAzureSqlDb: isAzure, timeoutSeconds: 0, ct);
+ }
+
+ sw.Stop();
+
+ if (string.IsNullOrEmpty(planXml))
+ {
+ statusLabel.Text = $"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)";
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ return;
+ }
+
+ // Replace loading content with the plan viewer
+ SetStatus($"{planType} plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
+ var viewer = new PlanViewerControl();
+ viewer.Metadata = _serverMetadata;
+ viewer.ConnectionString = _connectionString;
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
+ viewer.OpenInEditorRequested += OnOpenInEditorRequested;
+ viewer.LoadPlan(planXml, tabLabel, queryText);
+ loadingTab.Content = viewer;
+ HumanAdviceButton.IsEnabled = true;
+ RobotAdviceButton.IsEnabled = true;
+ }
+ catch (OperationCanceledException)
+ {
+ SetStatus("Cancelled");
+ SubTabControl.Items.Remove(loadingTab);
+ }
+ catch (SqlException ex)
+ {
+ statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ }
+ catch (Exception ex)
+ {
+ statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ }
+ }
+
+ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e)
+ {
+ var viewer = GetSelectedPlanViewer();
+ if (viewer == null)
+ {
+ SetStatus("Select a plan tab first");
+ return;
+ }
+
+ if (_connectionString == null || _selectedDatabase == null)
+ {
+ SetStatus("Connect to a server first", autoClear: false);
+ return;
+ }
+
+ var queryText = viewer.QueryText ?? "";
+ var planXml = viewer.RawXml;
+
+ if (string.IsNullOrEmpty(queryText))
+ {
+ SetStatus("No query text available for this plan");
+ return;
+ }
+
+ /* Show confirmation dialog */
+ var confirmed = await ShowConfirmationDialog(
+ "Get Actual Plan",
+ "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n\nAll data results will be discarded.\n\nContinue?");
+
+ if (!confirmed) return;
+
+ _executionCts?.Cancel();
+ _executionCts?.Dispose();
+ _executionCts = new CancellationTokenSource();
+ var ct = _executionCts.Token;
+
+ // Create loading tab with cancel button
+ var loadingPanel = new StackPanel
+ {
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Width = 300
+ };
+
+ var progressBar = new ProgressBar
+ {
+ IsIndeterminate = true,
+ Height = 4,
+ Margin = new Avalonia.Thickness(0, 0, 0, 12)
+ };
+
+ var statusLabel = new TextBlock
+ {
+ Text = "Capturing actual plan...",
+ FontSize = 14,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ HorizontalAlignment = HorizontalAlignment.Center
+ };
+
+ var cancelBtn = new Button
+ {
+ Content = "\u25A0 Cancel",
+ Height = 32,
+ Width = 120,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 13,
+ Margin = new Avalonia.Thickness(0, 16, 0, 0),
+ HorizontalAlignment = HorizontalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+ cancelBtn.Click += (_, _) => _executionCts?.Cancel();
+
+ loadingPanel.Children.Add(progressBar);
+ loadingPanel.Children.Add(statusLabel);
+ loadingPanel.Children.Add(cancelBtn);
+
+ var loadingContainer = new Grid
+ {
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Focusable = true,
+ Children = { loadingPanel }
+ };
+ loadingContainer.KeyDown += (_, ke) =>
+ {
+ if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; }
+ };
+
+ _planCounter++;
+ var tabLabel = $"Plan {_planCounter}";
+ var headerText = new TextBlock
+ {
+ Text = tabLabel,
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 12
+ };
+ var closeBtn = new Button
+ {
+ Content = "\u2715",
+ MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
+ Padding = new Avalonia.Thickness(0),
+ FontSize = 11,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+ var header = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children = { headerText, closeBtn }
+ };
+ var loadingTab = new TabItem { Header = header, Content = loadingContainer };
+ closeBtn.Tag = loadingTab;
+ closeBtn.Click += ClosePlanTab_Click;
+
+ SubTabControl.Items.Add(loadingTab);
+ SubTabControl.SelectedItem = loadingTab;
+ loadingContainer.Focus();
+
+ try
+ {
+ var sw = Stopwatch.StartNew();
+ var isAzure = IsAzureConnection;
+
+ var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync(
+ _connectionString, _selectedDatabase, queryText,
+ planXml, isolationLevel: null,
+ isAzureSqlDb: isAzure, timeoutSeconds: 0, ct);
+
+ sw.Stop();
+
+ if (string.IsNullOrEmpty(actualPlanXml))
+ {
+ statusLabel.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)";
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ return;
+ }
+
+ SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)");
+ var actualViewer = new PlanViewerControl();
+ actualViewer.Metadata = _serverMetadata;
+ actualViewer.ConnectionString = _connectionString;
+ actualViewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ actualViewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
+ actualViewer.OpenInEditorRequested += OnOpenInEditorRequested;
+ actualViewer.LoadPlan(actualPlanXml, tabLabel, queryText);
+ loadingTab.Content = actualViewer;
+ }
+ catch (OperationCanceledException)
+ {
+ SetStatus("Cancelled");
+ SubTabControl.Items.Remove(loadingTab);
+ }
+ catch (SqlException ex)
+ {
+ statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ }
+ catch (Exception ex)
+ {
+ statusLabel.Text = ex.Message.Length > 100 ? ex.Message[..100] + "..." : ex.Message;
+ progressBar.IsVisible = false;
+ cancelBtn.IsVisible = false;
+ }
+ finally
+ {
+ UpdatePlanTabButtonState();
+ }
+ }
+
+ ///
+ /// Shows a modal confirmation dialog and returns true if the user clicked OK.
+ ///
+ private async Task ShowConfirmationDialog(string title, string message)
+ {
+ var result = false;
+
+ var messageText = new TextBlock
+ {
+ Text = message,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 13,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Margin = new Avalonia.Thickness(0, 0, 0, 16)
+ };
+
+ var okBtn = new Button
+ {
+ Content = "OK",
+ Height = 32,
+ Width = 80,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ var cancelBtn = new Button
+ {
+ Content = "Cancel",
+ Height = 32,
+ Width = 80,
+ Padding = new Avalonia.Thickness(16, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0),
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+ buttonPanel.Children.Add(okBtn);
+ buttonPanel.Children.Add(cancelBtn);
+
+ var content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children = { messageText, buttonPanel }
+ };
+
+ var dialog = new Window
+ {
+ Title = title,
+ Width = 420,
+ Height = 200,
+ MinWidth = 420,
+ MinHeight = 200,
+ Icon = GetParentWindow().Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = content,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ okBtn.Click += (_, _) => { result = true; dialog.Close(); };
+ cancelBtn.Click += (_, _) => dialog.Close();
+
+ await dialog.ShowDialog(GetParentWindow());
+ return result;
+ }
+
+ ///
+ /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute.
+ /// Returns null if not found.
+ ///
+ private static string? ExtractDatabaseFromPlanXml(string? planXml)
+ {
+ if (string.IsNullOrEmpty(planXml)) return null;
+
+ try
+ {
+ var doc = XDocument.Parse(planXml);
+ XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan";
+
+ /* Try StmtSimple first — most queries have this */
+ var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault();
+ var dbContext = stmt?.Attribute("DatabaseContext")?.Value;
+
+ if (!string.IsNullOrEmpty(dbContext))
+ {
+ /* DatabaseContext is typically "[dbname]" — strip brackets */
+ return dbContext.Trim('[', ']');
+ }
+ }
+ catch
+ {
+ /* XML parse failure — fall through to null */
+ }
+
+ return null;
+ }
+
+ private Window GetParentWindow()
+ {
+ var parent = this.VisualRoot;
+ return parent as Window ?? throw new InvalidOperationException("No parent window");
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
new file mode 100644
index 0000000..9ead789
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
@@ -0,0 +1,159 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private async void CopyRepro_Click(object? sender, RoutedEventArgs e)
+ {
+ var viewer = GetSelectedPlanViewer();
+ if (viewer == null)
+ {
+ SetStatus("Select a plan tab first");
+ return;
+ }
+
+ var planXml = viewer.RawXml;
+ var queryText = viewer.QueryText ?? "";
+
+ if (string.IsNullOrEmpty(queryText) && string.IsNullOrEmpty(planXml))
+ {
+ SetStatus("No query or plan data available");
+ return;
+ }
+
+ /* Extract database name from plan XML StmtSimple/@DatabaseContext if available,
+ otherwise fall back to the currently selected database */
+ var database = ExtractDatabaseFromPlanXml(planXml) ?? _selectedDatabase;
+
+ var reproScript = ReproScriptBuilder.BuildReproScript(
+ queryText,
+ database,
+ planXml,
+ isolationLevel: null,
+ source: "Performance Studio",
+ isAzureSqlDb: IsAzureConnection);
+
+ try
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.Clipboard != null)
+ {
+ await topLevel.Clipboard.SetTextAsync(reproScript);
+ SetStatus("Repro script copied to clipboard");
+ }
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Clipboard error: {ex.Message}");
+ }
+ }
+
+ private async void Format_Click(object? sender, RoutedEventArgs e)
+ {
+ var sql = QueryEditor.Text;
+ if (string.IsNullOrWhiteSpace(sql))
+ return;
+
+ FormatButton.IsEnabled = false;
+ SetStatus("Formatting...");
+
+ try
+ {
+ var settings = SqlFormatSettingsService.Load(out var loadError);
+ if (loadError != null)
+ SetStatus("Warning: using default format settings (load failed)");
+
+ var (formatted, errors) = await Task.Run(() => SqlFormattingService.Format(sql, settings));
+
+ if (errors != null && errors.Count > 0)
+ {
+ var errorMessages = string.Join("\n", errors.Select(err => $"Line {err.Line}: {err.Message}"));
+ var dialog = new Window
+ {
+ Title = "SQL Format Error",
+ Width = 500,
+ Height = 250,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Icon = GetParentWindow().Icon,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = $"Could not format: {errors.Count} parse error(s)",
+ FontWeight = Avalonia.Media.FontWeight.Bold,
+ FontSize = 14,
+ Margin = new Avalonia.Thickness(0, 0, 0, 10)
+ },
+ new TextBlock
+ {
+ Text = errorMessages,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 12
+ }
+ }
+ }
+ };
+ await dialog.ShowDialog(GetParentWindow());
+ SetStatus($"Format failed: {errors.Count} error(s)");
+ return;
+ }
+
+ var caretOffset = QueryEditor.CaretOffset;
+
+ QueryEditor.Document.BeginUpdate();
+ try
+ {
+ QueryEditor.Document.Replace(0, QueryEditor.Document.TextLength, formatted);
+ }
+ finally
+ {
+ QueryEditor.Document.EndUpdate();
+ }
+
+ QueryEditor.CaretOffset = Math.Min(caretOffset, QueryEditor.Document.TextLength);
+ SetStatus("Formatted");
+ }
+ finally
+ {
+ FormatButton.IsEnabled = true;
+ }
+ }
+
+ private void FormatOptions_Click(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new Dialogs.FormatOptionsWindow();
+ dialog.ShowDialog(GetParentWindow());
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs
new file mode 100644
index 0000000..6965639
--- /dev/null
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs
@@ -0,0 +1,410 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Input.Platform;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using AvaloniaEdit;
+using AvaloniaEdit.CodeCompletion;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+using TextMateSharp.Grammars;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QuerySessionControl : UserControl
+{
+ private void AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null)
+ {
+ _planCounter++;
+ var label = labelOverride ?? (estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}");
+
+ var viewer = new PlanViewerControl();
+ viewer.Metadata = _serverMetadata;
+ viewer.ConnectionString = _connectionString;
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ if (_serverConnection != null)
+ viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase);
+ viewer.OpenInEditorRequested += OnOpenInEditorRequested;
+ viewer.LoadPlan(planXml, label, queryText);
+
+ // Build tab header with close button and right-click rename
+ var headerText = new TextBlock
+ {
+ Text = label,
+ VerticalAlignment = VerticalAlignment.Center,
+ FontSize = 12
+ };
+
+ var closeBtn = new Button
+ {
+ Content = "\u2715",
+ MinWidth = 22,
+ MinHeight = 22,
+ Width = 22,
+ Height = 22,
+ Padding = new Avalonia.Thickness(0),
+ FontSize = 11,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
+ VerticalAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+
+ var header = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children = { headerText, closeBtn }
+ };
+
+ var tab = new TabItem { Header = header, Content = viewer };
+ closeBtn.Tag = tab;
+ closeBtn.Click += ClosePlanTab_Click;
+
+ // Right-click context menu
+ var contextMenu = new ContextMenu
+ {
+ Items =
+ {
+ new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } },
+ new Separator(),
+ new MenuItem { Header = "Close", Tag = tab, InputGesture = new KeyGesture(Key.W, KeyModifiers.Control) },
+ new MenuItem { Header = "Close Other Tabs", Tag = tab },
+ new MenuItem { Header = "Close All Tabs" }
+ }
+ };
+
+ foreach (var item in contextMenu.Items.OfType