diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1cf980d
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,49 @@
+# Auto-detect text files and normalize line endings to LF in the repo.
+# Working-tree EOLs are decided per-pattern below.
+* text=auto
+
+# Source and config — normalize to LF in repo, check out as-is on each platform
+*.cs text eol=lf
+*.csproj text eol=lf
+*.sln text eol=lf -crlf
+*.props text eol=lf
+*.targets text eol=lf
+*.json text eol=lf
+*.xml text eol=lf
+*.config text eol=lf
+*.md text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.editorconfig text eol=lf
+*.gitattributes text eol=lf
+*.gitignore text eol=lf
+
+# Shell scripts must stay LF
+*.sh text eol=lf
+
+# Windows-only scripts must stay CRLF
+*.ps1 text eol=crlf
+*.psm1 text eol=crlf
+*.psd1 text eol=crlf
+*.cmd text eol=crlf
+*.bat text eol=crlf
+
+# Web assets
+*.js text eol=lf
+*.ts text eol=lf
+*.css text eol=lf
+*.html text eol=lf
+*.razor text eol=lf
+*.cshtml text eol=lf
+
+# Binary assets — never touch
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.pdf binary
+*.zip binary
+*.dll binary
+*.exe binary
+*.snk binary
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c75cb87..c3ac5da 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,89 +1,89 @@
-name: Bug Report
-description: Report a problem with SQL Performance Studio
-title: "[BUG] "
-labels: ["bug"]
-
-body:
- - type: dropdown
- id: component
- attributes:
- label: Component
- description: Which part of SQL Performance Studio is affected?
- options:
- - Desktop App (Windows)
- - Desktop App (macOS)
- - Desktop App (Linux)
- - CLI Tool
- - SSMS Extension
- validations:
- required: true
-
- - type: input
- id: version
- attributes:
- label: SQL Performance Studio Version
- description: Check the About dialog or the release you downloaded.
- placeholder: "e.g., 0.7.0"
- validations:
- required: true
-
- - type: input
- id: os-version
- attributes:
- label: Operating System
- description: The OS where you're running the app.
- placeholder: "e.g., Windows 11 23H2, macOS 15.3, Ubuntu 24.04"
- validations:
- required: true
-
- - type: textarea
- id: description
- attributes:
- label: Describe the Bug
- description: A clear description of what the bug is.
- validations:
- required: true
-
- - type: textarea
- id: steps
- attributes:
- label: Steps to Reproduce
- description: How can we reproduce this?
- placeholder: |
- 1. Open a .sqlplan file
- 2. Click on...
- 3. See error
- validations:
- required: true
-
- - type: textarea
- id: expected
- attributes:
- label: Expected Behavior
- description: What you expected to happen.
- validations:
- required: true
-
- - type: textarea
- id: actual
- attributes:
- label: Actual Behavior
- description: What actually happened.
- validations:
- required: true
-
- - type: textarea
- id: plan-file
- attributes:
- label: Plan File
- description: If applicable, attach or paste the .sqlplan file that triggers the issue. You can redact sensitive table/column names.
- validations:
- required: false
-
- - type: textarea
- id: screenshots
- attributes:
- label: Screenshots
- description: If applicable, add screenshots to help explain the problem.
- validations:
- required: false
+name: Bug Report
+description: Report a problem with SQL Performance Studio
+title: "[BUG] "
+labels: ["bug"]
+
+body:
+ - type: dropdown
+ id: component
+ attributes:
+ label: Component
+ description: Which part of SQL Performance Studio is affected?
+ options:
+ - Desktop App (Windows)
+ - Desktop App (macOS)
+ - Desktop App (Linux)
+ - CLI Tool
+ - SSMS Extension
+ validations:
+ required: true
+
+ - type: input
+ id: version
+ attributes:
+ label: SQL Performance Studio Version
+ description: Check the About dialog or the release you downloaded.
+ placeholder: "e.g., 0.7.0"
+ validations:
+ required: true
+
+ - type: input
+ id: os-version
+ attributes:
+ label: Operating System
+ description: The OS where you're running the app.
+ placeholder: "e.g., Windows 11 23H2, macOS 15.3, Ubuntu 24.04"
+ validations:
+ required: true
+
+ - type: textarea
+ id: description
+ attributes:
+ label: Describe the Bug
+ description: A clear description of what the bug is.
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to Reproduce
+ description: How can we reproduce this?
+ placeholder: |
+ 1. Open a .sqlplan file
+ 2. Click on...
+ 3. See error
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ description: What you expected to happen.
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ description: What actually happened.
+ validations:
+ required: true
+
+ - type: textarea
+ id: plan-file
+ attributes:
+ label: Plan File
+ description: If applicable, attach or paste the .sqlplan file that triggers the issue. You can redact sensitive table/column names.
+ validations:
+ required: false
+
+ - type: textarea
+ id: screenshots
+ attributes:
+ label: Screenshots
+ description: If applicable, add screenshots to help explain the problem.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index eb93a7f..0e2121f 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -1,5 +1,5 @@
-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
+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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 1bb2f68..1e3def2 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,50 +1,50 @@
-name: Feature Request
-description: Suggest a new feature or enhancement
-title: "[FEATURE] "
-labels: ["enhancement"]
-
-body:
- - type: checkboxes
- id: component
- attributes:
- label: Which component(s) does this affect?
- options:
- - label: Desktop App
- - label: CLI Tool
- - label: SSMS Extension
- - label: Plan Analysis Rules
- - label: Documentation
- validations:
- required: true
-
- - type: textarea
- id: problem
- attributes:
- label: Problem Statement
- description: Describe the problem you're trying to solve or the limitation you're facing.
- validations:
- required: true
-
- - type: textarea
- id: solution
- attributes:
- label: Proposed Solution
- description: Describe your proposed feature or enhancement.
- validations:
- required: true
-
- - type: textarea
- id: use-case
- attributes:
- label: Use Case
- description: How would you use this feature? Provide a specific example.
- validations:
- required: true
-
- - type: textarea
- id: alternatives
- attributes:
- label: Alternatives Considered
- description: Have you considered any alternative solutions or workarounds?
- validations:
- required: false
+name: Feature Request
+description: Suggest a new feature or enhancement
+title: "[FEATURE] "
+labels: ["enhancement"]
+
+body:
+ - type: checkboxes
+ id: component
+ attributes:
+ label: Which component(s) does this affect?
+ options:
+ - label: Desktop App
+ - label: CLI Tool
+ - label: SSMS Extension
+ - label: Plan Analysis Rules
+ - label: Documentation
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem Statement
+ description: Describe the problem you're trying to solve or the limitation you're facing.
+ validations:
+ required: true
+
+ - type: textarea
+ id: solution
+ attributes:
+ label: Proposed Solution
+ description: Describe your proposed feature or enhancement.
+ validations:
+ required: true
+
+ - type: textarea
+ id: use-case
+ attributes:
+ label: Use Case
+ description: How would you use this feature? Provide a specific example.
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives Considered
+ description: Have you considered any alternative solutions or workarounds?
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index f67926b..40c785a 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,25 +1,25 @@
-## What does this PR do?
-
-A clear description of the change and why it's being made.
-
-## Which component(s) does this affect?
-
-- [ ] Desktop App (PlanViewer.App)
-- [ ] Core Library (PlanViewer.Core)
-- [ ] CLI Tool (PlanViewer.Cli)
-- [ ] SSMS Extension (PlanViewer.Ssms)
-- [ ] Tests
-- [ ] Documentation
-
-## How was this tested?
-
-Describe the testing you've done. Include:
-- Plan files tested (estimated, actual, Query Store, etc.)
-- Platforms tested (Windows, macOS, Linux)
-
-## Checklist
-
-- [ ] I have read the [contributing guide](https://github.com/erikdarlingdata/PerformanceStudio/blob/main/CONTRIBUTING.md)
-- [ ] My code builds with zero warnings (`dotnet build -c Debug`)
-- [ ] All tests pass (`dotnet test`)
-- [ ] I have not introduced any hardcoded credentials or server names
+## What does this PR do?
+
+A clear description of the change and why it's being made.
+
+## Which component(s) does this affect?
+
+- [ ] Desktop App (PlanViewer.App)
+- [ ] Core Library (PlanViewer.Core)
+- [ ] CLI Tool (PlanViewer.Cli)
+- [ ] SSMS Extension (PlanViewer.Ssms)
+- [ ] Tests
+- [ ] Documentation
+
+## How was this tested?
+
+Describe the testing you've done. Include:
+- Plan files tested (estimated, actual, Query Store, etc.)
+- Platforms tested (Windows, macOS, Linux)
+
+## Checklist
+
+- [ ] I have read the [contributing guide](https://github.com/erikdarlingdata/PerformanceStudio/blob/main/CONTRIBUTING.md)
+- [ ] My code builds with zero warnings (`dotnet build -c Debug`)
+- [ ] All tests pass (`dotnet test`)
+- [ ] I have not introduced any hardcoded credentials or server names
diff --git a/.github/workflows/check-pr-branch.yml b/.github/workflows/check-pr-branch.yml
index 88fa6fa..1c14086 100644
--- a/.github/workflows/check-pr-branch.yml
+++ b/.github/workflows/check-pr-branch.yml
@@ -1,21 +1,21 @@
-name: Check pull request target branch
-on:
- pull_request_target:
- types:
- - opened
- - reopened
- - synchronize
- - edited
-jobs:
- check-branches:
- runs-on: ubuntu-latest
- steps:
- - name: Check branches
- env:
- HEAD_REF: ${{ github.head_ref }}
- BASE_REF: ${{ github.base_ref }}
- run: |
- if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then
- echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead."
- exit 1
- fi
+name: Check pull request target branch
+on:
+ pull_request_target:
+ types:
+ - opened
+ - reopened
+ - synchronize
+ - edited
+jobs:
+ check-branches:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check branches
+ env:
+ HEAD_REF: ${{ github.head_ref }}
+ BASE_REF: ${{ github.base_ref }}
+ run: |
+ if [ "$HEAD_REF" != "dev" ] && [ "$BASE_REF" == "main" ]; then
+ echo "::error::Pull requests to main are only allowed from dev. Please target the dev branch instead."
+ exit 1
+ fi
diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml
index 2a5d22a..b48de67 100644
--- a/.github/workflows/check-version-bump.yml
+++ b/.github/workflows/check-version-bump.yml
@@ -1,48 +1,48 @@
-name: Check version bump
-on:
- pull_request:
- branches: [main]
-
-jobs:
- check-version:
- if: github.head_ref == 'dev'
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout PR branch
- uses: actions/checkout@v4
-
- - name: Get PR version
- id: pr
- shell: pwsh
- run: |
- $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- Write-Host "PR version: $version"
-
- - name: Checkout main
- uses: actions/checkout@v4
- with:
- ref: main
- path: main-branch
-
- - name: Get main version
- id: main
- shell: pwsh
- run: |
- $version = ([xml](Get-Content main-branch/src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
- echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- Write-Host "Main version: $version"
-
- - name: Compare versions
- env:
- PR_VERSION: ${{ steps.pr.outputs.VERSION }}
- MAIN_VERSION: ${{ steps.main.outputs.VERSION }}
- run: |
- 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."
- exit 1
- fi
- echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION"
+name: Check version bump
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ check-version:
+ if: github.head_ref == 'dev'
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout PR branch
+ uses: actions/checkout@v4
+
+ - name: Get PR version
+ id: pr
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+ Write-Host "PR version: $version"
+
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ path: main-branch
+
+ - name: Get main version
+ id: main
+ shell: pwsh
+ run: |
+ $version = ([xml](Get-Content main-branch/src/PlanViewer.App/PlanViewer.App.csproj)).Project.PropertyGroup.Version | Where-Object { $_ }
+ echo "VERSION=$version" >> $env:GITHUB_OUTPUT
+ Write-Host "Main version: $version"
+
+ - name: Compare versions
+ env:
+ PR_VERSION: ${{ steps.pr.outputs.VERSION }}
+ MAIN_VERSION: ${{ steps.main.outputs.VERSION }}
+ run: |
+ 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."
+ exit 1
+ fi
+ echo "✅ Version bumped: $MAIN_VERSION → $PR_VERSION"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 325e626..19e94f2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,41 +1,41 @@
-name: CI
-
-on:
- push:
- branches: [main]
- pull_request:
- branches: [main, dev]
-
-jobs:
- build-and-test:
- runs-on: windows-latest
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.0.x
-
- - 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: Run tests
- run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main, dev]
+
+jobs:
+ build-and-test:
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - 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: Run tests
+ run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml
index 822bafc..c035742 100644
--- a/.github/workflows/nightly.yml
+++ b/.github/workflows/nightly.yml
@@ -1,161 +1,161 @@
-name: Nightly Build
-
-on:
- schedule:
- # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT)
- - cron: '0 6 * * *'
- workflow_dispatch: # manual trigger
-
-permissions:
- contents: write
-
-jobs:
- check:
- runs-on: ubuntu-latest
- outputs:
- has_changes: ${{ steps.check.outputs.has_changes }}
- steps:
- - uses: actions/checkout@v4
- with:
- ref: dev
- fetch-depth: 0
-
- - name: Check for new commits in last 24 hours
- id: check
- run: |
- RECENT=$(git log --since="24 hours ago" --oneline | head -1)
- if [ -n "$RECENT" ]; then
- echo "has_changes=true" >> $GITHUB_OUTPUT
- echo "New commits found — building nightly"
- else
- echo "has_changes=false" >> $GITHUB_OUTPUT
- echo "No new commits — skipping nightly build"
- fi
-
- build:
- needs: check
- if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch'
- runs-on: windows-latest
-
- steps:
- - uses: actions/checkout@v4
- with:
- ref: dev
-
- - name: Setup .NET 8.0
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: 8.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 { $_ }
- $date = Get-Date -Format "yyyyMMdd"
- $nightly = "$base-nightly.$date"
- echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT
- echo "Nightly version: $nightly"
-
- - 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 tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
-
- - name: Run tests
- run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal
-
- - name: Publish App (all platforms)
- run: |
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
- dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
-
- - name: Package artifacts
- shell: pwsh
- env:
- VERSION: ${{ steps.version.outputs.VERSION }}
- run: |
- New-Item -ItemType Directory -Force -Path releases
-
- # Package Windows and Linux as flat zips
- foreach ($rid in @('win-x64', 'linux-x64')) {
- if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
- Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
- }
-
- # Package macOS as proper .app bundles
- foreach ($rid in @('osx-x64', 'osx-arm64')) {
- $appName = "PerformanceStudio.app"
- $bundleDir = "publish/$rid-bundle/$appName"
-
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
- New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
-
- Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
-
- if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
- Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
- }
-
- $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
- $plist = $plist -replace '(CFBundleVersion \s*)[^<]*( )', "`${1}$env:VERSION`${2}"
- $plist = $plist -replace '(CFBundleShortVersionString \s*)[^<]*( )', "`${1}$env:VERSION`${2}"
- Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
-
- if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
- Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
- }
-
- $wrapperDir = "publish/$rid-bundle"
- if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
- if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
-
- Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
- }
-
- - name: Generate checksums
- shell: pwsh
- run: |
- $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
- $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
- "$hash $($_.Name)"
- }
- $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
- Write-Host "Checksums:"
- $checksums | ForEach-Object { Write-Host $_ }
-
- - name: Delete previous nightly release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0
- shell: pwsh
-
- - name: Create nightly release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: pwsh
- run: |
- $version = "${{ steps.version.outputs.VERSION }}"
- $sha = git rev-parse --short HEAD
- $body = @"
- Automated nightly build from ``dev`` branch.
-
- **Version:** ``$version``
- **Commit:** ``$sha``
- **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC")
-
- > These builds include the latest changes and may be unstable.
- > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest).
- "@
-
- gh release create nightly `
- --target dev `
- --title "Nightly Build ($version)" `
- --notes $body `
- --prerelease `
- releases/*.zip releases/SHA256SUMS.txt
+name: Nightly Build
+
+on:
+ schedule:
+ # 6:00 AM UTC (1:00 AM EST / 2:00 AM EDT)
+ - cron: '0 6 * * *'
+ workflow_dispatch: # manual trigger
+
+permissions:
+ contents: write
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ outputs:
+ has_changes: ${{ steps.check.outputs.has_changes }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: dev
+ fetch-depth: 0
+
+ - name: Check for new commits in last 24 hours
+ id: check
+ run: |
+ RECENT=$(git log --since="24 hours ago" --oneline | head -1)
+ if [ -n "$RECENT" ]; then
+ echo "has_changes=true" >> $GITHUB_OUTPUT
+ echo "New commits found — building nightly"
+ else
+ echo "has_changes=false" >> $GITHUB_OUTPUT
+ echo "No new commits — skipping nightly build"
+ fi
+
+ build:
+ needs: check
+ if: needs.check.outputs.has_changes == 'true' || github.event_name == 'workflow_dispatch'
+ runs-on: windows-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: dev
+
+ - name: Setup .NET 8.0
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.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 { $_ }
+ $date = Get-Date -Format "yyyyMMdd"
+ $nightly = "$base-nightly.$date"
+ echo "VERSION=$nightly" >> $env:GITHUB_OUTPUT
+ echo "Nightly version: $nightly"
+
+ - 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 tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj
+
+ - name: Run tests
+ run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal
+
+ - name: Publish App (all platforms)
+ run: |
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r win-x64 --self-contained -o publish/win-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r linux-x64 --self-contained -o publish/linux-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-x64 --self-contained -o publish/osx-x64
+ dotnet publish src/PlanViewer.App/PlanViewer.App.csproj -c Release -r osx-arm64 --self-contained -o publish/osx-arm64
+
+ - name: Package artifacts
+ shell: pwsh
+ env:
+ VERSION: ${{ steps.version.outputs.VERSION }}
+ run: |
+ New-Item -ItemType Directory -Force -Path releases
+
+ # Package Windows and Linux as flat zips
+ foreach ($rid in @('win-x64', 'linux-x64')) {
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "publish/$rid/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "publish/$rid/" }
+ Compress-Archive -Path "publish/$rid/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
+ }
+
+ # Package macOS as proper .app bundles
+ foreach ($rid in @('osx-x64', 'osx-arm64')) {
+ $appName = "PerformanceStudio.app"
+ $bundleDir = "publish/$rid-bundle/$appName"
+
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/MacOS"
+ New-Item -ItemType Directory -Force -Path "$bundleDir/Contents/Resources"
+
+ Copy-Item -Path "publish/$rid/*" -Destination "$bundleDir/Contents/MacOS/" -Recurse
+
+ if (Test-Path "$bundleDir/Contents/MacOS/Info.plist") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/Info.plist" -Destination "$bundleDir/Contents/Info.plist" -Force
+ }
+
+ $plist = Get-Content "$bundleDir/Contents/Info.plist" -Raw
+ $plist = $plist -replace '(CFBundleVersion \s*)[^<]*( )', "`${1}$env:VERSION`${2}"
+ $plist = $plist -replace '(CFBundleShortVersionString \s*)[^<]*( )', "`${1}$env:VERSION`${2}"
+ Set-Content -Path "$bundleDir/Contents/Info.plist" -Value $plist -NoNewline
+
+ if (Test-Path "$bundleDir/Contents/MacOS/EDD.icns") {
+ Move-Item -Path "$bundleDir/Contents/MacOS/EDD.icns" -Destination "$bundleDir/Contents/Resources/EDD.icns" -Force
+ }
+
+ $wrapperDir = "publish/$rid-bundle"
+ if (Test-Path 'README.md') { Copy-Item 'README.md' "$wrapperDir/" }
+ if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' "$wrapperDir/" }
+
+ Compress-Archive -Path "$wrapperDir/*" -DestinationPath "releases/PerformanceStudio-$rid-$env:VERSION.zip" -Force
+ }
+
+ - name: Generate checksums
+ shell: pwsh
+ run: |
+ $checksums = Get-ChildItem releases/*.zip | ForEach-Object {
+ $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower()
+ "$hash $($_.Name)"
+ }
+ $checksums | Out-File -FilePath releases/SHA256SUMS.txt -Encoding utf8
+ Write-Host "Checksums:"
+ $checksums | ForEach-Object { Write-Host $_ }
+
+ - name: Delete previous nightly release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: gh release delete nightly --yes --cleanup-tag 2>$null; exit 0
+ shell: pwsh
+
+ - name: Create nightly release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: pwsh
+ run: |
+ $version = "${{ steps.version.outputs.VERSION }}"
+ $sha = git rev-parse --short HEAD
+ $body = @"
+ Automated nightly build from ``dev`` branch.
+
+ **Version:** ``$version``
+ **Commit:** ``$sha``
+ **Built:** $(Get-Date -Format "yyyy-MM-dd HH:mm UTC")
+
+ > These builds include the latest changes and may be unstable.
+ > For production use, download the [latest stable release](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest).
+ "@
+
+ gh release create nightly `
+ --target dev `
+ --title "Nightly Build ($version)" `
+ --notes $body `
+ --prerelease `
+ releases/*.zip releases/SHA256SUMS.txt
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ee7999f..c21d7b4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,89 +1,89 @@
-# Contributing to Performance Studio
-
-Thank you for your interest in contributing to Performance Studio! This guide will help you get started.
-
-## Reporting Issues
-
-- Use [GitHub Issues](https://github.com/erikdarlingdata/PerformanceStudio/issues) for bugs and feature requests
-- Include the `.sqlplan` file (or a minimal reproduction) when reporting parser or analysis bugs
-- Specify your OS and .NET version
-
-## Development Setup
-
-### Prerequisites
-
-- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
-- Git
-
-### Build and Test
-
-```bash
-git clone https://github.com/erikdarlingdata/PerformanceStudio.git
-cd PerformanceStudio
-dotnet build
-dotnet test tests/PlanViewer.Core.Tests
-```
-
-### Run the GUI
-
-```bash
-dotnet run --project src/PlanViewer.App
-```
-
-### Run the CLI
-
-```bash
-dotnet run --project src/PlanViewer.Cli -- analyze --help
-```
-
-## Project Structure
-
-```
-PerformanceStudio/
-├── src/
-│ ├── PlanViewer.Core/ # Analysis engine (parser, rules, layout)
-│ ├── PlanViewer.App/ # Avalonia desktop GUI
-│ └── PlanViewer.Cli/ # CLI tool (planview command)
-└── tests/
- └── PlanViewer.Core.Tests/ # xUnit tests with real .sqlplan fixtures
-```
-
-## Architecture
-
-- **PlanViewer.Core** is the shared library. It contains the XML parser (`ShowPlanParser`), analysis rules (`PlanAnalyzer`), plan layout engine, text/JSON formatters, and all models. Both the GUI and CLI depend on it.
-- **PlanViewer.App** is an Avalonia 11 desktop app using code-behind (no MVVM framework). It renders plan trees on a Canvas with the same operator icons as SSMS.
-- **PlanViewer.Cli** is a System.CommandLine-based CLI tool that wraps Core for command-line use.
-
-## Code Style
-
-- File-scoped namespaces (`namespace Foo;`)
-- Nullable enabled across all projects
-- Code-behind pattern for UI (no MVVM, no ReactiveUI)
-- No unnecessary abstractions — keep it simple and direct
-- Tests use real `.sqlplan` XML fixtures, not mocks
-
-## Adding Analysis Rules
-
-Rules live in `PlanAnalyzer.cs`. Each rule:
-
-1. Inspects `PlanNode` properties (statement-level rules) or individual operator nodes
-2. Adds a `PlanWarning` with `WarningType`, `Message`, and `Severity` (Info, Warning, or Critical)
-3. Has a corresponding test in `PlanAnalyzerTests.cs` with a minimal `.sqlplan` fixture
-
-When adding a rule:
-- Add the rule logic to `AnalyzeStatement()` or `AnalyzeNode()` in `PlanAnalyzer.cs`
-- Create a minimal `.sqlplan` test fixture in `tests/PlanViewer.Core.Tests/Plans/`
-- Add a test method in `PlanAnalyzerTests.cs`
-- Ensure all existing tests still pass
-
-## Pull Requests
-
-1. Fork the repo and create a feature branch
-2. Make your changes
-3. Run `dotnet test` — all tests must pass
-4. Run `dotnet build` — no warnings or errors
-5. Open a PR with a clear description of what changed and why
-
-## License
-
-By contributing, you agree that your contributions will be licensed under the MIT License.
+# Contributing to Performance Studio
+
+Thank you for your interest in contributing to Performance Studio! This guide will help you get started.
+
+## Reporting Issues
+
+- Use [GitHub Issues](https://github.com/erikdarlingdata/PerformanceStudio/issues) for bugs and feature requests
+- Include the `.sqlplan` file (or a minimal reproduction) when reporting parser or analysis bugs
+- Specify your OS and .NET version
+
+## Development Setup
+
+### Prerequisites
+
+- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
+- Git
+
+### Build and Test
+
+```bash
+git clone https://github.com/erikdarlingdata/PerformanceStudio.git
+cd PerformanceStudio
+dotnet build
+dotnet test tests/PlanViewer.Core.Tests
+```
+
+### Run the GUI
+
+```bash
+dotnet run --project src/PlanViewer.App
+```
+
+### Run the CLI
+
+```bash
+dotnet run --project src/PlanViewer.Cli -- analyze --help
+```
+
+## Project Structure
+
+```
+PerformanceStudio/
+├── src/
+│ ├── PlanViewer.Core/ # Analysis engine (parser, rules, layout)
+│ ├── PlanViewer.App/ # Avalonia desktop GUI
+│ └── PlanViewer.Cli/ # CLI tool (planview command)
+└── tests/
+ └── PlanViewer.Core.Tests/ # xUnit tests with real .sqlplan fixtures
+```
+
+## Architecture
+
+- **PlanViewer.Core** is the shared library. It contains the XML parser (`ShowPlanParser`), analysis rules (`PlanAnalyzer`), plan layout engine, text/JSON formatters, and all models. Both the GUI and CLI depend on it.
+- **PlanViewer.App** is an Avalonia 11 desktop app using code-behind (no MVVM framework). It renders plan trees on a Canvas with the same operator icons as SSMS.
+- **PlanViewer.Cli** is a System.CommandLine-based CLI tool that wraps Core for command-line use.
+
+## Code Style
+
+- File-scoped namespaces (`namespace Foo;`)
+- Nullable enabled across all projects
+- Code-behind pattern for UI (no MVVM, no ReactiveUI)
+- No unnecessary abstractions — keep it simple and direct
+- Tests use real `.sqlplan` XML fixtures, not mocks
+
+## Adding Analysis Rules
+
+Rules live in `PlanAnalyzer.cs`. Each rule:
+
+1. Inspects `PlanNode` properties (statement-level rules) or individual operator nodes
+2. Adds a `PlanWarning` with `WarningType`, `Message`, and `Severity` (Info, Warning, or Critical)
+3. Has a corresponding test in `PlanAnalyzerTests.cs` with a minimal `.sqlplan` fixture
+
+When adding a rule:
+- Add the rule logic to `AnalyzeStatement()` or `AnalyzeNode()` in `PlanAnalyzer.cs`
+- Create a minimal `.sqlplan` test fixture in `tests/PlanViewer.Core.Tests/Plans/`
+- Add a test method in `PlanAnalyzerTests.cs`
+- Ensure all existing tests still pass
+
+## Pull Requests
+
+1. Fork the repo and create a feature branch
+2. Make your changes
+3. Run `dotnet test` — all tests must pass
+4. Run `dotnet build` — no warnings or errors
+5. Open a PR with a clear description of what changed and why
+
+## License
+
+By contributing, you agree that your contributions will be licensed under the MIT License.
diff --git a/LICENSE b/LICENSE
index 62a9a26..6ada7e9 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,21 @@
-MIT License
-
-Copyright (c) 2026 Erik Darling, Darling Data LLC
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+MIT License
+
+Copyright (c) 2026 Erik Darling, Darling Data LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/PlanViewer.sln b/PlanViewer.sln
index 685bc60..8abfbc2 100644
--- a/PlanViewer.sln
+++ b/PlanViewer.sln
@@ -1,57 +1,57 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31903.59
-MinimumVisualStudioVersion = 10.0.40219.1
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{21F75D2E-F228-49B3-825F-43F621760061}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Core", "src\PlanViewer.Core\PlanViewer.Core.csproj", "{8904045D-E083-4010-A320-104B7466C044}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.App", "src\PlanViewer.App\PlanViewer.App.csproj", "{F1018F88-B289-40CE-852A-56478DFBA91E}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Cli", "src\PlanViewer.Cli\PlanViewer.Cli.csproj", "{1504CE29-3CBF-4F0B-A46E-54644946B8ED}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Web", "src\PlanViewer.Web\PlanViewer.Web.csproj", "{B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}"
-EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A06217BE-DBE2-47D0-BD59-93F2108D447C}"
-EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Core.Tests", "tests\PlanViewer.Core.Tests\PlanViewer.Core.Tests.csproj", "{399A69AD-0CD1-4E9B-9988-E94882B827E6}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {8904045D-E083-4010-A320-104B7466C044}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {8904045D-E083-4010-A320-104B7466C044}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {8904045D-E083-4010-A320-104B7466C044}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {8904045D-E083-4010-A320-104B7466C044}.Release|Any CPU.Build.0 = Release|Any CPU
- {F1018F88-B289-40CE-852A-56478DFBA91E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {F1018F88-B289-40CE-852A-56478DFBA91E}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {F1018F88-B289-40CE-852A-56478DFBA91E}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {F1018F88-B289-40CE-852A-56478DFBA91E}.Release|Any CPU.Build.0 = Release|Any CPU
- {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Release|Any CPU.Build.0 = Release|Any CPU
- {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Release|Any CPU.Build.0 = Release|Any CPU
- {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
- GlobalSection(NestedProjects) = preSolution
- {8904045D-E083-4010-A320-104B7466C044} = {21F75D2E-F228-49B3-825F-43F621760061}
- {F1018F88-B289-40CE-852A-56478DFBA91E} = {21F75D2E-F228-49B3-825F-43F621760061}
- {1504CE29-3CBF-4F0B-A46E-54644946B8ED} = {21F75D2E-F228-49B3-825F-43F621760061}
- {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D} = {21F75D2E-F228-49B3-825F-43F621760061}
- {399A69AD-0CD1-4E9B-9988-E94882B827E6} = {A06217BE-DBE2-47D0-BD59-93F2108D447C}
- EndGlobalSection
-EndGlobal
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{21F75D2E-F228-49B3-825F-43F621760061}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Core", "src\PlanViewer.Core\PlanViewer.Core.csproj", "{8904045D-E083-4010-A320-104B7466C044}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.App", "src\PlanViewer.App\PlanViewer.App.csproj", "{F1018F88-B289-40CE-852A-56478DFBA91E}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Cli", "src\PlanViewer.Cli\PlanViewer.Cli.csproj", "{1504CE29-3CBF-4F0B-A46E-54644946B8ED}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Web", "src\PlanViewer.Web\PlanViewer.Web.csproj", "{B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A06217BE-DBE2-47D0-BD59-93F2108D447C}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PlanViewer.Core.Tests", "tests\PlanViewer.Core.Tests\PlanViewer.Core.Tests.csproj", "{399A69AD-0CD1-4E9B-9988-E94882B827E6}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {8904045D-E083-4010-A320-104B7466C044}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8904045D-E083-4010-A320-104B7466C044}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8904045D-E083-4010-A320-104B7466C044}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8904045D-E083-4010-A320-104B7466C044}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F1018F88-B289-40CE-852A-56478DFBA91E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F1018F88-B289-40CE-852A-56478DFBA91E}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F1018F88-B289-40CE-852A-56478DFBA91E}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F1018F88-B289-40CE-852A-56478DFBA91E}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1504CE29-3CBF-4F0B-A46E-54644946B8ED}.Release|Any CPU.Build.0 = Release|Any CPU
+ {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {399A69AD-0CD1-4E9B-9988-E94882B827E6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {8904045D-E083-4010-A320-104B7466C044} = {21F75D2E-F228-49B3-825F-43F621760061}
+ {F1018F88-B289-40CE-852A-56478DFBA91E} = {21F75D2E-F228-49B3-825F-43F621760061}
+ {1504CE29-3CBF-4F0B-A46E-54644946B8ED} = {21F75D2E-F228-49B3-825F-43F621760061}
+ {B2D3F7A1-8C4E-4F5A-9D6B-1E2F3A4B5C6D} = {21F75D2E-F228-49B3-825F-43F621760061}
+ {399A69AD-0CD1-4E9B-9988-E94882B827E6} = {A06217BE-DBE2-47D0-BD59-93F2108D447C}
+ EndGlobalSection
+EndGlobal
diff --git a/README.md b/README.md
index 9a22442..cbfed00 100644
--- a/README.md
+++ b/README.md
@@ -1,522 +1,522 @@
-# Performance Studio
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI.
-
-Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS.
-
-## Screenshots
-
-### Query Editor
-Write queries with syntax highlighting and SQL keyword completion, connect to any SQL Server, and capture plans with one click.
-
-
-
-### Actual Execution Plan with Plan Insights
-Graphical plan tree with SSMS-style operator icons, cost percentages, row counts, and warning badges. The Plan Insights panel shows runtime summary, missing indexes, parameters, and wait stats at a glance.
-
-
-
-### Multi-Statement Navigation
-Navigate stored procedures and batches with multiple statements. Click any statement in the grid to jump to its plan. Plan Insights shows parameters with compiled vs runtime values.
-
-
-
-### Operator Tooltip and Properties
-Hover over any operator for a detailed tooltip with costs, rows, I/O, timing, parallelism, and warnings. Click to open the full properties panel with per-thread timing, predicates, and more.
-
-
-
-
-
-### Advice for Humans
-One-click text report with server context, warnings, wait stats, and expensive operators — ready to read or share.
-
-
-
-### Plan Comparison
-Side-by-side comparison of two plans showing cost, runtime, I/O, memory, and wait stat differences.
-
-
-
-### Query Store Integration
-Fetch top queries by CPU, duration, logical reads, physical reads, writes, memory, or executions from Query Store and load their plans directly into the analyzer.
-
-
-
-### Minimap and colored links by accuracy ratio divergence
-The minimap provides a high-level overview of the entire plan, allowing you to quickly navigate to areas of interest. Colored links between operators indicate accuracy ratio divergence, helping you identify where estimates are most off from actuals.
-
-
-### MCP Integration
-Ask Claude Code to analyze loaded plans, identify warnings, suggest indexes, and compare plans — all through the built-in MCP server.
-
-
-
-## What It Does
-
-Feed it a query plan and it tells you what's wrong:
-
-- **Large memory grants** — flags queries hoarding memory they don't use
-- **Row estimate mismatches** — finds operators where estimates are 10x+ off from actuals
-- **Missing indexes** — extracts SQL Server's index suggestions with ready-to-run CREATE statements
-- **Hash, sort, and exchange spills** — identifies operators spilling to TempDB with severity based on volume
-- **Parallel skew** — detects threads doing all the work while others sit idle
-- **Scan predicates** — warns when scans filter rows with residual predicates
-- **Key and RID lookups** — flags lookups back to the base table, distinguishes heaps from clustered indexes
-- **Late filters** — finds Filter operators discarding rows deep in the plan
-- **Nested loop concerns** — flags high-execution nested loops that might be better as hash joins
-- **Parameter sniffing** — compares compiled vs runtime parameter values
-- **Scalar UDFs** — warns about T-SQL and CLR scalar functions in execution paths
-- **Implicit conversions** — detects type mismatches, upgrades severity when a seek plan is prevented
-- **Anti-patterns** — OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns, leading wildcards, function-wrapped predicates, and more
-
-Each warning includes severity (Info, Warning, or Critical), the operator node ID, and enough context to act on immediately.
-
-## Prerequisites
-
-- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.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)
-
-## Download
-
-Pre-built binaries are available on the [Releases](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest) page:
-
-| Platform | Download |
-|----------|----------|
-| Windows (x64) | [PerformanceStudio-win-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-win-x64.zip) |
-| macOS (Apple Silicon) | [PerformanceStudio-osx-arm64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-osx-arm64.zip) |
-| macOS (Intel) | [PerformanceStudio-osx-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-osx-x64.zip) |
-| Linux (x64) | [PerformanceStudio-linux-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-linux-x64.zip) |
-
-These are self-contained — no .NET SDK required. Extract the zip and run.
-
-**macOS note:** macOS may block the app because it isn't signed with an Apple Developer certificate. If you see a warning that the app "can't be opened," run this after extracting:
-
-```bash
-xattr -cr PerformanceStudio.app
-```
-
-Then open the app normally.
-
-## Build from Source
-
-Clone and build:
-
-```bash
-git clone https://github.com/erikdarlingdata/PerformanceStudio.git
-cd PerformanceStudio
-dotnet build
-```
-
-To verify the build:
-
-```bash
-dotnet test tests/PlanViewer.Core.Tests # 37 tests should pass
-dotnet run --project src/PlanViewer.Cli -- analyze --help
-```
-
-## Quick Start
-
-### Analyze an existing .sqlplan file
-
-If you already have a `.sqlplan` file (saved from SSMS, Azure Data Studio, or another tool):
-
-```bash
-# JSON output (default) — full operator tree, suitable for automation
-planview analyze my_query.sqlplan
-
-# Human-readable text output
-planview analyze my_query.sqlplan --output text
-
-# Text output, warnings and missing indexes only (skip operator tree)
-planview analyze my_query.sqlplan --output text --warnings-only
-```
-
-### Capture and analyze plans from a live server
-
-Connect to a SQL Server instance, run queries, and capture their execution plans automatically.
-
-**Quickest way** — pass credentials directly:
-
-```bash
-# Capture an actual execution plan (the query WILL run)
-planview analyze --server sql2022 --database AdventureWorks \
- --login sa --password YourPassword \
- --query "SELECT * FROM Sales.SalesOrderHeader WHERE OrderDate > '2024-01-01'" \
- --trust-cert --output-dir ./results/
-
-# Capture an estimated plan (safe for production — query is NOT executed)
-planview analyze --server sql2022 --database AdventureWorks \
- --login sa --password YourPassword \
- --query "SELECT * FROM Sales.SalesOrderHeader" \
- --estimated --trust-cert --output-dir ./results/
-```
-
-**Using a .env file** — drop a `.env` in your working directory to avoid repeating connection details:
-
-```bash
-# .env
-PLANVIEW_SERVER=sql2022
-PLANVIEW_DATABASE=AdventureWorks
-PLANVIEW_LOGIN=sa
-PLANVIEW_PASSWORD=YourPassword
-PLANVIEW_TRUST_CERT=true
-```
-
-Then just run:
-
-```bash
-planview analyze --query "SELECT * FROM Sales.SalesOrderHeader"
-planview analyze ./queries/ --output-dir ./results/
-```
-
-CLI arguments override `.env` values when both are provided.
-
-**Using the credential store** — for longer-term use, store credentials in your OS keychain:
-
-```bash
-# Store credentials (once per server)
-planview credential add sql2022 --user sa
-# You'll be prompted for the password — it's stored in your OS credential store
-
-# Now connect without --login/--password
-planview analyze --server sql2022 --database AdventureWorks \
- --query "SELECT * FROM Sales.SalesOrderHeader" \
- --trust-cert --output-dir ./results/
-```
-
-**Batch processing** a folder of .sql files:
-
-```bash
-planview analyze ./queries/ --server sql2022 --database StackOverflow2013 \
- --login sa --password YourPassword \
- --trust-cert --output-dir ./results/
-```
-
-Batch mode produces three files per query:
-- `query_name.sqlplan` — the raw execution plan XML (openable in SSMS or the Performance Studio GUI)
-- `query_name.analysis.json` — structured analysis with warnings, missing indexes, and operator tree
-- `query_name.analysis.txt` — human-readable text report
-
-### Manage credentials
-
-```bash
-planview credential add my-server --user sa # prompts for password
-planview credential add my-server --user sa -p pwd # non-interactive
-planview credential list # show stored credentials
-planview credential remove my-server # delete credential
-```
-
-Credentials are stored in the OS credential store — Windows Credential Manager on Windows, Apple Keychain on macOS. Nothing is written to disk in plaintext.
-
-## Example Output
-
-These examples were generated against StackOverflow2013 on SQL Server 2022. Source queries are in [`examples/queries/`](examples/queries/), plans and analysis in [`examples/output/`](examples/output/).
-
-### Text output (`--output text`)
-
-```
-Plan: 04_comment_heavy_posts.sqlplan
-SQL Server: 1.564 (build 16.0.4222.2)
-Statements: 1
-
---- Statement 1: SELECT ---
- Query: SELECT p.Id, p.Title, p.Score, COUNT(c.Id) AS CommentCount
- FROM dbo.Posts AS p JOIN dbo.Comments AS c ON c.PostId = p.Id
- WHERE p.PostTypeId = 1 GROUP BY p.Id, p.Title, p.Score
- HAVING COUNT(c.Id) > 20 ORDER BY CommentCount DESC
- Estimated cost: 4069.8700
- DOP: 8
- Runtime: 4551ms elapsed, 15049ms CPU
- Memory grant: 8,022,664 KB granted, 2,514,944 KB used
-
- Warnings:
- [Critical] Large Memory Grant: Query granted 7835 MB of memory.
-
- Operator warnings:
- [Critical] Parallelism (Node 0): Estimated 1 rows, actual 2,889 (2889x underestimated).
- [Critical] Sort (Node 1): Estimated 1 rows, actual 2,889 (2889x underestimated).
- [Warning] Sort (Node 1): Thread 1 processed 100% of rows. Work is heavily skewed.
- [Warning] Filter (Node 2): Filter discards rows late in the plan.
-
- Missing indexes:
- StackOverflow2013.dbo.Posts (impact: 74%)
- CREATE NONCLUSTERED INDEX [IX_Posts_PostTypeId]
- ON dbo.Posts (PostTypeId) INCLUDE (Score, Title)
- StackOverflow2013.dbo.Comments (impact: 19%)
- CREATE NONCLUSTERED INDEX [IX_Comments_PostId]
- ON dbo.Comments (PostId)
-
-=== Summary ===
- Warnings: 8 (4 critical)
- Missing indexes: 2
- Actual stats: yes
- Warning types: Filter Operator, Large Memory Grant, Parallel Skew,
- Row Estimate Mismatch, Scan With Predicate
-```
-
-### JSON output (default)
-
-The default JSON output includes the full operator tree, making it suitable for CI pipelines, LLM consumption, or further processing. See [`examples/output/`](examples/output/) for complete examples.
-
-### Batch processing
-
-```
-$ planview analyze ./examples/queries/ --server sql2022 \
- --database StackOverflow2013 --trust-cert --output-dir ./results/
-
-Capturing actual plans from sql2022/StackOverflow2013
-
-[1/5] 01_top_users_by_posts ... OK (1.8s)
-[2/5] 02_recent_questions ... OK (0.8s)
-[3/5] 03_unanswered_high_score ... OK (0.7s)
-[4/5] 04_comment_heavy_posts ... OK (4.7s)
-[5/5] 05_user_vote_summary ... OK (4.3s)
-
-Processed 5 files: 5 succeeded, 0 failed
-Output: ./results/
-```
-
-## Desktop GUI
-
-The Avalonia-based GUI renders execution plans visually with the same operator icons as SSMS. Open `.sqlplan` files via File > Open or drag-and-drop.
-
-Features:
-- Graphical plan tree with cost percentages and row counts
-- Warning badge on root node showing total warning count
-- Plan Insights panel — three-column view with runtime summary, missing indexes, and wait stats visualization
-- Zoom and pan (mouse wheel + middle-click drag)
-- Minimap for quick navigation of large plans
-- Color-coded links between operators based on accuracy ratio divergence (estimates vs actuals)
-- Click any operator to see full properties (30 sections)
-- Statement grid with sortable columns (cost, rows, DOP, warnings)
-- Tooltips on hover with key operator metrics
-- **Advice for Humans** — one-click text analysis report you can read or share
-- **Advice for Robots** — one-click JSON export designed for LLMs and automation
-- **Plan Comparison** — compare two plans side-by-side (cost, runtime, I/O, memory, wait stats)
-- **Copy Repro Script** — extracts parameters, SET options, and query text into a runnable `sp_executesql` script
-- **Get Actual Plan** — connect to a server and re-execute the query to capture runtime stats
-- **Query Store Analysis** — connect to a server and analyze top queries by CPU, duration, or reads
-- **Query History** — view a history of executed queries with their plans along the timeline and metrics from query store
-- **MCP Server** — built-in Model Context Protocol server for AI-assisted plan analysis (opt-in)
-- Dark theme
-
-```bash
-dotnet run --project src/PlanViewer.App
-```
-
-## SSMS Extension
-
-A VSIX extension that adds **"Open in Performance Studio"** to the execution plan right-click context menu in SSMS 18-22.
-
-### How it works
-
-1. Right-click on any execution plan in SSMS
-2. Click "Open in Performance Studio"
-3. The extension extracts the plan XML via reflection and saves it to a temp file
-4. Performance Studio opens with the plan loaded
-
-### Installation
-
-1. Download `PlanViewer.Ssms.vsix` and `InstallSsmsExtension.exe` from the [v0.7.0 release](https://github.com/erikdarlingdata/PerformanceStudio/releases/tag/v0.7.0) (SSMS extension is not yet included in automated builds)
-2. Place them in the same folder
-3. Double-click `InstallSsmsExtension.exe` and approve the UAC prompt
-4. The installer auto-detects SSMS 21 and/or SSMS 22 and installs into both
-5. Restart SSMS to activate the extension
-
-### First run
-
-On first use, if Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once.
-
-The extension searches for the app in this order:
-1. Registry key (set automatically after first browse)
-2. System PATH
-3. Common install locations (`%LOCALAPPDATA%\Programs\SQLPerformanceStudio\`, `Program Files`, etc.)
-
-## MCP Server (LLM Integration)
-
-The desktop GUI includes an embedded [Model Context Protocol](https://modelcontextprotocol.io) server that exposes loaded execution plans and Query Store data to LLM clients like Claude Code and Cursor.
-
-### Setup
-
-1. Enable the MCP server in `~/.planview/settings.json`:
-
-```json
-{
- "mcp_enabled": true,
- "mcp_port": 5152
-}
-```
-
-2. Register with Claude Code:
-
-```
-claude mcp add --transport http --scope user performance-studio http://localhost:5152/
-```
-
-3. Open a new Claude Code session and ask questions like:
- - "What plans are loaded in the application?"
- - "Analyze the execution plan and tell me what's wrong"
- - "Are there any missing index suggestions?"
- - "Compare these two plans — which is better?"
- - "Fetch the top 10 queries by CPU from Query Store"
-
-### Available Tools
-
-13 tools for plan analysis and Query Store data:
-
-| Category | Tools |
-|---|---|
-| Discovery | `list_plans`, `get_connections` |
-| Plan Analysis | `analyze_plan`, `get_plan_summary`, `get_plan_warnings`, `get_missing_indexes`, `get_plan_parameters`, `get_expensive_operators`, `get_plan_xml`, `compare_plans`, `get_repro_script` |
-| Query Store | `check_query_store`, `get_query_store_top` |
-
-Plan analysis tools work on plans loaded in the app (via file open, paste, query execution, or Query Store fetch). Query Store tools use a built-in read-only DMV query — no arbitrary SQL can be executed.
-
-The MCP server binds to `localhost` only and does not accept remote connections. Disabled by default.
-
-## Project Structure
-
-```
-PerformanceStudio/
-├── src/
-│ ├── PlanViewer.Core/ # Analysis engine (parser, 30 rules, layout)
-│ ├── PlanViewer.App/ # Avalonia desktop GUI
-│ ├── PlanViewer.Cli/ # CLI tool (planview command)
-│ ├── PlanViewer.Ssms/ # SSMS extension (.vsix, .NET Framework 4.7.2)
-│ └── PlanViewer.Ssms.Installer/ # SSMS extension installer (auto-detects SSMS 21/22)
-├── tests/
-│ └── PlanViewer.Core.Tests/ # 37 xUnit tests with real .sqlplan fixtures
-├── examples/
-│ ├── plans/ # Sample .sqlplan files for testing
-│ ├── queries/ # Sample .sql files
-│ └── output/ # Generated .sqlplan, .analysis.json, .txt
-└── PlanViewer.sln
-```
-
-## CLI Reference
-
-### `planview analyze`
-
-```
-Usage: planview analyze [] [options]
-
-Arguments:
- .sqlplan file, .sql file, or directory of .sql files
-
-Options:
- --stdin Read plan XML from stdin
- -o, --output json (default) or text
- --compact Compact JSON (no indentation)
- --warnings-only Skip operator tree, only output warnings and indexes
- -s, --server SQL Server name (matches credential store key)
- -d, --database Database context for execution
- -q, --query Inline SQL text to execute
- --output-dir Directory for output files
- --estimated Estimated plan only (query is NOT executed)
- --auth windows, sql, or entra (default: auto-detect)
- --trust-cert Trust server certificate
- --timeout Query timeout (default: 60)
- --login SQL Server login (bypasses credential store)
- --password SQL Server password (bypasses credential store)
-```
-
-### `planview credential`
-
-```
-planview credential add --user [-p ]
-planview credential list
-planview credential remove
-```
-
-## Authentication
-
-There are three ways to authenticate, in order of precedence:
-
-1. **`--login` / `--password`** — passed directly on the command line (or via `.env` file). Simplest for dev/test.
-2. **Credential store** — stored in Windows Credential Manager or Apple Keychain via `planview credential add`. Best for repeated use.
-3. **Windows Authentication** — used automatically when no SQL credentials are found. Requires a valid Kerberos ticket.
-
-Override the auto-detection with `--auth windows`, `--auth sql`, or `--auth entra`.
-
-**macOS note:** Windows Authentication does not work on macOS (no Kerberos ticket by default). Use `--login`/`--password`, the credential store, or `--auth entra` instead.
-
-## Platform Support
-
-| Platform | GUI | CLI | Credential Store |
-|----------|-----|-----|-----------------|
-| Windows | Yes | Yes | Windows Credential Manager |
-| macOS | Yes | Yes | Apple Keychain |
-| Linux | Yes | Yes | Not yet (file analysis works) |
-
-### macOS: SQL Server via Docker
-
-macOS users need a SQL Server instance to use the live capture features. The easiest path is Docker:
-
-```bash
-docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=YourPassword123" \
- -p 1433:1433 --name sql_server \
- -d mcr.microsoft.com/mssql/server:2022-latest
-
-# Store the credential
-planview credential add localhost --user sa -p YourPassword123
-
-# Test connectivity
-planview analyze --server localhost --database master --trust-cert \
- --query "SELECT @@VERSION" -o text
-```
-
-Always use `--trust-cert` with local Docker instances.
-
-## Analysis Rules
-
-The analyzer runs 30 rules against each plan, covering:
-
-| Category | Rules |
-|----------|-------|
-| Memory | Large grants, grant vs used ratio, spills to TempDB (including exchange spills) |
-| Estimates | Row estimate mismatches (10x+), zero-row actuals, row goals |
-| Indexes | Missing index suggestions, key lookups, RID lookups, scan with residual predicates |
-| Parallelism | Serial plan reasons, thread skew, ineffective parallelism, DOP reporting |
-| Joins | Nested loop high executions, many-to-many merge join worktables |
-| Filters | Late filter operators with impact quantification, function-wrapped predicates |
-| Functions | Scalar UDF detection (T-SQL and CLR), UDF timing |
-| Parameters | Compiled vs runtime values, sniffing issue detection |
-| Patterns | Leading wildcards, implicit conversions (with seek plan severity upgrade), OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns, OR expansion, CASE in predicates |
-| Compilation | High compile CPU, compile memory exceeded, early abort |
-| Objects | Table variables, table-valued functions, CTE multiple references, eager index spools, lazy spools, row count spools |
-| Operators | Operator self-time calculation with per-thread awareness for parallel plans |
-
-Rules can be disabled or have their severity overridden via a `.planview.json` config file. See the `--config` option.
-
-## Sponsors
-
-
-
-## License
-
-MIT — see [LICENSE](LICENSE).
-
-Execution plan operator icons are from Microsoft's [vscode-mssql](https://github.com/microsoft/vscode-mssql) extension (MIT). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details.
+# Performance Studio
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+A cross-platform SQL Server execution plan analyzer with built-in MCP server for AI-assisted analysis. Parses `.sqlplan` XML, identifies performance problems, suggests missing indexes, and provides actionable warnings — from the command line or a desktop GUI.
+
+Built for developers and DBAs who want fast, automated plan analysis without clicking through SSMS.
+
+## Screenshots
+
+### Query Editor
+Write queries with syntax highlighting and SQL keyword completion, connect to any SQL Server, and capture plans with one click.
+
+
+
+### Actual Execution Plan with Plan Insights
+Graphical plan tree with SSMS-style operator icons, cost percentages, row counts, and warning badges. The Plan Insights panel shows runtime summary, missing indexes, parameters, and wait stats at a glance.
+
+
+
+### Multi-Statement Navigation
+Navigate stored procedures and batches with multiple statements. Click any statement in the grid to jump to its plan. Plan Insights shows parameters with compiled vs runtime values.
+
+
+
+### Operator Tooltip and Properties
+Hover over any operator for a detailed tooltip with costs, rows, I/O, timing, parallelism, and warnings. Click to open the full properties panel with per-thread timing, predicates, and more.
+
+
+
+
+
+### Advice for Humans
+One-click text report with server context, warnings, wait stats, and expensive operators — ready to read or share.
+
+
+
+### Plan Comparison
+Side-by-side comparison of two plans showing cost, runtime, I/O, memory, and wait stat differences.
+
+
+
+### Query Store Integration
+Fetch top queries by CPU, duration, logical reads, physical reads, writes, memory, or executions from Query Store and load their plans directly into the analyzer.
+
+
+
+### Minimap and colored links by accuracy ratio divergence
+The minimap provides a high-level overview of the entire plan, allowing you to quickly navigate to areas of interest. Colored links between operators indicate accuracy ratio divergence, helping you identify where estimates are most off from actuals.
+
+
+### MCP Integration
+Ask Claude Code to analyze loaded plans, identify warnings, suggest indexes, and compare plans — all through the built-in MCP server.
+
+
+
+## What It Does
+
+Feed it a query plan and it tells you what's wrong:
+
+- **Large memory grants** — flags queries hoarding memory they don't use
+- **Row estimate mismatches** — finds operators where estimates are 10x+ off from actuals
+- **Missing indexes** — extracts SQL Server's index suggestions with ready-to-run CREATE statements
+- **Hash, sort, and exchange spills** — identifies operators spilling to TempDB with severity based on volume
+- **Parallel skew** — detects threads doing all the work while others sit idle
+- **Scan predicates** — warns when scans filter rows with residual predicates
+- **Key and RID lookups** — flags lookups back to the base table, distinguishes heaps from clustered indexes
+- **Late filters** — finds Filter operators discarding rows deep in the plan
+- **Nested loop concerns** — flags high-execution nested loops that might be better as hash joins
+- **Parameter sniffing** — compares compiled vs runtime parameter values
+- **Scalar UDFs** — warns about T-SQL and CLR scalar functions in execution paths
+- **Implicit conversions** — detects type mismatches, upgrades severity when a seek plan is prevented
+- **Anti-patterns** — OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns, leading wildcards, function-wrapped predicates, and more
+
+Each warning includes severity (Info, Warning, or Critical), the operator node ID, and enough context to act on immediately.
+
+## Prerequisites
+
+- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.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)
+
+## Download
+
+Pre-built binaries are available on the [Releases](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest) page:
+
+| Platform | Download |
+|----------|----------|
+| Windows (x64) | [PerformanceStudio-win-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-win-x64.zip) |
+| macOS (Apple Silicon) | [PerformanceStudio-osx-arm64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-osx-arm64.zip) |
+| macOS (Intel) | [PerformanceStudio-osx-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-osx-x64.zip) |
+| Linux (x64) | [PerformanceStudio-linux-x64.zip](https://github.com/erikdarlingdata/PerformanceStudio/releases/latest/download/PerformanceStudio-linux-x64.zip) |
+
+These are self-contained — no .NET SDK required. Extract the zip and run.
+
+**macOS note:** macOS may block the app because it isn't signed with an Apple Developer certificate. If you see a warning that the app "can't be opened," run this after extracting:
+
+```bash
+xattr -cr PerformanceStudio.app
+```
+
+Then open the app normally.
+
+## Build from Source
+
+Clone and build:
+
+```bash
+git clone https://github.com/erikdarlingdata/PerformanceStudio.git
+cd PerformanceStudio
+dotnet build
+```
+
+To verify the build:
+
+```bash
+dotnet test tests/PlanViewer.Core.Tests # 37 tests should pass
+dotnet run --project src/PlanViewer.Cli -- analyze --help
+```
+
+## Quick Start
+
+### Analyze an existing .sqlplan file
+
+If you already have a `.sqlplan` file (saved from SSMS, Azure Data Studio, or another tool):
+
+```bash
+# JSON output (default) — full operator tree, suitable for automation
+planview analyze my_query.sqlplan
+
+# Human-readable text output
+planview analyze my_query.sqlplan --output text
+
+# Text output, warnings and missing indexes only (skip operator tree)
+planview analyze my_query.sqlplan --output text --warnings-only
+```
+
+### Capture and analyze plans from a live server
+
+Connect to a SQL Server instance, run queries, and capture their execution plans automatically.
+
+**Quickest way** — pass credentials directly:
+
+```bash
+# Capture an actual execution plan (the query WILL run)
+planview analyze --server sql2022 --database AdventureWorks \
+ --login sa --password YourPassword \
+ --query "SELECT * FROM Sales.SalesOrderHeader WHERE OrderDate > '2024-01-01'" \
+ --trust-cert --output-dir ./results/
+
+# Capture an estimated plan (safe for production — query is NOT executed)
+planview analyze --server sql2022 --database AdventureWorks \
+ --login sa --password YourPassword \
+ --query "SELECT * FROM Sales.SalesOrderHeader" \
+ --estimated --trust-cert --output-dir ./results/
+```
+
+**Using a .env file** — drop a `.env` in your working directory to avoid repeating connection details:
+
+```bash
+# .env
+PLANVIEW_SERVER=sql2022
+PLANVIEW_DATABASE=AdventureWorks
+PLANVIEW_LOGIN=sa
+PLANVIEW_PASSWORD=YourPassword
+PLANVIEW_TRUST_CERT=true
+```
+
+Then just run:
+
+```bash
+planview analyze --query "SELECT * FROM Sales.SalesOrderHeader"
+planview analyze ./queries/ --output-dir ./results/
+```
+
+CLI arguments override `.env` values when both are provided.
+
+**Using the credential store** — for longer-term use, store credentials in your OS keychain:
+
+```bash
+# Store credentials (once per server)
+planview credential add sql2022 --user sa
+# You'll be prompted for the password — it's stored in your OS credential store
+
+# Now connect without --login/--password
+planview analyze --server sql2022 --database AdventureWorks \
+ --query "SELECT * FROM Sales.SalesOrderHeader" \
+ --trust-cert --output-dir ./results/
+```
+
+**Batch processing** a folder of .sql files:
+
+```bash
+planview analyze ./queries/ --server sql2022 --database StackOverflow2013 \
+ --login sa --password YourPassword \
+ --trust-cert --output-dir ./results/
+```
+
+Batch mode produces three files per query:
+- `query_name.sqlplan` — the raw execution plan XML (openable in SSMS or the Performance Studio GUI)
+- `query_name.analysis.json` — structured analysis with warnings, missing indexes, and operator tree
+- `query_name.analysis.txt` — human-readable text report
+
+### Manage credentials
+
+```bash
+planview credential add my-server --user sa # prompts for password
+planview credential add my-server --user sa -p pwd # non-interactive
+planview credential list # show stored credentials
+planview credential remove my-server # delete credential
+```
+
+Credentials are stored in the OS credential store — Windows Credential Manager on Windows, Apple Keychain on macOS. Nothing is written to disk in plaintext.
+
+## Example Output
+
+These examples were generated against StackOverflow2013 on SQL Server 2022. Source queries are in [`examples/queries/`](examples/queries/), plans and analysis in [`examples/output/`](examples/output/).
+
+### Text output (`--output text`)
+
+```
+Plan: 04_comment_heavy_posts.sqlplan
+SQL Server: 1.564 (build 16.0.4222.2)
+Statements: 1
+
+--- Statement 1: SELECT ---
+ Query: SELECT p.Id, p.Title, p.Score, COUNT(c.Id) AS CommentCount
+ FROM dbo.Posts AS p JOIN dbo.Comments AS c ON c.PostId = p.Id
+ WHERE p.PostTypeId = 1 GROUP BY p.Id, p.Title, p.Score
+ HAVING COUNT(c.Id) > 20 ORDER BY CommentCount DESC
+ Estimated cost: 4069.8700
+ DOP: 8
+ Runtime: 4551ms elapsed, 15049ms CPU
+ Memory grant: 8,022,664 KB granted, 2,514,944 KB used
+
+ Warnings:
+ [Critical] Large Memory Grant: Query granted 7835 MB of memory.
+
+ Operator warnings:
+ [Critical] Parallelism (Node 0): Estimated 1 rows, actual 2,889 (2889x underestimated).
+ [Critical] Sort (Node 1): Estimated 1 rows, actual 2,889 (2889x underestimated).
+ [Warning] Sort (Node 1): Thread 1 processed 100% of rows. Work is heavily skewed.
+ [Warning] Filter (Node 2): Filter discards rows late in the plan.
+
+ Missing indexes:
+ StackOverflow2013.dbo.Posts (impact: 74%)
+ CREATE NONCLUSTERED INDEX [IX_Posts_PostTypeId]
+ ON dbo.Posts (PostTypeId) INCLUDE (Score, Title)
+ StackOverflow2013.dbo.Comments (impact: 19%)
+ CREATE NONCLUSTERED INDEX [IX_Comments_PostId]
+ ON dbo.Comments (PostId)
+
+=== Summary ===
+ Warnings: 8 (4 critical)
+ Missing indexes: 2
+ Actual stats: yes
+ Warning types: Filter Operator, Large Memory Grant, Parallel Skew,
+ Row Estimate Mismatch, Scan With Predicate
+```
+
+### JSON output (default)
+
+The default JSON output includes the full operator tree, making it suitable for CI pipelines, LLM consumption, or further processing. See [`examples/output/`](examples/output/) for complete examples.
+
+### Batch processing
+
+```
+$ planview analyze ./examples/queries/ --server sql2022 \
+ --database StackOverflow2013 --trust-cert --output-dir ./results/
+
+Capturing actual plans from sql2022/StackOverflow2013
+
+[1/5] 01_top_users_by_posts ... OK (1.8s)
+[2/5] 02_recent_questions ... OK (0.8s)
+[3/5] 03_unanswered_high_score ... OK (0.7s)
+[4/5] 04_comment_heavy_posts ... OK (4.7s)
+[5/5] 05_user_vote_summary ... OK (4.3s)
+
+Processed 5 files: 5 succeeded, 0 failed
+Output: ./results/
+```
+
+## Desktop GUI
+
+The Avalonia-based GUI renders execution plans visually with the same operator icons as SSMS. Open `.sqlplan` files via File > Open or drag-and-drop.
+
+Features:
+- Graphical plan tree with cost percentages and row counts
+- Warning badge on root node showing total warning count
+- Plan Insights panel — three-column view with runtime summary, missing indexes, and wait stats visualization
+- Zoom and pan (mouse wheel + middle-click drag)
+- Minimap for quick navigation of large plans
+- Color-coded links between operators based on accuracy ratio divergence (estimates vs actuals)
+- Click any operator to see full properties (30 sections)
+- Statement grid with sortable columns (cost, rows, DOP, warnings)
+- Tooltips on hover with key operator metrics
+- **Advice for Humans** — one-click text analysis report you can read or share
+- **Advice for Robots** — one-click JSON export designed for LLMs and automation
+- **Plan Comparison** — compare two plans side-by-side (cost, runtime, I/O, memory, wait stats)
+- **Copy Repro Script** — extracts parameters, SET options, and query text into a runnable `sp_executesql` script
+- **Get Actual Plan** — connect to a server and re-execute the query to capture runtime stats
+- **Query Store Analysis** — connect to a server and analyze top queries by CPU, duration, or reads
+- **Query History** — view a history of executed queries with their plans along the timeline and metrics from query store
+- **MCP Server** — built-in Model Context Protocol server for AI-assisted plan analysis (opt-in)
+- Dark theme
+
+```bash
+dotnet run --project src/PlanViewer.App
+```
+
+## SSMS Extension
+
+A VSIX extension that adds **"Open in Performance Studio"** to the execution plan right-click context menu in SSMS 18-22.
+
+### How it works
+
+1. Right-click on any execution plan in SSMS
+2. Click "Open in Performance Studio"
+3. The extension extracts the plan XML via reflection and saves it to a temp file
+4. Performance Studio opens with the plan loaded
+
+### Installation
+
+1. Download `PlanViewer.Ssms.vsix` and `InstallSsmsExtension.exe` from the [v0.7.0 release](https://github.com/erikdarlingdata/PerformanceStudio/releases/tag/v0.7.0) (SSMS extension is not yet included in automated builds)
+2. Place them in the same folder
+3. Double-click `InstallSsmsExtension.exe` and approve the UAC prompt
+4. The installer auto-detects SSMS 21 and/or SSMS 22 and installs into both
+5. Restart SSMS to activate the extension
+
+### First run
+
+On first use, if Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once.
+
+The extension searches for the app in this order:
+1. Registry key (set automatically after first browse)
+2. System PATH
+3. Common install locations (`%LOCALAPPDATA%\Programs\SQLPerformanceStudio\`, `Program Files`, etc.)
+
+## MCP Server (LLM Integration)
+
+The desktop GUI includes an embedded [Model Context Protocol](https://modelcontextprotocol.io) server that exposes loaded execution plans and Query Store data to LLM clients like Claude Code and Cursor.
+
+### Setup
+
+1. Enable the MCP server in `~/.planview/settings.json`:
+
+```json
+{
+ "mcp_enabled": true,
+ "mcp_port": 5152
+}
+```
+
+2. Register with Claude Code:
+
+```
+claude mcp add --transport http --scope user performance-studio http://localhost:5152/
+```
+
+3. Open a new Claude Code session and ask questions like:
+ - "What plans are loaded in the application?"
+ - "Analyze the execution plan and tell me what's wrong"
+ - "Are there any missing index suggestions?"
+ - "Compare these two plans — which is better?"
+ - "Fetch the top 10 queries by CPU from Query Store"
+
+### Available Tools
+
+13 tools for plan analysis and Query Store data:
+
+| Category | Tools |
+|---|---|
+| Discovery | `list_plans`, `get_connections` |
+| Plan Analysis | `analyze_plan`, `get_plan_summary`, `get_plan_warnings`, `get_missing_indexes`, `get_plan_parameters`, `get_expensive_operators`, `get_plan_xml`, `compare_plans`, `get_repro_script` |
+| Query Store | `check_query_store`, `get_query_store_top` |
+
+Plan analysis tools work on plans loaded in the app (via file open, paste, query execution, or Query Store fetch). Query Store tools use a built-in read-only DMV query — no arbitrary SQL can be executed.
+
+The MCP server binds to `localhost` only and does not accept remote connections. Disabled by default.
+
+## Project Structure
+
+```
+PerformanceStudio/
+├── src/
+│ ├── PlanViewer.Core/ # Analysis engine (parser, 30 rules, layout)
+│ ├── PlanViewer.App/ # Avalonia desktop GUI
+│ ├── PlanViewer.Cli/ # CLI tool (planview command)
+│ ├── PlanViewer.Ssms/ # SSMS extension (.vsix, .NET Framework 4.7.2)
+│ └── PlanViewer.Ssms.Installer/ # SSMS extension installer (auto-detects SSMS 21/22)
+├── tests/
+│ └── PlanViewer.Core.Tests/ # 37 xUnit tests with real .sqlplan fixtures
+├── examples/
+│ ├── plans/ # Sample .sqlplan files for testing
+│ ├── queries/ # Sample .sql files
+│ └── output/ # Generated .sqlplan, .analysis.json, .txt
+└── PlanViewer.sln
+```
+
+## CLI Reference
+
+### `planview analyze`
+
+```
+Usage: planview analyze [] [options]
+
+Arguments:
+ .sqlplan file, .sql file, or directory of .sql files
+
+Options:
+ --stdin Read plan XML from stdin
+ -o, --output json (default) or text
+ --compact Compact JSON (no indentation)
+ --warnings-only Skip operator tree, only output warnings and indexes
+ -s, --server SQL Server name (matches credential store key)
+ -d, --database Database context for execution
+ -q, --query Inline SQL text to execute
+ --output-dir Directory for output files
+ --estimated Estimated plan only (query is NOT executed)
+ --auth windows, sql, or entra (default: auto-detect)
+ --trust-cert Trust server certificate
+ --timeout Query timeout (default: 60)
+ --login SQL Server login (bypasses credential store)
+ --password SQL Server password (bypasses credential store)
+```
+
+### `planview credential`
+
+```
+planview credential add --user [-p ]
+planview credential list
+planview credential remove
+```
+
+## Authentication
+
+There are three ways to authenticate, in order of precedence:
+
+1. **`--login` / `--password`** — passed directly on the command line (or via `.env` file). Simplest for dev/test.
+2. **Credential store** — stored in Windows Credential Manager or Apple Keychain via `planview credential add`. Best for repeated use.
+3. **Windows Authentication** — used automatically when no SQL credentials are found. Requires a valid Kerberos ticket.
+
+Override the auto-detection with `--auth windows`, `--auth sql`, or `--auth entra`.
+
+**macOS note:** Windows Authentication does not work on macOS (no Kerberos ticket by default). Use `--login`/`--password`, the credential store, or `--auth entra` instead.
+
+## Platform Support
+
+| Platform | GUI | CLI | Credential Store |
+|----------|-----|-----|-----------------|
+| Windows | Yes | Yes | Windows Credential Manager |
+| macOS | Yes | Yes | Apple Keychain |
+| Linux | Yes | Yes | Not yet (file analysis works) |
+
+### macOS: SQL Server via Docker
+
+macOS users need a SQL Server instance to use the live capture features. The easiest path is Docker:
+
+```bash
+docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=YourPassword123" \
+ -p 1433:1433 --name sql_server \
+ -d mcr.microsoft.com/mssql/server:2022-latest
+
+# Store the credential
+planview credential add localhost --user sa -p YourPassword123
+
+# Test connectivity
+planview analyze --server localhost --database master --trust-cert \
+ --query "SELECT @@VERSION" -o text
+```
+
+Always use `--trust-cert` with local Docker instances.
+
+## Analysis Rules
+
+The analyzer runs 30 rules against each plan, covering:
+
+| Category | Rules |
+|----------|-------|
+| Memory | Large grants, grant vs used ratio, spills to TempDB (including exchange spills) |
+| Estimates | Row estimate mismatches (10x+), zero-row actuals, row goals |
+| Indexes | Missing index suggestions, key lookups, RID lookups, scan with residual predicates |
+| Parallelism | Serial plan reasons, thread skew, ineffective parallelism, DOP reporting |
+| Joins | Nested loop high executions, many-to-many merge join worktables |
+| Filters | Late filter operators with impact quantification, function-wrapped predicates |
+| Functions | Scalar UDF detection (T-SQL and CLR), UDF timing |
+| Parameters | Compiled vs runtime values, sniffing issue detection |
+| Patterns | Leading wildcards, implicit conversions (with seek plan severity upgrade), OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns, OR expansion, CASE in predicates |
+| Compilation | High compile CPU, compile memory exceeded, early abort |
+| Objects | Table variables, table-valued functions, CTE multiple references, eager index spools, lazy spools, row count spools |
+| Operators | Operator self-time calculation with per-thread awareness for parallel plans |
+
+Rules can be disabled or have their severity overridden via a `.planview.json` config file. See the `--config` option.
+
+## Sponsors
+
+
+
+## License
+
+MIT — see [LICENSE](LICENSE).
+
+Execution plan operator icons are from Microsoft's [vscode-mssql](https://github.com/microsoft/vscode-mssql) extension (MIT). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details.
diff --git a/SECURITY.md b/SECURITY.md
index 5a54ea9..da867d4 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,34 +1,34 @@
-# Security Policy
-
-## Reporting a Vulnerability
-
-If you discover a security vulnerability in Performance Studio, please report it responsibly.
-
-**Do not open a public GitHub issue for security vulnerabilities.**
-
-Instead, please email **erik@erikdarling.com** with:
-
-- A description of the vulnerability
-- Steps to reproduce the issue
-- The potential impact
-- Any suggested fixes (optional)
-
-You should receive a response within 72 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
-
-## Scope
-
-This policy applies to:
-
-- Desktop application (PlanViewer.App)
-- Core analysis library (PlanViewer.Core)
-- CLI tool (PlanViewer.Cli)
-- SSMS extension (PlanViewer.Ssms)
-
-## Security Best Practices
-
-When using Performance Studio:
-
-- Use Windows Authentication where possible when connecting to SQL Server
-- Use dedicated accounts with minimal required permissions
-- Enable encryption for SQL Server connections
-- Keep your SQL Server instances patched and up to date
+# Security Policy
+
+## Reporting a Vulnerability
+
+If you discover a security vulnerability in Performance Studio, please report it responsibly.
+
+**Do not open a public GitHub issue for security vulnerabilities.**
+
+Instead, please email **erik@erikdarling.com** with:
+
+- A description of the vulnerability
+- Steps to reproduce the issue
+- The potential impact
+- Any suggested fixes (optional)
+
+You should receive a response within 72 hours. We will work with you to understand the issue and coordinate a fix before any public disclosure.
+
+## Scope
+
+This policy applies to:
+
+- Desktop application (PlanViewer.App)
+- Core analysis library (PlanViewer.Core)
+- CLI tool (PlanViewer.Cli)
+- SSMS extension (PlanViewer.Ssms)
+
+## Security Best Practices
+
+When using Performance Studio:
+
+- Use Windows Authentication where possible when connecting to SQL Server
+- Use dedicated accounts with minimal required permissions
+- Enable encryption for SQL Server connections
+- Keep your SQL Server instances patched and up to date
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index e253b2c..81c99ec 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,49 +1,49 @@
-# Third-Party Notices
-
-Performance Studio includes the following third-party open-source components. Each component is subject to the license terms specified below.
-
----
-
-## vscode-mssql (Execution Plan Icons)
-
-**Author**: Microsoft Corporation
-**Repository**: https://github.com/microsoft/vscode-mssql
-**License**: MIT License
-
-Execution plan operator icons (PNG) from the vscode-mssql extension are used to render graphical execution plans. Icons are located in `src/PlanViewer.Core/Resources/PlanIcons/`.
-
-### License Text
-
-MIT License
-
-Copyright (c) Microsoft Corporation
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
-Full license: https://github.com/microsoft/vscode-mssql/blob/main/LICENSE
-
----
-
-## Acknowledgments
-
-Performance Studio uses execution plan operator icons from **Microsoft's vscode-mssql extension**, which provides SQL Server tooling for Visual Studio Code. We are grateful for their commitment to open-source software.
-
----
-
-*Last Updated: March 5, 2026*
+# Third-Party Notices
+
+Performance Studio includes the following third-party open-source components. Each component is subject to the license terms specified below.
+
+---
+
+## vscode-mssql (Execution Plan Icons)
+
+**Author**: Microsoft Corporation
+**Repository**: https://github.com/microsoft/vscode-mssql
+**License**: MIT License
+
+Execution plan operator icons (PNG) from the vscode-mssql extension are used to render graphical execution plans. Icons are located in `src/PlanViewer.Core/Resources/PlanIcons/`.
+
+### License Text
+
+MIT License
+
+Copyright (c) Microsoft Corporation
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+Full license: https://github.com/microsoft/vscode-mssql/blob/main/LICENSE
+
+---
+
+## Acknowledgments
+
+Performance Studio uses execution plan operator icons from **Microsoft's vscode-mssql extension**, which provides SQL Server tooling for Visual Studio Code. We are grateful for their commitment to open-source software.
+
+---
+
+*Last Updated: March 5, 2026*
diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj
index 854439d..5e2bf89 100644
--- a/server/PlanShare/PlanShare.csproj
+++ b/server/PlanShare/PlanShare.csproj
@@ -1,13 +1,13 @@
-
-
-
- net8.0
- enable
- enable
-
-
-
-
-
-
-
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/server/PlanShare/Properties/launchSettings.json b/server/PlanShare/Properties/launchSettings.json
index 26c588f..4fcd679 100644
--- a/server/PlanShare/Properties/launchSettings.json
+++ b/server/PlanShare/Properties/launchSettings.json
@@ -1,38 +1,38 @@
-{
- "$schema": "http://json.schemastore.org/launchsettings.json",
- "iisSettings": {
- "windowsAuthentication": false,
- "anonymousAuthentication": true,
- "iisExpress": {
- "applicationUrl": "http://localhost:60802",
- "sslPort": 44322
- }
- },
- "profiles": {
- "http": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "http://localhost:5271",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "https": {
- "commandName": "Project",
- "dotnetRunMessages": true,
- "launchBrowser": true,
- "applicationUrl": "https://localhost:7060;http://localhost:5271",
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- },
- "IIS Express": {
- "commandName": "IISExpress",
- "launchBrowser": true,
- "environmentVariables": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- }
- }
- }
-}
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:60802",
+ "sslPort": 44322
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5271",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7060;http://localhost:5271",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/server/PlanShare/appsettings.Development.json b/server/PlanShare/appsettings.Development.json
index ff66ba6..0c208ae 100644
--- a/server/PlanShare/appsettings.Development.json
+++ b/server/PlanShare/appsettings.Development.json
@@ -1,8 +1,8 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/server/PlanShare/appsettings.json b/server/PlanShare/appsettings.json
index 4d56694..10f68b8 100644
--- a/server/PlanShare/appsettings.json
+++ b/server/PlanShare/appsettings.json
@@ -1,9 +1,9 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- },
- "AllowedHosts": "*"
-}
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/src/PlanViewer.App/AboutWindow.axaml b/src/PlanViewer.App/AboutWindow.axaml
index 7c49c0b..17f422d 100644
--- a/src/PlanViewer.App/AboutWindow.axaml
+++ b/src/PlanViewer.App/AboutWindow.axaml
@@ -1,90 +1,90 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs
index 89ac6d7..699a2b7 100644
--- a/src/PlanViewer.App/AboutWindow.axaml.cs
+++ b/src/PlanViewer.App/AboutWindow.axaml.cs
@@ -1,230 +1,230 @@
-/*
- * Performance Studio — SQL Server Execution Plan Analyzer
- * Copyright (c) 2026 Erik Darling, Darling Data LLC
- * Licensed under the MIT License - see LICENSE file for details
- */
-
-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;
-using Avalonia.Interactivity;
-using PlanViewer.App.Mcp;
-using PlanViewer.App.Services;
-using Velopack;
-
-namespace PlanViewer.App;
-
-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";
-
- public AboutWindow()
- {
- InitializeComponent();
- var version = Assembly.GetExecutingAssembly().GetName().Version;
- if (version != null)
- 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();
-
- // Save on change
- McpEnabledCheckBox.IsCheckedChanged += (_, _) => SaveMcpSettings();
- McpPortInput.LostFocus += (_, _) => SaveMcpSettings();
- }
-
- 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
- {
- mcp_enabled = McpEnabledCheckBox.IsChecked == true,
- mcp_port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152
- }, new JsonSerializerOptions { WriteIndented = true });
-
- Directory.CreateDirectory(settingsDir);
- Services.AtomicFile.WriteAllText(settingsFile, json);
- }
-
- private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);
- private void ReportIssueLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(IssuesUrl);
- private void DarlingDataLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(DarlingDataUrl);
- private async void CopyMcpCommand_Click(object? sender, RoutedEventArgs e)
- {
- var port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
- var command = $"claude mcp add --transport streamable-http --scope user performance-studio http://localhost:{port}/";
- var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(command);
- McpCopyStatus.Text = "Copied to clipboard!";
- }
- }
-
- private string? _updateUrl;
- private UpdateManager? _velopackMgr;
- private UpdateInfo? _velopackUpdate;
-
- private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
- {
- CheckUpdateButton.IsEnabled = false;
- UpdateStatusText.Text = "Checking...";
- UpdateLink.IsVisible = false;
-
- // Try Velopack first (Windows only, supports download + apply)
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- {
- try
- {
- _velopackMgr = new UpdateManager(
- new Velopack.Sources.GithubSource(
- "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
-
- _velopackUpdate = await _velopackMgr.CheckForUpdatesAsync();
- if (_velopackUpdate != null)
- {
- UpdateStatusText.Text = "Update available:";
- UpdateLink.Text = $"v{_velopackUpdate.TargetFullRelease.Version} — click to install";
- UpdateLink.IsVisible = true;
- CheckUpdateButton.IsEnabled = true;
- return;
- }
- }
- catch
- {
- // Velopack packages may not exist yet — fall through
- }
- }
-
- // Fallback: GitHub API check (opens browser)
- var currentVersion = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
- var result = await UpdateChecker.CheckAsync(currentVersion);
-
- if (result.Error != null)
- {
- UpdateStatusText.Text = $"Error: {result.Error}";
- }
- else if (result.UpdateAvailable)
- {
- UpdateStatusText.Text = $"New version available:";
- UpdateLink.Text = result.LatestVersion;
- UpdateLink.IsVisible = true;
- _updateUrl = result.ReleaseUrl;
- }
- else
- {
- UpdateStatusText.Text = $"You're up to date ({result.LatestVersion})";
- }
-
- CheckUpdateButton.IsEnabled = true;
- }
-
- private bool _updateDownloaded;
-
- private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
- {
- // Step 3: User clicks "Restart now" after download — confirm first
- if (_updateDownloaded && _velopackMgr != null && _velopackUpdate != null)
- {
- var dialog = new Avalonia.Controls.Window
- {
- Title = "Update Ready",
- Width = 350, Height = 150,
- WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterOwner,
- CanResize = false
- };
-
- var result = false;
- var panel = new Avalonia.Controls.StackPanel
- {
- Margin = new Avalonia.Thickness(20),
- Spacing = 15
- };
- panel.Children.Add(new Avalonia.Controls.TextBlock
- {
- Text = "The application will close and restart with the new version. Continue?",
- TextWrapping = Avalonia.Media.TextWrapping.Wrap
- });
- var buttonPanel = new Avalonia.Controls.StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
- Spacing = 8
- };
- var okButton = new Avalonia.Controls.Button { Content = "Restart Now" };
- var cancelButton = new Avalonia.Controls.Button { Content = "Later" };
- okButton.Click += (_, _) => { result = true; dialog.Close(); };
- cancelButton.Click += (_, _) => { dialog.Close(); };
- buttonPanel.Children.Add(okButton);
- buttonPanel.Children.Add(cancelButton);
- panel.Children.Add(buttonPanel);
- dialog.Content = panel;
-
- await dialog.ShowDialog(this);
-
- if (result)
- {
- _velopackMgr.ApplyUpdatesAndRestart(_velopackUpdate.TargetFullRelease);
- }
- return;
- }
-
- // Step 2: User clicks to download
- if (_velopackMgr != null && _velopackUpdate != null)
- {
- try
- {
- UpdateLink.IsVisible = false;
- UpdateStatusText.Text = "Downloading update...";
-
- await _velopackMgr.DownloadUpdatesAsync(_velopackUpdate);
-
- _updateDownloaded = true;
- UpdateStatusText.Text = "Update downloaded.";
- UpdateLink.Text = "Restart now to apply";
- UpdateLink.IsVisible = true;
- }
- catch (Exception ex)
- {
- UpdateStatusText.Text = $"Update failed: {ex.Message}";
- UpdateLink.IsVisible = false;
- }
- return;
- }
-
- // Fallback: open browser
- if (_updateUrl != null) OpenUrl(_updateUrl);
- }
-
- private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
-
- private static void OpenUrl(string url)
- {
- try
- {
- if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
- Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
- else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- Process.Start("open", url);
- else
- Process.Start("xdg-open", url);
- }
- catch
- {
- // Silently fail — nothing useful to show the user
- }
- }
-}
+/*
+ * Performance Studio — SQL Server Execution Plan Analyzer
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ * Licensed under the MIT License - see LICENSE file for details
+ */
+
+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;
+using Avalonia.Interactivity;
+using PlanViewer.App.Mcp;
+using PlanViewer.App.Services;
+using Velopack;
+
+namespace PlanViewer.App;
+
+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";
+
+ public AboutWindow()
+ {
+ InitializeComponent();
+ var version = Assembly.GetExecutingAssembly().GetName().Version;
+ if (version != null)
+ 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();
+
+ // Save on change
+ McpEnabledCheckBox.IsCheckedChanged += (_, _) => SaveMcpSettings();
+ McpPortInput.LostFocus += (_, _) => SaveMcpSettings();
+ }
+
+ 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
+ {
+ mcp_enabled = McpEnabledCheckBox.IsChecked == true,
+ mcp_port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152
+ }, new JsonSerializerOptions { WriteIndented = true });
+
+ Directory.CreateDirectory(settingsDir);
+ Services.AtomicFile.WriteAllText(settingsFile, json);
+ }
+
+ private void GitHubLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(GitHubUrl);
+ private void ReportIssueLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(IssuesUrl);
+ private void DarlingDataLink_Click(object? sender, PointerPressedEventArgs e) => OpenUrl(DarlingDataUrl);
+ private async void CopyMcpCommand_Click(object? sender, RoutedEventArgs e)
+ {
+ var port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
+ var command = $"claude mcp add --transport streamable-http --scope user performance-studio http://localhost:{port}/";
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(command);
+ McpCopyStatus.Text = "Copied to clipboard!";
+ }
+ }
+
+ private string? _updateUrl;
+ private UpdateManager? _velopackMgr;
+ private UpdateInfo? _velopackUpdate;
+
+ private async void CheckUpdate_Click(object? sender, RoutedEventArgs e)
+ {
+ CheckUpdateButton.IsEnabled = false;
+ UpdateStatusText.Text = "Checking...";
+ UpdateLink.IsVisible = false;
+
+ // Try Velopack first (Windows only, supports download + apply)
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ try
+ {
+ _velopackMgr = new UpdateManager(
+ new Velopack.Sources.GithubSource(
+ "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
+
+ _velopackUpdate = await _velopackMgr.CheckForUpdatesAsync();
+ if (_velopackUpdate != null)
+ {
+ UpdateStatusText.Text = "Update available:";
+ UpdateLink.Text = $"v{_velopackUpdate.TargetFullRelease.Version} — click to install";
+ UpdateLink.IsVisible = true;
+ CheckUpdateButton.IsEnabled = true;
+ return;
+ }
+ }
+ catch
+ {
+ // Velopack packages may not exist yet — fall through
+ }
+ }
+
+ // Fallback: GitHub API check (opens browser)
+ var currentVersion = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
+ var result = await UpdateChecker.CheckAsync(currentVersion);
+
+ if (result.Error != null)
+ {
+ UpdateStatusText.Text = $"Error: {result.Error}";
+ }
+ else if (result.UpdateAvailable)
+ {
+ UpdateStatusText.Text = $"New version available:";
+ UpdateLink.Text = result.LatestVersion;
+ UpdateLink.IsVisible = true;
+ _updateUrl = result.ReleaseUrl;
+ }
+ else
+ {
+ UpdateStatusText.Text = $"You're up to date ({result.LatestVersion})";
+ }
+
+ CheckUpdateButton.IsEnabled = true;
+ }
+
+ private bool _updateDownloaded;
+
+ private async void UpdateLink_Click(object? sender, PointerPressedEventArgs e)
+ {
+ // Step 3: User clicks "Restart now" after download — confirm first
+ if (_updateDownloaded && _velopackMgr != null && _velopackUpdate != null)
+ {
+ var dialog = new Avalonia.Controls.Window
+ {
+ Title = "Update Ready",
+ Width = 350, Height = 150,
+ WindowStartupLocation = Avalonia.Controls.WindowStartupLocation.CenterOwner,
+ CanResize = false
+ };
+
+ var result = false;
+ var panel = new Avalonia.Controls.StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 15
+ };
+ panel.Children.Add(new Avalonia.Controls.TextBlock
+ {
+ Text = "The application will close and restart with the new version. Continue?",
+ TextWrapping = Avalonia.Media.TextWrapping.Wrap
+ });
+ var buttonPanel = new Avalonia.Controls.StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
+ Spacing = 8
+ };
+ var okButton = new Avalonia.Controls.Button { Content = "Restart Now" };
+ var cancelButton = new Avalonia.Controls.Button { Content = "Later" };
+ okButton.Click += (_, _) => { result = true; dialog.Close(); };
+ cancelButton.Click += (_, _) => { dialog.Close(); };
+ buttonPanel.Children.Add(okButton);
+ buttonPanel.Children.Add(cancelButton);
+ panel.Children.Add(buttonPanel);
+ dialog.Content = panel;
+
+ await dialog.ShowDialog(this);
+
+ if (result)
+ {
+ _velopackMgr.ApplyUpdatesAndRestart(_velopackUpdate.TargetFullRelease);
+ }
+ return;
+ }
+
+ // Step 2: User clicks to download
+ if (_velopackMgr != null && _velopackUpdate != null)
+ {
+ try
+ {
+ UpdateLink.IsVisible = false;
+ UpdateStatusText.Text = "Downloading update...";
+
+ await _velopackMgr.DownloadUpdatesAsync(_velopackUpdate);
+
+ _updateDownloaded = true;
+ UpdateStatusText.Text = "Update downloaded.";
+ UpdateLink.Text = "Restart now to apply";
+ UpdateLink.IsVisible = true;
+ }
+ catch (Exception ex)
+ {
+ UpdateStatusText.Text = $"Update failed: {ex.Message}";
+ UpdateLink.IsVisible = false;
+ }
+ return;
+ }
+
+ // Fallback: open browser
+ if (_updateUrl != null) OpenUrl(_updateUrl);
+ }
+
+ private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close();
+
+ private static void OpenUrl(string url)
+ {
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true });
+ else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ Process.Start("open", url);
+ else
+ Process.Start("xdg-open", url);
+ }
+ catch
+ {
+ // Silently fail — nothing useful to show the user
+ }
+ }
+}
diff --git a/src/PlanViewer.App/App.axaml b/src/PlanViewer.App/App.axaml
index 6241169..8a8c17d 100644
--- a/src/PlanViewer.App/App.axaml
+++ b/src/PlanViewer.App/App.axaml
@@ -1,27 +1,27 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/App.axaml.cs b/src/PlanViewer.App/App.axaml.cs
index 6baee52..3fb559b 100644
--- a/src/PlanViewer.App/App.axaml.cs
+++ b/src/PlanViewer.App/App.axaml.cs
@@ -1,42 +1,42 @@
-using System;
-using System.Runtime.InteropServices;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.ApplicationLifetimes;
-using Avalonia.Markup.Xaml;
-
-namespace PlanViewer.App;
-
-public partial class App : Application
-{
- public override void Initialize()
- {
- AvaloniaXamlLoader.Load(this);
- }
-
- public override void OnFrameworkInitializationCompleted()
- {
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
- {
- desktop.MainWindow = new MainWindow();
- }
-
- if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- {
- var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "EDD.icns");
- MacOSDockIcon.SetDockIcon(iconPath);
- }
-
- base.OnFrameworkInitializationCompleted();
- }
-
- private void OnAboutClicked(object? sender, System.EventArgs e)
- {
- if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
- && desktop.MainWindow is Window mainWindow)
- {
- var about = new AboutWindow();
- about.ShowDialog(mainWindow);
- }
- }
+using System;
+using System.Runtime.InteropServices;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+
+namespace PlanViewer.App;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ {
+ var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "EDD.icns");
+ MacOSDockIcon.SetDockIcon(iconPath);
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ private void OnAboutClicked(object? sender, System.EventArgs e)
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
+ && desktop.MainWindow is Window mainWindow)
+ {
+ var about = new AboutWindow();
+ about.ShowDialog(mainWindow);
+ }
+ }
}
\ No newline at end of file
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
index 7bb50c3..d7cae00 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml
@@ -1,379 +1,379 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index f8d024a..a2d77e3 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -1,4497 +1,4497 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.RegularExpressions;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.Shapes;
-using Avalonia.Input;
-using Avalonia.Interactivity;
-using Avalonia.Layout;
-using Avalonia.Media;
-using Avalonia.Controls.Primitives;
-using Avalonia.Controls.Templates;
-using Avalonia.Platform.Storage;
-using AvaloniaEdit.TextMate;
-using Microsoft.Data.SqlClient;
-using PlanViewer.App.Dialogs;
-using PlanViewer.Core.Interfaces;
-using PlanViewer.App.Helpers;
-using PlanViewer.App.Services;
-using PlanViewer.App.Mcp;
-using PlanViewer.Core.Models;
-using PlanViewer.Core.Output;
-using PlanViewer.Core.Services;
-
-using AvaloniaPath = Avalonia.Controls.Shapes.Path;
-
-namespace PlanViewer.App.Controls;
-
-public class StatementRow
-{
- public int Index { get; set; }
- public string QueryText { get; set; } = "";
- public string FullQueryText { get; set; } = "";
- public long CpuMs { get; set; }
- public long ElapsedMs { get; set; }
- public long UdfMs { get; set; }
- public double EstCost { get; set; }
- public int Critical { get; set; }
- public int Warnings { get; set; }
- public PlanStatement Statement { get; set; } = null!;
-
- // Display helpers
- public string CpuDisplay => FormatDuration(CpuMs);
- public string ElapsedDisplay => FormatDuration(ElapsedMs);
- public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : "";
- public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : "";
-
- private static string FormatDuration(long ms)
- {
- if (ms < 1000) return $"{ms}ms";
- if (ms < 60_000) return $"{ms / 1000.0:F1}s";
- return $"{ms / 60_000}m {(ms % 60_000) / 1000}s";
- }
-}
-
-public partial class PlanViewerControl : UserControl
-{
- private readonly string _mcpSessionId = Guid.NewGuid().ToString();
- private ParsedPlan? _currentPlan;
- private PlanStatement? _currentStatement;
- private string? _queryText;
- private ServerMetadata? _serverMetadata;
- private double _zoomLevel = 1.0;
- private const double ZoomStep = 0.15;
- private const double MinZoom = 0.1;
- private const double MaxZoom = 3.0;
- private string _label = "";
-
- ///
- /// Full path on disk when the plan was loaded from a file.
- ///
- public string? SourceFilePath { get; set; }
-
- // Node selection
- private Border? _selectedNodeBorder;
- private IBrush? _selectedNodeOriginalBorder;
- private Thickness _selectedNodeOriginalThickness;
-
- // Border -> PlanNode mapping (replaces WPF Tag pattern)
- private readonly Dictionary _nodeBorderMap = new();
-
- // Brushes
- private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF));
- private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23));
- private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45));
- private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB));
- private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80));
- private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF));
- private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35));
- private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed);
- private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange);
- private static readonly SolidColorBrush MinimapExpensiveNodeBgBrush = new(Color.FromArgb(0x60, 0xE5, 0x73, 0x73));
-
- // Link accuracy coloring brushes (Dark theme)
- private static readonly SolidColorBrush LinkFluoBlueBrush = new(Color.FromRgb(0x00, 0xE5, 0xFF));
- private static readonly SolidColorBrush LinkLightBlueBrush = new(Color.FromRgb(0x64, 0xB5, 0xF6));
- private static readonly SolidColorBrush LinkBlueBrush = new(Color.FromRgb(0x42, 0x8B, 0xCA));
- private static readonly SolidColorBrush LinkLightOrangeBrush = new(Color.FromRgb(0xFF, 0xB7, 0x4D));
- private static readonly SolidColorBrush LinkFluoOrangeBrush = new(Color.FromRgb(0xFF, 0x8C, 0x00));
- private static readonly SolidColorBrush LinkFluoRedBrush = new(Color.FromRgb(0xFF, 0x17, 0x44));
-
-
- // Track all property section grids for synchronized column resize
- private readonly List _sectionLabelColumns = new();
- private double _propertyLabelWidth = 140;
- private bool _isSyncingColumnWidth;
- private Grid? _currentSectionGrid;
- private int _currentSectionRowIndex;
-
- // Non-control named elements that Avalonia codegen doesn't auto-generate fields for
- private readonly ColumnDefinition _statementsColumn;
- private readonly ColumnDefinition _statementsSplitterColumn;
- private readonly ColumnDefinition _splitterColumn;
- private readonly ColumnDefinition _propertiesColumn;
- private readonly ScaleTransform _zoomTransform;
-
- // Statement grid data
- private List? _allStatements;
-
- // Pan state
- private bool _isPanning;
- private Point _panStart;
- private double _panStartOffsetX;
- private double _panStartOffsetY;
-
- // Minimap state
- private static double _minimapWidth = 400;
- private static double _minimapHeight = 400;
- private const double MinimapMinSize = 200;
- private const double MinimapMaxSize = 500;
- private bool _minimapDragging;
- private Border? _minimapViewportBox;
- private bool _minimapResizing;
- private Point _minimapResizeStart;
- private double _minimapResizeStartW;
- private double _minimapResizeStartH;
- private readonly Dictionary _minimapNodeMap = new();
- private Border? _minimapSelectedNode;
- private PlanNode? _selectedNode;
-
- public PlanViewerControl()
- {
- InitializeComponent();
- // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event
- PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel);
- // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events
- PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel);
- PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel);
- PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel);
- PlanScrollViewer.ScrollChanged += (_, _) => UpdateMinimapViewportBox();
-
- // Resolve ColumnDefinitions from the named 5-column layout Grid.
- // (x:Name works on Grid but not on ColumnDefinition, so we index into the definitions.)
- // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0)
- _statementsColumn = PlanGrid.ColumnDefinitions[0];
- _statementsSplitterColumn = PlanGrid.ColumnDefinitions[1];
- _splitterColumn = PlanGrid.ColumnDefinitions[3];
- _propertiesColumn = PlanGrid.ColumnDefinitions[4];
-
- // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas
- var layoutTransform = this.FindControl("PlanLayoutTransform")!;
- _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
-
- Helpers.DataGridBehaviors.Attach(StatementsGrid);
-
- // Wire minimap resize grip (defined in AXAML, not in canvas)
- MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
- MinimapResizeGrip.PointerMoved += MinimapResizeGrip_PointerMoved;
- MinimapResizeGrip.PointerReleased += MinimapResizeGrip_PointerReleased;
-
- // Wire minimap canvas interaction handlers once
- MinimapCanvas.PointerPressed += MinimapCanvas_PointerPressed;
- MinimapCanvas.PointerMoved += MinimapCanvas_PointerMoved;
- MinimapCanvas.PointerReleased += MinimapCanvas_PointerReleased;
- }
-
- ///
- /// Exposes the raw XML so MainWindow can implement Save functionality.
- ///
- public string? RawXml => _currentPlan?.RawXml;
-
- ///
- /// Exposes the parsed and analyzed plan for advice generation.
- ///
- public ParsedPlan? CurrentPlan => _currentPlan;
-
- ///
- /// Exposes the query text associated with this plan (if any).
- ///
- public string? QueryText => _queryText;
-
- ///
- /// Server metadata for advice generation and Plan Insights display.
- ///
- public ServerMetadata? Metadata
- {
- get => _serverMetadata;
- set
- {
- _serverMetadata = value;
- if (_currentStatement != null)
- ShowServerContext();
- }
- }
-
- ///
- /// Connection string for schema lookups. Set when the plan was loaded from a connected session.
- ///
- public string? ConnectionString { get; set; }
-
- // Connection state for plans that connect via the toolbar
- private ServerConnection? _planConnection;
- private ICredentialService? _planCredentialService;
- private ConnectionStore? _planConnectionStore;
- private string? _planSelectedDatabase;
-
- ///
- /// Provide credential service and connection store so the plan viewer can show a connection dialog.
- ///
- public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore)
- {
- _planCredentialService = credentialService;
- _planConnectionStore = connectionStore;
- }
-
- ///
- /// Update the connection UI to reflect an active connection (used when connection is inherited).
- ///
- public void SetConnectionStatus(string serverName, string? database)
- {
- PlanServerLabel.Text = serverName;
- PlanServerLabel.Foreground = Brushes.LimeGreen;
- PlanConnectButton.Content = "Reconnect";
- if (database != null)
- _planSelectedDatabase = database;
- }
-
- // Events for MainWindow to wire up advice/repro actions
- public event EventHandler? HumanAdviceRequested;
- public event EventHandler? RobotAdviceRequested;
- public event EventHandler? CopyReproRequested;
- public event EventHandler? OpenInEditorRequested;
-
- ///
- /// Navigates to a specific plan node by ID: selects it, zooms to show it,
- /// and scrolls to center it in the viewport.
- ///
- public void NavigateToNode(int nodeId)
- {
- // Find the Border for this node
- Border? targetBorder = null;
- PlanNode? targetNode = null;
- foreach (var (border, node) in _nodeBorderMap)
- {
- if (node.NodeId == nodeId)
- {
- targetBorder = border;
- targetNode = node;
- break;
- }
- }
-
- if (targetBorder == null || targetNode == null)
- return;
-
- // Activate the parent window so the plan viewer becomes visible
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel is Window parentWindow)
- parentWindow.Activate();
-
- // Select the node (highlights it and shows properties)
- SelectNode(targetBorder, targetNode);
-
- // Ensure zoom level makes the node comfortably visible
- var viewWidth = PlanScrollViewer.Bounds.Width;
- var viewHeight = PlanScrollViewer.Bounds.Height;
- if (viewWidth <= 0 || viewHeight <= 0)
- return;
-
- // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport
- var nodeW = PlanLayoutEngine.NodeWidth;
- var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode);
- var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4));
- if (_zoomLevel < minVisibleZoom)
- SetZoom(Math.Min(minVisibleZoom, 1.0));
-
- // Scroll to center the node in the viewport
- var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2;
- var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2;
- centerX = Math.Max(0, centerX);
- centerY = Math.Max(0, centerY);
-
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- PlanScrollViewer.Offset = new Vector(centerX, centerY);
- });
- }
-
- public void LoadPlan(string planXml, string label, string? queryText = null)
- {
- _label = label;
- _queryText = queryText;
-
- // Query text stored for copy/repro but no longer shown in a
- // separate expander — it's already visible in the Statements grid.
-
- _currentPlan = ShowPlanParser.Parse(planXml);
- PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load(), _serverMetadata);
- BenefitScorer.Score(_currentPlan);
-
- var allStatements = _currentPlan.Batches
- .SelectMany(b => b.Statements)
- .Where(s => s.RootNode != null)
- .ToList();
-
- if (allStatements.Count == 0)
- {
- EmptyState.IsVisible = true;
- PlanScrollViewer.IsVisible = false;
- return;
- }
-
- EmptyState.IsVisible = false;
- PlanScrollViewer.IsVisible = true;
-
- // Always show statement grid — useful summary even for single-statement plans
- _allStatements = allStatements;
- PopulateStatementsGrid(allStatements);
- ShowStatementsPanel();
- StatementsGrid.SelectedIndex = 0;
-
- // Register with MCP session manager for AI tool access
- // Count warnings from both statement-level PlanWarnings and all node Warnings
- int warningCount = 0, criticalCount = 0;
- foreach (var s in allStatements)
- {
- warningCount += s.PlanWarnings.Count;
- criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
- if (s.RootNode != null)
- CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount);
- }
-
- PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession
- {
- SessionId = _mcpSessionId,
- Label = label,
- Source = "file",
- Plan = _currentPlan,
- QueryText = queryText,
- StatementCount = allStatements.Count,
- HasActualStats = allStatements.Any(s => s.QueryTimeStats != null),
- WarningCount = warningCount,
- CriticalWarningCount = criticalCount,
- MissingIndexCount = _currentPlan.AllMissingIndexes.Count
- });
- }
-
- public void Clear()
- {
- PlanSessionManager.Instance.Unregister(_mcpSessionId);
- PlanCanvas.Children.Clear();
- _nodeBorderMap.Clear();
- _currentPlan = null;
- _currentStatement = null;
- _queryText = null;
- _selectedNodeBorder = null;
- _selectedNode = null;
- EmptyState.IsVisible = true;
- PlanScrollViewer.IsVisible = false;
- InsightsPanel.IsVisible = false;
- CostText.Text = "";
- CloseStatementsPanel();
- StatementsButton.IsVisible = false;
- StatementsButtonSeparator.IsVisible = false;
- ClosePropertiesPanel();
- 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);
-
- // Re-apply selection highlight if a node is selected
- if (_selectedNode != null)
- UpdateMinimapSelection(_selectedNode);
- }
-
- private static readonly Color[] MinimapBranchColors =
- {
- Color.FromArgb(0x30, 0x4F, 0xA3, 0xFF), // blue
- Color.FromArgb(0x30, 0x7B, 0xCF, 0x7B), // green
- Color.FromArgb(0x30, 0xFF, 0xB3, 0x47), // orange
- Color.FromArgb(0x30, 0xE5, 0x73, 0x73), // red
- Color.FromArgb(0x30, 0xCF, 0x7B, 0xCF), // purple
- Color.FromArgb(0x30, 0x7B, 0xCF, 0xCF), // teal
- Color.FromArgb(0x30, 0xFF, 0xE0, 0x4F), // yellow
- 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
-
- private async void PlanConnect_Click(object? sender, RoutedEventArgs e)
- {
- if (_planCredentialService == null || _planConnectionStore == null) return;
-
- var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore);
- var topLevel = TopLevel.GetTopLevel(this);
- if (topLevel is not Window parentWindow) return;
-
- var result = await dialog.ShowDialog(parentWindow);
- if (result != true || dialog.ResultConnection == null) return;
-
- _planConnection = dialog.ResultConnection;
- _planSelectedDatabase = dialog.ResultDatabase;
- ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
-
- PlanServerLabel.Text = _planConnection.ServerName;
- PlanServerLabel.Foreground = Brushes.LimeGreen;
- PlanConnectButton.Content = "Reconnect";
-
- // Populate database dropdown
- try
- {
- var connStr = _planConnection.GetConnectionString(_planCredentialService, "master");
- await using var conn = new SqlConnection(connStr);
- await conn.OpenAsync();
-
- var databases = new List();
- using var cmd = new SqlCommand(
- "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; 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));
-
- PlanDatabaseBox.ItemsSource = databases;
- PlanDatabaseBox.IsEnabled = true;
-
- if (_planSelectedDatabase != null)
- {
- for (int i = 0; i < PlanDatabaseBox.Items.Count; i++)
- {
- if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase)
- {
- PlanDatabaseBox.SelectedIndex = i;
- break;
- }
- }
- }
- }
- catch
- {
- PlanDatabaseBox.IsEnabled = false;
- }
- }
-
- private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return;
-
- _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString();
- ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
- }
-
- #endregion
-
- #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
-}
-
-/// Sort DataGrid column by a long property on StatementRow.
-public class LongComparer : System.Collections.IComparer
-{
- private readonly Func _selector;
- public LongComparer(Func selector) => _selector = selector;
- public int Compare(object? x, object? y)
- {
- if (x is StatementRow a && y is StatementRow b)
- return _selector(a).CompareTo(_selector(b));
- return 0;
- }
-}
-
-/// Sort DataGrid column by a double property on StatementRow.
-public class DoubleComparer : System.Collections.IComparer
-{
- private readonly Func _selector;
- public DoubleComparer(Func selector) => _selector = selector;
- public int Compare(object? x, object? y)
- {
- if (x is StatementRow a && y is StatementRow b)
- return _selector(a).CompareTo(_selector(b));
- return 0;
- }
-}
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Shapes;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Layout;
+using Avalonia.Media;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Templates;
+using Avalonia.Platform.Storage;
+using AvaloniaEdit.TextMate;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Dialogs;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.App.Helpers;
+using PlanViewer.App.Services;
+using PlanViewer.App.Mcp;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+using AvaloniaPath = Avalonia.Controls.Shapes.Path;
+
+namespace PlanViewer.App.Controls;
+
+public class StatementRow
+{
+ public int Index { get; set; }
+ public string QueryText { get; set; } = "";
+ public string FullQueryText { get; set; } = "";
+ public long CpuMs { get; set; }
+ public long ElapsedMs { get; set; }
+ public long UdfMs { get; set; }
+ public double EstCost { get; set; }
+ public int Critical { get; set; }
+ public int Warnings { get; set; }
+ public PlanStatement Statement { get; set; } = null!;
+
+ // Display helpers
+ public string CpuDisplay => FormatDuration(CpuMs);
+ public string ElapsedDisplay => FormatDuration(ElapsedMs);
+ public string UdfDisplay => UdfMs > 0 ? FormatDuration(UdfMs) : "";
+ public string CostDisplay => EstCost > 0 ? $"{EstCost:F2}" : "";
+
+ private static string FormatDuration(long ms)
+ {
+ if (ms < 1000) return $"{ms}ms";
+ if (ms < 60_000) return $"{ms / 1000.0:F1}s";
+ return $"{ms / 60_000}m {(ms % 60_000) / 1000}s";
+ }
+}
+
+public partial class PlanViewerControl : UserControl
+{
+ private readonly string _mcpSessionId = Guid.NewGuid().ToString();
+ private ParsedPlan? _currentPlan;
+ private PlanStatement? _currentStatement;
+ private string? _queryText;
+ private ServerMetadata? _serverMetadata;
+ private double _zoomLevel = 1.0;
+ private const double ZoomStep = 0.15;
+ private const double MinZoom = 0.1;
+ private const double MaxZoom = 3.0;
+ private string _label = "";
+
+ ///
+ /// Full path on disk when the plan was loaded from a file.
+ ///
+ public string? SourceFilePath { get; set; }
+
+ // Node selection
+ private Border? _selectedNodeBorder;
+ private IBrush? _selectedNodeOriginalBorder;
+ private Thickness _selectedNodeOriginalThickness;
+
+ // Border -> PlanNode mapping (replaces WPF Tag pattern)
+ private readonly Dictionary _nodeBorderMap = new();
+
+ // Brushes
+ private static readonly SolidColorBrush SelectionBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF));
+ private static readonly SolidColorBrush TooltipBgBrush = new(Color.FromRgb(0x1A, 0x1D, 0x23));
+ private static readonly SolidColorBrush TooltipBorderBrush = new(Color.FromRgb(0x3A, 0x3D, 0x45));
+ private static readonly SolidColorBrush TooltipFgBrush = new(Color.FromRgb(0xE4, 0xE6, 0xEB));
+ private static readonly SolidColorBrush EdgeBrush = new(Color.FromRgb(0x6B, 0x72, 0x80));
+ private static readonly SolidColorBrush SectionHeaderBrush = new(Color.FromRgb(0x4F, 0xA3, 0xFF));
+ private static readonly SolidColorBrush PropSeparatorBrush = new(Color.FromRgb(0x2A, 0x2D, 0x35));
+ private static readonly SolidColorBrush OrangeRedBrush = new(Colors.OrangeRed);
+ private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange);
+ private static readonly SolidColorBrush MinimapExpensiveNodeBgBrush = new(Color.FromArgb(0x60, 0xE5, 0x73, 0x73));
+
+ // Link accuracy coloring brushes (Dark theme)
+ private static readonly SolidColorBrush LinkFluoBlueBrush = new(Color.FromRgb(0x00, 0xE5, 0xFF));
+ private static readonly SolidColorBrush LinkLightBlueBrush = new(Color.FromRgb(0x64, 0xB5, 0xF6));
+ private static readonly SolidColorBrush LinkBlueBrush = new(Color.FromRgb(0x42, 0x8B, 0xCA));
+ private static readonly SolidColorBrush LinkLightOrangeBrush = new(Color.FromRgb(0xFF, 0xB7, 0x4D));
+ private static readonly SolidColorBrush LinkFluoOrangeBrush = new(Color.FromRgb(0xFF, 0x8C, 0x00));
+ private static readonly SolidColorBrush LinkFluoRedBrush = new(Color.FromRgb(0xFF, 0x17, 0x44));
+
+
+ // Track all property section grids for synchronized column resize
+ private readonly List _sectionLabelColumns = new();
+ private double _propertyLabelWidth = 140;
+ private bool _isSyncingColumnWidth;
+ private Grid? _currentSectionGrid;
+ private int _currentSectionRowIndex;
+
+ // Non-control named elements that Avalonia codegen doesn't auto-generate fields for
+ private readonly ColumnDefinition _statementsColumn;
+ private readonly ColumnDefinition _statementsSplitterColumn;
+ private readonly ColumnDefinition _splitterColumn;
+ private readonly ColumnDefinition _propertiesColumn;
+ private readonly ScaleTransform _zoomTransform;
+
+ // Statement grid data
+ private List? _allStatements;
+
+ // Pan state
+ private bool _isPanning;
+ private Point _panStart;
+ private double _panStartOffsetX;
+ private double _panStartOffsetY;
+
+ // Minimap state
+ private static double _minimapWidth = 400;
+ private static double _minimapHeight = 400;
+ private const double MinimapMinSize = 200;
+ private const double MinimapMaxSize = 500;
+ private bool _minimapDragging;
+ private Border? _minimapViewportBox;
+ private bool _minimapResizing;
+ private Point _minimapResizeStart;
+ private double _minimapResizeStartW;
+ private double _minimapResizeStartH;
+ private readonly Dictionary _minimapNodeMap = new();
+ private Border? _minimapSelectedNode;
+ private PlanNode? _selectedNode;
+
+ public PlanViewerControl()
+ {
+ InitializeComponent();
+ // Use Tunnel routing so Ctrl+wheel zoom fires before ScrollViewer consumes the event
+ PlanScrollViewer.AddHandler(PointerWheelChangedEvent, PlanScrollViewer_PointerWheelChanged, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ // Use Tunnel routing so pan handlers fire before ScrollViewer consumes the events
+ PlanScrollViewer.AddHandler(PointerPressedEvent, PlanScrollViewer_PointerPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ PlanScrollViewer.AddHandler(PointerMovedEvent, PlanScrollViewer_PointerMoved, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ PlanScrollViewer.AddHandler(PointerReleasedEvent, PlanScrollViewer_PointerReleased, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+ PlanScrollViewer.ScrollChanged += (_, _) => UpdateMinimapViewportBox();
+
+ // Resolve ColumnDefinitions from the named 5-column layout Grid.
+ // (x:Name works on Grid but not on ColumnDefinition, so we index into the definitions.)
+ // [0]=Statements(0), [1]=StmtSplitter(0), [2]=Canvas(*), [3]=PropsSplitter(0), [4]=Props(0)
+ _statementsColumn = PlanGrid.ColumnDefinitions[0];
+ _statementsSplitterColumn = PlanGrid.ColumnDefinitions[1];
+ _splitterColumn = PlanGrid.ColumnDefinitions[3];
+ _propertiesColumn = PlanGrid.ColumnDefinitions[4];
+
+ // ScaleTransform is the LayoutTransform of the wrapper around PlanCanvas
+ var layoutTransform = this.FindControl("PlanLayoutTransform")!;
+ _zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
+
+ Helpers.DataGridBehaviors.Attach(StatementsGrid);
+
+ // Wire minimap resize grip (defined in AXAML, not in canvas)
+ MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
+ MinimapResizeGrip.PointerMoved += MinimapResizeGrip_PointerMoved;
+ MinimapResizeGrip.PointerReleased += MinimapResizeGrip_PointerReleased;
+
+ // Wire minimap canvas interaction handlers once
+ MinimapCanvas.PointerPressed += MinimapCanvas_PointerPressed;
+ MinimapCanvas.PointerMoved += MinimapCanvas_PointerMoved;
+ MinimapCanvas.PointerReleased += MinimapCanvas_PointerReleased;
+ }
+
+ ///
+ /// Exposes the raw XML so MainWindow can implement Save functionality.
+ ///
+ public string? RawXml => _currentPlan?.RawXml;
+
+ ///
+ /// Exposes the parsed and analyzed plan for advice generation.
+ ///
+ public ParsedPlan? CurrentPlan => _currentPlan;
+
+ ///
+ /// Exposes the query text associated with this plan (if any).
+ ///
+ public string? QueryText => _queryText;
+
+ ///
+ /// Server metadata for advice generation and Plan Insights display.
+ ///
+ public ServerMetadata? Metadata
+ {
+ get => _serverMetadata;
+ set
+ {
+ _serverMetadata = value;
+ if (_currentStatement != null)
+ ShowServerContext();
+ }
+ }
+
+ ///
+ /// Connection string for schema lookups. Set when the plan was loaded from a connected session.
+ ///
+ public string? ConnectionString { get; set; }
+
+ // Connection state for plans that connect via the toolbar
+ private ServerConnection? _planConnection;
+ private ICredentialService? _planCredentialService;
+ private ConnectionStore? _planConnectionStore;
+ private string? _planSelectedDatabase;
+
+ ///
+ /// Provide credential service and connection store so the plan viewer can show a connection dialog.
+ ///
+ public void SetConnectionServices(ICredentialService credentialService, ConnectionStore connectionStore)
+ {
+ _planCredentialService = credentialService;
+ _planConnectionStore = connectionStore;
+ }
+
+ ///
+ /// Update the connection UI to reflect an active connection (used when connection is inherited).
+ ///
+ public void SetConnectionStatus(string serverName, string? database)
+ {
+ PlanServerLabel.Text = serverName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "Reconnect";
+ if (database != null)
+ _planSelectedDatabase = database;
+ }
+
+ // Events for MainWindow to wire up advice/repro actions
+ public event EventHandler? HumanAdviceRequested;
+ public event EventHandler? RobotAdviceRequested;
+ public event EventHandler? CopyReproRequested;
+ public event EventHandler? OpenInEditorRequested;
+
+ ///
+ /// Navigates to a specific plan node by ID: selects it, zooms to show it,
+ /// and scrolls to center it in the viewport.
+ ///
+ public void NavigateToNode(int nodeId)
+ {
+ // Find the Border for this node
+ Border? targetBorder = null;
+ PlanNode? targetNode = null;
+ foreach (var (border, node) in _nodeBorderMap)
+ {
+ if (node.NodeId == nodeId)
+ {
+ targetBorder = border;
+ targetNode = node;
+ break;
+ }
+ }
+
+ if (targetBorder == null || targetNode == null)
+ return;
+
+ // Activate the parent window so the plan viewer becomes visible
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is Window parentWindow)
+ parentWindow.Activate();
+
+ // Select the node (highlights it and shows properties)
+ SelectNode(targetBorder, targetNode);
+
+ // Ensure zoom level makes the node comfortably visible
+ var viewWidth = PlanScrollViewer.Bounds.Width;
+ var viewHeight = PlanScrollViewer.Bounds.Height;
+ if (viewWidth <= 0 || viewHeight <= 0)
+ return;
+
+ // If the node is too small at the current zoom, zoom in so it's ~1/3 of the viewport
+ var nodeW = PlanLayoutEngine.NodeWidth;
+ var nodeH = PlanLayoutEngine.GetNodeHeight(targetNode);
+ var minVisibleZoom = Math.Min(viewWidth / (nodeW * 4), viewHeight / (nodeH * 4));
+ if (_zoomLevel < minVisibleZoom)
+ SetZoom(Math.Min(minVisibleZoom, 1.0));
+
+ // Scroll to center the node in the viewport
+ var centerX = (targetNode.X + nodeW / 2) * _zoomLevel - viewWidth / 2;
+ var centerY = (targetNode.Y + nodeH / 2) * _zoomLevel - viewHeight / 2;
+ centerX = Math.Max(0, centerX);
+ centerY = Math.Max(0, centerY);
+
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ PlanScrollViewer.Offset = new Vector(centerX, centerY);
+ });
+ }
+
+ public void LoadPlan(string planXml, string label, string? queryText = null)
+ {
+ _label = label;
+ _queryText = queryText;
+
+ // Query text stored for copy/repro but no longer shown in a
+ // separate expander — it's already visible in the Statements grid.
+
+ _currentPlan = ShowPlanParser.Parse(planXml);
+ PlanAnalyzer.Analyze(_currentPlan, ConfigLoader.Load(), _serverMetadata);
+ BenefitScorer.Score(_currentPlan);
+
+ var allStatements = _currentPlan.Batches
+ .SelectMany(b => b.Statements)
+ .Where(s => s.RootNode != null)
+ .ToList();
+
+ if (allStatements.Count == 0)
+ {
+ EmptyState.IsVisible = true;
+ PlanScrollViewer.IsVisible = false;
+ return;
+ }
+
+ EmptyState.IsVisible = false;
+ PlanScrollViewer.IsVisible = true;
+
+ // Always show statement grid — useful summary even for single-statement plans
+ _allStatements = allStatements;
+ PopulateStatementsGrid(allStatements);
+ ShowStatementsPanel();
+ StatementsGrid.SelectedIndex = 0;
+
+ // Register with MCP session manager for AI tool access
+ // Count warnings from both statement-level PlanWarnings and all node Warnings
+ int warningCount = 0, criticalCount = 0;
+ foreach (var s in allStatements)
+ {
+ warningCount += s.PlanWarnings.Count;
+ criticalCount += s.PlanWarnings.Count(w => w.Severity == PlanWarningSeverity.Critical);
+ if (s.RootNode != null)
+ CountNodeWarnings(s.RootNode, ref warningCount, ref criticalCount);
+ }
+
+ PlanSessionManager.Instance.Register(_mcpSessionId, new PlanSession
+ {
+ SessionId = _mcpSessionId,
+ Label = label,
+ Source = "file",
+ Plan = _currentPlan,
+ QueryText = queryText,
+ StatementCount = allStatements.Count,
+ HasActualStats = allStatements.Any(s => s.QueryTimeStats != null),
+ WarningCount = warningCount,
+ CriticalWarningCount = criticalCount,
+ MissingIndexCount = _currentPlan.AllMissingIndexes.Count
+ });
+ }
+
+ public void Clear()
+ {
+ PlanSessionManager.Instance.Unregister(_mcpSessionId);
+ PlanCanvas.Children.Clear();
+ _nodeBorderMap.Clear();
+ _currentPlan = null;
+ _currentStatement = null;
+ _queryText = null;
+ _selectedNodeBorder = null;
+ _selectedNode = null;
+ EmptyState.IsVisible = true;
+ PlanScrollViewer.IsVisible = false;
+ InsightsPanel.IsVisible = false;
+ CostText.Text = "";
+ CloseStatementsPanel();
+ StatementsButton.IsVisible = false;
+ StatementsButtonSeparator.IsVisible = false;
+ ClosePropertiesPanel();
+ 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);
+
+ // Re-apply selection highlight if a node is selected
+ if (_selectedNode != null)
+ UpdateMinimapSelection(_selectedNode);
+ }
+
+ private static readonly Color[] MinimapBranchColors =
+ {
+ Color.FromArgb(0x30, 0x4F, 0xA3, 0xFF), // blue
+ Color.FromArgb(0x30, 0x7B, 0xCF, 0x7B), // green
+ Color.FromArgb(0x30, 0xFF, 0xB3, 0x47), // orange
+ Color.FromArgb(0x30, 0xE5, 0x73, 0x73), // red
+ Color.FromArgb(0x30, 0xCF, 0x7B, 0xCF), // purple
+ Color.FromArgb(0x30, 0x7B, 0xCF, 0xCF), // teal
+ Color.FromArgb(0x30, 0xFF, 0xE0, 0x4F), // yellow
+ 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
+
+ private async void PlanConnect_Click(object? sender, RoutedEventArgs e)
+ {
+ if (_planCredentialService == null || _planConnectionStore == null) return;
+
+ var dialog = new ConnectionDialog(_planCredentialService, _planConnectionStore);
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is not Window parentWindow) return;
+
+ var result = await dialog.ShowDialog(parentWindow);
+ if (result != true || dialog.ResultConnection == null) return;
+
+ _planConnection = dialog.ResultConnection;
+ _planSelectedDatabase = dialog.ResultDatabase;
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+
+ PlanServerLabel.Text = _planConnection.ServerName;
+ PlanServerLabel.Foreground = Brushes.LimeGreen;
+ PlanConnectButton.Content = "Reconnect";
+
+ // Populate database dropdown
+ try
+ {
+ var connStr = _planConnection.GetConnectionString(_planCredentialService, "master");
+ await using var conn = new SqlConnection(connStr);
+ await conn.OpenAsync();
+
+ var databases = new List();
+ using var cmd = new SqlCommand(
+ "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; 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));
+
+ PlanDatabaseBox.ItemsSource = databases;
+ PlanDatabaseBox.IsEnabled = true;
+
+ if (_planSelectedDatabase != null)
+ {
+ for (int i = 0; i < PlanDatabaseBox.Items.Count; i++)
+ {
+ if (PlanDatabaseBox.Items[i]?.ToString() == _planSelectedDatabase)
+ {
+ PlanDatabaseBox.SelectedIndex = i;
+ break;
+ }
+ }
+ }
+ }
+ catch
+ {
+ PlanDatabaseBox.IsEnabled = false;
+ }
+ }
+
+ private void PlanDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (_planConnection == null || _planCredentialService == null || PlanDatabaseBox.SelectedItem == null) return;
+
+ _planSelectedDatabase = PlanDatabaseBox.SelectedItem.ToString();
+ ConnectionString = _planConnection.GetConnectionString(_planCredentialService, _planSelectedDatabase);
+ }
+
+ #endregion
+
+ #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
+}
+
+/// Sort DataGrid column by a long property on StatementRow.
+public class LongComparer : System.Collections.IComparer
+{
+ private readonly Func _selector;
+ public LongComparer(Func selector) => _selector = selector;
+ public int Compare(object? x, object? y)
+ {
+ if (x is StatementRow a && y is StatementRow b)
+ return _selector(a).CompareTo(_selector(b));
+ return 0;
+ }
+}
+
+/// Sort DataGrid column by a double property on StatementRow.
+public class DoubleComparer : System.Collections.IComparer
+{
+ private readonly Func _selector;
+ public DoubleComparer(Func selector) => _selector = selector;
+ public int Compare(object? x, object? y)
+ {
+ if (x is StatementRow a && y is StatementRow b)
+ return _selector(a).CompareTo(_selector(b));
+ return 0;
+ }
+}
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
index 99ca2c0..24d90f5 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml
@@ -1,127 +1,127 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
index 78ab33a..954f864 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
@@ -1,2094 +1,2096 @@
-using System;
-using System.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
-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 readonly ICredentialService _credentialService;
- private readonly ConnectionStore _connectionStore;
-
- private ServerConnection? _serverConnection;
- private string? _connectionString;
- private string? _selectedDatabase;
- private int _planCounter;
- private CancellationTokenSource? _executionCts;
- private ServerMetadata? _serverMetadata;
-
- // TextMate installation for syntax highlighting
- private TextMate.Installation? _textMateInstallation;
- private CancellationTokenSource? _statusClearCts;
- private CompletionWindow? _completionWindow;
-
- public QuerySessionControl(ICredentialService credentialService, ConnectionStore connectionStore)
- {
- _credentialService = credentialService;
- _connectionStore = connectionStore;
- InitializeComponent();
-
- // Initialize editor with empty text so the document is ready
- QueryEditor.Text = "";
- ZoomBox.SelectedIndex = 2; // 100%
-
- SetupSyntaxHighlighting();
- SetupEditorContextMenu();
-
- // Keybindings: F5/Ctrl+E for Execute, Ctrl+L for Estimated Plan
- KeyDown += OnKeyDown;
-
- // Ctrl+mousewheel for font zoom — use Tunnel so it fires before ScrollViewer consumes scroll-down
- QueryEditor.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, OnEditorPointerWheel, Avalonia.Interactivity.RoutingStrategies.Tunnel);
-
- // Code completion
- QueryEditor.TextArea.TextEntering += OnTextEntering;
- QueryEditor.TextArea.TextEntered += OnTextEntered;
-
- // Focus the editor when the control is attached to the visual tree
- // Re-install TextMate if it was disposed on detach (tab switching disposes it)
- AttachedToVisualTree += (_, _) =>
- {
- if (_textMateInstallation == null)
- SetupSyntaxHighlighting();
-
- QueryEditor.Focus();
- QueryEditor.TextArea.Focus();
- };
-
- // Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers.
- // Also cancel any in-flight status-clear dispatch so it doesn't fire on a dead control.
- DetachedFromVisualTree += (_, _) =>
- {
- _textMateInstallation?.Dispose();
- _textMateInstallation = null;
- _statusClearCts?.Cancel();
- _statusClearCts?.Dispose();
- _statusClearCts = null;
- };
-
- // Focus the editor when the Editor tab is selected; toggle plan-dependent buttons
- SubTabControl.SelectionChanged += (_, _) =>
- {
- if (SubTabControl.SelectedIndex == 0)
- {
- QueryEditor.Focus();
- QueryEditor.TextArea.Focus();
- }
- UpdatePlanTabButtonState();
- };
- }
-
- private void SetupSyntaxHighlighting()
- {
- var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
- _textMateInstallation = QueryEditor.InstallTextMate(registryOptions);
- _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
- }
-
- // Schema context menu items — stored as fields so we can toggle visibility on menu open
- private MenuItem? _showIndexesItem;
- private MenuItem? _showTableDefItem;
- private MenuItem? _showObjectDefItem;
- private Separator? _schemaSeparator;
- private ResolvedSqlObject? _contextMenuObject;
-
- 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 enum SchemaInfoKind { Indexes, TableDefinition, ObjectDefinition }
-
- private async Task ShowSchemaInfoAsync(SchemaInfoKind kind)
- {
- if (_contextMenuObject == null || _connectionString == null) return;
-
- var objectName = _contextMenuObject.FullName;
- SetStatus($"Fetching {kind} for {objectName}...", autoClear: false);
-
- try
- {
- string content;
- string tabLabel;
-
- switch (kind)
- {
- case SchemaInfoKind.Indexes:
- var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
- content = FormatIndexes(objectName, indexes);
- tabLabel = $"Indexes — {objectName}";
- break;
-
- case SchemaInfoKind.TableDefinition:
- var columns = await SchemaQueryService.FetchColumnsAsync(_connectionString, objectName);
- var tableIndexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
- content = FormatColumns(objectName, columns, tableIndexes);
- tabLabel = $"Table — {objectName}";
- break;
-
- case SchemaInfoKind.ObjectDefinition:
- var definition = await SchemaQueryService.FetchObjectDefinitionAsync(_connectionString, objectName);
- content = definition ?? $"-- No definition found for {objectName}";
- tabLabel = $"Definition — {objectName}";
- break;
-
- default:
- return;
- }
-
- AddSchemaTab(tabLabel, content, isSql: true);
- SetStatus($"Loaded {kind} for {objectName}");
- }
- catch (Exception ex)
- {
- SetStatus($"Error: {ex.Message}", autoClear: false);
- Debug.WriteLine($"Schema lookup error: {ex}");
- }
- }
-
- private void AddSchemaTab(string label, string content, bool isSql)
- {
- var editor = new TextEditor
- {
- Text = content,
- IsReadOnly = true,
- FontFamily = new FontFamily("Consolas, Menlo, monospace"),
- FontSize = 13,
- ShowLineNumbers = true,
- Background = (IBrush)this.FindResource("BackgroundBrush")!,
- Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
- HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
- VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
- Padding = new Avalonia.Thickness(4)
- };
-
- if (isSql)
- {
- var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
- var tm = editor.InstallTextMate(registryOptions);
- tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
- }
-
- // Context menu for read-only schema tabs
- var schemaCopy = new MenuItem { Header = "Copy" };
- schemaCopy.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 schemaCopyAll = new MenuItem { Header = "Copy All" };
- schemaCopyAll.Click += async (_, _) =>
- {
- var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
- if (clipboard == null) return;
- await clipboard.SetTextAsync(editor.Text);
- };
- var schemaSelectAll = new MenuItem { Header = "Select All" };
- schemaSelectAll.Click += (_, _) => editor.SelectAll();
- editor.TextArea.ContextMenu = new ContextMenu
- {
- Items = { schemaCopy, schemaCopyAll, new Separator(), schemaSelectAll }
- };
-
- 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 = editor };
- closeBtn.Tag = tab;
- closeBtn.Click += (s, _) =>
- {
- if (s is Button btn && btn.Tag is TabItem t)
- SubTabControl.Items.Remove(t);
- };
-
- SubTabControl.Items.Add(tab);
- SubTabControl.SelectedItem = tab;
- }
-
- 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 **");
-
- // Usage stats as a comment
- 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 {BracketName(ix.PartitionScheme)}({BracketName(ix.PartitionColumn)})"
- : null;
-
- if (ix.IsPrimaryKey)
- {
- var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- ? "CLUSTERED" : "NONCLUSTERED";
- sb.AppendLine($"ALTER TABLE {objectName}");
- sb.AppendLine($"ADD CONSTRAINT {BracketName(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))
- {
- // Columnstore indexes: no key columns, no INCLUDE, no row/page lock or compression options
- var clustered = ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- ? "NONCLUSTERED " : "CLUSTERED ";
- sb.Append($"CREATE {clustered}COLUMNSTORE INDEX {BracketName(ix.IndexName)}");
- sb.AppendLine($" ON {objectName}");
-
- // Nonclustered columnstore can have a column list
- if (ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- && !string.IsNullOrEmpty(ix.KeyColumns))
- {
- sb.AppendLine($"({ix.KeyColumns})");
- }
-
- // Only emit non-default options that aren't inherent to columnstore
- var csOptions = BuildColumnstoreWithOptions(ix);
- if (csOptions.Count > 0)
- sb.AppendLine($"WITH ({string.Join(", ", csOptions)})");
-
- if (onPartition != null)
- sb.AppendLine(onPartition);
-
- // Remove trailing newline before semicolon
- if (sb[sb.Length - 1] == '\n') sb.Length--;
- if (sb[sb.Length - 1] == '\r') sb.Length--;
- sb.AppendLine(";");
- }
- else
- {
- var unique = ix.IsUnique ? "UNIQUE " : "";
- var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- ? "CLUSTERED " : "NONCLUSTERED ";
- sb.Append($"CREATE {unique}{clustered}INDEX {BracketName(ix.IndexName)}");
- sb.AppendLine($" ON {objectName}");
- sb.Append($"(");
- sb.Append(ix.KeyColumns);
- sb.AppendLine(")");
-
- 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);
-
- // Remove trailing newline before semicolon
- if (sb[sb.Length - 1] == '\n') sb.Length--;
- if (sb[sb.Length - 1] == '\r') sb.Length--;
- sb.AppendLine(";");
- }
-
- sb.AppendLine();
- }
-
- return sb.ToString();
- }
-
- private static bool IsColumnstore(IndexInfo ix) =>
- ix.IndexType.Contains("COLUMNSTORE", System.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", System.StringComparison.OrdinalIgnoreCase))
- options.Add($"DATA_COMPRESSION = {ix.DataCompression}");
-
- return options;
- }
-
- ///
- /// For columnstore indexes, skip options that are inherent to the storage format
- /// (row/page locks are always OFF, compression is always COLUMNSTORE).
- /// Only emit fill factor and pad index if non-default.
- ///
- 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 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("(");
-
- for (int i = 0; i < columns.Count; i++)
- {
- var col = columns[i];
- var isLast = i == columns.Count - 1;
-
- sb.Append($" {BracketName(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}");
- }
-
- // Check if we need a PK constraint after all columns
- var pk = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
- var needsTrailingComma = !isLast || pk != null;
-
- sb.AppendLine(needsTrailingComma ? "," : "");
- }
-
- // Add PK constraint
- var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
- if (pkIndex != null)
- {
- var clustered = pkIndex.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- && !pkIndex.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- ? "CLUSTERED " : "NONCLUSTERED ";
- sb.AppendLine($" CONSTRAINT {BracketName(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(")");
-
- // Add partition scheme from the clustered index (determines table storage)
- var clusteredIx = indexes.FirstOrDefault(ix =>
- ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
- && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase));
- if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null)
- {
- sb.AppendLine();
- sb.Append($"ON {BracketName(clusteredIx.PartitionScheme)}({BracketName(clusteredIx.PartitionColumn)})");
- }
-
- sb.AppendLine(";");
-
- return sb.ToString();
- }
-
- private static string BracketName(string name)
- {
- // Already bracketed
- if (name.StartsWith('['))
- return name;
- return $"[{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 prefix, int startOffset) GetWordBeforeCaret()
- {
- var doc = QueryEditor.Document;
- var offset = QueryEditor.CaretOffset;
- var start = offset;
-
- while (start > 0)
- {
- var ch = doc.GetCharAt(start - 1);
- if (char.IsLetterOrDigit(ch) || ch == '_')
- start--;
- else
- break;
- }
-
- return (doc.GetText(start, offset - start), start);
- }
-
- 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);
- }
- }
-
- 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.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 bool IsAzureConnection =>
- _serverConnection != null &&
- (_serverConnection.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) ||
- _serverConnection.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase));
-
- 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
- }
- }
-
- 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 AnalysisResult? GetCurrentAnalysis()
- {
- return GetCurrentAnalysisWithViewer().Analysis;
- }
-
- private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer()
- {
- // Find the currently selected plan tab's PlanViewerControl
- if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
- && viewer.CurrentPlan != null)
- {
- return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer);
- }
-
- // Fallback: find the most recent plan tab
- for (int i = SubTabControl.Items.Count - 1; i >= 0; i--)
- {
- if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v
- && v.CurrentPlan != null)
- {
- return (ResultMapper.Map(v.CurrentPlan, "query editor"), v);
- }
- }
-
- return (null, null);
- }
-
- 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);
- }
-
- 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())
- item.Click += PlanTabContextMenu_Click;
-
- header.ContextMenu = contextMenu;
-
- SubTabControl.Items.Add(tab);
- SubTabControl.SelectedItem = tab;
- UpdateCompareButtonState();
- }
-
- private void StartRename(StackPanel header, TextBlock headerText)
- {
- var textBox = new TextBox
- {
- Text = headerText.Text,
- FontSize = 12,
- MinWidth = 80,
- Padding = new Avalonia.Thickness(2, 0),
- VerticalAlignment = VerticalAlignment.Center
- };
-
- headerText.IsVisible = false;
- header.Children.Insert(0, textBox);
- textBox.Focus();
- textBox.SelectAll();
-
- void CommitRename()
- {
- var newName = textBox.Text?.Trim();
- if (!string.IsNullOrEmpty(newName))
- headerText.Text = newName;
-
- headerText.IsVisible = true;
- header.Children.Remove(textBox);
- }
-
- textBox.KeyDown += (_, ke) =>
- {
- if (ke.Key == Key.Enter || ke.Key == Key.Escape)
- {
- if (ke.Key == Key.Escape)
- textBox.Text = headerText.Text;
- CommitRename();
- ke.Handled = true;
- }
- };
-
- textBox.LostFocus += (_, _) => CommitRename();
- }
-
- private void ClosePlanTab_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is Button btn && btn.Tag is TabItem tab)
- {
- if (tab.Content is PlanViewerControl viewer)
- viewer.Clear();
- SubTabControl.Items.Remove(tab);
- UpdateCompareButtonState();
- }
- }
-
- private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is not MenuItem item) return;
-
- switch (item.Header?.ToString())
- {
- case "Rename Tab":
- if (item.Tag is object[] parts)
- StartRename((StackPanel)parts[0], (TextBlock)parts[1]);
- break;
-
- case "Close":
- if (item.Tag is TabItem tab)
- {
- if (tab.Content is PlanViewerControl closeViewer)
- closeViewer.Clear();
- SubTabControl.Items.Remove(tab);
- UpdateCompareButtonState();
- }
- break;
-
- case "Close Other Tabs":
- if (item.Tag is TabItem keepTab)
- {
- // Keep the Editor tab (index 0) and the selected tab
- var others = SubTabControl.Items.Cast()
- .OfType()
- .Where(t => t != keepTab && t.Content is PlanViewerControl)
- .ToList();
- foreach (var t in others)
- {
- if (t.Content is PlanViewerControl otherViewer)
- otherViewer.Clear();
- SubTabControl.Items.Remove(t);
- }
- SubTabControl.SelectedItem = keepTab;
- UpdateCompareButtonState();
- }
- break;
-
- case "Close All Tabs":
- var planTabs = SubTabControl.Items.Cast()
- .OfType()
- .Where(t => t.Content is PlanViewerControl)
- .ToList();
- foreach (var t in planTabs)
- {
- if (t.Content is PlanViewerControl allViewer)
- allViewer.Clear();
- SubTabControl.Items.Remove(t);
- }
- SubTabControl.SelectedIndex = 0; // back to Editor
- UpdateCompareButtonState();
- break;
- }
- }
-
- private void UpdateCompareButtonState()
- {
- int planCount = 0;
- foreach (var item in SubTabControl.Items)
- {
- if (item is TabItem t && t.Content is PlanViewerControl v && v.CurrentPlan != null)
- planCount++;
- }
- ComparePlansButton.IsEnabled = planCount >= 2;
- }
-
- public IEnumerable<(string label, PlanViewerControl viewer)> GetPlanTabs()
- {
- foreach (var item in SubTabControl.Items)
- {
- if (item is TabItem tab && tab.Content is PlanViewerControl viewer
- && viewer.CurrentPlan != null)
- {
- yield return (GetTabLabel(tab), viewer);
- }
- }
- }
-
- private static string GetTabLabel(TabItem tab)
- {
- if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
- return tb.Text ?? "Plan";
- if (tab.Header is string s)
- return s;
- return "Plan";
- }
-
- private bool HasQueryStoreTab()
- {
- return SubTabControl.Items.OfType()
- .Any(t => t.Content is QueryStoreGridControl);
- }
-
- public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs());
-
- private async void QueryStore_Click(object? sender, RoutedEventArgs e)
- {
- // If a QS tab already exists, always show connection dialog for a fresh tab
- if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null)
- {
- await ShowConnectionDialogAsync();
- if (_connectionString == null || _selectedDatabase == null)
- return;
- }
-
- // Check if Query Store is enabled
- SetStatus("Checking Query Store...");
- try
- {
- var (enabled, state) = await QueryStoreService.CheckEnabledAsync(_connectionString);
- if (!enabled)
- {
- SetStatus($"Query Store not enabled ({state ?? "unknown"})");
- return;
- }
- }
- catch (Exception ex)
- {
- SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
- return;
- }
-
- SetStatus("");
-
- // Check if wait stats are supported (SQL 2017+ / Azure) and capture is enabled
- var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
- if (supportsWaitStats)
- {
- try
- {
- var connStr = _serverConnection!.GetConnectionString(_credentialService, _selectedDatabase!);
- supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr);
- }
- catch
- {
- supportsWaitStats = false;
- }
- }
-
- // Build database list from the current DatabaseBox
- var databases = DatabaseBox.Items.OfType().ToList();
-
- var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
- _selectedDatabase!, databases, supportsWaitStats);
- grid.PlansSelected += OnQueryStorePlansSelected;
-
- var headerText = new TextBlock
- {
- Text = $"Query Store — {_selectedDatabase}",
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
- FontSize = 12
- };
-
- // Update tab header when database is changed via the grid's picker
- grid.DatabaseChanged += (_, db) =>
- {
- headerText.Text = $"Query Store — {db}";
- };
-
- 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 = Avalonia.Layout.VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- VerticalContentAlignment = VerticalAlignment.Center
- };
-
- var header = new StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- Children = { headerText, closeBtn }
- };
-
- var tab = new TabItem { Header = header, Content = grid };
- closeBtn.Tag = tab;
- closeBtn.Click += (s, _) =>
- {
- if (s is Button btn && btn.Tag is TabItem t)
- SubTabControl.Items.Remove(t);
- };
-
- SubTabControl.Items.Add(tab);
- SubTabControl.SelectedItem = tab;
- }
-
- private void OnQueryStorePlansSelected(object? sender, List plans)
- {
- foreach (var qsPlan in plans)
- {
- var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
- AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
- }
-
- SetStatus($"{plans.Count} Query Store plans loaded");
- HumanAdviceButton.IsEnabled = true;
- RobotAdviceButton.IsEnabled = true;
- }
-
- private void ComparePlans_Click(object? sender, RoutedEventArgs e)
- {
- var planTabs = GetPlanTabs().ToList();
- if (planTabs.Count < 2)
- {
- SetStatus("Need at least 2 plan tabs to compare");
- return;
- }
-
- ShowComparePickerDialog(planTabs);
- }
-
- private void ShowComparePickerDialog(List<(string label, PlanViewerControl viewer)> planTabs)
- {
- var items = planTabs.Select(t => t.label).ToList();
-
- var comboA = new ComboBox
- {
- ItemsSource = items,
- SelectedIndex = 0,
- Width = 200,
- Height = 28,
- FontSize = 12,
- Margin = new Avalonia.Thickness(8, 0, 0, 0)
- };
-
- var comboB = new ComboBox
- {
- ItemsSource = items,
- SelectedIndex = items.Count > 1 ? 1 : 0,
- Width = 200,
- Height = 28,
- FontSize = 12,
- Margin = new Avalonia.Thickness(8, 0, 0, 0)
- };
-
- var compareBtn = new Button
- {
- Content = "Compare",
- Height = 32,
- 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,
- 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")!
- };
-
- void UpdateCompareEnabled()
- {
- compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0
- && comboA.SelectedIndex != comboB.SelectedIndex;
- }
-
- comboA.SelectionChanged += (_, _) => UpdateCompareEnabled();
- comboB.SelectionChanged += (_, _) => UpdateCompareEnabled();
- UpdateCompareEnabled();
-
- var rowA = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Margin = new Avalonia.Thickness(0, 0, 0, 8),
- Children =
- {
- new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
- comboA
- }
- };
-
- var rowB = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Children =
- {
- new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
- comboB
- }
- };
-
- var buttonPanel = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Right,
- Margin = new Avalonia.Thickness(0, 16, 0, 0),
- Children = { compareBtn, cancelBtn }
- };
-
- var content = new StackPanel
- {
- Margin = new Avalonia.Thickness(20),
- Children =
- {
- new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) },
- rowA,
- rowB,
- buttonPanel
- }
- };
-
- var dialog = new Window
- {
- Title = "Compare Plans",
- Width = 380,
- Height = 220,
- MinWidth = 380,
- MinHeight = 220,
- Icon = GetParentWindow().Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = content,
- WindowStartupLocation = WindowStartupLocation.CenterOwner
- };
-
- compareBtn.Click += (_, _) =>
- {
- var idxA = comboA.SelectedIndex;
- var idxB = comboB.SelectedIndex;
- if (idxA < 0 || idxB < 0 || idxA == idxB) return;
-
- var (labelA, viewerA) = planTabs[idxA];
- var (labelB, viewerB) = planTabs[idxB];
-
- var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "query editor", _serverMetadata);
- var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "query editor", _serverMetadata);
-
- var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB);
- dialog.Close();
- ShowAdviceWindow("Plan Comparison", comparison);
- };
-
- cancelBtn.Click += (_, _) => dialog.Close();
-
- dialog.ShowDialog(GetParentWindow());
- }
-
- ///
- /// Gets the PlanViewerControl for the currently selected plan tab, or null if
- /// the Editor tab or no plan tab is selected.
- ///
- private PlanViewerControl? GetSelectedPlanViewer()
- {
- if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
- && viewer.CurrentPlan != null)
- {
- return viewer;
- }
- return null;
- }
-
- ///
- /// Enables or disables buttons that require a plan tab to be selected.
- /// Called when the SubTabControl selection changes and after plan tabs are added/removed.
- ///
- private void UpdatePlanTabButtonState()
- {
- var hasPlanTab = GetSelectedPlanViewer() != null;
- var hasConnection = _connectionString != null && _selectedDatabase != null;
-
- CopyReproButton.IsEnabled = hasPlanTab;
- GetActualPlanButton.IsEnabled = hasPlanTab && hasConnection;
-
- // Advice buttons also depend on a plan being selected
- HumanAdviceButton.IsEnabled = hasPlanTab;
- RobotAdviceButton.IsEnabled = hasPlanTab;
- }
-
- 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 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");
- }
-
- 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());
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+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 readonly ICredentialService _credentialService;
+ private readonly ConnectionStore _connectionStore;
+
+ private ServerConnection? _serverConnection;
+ private string? _connectionString;
+ private string? _selectedDatabase;
+ private int _planCounter;
+ private CancellationTokenSource? _executionCts;
+ private ServerMetadata? _serverMetadata;
+
+ // TextMate installation for syntax highlighting
+ private TextMate.Installation? _textMateInstallation;
+ private CancellationTokenSource? _statusClearCts;
+ private CompletionWindow? _completionWindow;
+
+ public QuerySessionControl(ICredentialService credentialService, ConnectionStore connectionStore)
+ {
+ _credentialService = credentialService;
+ _connectionStore = connectionStore;
+ InitializeComponent();
+
+ // Initialize editor with empty text so the document is ready
+ QueryEditor.Text = "";
+ ZoomBox.SelectedIndex = 2; // 100%
+
+ SetupSyntaxHighlighting();
+ SetupEditorContextMenu();
+
+ // Keybindings: F5/Ctrl+E for Execute, Ctrl+L for Estimated Plan
+ KeyDown += OnKeyDown;
+
+ // Ctrl+mousewheel for font zoom — use Tunnel so it fires before ScrollViewer consumes scroll-down
+ QueryEditor.AddHandler(Avalonia.Input.InputElement.PointerWheelChangedEvent, OnEditorPointerWheel, Avalonia.Interactivity.RoutingStrategies.Tunnel);
+
+ // Code completion
+ QueryEditor.TextArea.TextEntering += OnTextEntering;
+ QueryEditor.TextArea.TextEntered += OnTextEntered;
+
+ // Focus the editor when the control is attached to the visual tree
+ // Re-install TextMate if it was disposed on detach (tab switching disposes it)
+ AttachedToVisualTree += (_, _) =>
+ {
+ if (_textMateInstallation == null)
+ SetupSyntaxHighlighting();
+
+ QueryEditor.Focus();
+ QueryEditor.TextArea.Focus();
+ };
+
+ // Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers.
+ // Also cancel any in-flight status-clear dispatch so it doesn't fire on a dead control.
+ DetachedFromVisualTree += (_, _) =>
+ {
+ _textMateInstallation?.Dispose();
+ _textMateInstallation = null;
+ _statusClearCts?.Cancel();
+ _statusClearCts?.Dispose();
+ _statusClearCts = null;
+ };
+
+ // Focus the editor when the Editor tab is selected; toggle plan-dependent buttons
+ SubTabControl.SelectionChanged += (_, _) =>
+ {
+ if (SubTabControl.SelectedIndex == 0)
+ {
+ QueryEditor.Focus();
+ QueryEditor.TextArea.Focus();
+ }
+ UpdatePlanTabButtonState();
+ };
+ }
+
+ private void SetupSyntaxHighlighting()
+ {
+ var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
+ _textMateInstallation = QueryEditor.InstallTextMate(registryOptions);
+ _textMateInstallation.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
+ }
+
+ // Schema context menu items — stored as fields so we can toggle visibility on menu open
+ private MenuItem? _showIndexesItem;
+ private MenuItem? _showTableDefItem;
+ private MenuItem? _showObjectDefItem;
+ private Separator? _schemaSeparator;
+ private ResolvedSqlObject? _contextMenuObject;
+
+ 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 enum SchemaInfoKind { Indexes, TableDefinition, ObjectDefinition }
+
+ private async Task ShowSchemaInfoAsync(SchemaInfoKind kind)
+ {
+ if (_contextMenuObject == null || _connectionString == null) return;
+
+ var objectName = _contextMenuObject.FullName;
+ SetStatus($"Fetching {kind} for {objectName}...", autoClear: false);
+
+ try
+ {
+ string content;
+ string tabLabel;
+
+ switch (kind)
+ {
+ case SchemaInfoKind.Indexes:
+ var indexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
+ content = FormatIndexes(objectName, indexes);
+ tabLabel = $"Indexes — {objectName}";
+ break;
+
+ case SchemaInfoKind.TableDefinition:
+ var columns = await SchemaQueryService.FetchColumnsAsync(_connectionString, objectName);
+ var tableIndexes = await SchemaQueryService.FetchIndexesAsync(_connectionString, objectName);
+ content = FormatColumns(objectName, columns, tableIndexes);
+ tabLabel = $"Table — {objectName}";
+ break;
+
+ case SchemaInfoKind.ObjectDefinition:
+ var definition = await SchemaQueryService.FetchObjectDefinitionAsync(_connectionString, objectName);
+ content = definition ?? $"-- No definition found for {objectName}";
+ tabLabel = $"Definition — {objectName}";
+ break;
+
+ default:
+ return;
+ }
+
+ AddSchemaTab(tabLabel, content, isSql: true);
+ SetStatus($"Loaded {kind} for {objectName}");
+ }
+ catch (Exception ex)
+ {
+ SetStatus($"Error: {ex.Message}", autoClear: false);
+ Debug.WriteLine($"Schema lookup error: {ex}");
+ }
+ }
+
+ private void AddSchemaTab(string label, string content, bool isSql)
+ {
+ var editor = new TextEditor
+ {
+ Text = content,
+ IsReadOnly = true,
+ FontFamily = new FontFamily("Consolas, Menlo, monospace"),
+ FontSize = 13,
+ ShowLineNumbers = true,
+ Background = (IBrush)this.FindResource("BackgroundBrush")!,
+ Foreground = (IBrush)this.FindResource("ForegroundBrush")!,
+ HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto,
+ Padding = new Avalonia.Thickness(4)
+ };
+
+ if (isSql)
+ {
+ var registryOptions = new RegistryOptions(ThemeName.DarkPlus);
+ var tm = editor.InstallTextMate(registryOptions);
+ tm.SetGrammar(registryOptions.GetScopeByLanguageId("sql"));
+ }
+
+ // Context menu for read-only schema tabs
+ var schemaCopy = new MenuItem { Header = "Copy" };
+ schemaCopy.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 schemaCopyAll = new MenuItem { Header = "Copy All" };
+ schemaCopyAll.Click += async (_, _) =>
+ {
+ var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
+ if (clipboard == null) return;
+ await clipboard.SetTextAsync(editor.Text);
+ };
+ var schemaSelectAll = new MenuItem { Header = "Select All" };
+ schemaSelectAll.Click += (_, _) => editor.SelectAll();
+ editor.TextArea.ContextMenu = new ContextMenu
+ {
+ Items = { schemaCopy, schemaCopyAll, new Separator(), schemaSelectAll }
+ };
+
+ 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 = editor };
+ closeBtn.Tag = tab;
+ closeBtn.Click += (s, _) =>
+ {
+ if (s is Button btn && btn.Tag is TabItem t)
+ SubTabControl.Items.Remove(t);
+ };
+
+ SubTabControl.Items.Add(tab);
+ SubTabControl.SelectedItem = tab;
+ }
+
+ 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 **");
+
+ // Usage stats as a comment
+ 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 {BracketName(ix.PartitionScheme)}({BracketName(ix.PartitionColumn)})"
+ : null;
+
+ if (ix.IsPrimaryKey)
+ {
+ var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED" : "NONCLUSTERED";
+ sb.AppendLine($"ALTER TABLE {objectName}");
+ sb.AppendLine($"ADD CONSTRAINT {BracketName(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))
+ {
+ // Columnstore indexes: no key columns, no INCLUDE, no row/page lock or compression options
+ var clustered = ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "NONCLUSTERED " : "CLUSTERED ";
+ sb.Append($"CREATE {clustered}COLUMNSTORE INDEX {BracketName(ix.IndexName)}");
+ sb.AppendLine($" ON {objectName}");
+
+ // Nonclustered columnstore can have a column list
+ if (ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !string.IsNullOrEmpty(ix.KeyColumns))
+ {
+ sb.AppendLine($"({ix.KeyColumns})");
+ }
+
+ // Only emit non-default options that aren't inherent to columnstore
+ var csOptions = BuildColumnstoreWithOptions(ix);
+ if (csOptions.Count > 0)
+ sb.AppendLine($"WITH ({string.Join(", ", csOptions)})");
+
+ if (onPartition != null)
+ sb.AppendLine(onPartition);
+
+ // Remove trailing newline before semicolon
+ if (sb[sb.Length - 1] == '\n') sb.Length--;
+ if (sb[sb.Length - 1] == '\r') sb.Length--;
+ sb.AppendLine(";");
+ }
+ else
+ {
+ var unique = ix.IsUnique ? "UNIQUE " : "";
+ var clustered = ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.Append($"CREATE {unique}{clustered}INDEX {BracketName(ix.IndexName)}");
+ sb.AppendLine($" ON {objectName}");
+ sb.Append($"(");
+ sb.Append(ix.KeyColumns);
+ sb.AppendLine(")");
+
+ 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);
+
+ // Remove trailing newline before semicolon
+ if (sb[sb.Length - 1] == '\n') sb.Length--;
+ if (sb[sb.Length - 1] == '\r') sb.Length--;
+ sb.AppendLine(";");
+ }
+
+ sb.AppendLine();
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool IsColumnstore(IndexInfo ix) =>
+ ix.IndexType.Contains("COLUMNSTORE", System.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", System.StringComparison.OrdinalIgnoreCase))
+ options.Add($"DATA_COMPRESSION = {ix.DataCompression}");
+
+ return options;
+ }
+
+ ///
+ /// For columnstore indexes, skip options that are inherent to the storage format
+ /// (row/page locks are always OFF, compression is always COLUMNSTORE).
+ /// Only emit fill factor and pad index if non-default.
+ ///
+ 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 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("(");
+
+ for (int i = 0; i < columns.Count; i++)
+ {
+ var col = columns[i];
+ var isLast = i == columns.Count - 1;
+
+ sb.Append($" {BracketName(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}");
+ }
+
+ // Check if we need a PK constraint after all columns
+ var pk = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
+ var needsTrailingComma = !isLast || pk != null;
+
+ sb.AppendLine(needsTrailingComma ? "," : "");
+ }
+
+ // Add PK constraint
+ var pkIndex = indexes.FirstOrDefault(ix => ix.IsPrimaryKey);
+ if (pkIndex != null)
+ {
+ var clustered = pkIndex.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !pkIndex.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ ? "CLUSTERED " : "NONCLUSTERED ";
+ sb.AppendLine($" CONSTRAINT {BracketName(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(")");
+
+ // Add partition scheme from the clustered index (determines table storage)
+ var clusteredIx = indexes.FirstOrDefault(ix =>
+ ix.IndexType.Contains("CLUSTERED", System.StringComparison.OrdinalIgnoreCase)
+ && !ix.IndexType.Contains("NONCLUSTERED", System.StringComparison.OrdinalIgnoreCase));
+ if (clusteredIx?.PartitionScheme != null && clusteredIx.PartitionColumn != null)
+ {
+ sb.AppendLine();
+ sb.Append($"ON {BracketName(clusteredIx.PartitionScheme)}({BracketName(clusteredIx.PartitionColumn)})");
+ }
+
+ sb.AppendLine(";");
+
+ return sb.ToString();
+ }
+
+ private static string BracketName(string name)
+ {
+ // Already bracketed
+ if (name.StartsWith('['))
+ return name;
+ return $"[{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 prefix, int startOffset) GetWordBeforeCaret()
+ {
+ var doc = QueryEditor.Document;
+ var offset = QueryEditor.CaretOffset;
+ var start = offset;
+
+ while (start > 0)
+ {
+ var ch = doc.GetCharAt(start - 1);
+ if (char.IsLetterOrDigit(ch) || ch == '_')
+ start--;
+ else
+ break;
+ }
+
+ return (doc.GetText(start, offset - start), start);
+ }
+
+ 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);
+ }
+ }
+
+ 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 bool IsAzureConnection =>
+ _serverConnection != null &&
+ (_serverConnection.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) ||
+ _serverConnection.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase));
+
+ 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
+ }
+ }
+
+ 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 AnalysisResult? GetCurrentAnalysis()
+ {
+ return GetCurrentAnalysisWithViewer().Analysis;
+ }
+
+ private (AnalysisResult? Analysis, PlanViewerControl? Viewer) GetCurrentAnalysisWithViewer()
+ {
+ // Find the currently selected plan tab's PlanViewerControl
+ if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
+ && viewer.CurrentPlan != null)
+ {
+ return (ResultMapper.Map(viewer.CurrentPlan, "query editor", _serverMetadata), viewer);
+ }
+
+ // Fallback: find the most recent plan tab
+ for (int i = SubTabControl.Items.Count - 1; i >= 0; i--)
+ {
+ if (SubTabControl.Items[i] is TabItem planTab && planTab.Content is PlanViewerControl v
+ && v.CurrentPlan != null)
+ {
+ return (ResultMapper.Map(v.CurrentPlan, "query editor"), v);
+ }
+ }
+
+ return (null, null);
+ }
+
+ 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);
+ }
+
+ 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())
+ item.Click += PlanTabContextMenu_Click;
+
+ header.ContextMenu = contextMenu;
+
+ SubTabControl.Items.Add(tab);
+ SubTabControl.SelectedItem = tab;
+ UpdateCompareButtonState();
+ }
+
+ private void StartRename(StackPanel header, TextBlock headerText)
+ {
+ var textBox = new TextBox
+ {
+ Text = headerText.Text,
+ FontSize = 12,
+ MinWidth = 80,
+ Padding = new Avalonia.Thickness(2, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ headerText.IsVisible = false;
+ header.Children.Insert(0, textBox);
+ textBox.Focus();
+ textBox.SelectAll();
+
+ void CommitRename()
+ {
+ var newName = textBox.Text?.Trim();
+ if (!string.IsNullOrEmpty(newName))
+ headerText.Text = newName;
+
+ headerText.IsVisible = true;
+ header.Children.Remove(textBox);
+ }
+
+ textBox.KeyDown += (_, ke) =>
+ {
+ if (ke.Key == Key.Enter || ke.Key == Key.Escape)
+ {
+ if (ke.Key == Key.Escape)
+ textBox.Text = headerText.Text;
+ CommitRename();
+ ke.Handled = true;
+ }
+ };
+
+ textBox.LostFocus += (_, _) => CommitRename();
+ }
+
+ private void ClosePlanTab_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is TabItem tab)
+ {
+ if (tab.Content is PlanViewerControl viewer)
+ viewer.Clear();
+ SubTabControl.Items.Remove(tab);
+ UpdateCompareButtonState();
+ }
+ }
+
+ private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem item) return;
+
+ switch (item.Header?.ToString())
+ {
+ case "Rename Tab":
+ if (item.Tag is object[] parts)
+ StartRename((StackPanel)parts[0], (TextBlock)parts[1]);
+ break;
+
+ case "Close":
+ if (item.Tag is TabItem tab)
+ {
+ if (tab.Content is PlanViewerControl closeViewer)
+ closeViewer.Clear();
+ SubTabControl.Items.Remove(tab);
+ UpdateCompareButtonState();
+ }
+ break;
+
+ case "Close Other Tabs":
+ if (item.Tag is TabItem keepTab)
+ {
+ // Keep the Editor tab (index 0) and the selected tab
+ var others = SubTabControl.Items.Cast()
+ .OfType()
+ .Where(t => t != keepTab && t.Content is PlanViewerControl)
+ .ToList();
+ foreach (var t in others)
+ {
+ if (t.Content is PlanViewerControl otherViewer)
+ otherViewer.Clear();
+ SubTabControl.Items.Remove(t);
+ }
+ SubTabControl.SelectedItem = keepTab;
+ UpdateCompareButtonState();
+ }
+ break;
+
+ case "Close All Tabs":
+ var planTabs = SubTabControl.Items.Cast()
+ .OfType()
+ .Where(t => t.Content is PlanViewerControl)
+ .ToList();
+ foreach (var t in planTabs)
+ {
+ if (t.Content is PlanViewerControl allViewer)
+ allViewer.Clear();
+ SubTabControl.Items.Remove(t);
+ }
+ SubTabControl.SelectedIndex = 0; // back to Editor
+ UpdateCompareButtonState();
+ break;
+ }
+ }
+
+ private void UpdateCompareButtonState()
+ {
+ int planCount = 0;
+ foreach (var item in SubTabControl.Items)
+ {
+ if (item is TabItem t && t.Content is PlanViewerControl v && v.CurrentPlan != null)
+ planCount++;
+ }
+ ComparePlansButton.IsEnabled = planCount >= 2;
+ }
+
+ public IEnumerable<(string label, PlanViewerControl viewer)> GetPlanTabs()
+ {
+ foreach (var item in SubTabControl.Items)
+ {
+ if (item is TabItem tab && tab.Content is PlanViewerControl viewer
+ && viewer.CurrentPlan != null)
+ {
+ yield return (GetTabLabel(tab), viewer);
+ }
+ }
+ }
+
+ private static string GetTabLabel(TabItem tab)
+ {
+ if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
+ return tb.Text ?? "Plan";
+ if (tab.Header is string s)
+ return s;
+ return "Plan";
+ }
+
+ private bool HasQueryStoreTab()
+ {
+ return SubTabControl.Items.OfType()
+ .Any(t => t.Content is QueryStoreGridControl);
+ }
+
+ public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs());
+
+ private async void QueryStore_Click(object? sender, RoutedEventArgs e)
+ {
+ // If a QS tab already exists, always show connection dialog for a fresh tab
+ if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null)
+ {
+ await ShowConnectionDialogAsync();
+ if (_connectionString == null || _selectedDatabase == null)
+ return;
+ }
+
+ // Check if Query Store is enabled
+ SetStatus("Checking Query Store...");
+ try
+ {
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(_connectionString);
+ if (!enabled)
+ {
+ SetStatus($"Query Store not enabled ({state ?? "unknown"})");
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
+ return;
+ }
+
+ SetStatus("");
+
+ // Check if wait stats are supported (SQL 2017+ / Azure) and capture is enabled
+ var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
+ if (supportsWaitStats)
+ {
+ try
+ {
+ var connStr = _serverConnection!.GetConnectionString(_credentialService, _selectedDatabase!);
+ supportsWaitStats = await QueryStoreService.IsWaitStatsCaptureEnabledAsync(connStr);
+ }
+ catch
+ {
+ supportsWaitStats = false;
+ }
+ }
+
+ // Build database list from the current DatabaseBox
+ var databases = DatabaseBox.Items.OfType().ToList();
+
+ var grid = new QueryStoreGridControl(_serverConnection!, _credentialService,
+ _selectedDatabase!, databases, supportsWaitStats);
+ grid.PlansSelected += OnQueryStorePlansSelected;
+
+ var headerText = new TextBlock
+ {
+ Text = $"Query Store — {_selectedDatabase}",
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ FontSize = 12
+ };
+
+ // Update tab header when database is changed via the grid's picker
+ grid.DatabaseChanged += (_, db) =>
+ {
+ headerText.Text = $"Query Store — {db}";
+ };
+
+ 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 = Avalonia.Layout.VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ VerticalContentAlignment = VerticalAlignment.Center
+ };
+
+ var header = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ Children = { headerText, closeBtn }
+ };
+
+ var tab = new TabItem { Header = header, Content = grid };
+ closeBtn.Tag = tab;
+ closeBtn.Click += (s, _) =>
+ {
+ if (s is Button btn && btn.Tag is TabItem t)
+ SubTabControl.Items.Remove(t);
+ };
+
+ SubTabControl.Items.Add(tab);
+ SubTabControl.SelectedItem = tab;
+ }
+
+ private void OnQueryStorePlansSelected(object? sender, List plans)
+ {
+ foreach (var qsPlan in plans)
+ {
+ var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
+ AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
+ }
+
+ SetStatus($"{plans.Count} Query Store plans loaded");
+ HumanAdviceButton.IsEnabled = true;
+ RobotAdviceButton.IsEnabled = true;
+ }
+
+ private void ComparePlans_Click(object? sender, RoutedEventArgs e)
+ {
+ var planTabs = GetPlanTabs().ToList();
+ if (planTabs.Count < 2)
+ {
+ SetStatus("Need at least 2 plan tabs to compare");
+ return;
+ }
+
+ ShowComparePickerDialog(planTabs);
+ }
+
+ private void ShowComparePickerDialog(List<(string label, PlanViewerControl viewer)> planTabs)
+ {
+ var items = planTabs.Select(t => t.label).ToList();
+
+ var comboA = new ComboBox
+ {
+ ItemsSource = items,
+ SelectedIndex = 0,
+ Width = 200,
+ Height = 28,
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0)
+ };
+
+ var comboB = new ComboBox
+ {
+ ItemsSource = items,
+ SelectedIndex = items.Count > 1 ? 1 : 0,
+ Width = 200,
+ Height = 28,
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0)
+ };
+
+ var compareBtn = new Button
+ {
+ Content = "Compare",
+ Height = 32,
+ 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,
+ 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")!
+ };
+
+ void UpdateCompareEnabled()
+ {
+ compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0
+ && comboA.SelectedIndex != comboB.SelectedIndex;
+ }
+
+ comboA.SelectionChanged += (_, _) => UpdateCompareEnabled();
+ comboB.SelectionChanged += (_, _) => UpdateCompareEnabled();
+ UpdateCompareEnabled();
+
+ var rowA = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Margin = new Avalonia.Thickness(0, 0, 0, 8),
+ Children =
+ {
+ new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
+ comboA
+ }
+ };
+
+ var rowB = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children =
+ {
+ new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
+ comboB
+ }
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Avalonia.Thickness(0, 16, 0, 0),
+ Children = { compareBtn, cancelBtn }
+ };
+
+ var content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) },
+ rowA,
+ rowB,
+ buttonPanel
+ }
+ };
+
+ var dialog = new Window
+ {
+ Title = "Compare Plans",
+ Width = 380,
+ Height = 220,
+ MinWidth = 380,
+ MinHeight = 220,
+ Icon = GetParentWindow().Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = content,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ compareBtn.Click += (_, _) =>
+ {
+ var idxA = comboA.SelectedIndex;
+ var idxB = comboB.SelectedIndex;
+ if (idxA < 0 || idxB < 0 || idxA == idxB) return;
+
+ var (labelA, viewerA) = planTabs[idxA];
+ var (labelB, viewerB) = planTabs[idxB];
+
+ var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "query editor", _serverMetadata);
+ var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "query editor", _serverMetadata);
+
+ var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB);
+ dialog.Close();
+ ShowAdviceWindow("Plan Comparison", comparison);
+ };
+
+ cancelBtn.Click += (_, _) => dialog.Close();
+
+ dialog.ShowDialog(GetParentWindow());
+ }
+
+ ///
+ /// Gets the PlanViewerControl for the currently selected plan tab, or null if
+ /// the Editor tab or no plan tab is selected.
+ ///
+ private PlanViewerControl? GetSelectedPlanViewer()
+ {
+ if (SubTabControl.SelectedItem is TabItem tab && tab.Content is PlanViewerControl viewer
+ && viewer.CurrentPlan != null)
+ {
+ return viewer;
+ }
+ return null;
+ }
+
+ ///
+ /// Enables or disables buttons that require a plan tab to be selected.
+ /// Called when the SubTabControl selection changes and after plan tabs are added/removed.
+ ///
+ private void UpdatePlanTabButtonState()
+ {
+ var hasPlanTab = GetSelectedPlanViewer() != null;
+ var hasConnection = _connectionString != null && _selectedDatabase != null;
+
+ CopyReproButton.IsEnabled = hasPlanTab;
+ GetActualPlanButton.IsEnabled = hasPlanTab && hasConnection;
+
+ // Advice buttons also depend on a plan being selected
+ HumanAdviceButton.IsEnabled = hasPlanTab;
+ RobotAdviceButton.IsEnabled = hasPlanTab;
+ }
+
+ 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 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");
+ }
+
+ 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/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
index 3a9f670..29555d1 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml
@@ -1,378 +1,378 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index c7261a9..ee25a09 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -1,1931 +1,1900 @@
-using System;
-using System.Collections.Generic;
-using System.Collections.ObjectModel;
-using System.ComponentModel;
-using System.Linq;
-using System.Runtime.CompilerServices;
-using System.Threading;
-using Avalonia;
-using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-using Avalonia.Interactivity;
-using Avalonia.Media;
-using Microsoft.Data.SqlClient;
-using PlanViewer.Core.Interfaces;
-using PlanViewer.Core.Models;
-using PlanViewer.App.Dialogs;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Services;
-
-namespace PlanViewer.App.Controls;
-
-public partial class QueryStoreGridControl : UserControl
-{
- private readonly ServerConnection _serverConnection;
- private readonly ICredentialService _credentialService;
- private string _connectionString;
- private string _database;
- private CancellationTokenSource? _fetchCts;
- private ObservableCollection _rows = new();
- private ObservableCollection _filteredRows = new();
- private readonly Dictionary _activeFilters = new();
- private Popup? _filterPopup;
- private ColumnFilterPopup? _filterPopupContent;
- private string? _sortedColumnTag;
- private bool _sortAscending;
- private DateTime? _slicerStartUtc;
- private DateTime? _slicerEndUtc;
- private int _slicerDaysBack = 30;
- private string _lastFetchedOrderBy = "cpu";
- private bool _initialOrderByLoaded;
- private bool _suppressRangeChanged;
- private string? _waitHighlightCategory;
- private const int AutoSelectTopN = 1; // number of rows auto-selected after each fetch
- private bool _waitStatsSupported; // false until version + capture mode confirmed
- private bool _waitStatsEnabled = true;
- private bool _waitPercentMode;
- private QueryStoreGroupBy _groupByMode = QueryStoreGroupBy.None;
- private List _groupedRootRows = new(); // top-level rows for grouped mode
-
- public event EventHandler>? PlansSelected;
- public event EventHandler? DatabaseChanged;
-
- public string Database => _database;
-
- public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService,
- string initialDatabase, List databases, bool supportsWaitStats = false)
- {
- _serverConnection = serverConnection;
- _credentialService = credentialService;
- _database = initialDatabase;
- _connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
- _waitStatsSupported = supportsWaitStats;
- _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
- InitializeComponent();
- ResultsGrid.ItemsSource = _filteredRows;
- Helpers.DataGridBehaviors.Attach(ResultsGrid);
- EnsureFilterPopup();
- SetupColumnHeaders();
- PopulateDatabaseBox(databases, initialDatabase);
- TimeRangeSlicer.RangeChanged += OnTimeRangeChanged;
-
- WaitStatsProfile.CategoryClicked += OnWaitCategoryClicked;
- WaitStatsProfile.CategoryDoubleClicked += OnWaitCategoryDoubleClicked;
- WaitStatsProfile.CollapsedChanged += OnWaitStatsCollapsedChanged;
-
- if (!_waitStatsSupported)
- {
- // Hide wait stats panel and column when server doesn't support it
- WaitStatsProfile.Collapse();
- WaitStatsChevronButton.IsVisible = false;
- WaitStatsSplitter.IsVisible = false;
- SlicerRow.ColumnDefinitions[2].Width = new GridLength(0);
- var waitProfileCol = ResultsGrid.Columns
- .FirstOrDefault(c => c.SortMemberPath == "WaitGrandTotalSort");
- if (waitProfileCol != null)
- waitProfileCol.IsVisible = false;
- }
-
- // Auto-fetch with default settings on connect
- Avalonia.Threading.Dispatcher.UIThread.Post(() =>
- {
- Fetch_Click(null, new RoutedEventArgs());
- _initialOrderByLoaded = true;
- }, Avalonia.Threading.DispatcherPriority.Loaded);
- }
-
- private void PopulateDatabaseBox(List databases, string selectedDatabase)
- {
- QsDatabaseBox.ItemsSource = databases;
- QsDatabaseBox.SelectedItem = selectedDatabase;
- }
-
- private async void QsDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (QsDatabaseBox.SelectedItem is not string db || db == _database) return;
-
- _fetchCts?.Cancel();
-
- // Check if Query Store is enabled on the new database
- var newConnStr = _serverConnection.GetConnectionString(_credentialService, db);
- StatusText.Text = "Checking Query Store...";
-
- try
- {
- var (enabled, state) = await QueryStoreService.CheckEnabledAsync(newConnStr);
- if (!enabled)
- {
- StatusText.Text = $"Query Store not enabled on {db} ({state ?? "unknown"})";
- QsDatabaseBox.SelectedItem = _database; // revert
- return;
- }
- }
- catch (Exception ex)
- {
- StatusText.Text = ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message;
- QsDatabaseBox.SelectedItem = _database; // revert
- return;
- }
-
- _database = db;
- _connectionString = newConnStr;
- _rows.Clear();
- _filteredRows.Clear();
- LoadButton.IsEnabled = false;
- StatusText.Text = "";
- DatabaseChanged?.Invoke(this, db);
- }
-
- private async void Fetch_Click(object? sender, RoutedEventArgs e)
- {
- _fetchCts?.Cancel();
- _fetchCts?.Dispose();
- _fetchCts = new CancellationTokenSource();
- var ct = _fetchCts.Token;
-
- var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
- _lastFetchedOrderBy = orderBy;
-
- FetchButton.IsEnabled = false;
- LoadButton.IsEnabled = false;
- StatusText.Text = "Loading time slicer...";
- _rows.Clear();
- _filteredRows.Clear();
-
- try
- {
- // Load slicer data, preserving the current selection if one exists.
- // Without this, LoadData defaults to last 24h and the user's range is lost.
- await LoadTimeSlicerDataAsync(orderBy, ct, _slicerStartUtc, _slicerEndUtc);
- }
- catch (OperationCanceledException)
- {
- StatusText.Text = "Cancelled.";
- }
- catch (Exception ex)
- {
- StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
- }
- finally
- {
- FetchButton.IsEnabled = true;
- }
- }
-
- private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
- {
- _fetchCts?.Cancel();
- _fetchCts?.Dispose();
- _fetchCts = new CancellationTokenSource();
- var ct = _fetchCts.Token;
-
- var topN = (int)(TopNBox.Value ?? 25);
- var orderBy = _lastFetchedOrderBy;
- var filter = BuildSearchFilter();
-
- FetchButton.IsEnabled = false;
- LoadButton.IsEnabled = false;
- StatusText.Text = "Fetching plans...";
- GridLoadingOverlay.IsVisible = true;
- GridLoadingText.Text = "Fetching plans...";
- GridEmptyMessage.IsVisible = false;
- _rows.Clear();
- _filteredRows.Clear();
- _groupedRootRows.Clear();
-
- // Start global + ribbon wait stats early (they don't depend on plan results)
- if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- _ = FetchGlobalWaitStatsOnlyAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
-
- try
- {
- if (_groupByMode == QueryStoreGroupBy.None)
- {
- await FetchFlatPlansAsync(topN, orderBy, filter, ct);
- }
- else
- {
- await FetchGroupedPlansAsync(topN, orderBy, filter, ct);
- }
- }
- catch (OperationCanceledException)
- {
- StatusText.Text = "Cancelled.";
- }
- catch (Exception ex)
- {
- StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
- }
- finally
- {
- GridLoadingOverlay.IsVisible = false;
- FetchButton.IsEnabled = true;
- }
- }
-
- private async System.Threading.Tasks.Task FetchFlatPlansAsync(
- int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
- {
- var plans = await QueryStoreService.FetchTopPlansAsync(
- _connectionString, topN, orderBy, filter: filter, ct: ct,
- startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
-
- GridLoadingOverlay.IsVisible = false;
-
- if (plans.Count == 0)
- {
- StatusText.Text = "No Query Store data found for the selected range.";
- return;
- }
-
- foreach (var plan in plans)
- _rows.Add(new QueryStoreRow(plan));
-
- ApplyFilters();
- LoadButton.IsEnabled = true;
- SelectToggleButton.Content = "Select All";
-
- // Fetch per-plan wait stats after grid is populated (needs plan IDs)
- if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
- }
-
- private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
- int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
- {
- QueryStoreGroupedResult grouped;
- if (_groupByMode == QueryStoreGroupBy.QueryHash)
- {
- grouped = await QueryStoreService.FetchGroupedByQueryHashAsync(
- _connectionString, topN, orderBy, filter, ct,
- _slicerStartUtc, _slicerEndUtc);
- }
- else // Module
- {
- grouped = await QueryStoreService.FetchGroupedByModuleAsync(
- _connectionString, topN, orderBy, filter, ct,
- _slicerStartUtc, _slicerEndUtc);
- }
-
- GridLoadingOverlay.IsVisible = false;
- GridEmptyMessage.IsVisible = false;
-
- if (grouped.IntermediateRows.Count == 0)
- {
- if (_groupByMode == QueryStoreGroupBy.Module)
- {
- GridEmptyMessageText.Text = "No module found in the selected period";
- GridEmptyMessage.IsVisible = true;
- }
- else
- {
- StatusText.Text = "No Query Store data found for the selected range.";
- }
- return;
- }
-
- var rootRows = BuildGroupedRows(grouped);
-
- // Sort root rows by consolidated metric descending
- var metricAccessor = GetMetricAccessor(orderBy);
- rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList();
- _groupedRootRows = rootRows;
-
- // Flatten to _rows (all levels) and show only top-level in _filteredRows
- foreach (var root in rootRows)
- {
- _rows.Add(root);
- foreach (var mid in root.Children)
- {
- _rows.Add(mid);
- foreach (var leaf in mid.Children)
- _rows.Add(leaf);
- }
- }
-
- // Show only root-level rows initially (collapsed)
- _filteredRows.Clear();
- foreach (var root in rootRows)
- _filteredRows.Add(root);
-
- LoadButton.IsEnabled = true;
- SelectToggleButton.Content = "Select All";
-
- // Auto-expand the first root row to the deepest level
- if (rootRows.Count > 0)
- {
- var first = rootRows[0];
- ExpandRowRecursive(first);
- }
-
- UpdateStatusText();
- UpdateBarRatios();
-
- // Fetch per-plan wait stats for leaf rows, then consolidate upward
- if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
- }
-
- ///
- /// Recursively expands a row and all its children, inserting them into _filteredRows.
- ///
- private void ExpandRowRecursive(QueryStoreRow row)
- {
- if (!row.HasChildren) return;
- row.IsExpanded = true;
-
- var idx = _filteredRows.IndexOf(row);
- if (idx < 0) return;
-
- var insertAt = idx + 1;
- foreach (var child in row.Children)
- {
- _filteredRows.Insert(insertAt, child);
- insertAt++;
- }
-
- // Recurse into each child that has children
- foreach (var child in row.Children)
- ExpandRowRecursive(child);
- }
-
- ///
- /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy,
- /// assigns them to leaf rows, then consolidates upward to intermediate and root rows.
- ///
- private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync(
- DateTime startUtc, DateTime endUtc, CancellationToken ct)
- {
- try
- {
- // Collect all real plan IDs from rows that have a real PlanId
- var allPlanIds = _rows
- .Where(r => r.PlanId > 0)
- .Select(r => r.PlanId)
- .Distinct()
- .ToList();
-
- if (allPlanIds.Count == 0) return;
-
- var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
- _connectionString, startUtc, endUtc, allPlanIds, ct);
- if (ct.IsCancellationRequested) return;
-
- // Build lookup: plan_id → list of WaitCategoryTotal
- var byPlan = planWaits
- .GroupBy(x => x.PlanId)
- .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
-
- // 1. Assign raw waits + profiles to rows with a real PlanId
- foreach (var row in _rows)
- {
- if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits))
- {
- row.RawWaitCategories = waits;
- row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
- }
- }
-
- // 2. Consolidate upward through the hierarchy
- foreach (var root in _groupedRootRows)
- ConsolidateWaitProfileUpward(root);
-
- UpdateWaitBarMode();
- }
- catch (OperationCanceledException) { }
- catch (Exception) { }
- }
-
- ///
- /// Recursively consolidates wait profiles from children into their parent.
- /// For each parent: merges all children's RawWaitCategories by summing WaitRatio
- /// per category, then builds a new WaitProfile from the merged totals.
- ///
- private static void ConsolidateWaitProfileUpward(QueryStoreRow parent)
- {
- if (parent.Children.Count == 0) return;
-
- // Recurse first so children are consolidated before we merge them
- foreach (var child in parent.Children)
- ConsolidateWaitProfileUpward(child);
-
- // Merge all children's raw wait categories by summing WaitRatio per category
- var merged = parent.Children
- .SelectMany(c => c.RawWaitCategories)
- .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc })
- .Select(g => new WaitCategoryTotal
- {
- WaitCategory = g.Key.WaitCategory,
- WaitCategoryDesc = g.Key.WaitCategoryDesc,
- WaitRatio = g.Sum(w => w.WaitRatio),
- })
- .ToList();
-
- if (merged.Count > 0)
- {
- parent.RawWaitCategories = merged;
- parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged);
- }
- }
-
- /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow.
- private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch
- {
- "cpu" => r => r.TotalCpuSort,
- "avg-cpu" => r => r.AvgCpuSort,
- "duration" => r => r.TotalDurSort,
- "avg-duration" => r => r.AvgDurSort,
- "reads" => r => r.TotalReadsSort,
- "avg-reads" => r => r.AvgReadsSort,
- "writes" => r => r.TotalWritesSort,
- "avg-writes" => r => r.AvgWritesSort,
- "physical-reads" => r => r.TotalPhysReadsSort,
- "avg-physical-reads" => r => r.AvgPhysReadsSort,
- "memory" => r => r.TotalMemSort,
- "avg-memory" => r => r.AvgMemSort,
- "executions" => r => r.ExecsSort,
- _ => r => r.TotalCpuSort,
- };
-
- private List BuildGroupedRows(QueryStoreGroupedResult grouped)
- {
- var roots = new List();
- var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy);
-
- if (_groupByMode == QueryStoreGroupBy.QueryHash)
- {
- // Level 0: QueryHash groups
- var queryHashGroups = grouped.IntermediateRows
- .GroupBy(r => r.QueryHash)
- .ToList();
-
- foreach (var qhGroup in queryHashGroups)
- {
- var qhKey = qhGroup.Key;
- var intermediateRows = qhGroup.ToList();
-
- // Build level-1 children (PlanHash)
- var midChildren = new List();
- foreach (var mid in intermediateRows)
- {
- // Build level-2 children (QueryId/PlanId)
- var leafChildren = new List();
- var leaves = grouped.LeafRows
- .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash)
- .ToList();
- foreach (var leaf in leaves)
- {
- var leafPlan = GroupedRowToPlan(leaf);
- leafChildren.Add(new QueryStoreRow(leafPlan, 2,
- $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
- }
-
- // Sort leaf children by metric descending
- leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
-
- var midPlan = GroupedRowToPlan(mid);
- // Populate QueryText from the top representative leaf for this plan hash
- var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
- if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
- midPlan.QueryText = topLeafForMid.QueryText;
- midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
- }
-
- // Sort mid children by metric descending
- midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
-
- // Aggregate metrics at QueryHash level
- var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? "");
- // Populate QueryText from the top representative leaf across all leaves in this query hash group
- var topLeafForRoot = grouped.LeafRows
- .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
- .FirstOrDefault()
- ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
- if (topLeafForRoot != null)
- aggPlan.QueryText = topLeafForRoot.QueryText;
- roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
- }
- }
- else // Module
- {
- // Level 0: Module groups
- var moduleGroups = grouped.IntermediateRows
- .GroupBy(r => r.ModuleName)
- .ToList();
-
- foreach (var modGroup in moduleGroups)
- {
- var modKey = modGroup.Key;
- var intermediateRows = modGroup.ToList();
-
- // Build level-1 children (QueryHash)
- var midChildren = new List();
- foreach (var mid in intermediateRows)
- {
- // Build level-2 children (QueryId/PlanId)
- var leafChildren = new List();
- var leaves = grouped.LeafRows
- .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash)
- .ToList();
- foreach (var leaf in leaves)
- {
- var leafPlan = GroupedRowToPlan(leaf);
- leafChildren.Add(new QueryStoreRow(leafPlan, 2,
- $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
- }
-
- // Sort leaf children by metric descending
- leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
-
- var midPlan = GroupedRowToPlan(mid);
- // Populate QueryText from the top representative leaf for this query hash
- var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
- if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
- midPlan.QueryText = topLeafForMid.QueryText;
- midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
- }
-
- // Sort mid children by metric descending
- midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
-
- // Aggregate metrics at Module level
- var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey);
- // Populate QueryText from the top representative leaf across all leaves in this module group
- var topLeafForRoot = grouped.LeafRows
- .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
- .FirstOrDefault()
- ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
- if (topLeafForRoot != null)
- aggPlan.QueryText = topLeafForRoot.QueryText;
- roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
- }
- }
-
- return roots;
- }
-
- private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row)
- {
- var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1;
- return new QueryStorePlan
- {
- QueryId = row.QueryId,
- PlanId = row.PlanId,
- QueryHash = row.QueryHash,
- QueryPlanHash = row.QueryPlanHash,
- ModuleName = row.ModuleName,
- QueryText = row.QueryText,
- PlanXml = row.PlanXml,
- CountExecutions = row.CountExecutions,
- TotalCpuTimeUs = row.TotalCpuTimeUs,
- TotalDurationUs = row.TotalDurationUs,
- TotalLogicalIoReads = row.TotalLogicalIoReads,
- TotalLogicalIoWrites = row.TotalLogicalIoWrites,
- TotalPhysicalIoReads = row.TotalPhysicalIoReads,
- TotalMemoryGrantPages = row.TotalMemoryGrantPages,
- AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs,
- AvgDurationUs = (double)row.TotalDurationUs / totalExecs,
- AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs,
- AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs,
- AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs,
- AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs,
- LastExecutedUtc = row.LastExecutedUtc,
- };
- }
-
- private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName)
- {
- var totalExecs = rows.Sum(r => r.CountExecutions);
- var safeExecs = totalExecs > 0 ? totalExecs : 1;
- var totalCpu = rows.Sum(r => r.TotalCpuTimeUs);
- var totalDur = rows.Sum(r => r.TotalDurationUs);
- var totalReads = rows.Sum(r => r.TotalLogicalIoReads);
- var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites);
- var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads);
- var totalMem = rows.Sum(r => r.TotalMemoryGrantPages);
- var lastExec = rows.Max(r => r.LastExecutedUtc);
-
- return new QueryStorePlan
- {
- QueryHash = queryHash,
- ModuleName = moduleName,
- CountExecutions = totalExecs,
- TotalCpuTimeUs = totalCpu,
- TotalDurationUs = totalDur,
- TotalLogicalIoReads = totalReads,
- TotalLogicalIoWrites = totalWrites,
- TotalPhysicalIoReads = totalPhysReads,
- TotalMemoryGrantPages = totalMem,
- AvgCpuTimeUs = (double)totalCpu / safeExecs,
- AvgDurationUs = (double)totalDur / safeExecs,
- AvgLogicalIoReads = (double)totalReads / safeExecs,
- AvgLogicalIoWrites = (double)totalWrites / safeExecs,
- AvgPhysicalIoReads = (double)totalPhysReads / safeExecs,
- AvgMemoryGrantPages = (double)totalMem / safeExecs,
- LastExecutedUtc = lastExec,
- };
- }
-
- private QueryStoreFilter? BuildSearchFilter()
- {
- var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
- var searchValue = SearchValueBox.Text?.Trim();
-
- if (string.IsNullOrEmpty(searchType) || string.IsNullOrEmpty(searchValue))
- return null;
-
- var filter = new QueryStoreFilter();
-
- switch (searchType)
- {
- case "query-id" when long.TryParse(searchValue, out var qid):
- filter.QueryId = qid;
- break;
- case "query-id":
- StatusText.Text = "Invalid Query ID";
- return null;
- case "plan-id" when long.TryParse(searchValue, out var pid):
- filter.PlanId = pid;
- break;
- case "plan-id":
- StatusText.Text = "Invalid Plan ID";
- return null;
- case "query-hash":
- filter.QueryHash = searchValue;
- break;
- case "plan-hash":
- filter.QueryPlanHash = searchValue;
- break;
- case "module":
- // Default to dbo schema if no schema specified, following sp_QuickieStore pattern
- filter.ModuleName = searchValue.Contains('.') ? searchValue : $"dbo.{searchValue}";
- break;
- default:
- return null;
- }
-
- return filter;
- }
-
- private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
- {
- if (e.Key == Avalonia.Input.Key.Enter)
- {
- Fetch_Click(sender, e);
- e.Handled = true;
- }
- }
-
- private int[]? _savedColumnDisplayIndices;
-
- private void GroupBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (!_initialOrderByLoaded) return;
- var tag = (GroupByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "none";
- var newMode = tag switch
- {
- "query-hash" => QueryStoreGroupBy.QueryHash,
- "module" => QueryStoreGroupBy.Module,
- _ => QueryStoreGroupBy.None,
- };
- if (newMode == _groupByMode) return;
- _groupByMode = newMode;
-
- // Show/hide the expand column (first column in the grid)
- ResultsGrid.Columns[0].IsVisible = _groupByMode != QueryStoreGroupBy.None;
-
- // Reorder columns: move the group key column right after expand+checkbox
- ReorderColumnsForGroupBy();
-
- // Re-fetch with new grouping
- Fetch_Click(null, new RoutedEventArgs());
- }
-
- private void ReorderColumnsForGroupBy()
- {
- var cols = ResultsGrid.Columns;
-
- if (_groupByMode == QueryStoreGroupBy.None)
- {
- // Restore original column order
- if (_savedColumnDisplayIndices != null)
- {
- for (int i = 0; i < cols.Count && i < _savedColumnDisplayIndices.Length; i++)
- cols[i].DisplayIndex = _savedColumnDisplayIndices[i];
- _savedColumnDisplayIndices = null;
- }
- // Reset header colors
- ApplyGroupByHeaderColors();
- return;
- }
-
- // Save original order if not yet saved
- _savedColumnDisplayIndices ??= cols.Select(c => c.DisplayIndex).ToArray();
-
- // Column definition indices (AXAML order):
- // 0=Expand, 1=Checkbox, 2=QueryId, 3=PlanId, 4=QueryHash, 5=PlanHash, 6=Module
- if (_groupByMode == QueryStoreGroupBy.QueryHash)
- {
- // Order: Expand, Checkbox, QueryHash, PlanHash, QueryId, PlanId, ...
- cols[4].DisplayIndex = 2; // QueryHash → 2
- cols[5].DisplayIndex = 3; // PlanHash → 3
- cols[2].DisplayIndex = 4; // QueryId → 4
- cols[3].DisplayIndex = 5; // PlanId → 5
- }
- else // Module
- {
- // Order: Expand, Checkbox, Module, QueryHash, QueryId, PlanId, ...
- cols[6].DisplayIndex = 2; // Module → 2
- cols[4].DisplayIndex = 3; // QueryHash → 3
- cols[2].DisplayIndex = 4; // QueryId → 4
- cols[3].DisplayIndex = 5; // PlanId → 5
- }
-
- // Apply golden header colors for expandable columns
- ApplyGroupByHeaderColors();
- }
-
- ///
- /// Applies golden foreground to column headers that represent expandable/collapsible
- /// grouping levels in the current GroupBy mode, and resets others.
- ///
- private void ApplyGroupByHeaderColors()
- {
- // Column definition indices: 4=QueryHash, 5=PlanHash, 6=Module
- var goldenCols = _groupByMode switch
- {
- QueryStoreGroupBy.QueryHash => new HashSet { 4, 5 }, // QueryHash + PlanHash
- QueryStoreGroupBy.Module => new HashSet { 6, 4 }, // Module + QueryHash
- _ => new HashSet(),
- };
-
- var goldenBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00)); // Gold
-
- for (int i = 0; i < ResultsGrid.Columns.Count; i++)
- {
- var col = ResultsGrid.Columns[i];
- if (col.Header is not StackPanel sp) continue;
- var label = sp.Children.OfType().LastOrDefault();
- if (label == null) continue;
-
- if (goldenCols.Contains(i))
- label.Foreground = goldenBrush;
- else
- label.ClearValue(TextBlock.ForegroundProperty);
- }
- }
-
- private void ExpandRow_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is not Button btn) return;
- if (btn.DataContext is not QueryStoreRow row) return;
- if (!row.HasChildren) return;
-
- row.IsExpanded = !row.IsExpanded;
-
- if (row.IsExpanded)
- {
- // Insert children after this row in _filteredRows
- var idx = _filteredRows.IndexOf(row);
- if (idx < 0) return;
- var insertAt = idx + 1;
- foreach (var child in row.Children)
- {
- _filteredRows.Insert(insertAt, child);
- insertAt++;
- }
-
- // Scroll the first child into view so the expansion is visible
- if (row.Children.Count > 0)
- ResultsGrid.ScrollIntoView(row.Children[0], null);
- }
- else
- {
- // Remove children (and their expanded children) recursively
- CollapseRowChildren(row);
- }
-
- UpdateStatusText();
- UpdateBarRatios();
- }
-
- private void CollapseRowChildren(QueryStoreRow parent)
- {
- foreach (var child in parent.Children)
- {
- if (child.IsExpanded)
- {
- child.IsExpanded = false;
- CollapseRowChildren(child);
- }
- _filteredRows.Remove(child);
- }
- }
-
- private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (!_initialOrderByLoaded) return;
- var newOrderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
- if (newOrderBy == _lastFetchedOrderBy) return;
-
- _lastFetchedOrderBy = newOrderBy;
-
- _fetchCts?.Cancel();
- _fetchCts?.Dispose();
- _fetchCts = new CancellationTokenSource();
- var ct = _fetchCts.Token;
-
- // Capture the current slicer selection so it survives the reload
- var selStart = TimeRangeSlicer.SelectionStart;
- var selEnd = TimeRangeSlicer.SelectionEnd;
-
- FetchButton.IsEnabled = false;
- StatusText.Text = "Refreshing metric...";
-
- try
- {
- var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
- _connectionString, newOrderBy, _slicerDaysBack, ct);
- if (ct.IsCancellationRequested) return;
-
- if (sliceData.Count > 0)
- {
- // Suppress the implicit RangeChanged fetch — we will refresh the grid explicitly below
- _suppressRangeChanged = true;
- try { TimeRangeSlicer.LoadData(sliceData, newOrderBy, selStart, selEnd); }
- finally { _suppressRangeChanged = false; }
-
- // Explicitly refresh the grid with the new metric and current time range
- await FetchPlansForRangeAsync();
- }
- else
- {
- StatusText.Text = "No time-slicer data available.";
- }
- }
- catch (OperationCanceledException) { }
- catch (Exception ex)
- {
- StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
- }
- finally
- {
- FetchButton.IsEnabled = true;
- }
- }
-
- private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (!IsInitialized) return;
- var tag = (TimeDisplayBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
- if (tag == null) return;
- TimeDisplayHelper.Current = tag switch
- {
- "Utc" => TimeDisplayMode.Utc,
- "Server" => TimeDisplayMode.Server,
- _ => TimeDisplayMode.Local
- };
- // Refresh grid display
- if (_filteredRows.Count > 0)
- {
- foreach (var row in _filteredRows)
- row.NotifyTimeDisplayChanged();
- ResultsGrid.ItemsSource = null;
- ResultsGrid.ItemsSource = _filteredRows;
- }
- // Refresh slicer labels
- TimeRangeSlicer.Redraw();
- }
-
- private void ClearSearch_Click(object? sender, RoutedEventArgs e)
- {
- SearchTypeBox.SelectedIndex = 0;
- SearchValueBox.Text = "";
- }
-
- private async System.Threading.Tasks.Task LoadTimeSlicerDataAsync(
- string metric, CancellationToken ct,
- DateTime? preserveStart = null, DateTime? preserveEnd = null)
- {
- try
- {
- var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
- _connectionString, metric, _slicerDaysBack, ct);
- if (ct.IsCancellationRequested) return;
- if (sliceData.Count > 0)
- TimeRangeSlicer.LoadData(sliceData, metric, preserveStart, preserveEnd);
- else
- StatusText.Text = "No time-slicer data available.";
- }
- catch (OperationCanceledException) { throw; }
- catch (Exception ex)
- {
- StatusText.Text = $"Slicer: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}";
- }
- }
-
- private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs e)
- {
- _slicerStartUtc = e.StartUtc;
- _slicerEndUtc = e.EndUtc;
- if (_suppressRangeChanged) return;
- await FetchPlansForRangeAsync();
- }
-
- // ── Wait stats ─────────────────────────────────────────────────────────
-
- ///
- /// Fetches global bar + ribbon wait stats (independent of grid plan IDs).
- /// Shows loading indicator on the wait stats panel.
- ///
- private async System.Threading.Tasks.Task FetchGlobalWaitStatsOnlyAsync(
- DateTime startUtc, DateTime endUtc, CancellationToken ct)
- {
- WaitStatsProfile.SetLoading(true);
- try
- {
- // Global (bar)
- var globalWaits = await QueryStoreService.FetchGlobalWaitStatsAsync(
- _connectionString, startUtc, endUtc, ct);
- if (ct.IsCancellationRequested) { return; }
- var globalProfile = QueryStoreService.BuildWaitProfile(globalWaits);
- WaitStatsProfile.SetBarProfile(globalProfile);
-
- // Global (ribbon) — fetched lazily, data ready for toggle
- var ribbonData = await QueryStoreService.FetchGlobalWaitStatsRibbonAsync(
- _connectionString, startUtc, endUtc, ct);
- if (ct.IsCancellationRequested) { return; }
- WaitStatsProfile.SetRibbonData(ribbonData);
- }
- catch (OperationCanceledException) { }
- catch (Exception) { }
- finally
- {
- WaitStatsProfile.SetLoading(false);
- }
- }
-
- ///
- /// Fetches per-plan wait stats for the plan IDs currently in the grid.
- ///
- private async System.Threading.Tasks.Task FetchPerPlanWaitStatsAsync(
- DateTime startUtc, DateTime endUtc, CancellationToken ct)
- {
- try
- {
- var visiblePlanIds = _rows.Select(r => r.PlanId).ToList();
- var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
- _connectionString, startUtc, endUtc, visiblePlanIds, ct);
- if (ct.IsCancellationRequested) { return; }
-
- var byPlan = planWaits
- .GroupBy(x => x.PlanId)
- .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
-
- foreach (var row in _rows)
- {
- if (byPlan.TryGetValue(row.PlanId, out var waits))
- row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
- else
- row.WaitProfile = null;
- }
- UpdateWaitBarMode();
- }
- catch (OperationCanceledException) { }
- catch (Exception) { }
- }
-
- ///
- /// Full wait stats fetch (global + ribbon + per-plan). Used when re-expanding the wait stats panel.
- ///
- private async System.Threading.Tasks.Task FetchWaitStatsAsync(
- DateTime startUtc, DateTime endUtc, CancellationToken ct)
- {
- await FetchGlobalWaitStatsOnlyAsync(startUtc, endUtc, ct);
- if (_groupByMode != QueryStoreGroupBy.None)
- await FetchGroupedWaitStatsAsync(startUtc, endUtc, ct);
- else
- await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
- }
-
- private void OnWaitCategoryClicked(object? sender, string category)
- {
- // Toggle highlight: click same category again → clear
- if (_waitHighlightCategory == category)
- _waitHighlightCategory = null;
- else
- _waitHighlightCategory = category;
-
- ApplyWaitHighlight();
- }
-
- private void OnWaitCategoryDoubleClicked(object? sender, string category)
- {
- _waitHighlightCategory = category;
- ApplyWaitHighlight();
-
- // Sort grid by this category's wait ratio (descending)
- var sorted = _filteredRows
- .OrderByDescending(r =>
- r.WaitProfile?.Segments
- .Where(s => s.Category == category)
- .Sum(s => s.WaitRatio) ?? 0)
- .ToList();
-
- _filteredRows.Clear();
- foreach (var row in sorted)
- _filteredRows.Add(row);
-
- // Clear column sort indicators since we're using custom sort
- _sortedColumnTag = null;
- UpdateSortIndicators(null);
- ReapplyTopNSelection();
- UpdateBarRatios();
- }
-
- private void ApplyWaitHighlight()
- {
- WaitStatsProfile.SetHighlight(_waitHighlightCategory);
- foreach (var row in _rows)
- row.WaitHighlightCategory = _waitHighlightCategory;
- }
-
- private void OnWaitStatsCollapsedChanged(object? sender, bool collapsed)
- {
- _waitStatsEnabled = !collapsed;
-
- var waitProfileCol = ResultsGrid.Columns
- .FirstOrDefault(c => c.SortMemberPath == "WaitGrandTotalSort");
- if (waitProfileCol != null)
- waitProfileCol.IsVisible = !collapsed;
-
- if (!collapsed && _waitStatsSupported && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
- {
- // Re-fetch wait stats when expanding — reuse the shared CTS
- var ct = _fetchCts?.Token ?? CancellationToken.None;
- _ = FetchWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
- }
- }
-
- private void WaitStatsChevron_Click(object? sender, RoutedEventArgs e)
- {
- if (WaitStatsProfile.IsCollapsed)
- {
- WaitStatsProfile.Expand();
- WaitStatsChevronButton.Content = "»";
- SlicerRow.ColumnDefinitions[0].Width = new GridLength(2, GridUnitType.Star);
- SlicerRow.ColumnDefinitions[2].Width = new GridLength(1, GridUnitType.Star);
- }
- else
- {
- WaitStatsProfile.Collapse();
- WaitStatsChevronButton.Content = "«";
- SlicerRow.ColumnDefinitions[0].Width = new GridLength(1, GridUnitType.Star);
- SlicerRow.ColumnDefinitions[2].Width = new GridLength(0);
- }
- }
-
- private void WaitModeToggle_Click(object? sender, RoutedEventArgs e)
- {
- _waitPercentMode = !_waitPercentMode;
- if (sender is Button btn)
- btn.Content = _waitPercentMode ? "%" : "v";
- UpdateWaitBarMode();
- }
-
- private void UpdateWaitBarMode()
- {
- var maxGrand = _filteredRows.Count > 0
- ? _filteredRows.Max(r => r.WaitProfile?.GrandTotalRatio ?? 0)
- : 1.0;
- if (maxGrand <= 0) maxGrand = 1.0;
- foreach (var row in _filteredRows)
- {
- row.WaitPercentMode = _waitPercentMode;
- row.WaitMaxGrandTotal = maxGrand;
- }
- }
-
- private void SelectToggle_Click(object? sender, RoutedEventArgs e)
- {
- var allSelected = _filteredRows.Count > 0 && _filteredRows.All(r => r.IsSelected);
- foreach (var row in _filteredRows)
- row.IsSelected = !allSelected;
- SelectToggleButton.Content = allSelected ? "Select All" : "Select None";
- }
-
- private void LoadSelected_Click(object? sender, RoutedEventArgs e)
- {
- List selected;
- if (_groupByMode != QueryStoreGroupBy.None)
- {
- // In grouped mode, expand selected grouped rows to their leaf plans
- selected = _filteredRows
- .Where(r => r.IsSelected)
- .SelectMany(r => r.HasChildren ? CollectLeafPlans(r) : (r.PlanId > 0 && r.QueryId > 0 ? [r.Plan] : []))
- .ToList();
- }
- else
- {
- selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
- }
- if (selected.Count > 0)
- PlansSelected?.Invoke(this, selected);
- }
-
- private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
- {
- if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
-
- // In grouped mode, load all descendant leaf plans with real IDs
- if (_groupByMode != QueryStoreGroupBy.None && row.HasChildren)
- {
- var leafPlans = CollectLeafPlans(row);
- if (leafPlans.Count > 0)
- PlansSelected?.Invoke(this, leafPlans);
- }
- else if (row.PlanId > 0 && row.QueryId > 0)
- {
- PlansSelected?.Invoke(this, new List { row.Plan });
- }
- }
-
- ///
- /// Recursively collects all leaf-level plans (PlanId > 0 and QueryId > 0) from a grouped row and its descendants.
- ///
- private static List CollectLeafPlans(QueryStoreRow row)
- {
- var plans = new List();
- if (row.Children.Count == 0)
- {
- if (row.PlanId > 0 && row.QueryId > 0)
- plans.Add(row.Plan);
- }
- else
- {
- foreach (var child in row.Children)
- plans.AddRange(CollectLeafPlans(child));
- }
- return plans;
- }
-
- private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
- {
- if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
- if (string.IsNullOrEmpty(row.QueryHash)) return;
-
- var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
-
- var window = new QueryStoreHistoryWindow(
- _connectionString,
- row.QueryHash,
- row.FullQueryText,
- _database,
- initialMetricTag: metricTag,
- slicerStartUtc: _slicerStartUtc,
- slicerEndUtc: _slicerEndUtc,
- slicerDaysBack: _slicerDaysBack);
-
- var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
- if (topLevel is Window parentWindow)
- await window.ShowDialog(parentWindow);
- else
- window.Show();
- }
-
- // ── Context menu ────────────────────────────────────────────────────────
-
- private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
- {
- var row = ResultsGrid.SelectedItem as QueryStoreRow;
- var hasRow = row != null;
-
- ViewHistoryItem.IsEnabled = hasRow;
- CopyQueryIdItem.IsEnabled = hasRow;
- CopyPlanIdItem.IsEnabled = hasRow;
- CopyQueryHashItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.QueryHash);
- CopyPlanHashItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.QueryPlanHash);
- CopyModuleItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.ModuleName);
- CopyQueryTextItem.IsEnabled = hasRow;
- CopyRowItem.IsEnabled = hasRow;
-
- // Wire click handlers (clear first to avoid stacking)
- CopyQueryIdItem.Click -= CopyMenuItem_Click;
- CopyPlanIdItem.Click -= CopyMenuItem_Click;
- CopyQueryHashItem.Click -= CopyMenuItem_Click;
- CopyPlanHashItem.Click -= CopyMenuItem_Click;
- CopyModuleItem.Click -= CopyMenuItem_Click;
- CopyQueryTextItem.Click -= CopyMenuItem_Click;
- CopyRowItem.Click -= CopyMenuItem_Click;
-
- if (!hasRow) return;
-
- CopyQueryIdItem.Tag = row!.QueryId.ToString();
- CopyPlanIdItem.Tag = row.PlanId.ToString();
- CopyQueryHashItem.Tag = row.QueryHash;
- CopyPlanHashItem.Tag = row.QueryPlanHash;
- CopyModuleItem.Tag = row.ModuleName;
- CopyQueryTextItem.Tag = row.FullQueryText;
- CopyRowItem.Tag = $"{row.QueryId}\t{row.PlanId}\t{row.QueryHash}\t{row.QueryPlanHash}\t{row.ModuleName}\t{row.LastExecutedLocal}\t{row.ExecsDisplay}\t{row.TotalCpuDisplay}\t{row.AvgCpuDisplay}\t{row.TotalDurDisplay}\t{row.AvgDurDisplay}\t{row.TotalReadsDisplay}\t{row.AvgReadsDisplay}\t{row.TotalWritesDisplay}\t{row.AvgWritesDisplay}\t{row.TotalPhysReadsDisplay}\t{row.AvgPhysReadsDisplay}\t{row.TotalMemDisplay}\t{row.AvgMemDisplay}\t{row.FullQueryText}";
-
- CopyQueryIdItem.Click += CopyMenuItem_Click;
- CopyPlanIdItem.Click += CopyMenuItem_Click;
- CopyQueryHashItem.Click += CopyMenuItem_Click;
- CopyPlanHashItem.Click += CopyMenuItem_Click;
- CopyModuleItem.Click += CopyMenuItem_Click;
- CopyQueryTextItem.Click += CopyMenuItem_Click;
- CopyRowItem.Click += CopyMenuItem_Click;
- }
-
- private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is MenuItem item && item.Tag is string text)
- await SetClipboardTextAsync(text);
- }
-
- private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
- {
- var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
- if (topLevel?.Clipboard != null)
- await topLevel.Clipboard.SetTextAsync(text);
- }
-
- // ── Column filter infrastructure ───────────────────────────────────────
-
- private static readonly Dictionary> TextAccessors = new()
- {
- ["QueryHash"] = r => r.QueryHash,
- ["PlanHash"] = r => r.QueryPlanHash,
- ["ModuleName"] = r => r.ModuleName,
- ["LastExecuted"] = r => r.LastExecutedLocal,
- ["QueryText"] = r => r.FullQueryText,
- };
-
- private static readonly Dictionary> NumericAccessors = new()
- {
- ["QueryId"] = r => r.QueryId,
- ["PlanId"] = r => r.PlanId,
- ["Executions"] = r => r.ExecsSort,
- ["TotalCpu"] = r => r.TotalCpuSort / 1000.0, // µs → ms (matches display)
- ["AvgCpu"] = r => r.AvgCpuSort / 1000.0, // µs → ms
- ["TotalDuration"] = r => r.TotalDurSort / 1000.0, // µs → ms
- ["AvgDuration"] = r => r.AvgDurSort / 1000.0, // µs → ms
- ["TotalReads"] = r => r.TotalReadsSort,
- ["AvgReads"] = r => r.AvgReadsSort,
- ["TotalWrites"] = r => r.TotalWritesSort,
- ["AvgWrites"] = r => r.AvgWritesSort,
- ["TotalPhysReads"] = r => r.TotalPhysReadsSort,
- ["AvgPhysReads"] = r => r.AvgPhysReadsSort,
- ["TotalMemory"] = r => r.TotalMemSort * 8.0 / 1024.0, // pages → MB (matches display)
- ["AvgMemory"] = r => r.AvgMemSort * 8.0 / 1024.0, // pages → MB
- };
-
- private void SetupColumnHeaders()
- {
- var cols = ResultsGrid.Columns;
- // cols[0] = Expand column, cols[1] = Checkbox
- SetColumnFilterButton(cols[2], "QueryId", "Query ID");
- SetColumnFilterButton(cols[3], "PlanId", "Plan ID");
- SetColumnFilterButton(cols[4], "QueryHash", "Query Hash");
- SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash");
- SetColumnFilterButton(cols[6], "ModuleName", "Module");
- // cols[7] = WaitProfile (no filter button)
- SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)");
- SetColumnFilterButton(cols[9], "Executions", "Executions");
- SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)");
- SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)");
- SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)");
- SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)");
- SetColumnFilterButton(cols[14], "TotalReads", "Total Reads");
- SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads");
- SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes");
- SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes");
- SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads");
- SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads");
- SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)");
- SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)");
- SetColumnFilterButton(cols[22], "QueryText", "Query Text");
- }
-
- private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
- {
- var icon = new TextBlock
- {
- Text = "▽",
- FontSize = 12,
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
- HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
- };
- var btn = new Button
- {
- Content = icon,
- Tag = columnId,
- Width = 16,
- Height = 16,
- Padding = new Avalonia.Thickness(0),
- Background = Brushes.Transparent,
- BorderThickness = new Avalonia.Thickness(0),
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
- };
- btn.Click += ColumnFilter_Click;
- ToolTip.SetTip(btn, "Click to filter");
-
- var text = new TextBlock
- {
- Text = label,
- FontWeight = FontWeight.Bold,
- VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
- Margin = new Avalonia.Thickness(4, 0, 0, 0),
- };
-
- var header = new StackPanel
- {
- Orientation = Avalonia.Layout.Orientation.Horizontal,
- HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
- };
- header.Children.Add(btn);
- header.Children.Add(text);
- col.Header = header;
- }
-
- private void EnsureFilterPopup()
- {
- if (_filterPopup != null) return;
- _filterPopupContent = new ColumnFilterPopup();
- _filterPopup = new Popup
- {
- Child = _filterPopupContent,
- IsLightDismissEnabled = true,
- Placement = PlacementMode.Bottom,
- };
- // Add to visual tree so DynamicResources resolve inside the popup
- ((Grid)Content!).Children.Add(_filterPopup);
- _filterPopupContent.FilterApplied += OnFilterApplied;
- _filterPopupContent.FilterCleared += OnFilterCleared;
- }
-
- private void ColumnFilter_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is not Button button || button.Tag is not string columnId) return;
- EnsureFilterPopup();
- _activeFilters.TryGetValue(columnId, out var existing);
- _filterPopupContent!.Initialize(columnId, existing);
- _filterPopup!.PlacementTarget = button;
- _filterPopup.IsOpen = true;
- }
-
- private void OnFilterApplied(object? sender, FilterAppliedEventArgs e)
- {
- _filterPopup!.IsOpen = false;
- if (e.FilterState.IsActive)
- _activeFilters[e.FilterState.ColumnName] = e.FilterState;
- else
- _activeFilters.Remove(e.FilterState.ColumnName);
- ApplySortAndFilters();
- UpdateFilterButtonStyles();
- }
-
- private void OnFilterCleared(object? sender, EventArgs e)
- {
- _filterPopup!.IsOpen = false;
- }
-
- private void UpdateFilterButtonStyles()
- {
- foreach (var col in ResultsGrid.Columns)
- {
- if (col.Header is not StackPanel sp) continue;
- var btn = sp.Children.OfType().FirstOrDefault();
- if (btn?.Tag is not string colId) continue;
- if (btn.Content is not TextBlock tb) continue;
-
- bool hasFilter = _activeFilters.TryGetValue(colId, out var f) && f.IsActive;
- tb.Text = hasFilter ? "▼" : "▽";
- if (hasFilter)
- tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00));
- else
- tb.ClearValue(TextBlock.ForegroundProperty);
-
- ToolTip.SetTip(btn, hasFilter
- ? $"Filter: {f!.DisplayText} (click to modify)"
- : "Click to filter");
- }
- }
-
- private void ApplyFilters()
- {
- ApplySortAndFilters();
- }
-
- private bool RowMatchesAllFilters(QueryStoreRow row)
- {
- foreach (var (colId, state) in _activeFilters)
- {
- if (!state.IsActive) continue;
- if (TextAccessors.TryGetValue(colId, out var textAcc))
- {
- if (!MatchText(textAcc(row), state.Operator, state.Value)) return false;
- }
- else if (NumericAccessors.TryGetValue(colId, out var numAcc))
- {
- var isTextOp = state.Operator is FilterOperator.Contains or FilterOperator.StartsWith
- or FilterOperator.EndsWith or FilterOperator.IsEmpty or FilterOperator.IsNotEmpty;
- if (isTextOp)
- {
- if (!MatchText(numAcc(row).ToString("G"), state.Operator, state.Value)) return false;
- }
- else
- {
- if (!double.TryParse(state.Value, out var numVal)) continue;
- if (!MatchNumeric(numAcc(row), state.Operator, numVal)) return false;
- }
- }
- }
- return true;
- }
-
- private static bool MatchText(string data, FilterOperator op, string val) => op switch
- {
- FilterOperator.Contains => data.Contains(val, StringComparison.OrdinalIgnoreCase),
- FilterOperator.Equals => data.Equals(val, StringComparison.OrdinalIgnoreCase),
- FilterOperator.NotEquals => !data.Equals(val, StringComparison.OrdinalIgnoreCase),
- FilterOperator.StartsWith => data.StartsWith(val, StringComparison.OrdinalIgnoreCase),
- FilterOperator.EndsWith => data.EndsWith(val, StringComparison.OrdinalIgnoreCase),
- FilterOperator.IsEmpty => string.IsNullOrEmpty(data),
- FilterOperator.IsNotEmpty => !string.IsNullOrEmpty(data),
- _ => true,
- };
-
- private static bool MatchNumeric(double data, FilterOperator op, double val) => op switch
- {
- FilterOperator.Equals => Math.Abs(data - val) < 1e-9,
- FilterOperator.NotEquals => Math.Abs(data - val) >= 1e-9,
- FilterOperator.GreaterThan => data > val,
- FilterOperator.GreaterThanOrEqual => data >= val,
- FilterOperator.LessThan => data < val,
- FilterOperator.LessThanOrEqual => data <= val,
- _ => true,
- };
-
- private void UpdateStatusText()
- {
- if (_rows.Count == 0) return;
- if (_groupByMode != QueryStoreGroupBy.None)
- {
- var rootCount = _groupedRootRows.Count;
- var visibleRoots = _filteredRows.Count(r => r.IndentLevel == 0);
- StatusText.Text = visibleRoots == rootCount
- ? $"{rootCount} groups ({_rows.Count} total rows)"
- : $"{visibleRoots} / {rootCount} groups (filtered)";
- }
- else
- {
- StatusText.Text = _filteredRows.Count == _rows.Count
- ? $"{_rows.Count} plans"
- : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
- }
- }
-
- private void ResultsGrid_Sorting(object? sender, DataGridColumnEventArgs e)
- {
- e.Handled = true;
-
- var colTag = e.Column.Tag as string ?? e.Column.SortMemberPath;
- if (colTag == null) return;
-
- // Toggle: first click on a new column → descending; second click → ascending; third → clear
- if (_sortedColumnTag == colTag)
- {
- if (!_sortAscending)
- _sortAscending = true; // descending → ascending
- else
- {
- // ascending → clear sort
- _sortedColumnTag = null;
- foreach (var col in ResultsGrid.Columns)
- col.Tag = col.Tag; // no-op, just reset indicator below
- UpdateSortIndicators(null);
- ApplySortAndFilters();
- return;
- }
- }
- else
- {
- _sortedColumnTag = colTag;
- _sortAscending = false; // first click → descending
- }
-
- UpdateSortIndicators(e.Column);
- ApplySortAndFilters();
- }
-
- private void UpdateSortIndicators(DataGridColumn? activeColumn)
- {
- foreach (var col in ResultsGrid.Columns)
- {
- if (col.Header is not StackPanel sp) continue;
- var label = sp.Children.OfType().LastOrDefault();
- if (label == null) continue;
-
- if (col == activeColumn)
- label.Text = _sortAscending ? $"{GetColumnLabel(sp)} ▲" : $"{GetColumnLabel(sp)} ▼";
- else
- label.Text = GetColumnLabel(sp);
- }
- }
-
- private static string GetColumnLabel(StackPanel header)
- {
- var tb = header.Children.OfType().LastOrDefault();
- if (tb == null) return string.Empty;
- // Strip any existing sort indicator
- return tb.Text?.TrimEnd(' ', '▲', '▼') ?? string.Empty;
- }
-
- private void ReapplyTopNSelection()
- {
- if (_filteredRows.Count == 0) return;
- foreach (var r in _rows) r.IsSelected = false;
- foreach (var r in _filteredRows.Take(AutoSelectTopN)) r.IsSelected = true;
- }
-
- private void ApplySortAndFilters()
- {
- if (_groupByMode != QueryStoreGroupBy.None)
- {
- ApplySortAndFiltersGrouped();
- return;
- }
-
- IEnumerable source = _rows.Where(RowMatchesAllFilters);
-
- if (_sortedColumnTag != null)
- {
- source = _sortAscending
- ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
- : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
- }
-
- _filteredRows.Clear();
- foreach (var row in source)
- _filteredRows.Add(row);
-
- ReapplyTopNSelection();
- UpdateStatusText();
- UpdateBarRatios();
- }
-
- private void ApplySortAndFiltersGrouped()
- {
- // In grouped mode, sort/filter only root rows and rebuild the visible list
- IEnumerable source = _groupedRootRows.Where(RowMatchesAllFilters);
-
- if (_sortedColumnTag != null)
- {
- source = _sortAscending
- ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
- : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
- }
-
- _filteredRows.Clear();
- foreach (var root in source)
- {
- _filteredRows.Add(root);
- if (root.IsExpanded)
- AddExpandedChildren(root);
- }
-
- UpdateStatusText();
- UpdateBarRatios();
- }
-
- private void AddExpandedChildren(QueryStoreRow parent)
- {
- foreach (var child in parent.Children)
- {
- _filteredRows.Add(child);
- if (child.IsExpanded)
- AddExpandedChildren(child);
- }
- }
-
- // ── Bar chart ratio computation ────────────────────────────────────────
-
- // Maps a ColumnId (used in BarChartConfig) to the accessor that returns the raw sort value.
- private static readonly (string ColumnId, Func Accessor)[] BarColumns =
- [
- ("Executions", r => r.ExecsSort),
- ("TotalCpu", r => r.TotalCpuSort),
- ("AvgCpu", r => r.AvgCpuSort),
- ("TotalDuration", r => r.TotalDurSort),
- ("AvgDuration", r => r.AvgDurSort),
- ("TotalReads", r => r.TotalReadsSort),
- ("AvgReads", r => r.AvgReadsSort),
- ("TotalWrites", r => r.TotalWritesSort),
- ("AvgWrites", r => r.AvgWritesSort),
- ("TotalPhysReads",r => r.TotalPhysReadsSort),
- ("AvgPhysReads", r => r.AvgPhysReadsSort),
- ("TotalMemory", r => r.TotalMemSort),
- ("AvgMemory", r => r.AvgMemSort),
- ];
-
- // Maps a SortMemberPath tag (used in the sort dictionary) → ColumnId
- private static readonly Dictionary SortTagToColumnId = new()
- {
- ["ExecsSort"] = "Executions",
- ["TotalCpuSort"] = "TotalCpu",
- ["AvgCpuSort"] = "AvgCpu",
- ["TotalDurSort"] = "TotalDuration",
- ["AvgDurSort"] = "AvgDuration",
- ["TotalReadsSort"] = "TotalReads",
- ["AvgReadsSort"] = "AvgReads",
- ["TotalWritesSort"] = "TotalWrites",
- ["AvgWritesSort"] = "AvgWrites",
- ["TotalPhysReadsSort"] = "TotalPhysReads",
- ["AvgPhysReadsSort"] = "AvgPhysReads",
- ["TotalMemSort"] = "TotalMemory",
- ["AvgMemSort"] = "AvgMemory",
- };
-
- private void UpdateBarRatios()
- {
- if (_filteredRows.Count == 0) return;
-
- var sortedColumnId = _sortedColumnTag != null &&
- SortTagToColumnId.TryGetValue(_sortedColumnTag, out var sid) ? sid : null;
-
- foreach (var (columnId, accessor) in BarColumns)
- {
- var max = _filteredRows.Max(r => accessor(r));
- var isSorted = columnId == sortedColumnId;
- foreach (var row in _filteredRows)
- {
- var ratio = max > 0 ? accessor(row) / max : 0.0;
- row.SetBar(columnId, ratio, isSorted);
- }
- }
-
- UpdateWaitBarMode();
- }
-
- private static IComparable GetSortKey(string columnTag, QueryStoreRow r) =>
- columnTag switch
- {
- // Columns with no SortMemberPath: Avalonia uses the binding property name as key
- "QueryId" => (IComparable)r.QueryId,
- "PlanId" => r.PlanId,
- "QueryHash" => r.QueryHash,
- "QueryPlanHash" => r.QueryPlanHash,
- "ModuleName" => r.ModuleName,
- "LastExecutedLocal" => r.LastExecutedLocal,
- // Columns with explicit SortMemberPath
- "ExecsSort" => r.ExecsSort,
- "TotalCpuSort" => r.TotalCpuSort,
- "AvgCpuSort" => r.AvgCpuSort,
- "TotalDurSort" => r.TotalDurSort,
- "AvgDurSort" => r.AvgDurSort,
- "TotalReadsSort" => r.TotalReadsSort,
- "AvgReadsSort" => r.AvgReadsSort,
- "TotalWritesSort" => r.TotalWritesSort,
- "AvgWritesSort" => r.AvgWritesSort,
- "TotalPhysReadsSort" => r.TotalPhysReadsSort,
- "AvgPhysReadsSort" => r.AvgPhysReadsSort,
- "TotalMemSort" => r.TotalMemSort,
- "AvgMemSort" => r.AvgMemSort,
- "WaitGrandTotalSort" => r.WaitGrandTotalSort,
- _ => r.LastExecutedLocal,
- };
-}
-
-public class QueryStoreRow : INotifyPropertyChanged
-{
- private bool _isSelected = false;
-
- // Bar ratios [0..1] per column
- private double _execsRatio;
- private double _totalCpuRatio;
- private double _avgCpuRatio;
- private double _totalDurRatio;
- private double _avgDurRatio;
- private double _totalReadsRatio;
- private double _avgReadsRatio;
- private double _totalWritesRatio;
- private double _avgWritesRatio;
- private double _totalPhysReadsRatio;
- private double _avgPhysReadsRatio;
- private double _totalMemRatio;
- private double _avgMemRatio;
-
- // IsSortedColumn flags
- private bool _isSorted_Executions;
- private bool _isSorted_TotalCpu;
- private bool _isSorted_AvgCpu;
- private bool _isSorted_TotalDuration;
- private bool _isSorted_AvgDuration;
- private bool _isSorted_TotalReads;
- private bool _isSorted_AvgReads;
- private bool _isSorted_TotalWrites;
- private bool _isSorted_AvgWrites;
- private bool _isSorted_TotalPhysReads;
- private bool _isSorted_AvgPhysReads;
- private bool _isSorted_TotalMemory;
- private bool _isSorted_AvgMemory;
-
- // Wait stats
- private WaitProfile? _waitProfile;
- private string? _waitHighlightCategory;
-
- /// Raw wait category totals for this row. Used for upward consolidation in grouped mode.
- public List RawWaitCategories { get; set; } = new();
-
- // Hierarchy support
- private bool _isExpanded;
- private int _indentLevel;
-
- /// Standard constructor for flat (ungrouped) rows.
- public QueryStoreRow(QueryStorePlan plan)
- {
- Plan = plan;
- }
-
- /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
- public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
- {
- Plan = syntheticPlan;
- _indentLevel = indentLevel;
- GroupLabel = groupLabel;
- Children = children;
- }
-
- public QueryStorePlan Plan { get; }
-
- // ── Hierarchy properties ───────────────────────────────────────────────
-
- /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
- public int IndentLevel
- {
- get => _indentLevel;
- set { _indentLevel = value; OnPropertyChanged(); }
- }
-
- /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
- public string GroupLabel { get; set; } = "";
-
- /// Direct children of this group row.
- public List Children { get; set; } = new();
-
- public bool HasChildren => Children.Count > 0;
-
- public bool IsExpanded
- {
- get => _isExpanded;
- set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
- }
-
- public string ExpandChevron => HasChildren ? (IsExpanded ? "▾" : "▸") : "";
-
- /// Left margin that increases with indent level to visually show hierarchy.
- public Avalonia.Thickness IndentMargin => new(IndentLevel * 20, 0, 0, 0);
-
- /// Text shown next to the chevron: the group label for parent rows, or QueryId/PlanId for leaves.
- public string GroupDisplayText => !string.IsNullOrEmpty(GroupLabel) ? GroupLabel : "";
-
- /// Bold for top-level groups, normal for children.
- public Avalonia.Media.FontWeight GroupFontWeight => IndentLevel == 0 ? Avalonia.Media.FontWeight.Bold : Avalonia.Media.FontWeight.Normal;
-
- public bool IsSelected
- {
- get => _isSelected;
- set { _isSelected = value; OnPropertyChanged(); }
- }
-
- // ── Bar ratio properties ───────────────────────────────────────────────
- public double ExecsRatio { get => _execsRatio; private set { _execsRatio = value; OnPropertyChanged(); } }
- public double TotalCpuRatio { get => _totalCpuRatio; private set { _totalCpuRatio = value; OnPropertyChanged(); } }
- public double AvgCpuRatio { get => _avgCpuRatio; private set { _avgCpuRatio = value; OnPropertyChanged(); } }
- public double TotalDurRatio { get => _totalDurRatio; private set { _totalDurRatio = value; OnPropertyChanged(); } }
- public double AvgDurRatio { get => _avgDurRatio; private set { _avgDurRatio = value; OnPropertyChanged(); } }
- public double TotalReadsRatio { get => _totalReadsRatio; private set { _totalReadsRatio = value; OnPropertyChanged(); } }
- public double AvgReadsRatio { get => _avgReadsRatio; private set { _avgReadsRatio = value; OnPropertyChanged(); } }
- public double TotalWritesRatio { get => _totalWritesRatio; private set { _totalWritesRatio = value; OnPropertyChanged(); } }
- public double AvgWritesRatio { get => _avgWritesRatio; private set { _avgWritesRatio = value; OnPropertyChanged(); } }
- public double TotalPhysReadsRatio{ get => _totalPhysReadsRatio; private set { _totalPhysReadsRatio = value; OnPropertyChanged(); } }
- public double AvgPhysReadsRatio { get => _avgPhysReadsRatio; private set { _avgPhysReadsRatio = value; OnPropertyChanged(); } }
- public double TotalMemRatio { get => _totalMemRatio; private set { _totalMemRatio = value; OnPropertyChanged(); } }
- public double AvgMemRatio { get => _avgMemRatio; private set { _avgMemRatio = value; OnPropertyChanged(); } }
-
- // ── IsSortedColumn properties ──────────────────────────────────────────
- public bool IsSortedColumn_Executions { get => _isSorted_Executions; private set { _isSorted_Executions = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalCpu { get => _isSorted_TotalCpu; private set { _isSorted_TotalCpu = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgCpu { get => _isSorted_AvgCpu; private set { _isSorted_AvgCpu = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalDuration { get => _isSorted_TotalDuration; private set { _isSorted_TotalDuration = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgDuration { get => _isSorted_AvgDuration; private set { _isSorted_AvgDuration = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalReads { get => _isSorted_TotalReads; private set { _isSorted_TotalReads = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgReads { get => _isSorted_AvgReads; private set { _isSorted_AvgReads = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalWrites { get => _isSorted_TotalWrites; private set { _isSorted_TotalWrites = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgWrites { get => _isSorted_AvgWrites; private set { _isSorted_AvgWrites = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalPhysReads{ get => _isSorted_TotalPhysReads;private set { _isSorted_TotalPhysReads = value;OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgPhysReads { get => _isSorted_AvgPhysReads; private set { _isSorted_AvgPhysReads = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_TotalMemory { get => _isSorted_TotalMemory; private set { _isSorted_TotalMemory = value; OnPropertyChanged(); } }
- public bool IsSortedColumn_AvgMemory { get => _isSorted_AvgMemory; private set { _isSorted_AvgMemory = value; OnPropertyChanged(); } }
-
- /// Called by the grid after each sort/filter pass to update bar rendering.
- public void SetBar(string columnId, double ratio, bool isSorted)
- {
- switch (columnId)
- {
- case "Executions": ExecsRatio = ratio; IsSortedColumn_Executions = isSorted; break;
- case "TotalCpu": TotalCpuRatio = ratio; IsSortedColumn_TotalCpu = isSorted; break;
- case "AvgCpu": AvgCpuRatio = ratio; IsSortedColumn_AvgCpu = isSorted; break;
- case "TotalDuration": TotalDurRatio = ratio; IsSortedColumn_TotalDuration = isSorted; break;
- case "AvgDuration": AvgDurRatio = ratio; IsSortedColumn_AvgDuration = isSorted; break;
- case "TotalReads": TotalReadsRatio = ratio; IsSortedColumn_TotalReads = isSorted; break;
- case "AvgReads": AvgReadsRatio = ratio; IsSortedColumn_AvgReads = isSorted; break;
- case "TotalWrites": TotalWritesRatio = ratio; IsSortedColumn_TotalWrites = isSorted; break;
- case "AvgWrites": AvgWritesRatio = ratio; IsSortedColumn_AvgWrites = isSorted; break;
- case "TotalPhysReads": TotalPhysReadsRatio = ratio; IsSortedColumn_TotalPhysReads = isSorted; break;
- case "AvgPhysReads": AvgPhysReadsRatio = ratio; IsSortedColumn_AvgPhysReads = isSorted; break;
- case "TotalMemory": TotalMemRatio = ratio; IsSortedColumn_TotalMemory = isSorted; break;
- case "AvgMemory": AvgMemRatio = ratio; IsSortedColumn_AvgMemory = isSorted; break;
- }
- }
-
- // ── Wait profile ───────────────────────────────────────────────────────
-
- public WaitProfile? WaitProfile
- {
- get => _waitProfile;
- set { _waitProfile = value; OnPropertyChanged(); }
- }
-
- public string? WaitHighlightCategory
- {
- get => _waitHighlightCategory;
- set { _waitHighlightCategory = value; OnPropertyChanged(); }
- }
-
- private bool _waitPercentMode;
- private double _waitMaxGrandTotal = 1.0;
-
- public bool WaitPercentMode
- {
- get => _waitPercentMode;
- set { _waitPercentMode = value; OnPropertyChanged(); }
- }
-
- public double WaitMaxGrandTotal
- {
- get => _waitMaxGrandTotal;
- set { _waitMaxGrandTotal = value; OnPropertyChanged(); }
- }
-
- public double WaitGrandTotalSort => _waitProfile?.GrandTotalRatio ?? 0;
-
- public long QueryId => Plan.QueryId;
- public long PlanId => Plan.PlanId;
- public string QueryHash => Plan.QueryHash;
- public string QueryPlanHash => Plan.QueryPlanHash;
- public string ModuleName => Plan.ModuleName;
-
- public string ExecsDisplay => Plan.CountExecutions.ToString("N0");
- public string TotalCpuDisplay => (Plan.TotalCpuTimeUs / 1000.0).ToString("N0");
- public string AvgCpuDisplay => (Plan.AvgCpuTimeUs / 1000.0).ToString("N1");
- public string TotalDurDisplay => (Plan.TotalDurationUs / 1000.0).ToString("N0");
- public string AvgDurDisplay => (Plan.AvgDurationUs / 1000.0).ToString("N1");
- public string TotalReadsDisplay => Plan.TotalLogicalIoReads.ToString("N0");
- public string AvgReadsDisplay => Plan.AvgLogicalIoReads.ToString("N0");
- public string TotalWritesDisplay => Plan.TotalLogicalIoWrites.ToString("N0");
- public string AvgWritesDisplay => Plan.AvgLogicalIoWrites.ToString("N0");
- public string TotalPhysReadsDisplay => Plan.TotalPhysicalIoReads.ToString("N0");
- public string AvgPhysReadsDisplay => Plan.AvgPhysicalIoReads.ToString("N0");
- public string TotalMemDisplay => (Plan.TotalMemoryGrantPages * 8.0 / 1024.0).ToString("N1");
- public string AvgMemDisplay => (Plan.AvgMemoryGrantPages * 8.0 / 1024.0).ToString("N1");
-
- // Numeric sort properties (DataGrid SortMemberPath targets)
- public long ExecsSort => Plan.CountExecutions;
- public long TotalCpuSort => Plan.TotalCpuTimeUs;
- public double AvgCpuSort => Plan.AvgCpuTimeUs;
- public long TotalDurSort => Plan.TotalDurationUs;
- public double AvgDurSort => Plan.AvgDurationUs;
- public long TotalReadsSort => Plan.TotalLogicalIoReads;
- public double AvgReadsSort => Plan.AvgLogicalIoReads;
- public long TotalWritesSort => Plan.TotalLogicalIoWrites;
- public double AvgWritesSort => Plan.AvgLogicalIoWrites;
- public long TotalPhysReadsSort => Plan.TotalPhysicalIoReads;
- public double AvgPhysReadsSort => Plan.AvgPhysicalIoReads;
- public long TotalMemSort => Plan.TotalMemoryGrantPages;
- public double AvgMemSort => Plan.AvgMemoryGrantPages;
-
- public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc);
-
- public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal));
-
- public string QueryPreview => Plan.QueryText.Length > 80
- ? Plan.QueryText[..80].Replace("\n", " ").Replace("\r", "") + "..."
- : Plan.QueryText.Replace("\n", " ").Replace("\r", "");
- public string FullQueryText => Plan.QueryText;
-
- public event PropertyChangedEventHandler? PropertyChanged;
- private void OnPropertyChanged([CallerMemberName] string? name = null)
- => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
-}
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.App.Dialogs;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Controls;
+
+public partial class QueryStoreGridControl : UserControl
+{
+ private readonly ServerConnection _serverConnection;
+ private readonly ICredentialService _credentialService;
+ private string _connectionString;
+ private string _database;
+ private CancellationTokenSource? _fetchCts;
+ private ObservableCollection _rows = new();
+ private ObservableCollection _filteredRows = new();
+ private readonly Dictionary _activeFilters = new();
+ private Popup? _filterPopup;
+ private ColumnFilterPopup? _filterPopupContent;
+ private string? _sortedColumnTag;
+ private bool _sortAscending;
+ private DateTime? _slicerStartUtc;
+ private DateTime? _slicerEndUtc;
+ private int _slicerDaysBack = 30;
+ private string _lastFetchedOrderBy = "cpu";
+ private bool _initialOrderByLoaded;
+ private bool _suppressRangeChanged;
+ private string? _waitHighlightCategory;
+ private const int AutoSelectTopN = 1; // number of rows auto-selected after each fetch
+ private bool _waitStatsSupported; // false until version + capture mode confirmed
+ private bool _waitStatsEnabled = true;
+ private bool _waitPercentMode;
+ private QueryStoreGroupBy _groupByMode = QueryStoreGroupBy.QueryHash;
+ private List _groupedRootRows = new(); // top-level rows for grouped mode
+
+ public event EventHandler>? PlansSelected;
+ public event EventHandler? DatabaseChanged;
+
+ public string Database => _database;
+
+ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService,
+ string initialDatabase, List databases, bool supportsWaitStats = false)
+ {
+ _serverConnection = serverConnection;
+ _credentialService = credentialService;
+ _database = initialDatabase;
+ _connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase);
+ _waitStatsSupported = supportsWaitStats;
+ _slicerDaysBack = AppSettingsService.Load().QueryStoreSlicerDays;
+ InitializeComponent();
+ ResultsGrid.ItemsSource = _filteredRows;
+ Helpers.DataGridBehaviors.Attach(ResultsGrid);
+ EnsureFilterPopup();
+ SetupColumnHeaders();
+ PopulateDatabaseBox(databases, initialDatabase);
+ TimeRangeSlicer.RangeChanged += OnTimeRangeChanged;
+
+ WaitStatsProfile.CategoryClicked += OnWaitCategoryClicked;
+ WaitStatsProfile.CategoryDoubleClicked += OnWaitCategoryDoubleClicked;
+ WaitStatsProfile.CollapsedChanged += OnWaitStatsCollapsedChanged;
+
+ if (!_waitStatsSupported)
+ {
+ // Hide wait stats panel and column when server doesn't support it
+ WaitStatsProfile.Collapse();
+ WaitStatsChevronButton.IsVisible = false;
+ WaitStatsSplitter.IsVisible = false;
+ SlicerRow.ColumnDefinitions[2].Width = new GridLength(0);
+ var waitProfileCol = ResultsGrid.Columns
+ .FirstOrDefault(c => c.SortMemberPath == "WaitGrandTotalSort");
+ if (waitProfileCol != null)
+ waitProfileCol.IsVisible = false;
+ }
+
+ // Auto-fetch with default settings on connect
+ Avalonia.Threading.Dispatcher.UIThread.Post(() =>
+ {
+ ReorderColumnsForGroupBy();
+ Fetch_Click(null, new RoutedEventArgs());
+ _initialOrderByLoaded = true;
+ }, Avalonia.Threading.DispatcherPriority.Loaded);
+ }
+
+ private void PopulateDatabaseBox(List databases, string selectedDatabase)
+ {
+ QsDatabaseBox.ItemsSource = databases;
+ QsDatabaseBox.SelectedItem = selectedDatabase;
+ }
+
+ private async void QsDatabase_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (QsDatabaseBox.SelectedItem is not string db || db == _database) return;
+
+ _fetchCts?.Cancel();
+
+ // Check if Query Store is enabled on the new database
+ var newConnStr = _serverConnection.GetConnectionString(_credentialService, db);
+ StatusText.Text = "Checking Query Store...";
+
+ try
+ {
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(newConnStr);
+ if (!enabled)
+ {
+ StatusText.Text = $"Query Store not enabled on {db} ({state ?? "unknown"})";
+ QsDatabaseBox.SelectedItem = _database; // revert
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message;
+ QsDatabaseBox.SelectedItem = _database; // revert
+ return;
+ }
+
+ _database = db;
+ _connectionString = newConnStr;
+ _rows.Clear();
+ _filteredRows.Clear();
+ LoadButton.IsEnabled = false;
+ StatusText.Text = "";
+ DatabaseChanged?.Invoke(this, db);
+ }
+
+ private async void Fetch_Click(object? sender, RoutedEventArgs e)
+ {
+ _fetchCts?.Cancel();
+ _fetchCts?.Dispose();
+ _fetchCts = new CancellationTokenSource();
+ var ct = _fetchCts.Token;
+
+ var orderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
+ _lastFetchedOrderBy = orderBy;
+
+ FetchButton.IsEnabled = false;
+ LoadButton.IsEnabled = false;
+ StatusText.Text = "Loading time slicer...";
+ _rows.Clear();
+ _filteredRows.Clear();
+
+ try
+ {
+ // Load slicer data, preserving the current selection if one exists.
+ // Without this, LoadData defaults to last 24h and the user's range is lost.
+ await LoadTimeSlicerDataAsync(orderBy, ct, _slicerStartUtc, _slicerEndUtc);
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText.Text = "Cancelled.";
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
+ }
+ finally
+ {
+ FetchButton.IsEnabled = true;
+ }
+ }
+
+ private async System.Threading.Tasks.Task FetchPlansForRangeAsync()
+ {
+ _fetchCts?.Cancel();
+ _fetchCts?.Dispose();
+ _fetchCts = new CancellationTokenSource();
+ var ct = _fetchCts.Token;
+
+ var topN = (int)(TopNBox.Value ?? 25);
+ var orderBy = _lastFetchedOrderBy;
+ var filter = BuildSearchFilter();
+
+ FetchButton.IsEnabled = false;
+ LoadButton.IsEnabled = false;
+ StatusText.Text = "Fetching plans...";
+ GridLoadingOverlay.IsVisible = true;
+ GridLoadingText.Text = "Fetching plans...";
+ GridEmptyMessage.IsVisible = false;
+ _rows.Clear();
+ _filteredRows.Clear();
+ _groupedRootRows.Clear();
+
+ // Start global + ribbon wait stats early (they don't depend on plan results)
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchGlobalWaitStatsOnlyAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+
+ try
+ {
+ if (_groupByMode == QueryStoreGroupBy.None)
+ {
+ await FetchFlatPlansAsync(topN, orderBy, filter, ct);
+ }
+ else
+ {
+ await FetchGroupedPlansAsync(topN, orderBy, filter, ct);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ StatusText.Text = "Cancelled.";
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
+ }
+ finally
+ {
+ GridLoadingOverlay.IsVisible = false;
+ FetchButton.IsEnabled = true;
+ }
+ }
+
+ private async System.Threading.Tasks.Task FetchFlatPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ var plans = await QueryStoreService.FetchTopPlansAsync(
+ _connectionString, topN, orderBy, filter: filter, ct: ct,
+ startUtc: _slicerStartUtc, endUtc: _slicerEndUtc);
+
+ GridLoadingOverlay.IsVisible = false;
+
+ if (plans.Count == 0)
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ return;
+ }
+
+ foreach (var plan in plans)
+ _rows.Add(new QueryStoreRow(plan));
+
+ ApplyFilters();
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+
+ // Fetch per-plan wait stats after grid is populated (needs plan IDs)
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchPerPlanWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ private async System.Threading.Tasks.Task FetchGroupedPlansAsync(
+ int topN, string orderBy, QueryStoreFilter? filter, CancellationToken ct)
+ {
+ QueryStoreGroupedResult grouped;
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ grouped = await QueryStoreService.FetchGroupedByQueryHashAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+ else // Module
+ {
+ grouped = await QueryStoreService.FetchGroupedByModuleAsync(
+ _connectionString, topN, orderBy, filter, ct,
+ _slicerStartUtc, _slicerEndUtc);
+ }
+
+ GridLoadingOverlay.IsVisible = false;
+ GridEmptyMessage.IsVisible = false;
+
+ if (grouped.IntermediateRows.Count == 0)
+ {
+ if (_groupByMode == QueryStoreGroupBy.Module)
+ {
+ GridEmptyMessageText.Text = "No module found in the selected period";
+ GridEmptyMessage.IsVisible = true;
+ }
+ else
+ {
+ StatusText.Text = "No Query Store data found for the selected range.";
+ }
+ return;
+ }
+
+ var rootRows = BuildGroupedRows(grouped);
+
+ // Sort root rows by consolidated metric descending
+ var metricAccessor = GetMetricAccessor(orderBy);
+ rootRows = rootRows.OrderByDescending(r => metricAccessor(r)).ToList();
+ _groupedRootRows = rootRows;
+
+ // Flatten to _rows (all levels) and show only top-level in _filteredRows
+ foreach (var root in rootRows)
+ {
+ _rows.Add(root);
+ foreach (var mid in root.Children)
+ {
+ _rows.Add(mid);
+ foreach (var leaf in mid.Children)
+ _rows.Add(leaf);
+ }
+ }
+
+ // Show only root-level rows initially (collapsed)
+ _filteredRows.Clear();
+ foreach (var root in rootRows)
+ _filteredRows.Add(root);
+
+ LoadButton.IsEnabled = true;
+ SelectToggleButton.Content = "Select All";
+
+ UpdateStatusText();
+ UpdateBarRatios();
+
+ // Fetch per-plan wait stats for leaf rows, then consolidate upward
+ if (_waitStatsSupported && _waitStatsEnabled && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ _ = FetchGroupedWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+
+ ///
+ /// Fetches per-plan wait stats for all real plan IDs found in the grouped hierarchy,
+ /// assigns them to leaf rows, then consolidates upward to intermediate and root rows.
+ ///
+ private async System.Threading.Tasks.Task FetchGroupedWaitStatsAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ try
+ {
+ // Collect all real plan IDs from rows that have a real PlanId
+ var allPlanIds = _rows
+ .Where(r => r.PlanId > 0)
+ .Select(r => r.PlanId)
+ .Distinct()
+ .ToList();
+
+ if (allPlanIds.Count == 0) return;
+
+ var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
+ _connectionString, startUtc, endUtc, allPlanIds, ct);
+ if (ct.IsCancellationRequested) return;
+
+ // Build lookup: plan_id → list of WaitCategoryTotal
+ var byPlan = planWaits
+ .GroupBy(x => x.PlanId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
+
+ // 1. Assign raw waits + profiles to rows with a real PlanId
+ foreach (var row in _rows)
+ {
+ if (row.PlanId > 0 && byPlan.TryGetValue(row.PlanId, out var waits))
+ {
+ row.RawWaitCategories = waits;
+ row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
+ }
+ }
+
+ // 2. Consolidate upward through the hierarchy
+ foreach (var root in _groupedRootRows)
+ ConsolidateWaitProfileUpward(root);
+
+ UpdateWaitBarMode();
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception) { }
+ }
+
+ ///
+ /// Recursively consolidates wait profiles from children into their parent.
+ /// For each parent: merges all children's RawWaitCategories by summing WaitRatio
+ /// per category, then builds a new WaitProfile from the merged totals.
+ ///
+ private static void ConsolidateWaitProfileUpward(QueryStoreRow parent)
+ {
+ if (parent.Children.Count == 0) return;
+
+ // Recurse first so children are consolidated before we merge them
+ foreach (var child in parent.Children)
+ ConsolidateWaitProfileUpward(child);
+
+ // Merge all children's raw wait categories by summing WaitRatio per category
+ var merged = parent.Children
+ .SelectMany(c => c.RawWaitCategories)
+ .GroupBy(w => new { w.WaitCategory, w.WaitCategoryDesc })
+ .Select(g => new WaitCategoryTotal
+ {
+ WaitCategory = g.Key.WaitCategory,
+ WaitCategoryDesc = g.Key.WaitCategoryDesc,
+ WaitRatio = g.Sum(w => w.WaitRatio),
+ })
+ .ToList();
+
+ if (merged.Count > 0)
+ {
+ parent.RawWaitCategories = merged;
+ parent.WaitProfile = QueryStoreService.BuildWaitProfile(merged);
+ }
+ }
+
+ /// Maps an orderBy metric string to a Func that extracts the sort value from a QueryStoreRow.
+ private static Func GetMetricAccessor(string orderBy) => orderBy.ToLowerInvariant() switch
+ {
+ "cpu" => r => r.TotalCpuSort,
+ "avg-cpu" => r => r.AvgCpuSort,
+ "duration" => r => r.TotalDurSort,
+ "avg-duration" => r => r.AvgDurSort,
+ "reads" => r => r.TotalReadsSort,
+ "avg-reads" => r => r.AvgReadsSort,
+ "writes" => r => r.TotalWritesSort,
+ "avg-writes" => r => r.AvgWritesSort,
+ "physical-reads" => r => r.TotalPhysReadsSort,
+ "avg-physical-reads" => r => r.AvgPhysReadsSort,
+ "memory" => r => r.TotalMemSort,
+ "avg-memory" => r => r.AvgMemSort,
+ "executions" => r => r.ExecsSort,
+ _ => r => r.TotalCpuSort,
+ };
+
+ private List BuildGroupedRows(QueryStoreGroupedResult grouped)
+ {
+ var roots = new List();
+ var metricAccessor = GetMetricAccessor(_lastFetchedOrderBy);
+
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Level 0: QueryHash groups
+ var queryHashGroups = grouped.IntermediateRows
+ .GroupBy(r => r.QueryHash)
+ .ToList();
+
+ foreach (var qhGroup in queryHashGroups)
+ {
+ var qhKey = qhGroup.Key;
+ var intermediateRows = qhGroup.ToList();
+
+ // Build level-1 children (PlanHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.QueryHash == mid.QueryHash && l.QueryPlanHash == mid.QueryPlanHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this plan hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at QueryHash level
+ var aggPlan = AggregateGroupedRows(intermediateRows, qhKey, intermediateRows.FirstOrDefault()?.ModuleName ?? "");
+ // Populate QueryText from the top representative leaf across all leaves in this query hash group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.QueryHash == qhKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
+ roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren));
+ }
+ }
+ else // Module
+ {
+ // Level 0: Module groups
+ var moduleGroups = grouped.IntermediateRows
+ .GroupBy(r => r.ModuleName)
+ .ToList();
+
+ foreach (var modGroup in moduleGroups)
+ {
+ var modKey = modGroup.Key;
+ var intermediateRows = modGroup.ToList();
+
+ // Build level-1 children (QueryHash)
+ var midChildren = new List();
+ foreach (var mid in intermediateRows)
+ {
+ // Build level-2 children (QueryId/PlanId)
+ var leafChildren = new List();
+ var leaves = grouped.LeafRows
+ .Where(l => l.ModuleName == mid.ModuleName && l.QueryHash == mid.QueryHash)
+ .ToList();
+ foreach (var leaf in leaves)
+ {
+ var leafPlan = GroupedRowToPlan(leaf);
+ leafChildren.Add(new QueryStoreRow(leafPlan, 2,
+ $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List()));
+ }
+
+ // Sort leaf children by metric descending
+ leafChildren = leafChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ var midPlan = GroupedRowToPlan(mid);
+ // Populate QueryText from the top representative leaf for this query hash
+ var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault();
+ if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText))
+ midPlan.QueryText = topLeafForMid.QueryText;
+ midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren));
+ }
+
+ // Sort mid children by metric descending
+ midChildren = midChildren.OrderByDescending(r => metricAccessor(r)).ToList();
+
+ // Aggregate metrics at Module level
+ var aggPlan = AggregateGroupedRows(intermediateRows, "", modKey);
+ // Populate QueryText from the top representative leaf across all leaves in this module group
+ var topLeafForRoot = grouped.LeafRows
+ .Where(l => l.ModuleName == modKey && l.IsTopRepresentative && !string.IsNullOrEmpty(l.QueryText))
+ .FirstOrDefault()
+ ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText));
+ if (topLeafForRoot != null)
+ aggPlan.QueryText = topLeafForRoot.QueryText;
+ roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren));
+ }
+ }
+
+ return roots;
+ }
+
+ private static QueryStorePlan GroupedRowToPlan(QueryStoreGroupedPlanRow row)
+ {
+ var totalExecs = row.CountExecutions > 0 ? row.CountExecutions : 1;
+ return new QueryStorePlan
+ {
+ QueryId = row.QueryId,
+ PlanId = row.PlanId,
+ QueryHash = row.QueryHash,
+ QueryPlanHash = row.QueryPlanHash,
+ ModuleName = row.ModuleName,
+ QueryText = row.QueryText,
+ PlanXml = row.PlanXml,
+ CountExecutions = row.CountExecutions,
+ TotalCpuTimeUs = row.TotalCpuTimeUs,
+ TotalDurationUs = row.TotalDurationUs,
+ TotalLogicalIoReads = row.TotalLogicalIoReads,
+ TotalLogicalIoWrites = row.TotalLogicalIoWrites,
+ TotalPhysicalIoReads = row.TotalPhysicalIoReads,
+ TotalMemoryGrantPages = row.TotalMemoryGrantPages,
+ AvgCpuTimeUs = (double)row.TotalCpuTimeUs / totalExecs,
+ AvgDurationUs = (double)row.TotalDurationUs / totalExecs,
+ AvgLogicalIoReads = (double)row.TotalLogicalIoReads / totalExecs,
+ AvgLogicalIoWrites = (double)row.TotalLogicalIoWrites / totalExecs,
+ AvgPhysicalIoReads = (double)row.TotalPhysicalIoReads / totalExecs,
+ AvgMemoryGrantPages = (double)row.TotalMemoryGrantPages / totalExecs,
+ LastExecutedUtc = row.LastExecutedUtc,
+ };
+ }
+
+ private static QueryStorePlan AggregateGroupedRows(List rows, string queryHash, string moduleName)
+ {
+ var totalExecs = rows.Sum(r => r.CountExecutions);
+ var safeExecs = totalExecs > 0 ? totalExecs : 1;
+ var totalCpu = rows.Sum(r => r.TotalCpuTimeUs);
+ var totalDur = rows.Sum(r => r.TotalDurationUs);
+ var totalReads = rows.Sum(r => r.TotalLogicalIoReads);
+ var totalWrites = rows.Sum(r => r.TotalLogicalIoWrites);
+ var totalPhysReads = rows.Sum(r => r.TotalPhysicalIoReads);
+ var totalMem = rows.Sum(r => r.TotalMemoryGrantPages);
+ var lastExec = rows.Max(r => r.LastExecutedUtc);
+
+ return new QueryStorePlan
+ {
+ QueryHash = queryHash,
+ ModuleName = moduleName,
+ CountExecutions = totalExecs,
+ TotalCpuTimeUs = totalCpu,
+ TotalDurationUs = totalDur,
+ TotalLogicalIoReads = totalReads,
+ TotalLogicalIoWrites = totalWrites,
+ TotalPhysicalIoReads = totalPhysReads,
+ TotalMemoryGrantPages = totalMem,
+ AvgCpuTimeUs = (double)totalCpu / safeExecs,
+ AvgDurationUs = (double)totalDur / safeExecs,
+ AvgLogicalIoReads = (double)totalReads / safeExecs,
+ AvgLogicalIoWrites = (double)totalWrites / safeExecs,
+ AvgPhysicalIoReads = (double)totalPhysReads / safeExecs,
+ AvgMemoryGrantPages = (double)totalMem / safeExecs,
+ LastExecutedUtc = lastExec,
+ };
+ }
+
+ private QueryStoreFilter? BuildSearchFilter()
+ {
+ var searchType = (SearchTypeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
+ var searchValue = SearchValueBox.Text?.Trim();
+
+ if (string.IsNullOrEmpty(searchType) || string.IsNullOrEmpty(searchValue))
+ return null;
+
+ var filter = new QueryStoreFilter();
+
+ switch (searchType)
+ {
+ case "query-id" when long.TryParse(searchValue, out var qid):
+ filter.QueryId = qid;
+ break;
+ case "query-id":
+ StatusText.Text = "Invalid Query ID";
+ return null;
+ case "plan-id" when long.TryParse(searchValue, out var pid):
+ filter.PlanId = pid;
+ break;
+ case "plan-id":
+ StatusText.Text = "Invalid Plan ID";
+ return null;
+ case "query-hash":
+ filter.QueryHash = searchValue;
+ break;
+ case "plan-hash":
+ filter.QueryPlanHash = searchValue;
+ break;
+ case "module":
+ // Default to dbo schema if no schema specified, following sp_QuickieStore pattern
+ filter.ModuleName = searchValue.Contains('.') ? searchValue : $"dbo.{searchValue}";
+ break;
+ default:
+ return null;
+ }
+
+ return filter;
+ }
+
+ private void SearchValue_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
+ {
+ if (e.Key == Avalonia.Input.Key.Enter)
+ {
+ Fetch_Click(sender, e);
+ e.Handled = true;
+ }
+ }
+
+ private int[]? _savedColumnDisplayIndices;
+
+ private void GroupBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!_initialOrderByLoaded) return;
+ var tag = (GroupByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "none";
+ var newMode = tag switch
+ {
+ "query-hash" => QueryStoreGroupBy.QueryHash,
+ "module" => QueryStoreGroupBy.Module,
+ _ => QueryStoreGroupBy.None,
+ };
+ if (newMode == _groupByMode) return;
+ _groupByMode = newMode;
+
+ // Show/hide the expand column (first column in the grid)
+ ResultsGrid.Columns[0].IsVisible = _groupByMode != QueryStoreGroupBy.None;
+
+ // Reorder columns: move the group key column right after expand+checkbox
+ ReorderColumnsForGroupBy();
+
+ // Re-fetch with new grouping
+ Fetch_Click(null, new RoutedEventArgs());
+ }
+
+ private void ReorderColumnsForGroupBy()
+ {
+ var cols = ResultsGrid.Columns;
+
+ if (_groupByMode == QueryStoreGroupBy.None)
+ {
+ // Restore original column order
+ if (_savedColumnDisplayIndices != null)
+ {
+ for (int i = 0; i < cols.Count && i < _savedColumnDisplayIndices.Length; i++)
+ cols[i].DisplayIndex = _savedColumnDisplayIndices[i];
+ _savedColumnDisplayIndices = null;
+ }
+ // Reset header colors
+ ApplyGroupByHeaderColors();
+ return;
+ }
+
+ // Save original order if not yet saved
+ _savedColumnDisplayIndices ??= cols.Select(c => c.DisplayIndex).ToArray();
+
+ // Column definition indices (AXAML order):
+ // 0=Expand, 1=Checkbox, 2=QueryId, 3=PlanId, 4=QueryHash, 5=PlanHash, 6=Module
+ if (_groupByMode == QueryStoreGroupBy.QueryHash)
+ {
+ // Order: Expand, Checkbox, QueryHash, PlanHash, QueryId, PlanId, ...
+ cols[4].DisplayIndex = 2; // QueryHash → 2
+ cols[5].DisplayIndex = 3; // PlanHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
+ else // Module
+ {
+ // Order: Expand, Checkbox, Module, QueryHash, QueryId, PlanId, ...
+ cols[6].DisplayIndex = 2; // Module → 2
+ cols[4].DisplayIndex = 3; // QueryHash → 3
+ cols[2].DisplayIndex = 4; // QueryId → 4
+ cols[3].DisplayIndex = 5; // PlanId → 5
+ }
+
+ // Apply golden header colors for expandable columns
+ ApplyGroupByHeaderColors();
+ }
+
+ ///
+ /// Applies golden foreground to column headers that represent expandable/collapsible
+ /// grouping levels in the current GroupBy mode, and resets others.
+ ///
+ private void ApplyGroupByHeaderColors()
+ {
+ // Column definition indices: 4=QueryHash, 5=PlanHash, 6=Module
+ var goldenCols = _groupByMode switch
+ {
+ QueryStoreGroupBy.QueryHash => new HashSet { 4, 5 }, // QueryHash + PlanHash
+ QueryStoreGroupBy.Module => new HashSet { 6, 4 }, // Module + QueryHash
+ _ => new HashSet(),
+ };
+
+ var goldenBrush = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00)); // Gold
+
+ for (int i = 0; i < ResultsGrid.Columns.Count; i++)
+ {
+ var col = ResultsGrid.Columns[i];
+ if (col.Header is not StackPanel sp) continue;
+ var label = sp.Children.OfType().LastOrDefault();
+ if (label == null) continue;
+
+ if (goldenCols.Contains(i))
+ label.Foreground = goldenBrush;
+ else
+ label.ClearValue(TextBlock.ForegroundProperty);
+ }
+ }
+
+ private void ExpandRow_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button btn) return;
+ if (btn.DataContext is not QueryStoreRow row) return;
+ if (!row.HasChildren) return;
+
+ row.IsExpanded = !row.IsExpanded;
+
+ if (row.IsExpanded)
+ {
+ // Insert children after this row in _filteredRows
+ var idx = _filteredRows.IndexOf(row);
+ if (idx < 0) return;
+ var insertAt = idx + 1;
+ foreach (var child in row.Children)
+ {
+ _filteredRows.Insert(insertAt, child);
+ insertAt++;
+ }
+
+ // Scroll the first child into view so the expansion is visible
+ if (row.Children.Count > 0)
+ ResultsGrid.ScrollIntoView(row.Children[0], null);
+ }
+ else
+ {
+ // Remove children (and their expanded children) recursively
+ CollapseRowChildren(row);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void CollapseRowChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ if (child.IsExpanded)
+ {
+ child.IsExpanded = false;
+ CollapseRowChildren(child);
+ }
+ _filteredRows.Remove(child);
+ }
+ }
+
+ private async void OrderBy_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!_initialOrderByLoaded) return;
+ var newOrderBy = (OrderByBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "cpu";
+ if (newOrderBy == _lastFetchedOrderBy) return;
+
+ _lastFetchedOrderBy = newOrderBy;
+
+ _fetchCts?.Cancel();
+ _fetchCts?.Dispose();
+ _fetchCts = new CancellationTokenSource();
+ var ct = _fetchCts.Token;
+
+ // Capture the current slicer selection so it survives the reload
+ var selStart = TimeRangeSlicer.SelectionStart;
+ var selEnd = TimeRangeSlicer.SelectionEnd;
+
+ FetchButton.IsEnabled = false;
+ StatusText.Text = "Refreshing metric...";
+
+ try
+ {
+ var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
+ _connectionString, newOrderBy, _slicerDaysBack, ct);
+ if (ct.IsCancellationRequested) return;
+
+ if (sliceData.Count > 0)
+ {
+ // Suppress the implicit RangeChanged fetch — we will refresh the grid explicitly below
+ _suppressRangeChanged = true;
+ try { TimeRangeSlicer.LoadData(sliceData, newOrderBy, selStart, selEnd); }
+ finally { _suppressRangeChanged = false; }
+
+ // Explicitly refresh the grid with the new metric and current time range
+ await FetchPlansForRangeAsync();
+ }
+ else
+ {
+ StatusText.Text = "No time-slicer data available.";
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception ex)
+ {
+ StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
+ }
+ finally
+ {
+ FetchButton.IsEnabled = true;
+ }
+ }
+
+ private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (!IsInitialized) return;
+ var tag = (TimeDisplayBox.SelectedItem as ComboBoxItem)?.Tag?.ToString();
+ if (tag == null) return;
+ TimeDisplayHelper.Current = tag switch
+ {
+ "Utc" => TimeDisplayMode.Utc,
+ "Server" => TimeDisplayMode.Server,
+ _ => TimeDisplayMode.Local
+ };
+ // Refresh grid display
+ if (_filteredRows.Count > 0)
+ {
+ foreach (var row in _filteredRows)
+ row.NotifyTimeDisplayChanged();
+ ResultsGrid.ItemsSource = null;
+ ResultsGrid.ItemsSource = _filteredRows;
+ }
+ // Refresh slicer labels
+ TimeRangeSlicer.Redraw();
+ }
+
+ private void ClearSearch_Click(object? sender, RoutedEventArgs e)
+ {
+ SearchTypeBox.SelectedIndex = 0;
+ SearchValueBox.Text = "";
+ }
+
+ private async System.Threading.Tasks.Task LoadTimeSlicerDataAsync(
+ string metric, CancellationToken ct,
+ DateTime? preserveStart = null, DateTime? preserveEnd = null)
+ {
+ try
+ {
+ var sliceData = await QueryStoreService.FetchTimeSliceDataAsync(
+ _connectionString, metric, _slicerDaysBack, ct);
+ if (ct.IsCancellationRequested) return;
+ if (sliceData.Count > 0)
+ TimeRangeSlicer.LoadData(sliceData, metric, preserveStart, preserveEnd);
+ else
+ StatusText.Text = "No time-slicer data available.";
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ StatusText.Text = $"Slicer: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}";
+ }
+ }
+
+ private async void OnTimeRangeChanged(object? sender, TimeRangeChangedEventArgs e)
+ {
+ _slicerStartUtc = e.StartUtc;
+ _slicerEndUtc = e.EndUtc;
+ if (_suppressRangeChanged) return;
+ await FetchPlansForRangeAsync();
+ }
+
+ // ── Wait stats ─────────────────────────────────────────────────────────
+
+ ///
+ /// Fetches global bar + ribbon wait stats (independent of grid plan IDs).
+ /// Shows loading indicator on the wait stats panel.
+ ///
+ private async System.Threading.Tasks.Task FetchGlobalWaitStatsOnlyAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ WaitStatsProfile.SetLoading(true);
+ try
+ {
+ // Global (bar)
+ var globalWaits = await QueryStoreService.FetchGlobalWaitStatsAsync(
+ _connectionString, startUtc, endUtc, ct);
+ if (ct.IsCancellationRequested) { return; }
+ var globalProfile = QueryStoreService.BuildWaitProfile(globalWaits);
+ WaitStatsProfile.SetBarProfile(globalProfile);
+
+ // Global (ribbon) — fetched lazily, data ready for toggle
+ var ribbonData = await QueryStoreService.FetchGlobalWaitStatsRibbonAsync(
+ _connectionString, startUtc, endUtc, ct);
+ if (ct.IsCancellationRequested) { return; }
+ WaitStatsProfile.SetRibbonData(ribbonData);
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception) { }
+ finally
+ {
+ WaitStatsProfile.SetLoading(false);
+ }
+ }
+
+ ///
+ /// Fetches per-plan wait stats for the plan IDs currently in the grid.
+ ///
+ private async System.Threading.Tasks.Task FetchPerPlanWaitStatsAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ try
+ {
+ var visiblePlanIds = _rows.Select(r => r.PlanId).ToList();
+ var planWaits = await QueryStoreService.FetchPlanWaitStatsAsync(
+ _connectionString, startUtc, endUtc, visiblePlanIds, ct);
+ if (ct.IsCancellationRequested) { return; }
+
+ var byPlan = planWaits
+ .GroupBy(x => x.PlanId)
+ .ToDictionary(g => g.Key, g => g.Select(x => x.Wait).ToList());
+
+ foreach (var row in _rows)
+ {
+ if (byPlan.TryGetValue(row.PlanId, out var waits))
+ row.WaitProfile = QueryStoreService.BuildWaitProfile(waits);
+ else
+ row.WaitProfile = null;
+ }
+ UpdateWaitBarMode();
+ }
+ catch (OperationCanceledException) { }
+ catch (Exception) { }
+ }
+
+ ///
+ /// Full wait stats fetch (global + ribbon + per-plan). Used when re-expanding the wait stats panel.
+ ///
+ private async System.Threading.Tasks.Task FetchWaitStatsAsync(
+ DateTime startUtc, DateTime endUtc, CancellationToken ct)
+ {
+ await FetchGlobalWaitStatsOnlyAsync(startUtc, endUtc, ct);
+ if (_groupByMode != QueryStoreGroupBy.None)
+ await FetchGroupedWaitStatsAsync(startUtc, endUtc, ct);
+ else
+ await FetchPerPlanWaitStatsAsync(startUtc, endUtc, ct);
+ }
+
+ private void OnWaitCategoryClicked(object? sender, string category)
+ {
+ // Toggle highlight: click same category again → clear
+ if (_waitHighlightCategory == category)
+ _waitHighlightCategory = null;
+ else
+ _waitHighlightCategory = category;
+
+ ApplyWaitHighlight();
+ }
+
+ private void OnWaitCategoryDoubleClicked(object? sender, string category)
+ {
+ _waitHighlightCategory = category;
+ ApplyWaitHighlight();
+
+ // Sort grid by this category's wait ratio (descending)
+ var sorted = _filteredRows
+ .OrderByDescending(r =>
+ r.WaitProfile?.Segments
+ .Where(s => s.Category == category)
+ .Sum(s => s.WaitRatio) ?? 0)
+ .ToList();
+
+ _filteredRows.Clear();
+ foreach (var row in sorted)
+ _filteredRows.Add(row);
+
+ // Clear column sort indicators since we're using custom sort
+ _sortedColumnTag = null;
+ UpdateSortIndicators(null);
+ ReapplyTopNSelection();
+ UpdateBarRatios();
+ }
+
+ private void ApplyWaitHighlight()
+ {
+ WaitStatsProfile.SetHighlight(_waitHighlightCategory);
+ foreach (var row in _rows)
+ row.WaitHighlightCategory = _waitHighlightCategory;
+ }
+
+ private void OnWaitStatsCollapsedChanged(object? sender, bool collapsed)
+ {
+ _waitStatsEnabled = !collapsed;
+
+ var waitProfileCol = ResultsGrid.Columns
+ .FirstOrDefault(c => c.SortMemberPath == "WaitGrandTotalSort");
+ if (waitProfileCol != null)
+ waitProfileCol.IsVisible = !collapsed;
+
+ if (!collapsed && _waitStatsSupported && _slicerStartUtc.HasValue && _slicerEndUtc.HasValue)
+ {
+ // Re-fetch wait stats when expanding — reuse the shared CTS
+ var ct = _fetchCts?.Token ?? CancellationToken.None;
+ _ = FetchWaitStatsAsync(_slicerStartUtc.Value, _slicerEndUtc.Value, ct);
+ }
+ }
+
+ private void WaitStatsChevron_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WaitStatsProfile.IsCollapsed)
+ {
+ WaitStatsProfile.Expand();
+ WaitStatsChevronButton.Content = "»";
+ SlicerRow.ColumnDefinitions[0].Width = new GridLength(2, GridUnitType.Star);
+ SlicerRow.ColumnDefinitions[2].Width = new GridLength(1, GridUnitType.Star);
+ }
+ else
+ {
+ WaitStatsProfile.Collapse();
+ WaitStatsChevronButton.Content = "«";
+ SlicerRow.ColumnDefinitions[0].Width = new GridLength(1, GridUnitType.Star);
+ SlicerRow.ColumnDefinitions[2].Width = new GridLength(0);
+ }
+ }
+
+ private void WaitModeToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ _waitPercentMode = !_waitPercentMode;
+ if (sender is Button btn)
+ btn.Content = _waitPercentMode ? "%" : "v";
+ UpdateWaitBarMode();
+ }
+
+ private void UpdateWaitBarMode()
+ {
+ var maxGrand = _filteredRows.Count > 0
+ ? _filteredRows.Max(r => r.WaitProfile?.GrandTotalRatio ?? 0)
+ : 1.0;
+ if (maxGrand <= 0) maxGrand = 1.0;
+ foreach (var row in _filteredRows)
+ {
+ row.WaitPercentMode = _waitPercentMode;
+ row.WaitMaxGrandTotal = maxGrand;
+ }
+ }
+
+ private void SelectToggle_Click(object? sender, RoutedEventArgs e)
+ {
+ var allSelected = _filteredRows.Count > 0 && _filteredRows.All(r => r.IsSelected);
+ foreach (var row in _filteredRows)
+ row.IsSelected = !allSelected;
+ SelectToggleButton.Content = allSelected ? "Select All" : "Select None";
+ }
+
+ private void LoadSelected_Click(object? sender, RoutedEventArgs e)
+ {
+ List selected;
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ // In grouped mode, expand selected grouped rows to their leaf plans
+ selected = _filteredRows
+ .Where(r => r.IsSelected)
+ .SelectMany(r => r.HasChildren ? CollectLeafPlans(r) : (r.PlanId > 0 && r.QueryId > 0 ? [r.Plan] : []))
+ .ToList();
+ }
+ else
+ {
+ selected = _filteredRows.Where(r => r.IsSelected).Select(r => r.Plan).ToList();
+ }
+ if (selected.Count > 0)
+ PlansSelected?.Invoke(this, selected);
+ }
+
+ private void LoadHighlightedPlan_Click(object? sender, RoutedEventArgs e)
+ {
+ if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+
+ // In grouped mode, load all descendant leaf plans with real IDs
+ if (_groupByMode != QueryStoreGroupBy.None && row.HasChildren)
+ {
+ var leafPlans = CollectLeafPlans(row);
+ if (leafPlans.Count > 0)
+ PlansSelected?.Invoke(this, leafPlans);
+ }
+ else if (row.PlanId > 0 && row.QueryId > 0)
+ {
+ PlansSelected?.Invoke(this, new List { row.Plan });
+ }
+ }
+
+ ///
+ /// Recursively collects all leaf-level plans (PlanId > 0 and QueryId > 0) from a grouped row and its descendants.
+ ///
+ private static List CollectLeafPlans(QueryStoreRow row)
+ {
+ var plans = new List();
+ if (row.Children.Count == 0)
+ {
+ if (row.PlanId > 0 && row.QueryId > 0)
+ plans.Add(row.Plan);
+ }
+ else
+ {
+ foreach (var child in row.Children)
+ plans.AddRange(CollectLeafPlans(child));
+ }
+ return plans;
+ }
+
+ private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
+ {
+ if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
+ if (string.IsNullOrEmpty(row.QueryHash)) return;
+
+ var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
+
+ var window = new QueryStoreHistoryWindow(
+ _connectionString,
+ row.QueryHash,
+ row.FullQueryText,
+ _database,
+ initialMetricTag: metricTag,
+ slicerStartUtc: _slicerStartUtc,
+ slicerEndUtc: _slicerEndUtc,
+ slicerDaysBack: _slicerDaysBack);
+
+ var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
+ if (topLevel is Window parentWindow)
+ await window.ShowDialog(parentWindow);
+ else
+ window.Show();
+ }
+
+ // ── Context menu ────────────────────────────────────────────────────────
+
+ private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
+ {
+ var row = ResultsGrid.SelectedItem as QueryStoreRow;
+ var hasRow = row != null;
+
+ ViewHistoryItem.IsEnabled = hasRow;
+ CopyQueryIdItem.IsEnabled = hasRow;
+ CopyPlanIdItem.IsEnabled = hasRow;
+ CopyQueryHashItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.QueryHash);
+ CopyPlanHashItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.QueryPlanHash);
+ CopyModuleItem.IsEnabled = hasRow && !string.IsNullOrEmpty(row!.ModuleName);
+ CopyQueryTextItem.IsEnabled = hasRow;
+ CopyRowItem.IsEnabled = hasRow;
+
+ // Wire click handlers (clear first to avoid stacking)
+ CopyQueryIdItem.Click -= CopyMenuItem_Click;
+ CopyPlanIdItem.Click -= CopyMenuItem_Click;
+ CopyQueryHashItem.Click -= CopyMenuItem_Click;
+ CopyPlanHashItem.Click -= CopyMenuItem_Click;
+ CopyModuleItem.Click -= CopyMenuItem_Click;
+ CopyQueryTextItem.Click -= CopyMenuItem_Click;
+ CopyRowItem.Click -= CopyMenuItem_Click;
+
+ if (!hasRow) return;
+
+ CopyQueryIdItem.Tag = row!.QueryId.ToString();
+ CopyPlanIdItem.Tag = row.PlanId.ToString();
+ CopyQueryHashItem.Tag = row.QueryHash;
+ CopyPlanHashItem.Tag = row.QueryPlanHash;
+ CopyModuleItem.Tag = row.ModuleName;
+ CopyQueryTextItem.Tag = row.FullQueryText;
+ CopyRowItem.Tag = $"{row.QueryId}\t{row.PlanId}\t{row.QueryHash}\t{row.QueryPlanHash}\t{row.ModuleName}\t{row.LastExecutedLocal}\t{row.ExecsDisplay}\t{row.TotalCpuDisplay}\t{row.AvgCpuDisplay}\t{row.TotalDurDisplay}\t{row.AvgDurDisplay}\t{row.TotalReadsDisplay}\t{row.AvgReadsDisplay}\t{row.TotalWritesDisplay}\t{row.AvgWritesDisplay}\t{row.TotalPhysReadsDisplay}\t{row.AvgPhysReadsDisplay}\t{row.TotalMemDisplay}\t{row.AvgMemDisplay}\t{row.FullQueryText}";
+
+ CopyQueryIdItem.Click += CopyMenuItem_Click;
+ CopyPlanIdItem.Click += CopyMenuItem_Click;
+ CopyQueryHashItem.Click += CopyMenuItem_Click;
+ CopyPlanHashItem.Click += CopyMenuItem_Click;
+ CopyModuleItem.Click += CopyMenuItem_Click;
+ CopyQueryTextItem.Click += CopyMenuItem_Click;
+ CopyRowItem.Click += CopyMenuItem_Click;
+ }
+
+ private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is MenuItem item && item.Tag is string text)
+ await SetClipboardTextAsync(text);
+ }
+
+ private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
+ {
+ var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
+ if (topLevel?.Clipboard != null)
+ await topLevel.Clipboard.SetTextAsync(text);
+ }
+
+ // ── Column filter infrastructure ───────────────────────────────────────
+
+ private static readonly Dictionary> TextAccessors = new()
+ {
+ ["QueryHash"] = r => r.QueryHash,
+ ["PlanHash"] = r => r.QueryPlanHash,
+ ["ModuleName"] = r => r.ModuleName,
+ ["LastExecuted"] = r => r.LastExecutedLocal,
+ ["QueryText"] = r => r.FullQueryText,
+ };
+
+ private static readonly Dictionary> NumericAccessors = new()
+ {
+ ["QueryId"] = r => r.QueryId,
+ ["PlanId"] = r => r.PlanId,
+ ["Executions"] = r => r.ExecsSort,
+ ["TotalCpu"] = r => r.TotalCpuSort / 1000.0, // µs → ms (matches display)
+ ["AvgCpu"] = r => r.AvgCpuSort / 1000.0, // µs → ms
+ ["TotalDuration"] = r => r.TotalDurSort / 1000.0, // µs → ms
+ ["AvgDuration"] = r => r.AvgDurSort / 1000.0, // µs → ms
+ ["TotalReads"] = r => r.TotalReadsSort,
+ ["AvgReads"] = r => r.AvgReadsSort,
+ ["TotalWrites"] = r => r.TotalWritesSort,
+ ["AvgWrites"] = r => r.AvgWritesSort,
+ ["TotalPhysReads"] = r => r.TotalPhysReadsSort,
+ ["AvgPhysReads"] = r => r.AvgPhysReadsSort,
+ ["TotalMemory"] = r => r.TotalMemSort * 8.0 / 1024.0, // pages → MB (matches display)
+ ["AvgMemory"] = r => r.AvgMemSort * 8.0 / 1024.0, // pages → MB
+ };
+
+ private void SetupColumnHeaders()
+ {
+ var cols = ResultsGrid.Columns;
+ // cols[0] = Expand column, cols[1] = Checkbox
+ SetColumnFilterButton(cols[2], "QueryId", "Query ID");
+ SetColumnFilterButton(cols[3], "PlanId", "Plan ID");
+ SetColumnFilterButton(cols[4], "QueryHash", "Query Hash");
+ SetColumnFilterButton(cols[5], "PlanHash", "Plan Hash");
+ SetColumnFilterButton(cols[6], "ModuleName", "Module");
+ // cols[7] = WaitProfile (no filter button)
+ SetColumnFilterButton(cols[8], "LastExecuted", "Last Executed (Local)");
+ SetColumnFilterButton(cols[9], "Executions", "Executions");
+ SetColumnFilterButton(cols[10], "TotalCpu", "Total CPU (ms)");
+ SetColumnFilterButton(cols[11], "AvgCpu", "Avg CPU (ms)");
+ SetColumnFilterButton(cols[12], "TotalDuration", "Total Duration (ms)");
+ SetColumnFilterButton(cols[13], "AvgDuration", "Avg Duration (ms)");
+ SetColumnFilterButton(cols[14], "TotalReads", "Total Reads");
+ SetColumnFilterButton(cols[15], "AvgReads", "Avg Reads");
+ SetColumnFilterButton(cols[16], "TotalWrites", "Total Writes");
+ SetColumnFilterButton(cols[17], "AvgWrites", "Avg Writes");
+ SetColumnFilterButton(cols[18], "TotalPhysReads", "Total Physical Reads");
+ SetColumnFilterButton(cols[19], "AvgPhysReads", "Avg Physical Reads");
+ SetColumnFilterButton(cols[20], "TotalMemory", "Total Memory (MB)");
+ SetColumnFilterButton(cols[21], "AvgMemory", "Avg Memory (MB)");
+ SetColumnFilterButton(cols[22], "QueryText", "Query Text");
+ }
+
+ private void SetColumnFilterButton(DataGridColumn col, string columnId, string label)
+ {
+ var icon = new TextBlock
+ {
+ Text = "▽",
+ FontSize = 12,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ };
+ var btn = new Button
+ {
+ Content = icon,
+ Tag = columnId,
+ Width = 16,
+ Height = 16,
+ Padding = new Avalonia.Thickness(0),
+ Background = Brushes.Transparent,
+ BorderThickness = new Avalonia.Thickness(0),
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ };
+ btn.Click += ColumnFilter_Click;
+ ToolTip.SetTip(btn, "Click to filter");
+
+ var text = new TextBlock
+ {
+ Text = label,
+ FontWeight = FontWeight.Bold,
+ VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
+ Margin = new Avalonia.Thickness(4, 0, 0, 0),
+ };
+
+ var header = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left,
+ };
+ header.Children.Add(btn);
+ header.Children.Add(text);
+ col.Header = header;
+ }
+
+ private void EnsureFilterPopup()
+ {
+ if (_filterPopup != null) return;
+ _filterPopupContent = new ColumnFilterPopup();
+ _filterPopup = new Popup
+ {
+ Child = _filterPopupContent,
+ IsLightDismissEnabled = true,
+ Placement = PlacementMode.Bottom,
+ };
+ // Add to visual tree so DynamicResources resolve inside the popup
+ ((Grid)Content!).Children.Add(_filterPopup);
+ _filterPopupContent.FilterApplied += OnFilterApplied;
+ _filterPopupContent.FilterCleared += OnFilterCleared;
+ }
+
+ private void ColumnFilter_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not Button button || button.Tag is not string columnId) return;
+ EnsureFilterPopup();
+ _activeFilters.TryGetValue(columnId, out var existing);
+ _filterPopupContent!.Initialize(columnId, existing);
+ _filterPopup!.PlacementTarget = button;
+ _filterPopup.IsOpen = true;
+ }
+
+ private void OnFilterApplied(object? sender, FilterAppliedEventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ if (e.FilterState.IsActive)
+ _activeFilters[e.FilterState.ColumnName] = e.FilterState;
+ else
+ _activeFilters.Remove(e.FilterState.ColumnName);
+ ApplySortAndFilters();
+ UpdateFilterButtonStyles();
+ }
+
+ private void OnFilterCleared(object? sender, EventArgs e)
+ {
+ _filterPopup!.IsOpen = false;
+ }
+
+ private void UpdateFilterButtonStyles()
+ {
+ foreach (var col in ResultsGrid.Columns)
+ {
+ if (col.Header is not StackPanel sp) continue;
+ var btn = sp.Children.OfType().FirstOrDefault();
+ if (btn?.Tag is not string colId) continue;
+ if (btn.Content is not TextBlock tb) continue;
+
+ bool hasFilter = _activeFilters.TryGetValue(colId, out var f) && f.IsActive;
+ tb.Text = hasFilter ? "▼" : "▽";
+ if (hasFilter)
+ tb.Foreground = new SolidColorBrush(Color.FromRgb(0xFF, 0xD7, 0x00));
+ else
+ tb.ClearValue(TextBlock.ForegroundProperty);
+
+ ToolTip.SetTip(btn, hasFilter
+ ? $"Filter: {f!.DisplayText} (click to modify)"
+ : "Click to filter");
+ }
+ }
+
+ private void ApplyFilters()
+ {
+ ApplySortAndFilters();
+ }
+
+ private bool RowMatchesAllFilters(QueryStoreRow row)
+ {
+ foreach (var (colId, state) in _activeFilters)
+ {
+ if (!state.IsActive) continue;
+ if (TextAccessors.TryGetValue(colId, out var textAcc))
+ {
+ if (!MatchText(textAcc(row), state.Operator, state.Value)) return false;
+ }
+ else if (NumericAccessors.TryGetValue(colId, out var numAcc))
+ {
+ var isTextOp = state.Operator is FilterOperator.Contains or FilterOperator.StartsWith
+ or FilterOperator.EndsWith or FilterOperator.IsEmpty or FilterOperator.IsNotEmpty;
+ if (isTextOp)
+ {
+ if (!MatchText(numAcc(row).ToString("G"), state.Operator, state.Value)) return false;
+ }
+ else
+ {
+ if (!double.TryParse(state.Value, out var numVal)) continue;
+ if (!MatchNumeric(numAcc(row), state.Operator, numVal)) return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static bool MatchText(string data, FilterOperator op, string val) => op switch
+ {
+ FilterOperator.Contains => data.Contains(val, StringComparison.OrdinalIgnoreCase),
+ FilterOperator.Equals => data.Equals(val, StringComparison.OrdinalIgnoreCase),
+ FilterOperator.NotEquals => !data.Equals(val, StringComparison.OrdinalIgnoreCase),
+ FilterOperator.StartsWith => data.StartsWith(val, StringComparison.OrdinalIgnoreCase),
+ FilterOperator.EndsWith => data.EndsWith(val, StringComparison.OrdinalIgnoreCase),
+ FilterOperator.IsEmpty => string.IsNullOrEmpty(data),
+ FilterOperator.IsNotEmpty => !string.IsNullOrEmpty(data),
+ _ => true,
+ };
+
+ private static bool MatchNumeric(double data, FilterOperator op, double val) => op switch
+ {
+ FilterOperator.Equals => Math.Abs(data - val) < 1e-9,
+ FilterOperator.NotEquals => Math.Abs(data - val) >= 1e-9,
+ FilterOperator.GreaterThan => data > val,
+ FilterOperator.GreaterThanOrEqual => data >= val,
+ FilterOperator.LessThan => data < val,
+ FilterOperator.LessThanOrEqual => data <= val,
+ _ => true,
+ };
+
+ private void UpdateStatusText()
+ {
+ if (_rows.Count == 0) return;
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ var rootCount = _groupedRootRows.Count;
+ var visibleRoots = _filteredRows.Count(r => r.IndentLevel == 0);
+ StatusText.Text = visibleRoots == rootCount
+ ? $"{rootCount} groups ({_rows.Count} total rows)"
+ : $"{visibleRoots} / {rootCount} groups (filtered)";
+ }
+ else
+ {
+ StatusText.Text = _filteredRows.Count == _rows.Count
+ ? $"{_rows.Count} plans"
+ : $"{_filteredRows.Count} / {_rows.Count} plans (filtered)";
+ }
+ }
+
+ private void ResultsGrid_Sorting(object? sender, DataGridColumnEventArgs e)
+ {
+ e.Handled = true;
+
+ var colTag = e.Column.Tag as string ?? e.Column.SortMemberPath;
+ if (colTag == null) return;
+
+ // Toggle: first click on a new column → descending; second click → ascending; third → clear
+ if (_sortedColumnTag == colTag)
+ {
+ if (!_sortAscending)
+ _sortAscending = true; // descending → ascending
+ else
+ {
+ // ascending → clear sort
+ _sortedColumnTag = null;
+ foreach (var col in ResultsGrid.Columns)
+ col.Tag = col.Tag; // no-op, just reset indicator below
+ UpdateSortIndicators(null);
+ ApplySortAndFilters();
+ return;
+ }
+ }
+ else
+ {
+ _sortedColumnTag = colTag;
+ _sortAscending = false; // first click → descending
+ }
+
+ UpdateSortIndicators(e.Column);
+ ApplySortAndFilters();
+ }
+
+ private void UpdateSortIndicators(DataGridColumn? activeColumn)
+ {
+ foreach (var col in ResultsGrid.Columns)
+ {
+ if (col.Header is not StackPanel sp) continue;
+ var label = sp.Children.OfType().LastOrDefault();
+ if (label == null) continue;
+
+ if (col == activeColumn)
+ label.Text = _sortAscending ? $"{GetColumnLabel(sp)} ▲" : $"{GetColumnLabel(sp)} ▼";
+ else
+ label.Text = GetColumnLabel(sp);
+ }
+ }
+
+ private static string GetColumnLabel(StackPanel header)
+ {
+ var tb = header.Children.OfType().LastOrDefault();
+ if (tb == null) return string.Empty;
+ // Strip any existing sort indicator
+ return tb.Text?.TrimEnd(' ', '▲', '▼') ?? string.Empty;
+ }
+
+ private void ReapplyTopNSelection()
+ {
+ if (_filteredRows.Count == 0) return;
+ foreach (var r in _rows) r.IsSelected = false;
+ foreach (var r in _filteredRows.Take(AutoSelectTopN)) r.IsSelected = true;
+ }
+
+ private void ApplySortAndFilters()
+ {
+ if (_groupByMode != QueryStoreGroupBy.None)
+ {
+ ApplySortAndFiltersGrouped();
+ return;
+ }
+
+ IEnumerable source = _rows.Where(RowMatchesAllFilters);
+
+ if (_sortedColumnTag != null)
+ {
+ source = _sortAscending
+ ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
+ : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
+ }
+
+ _filteredRows.Clear();
+ foreach (var row in source)
+ _filteredRows.Add(row);
+
+ ReapplyTopNSelection();
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void ApplySortAndFiltersGrouped()
+ {
+ // In grouped mode, sort/filter only root rows and rebuild the visible list
+ IEnumerable source = _groupedRootRows.Where(RowMatchesAllFilters);
+
+ if (_sortedColumnTag != null)
+ {
+ source = _sortAscending
+ ? source.OrderBy(r => GetSortKey(_sortedColumnTag, r))
+ : source.OrderByDescending(r => GetSortKey(_sortedColumnTag, r));
+ }
+
+ _filteredRows.Clear();
+ foreach (var root in source)
+ {
+ _filteredRows.Add(root);
+ if (root.IsExpanded)
+ AddExpandedChildren(root);
+ }
+
+ UpdateStatusText();
+ UpdateBarRatios();
+ }
+
+ private void AddExpandedChildren(QueryStoreRow parent)
+ {
+ foreach (var child in parent.Children)
+ {
+ _filteredRows.Add(child);
+ if (child.IsExpanded)
+ AddExpandedChildren(child);
+ }
+ }
+
+ // ── Bar chart ratio computation ────────────────────────────────────────
+
+ // Maps a ColumnId (used in BarChartConfig) to the accessor that returns the raw sort value.
+ private static readonly (string ColumnId, Func Accessor)[] BarColumns =
+ [
+ ("Executions", r => r.ExecsSort),
+ ("TotalCpu", r => r.TotalCpuSort),
+ ("AvgCpu", r => r.AvgCpuSort),
+ ("TotalDuration", r => r.TotalDurSort),
+ ("AvgDuration", r => r.AvgDurSort),
+ ("TotalReads", r => r.TotalReadsSort),
+ ("AvgReads", r => r.AvgReadsSort),
+ ("TotalWrites", r => r.TotalWritesSort),
+ ("AvgWrites", r => r.AvgWritesSort),
+ ("TotalPhysReads",r => r.TotalPhysReadsSort),
+ ("AvgPhysReads", r => r.AvgPhysReadsSort),
+ ("TotalMemory", r => r.TotalMemSort),
+ ("AvgMemory", r => r.AvgMemSort),
+ ];
+
+ // Maps a SortMemberPath tag (used in the sort dictionary) → ColumnId
+ private static readonly Dictionary SortTagToColumnId = new()
+ {
+ ["ExecsSort"] = "Executions",
+ ["TotalCpuSort"] = "TotalCpu",
+ ["AvgCpuSort"] = "AvgCpu",
+ ["TotalDurSort"] = "TotalDuration",
+ ["AvgDurSort"] = "AvgDuration",
+ ["TotalReadsSort"] = "TotalReads",
+ ["AvgReadsSort"] = "AvgReads",
+ ["TotalWritesSort"] = "TotalWrites",
+ ["AvgWritesSort"] = "AvgWrites",
+ ["TotalPhysReadsSort"] = "TotalPhysReads",
+ ["AvgPhysReadsSort"] = "AvgPhysReads",
+ ["TotalMemSort"] = "TotalMemory",
+ ["AvgMemSort"] = "AvgMemory",
+ };
+
+ private void UpdateBarRatios()
+ {
+ if (_filteredRows.Count == 0) return;
+
+ var sortedColumnId = _sortedColumnTag != null &&
+ SortTagToColumnId.TryGetValue(_sortedColumnTag, out var sid) ? sid : null;
+
+ foreach (var (columnId, accessor) in BarColumns)
+ {
+ var max = _filteredRows.Max(r => accessor(r));
+ var isSorted = columnId == sortedColumnId;
+ foreach (var row in _filteredRows)
+ {
+ var ratio = max > 0 ? accessor(row) / max : 0.0;
+ row.SetBar(columnId, ratio, isSorted);
+ }
+ }
+
+ UpdateWaitBarMode();
+ }
+
+ private static IComparable GetSortKey(string columnTag, QueryStoreRow r) =>
+ columnTag switch
+ {
+ // Columns with no SortMemberPath: Avalonia uses the binding property name as key
+ "QueryId" => (IComparable)r.QueryId,
+ "PlanId" => r.PlanId,
+ "QueryHash" => r.QueryHash,
+ "QueryPlanHash" => r.QueryPlanHash,
+ "ModuleName" => r.ModuleName,
+ "LastExecutedLocal" => r.LastExecutedLocal,
+ // Columns with explicit SortMemberPath
+ "ExecsSort" => r.ExecsSort,
+ "TotalCpuSort" => r.TotalCpuSort,
+ "AvgCpuSort" => r.AvgCpuSort,
+ "TotalDurSort" => r.TotalDurSort,
+ "AvgDurSort" => r.AvgDurSort,
+ "TotalReadsSort" => r.TotalReadsSort,
+ "AvgReadsSort" => r.AvgReadsSort,
+ "TotalWritesSort" => r.TotalWritesSort,
+ "AvgWritesSort" => r.AvgWritesSort,
+ "TotalPhysReadsSort" => r.TotalPhysReadsSort,
+ "AvgPhysReadsSort" => r.AvgPhysReadsSort,
+ "TotalMemSort" => r.TotalMemSort,
+ "AvgMemSort" => r.AvgMemSort,
+ "WaitGrandTotalSort" => r.WaitGrandTotalSort,
+ _ => r.LastExecutedLocal,
+ };
+}
+
+public class QueryStoreRow : INotifyPropertyChanged
+{
+ private bool _isSelected = false;
+
+ // Bar ratios [0..1] per column
+ private double _execsRatio;
+ private double _totalCpuRatio;
+ private double _avgCpuRatio;
+ private double _totalDurRatio;
+ private double _avgDurRatio;
+ private double _totalReadsRatio;
+ private double _avgReadsRatio;
+ private double _totalWritesRatio;
+ private double _avgWritesRatio;
+ private double _totalPhysReadsRatio;
+ private double _avgPhysReadsRatio;
+ private double _totalMemRatio;
+ private double _avgMemRatio;
+
+ // IsSortedColumn flags
+ private bool _isSorted_Executions;
+ private bool _isSorted_TotalCpu;
+ private bool _isSorted_AvgCpu;
+ private bool _isSorted_TotalDuration;
+ private bool _isSorted_AvgDuration;
+ private bool _isSorted_TotalReads;
+ private bool _isSorted_AvgReads;
+ private bool _isSorted_TotalWrites;
+ private bool _isSorted_AvgWrites;
+ private bool _isSorted_TotalPhysReads;
+ private bool _isSorted_AvgPhysReads;
+ private bool _isSorted_TotalMemory;
+ private bool _isSorted_AvgMemory;
+
+ // Wait stats
+ private WaitProfile? _waitProfile;
+ private string? _waitHighlightCategory;
+
+ /// Raw wait category totals for this row. Used for upward consolidation in grouped mode.
+ public List RawWaitCategories { get; set; } = new();
+
+ // Hierarchy support
+ private bool _isExpanded;
+ private int _indentLevel;
+
+ /// Standard constructor for flat (ungrouped) rows.
+ public QueryStoreRow(QueryStorePlan plan)
+ {
+ Plan = plan;
+ }
+
+ /// Constructor for grouped parent/intermediate rows (aggregated, no single plan).
+ public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children)
+ {
+ Plan = syntheticPlan;
+ _indentLevel = indentLevel;
+ GroupLabel = groupLabel;
+ Children = children;
+ }
+
+ public QueryStorePlan Plan { get; }
+
+ // ── Hierarchy properties ───────────────────────────────────────────────
+
+ /// Indentation level: 0 = top group, 1 = intermediate, 2 = leaf.
+ public int IndentLevel
+ {
+ get => _indentLevel;
+ set { _indentLevel = value; OnPropertyChanged(); }
+ }
+
+ /// Label shown for grouped rows (e.g. "0x1A2B3C" or "dbo.MyProc").
+ public string GroupLabel { get; set; } = "";
+
+ /// Direct children of this group row.
+ public List Children { get; set; } = new();
+
+ public bool HasChildren => Children.Count > 0;
+
+ public bool IsExpanded
+ {
+ get => _isExpanded;
+ set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandChevron)); }
+ }
+
+ public string ExpandChevron => HasChildren ? (IsExpanded ? "▾" : "▸") : "";
+
+ /// Left margin that increases with indent level to visually show hierarchy.
+ public Avalonia.Thickness IndentMargin => new(IndentLevel * 20, 0, 0, 0);
+
+ /// Text shown next to the chevron: the group label for parent rows, or QueryId/PlanId for leaves.
+ public string GroupDisplayText => !string.IsNullOrEmpty(GroupLabel) ? GroupLabel : "";
+
+ /// Bold for top-level groups, normal for children.
+ public Avalonia.Media.FontWeight GroupFontWeight => IndentLevel == 0 ? Avalonia.Media.FontWeight.Bold : Avalonia.Media.FontWeight.Normal;
+
+ public bool IsSelected
+ {
+ get => _isSelected;
+ set { _isSelected = value; OnPropertyChanged(); }
+ }
+
+ // ── Bar ratio properties ───────────────────────────────────────────────
+ public double ExecsRatio { get => _execsRatio; private set { _execsRatio = value; OnPropertyChanged(); } }
+ public double TotalCpuRatio { get => _totalCpuRatio; private set { _totalCpuRatio = value; OnPropertyChanged(); } }
+ public double AvgCpuRatio { get => _avgCpuRatio; private set { _avgCpuRatio = value; OnPropertyChanged(); } }
+ public double TotalDurRatio { get => _totalDurRatio; private set { _totalDurRatio = value; OnPropertyChanged(); } }
+ public double AvgDurRatio { get => _avgDurRatio; private set { _avgDurRatio = value; OnPropertyChanged(); } }
+ public double TotalReadsRatio { get => _totalReadsRatio; private set { _totalReadsRatio = value; OnPropertyChanged(); } }
+ public double AvgReadsRatio { get => _avgReadsRatio; private set { _avgReadsRatio = value; OnPropertyChanged(); } }
+ public double TotalWritesRatio { get => _totalWritesRatio; private set { _totalWritesRatio = value; OnPropertyChanged(); } }
+ public double AvgWritesRatio { get => _avgWritesRatio; private set { _avgWritesRatio = value; OnPropertyChanged(); } }
+ public double TotalPhysReadsRatio{ get => _totalPhysReadsRatio; private set { _totalPhysReadsRatio = value; OnPropertyChanged(); } }
+ public double AvgPhysReadsRatio { get => _avgPhysReadsRatio; private set { _avgPhysReadsRatio = value; OnPropertyChanged(); } }
+ public double TotalMemRatio { get => _totalMemRatio; private set { _totalMemRatio = value; OnPropertyChanged(); } }
+ public double AvgMemRatio { get => _avgMemRatio; private set { _avgMemRatio = value; OnPropertyChanged(); } }
+
+ // ── IsSortedColumn properties ──────────────────────────────────────────
+ public bool IsSortedColumn_Executions { get => _isSorted_Executions; private set { _isSorted_Executions = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalCpu { get => _isSorted_TotalCpu; private set { _isSorted_TotalCpu = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgCpu { get => _isSorted_AvgCpu; private set { _isSorted_AvgCpu = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalDuration { get => _isSorted_TotalDuration; private set { _isSorted_TotalDuration = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgDuration { get => _isSorted_AvgDuration; private set { _isSorted_AvgDuration = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalReads { get => _isSorted_TotalReads; private set { _isSorted_TotalReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgReads { get => _isSorted_AvgReads; private set { _isSorted_AvgReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalWrites { get => _isSorted_TotalWrites; private set { _isSorted_TotalWrites = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgWrites { get => _isSorted_AvgWrites; private set { _isSorted_AvgWrites = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalPhysReads{ get => _isSorted_TotalPhysReads;private set { _isSorted_TotalPhysReads = value;OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgPhysReads { get => _isSorted_AvgPhysReads; private set { _isSorted_AvgPhysReads = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_TotalMemory { get => _isSorted_TotalMemory; private set { _isSorted_TotalMemory = value; OnPropertyChanged(); } }
+ public bool IsSortedColumn_AvgMemory { get => _isSorted_AvgMemory; private set { _isSorted_AvgMemory = value; OnPropertyChanged(); } }
+
+ /// Called by the grid after each sort/filter pass to update bar rendering.
+ public void SetBar(string columnId, double ratio, bool isSorted)
+ {
+ switch (columnId)
+ {
+ case "Executions": ExecsRatio = ratio; IsSortedColumn_Executions = isSorted; break;
+ case "TotalCpu": TotalCpuRatio = ratio; IsSortedColumn_TotalCpu = isSorted; break;
+ case "AvgCpu": AvgCpuRatio = ratio; IsSortedColumn_AvgCpu = isSorted; break;
+ case "TotalDuration": TotalDurRatio = ratio; IsSortedColumn_TotalDuration = isSorted; break;
+ case "AvgDuration": AvgDurRatio = ratio; IsSortedColumn_AvgDuration = isSorted; break;
+ case "TotalReads": TotalReadsRatio = ratio; IsSortedColumn_TotalReads = isSorted; break;
+ case "AvgReads": AvgReadsRatio = ratio; IsSortedColumn_AvgReads = isSorted; break;
+ case "TotalWrites": TotalWritesRatio = ratio; IsSortedColumn_TotalWrites = isSorted; break;
+ case "AvgWrites": AvgWritesRatio = ratio; IsSortedColumn_AvgWrites = isSorted; break;
+ case "TotalPhysReads": TotalPhysReadsRatio = ratio; IsSortedColumn_TotalPhysReads = isSorted; break;
+ case "AvgPhysReads": AvgPhysReadsRatio = ratio; IsSortedColumn_AvgPhysReads = isSorted; break;
+ case "TotalMemory": TotalMemRatio = ratio; IsSortedColumn_TotalMemory = isSorted; break;
+ case "AvgMemory": AvgMemRatio = ratio; IsSortedColumn_AvgMemory = isSorted; break;
+ }
+ }
+
+ // ── Wait profile ───────────────────────────────────────────────────────
+
+ public WaitProfile? WaitProfile
+ {
+ get => _waitProfile;
+ set { _waitProfile = value; OnPropertyChanged(); }
+ }
+
+ public string? WaitHighlightCategory
+ {
+ get => _waitHighlightCategory;
+ set { _waitHighlightCategory = value; OnPropertyChanged(); }
+ }
+
+ private bool _waitPercentMode;
+ private double _waitMaxGrandTotal = 1.0;
+
+ public bool WaitPercentMode
+ {
+ get => _waitPercentMode;
+ set { _waitPercentMode = value; OnPropertyChanged(); }
+ }
+
+ public double WaitMaxGrandTotal
+ {
+ get => _waitMaxGrandTotal;
+ set { _waitMaxGrandTotal = value; OnPropertyChanged(); }
+ }
+
+ public double WaitGrandTotalSort => _waitProfile?.GrandTotalRatio ?? 0;
+
+ public long QueryId => Plan.QueryId;
+ public long PlanId => Plan.PlanId;
+ public string QueryHash => Plan.QueryHash;
+ public string QueryPlanHash => Plan.QueryPlanHash;
+ public string ModuleName => Plan.ModuleName;
+
+ public string ExecsDisplay => Plan.CountExecutions.ToString("N0");
+ public string TotalCpuDisplay => (Plan.TotalCpuTimeUs / 1000.0).ToString("N0");
+ public string AvgCpuDisplay => (Plan.AvgCpuTimeUs / 1000.0).ToString("N1");
+ public string TotalDurDisplay => (Plan.TotalDurationUs / 1000.0).ToString("N0");
+ public string AvgDurDisplay => (Plan.AvgDurationUs / 1000.0).ToString("N1");
+ public string TotalReadsDisplay => Plan.TotalLogicalIoReads.ToString("N0");
+ public string AvgReadsDisplay => Plan.AvgLogicalIoReads.ToString("N0");
+ public string TotalWritesDisplay => Plan.TotalLogicalIoWrites.ToString("N0");
+ public string AvgWritesDisplay => Plan.AvgLogicalIoWrites.ToString("N0");
+ public string TotalPhysReadsDisplay => Plan.TotalPhysicalIoReads.ToString("N0");
+ public string AvgPhysReadsDisplay => Plan.AvgPhysicalIoReads.ToString("N0");
+ public string TotalMemDisplay => (Plan.TotalMemoryGrantPages * 8.0 / 1024.0).ToString("N1");
+ public string AvgMemDisplay => (Plan.AvgMemoryGrantPages * 8.0 / 1024.0).ToString("N1");
+
+ // Numeric sort properties (DataGrid SortMemberPath targets)
+ public long ExecsSort => Plan.CountExecutions;
+ public long TotalCpuSort => Plan.TotalCpuTimeUs;
+ public double AvgCpuSort => Plan.AvgCpuTimeUs;
+ public long TotalDurSort => Plan.TotalDurationUs;
+ public double AvgDurSort => Plan.AvgDurationUs;
+ public long TotalReadsSort => Plan.TotalLogicalIoReads;
+ public double AvgReadsSort => Plan.AvgLogicalIoReads;
+ public long TotalWritesSort => Plan.TotalLogicalIoWrites;
+ public double AvgWritesSort => Plan.AvgLogicalIoWrites;
+ public long TotalPhysReadsSort => Plan.TotalPhysicalIoReads;
+ public double AvgPhysReadsSort => Plan.AvgPhysicalIoReads;
+ public long TotalMemSort => Plan.TotalMemoryGrantPages;
+ public double AvgMemSort => Plan.AvgMemoryGrantPages;
+
+ public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc);
+
+ public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal));
+
+ public string QueryPreview => Plan.QueryText.Length > 80
+ ? Plan.QueryText[..80].Replace("\n", " ").Replace("\r", "") + "..."
+ : Plan.QueryText.Replace("\n", " ").Replace("\r", "");
+ public string FullQueryText => Plan.QueryText;
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+ private void OnPropertyChanged([CallerMemberName] string? name = null)
+ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
+}
diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
index 818c016..38bdec0 100644
--- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
+++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml
@@ -1,110 +1,119 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs
index 8a578e8..5f32da3 100644
--- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs
@@ -1,273 +1,242 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Threading.Tasks;
-using Avalonia.Controls;
-using Avalonia.Interactivity;
-using Microsoft.Data.SqlClient;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Interfaces;
-using PlanViewer.Core.Models;
-
-namespace PlanViewer.App.Dialogs;
-
-public partial class ConnectionDialog : Window
-{
- private readonly ICredentialService _credentialService;
- private readonly ConnectionStore _connectionStore;
- private List _savedConnections = new();
-
- public ServerConnection? ResultConnection { get; private set; }
- public string? ResultDatabase { get; private set; }
-
- public ConnectionDialog(ICredentialService credentialService, ConnectionStore connectionStore)
- {
- _credentialService = credentialService;
- _connectionStore = connectionStore;
- InitializeComponent();
-
- AuthTypeBox.SelectedIndex = 0;
- EncryptBox.SelectedIndex = 0;
- PopulateSavedServers();
- }
-
- private void PopulateSavedServers()
- {
- _savedConnections = _connectionStore.Load();
- var serverNames = _savedConnections
- .OrderByDescending(s => s.LastConnected)
- .Select(s => s.ServerName)
- .Distinct()
- .ToList();
- ServerList.ItemsSource = serverNames;
-
- // Pre-fill the most recently used connection
- var mostRecent = _savedConnections
- .OrderByDescending(s => s.LastConnected)
- .FirstOrDefault();
-
- if (mostRecent != null)
- {
- ServerNameBox.Text = mostRecent.ServerName;
- ApplySavedConnection(mostRecent);
- }
- }
-
- private void ApplySavedConnection(ServerConnection saved)
- {
- // Auth type
- for (int i = 0; i < AuthTypeBox.Items.Count; i++)
- {
- if (AuthTypeBox.Items[i] is ComboBoxItem item &&
- item.Tag?.ToString() == saved.AuthenticationType)
- {
- AuthTypeBox.SelectedIndex = i;
- break;
- }
- }
-
- // Encrypt mode
- for (int i = 0; i < EncryptBox.Items.Count; i++)
- {
- if (EncryptBox.Items[i] is ComboBoxItem item &&
- item.Tag?.ToString() == saved.EncryptMode)
- {
- EncryptBox.SelectedIndex = i;
- break;
- }
- }
-
- TrustCertBox.IsChecked = saved.TrustServerCertificate;
-
- // Load stored credentials
- var cred = _credentialService.GetCredential(saved.Id);
- if (cred != null)
- {
- LoginBox.Text = cred.Value.Username;
- PasswordBox.Text = cred.Value.Password;
- }
- }
-
- private void ServerList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- var serverName = ServerList.SelectedItem?.ToString();
- if (string.IsNullOrEmpty(serverName)) return;
-
- ServerNameBox.Text = serverName;
- ServerDropdown.IsOpen = false;
-
- var saved = _savedConnections.FirstOrDefault(s =>
- s.ServerName.Equals(serverName, StringComparison.OrdinalIgnoreCase));
-
- if (saved != null)
- ApplySavedConnection(saved);
- }
-
- private void AuthType_SelectionChanged(object? sender, SelectionChangedEventArgs e)
- {
- if (AuthTypeBox.SelectedItem is not ComboBoxItem item) return;
- var authType = item.Tag?.ToString();
-
- var showLogin = authType is "SqlServer" or "EntraMFA";
- var showPassword = authType == "SqlServer";
-
- LoginPanel.IsVisible = showLogin;
- PasswordPanel.IsVisible = showPassword;
- }
-
- private async void TestConnection_Click(object? sender, RoutedEventArgs e)
- {
- var serverName = ServerNameBox.Text?.Trim();
- if (string.IsNullOrEmpty(serverName))
- {
- StatusText.Text = "Enter a server name";
- StatusText.Foreground = Avalonia.Media.Brushes.OrangeRed;
- return;
- }
-
- StatusText.Text = "Connecting...";
- StatusText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE4, 0xE6, 0xEB));
- TestButton.IsEnabled = false;
-
- try
- {
- var connection = BuildServerConnection();
- var connectionString = BuildConnectionString(connection);
-
- await using var conn = new SqlConnection(connectionString);
- await conn.OpenAsync();
-
- // Fetch databases
- 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;
- ConnectButton.IsEnabled = true;
-
- // Default to master if available
- var masterIdx = databases.IndexOf("master");
- if (masterIdx >= 0) DatabaseBox.SelectedIndex = masterIdx;
-
- StatusText.Text = $"Connected ({databases.Count} databases)";
- StatusText.Foreground = Avalonia.Media.Brushes.LimeGreen;
- }
- catch (Exception ex)
- {
- StatusText.Text = ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message;
- StatusText.Foreground = Avalonia.Media.Brushes.OrangeRed;
- DatabaseBox.IsEnabled = false;
- ConnectButton.IsEnabled = false;
- }
- finally
- {
- TestButton.IsEnabled = true;
- }
- }
-
- private void Connect_Click(object? sender, RoutedEventArgs e)
- {
- var connection = BuildServerConnection();
-
- // Save credentials
- var authType = GetSelectedAuthType();
- if (authType == AuthenticationTypes.SqlServer)
- {
- var login = LoginBox.Text?.Trim() ?? "";
- var password = PasswordBox.Text ?? "";
- _credentialService.SaveCredential(connection.Id, login, password);
- }
- else if (authType == AuthenticationTypes.EntraMFA)
- {
- var login = LoginBox.Text?.Trim() ?? "";
- if (!string.IsNullOrEmpty(login))
- _credentialService.SaveCredential(connection.Id, login, "");
- }
-
- // Save connection to store
- _connectionStore.AddOrUpdate(connection);
-
- ResultConnection = connection;
- ResultDatabase = DatabaseBox.SelectedItem?.ToString();
- Close(true);
- }
-
- private void DropdownButton_Click(object? sender, RoutedEventArgs e)
- {
- ServerDropdown.MinWidth = ServerNameGrid.Bounds.Width;
- ServerDropdown.IsOpen = !ServerDropdown.IsOpen;
- }
-
- private void Cancel_Click(object? sender, RoutedEventArgs e)
- {
- Close(false);
- }
-
- private ServerConnection BuildServerConnection()
- {
- var serverName = ServerNameBox.Text?.Trim() ?? "";
- return new ServerConnection
- {
- Id = serverName,
- ServerName = serverName,
- DisplayName = serverName,
- AuthenticationType = GetSelectedAuthType(),
- TrustServerCertificate = TrustCertBox.IsChecked == true,
- EncryptMode = GetSelectedEncryptMode()
- };
- }
-
- private string GetSelectedAuthType()
- {
- if (AuthTypeBox.SelectedItem is ComboBoxItem item)
- return item.Tag?.ToString() ?? AuthenticationTypes.Windows;
- return AuthenticationTypes.Windows;
- }
-
- private string GetSelectedEncryptMode()
- {
- if (EncryptBox.SelectedItem is ComboBoxItem item)
- return item.Tag?.ToString() ?? "Mandatory";
- return "Mandatory";
- }
-
- private string BuildConnectionString(ServerConnection connection)
- {
- var builder = new SqlConnectionStringBuilder
- {
- DataSource = connection.ServerName,
- InitialCatalog = "master",
- ApplicationName = "PlanViewer",
- ConnectTimeout = 15,
- TrustServerCertificate = connection.TrustServerCertificate,
- Encrypt = connection.EncryptMode switch
- {
- "Optional" => SqlConnectionEncryptOption.Optional,
- "Strict" => SqlConnectionEncryptOption.Strict,
- _ => SqlConnectionEncryptOption.Mandatory
- }
- };
-
- switch (connection.AuthenticationType)
- {
- case AuthenticationTypes.SqlServer:
- builder.UserID = LoginBox.Text?.Trim() ?? "";
- builder.Password = PasswordBox.Text ?? "";
- break;
- case AuthenticationTypes.EntraMFA:
- builder.Authentication = SqlAuthenticationMethod.ActiveDirectoryInteractive;
- if (!string.IsNullOrEmpty(LoginBox.Text?.Trim()))
- builder.UserID = LoginBox.Text!.Trim();
- break;
- default:
- builder.IntegratedSecurity = true;
- break;
- }
-
- return builder.ConnectionString;
- }
-}
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Microsoft.Data.SqlClient;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Dialogs;
+
+public partial class ConnectionDialog : Window
+{
+ private readonly ICredentialService _credentialService;
+ private readonly ConnectionStore _connectionStore;
+ private List _savedConnections = new();
+
+ public ServerConnection? ResultConnection { get; private set; }
+ public string? ResultDatabase { get; private set; }
+
+ public ConnectionDialog(ICredentialService credentialService, ConnectionStore connectionStore)
+ {
+ _credentialService = credentialService;
+ _connectionStore = connectionStore;
+ InitializeComponent();
+
+ AuthTypeBox.SelectedIndex = 0;
+ EncryptBox.SelectedIndex = 0;
+ PopulateSavedServers();
+ }
+
+ private void PopulateSavedServers()
+ {
+ _savedConnections = _connectionStore.Load();
+ var serverNames = _savedConnections
+ .OrderByDescending(s => s.LastConnected)
+ .Select(s => s.ServerName)
+ .Distinct()
+ .ToList();
+ ServerList.ItemsSource = serverNames;
+
+ // Pre-fill the most recently used connection
+ var mostRecent = _savedConnections
+ .OrderByDescending(s => s.LastConnected)
+ .FirstOrDefault();
+
+ if (mostRecent != null)
+ {
+ ServerNameBox.Text = mostRecent.ServerName;
+ ApplySavedConnection(mostRecent);
+ }
+ }
+
+ private void ApplySavedConnection(ServerConnection saved)
+ {
+ // Auth type
+ for (int i = 0; i < AuthTypeBox.Items.Count; i++)
+ {
+ if (AuthTypeBox.Items[i] is ComboBoxItem item &&
+ item.Tag?.ToString() == saved.AuthenticationType)
+ {
+ AuthTypeBox.SelectedIndex = i;
+ break;
+ }
+ }
+
+ // Encrypt mode
+ for (int i = 0; i < EncryptBox.Items.Count; i++)
+ {
+ if (EncryptBox.Items[i] is ComboBoxItem item &&
+ item.Tag?.ToString() == saved.EncryptMode)
+ {
+ EncryptBox.SelectedIndex = i;
+ break;
+ }
+ }
+
+ TrustCertBox.IsChecked = saved.TrustServerCertificate;
+ ReadOnlyIntentCheckBox.IsChecked = saved.ApplicationIntentReadOnly;
+
+ // Load stored credentials
+ var cred = _credentialService.GetCredential(saved.Id);
+ if (cred != null)
+ {
+ LoginBox.Text = cred.Value.Username;
+ PasswordBox.Text = cred.Value.Password;
+ }
+ }
+
+ private void ServerList_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ var serverName = ServerList.SelectedItem?.ToString();
+ if (string.IsNullOrEmpty(serverName)) return;
+
+ ServerNameBox.Text = serverName;
+ ServerDropdown.IsOpen = false;
+
+ var saved = _savedConnections.FirstOrDefault(s =>
+ s.ServerName.Equals(serverName, StringComparison.OrdinalIgnoreCase));
+
+ if (saved != null)
+ ApplySavedConnection(saved);
+ }
+
+ private void AuthType_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (AuthTypeBox.SelectedItem is not ComboBoxItem item) return;
+ var authType = item.Tag?.ToString();
+
+ var showLogin = authType is "SqlServer" or "EntraMFA";
+ var showPassword = authType == "SqlServer";
+
+ LoginPanel.IsVisible = showLogin;
+ PasswordPanel.IsVisible = showPassword;
+ }
+
+ private async void TestConnection_Click(object? sender, RoutedEventArgs e)
+ {
+ var serverName = ServerNameBox.Text?.Trim();
+ if (string.IsNullOrEmpty(serverName))
+ {
+ StatusText.Text = "Enter a server name";
+ StatusText.Foreground = Avalonia.Media.Brushes.OrangeRed;
+ return;
+ }
+
+ StatusText.Text = "Connecting...";
+ StatusText.Foreground = new Avalonia.Media.SolidColorBrush(Avalonia.Media.Color.FromRgb(0xE4, 0xE6, 0xEB));
+ TestButton.IsEnabled = false;
+
+ try
+ {
+ var connection = BuildServerConnection();
+ var connectionString = connection.GetConnectionString(
+ LoginBox.Text?.Trim(),
+ PasswordBox.Text,
+ "master");
+
+ await using var conn = new SqlConnection(connectionString);
+ await conn.OpenAsync();
+
+ // Fetch databases
+ 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;
+ ConnectButton.IsEnabled = true;
+
+ // Default to master if available
+ var masterIdx = databases.IndexOf("master");
+ if (masterIdx >= 0) DatabaseBox.SelectedIndex = masterIdx;
+
+ StatusText.Text = $"Connected ({databases.Count} databases)";
+ StatusText.Foreground = Avalonia.Media.Brushes.LimeGreen;
+ }
+ catch (Exception ex)
+ {
+ StatusText.Text = ex.Message;
+ StatusText.Foreground = Avalonia.Media.Brushes.OrangeRed;
+ DatabaseBox.IsEnabled = false;
+ ConnectButton.IsEnabled = false;
+ }
+ finally
+ {
+ TestButton.IsEnabled = true;
+ }
+ }
+
+ private void Connect_Click(object? sender, RoutedEventArgs e)
+ {
+ var connection = BuildServerConnection();
+
+ // Save credentials
+ var authType = GetSelectedAuthType();
+ if (authType == AuthenticationTypes.SqlServer)
+ {
+ var login = LoginBox.Text?.Trim() ?? "";
+ var password = PasswordBox.Text ?? "";
+ _credentialService.SaveCredential(connection.Id, login, password);
+ }
+ else if (authType == AuthenticationTypes.EntraMFA)
+ {
+ var login = LoginBox.Text?.Trim() ?? "";
+ if (!string.IsNullOrEmpty(login))
+ _credentialService.SaveCredential(connection.Id, login, "");
+ }
+
+ // Save connection to store
+ _connectionStore.AddOrUpdate(connection);
+
+ ResultConnection = connection;
+ ResultDatabase = DatabaseBox.SelectedItem?.ToString();
+ Close(true);
+ }
+
+ private void DropdownButton_Click(object? sender, RoutedEventArgs e)
+ {
+ ServerDropdown.MinWidth = ServerNameGrid.Bounds.Width;
+ ServerDropdown.IsOpen = !ServerDropdown.IsOpen;
+ }
+
+ private void Cancel_Click(object? sender, RoutedEventArgs e)
+ {
+ Close(false);
+ }
+
+ private ServerConnection BuildServerConnection()
+ {
+ var serverName = ServerNameBox.Text?.Trim() ?? "";
+ return new ServerConnection
+ {
+ Id = serverName,
+ ServerName = serverName,
+ DisplayName = serverName,
+ AuthenticationType = GetSelectedAuthType(),
+ TrustServerCertificate = TrustCertBox.IsChecked == true,
+ EncryptMode = GetSelectedEncryptMode(),
+ ApplicationIntentReadOnly = ReadOnlyIntentCheckBox.IsChecked == true
+ };
+ }
+
+ private string GetSelectedAuthType()
+ {
+ if (AuthTypeBox.SelectedItem is ComboBoxItem item)
+ return item.Tag?.ToString() ?? AuthenticationTypes.Windows;
+ return AuthenticationTypes.Windows;
+ }
+
+ private string GetSelectedEncryptMode()
+ {
+ if (EncryptBox.SelectedItem is ComboBoxItem item)
+ return item.Tag?.ToString() ?? "Mandatory";
+ return "Mandatory";
+ }
+
+}
diff --git a/src/PlanViewer.App/Helpers/IconHelper.cs b/src/PlanViewer.App/Helpers/IconHelper.cs
index b660404..bddd1d4 100644
--- a/src/PlanViewer.App/Helpers/IconHelper.cs
+++ b/src/PlanViewer.App/Helpers/IconHelper.cs
@@ -1,38 +1,38 @@
-using System.Collections.Generic;
-using System.IO;
-using Avalonia.Media.Imaging;
-using PlanViewer.Core.Services;
-
-namespace PlanViewer.App.Helpers;
-
-public static class IconHelper
-{
- private static readonly Dictionary Cache = new();
-
- public static Bitmap? LoadIcon(string iconName)
- {
- if (Cache.TryGetValue(iconName, out var cached))
- return cached;
-
- var asm = typeof(PlanIconMapper).Assembly;
- var stream = asm.GetManifestResourceStream(
- $"PlanViewer.Core.Resources.PlanIcons.{iconName}.png");
-
- Bitmap? bitmap = null;
- if (stream != null)
- {
- bitmap = new Bitmap(stream);
- }
- else
- {
- // Try fallback
- var fallback = asm.GetManifestResourceStream(
- "PlanViewer.Core.Resources.PlanIcons.iterator_catch_all.png");
- if (fallback != null)
- bitmap = new Bitmap(fallback);
- }
-
- Cache[iconName] = bitmap;
- return bitmap;
- }
-}
+using System.Collections.Generic;
+using System.IO;
+using Avalonia.Media.Imaging;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App.Helpers;
+
+public static class IconHelper
+{
+ private static readonly Dictionary Cache = new();
+
+ public static Bitmap? LoadIcon(string iconName)
+ {
+ if (Cache.TryGetValue(iconName, out var cached))
+ return cached;
+
+ var asm = typeof(PlanIconMapper).Assembly;
+ var stream = asm.GetManifestResourceStream(
+ $"PlanViewer.Core.Resources.PlanIcons.{iconName}.png");
+
+ Bitmap? bitmap = null;
+ if (stream != null)
+ {
+ bitmap = new Bitmap(stream);
+ }
+ else
+ {
+ // Try fallback
+ var fallback = asm.GetManifestResourceStream(
+ "PlanViewer.Core.Resources.PlanIcons.iterator_catch_all.png");
+ if (fallback != null)
+ bitmap = new Bitmap(fallback);
+ }
+
+ Cache[iconName] = bitmap;
+ return bitmap;
+ }
+}
diff --git a/src/PlanViewer.App/Info.plist b/src/PlanViewer.App/Info.plist
index 2cd39e7..4599f54 100644
--- a/src/PlanViewer.App/Info.plist
+++ b/src/PlanViewer.App/Info.plist
@@ -1,26 +1,26 @@
-
-
-
-
- CFBundleName
- Performance Studio
- CFBundleDisplayName
- Performance Studio
- CFBundleIdentifier
- com.darlingdata.sqlperformancestudio
- CFBundleExecutable
- PlanViewer.App
- CFBundleVersion
- 1.0.0
- CFBundleShortVersionString
- 1.0.0
- CFBundlePackageType
- APPL
- NSHumanReadableCopyright
- Copyright © 2026 Erik Darling, Darling Data LLC
- CFBundleIconFile
- EDD.icns
- NSHighResolutionCapable
-
-
-
+
+
+
+
+ CFBundleName
+ Performance Studio
+ CFBundleDisplayName
+ Performance Studio
+ CFBundleIdentifier
+ com.darlingdata.sqlperformancestudio
+ CFBundleExecutable
+ PlanViewer.App
+ CFBundleVersion
+ 1.0.0
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundlePackageType
+ APPL
+ NSHumanReadableCopyright
+ Copyright © 2026 Erik Darling, Darling Data LLC
+ CFBundleIconFile
+ EDD.icns
+ NSHighResolutionCapable
+
+
+
diff --git a/src/PlanViewer.App/MacOSDockIcon.cs b/src/PlanViewer.App/MacOSDockIcon.cs
index 28b4ba7..e450178 100644
--- a/src/PlanViewer.App/MacOSDockIcon.cs
+++ b/src/PlanViewer.App/MacOSDockIcon.cs
@@ -1,64 +1,64 @@
-using System;
-using System.Runtime.InteropServices;
-
-namespace PlanViewer.App;
-
-internal static class MacOSDockIcon
-{
- private const string ObjCLib = "/usr/lib/libobjc.A.dylib";
-
- [DllImport(ObjCLib, EntryPoint = "objc_getClass")]
- private static extern IntPtr objc_getClass(string className);
-
- [DllImport(ObjCLib, EntryPoint = "sel_registerName")]
- private static extern IntPtr sel_registerName(string selectorName);
-
- [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
- private static extern IntPtr objc_msgSend_retIntPtr(IntPtr receiver, IntPtr selector);
-
- [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
- private static extern IntPtr objc_msgSend_retIntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
-
- [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
- private static extern void objc_msgSend_void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
-
- public static void SetDockIcon(string iconFilePath)
- {
- if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
- return;
-
- try
- {
- var nsStringClass = objc_getClass("NSString");
- var allocSel = sel_registerName("alloc");
- var initWithUTF8StringSel = sel_registerName("initWithUTF8String:");
-
- var nsStringAlloc = objc_msgSend_retIntPtr(nsStringClass, allocSel);
- var pathPtr = Marshal.StringToCoTaskMemUTF8(iconFilePath);
- var nsStringPath = objc_msgSend_retIntPtr_IntPtr(
- nsStringAlloc, initWithUTF8StringSel, pathPtr);
- Marshal.FreeCoTaskMem(pathPtr);
-
- var nsImageClass = objc_getClass("NSImage");
- var initWithContentsOfFileSel = sel_registerName("initWithContentsOfFile:");
-
- var nsImageAlloc = objc_msgSend_retIntPtr(nsImageClass, allocSel);
- var nsImage = objc_msgSend_retIntPtr_IntPtr(
- nsImageAlloc, initWithContentsOfFileSel, nsStringPath);
-
- if (nsImage == IntPtr.Zero)
- return;
-
- var nsAppClass = objc_getClass("NSApplication");
- var sharedAppSel = sel_registerName("sharedApplication");
- var nsApp = objc_msgSend_retIntPtr(nsAppClass, sharedAppSel);
-
- var setIconSel = sel_registerName("setApplicationIconImage:");
- objc_msgSend_void_IntPtr(nsApp, setIconSel, nsImage);
- }
- catch
- {
- // Silently fail if native calls fail
- }
- }
-}
+using System;
+using System.Runtime.InteropServices;
+
+namespace PlanViewer.App;
+
+internal static class MacOSDockIcon
+{
+ private const string ObjCLib = "/usr/lib/libobjc.A.dylib";
+
+ [DllImport(ObjCLib, EntryPoint = "objc_getClass")]
+ private static extern IntPtr objc_getClass(string className);
+
+ [DllImport(ObjCLib, EntryPoint = "sel_registerName")]
+ private static extern IntPtr sel_registerName(string selectorName);
+
+ [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr objc_msgSend_retIntPtr(IntPtr receiver, IntPtr selector);
+
+ [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
+ private static extern IntPtr objc_msgSend_retIntPtr_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
+
+ [DllImport(ObjCLib, EntryPoint = "objc_msgSend")]
+ private static extern void objc_msgSend_void_IntPtr(IntPtr receiver, IntPtr selector, IntPtr arg1);
+
+ public static void SetDockIcon(string iconFilePath)
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
+ return;
+
+ try
+ {
+ var nsStringClass = objc_getClass("NSString");
+ var allocSel = sel_registerName("alloc");
+ var initWithUTF8StringSel = sel_registerName("initWithUTF8String:");
+
+ var nsStringAlloc = objc_msgSend_retIntPtr(nsStringClass, allocSel);
+ var pathPtr = Marshal.StringToCoTaskMemUTF8(iconFilePath);
+ var nsStringPath = objc_msgSend_retIntPtr_IntPtr(
+ nsStringAlloc, initWithUTF8StringSel, pathPtr);
+ Marshal.FreeCoTaskMem(pathPtr);
+
+ var nsImageClass = objc_getClass("NSImage");
+ var initWithContentsOfFileSel = sel_registerName("initWithContentsOfFile:");
+
+ var nsImageAlloc = objc_msgSend_retIntPtr(nsImageClass, allocSel);
+ var nsImage = objc_msgSend_retIntPtr_IntPtr(
+ nsImageAlloc, initWithContentsOfFileSel, nsStringPath);
+
+ if (nsImage == IntPtr.Zero)
+ return;
+
+ var nsAppClass = objc_getClass("NSApplication");
+ var sharedAppSel = sel_registerName("sharedApplication");
+ var nsApp = objc_msgSend_retIntPtr(nsAppClass, sharedAppSel);
+
+ var setIconSel = sel_registerName("setApplicationIconImage:");
+ objc_msgSend_void_IntPtr(nsApp, setIconSel, nsImage);
+ }
+ catch
+ {
+ // Silently fail if native calls fail
+ }
+ }
+}
diff --git a/src/PlanViewer.App/MainWindow.axaml b/src/PlanViewer.App/MainWindow.axaml
index 40e7c87..166a9a5 100644
--- a/src/PlanViewer.App/MainWindow.axaml
+++ b/src/PlanViewer.App/MainWindow.axaml
@@ -1,68 +1,68 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs
index 01315ae..fa4f4b4 100644
--- a/src/PlanViewer.App/MainWindow.axaml.cs
+++ b/src/PlanViewer.App/MainWindow.axaml.cs
@@ -1,1352 +1,1352 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.IO.Pipes;
-using System.Linq;
-using System.Text.Json;
-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 Avalonia.Platform.Storage;
-using Avalonia.Threading;
-using PlanViewer.App.Controls;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Interfaces;
-using PlanViewer.Core.Models;
-using PlanViewer.App.Mcp;
-using PlanViewer.Core.Output;
-using PlanViewer.Core.Services;
-
-namespace PlanViewer.App;
-
-public partial class MainWindow : Window
-{
- private const string PipeName = "SQLPerformanceStudio_OpenFile";
-
- private readonly ICredentialService _credentialService;
- private readonly ConnectionStore _connectionStore;
- private readonly CancellationTokenSource _pipeCts = new();
- private McpHostService? _mcpHost;
- private CancellationTokenSource? _mcpCts;
- private int _queryCounter;
- private AppSettings _appSettings;
-
- public MainWindow()
- {
- _credentialService = CredentialServiceFactory.Create();
- _connectionStore = new ConnectionStore();
- _appSettings = AppSettingsService.Load();
-
- // Listen for file paths from other instances (e.g. SSMS extension)
- StartPipeServer();
-
- InitializeComponent();
-
- // Check for updates on startup (non-blocking)
- _ = CheckForUpdatesOnStartupAsync();
-
- // Build the Recent Plans submenu from saved state
- RebuildRecentPlansMenu();
-
- // Wire up drag-and-drop
- AddHandler(DragDrop.DropEvent, OnDrop);
- AddHandler(DragDrop.DragOverEvent, OnDragOver);
-
- // Track tab changes to update empty overlay
- MainTabControl.SelectionChanged += (_, _) => UpdateEmptyOverlay();
-
- // Global hotkeys via tunnel routing so they fire before AvaloniaEdit consumes them
- AddHandler(KeyDownEvent, (_, e) =>
- {
- if (e.KeyModifiers == KeyModifiers.Control)
- {
- switch (e.Key)
- {
- case Key.N:
- NewQuery_Click(this, new RoutedEventArgs());
- e.Handled = true;
- break;
- case Key.O:
- OpenFile_Click(this, new RoutedEventArgs());
- e.Handled = true;
- break;
- case Key.W:
- if (MainTabControl.SelectedItem is TabItem selected)
- {
- MainTabControl.Items.Remove(selected);
- UpdateEmptyOverlay();
- e.Handled = true;
- }
- break;
- case Key.V:
- // Only intercept paste when focus is NOT in a text editor
- if (e.Source is not TextBox && e.Source is not AvaloniaEdit.Editing.TextArea)
- {
- _ = PasteXmlAsync();
- e.Handled = true;
- }
- break;
- }
- }
- }, RoutingStrategies.Tunnel);
-
- // Accept command-line argument or restore previously open plans
- var args = Environment.GetCommandLineArgs();
- if (args.Length > 1 && File.Exists(args[1]))
- {
- LoadPlanFile(args[1]);
- }
- else
- {
- // Restore plans that were open in the previous session
- RestoreOpenPlans();
- }
-
- // Start MCP server if enabled in settings
- StartMcpServer();
- }
-
- private void StartPipeServer()
- {
- var token = _pipeCts.Token;
- Task.Run(async () =>
- {
- while (!token.IsCancellationRequested)
- {
- try
- {
- using var server = new NamedPipeServerStream(
- PipeName, PipeDirection.In, 1,
- PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
-
- await server.WaitForConnectionAsync(token);
-
- using var reader = new StreamReader(server);
- var filePath = await reader.ReadLineAsync();
-
- if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
- {
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- OpenFileByExtension(filePath);
- Activate();
- });
- }
- }
- catch (OperationCanceledException)
- {
- break;
- }
- catch
- {
- // Pipe error — restart the listener
- }
- }
- }, token);
- }
-
- private void StartMcpServer()
- {
- var settings = McpSettings.Load();
- if (!settings.Enabled)
- {
- McpStatusMenuItem.Header = "MCP Server: Off";
- return;
- }
-
- _mcpCts = new CancellationTokenSource();
- _mcpHost = new McpHostService(
- PlanSessionManager.Instance, _connectionStore, _credentialService, settings.Port);
-
- _ = _mcpHost.StartAsync(_mcpCts.Token);
- McpStatusMenuItem.Header = $"MCP Server: Running (port {settings.Port})";
- }
-
- protected override async void OnClosed(EventArgs e)
- {
- // Save the list of currently open file-based plans for session restore
- SaveOpenPlans();
-
- _pipeCts.Cancel();
-
- if (_mcpHost != null && _mcpCts != null)
- {
- _mcpCts.Cancel();
- await _mcpHost.StopAsync(CancellationToken.None);
- _mcpHost = null;
- }
-
- base.OnClosed(e);
- }
-
- private void UpdateEmptyOverlay()
- {
- EmptyOverlay.IsVisible = MainTabControl.Items.Count == 0;
- }
-
- private void NewQuery_Click(object? sender, RoutedEventArgs e)
- {
- _queryCounter++;
- var label = $"Query {_queryCounter}";
-
- var session = new QuerySessionControl(_credentialService, _connectionStore);
- var tab = CreateTab(label, session);
-
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- }
-
- private async void OpenFile_Click(object? sender, RoutedEventArgs e)
- {
- var storage = StorageProvider;
- var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
- {
- Title = "Open File",
- AllowMultiple = true,
- FileTypeFilter = new[]
- {
- new FilePickerFileType("SQL Server Execution Plans")
- {
- Patterns = new[] { "*.sqlplan" }
- },
- new FilePickerFileType("SQL Scripts")
- {
- Patterns = new[] { "*.sql" }
- },
- new FilePickerFileType("XML Files")
- {
- Patterns = new[] { "*.xml" }
- },
- FilePickerFileTypes.All
- }
- });
-
- foreach (var file in files)
- {
- var path = file.TryGetLocalPath();
- if (path != null)
- OpenFileByExtension(path);
- }
- }
-
- private async void PasteXml_Click(object? sender, RoutedEventArgs e)
- {
- await PasteXmlAsync();
- }
-
- private void Exit_Click(object? sender, RoutedEventArgs e)
- {
- Close();
- }
-
- private void About_Click(object? sender, RoutedEventArgs e)
- {
- var about = new AboutWindow();
- about.ShowDialog(this);
- }
-
-#pragma warning disable CS0618 // Data/DataFormats.Files deprecated but IDataTransfer API differs
- private static readonly string[] _supportedExtensions = { ".sqlplan", ".xml", ".sql" };
-
- private static bool IsSupportedFile(string? path)
- {
- return path != null && _supportedExtensions.Any(ext =>
- path.EndsWith(ext, StringComparison.OrdinalIgnoreCase));
- }
-
- private void OnDragOver(object? sender, DragEventArgs e)
- {
- e.DragEffects = DragDropEffects.None;
-
- if (e.Data.Contains(DataFormats.Files))
- {
- var files = e.Data.GetFiles();
- if (files != null && files.Any(f => IsSupportedFile(f.TryGetLocalPath())))
- e.DragEffects = DragDropEffects.Copy;
- }
- }
-
- private void OnDrop(object? sender, DragEventArgs e)
- {
- if (!e.Data.Contains(DataFormats.Files)) return;
-
- var files = e.Data.GetFiles();
- if (files == null) return;
-
- foreach (var file in files)
- {
- var path = file.TryGetLocalPath();
- if (IsSupportedFile(path))
- OpenFileByExtension(path!);
- }
- }
-#pragma warning restore CS0618
-
- private void OpenFileByExtension(string filePath)
- {
- if (filePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
- LoadSqlFile(filePath);
- else
- LoadPlanFile(filePath);
- }
-
- private void LoadSqlFile(string filePath)
- {
- try
- {
- var text = File.ReadAllText(filePath);
- var fileName = Path.GetFileName(filePath);
-
- _queryCounter++;
- var session = new QuerySessionControl(_credentialService, _connectionStore);
- session.QueryEditor.Text = text;
-
- var tab = CreateTab(fileName, session);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- }
- catch (Exception ex)
- {
- var dialog = new Window
- {
- Title = "Error Opening File",
- Width = 450,
- Height = 200,
- WindowStartupLocation = WindowStartupLocation.CenterOwner,
- Content = new StackPanel
- {
- Margin = new Avalonia.Thickness(20),
- Children =
- {
- new TextBlock
- {
- Text = $"Failed to open: {Path.GetFileName(filePath)}",
- FontWeight = FontWeight.Bold,
- Margin = new Avalonia.Thickness(0, 0, 0, 10)
- },
- new TextBlock
- {
- Text = ex.Message,
- TextWrapping = TextWrapping.Wrap
- }
- }
- }
- };
- dialog.ShowDialog(this);
- }
- }
-
- private void LoadPlanFile(string filePath)
- {
- try
- {
- var xml = File.ReadAllText(filePath);
-
- // SSMS saves plans as UTF-16 with encoding="utf-16" in the XML declaration.
- // File.ReadAllText auto-detects the BOM, but the resulting C# string still
- // contains encoding="utf-16" which causes XDocument.Parse to fail.
- xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
-
- var fileName = Path.GetFileName(filePath);
-
- if (!ValidatePlanXml(xml, fileName))
- return;
-
- var viewer = new PlanViewerControl();
- viewer.SetConnectionServices(_credentialService, _connectionStore);
- viewer.LoadPlan(xml, fileName);
- viewer.SourceFilePath = filePath;
-
- // Wrap viewer with advice toolbar
- var content = CreatePlanTabContent(viewer);
-
- var tab = CreateTab(fileName, content);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
-
- // Track in recent plans list and persist
- TrackRecentPlan(filePath);
- }
- catch (Exception ex)
- {
- ShowError($"Failed to open {Path.GetFileName(filePath)}:\n\n{ex.Message}");
- }
- }
-
- private async Task PasteXmlAsync()
- {
- var clipboard = this.Clipboard;
- if (clipboard == null) return;
-
- var xml = await clipboard.TryGetTextAsync();
- if (string.IsNullOrWhiteSpace(xml))
- {
- ShowError("The clipboard does not contain any text.");
- return;
- }
-
- xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
-
- if (!ValidatePlanXml(xml, "Pasted Plan"))
- return;
-
- var viewer = new PlanViewerControl();
- viewer.SetConnectionServices(_credentialService, _connectionStore);
- viewer.LoadPlan(xml, "Pasted Plan");
-
- var content = CreatePlanTabContent(viewer);
- var tab = CreateTab("Pasted Plan", content);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- }
-
- private bool ValidatePlanXml(string xml, string label)
- {
- try
- {
- var doc = XDocument.Parse(xml);
- XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan";
- if (doc.Root?.Name.LocalName != "ShowPlanXML" &&
- doc.Descendants(ns + "ShowPlanXML").FirstOrDefault() == null)
- {
- ShowError($"{label}: XML is valid but does not appear to be a SQL Server execution plan.\n\nExpected root element: ShowPlanXML");
- return false;
- }
- return true;
- }
- catch (System.Xml.XmlException ex)
- {
- ShowError($"{label}: The XML is not valid.\n\n{ex.Message}");
- return false;
- }
- }
-
- private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
- {
- var humanBtn = new Button
- {
- Content = "\U0001f9d1 Human Advice",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- Margin = new Avalonia.Thickness(0, 0, 6, 0),
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- var robotBtn = new Button
- {
- Content = "\U0001f916 Robot Advice",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- Action showHumanAdvice = () =>
- {
- if (viewer.CurrentPlan == null) return;
- var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
- ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer);
- };
-
- Action showRobotAdvice = () =>
- {
- if (viewer.CurrentPlan == null) return;
- var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
- var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true });
- ShowAdviceWindow("Advice for Robots", json);
- };
-
- humanBtn.Click += (_, _) => showHumanAdvice();
- robotBtn.Click += (_, _) => showRobotAdvice();
-
- var compareBtn = new Button
- {
- Content = "\u2194 Compare Plans",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- Margin = new Avalonia.Thickness(6, 0, 0, 0),
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- compareBtn.Click += (_, _) => ShowCompareDialog();
-
- var separator1 = new TextBlock
- {
- Text = "|",
- VerticalAlignment = VerticalAlignment.Center,
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Margin = new Avalonia.Thickness(4, 0)
- };
-
- var copyReproBtn = new Button
- {
- Content = "\U0001f4cb Copy Repro",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- Margin = new Avalonia.Thickness(0, 0, 6, 0),
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- Func copyRepro = async () =>
- {
- if (viewer.CurrentPlan == null) return;
- var queryText = GetQueryTextFromPlan(viewer);
- var planXml = viewer.RawXml;
- var database = ExtractDatabaseFromPlanXml(planXml);
-
- var reproScript = ReproScriptBuilder.BuildReproScript(
- queryText, database, planXml,
- isolationLevel: null, source: "Performance Studio");
-
- var clipboard = this.Clipboard;
- if (clipboard != null)
- {
- await clipboard.SetTextAsync(reproScript);
- }
- };
-
- copyReproBtn.Click += async (_, _) => await copyRepro();
-
- // Wire up context menu events from PlanViewerControl
- viewer.HumanAdviceRequested += (_, _) => showHumanAdvice();
- viewer.RobotAdviceRequested += (_, _) => showRobotAdvice();
- viewer.CopyReproRequested += async (_, _) => await copyRepro();
- viewer.OpenInEditorRequested += (_, queryText) =>
- {
- _queryCounter++;
- var session = new QuerySessionControl(_credentialService, _connectionStore);
- session.QueryEditor.Text = queryText;
- var tab = CreateTab($"Query {_queryCounter}", session);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- };
-
- var getActualPlanBtn = new Button
- {
- Content = "\u25b6 Run Repro",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
-
- getActualPlanBtn.Click += async (_, _) =>
- {
- if (viewer.CurrentPlan == null) return;
- await GetActualPlanFromFile(viewer);
- };
-
- var separator2 = new TextBlock
- {
- Text = "|",
- VerticalAlignment = VerticalAlignment.Center,
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Margin = new Avalonia.Thickness(4, 0)
- };
-
- var queryStoreBtn = new Button
- {
- Content = "\U0001f4ca Query Store",
- Height = 28,
- Padding = new Avalonia.Thickness(10, 0),
- FontSize = 12,
- Margin = new Avalonia.Thickness(6, 0, 0, 0),
- VerticalContentAlignment = VerticalAlignment.Center,
- HorizontalContentAlignment = HorizontalAlignment.Center,
- Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
- };
- ToolTip.SetTip(queryStoreBtn, "Open a Query Store session");
- queryStoreBtn.Click += (_, _) =>
- {
- _queryCounter++;
- var session = new QuerySessionControl(_credentialService, _connectionStore);
- var tab = CreateTab($"Query {_queryCounter}", session);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- session.TriggerQueryStore();
- };
-
- var toolbar = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Margin = new Avalonia.Thickness(8, 6),
- Children = { humanBtn, robotBtn, compareBtn, separator1, copyReproBtn, getActualPlanBtn, separator2, queryStoreBtn }
- };
-
- var panel = new DockPanel();
- DockPanel.SetDock(toolbar, Dock.Top);
- panel.Children.Add(toolbar);
- panel.Children.Add(viewer);
-
- return panel;
- }
-
- private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
- {
- AdviceWindowHelper.Show(this, title, content, analysis, sourceViewer);
- }
-
- private List<(string label, PlanViewerControl viewer)> CollectAllPlanTabs()
- {
- var entries = new List<(string label, PlanViewerControl viewer)>();
-
- foreach (var item in MainTabControl.Items)
- {
- if (item is not TabItem tab) continue;
-
- // File-mode tabs: DockPanel containing PlanViewerControl
- if (tab.Content is DockPanel dock)
- {
- var viewer = dock.Children.OfType().FirstOrDefault();
- if (viewer?.CurrentPlan != null)
- {
- var label = GetTabLabel(tab);
- entries.Add((label, viewer));
- }
- }
-
- // Query session tabs: iterate sub-tabs
- if (tab.Content is QuerySessionControl session)
- {
- var sessionLabel = GetTabLabel(tab);
- foreach (var (planLabel, viewer) in session.GetPlanTabs())
- {
- entries.Add(($"{sessionLabel} > {planLabel}", viewer));
- }
- }
- }
-
- return entries;
- }
-
- private static string GetTabLabel(TabItem tab)
- {
- if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
- return tb.Text ?? "Tab";
- if (tab.Header is string s)
- return s;
- return "Tab";
- }
-
- private void ShowCompareDialog()
- {
- var planTabs = CollectAllPlanTabs();
- if (planTabs.Count < 2)
- {
- // Not enough plans to compare
- return;
- }
-
- var items = planTabs.Select(t => t.label).ToList();
-
- var comboA = new ComboBox
- {
- ItemsSource = items,
- SelectedIndex = 0,
- Width = 250,
- Height = 28,
- FontSize = 12,
- Margin = new Avalonia.Thickness(8, 0, 0, 0)
- };
-
- var comboB = new ComboBox
- {
- ItemsSource = items,
- SelectedIndex = items.Count > 1 ? 1 : 0,
- Width = 250,
- Height = 28,
- FontSize = 12,
- Margin = new Avalonia.Thickness(8, 0, 0, 0)
- };
-
- var compareBtn = new Button
- {
- Content = "Compare",
- Height = 32,
- 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,
- 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")!
- };
-
- void UpdateCompareEnabled()
- {
- compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0
- && comboA.SelectedIndex != comboB.SelectedIndex;
- }
-
- comboA.SelectionChanged += (_, _) => UpdateCompareEnabled();
- comboB.SelectionChanged += (_, _) => UpdateCompareEnabled();
- UpdateCompareEnabled();
-
- var rowA = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Margin = new Avalonia.Thickness(0, 0, 0, 8),
- Children =
- {
- new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
- comboA
- }
- };
-
- var rowB = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- Children =
- {
- new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
- comboB
- }
- };
-
- var buttonPanel = new StackPanel
- {
- Orientation = Orientation.Horizontal,
- HorizontalAlignment = HorizontalAlignment.Right,
- Margin = new Avalonia.Thickness(0, 16, 0, 0),
- Children = { compareBtn, cancelBtn }
- };
-
- var content = new StackPanel
- {
- Margin = new Avalonia.Thickness(20),
- Children =
- {
- new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) },
- rowA,
- rowB,
- buttonPanel
- }
- };
-
- var dialog = new Window
- {
- Title = "Compare Plans",
- Width = 420,
- Height = 220,
- MinWidth = 420,
- MinHeight = 220,
- Icon = this.Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = content,
- WindowStartupLocation = WindowStartupLocation.CenterOwner
- };
-
- compareBtn.Click += (_, _) =>
- {
- var idxA = comboA.SelectedIndex;
- var idxB = comboB.SelectedIndex;
- if (idxA < 0 || idxB < 0 || idxA == idxB) return;
-
- var (labelA, viewerA) = planTabs[idxA];
- var (labelB, viewerB) = planTabs[idxB];
-
- var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "file");
- var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "file");
-
- var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB);
- dialog.Close();
- ShowAdviceWindow("Plan Comparison", comparison);
- };
-
- cancelBtn.Click += (_, _) => dialog.Close();
-
- dialog.ShowDialog(this);
- }
-
- private TabItem CreateTab(string label, Control content)
- {
- 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 = content };
- closeBtn.Tag = tab;
- closeBtn.Click += CloseTab_Click;
-
- // Right-click context menu
- var copyPathItem = new MenuItem { Header = "Copy Path", Tag = tab };
- // Only visible when tab content has a file path
- var filePath = GetTabFilePath(tab);
- copyPathItem.IsVisible = filePath != null;
-
- var contextMenu = new ContextMenu
- {
- Items =
- {
- new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } },
- copyPathItem,
- 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())
- item.Click += TabContextMenu_Click;
-
- header.ContextMenu = contextMenu;
-
- return tab;
- }
-
- private void CloseTab_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is Button btn && btn.Tag is TabItem tab)
- {
- MainTabControl.Items.Remove(tab);
- UpdateEmptyOverlay();
- }
- }
-
- private void TabContextMenu_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is not MenuItem item) return;
-
- var headerText = item.Header?.ToString();
-
- switch (headerText)
- {
- case "Rename Tab":
- if (item.Tag is object[] parts)
- StartRename((StackPanel)parts[0], (TextBlock)parts[1]);
- break;
-
- case "Copy Path":
- if (item.Tag is TabItem pathTab)
- {
- var path = GetTabFilePath(pathTab);
- if (path != null)
- _ = this.Clipboard?.SetTextAsync(path);
- }
- break;
-
- case "Close":
- if (item.Tag is TabItem tab)
- {
- MainTabControl.Items.Remove(tab);
- UpdateEmptyOverlay();
- }
- break;
-
- case "Close Other Tabs":
- if (item.Tag is TabItem keepTab)
- {
- var others = MainTabControl.Items.Cast().Where(t => t != keepTab).ToList();
- foreach (var t in others)
- MainTabControl.Items.Remove(t);
- MainTabControl.SelectedItem = keepTab;
- UpdateEmptyOverlay();
- }
- break;
-
- case "Close All Tabs":
- MainTabControl.Items.Clear();
- UpdateEmptyOverlay();
- break;
- }
- }
-
- private static string? GetTabFilePath(TabItem tab)
- {
- // Plans opened from file are wrapped in a DockPanel with the viewer as the last child
- if (tab.Content is DockPanel dp)
- {
- foreach (var child in dp.Children)
- {
- if (child is PlanViewerControl v)
- return v.SourceFilePath;
- }
- }
- return null;
- }
-
- private void StartRename(StackPanel header, TextBlock headerText)
- {
- var textBox = new TextBox
- {
- Text = headerText.Text,
- FontSize = 12,
- MinWidth = 80,
- Padding = new Avalonia.Thickness(2, 0),
- VerticalAlignment = VerticalAlignment.Center
- };
-
- // Hide the text, show the textbox
- headerText.IsVisible = false;
- header.Children.Insert(0, textBox);
- textBox.Focus();
- textBox.SelectAll();
-
- void CommitRename()
- {
- var newName = textBox.Text?.Trim();
- if (!string.IsNullOrEmpty(newName))
- headerText.Text = newName;
-
- headerText.IsVisible = true;
- header.Children.Remove(textBox);
- }
-
- textBox.KeyDown += (_, ke) =>
- {
- if (ke.Key == Key.Enter || ke.Key == Key.Escape)
- {
- if (ke.Key == Key.Escape)
- textBox.Text = headerText.Text; // revert
- CommitRename();
- ke.Handled = true;
- }
- };
-
- textBox.LostFocus += (_, _) => CommitRename();
- }
-
- ///
- /// Gets query text from a PlanViewerControl — uses QueryText if set,
- /// otherwise concatenates StatementText from all parsed statements.
- ///
- private static string GetQueryTextFromPlan(PlanViewerControl viewer)
- {
- if (!string.IsNullOrEmpty(viewer.QueryText))
- return viewer.QueryText;
-
- if (viewer.CurrentPlan == null)
- return "";
-
- var statements = viewer.CurrentPlan.Batches
- .SelectMany(b => b.Statements)
- .Select(s => s.StatementText)
- .Where(t => !string.IsNullOrEmpty(t));
-
- return string.Join(Environment.NewLine, statements);
- }
-
- ///
- /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute.
- ///
- 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";
- var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault();
- var dbContext = stmt?.Attribute("DatabaseContext")?.Value;
- if (!string.IsNullOrEmpty(dbContext))
- return dbContext.Trim('[', ']');
- }
- catch { }
-
- return null;
- }
-
- ///
- /// Prompts for a connection, then executes the query from the plan to get an actual plan.
- ///
- private async Task GetActualPlanFromFile(PlanViewerControl viewer)
- {
- var queryText = GetQueryTextFromPlan(viewer);
- if (string.IsNullOrEmpty(queryText))
- {
- ShowError("No query text available in this plan.");
- return;
- }
-
- // Show connection dialog
- var dialog = new Dialogs.ConnectionDialog(_credentialService, _connectionStore);
- var result = await dialog.ShowDialog(this);
- if (result != true || dialog.ResultConnection == null)
- return;
-
- var database = dialog.ResultDatabase ?? ExtractDatabaseFromPlanXml(viewer.RawXml);
- var connectionString = dialog.ResultConnection.GetConnectionString(_credentialService, database);
- var isAzure = dialog.ResultConnection.ServerName.Contains(".database.windows.net",
- StringComparison.OrdinalIgnoreCase) ||
- dialog.ResultConnection.ServerName.Contains(".database.azure.com",
- StringComparison.OrdinalIgnoreCase);
-
- // Create a loading placeholder tab immediately
- 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 statusText = new TextBlock
- {
- Text = "Executing query...",
- 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")!
- };
-
- loadingPanel.Children.Add(progressBar);
- loadingPanel.Children.Add(statusText);
- loadingPanel.Children.Add(cancelBtn);
-
- var cts = new System.Threading.CancellationTokenSource();
- cancelBtn.Click += (_, _) => cts.Cancel();
-
- var loadingContainer = new Grid
- {
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Focusable = true,
- Children = { loadingPanel }
- };
- loadingContainer.KeyDown += (_, ke) =>
- {
- if (ke.Key == Avalonia.Input.Key.Escape) { cts.Cancel(); ke.Handled = true; }
- };
-
- var tab = CreateTab("Actual Plan", loadingContainer);
- MainTabControl.Items.Add(tab);
- MainTabControl.SelectedItem = tab;
- UpdateEmptyOverlay();
- loadingContainer.Focus();
-
- try
- {
- // Fetch server metadata for advice and Plan Insights
- ServerMetadata? metadata = null;
- try
- {
- metadata = await ServerMetadataService.FetchServerMetadataAsync(
- connectionString, isAzure);
- metadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync(
- connectionString, metadata.SupportsScopedConfigs);
- }
- catch { /* Non-fatal — advice will just lack server context */ }
-
- statusText.Text = "Capturing actual plan...";
-
- var sw = System.Diagnostics.Stopwatch.StartNew();
-
- var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync(
- connectionString, database, queryText,
- viewer.RawXml, isolationLevel: null,
- isAzureSqlDb: isAzure, timeoutSeconds: 0, cts.Token);
-
- sw.Stop();
-
- if (string.IsNullOrEmpty(actualPlanXml))
- {
- statusText.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s).";
- progressBar.IsVisible = false;
- return;
- }
-
- // Replace loading content with the actual plan
- var actualViewer = new PlanViewerControl();
- actualViewer.Metadata = metadata;
- actualViewer.LoadPlan(actualPlanXml, "Actual Plan", queryText);
-
- tab.Content = CreatePlanTabContent(actualViewer);
- }
- catch (Exception ex)
- {
- statusText.Text = $"Error: {ex.Message}";
- progressBar.IsVisible = false;
- }
- }
-
- // ── Recent Plans & Session Restore ────────────────────────────────────
-
- ///
- /// Adds a file path to the recent plans list, saves settings, and rebuilds the menu.
- ///
- private void TrackRecentPlan(string filePath)
- {
- AppSettingsService.AddRecentPlan(_appSettings, filePath);
- AppSettingsService.Save(_appSettings);
- RebuildRecentPlansMenu();
- }
-
- ///
- /// Rebuilds the Recent Plans submenu from the current settings.
- /// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator.
- ///
- private void RebuildRecentPlansMenu()
- {
- RecentPlansMenu.Items.Clear();
-
- if (_appSettings.RecentPlans.Count == 0)
- {
- var emptyItem = new MenuItem
- {
- Header = "(empty)",
- IsEnabled = false
- };
- RecentPlansMenu.Items.Add(emptyItem);
- return;
- }
-
- foreach (var path in _appSettings.RecentPlans)
- {
- var fileName = Path.GetFileName(path);
- var directory = Path.GetDirectoryName(path) ?? "";
-
- // Show "filename — directory" so the user can distinguish same-named files
- var displayText = string.IsNullOrEmpty(directory)
- ? fileName
- : $"{fileName} — {directory}";
-
- var item = new MenuItem
- {
- Header = displayText,
- Tag = path
- };
-
- item.Click += RecentPlanItem_Click;
- RecentPlansMenu.Items.Add(item);
- }
-
- RecentPlansMenu.Items.Add(new Separator());
-
- var clearItem = new MenuItem { Header = "Clear Recent Plans" };
- clearItem.Click += ClearRecentPlans_Click;
- RecentPlansMenu.Items.Add(clearItem);
- }
-
- private void RecentPlanItem_Click(object? sender, RoutedEventArgs e)
- {
- if (sender is not MenuItem item || item.Tag is not string path)
- return;
-
- if (!File.Exists(path))
- {
- // File was moved or deleted — remove from the list and notify the user
- AppSettingsService.RemoveRecentPlan(_appSettings, path);
- AppSettingsService.Save(_appSettings);
- RebuildRecentPlansMenu();
-
- ShowError($"The file no longer exists and has been removed from recent plans:\n\n{path}");
- return;
- }
-
- LoadPlanFile(path);
- }
-
- private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e)
- {
- _appSettings.RecentPlans.Clear();
- AppSettingsService.Save(_appSettings);
- RebuildRecentPlansMenu();
- }
-
- ///
- /// Saves the file paths of all currently open file-based plan tabs.
- ///
- private void SaveOpenPlans()
- {
- _appSettings.OpenPlans.Clear();
-
- foreach (var item in MainTabControl.Items)
- {
- if (item is not TabItem tab) continue;
-
- var path = GetTabFilePath(tab);
- if (!string.IsNullOrEmpty(path))
- _appSettings.OpenPlans.Add(path);
- }
-
- AppSettingsService.Save(_appSettings);
- }
-
- ///
- /// Restores plan tabs from the previous session. Skips files that no longer exist.
- /// Falls back to a new query tab if nothing was restored.
- ///
- private void RestoreOpenPlans()
- {
- var restored = false;
-
- foreach (var path in _appSettings.OpenPlans)
- {
- if (File.Exists(path))
- {
- LoadPlanFile(path);
- restored = true;
- }
- }
-
- // Clear the open plans list now that we've restored
- _appSettings.OpenPlans.Clear();
- AppSettingsService.Save(_appSettings);
-
- if (!restored)
- {
- // Nothing to restore — open a fresh query editor like before
- NewQuery_Click(this, new RoutedEventArgs());
- }
- }
-
- private void ShowError(string message)
- {
- var dialog = new Window
- {
- Title = "Performance Studio",
- Width = 450,
- Height = 200,
- WindowStartupLocation = WindowStartupLocation.CenterOwner,
- Icon = this.Icon,
- Background = new SolidColorBrush(Color.Parse("#1A1D23")),
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
- Content = new StackPanel
- {
- Margin = new Avalonia.Thickness(20),
- Children =
- {
- new TextBlock
- {
- Text = message,
- TextWrapping = TextWrapping.Wrap,
- FontSize = 13,
- Foreground = new SolidColorBrush(Color.Parse("#E4E6EB"))
- }
- }
- }
- };
- dialog.ShowDialog(this);
- }
-
- private async Task CheckForUpdatesOnStartupAsync()
- {
- try
- {
- await Task.Delay(5000); // Don't slow down startup
-
- if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
- System.Runtime.InteropServices.OSPlatform.Windows))
- {
- try
- {
- var mgr = new Velopack.UpdateManager(
- new Velopack.Sources.GithubSource(
- "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
-
- var update = await mgr.CheckForUpdatesAsync();
- if (update != null)
- {
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- Title = $"Performance Studio — Update v{update.TargetFullRelease.Version} available (Help > About)";
- });
- return;
- }
- }
- catch
- {
- // Velopack not available — fall through
- }
- }
-
- var currentVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
- ?? new Version(0, 0, 0);
- var result = await UpdateChecker.CheckAsync(currentVersion);
- if (result.UpdateAvailable)
- {
- await Dispatcher.UIThread.InvokeAsync(() =>
- {
- Title = $"Performance Studio — Update {result.LatestVersion} available (Help > About)";
- });
- }
- }
- catch
- {
- // Never crash on update check
- }
- }
-}
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.IO.Pipes;
+using System.Linq;
+using System.Text.Json;
+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 Avalonia.Platform.Storage;
+using Avalonia.Threading;
+using PlanViewer.App.Controls;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.App.Mcp;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+namespace PlanViewer.App;
+
+public partial class MainWindow : Window
+{
+ private const string PipeName = "SQLPerformanceStudio_OpenFile";
+
+ private readonly ICredentialService _credentialService;
+ private readonly ConnectionStore _connectionStore;
+ private readonly CancellationTokenSource _pipeCts = new();
+ private McpHostService? _mcpHost;
+ private CancellationTokenSource? _mcpCts;
+ private int _queryCounter;
+ private AppSettings _appSettings;
+
+ public MainWindow()
+ {
+ _credentialService = CredentialServiceFactory.Create();
+ _connectionStore = new ConnectionStore();
+ _appSettings = AppSettingsService.Load();
+
+ // Listen for file paths from other instances (e.g. SSMS extension)
+ StartPipeServer();
+
+ InitializeComponent();
+
+ // Check for updates on startup (non-blocking)
+ _ = CheckForUpdatesOnStartupAsync();
+
+ // Build the Recent Plans submenu from saved state
+ RebuildRecentPlansMenu();
+
+ // Wire up drag-and-drop
+ AddHandler(DragDrop.DropEvent, OnDrop);
+ AddHandler(DragDrop.DragOverEvent, OnDragOver);
+
+ // Track tab changes to update empty overlay
+ MainTabControl.SelectionChanged += (_, _) => UpdateEmptyOverlay();
+
+ // Global hotkeys via tunnel routing so they fire before AvaloniaEdit consumes them
+ AddHandler(KeyDownEvent, (_, e) =>
+ {
+ if (e.KeyModifiers == KeyModifiers.Control)
+ {
+ switch (e.Key)
+ {
+ case Key.N:
+ NewQuery_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ break;
+ case Key.O:
+ OpenFile_Click(this, new RoutedEventArgs());
+ e.Handled = true;
+ break;
+ case Key.W:
+ if (MainTabControl.SelectedItem is TabItem selected)
+ {
+ MainTabControl.Items.Remove(selected);
+ UpdateEmptyOverlay();
+ e.Handled = true;
+ }
+ break;
+ case Key.V:
+ // Only intercept paste when focus is NOT in a text editor
+ if (e.Source is not TextBox && e.Source is not AvaloniaEdit.Editing.TextArea)
+ {
+ _ = PasteXmlAsync();
+ e.Handled = true;
+ }
+ break;
+ }
+ }
+ }, RoutingStrategies.Tunnel);
+
+ // Accept command-line argument or restore previously open plans
+ var args = Environment.GetCommandLineArgs();
+ if (args.Length > 1 && File.Exists(args[1]))
+ {
+ LoadPlanFile(args[1]);
+ }
+ else
+ {
+ // Restore plans that were open in the previous session
+ RestoreOpenPlans();
+ }
+
+ // Start MCP server if enabled in settings
+ StartMcpServer();
+ }
+
+ private void StartPipeServer()
+ {
+ var token = _pipeCts.Token;
+ Task.Run(async () =>
+ {
+ while (!token.IsCancellationRequested)
+ {
+ try
+ {
+ using var server = new NamedPipeServerStream(
+ PipeName, PipeDirection.In, 1,
+ PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
+
+ await server.WaitForConnectionAsync(token);
+
+ using var reader = new StreamReader(server);
+ var filePath = await reader.ReadLineAsync();
+
+ if (!string.IsNullOrWhiteSpace(filePath) && File.Exists(filePath))
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ OpenFileByExtension(filePath);
+ Activate();
+ });
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch
+ {
+ // Pipe error — restart the listener
+ }
+ }
+ }, token);
+ }
+
+ private void StartMcpServer()
+ {
+ var settings = McpSettings.Load();
+ if (!settings.Enabled)
+ {
+ McpStatusMenuItem.Header = "MCP Server: Off";
+ return;
+ }
+
+ _mcpCts = new CancellationTokenSource();
+ _mcpHost = new McpHostService(
+ PlanSessionManager.Instance, _connectionStore, _credentialService, settings.Port);
+
+ _ = _mcpHost.StartAsync(_mcpCts.Token);
+ McpStatusMenuItem.Header = $"MCP Server: Running (port {settings.Port})";
+ }
+
+ protected override async void OnClosed(EventArgs e)
+ {
+ // Save the list of currently open file-based plans for session restore
+ SaveOpenPlans();
+
+ _pipeCts.Cancel();
+
+ if (_mcpHost != null && _mcpCts != null)
+ {
+ _mcpCts.Cancel();
+ await _mcpHost.StopAsync(CancellationToken.None);
+ _mcpHost = null;
+ }
+
+ base.OnClosed(e);
+ }
+
+ private void UpdateEmptyOverlay()
+ {
+ EmptyOverlay.IsVisible = MainTabControl.Items.Count == 0;
+ }
+
+ private void NewQuery_Click(object? sender, RoutedEventArgs e)
+ {
+ _queryCounter++;
+ var label = $"Query {_queryCounter}";
+
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ var tab = CreateTab(label, session);
+
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ }
+
+ private async void OpenFile_Click(object? sender, RoutedEventArgs e)
+ {
+ var storage = StorageProvider;
+ var files = await storage.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Open File",
+ AllowMultiple = true,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("SQL Server Execution Plans")
+ {
+ Patterns = new[] { "*.sqlplan" }
+ },
+ new FilePickerFileType("SQL Scripts")
+ {
+ Patterns = new[] { "*.sql" }
+ },
+ new FilePickerFileType("XML Files")
+ {
+ Patterns = new[] { "*.xml" }
+ },
+ FilePickerFileTypes.All
+ }
+ });
+
+ foreach (var file in files)
+ {
+ var path = file.TryGetLocalPath();
+ if (path != null)
+ OpenFileByExtension(path);
+ }
+ }
+
+ private async void PasteXml_Click(object? sender, RoutedEventArgs e)
+ {
+ await PasteXmlAsync();
+ }
+
+ private void Exit_Click(object? sender, RoutedEventArgs e)
+ {
+ Close();
+ }
+
+ private void About_Click(object? sender, RoutedEventArgs e)
+ {
+ var about = new AboutWindow();
+ about.ShowDialog(this);
+ }
+
+#pragma warning disable CS0618 // Data/DataFormats.Files deprecated but IDataTransfer API differs
+ private static readonly string[] _supportedExtensions = { ".sqlplan", ".xml", ".sql" };
+
+ private static bool IsSupportedFile(string? path)
+ {
+ return path != null && _supportedExtensions.Any(ext =>
+ path.EndsWith(ext, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void OnDragOver(object? sender, DragEventArgs e)
+ {
+ e.DragEffects = DragDropEffects.None;
+
+ if (e.Data.Contains(DataFormats.Files))
+ {
+ var files = e.Data.GetFiles();
+ if (files != null && files.Any(f => IsSupportedFile(f.TryGetLocalPath())))
+ e.DragEffects = DragDropEffects.Copy;
+ }
+ }
+
+ private void OnDrop(object? sender, DragEventArgs e)
+ {
+ if (!e.Data.Contains(DataFormats.Files)) return;
+
+ var files = e.Data.GetFiles();
+ if (files == null) return;
+
+ foreach (var file in files)
+ {
+ var path = file.TryGetLocalPath();
+ if (IsSupportedFile(path))
+ OpenFileByExtension(path!);
+ }
+ }
+#pragma warning restore CS0618
+
+ private void OpenFileByExtension(string filePath)
+ {
+ if (filePath.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
+ LoadSqlFile(filePath);
+ else
+ LoadPlanFile(filePath);
+ }
+
+ private void LoadSqlFile(string filePath)
+ {
+ try
+ {
+ var text = File.ReadAllText(filePath);
+ var fileName = Path.GetFileName(filePath);
+
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ session.QueryEditor.Text = text;
+
+ var tab = CreateTab(fileName, session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ }
+ catch (Exception ex)
+ {
+ var dialog = new Window
+ {
+ Title = "Error Opening File",
+ Width = 450,
+ Height = 200,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = $"Failed to open: {Path.GetFileName(filePath)}",
+ FontWeight = FontWeight.Bold,
+ Margin = new Avalonia.Thickness(0, 0, 0, 10)
+ },
+ new TextBlock
+ {
+ Text = ex.Message,
+ TextWrapping = TextWrapping.Wrap
+ }
+ }
+ }
+ };
+ dialog.ShowDialog(this);
+ }
+ }
+
+ private void LoadPlanFile(string filePath)
+ {
+ try
+ {
+ var xml = File.ReadAllText(filePath);
+
+ // SSMS saves plans as UTF-16 with encoding="utf-16" in the XML declaration.
+ // File.ReadAllText auto-detects the BOM, but the resulting C# string still
+ // contains encoding="utf-16" which causes XDocument.Parse to fail.
+ xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
+
+ var fileName = Path.GetFileName(filePath);
+
+ if (!ValidatePlanXml(xml, fileName))
+ return;
+
+ var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ viewer.LoadPlan(xml, fileName);
+ viewer.SourceFilePath = filePath;
+
+ // Wrap viewer with advice toolbar
+ var content = CreatePlanTabContent(viewer);
+
+ var tab = CreateTab(fileName, content);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+
+ // Track in recent plans list and persist
+ TrackRecentPlan(filePath);
+ }
+ catch (Exception ex)
+ {
+ ShowError($"Failed to open {Path.GetFileName(filePath)}:\n\n{ex.Message}");
+ }
+ }
+
+ private async Task PasteXmlAsync()
+ {
+ var clipboard = this.Clipboard;
+ if (clipboard == null) return;
+
+ var xml = await clipboard.TryGetTextAsync();
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ ShowError("The clipboard does not contain any text.");
+ return;
+ }
+
+ xml = xml.Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
+
+ if (!ValidatePlanXml(xml, "Pasted Plan"))
+ return;
+
+ var viewer = new PlanViewerControl();
+ viewer.SetConnectionServices(_credentialService, _connectionStore);
+ viewer.LoadPlan(xml, "Pasted Plan");
+
+ var content = CreatePlanTabContent(viewer);
+ var tab = CreateTab("Pasted Plan", content);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ }
+
+ private bool ValidatePlanXml(string xml, string label)
+ {
+ try
+ {
+ var doc = XDocument.Parse(xml);
+ XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan";
+ if (doc.Root?.Name.LocalName != "ShowPlanXML" &&
+ doc.Descendants(ns + "ShowPlanXML").FirstOrDefault() == null)
+ {
+ ShowError($"{label}: XML is valid but does not appear to be a SQL Server execution plan.\n\nExpected root element: ShowPlanXML");
+ return false;
+ }
+ return true;
+ }
+ catch (System.Xml.XmlException ex)
+ {
+ ShowError($"{label}: The XML is not valid.\n\n{ex.Message}");
+ return false;
+ }
+ }
+
+ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
+ {
+ var humanBtn = new Button
+ {
+ Content = "\U0001f9d1 Human Advice",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(0, 0, 6, 0),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ var robotBtn = new Button
+ {
+ Content = "\U0001f916 Robot Advice",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ Action showHumanAdvice = () =>
+ {
+ if (viewer.CurrentPlan == null) return;
+ var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
+ ShowAdviceWindow("Advice for Humans", TextFormatter.Format(analysis), analysis, viewer);
+ };
+
+ Action showRobotAdvice = () =>
+ {
+ if (viewer.CurrentPlan == null) return;
+ var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
+ var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true });
+ ShowAdviceWindow("Advice for Robots", json);
+ };
+
+ humanBtn.Click += (_, _) => showHumanAdvice();
+ robotBtn.Click += (_, _) => showRobotAdvice();
+
+ var compareBtn = new Button
+ {
+ Content = "\u2194 Compare Plans",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ compareBtn.Click += (_, _) => ShowCompareDialog();
+
+ var separator1 = new TextBlock
+ {
+ Text = "|",
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Margin = new Avalonia.Thickness(4, 0)
+ };
+
+ var copyReproBtn = new Button
+ {
+ Content = "\U0001f4cb Copy Repro",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(0, 0, 6, 0),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ Func copyRepro = async () =>
+ {
+ if (viewer.CurrentPlan == null) return;
+ var queryText = GetQueryTextFromPlan(viewer);
+ var planXml = viewer.RawXml;
+ var database = ExtractDatabaseFromPlanXml(planXml);
+
+ var reproScript = ReproScriptBuilder.BuildReproScript(
+ queryText, database, planXml,
+ isolationLevel: null, source: "Performance Studio");
+
+ var clipboard = this.Clipboard;
+ if (clipboard != null)
+ {
+ await clipboard.SetTextAsync(reproScript);
+ }
+ };
+
+ copyReproBtn.Click += async (_, _) => await copyRepro();
+
+ // Wire up context menu events from PlanViewerControl
+ viewer.HumanAdviceRequested += (_, _) => showHumanAdvice();
+ viewer.RobotAdviceRequested += (_, _) => showRobotAdvice();
+ viewer.CopyReproRequested += async (_, _) => await copyRepro();
+ viewer.OpenInEditorRequested += (_, queryText) =>
+ {
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ session.QueryEditor.Text = queryText;
+ var tab = CreateTab($"Query {_queryCounter}", session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ };
+
+ var getActualPlanBtn = new Button
+ {
+ Content = "\u25b6 Run Repro",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+
+ getActualPlanBtn.Click += async (_, _) =>
+ {
+ if (viewer.CurrentPlan == null) return;
+ await GetActualPlanFromFile(viewer);
+ };
+
+ var separator2 = new TextBlock
+ {
+ Text = "|",
+ VerticalAlignment = VerticalAlignment.Center,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Margin = new Avalonia.Thickness(4, 0)
+ };
+
+ var queryStoreBtn = new Button
+ {
+ Content = "\U0001f4ca Query Store",
+ Height = 28,
+ Padding = new Avalonia.Thickness(10, 0),
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(6, 0, 0, 0),
+ VerticalContentAlignment = VerticalAlignment.Center,
+ HorizontalContentAlignment = HorizontalAlignment.Center,
+ Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")!
+ };
+ ToolTip.SetTip(queryStoreBtn, "Open a Query Store session");
+ queryStoreBtn.Click += (_, _) =>
+ {
+ _queryCounter++;
+ var session = new QuerySessionControl(_credentialService, _connectionStore);
+ var tab = CreateTab($"Query {_queryCounter}", session);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ session.TriggerQueryStore();
+ };
+
+ var toolbar = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Margin = new Avalonia.Thickness(8, 6),
+ Children = { humanBtn, robotBtn, compareBtn, separator1, copyReproBtn, getActualPlanBtn, separator2, queryStoreBtn }
+ };
+
+ var panel = new DockPanel();
+ DockPanel.SetDock(toolbar, Dock.Top);
+ panel.Children.Add(toolbar);
+ panel.Children.Add(viewer);
+
+ return panel;
+ }
+
+ private void ShowAdviceWindow(string title, string content, AnalysisResult? analysis = null, PlanViewerControl? sourceViewer = null)
+ {
+ AdviceWindowHelper.Show(this, title, content, analysis, sourceViewer);
+ }
+
+ private List<(string label, PlanViewerControl viewer)> CollectAllPlanTabs()
+ {
+ var entries = new List<(string label, PlanViewerControl viewer)>();
+
+ foreach (var item in MainTabControl.Items)
+ {
+ if (item is not TabItem tab) continue;
+
+ // File-mode tabs: DockPanel containing PlanViewerControl
+ if (tab.Content is DockPanel dock)
+ {
+ var viewer = dock.Children.OfType().FirstOrDefault();
+ if (viewer?.CurrentPlan != null)
+ {
+ var label = GetTabLabel(tab);
+ entries.Add((label, viewer));
+ }
+ }
+
+ // Query session tabs: iterate sub-tabs
+ if (tab.Content is QuerySessionControl session)
+ {
+ var sessionLabel = GetTabLabel(tab);
+ foreach (var (planLabel, viewer) in session.GetPlanTabs())
+ {
+ entries.Add(($"{sessionLabel} > {planLabel}", viewer));
+ }
+ }
+ }
+
+ return entries;
+ }
+
+ private static string GetTabLabel(TabItem tab)
+ {
+ if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
+ return tb.Text ?? "Tab";
+ if (tab.Header is string s)
+ return s;
+ return "Tab";
+ }
+
+ private void ShowCompareDialog()
+ {
+ var planTabs = CollectAllPlanTabs();
+ if (planTabs.Count < 2)
+ {
+ // Not enough plans to compare
+ return;
+ }
+
+ var items = planTabs.Select(t => t.label).ToList();
+
+ var comboA = new ComboBox
+ {
+ ItemsSource = items,
+ SelectedIndex = 0,
+ Width = 250,
+ Height = 28,
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0)
+ };
+
+ var comboB = new ComboBox
+ {
+ ItemsSource = items,
+ SelectedIndex = items.Count > 1 ? 1 : 0,
+ Width = 250,
+ Height = 28,
+ FontSize = 12,
+ Margin = new Avalonia.Thickness(8, 0, 0, 0)
+ };
+
+ var compareBtn = new Button
+ {
+ Content = "Compare",
+ Height = 32,
+ 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,
+ 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")!
+ };
+
+ void UpdateCompareEnabled()
+ {
+ compareBtn.IsEnabled = comboA.SelectedIndex >= 0 && comboB.SelectedIndex >= 0
+ && comboA.SelectedIndex != comboB.SelectedIndex;
+ }
+
+ comboA.SelectionChanged += (_, _) => UpdateCompareEnabled();
+ comboB.SelectionChanged += (_, _) => UpdateCompareEnabled();
+ UpdateCompareEnabled();
+
+ var rowA = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Margin = new Avalonia.Thickness(0, 0, 0, 8),
+ Children =
+ {
+ new TextBlock { Text = "Plan A:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
+ comboA
+ }
+ };
+
+ var rowB = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Children =
+ {
+ new TextBlock { Text = "Plan B:", VerticalAlignment = VerticalAlignment.Center, FontSize = 13, Width = 55 },
+ comboB
+ }
+ };
+
+ var buttonPanel = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Avalonia.Thickness(0, 16, 0, 0),
+ Children = { compareBtn, cancelBtn }
+ };
+
+ var content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock { Text = "Select two plans to compare:", FontSize = 14, Margin = new Avalonia.Thickness(0, 0, 0, 12) },
+ rowA,
+ rowB,
+ buttonPanel
+ }
+ };
+
+ var dialog = new Window
+ {
+ Title = "Compare Plans",
+ Width = 420,
+ Height = 220,
+ MinWidth = 420,
+ MinHeight = 220,
+ Icon = this.Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = content,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ compareBtn.Click += (_, _) =>
+ {
+ var idxA = comboA.SelectedIndex;
+ var idxB = comboB.SelectedIndex;
+ if (idxA < 0 || idxB < 0 || idxA == idxB) return;
+
+ var (labelA, viewerA) = planTabs[idxA];
+ var (labelB, viewerB) = planTabs[idxB];
+
+ var analysisA = ResultMapper.Map(viewerA.CurrentPlan!, "file");
+ var analysisB = ResultMapper.Map(viewerB.CurrentPlan!, "file");
+
+ var comparison = ComparisonFormatter.Compare(analysisA, analysisB, labelA, labelB);
+ dialog.Close();
+ ShowAdviceWindow("Plan Comparison", comparison);
+ };
+
+ cancelBtn.Click += (_, _) => dialog.Close();
+
+ dialog.ShowDialog(this);
+ }
+
+ private TabItem CreateTab(string label, Control content)
+ {
+ 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 = content };
+ closeBtn.Tag = tab;
+ closeBtn.Click += CloseTab_Click;
+
+ // Right-click context menu
+ var copyPathItem = new MenuItem { Header = "Copy Path", Tag = tab };
+ // Only visible when tab content has a file path
+ var filePath = GetTabFilePath(tab);
+ copyPathItem.IsVisible = filePath != null;
+
+ var contextMenu = new ContextMenu
+ {
+ Items =
+ {
+ new MenuItem { Header = "Rename Tab", Tag = new object[] { header, headerText } },
+ copyPathItem,
+ 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())
+ item.Click += TabContextMenu_Click;
+
+ header.ContextMenu = contextMenu;
+
+ return tab;
+ }
+
+ private void CloseTab_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is Button btn && btn.Tag is TabItem tab)
+ {
+ MainTabControl.Items.Remove(tab);
+ UpdateEmptyOverlay();
+ }
+ }
+
+ private void TabContextMenu_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem item) return;
+
+ var headerText = item.Header?.ToString();
+
+ switch (headerText)
+ {
+ case "Rename Tab":
+ if (item.Tag is object[] parts)
+ StartRename((StackPanel)parts[0], (TextBlock)parts[1]);
+ break;
+
+ case "Copy Path":
+ if (item.Tag is TabItem pathTab)
+ {
+ var path = GetTabFilePath(pathTab);
+ if (path != null)
+ _ = this.Clipboard?.SetTextAsync(path);
+ }
+ break;
+
+ case "Close":
+ if (item.Tag is TabItem tab)
+ {
+ MainTabControl.Items.Remove(tab);
+ UpdateEmptyOverlay();
+ }
+ break;
+
+ case "Close Other Tabs":
+ if (item.Tag is TabItem keepTab)
+ {
+ var others = MainTabControl.Items.Cast().Where(t => t != keepTab).ToList();
+ foreach (var t in others)
+ MainTabControl.Items.Remove(t);
+ MainTabControl.SelectedItem = keepTab;
+ UpdateEmptyOverlay();
+ }
+ break;
+
+ case "Close All Tabs":
+ MainTabControl.Items.Clear();
+ UpdateEmptyOverlay();
+ break;
+ }
+ }
+
+ private static string? GetTabFilePath(TabItem tab)
+ {
+ // Plans opened from file are wrapped in a DockPanel with the viewer as the last child
+ if (tab.Content is DockPanel dp)
+ {
+ foreach (var child in dp.Children)
+ {
+ if (child is PlanViewerControl v)
+ return v.SourceFilePath;
+ }
+ }
+ return null;
+ }
+
+ private void StartRename(StackPanel header, TextBlock headerText)
+ {
+ var textBox = new TextBox
+ {
+ Text = headerText.Text,
+ FontSize = 12,
+ MinWidth = 80,
+ Padding = new Avalonia.Thickness(2, 0),
+ VerticalAlignment = VerticalAlignment.Center
+ };
+
+ // Hide the text, show the textbox
+ headerText.IsVisible = false;
+ header.Children.Insert(0, textBox);
+ textBox.Focus();
+ textBox.SelectAll();
+
+ void CommitRename()
+ {
+ var newName = textBox.Text?.Trim();
+ if (!string.IsNullOrEmpty(newName))
+ headerText.Text = newName;
+
+ headerText.IsVisible = true;
+ header.Children.Remove(textBox);
+ }
+
+ textBox.KeyDown += (_, ke) =>
+ {
+ if (ke.Key == Key.Enter || ke.Key == Key.Escape)
+ {
+ if (ke.Key == Key.Escape)
+ textBox.Text = headerText.Text; // revert
+ CommitRename();
+ ke.Handled = true;
+ }
+ };
+
+ textBox.LostFocus += (_, _) => CommitRename();
+ }
+
+ ///
+ /// Gets query text from a PlanViewerControl — uses QueryText if set,
+ /// otherwise concatenates StatementText from all parsed statements.
+ ///
+ private static string GetQueryTextFromPlan(PlanViewerControl viewer)
+ {
+ if (!string.IsNullOrEmpty(viewer.QueryText))
+ return viewer.QueryText;
+
+ if (viewer.CurrentPlan == null)
+ return "";
+
+ var statements = viewer.CurrentPlan.Batches
+ .SelectMany(b => b.Statements)
+ .Select(s => s.StatementText)
+ .Where(t => !string.IsNullOrEmpty(t));
+
+ return string.Join(Environment.NewLine, statements);
+ }
+
+ ///
+ /// Extracts the database name from plan XML's StmtSimple DatabaseContext attribute.
+ ///
+ 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";
+ var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault();
+ var dbContext = stmt?.Attribute("DatabaseContext")?.Value;
+ if (!string.IsNullOrEmpty(dbContext))
+ return dbContext.Trim('[', ']');
+ }
+ catch { }
+
+ return null;
+ }
+
+ ///
+ /// Prompts for a connection, then executes the query from the plan to get an actual plan.
+ ///
+ private async Task GetActualPlanFromFile(PlanViewerControl viewer)
+ {
+ var queryText = GetQueryTextFromPlan(viewer);
+ if (string.IsNullOrEmpty(queryText))
+ {
+ ShowError("No query text available in this plan.");
+ return;
+ }
+
+ // Show connection dialog
+ var dialog = new Dialogs.ConnectionDialog(_credentialService, _connectionStore);
+ var result = await dialog.ShowDialog(this);
+ if (result != true || dialog.ResultConnection == null)
+ return;
+
+ var database = dialog.ResultDatabase ?? ExtractDatabaseFromPlanXml(viewer.RawXml);
+ var connectionString = dialog.ResultConnection.GetConnectionString(_credentialService, database);
+ var isAzure = dialog.ResultConnection.ServerName.Contains(".database.windows.net",
+ StringComparison.OrdinalIgnoreCase) ||
+ dialog.ResultConnection.ServerName.Contains(".database.azure.com",
+ StringComparison.OrdinalIgnoreCase);
+
+ // Create a loading placeholder tab immediately
+ 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 statusText = new TextBlock
+ {
+ Text = "Executing query...",
+ 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")!
+ };
+
+ loadingPanel.Children.Add(progressBar);
+ loadingPanel.Children.Add(statusText);
+ loadingPanel.Children.Add(cancelBtn);
+
+ var cts = new System.Threading.CancellationTokenSource();
+ cancelBtn.Click += (_, _) => cts.Cancel();
+
+ var loadingContainer = new Grid
+ {
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Focusable = true,
+ Children = { loadingPanel }
+ };
+ loadingContainer.KeyDown += (_, ke) =>
+ {
+ if (ke.Key == Avalonia.Input.Key.Escape) { cts.Cancel(); ke.Handled = true; }
+ };
+
+ var tab = CreateTab("Actual Plan", loadingContainer);
+ MainTabControl.Items.Add(tab);
+ MainTabControl.SelectedItem = tab;
+ UpdateEmptyOverlay();
+ loadingContainer.Focus();
+
+ try
+ {
+ // Fetch server metadata for advice and Plan Insights
+ ServerMetadata? metadata = null;
+ try
+ {
+ metadata = await ServerMetadataService.FetchServerMetadataAsync(
+ connectionString, isAzure);
+ metadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync(
+ connectionString, metadata.SupportsScopedConfigs);
+ }
+ catch { /* Non-fatal — advice will just lack server context */ }
+
+ statusText.Text = "Capturing actual plan...";
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+
+ var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync(
+ connectionString, database, queryText,
+ viewer.RawXml, isolationLevel: null,
+ isAzureSqlDb: isAzure, timeoutSeconds: 0, cts.Token);
+
+ sw.Stop();
+
+ if (string.IsNullOrEmpty(actualPlanXml))
+ {
+ statusText.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s).";
+ progressBar.IsVisible = false;
+ return;
+ }
+
+ // Replace loading content with the actual plan
+ var actualViewer = new PlanViewerControl();
+ actualViewer.Metadata = metadata;
+ actualViewer.LoadPlan(actualPlanXml, "Actual Plan", queryText);
+
+ tab.Content = CreatePlanTabContent(actualViewer);
+ }
+ catch (Exception ex)
+ {
+ statusText.Text = $"Error: {ex.Message}";
+ progressBar.IsVisible = false;
+ }
+ }
+
+ // ── Recent Plans & Session Restore ────────────────────────────────────
+
+ ///
+ /// Adds a file path to the recent plans list, saves settings, and rebuilds the menu.
+ ///
+ private void TrackRecentPlan(string filePath)
+ {
+ AppSettingsService.AddRecentPlan(_appSettings, filePath);
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+ }
+
+ ///
+ /// Rebuilds the Recent Plans submenu from the current settings.
+ /// Shows a disabled "(empty)" item when the list is empty, plus a Clear Recent separator.
+ ///
+ private void RebuildRecentPlansMenu()
+ {
+ RecentPlansMenu.Items.Clear();
+
+ if (_appSettings.RecentPlans.Count == 0)
+ {
+ var emptyItem = new MenuItem
+ {
+ Header = "(empty)",
+ IsEnabled = false
+ };
+ RecentPlansMenu.Items.Add(emptyItem);
+ return;
+ }
+
+ foreach (var path in _appSettings.RecentPlans)
+ {
+ var fileName = Path.GetFileName(path);
+ var directory = Path.GetDirectoryName(path) ?? "";
+
+ // Show "filename — directory" so the user can distinguish same-named files
+ var displayText = string.IsNullOrEmpty(directory)
+ ? fileName
+ : $"{fileName} — {directory}";
+
+ var item = new MenuItem
+ {
+ Header = displayText,
+ Tag = path
+ };
+
+ item.Click += RecentPlanItem_Click;
+ RecentPlansMenu.Items.Add(item);
+ }
+
+ RecentPlansMenu.Items.Add(new Separator());
+
+ var clearItem = new MenuItem { Header = "Clear Recent Plans" };
+ clearItem.Click += ClearRecentPlans_Click;
+ RecentPlansMenu.Items.Add(clearItem);
+ }
+
+ private void RecentPlanItem_Click(object? sender, RoutedEventArgs e)
+ {
+ if (sender is not MenuItem item || item.Tag is not string path)
+ return;
+
+ if (!File.Exists(path))
+ {
+ // File was moved or deleted — remove from the list and notify the user
+ AppSettingsService.RemoveRecentPlan(_appSettings, path);
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+
+ ShowError($"The file no longer exists and has been removed from recent plans:\n\n{path}");
+ return;
+ }
+
+ LoadPlanFile(path);
+ }
+
+ private void ClearRecentPlans_Click(object? sender, RoutedEventArgs e)
+ {
+ _appSettings.RecentPlans.Clear();
+ AppSettingsService.Save(_appSettings);
+ RebuildRecentPlansMenu();
+ }
+
+ ///
+ /// Saves the file paths of all currently open file-based plan tabs.
+ ///
+ private void SaveOpenPlans()
+ {
+ _appSettings.OpenPlans.Clear();
+
+ foreach (var item in MainTabControl.Items)
+ {
+ if (item is not TabItem tab) continue;
+
+ var path = GetTabFilePath(tab);
+ if (!string.IsNullOrEmpty(path))
+ _appSettings.OpenPlans.Add(path);
+ }
+
+ AppSettingsService.Save(_appSettings);
+ }
+
+ ///
+ /// Restores plan tabs from the previous session. Skips files that no longer exist.
+ /// Falls back to a new query tab if nothing was restored.
+ ///
+ private void RestoreOpenPlans()
+ {
+ var restored = false;
+
+ foreach (var path in _appSettings.OpenPlans)
+ {
+ if (File.Exists(path))
+ {
+ LoadPlanFile(path);
+ restored = true;
+ }
+ }
+
+ // Clear the open plans list now that we've restored
+ _appSettings.OpenPlans.Clear();
+ AppSettingsService.Save(_appSettings);
+
+ if (!restored)
+ {
+ // Nothing to restore — open a fresh query editor like before
+ NewQuery_Click(this, new RoutedEventArgs());
+ }
+ }
+
+ private void ShowError(string message)
+ {
+ var dialog = new Window
+ {
+ Title = "Performance Studio",
+ Width = 450,
+ Height = 200,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Icon = this.Icon,
+ Background = new SolidColorBrush(Color.Parse("#1A1D23")),
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Children =
+ {
+ new TextBlock
+ {
+ Text = message,
+ TextWrapping = TextWrapping.Wrap,
+ FontSize = 13,
+ Foreground = new SolidColorBrush(Color.Parse("#E4E6EB"))
+ }
+ }
+ }
+ };
+ dialog.ShowDialog(this);
+ }
+
+ private async Task CheckForUpdatesOnStartupAsync()
+ {
+ try
+ {
+ await Task.Delay(5000); // Don't slow down startup
+
+ if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(
+ System.Runtime.InteropServices.OSPlatform.Windows))
+ {
+ try
+ {
+ var mgr = new Velopack.UpdateManager(
+ new Velopack.Sources.GithubSource(
+ "https://github.com/erikdarlingdata/PerformanceStudio", null, false));
+
+ var update = await mgr.CheckForUpdatesAsync();
+ if (update != null)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Title = $"Performance Studio — Update v{update.TargetFullRelease.Version} available (Help > About)";
+ });
+ return;
+ }
+ }
+ catch
+ {
+ // Velopack not available — fall through
+ }
+ }
+
+ var currentVersion = System.Reflection.Assembly.GetExecutingAssembly().GetName().Version
+ ?? new Version(0, 0, 0);
+ var result = await UpdateChecker.CheckAsync(currentVersion);
+ if (result.UpdateAvailable)
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Title = $"Performance Studio — Update {result.LatestVersion} available (Help > About)";
+ });
+ }
+ }
+ catch
+ {
+ // Never crash on update check
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpHelpers.cs b/src/PlanViewer.App/Mcp/McpHelpers.cs
index 6bd2807..c7f9906 100644
--- a/src/PlanViewer.App/Mcp/McpHelpers.cs
+++ b/src/PlanViewer.App/Mcp/McpHelpers.cs
@@ -1,29 +1,29 @@
-using System;
-using System.Text.Json;
-
-namespace PlanViewer.App.Mcp;
-
-internal static class McpHelpers
-{
- public const int MaxTop = 100;
-
- public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
-
- public static string? Truncate(string? value, int maxLength)
- {
- if (value == null || value.Length <= maxLength) return value;
- return value[..maxLength] + "... (truncated)";
- }
-
- public static string? ValidateTop(int top, string paramName = "top")
- {
- if (top <= 0)
- return $"Invalid {paramName} value '{top}'. Must be a positive integer (1-{MaxTop}).";
- if (top > MaxTop)
- return $"{paramName} value '{top}' exceeds maximum of {MaxTop}. Use a smaller value.";
- return null;
- }
-
- public static string FormatError(string operation, Exception ex) =>
- $"Error during {operation}: {ex.Message}";
-}
+using System;
+using System.Text.Json;
+
+namespace PlanViewer.App.Mcp;
+
+internal static class McpHelpers
+{
+ public const int MaxTop = 100;
+
+ public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ public static string? Truncate(string? value, int maxLength)
+ {
+ if (value == null || value.Length <= maxLength) return value;
+ return value[..maxLength] + "... (truncated)";
+ }
+
+ public static string? ValidateTop(int top, string paramName = "top")
+ {
+ if (top <= 0)
+ return $"Invalid {paramName} value '{top}'. Must be a positive integer (1-{MaxTop}).";
+ if (top > MaxTop)
+ return $"{paramName} value '{top}' exceeds maximum of {MaxTop}. Use a smaller value.";
+ return null;
+ }
+
+ public static string FormatError(string operation, Exception ex) =>
+ $"Error during {operation}: {ex.Message}";
+}
diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs
index bc66cab..f854bda 100644
--- a/src/PlanViewer.App/Mcp/McpHostService.cs
+++ b/src/PlanViewer.App/Mcp/McpHostService.cs
@@ -1,100 +1,100 @@
-using System;
-using System.Threading;
-using System.Threading.Tasks;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using ModelContextProtocol.AspNetCore;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Interfaces;
-
-namespace PlanViewer.App.Mcp;
-
-///
-/// Background service that hosts an MCP server over Streamable HTTP transport.
-/// Allows LLM clients to discover and call plan analysis tools via http://localhost:{port}.
-///
-public sealed class McpHostService : BackgroundService
-{
- private readonly PlanSessionManager _sessionManager;
- private readonly ConnectionStore _connectionStore;
- private readonly ICredentialService _credentialService;
- private readonly int _port;
- private WebApplication? _app;
-
- public McpHostService(
- PlanSessionManager sessionManager,
- ConnectionStore connectionStore,
- ICredentialService credentialService,
- int port)
- {
- _sessionManager = sessionManager;
- _connectionStore = connectionStore;
- _credentialService = credentialService;
- _port = port;
- }
-
- protected override async Task ExecuteAsync(CancellationToken stoppingToken)
- {
- try
- {
- var builder = WebApplication.CreateBuilder();
-
- builder.WebHost.ConfigureKestrel(options =>
- {
- options.ListenLocalhost(_port);
- });
-
- /* Suppress ASP.NET Core console logging */
- builder.Logging.ClearProviders();
- builder.Logging.SetMinimumLevel(LogLevel.Warning);
-
- /* Register services that MCP tools need via dependency injection */
- builder.Services.AddSingleton(_sessionManager);
- builder.Services.AddSingleton(_connectionStore);
- builder.Services.AddSingleton(_credentialService);
-
- /* Register MCP server with all tool classes */
- builder.Services
- .AddMcpServer(options =>
- {
- options.ServerInfo = new()
- {
- Name = "PerformanceStudio",
- Version = "0.7.0"
- };
- options.ServerInstructions = McpInstructions.Text;
- })
- .WithHttpTransport()
- .WithTools()
- .WithTools();
-
- _app = builder.Build();
- _app.MapMcp();
-
- await _app.RunAsync(stoppingToken);
- }
- catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
- {
- /* Normal shutdown */
- }
- catch (Exception ex)
- {
- System.Diagnostics.Debug.WriteLine($"MCP server failed to start: {ex.Message}");
- }
- }
-
- public override async Task StopAsync(CancellationToken cancellationToken)
- {
- if (_app != null)
- {
- await _app.StopAsync(cancellationToken);
- await _app.DisposeAsync();
- _app = null;
- }
-
- await base.StopAsync(cancellationToken);
- }
-}
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.AspNetCore;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+
+namespace PlanViewer.App.Mcp;
+
+///
+/// Background service that hosts an MCP server over Streamable HTTP transport.
+/// Allows LLM clients to discover and call plan analysis tools via http://localhost:{port}.
+///
+public sealed class McpHostService : BackgroundService
+{
+ private readonly PlanSessionManager _sessionManager;
+ private readonly ConnectionStore _connectionStore;
+ private readonly ICredentialService _credentialService;
+ private readonly int _port;
+ private WebApplication? _app;
+
+ public McpHostService(
+ PlanSessionManager sessionManager,
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ int port)
+ {
+ _sessionManager = sessionManager;
+ _connectionStore = connectionStore;
+ _credentialService = credentialService;
+ _port = port;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ builder.WebHost.ConfigureKestrel(options =>
+ {
+ options.ListenLocalhost(_port);
+ });
+
+ /* Suppress ASP.NET Core console logging */
+ builder.Logging.ClearProviders();
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ /* Register services that MCP tools need via dependency injection */
+ builder.Services.AddSingleton(_sessionManager);
+ builder.Services.AddSingleton(_connectionStore);
+ builder.Services.AddSingleton(_credentialService);
+
+ /* Register MCP server with all tool classes */
+ builder.Services
+ .AddMcpServer(options =>
+ {
+ options.ServerInfo = new()
+ {
+ Name = "PerformanceStudio",
+ Version = "0.7.0"
+ };
+ options.ServerInstructions = McpInstructions.Text;
+ })
+ .WithHttpTransport()
+ .WithTools()
+ .WithTools();
+
+ _app = builder.Build();
+ _app.MapMcp();
+
+ await _app.RunAsync(stoppingToken);
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ /* Normal shutdown */
+ }
+ catch (Exception ex)
+ {
+ System.Diagnostics.Debug.WriteLine($"MCP server failed to start: {ex.Message}");
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (_app != null)
+ {
+ await _app.StopAsync(cancellationToken);
+ await _app.DisposeAsync();
+ _app = null;
+ }
+
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpInstructions.cs b/src/PlanViewer.App/Mcp/McpInstructions.cs
index 16bbe34..5ab3a69 100644
--- a/src/PlanViewer.App/Mcp/McpInstructions.cs
+++ b/src/PlanViewer.App/Mcp/McpInstructions.cs
@@ -1,122 +1,122 @@
-namespace PlanViewer.App.Mcp;
-
-internal static class McpInstructions
-{
- public const string Text = """
- You are connected to Performance Studio, a SQL Server execution plan analyzer.
-
- ## CRITICAL: Read-Only Access
-
- This MCP server provides READ-ONLY access to execution plans and Query Store data. You CANNOT:
- - Execute arbitrary or ad-hoc SQL queries against any server
- - Modify any server configuration or settings
- - Write or modify any files
- - Change application settings
-
- The only server-side query this MCP can run is the built-in Query Store fetch query
- (via `get_query_store_top`), which reads from `sys.query_store_*` DMVs. No other
- queries can be executed.
-
- ## How Plans Get Loaded
-
- Plans are loaded into the application by the user through:
- - Opening .sqlplan files (File > Open)
- - Pasting XML from the clipboard (Ctrl+V or File > Paste Plan XML)
- - Executing queries from the built-in query editor (estimated or actual plans)
- - Fetching from Query Store (via the Query Store dialog in the app)
-
- Each loaded plan gets a unique `session_id`. Use `list_plans` to see all loaded plans and their session IDs.
-
- ## Tool Reference
-
- ### Discovery
- | Tool | Purpose |
- |------|---------|
- | `list_plans` | Lists all loaded plans with session IDs, labels, and summary stats |
- | `get_connections` | Lists saved SQL Server connections (names only, no credentials) |
-
- ### Plan Analysis (works on loaded plans)
- | Tool | Purpose |
- |------|---------|
- | `analyze_plan` | Full JSON analysis: statements, warnings, operators, parameters, memory grants |
- | `get_plan_summary` | Concise text summary for quick assessment |
- | `get_plan_warnings` | Warnings only, filterable by severity |
- | `get_missing_indexes` | Missing index suggestions with CREATE INDEX statements |
- | `get_plan_parameters` | Parameter details with compiled vs runtime value comparison |
- | `get_expensive_operators` | Top N costly operators by cost or actual elapsed time |
- | `get_plan_xml` | Raw showplan XML |
- | `compare_plans` | Side-by-side comparison of two plans |
- | `get_repro_script` | Generates paste-ready T-SQL reproduction script |
-
- ### Query Store (uses built-in read-only query only)
- | Tool | Purpose |
- |------|---------|
- | `check_query_store` | Checks if Query Store is enabled on a database |
- | `get_query_store_top` | Fetches top N plans from Query Store; auto-loads them for analysis |
-
- ## Recommended Workflow
-
- ### Analyzing loaded plans
- 1. `list_plans` — see what plans are loaded in the application
- 2. `analyze_plan` with the target session_id — get full analysis
- 3. Focus on critical issues: `get_plan_warnings` with severity="Critical"
- 4. Check for parameter sniffing: `get_plan_parameters`
- 5. Review index suggestions: `get_missing_indexes`
- 6. Find bottlenecks: `get_expensive_operators`
- 7. For comparison: `compare_plans` with two session_ids
- 8. For reproduction: `get_repro_script` to generate runnable T-SQL
-
- ### Fetching from Query Store
- 1. `get_connections` — see available saved connections
- 2. `check_query_store` — verify Query Store is enabled on the target database
- 3. `get_query_store_top` — fetch top queries (auto-loads plans into the app)
- 4. Use plan analysis tools above with the returned session_ids
-
- ## Analysis Rules
-
- The analyzer runs 30 rules covering:
- - Memory: Large grants, grant vs used ratio, spills to TempDB (sort, hash, exchange)
- - Estimates: Row estimate mismatches (10x+), zero-row actuals, row goals
- - Indexes: Missing index suggestions, key lookups, RID lookups, scan with residual predicates
- - Parallelism: Serial plan reasons, thread skew, ineffective parallelism, DOP reporting
- - Joins: Nested loop high executions, many-to-many merge join worktables
- - Filters: Late filter operators, function-wrapped predicates
- - Functions: Scalar UDF detection (T-SQL and CLR)
- - Parameters: Compiled vs runtime values, sniffing issue detection, local variables
- - Patterns: Leading wildcards, implicit conversions, OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns
- - Compilation: High compile CPU, compile memory exceeded, early abort
- - Objects: Table variables, table-valued functions, CTE multiple references, spools
-
- Warnings have three severity levels: Critical, Warning, Info.
-
- ## Data Characteristics
-
- - Plans can be **estimated** (no runtime stats) or **actual** (with row counts, elapsed time, I/O stats)
- - Estimated plans show expected costs and row estimates only
- - Actual plans additionally show per-thread runtime data, elapsed times, logical/physical reads, wait stats
- - Memory grant analysis is only meaningful in actual plans (when GrantedKB > 0)
- - Wait stats are only present in actual plans captured with SET STATISTICS XML ON
- - Query Store plans are always estimated (plan cache snapshots)
-
- ## MCP Client Configuration
-
- For Claude Code, add to your MCP config:
- ```json
- {
- "mcpServers": {
- "performance-studio": {
- "type": "http",
- "url": "http://localhost:5152/"
- }
- }
- }
- ```
-
- ## Key Limitations
-
- - Plans must be loaded in the application before MCP tools can access them
- - Query Store tools require a saved connection with valid credentials
- - Plan XML in `get_plan_xml` is truncated at 500KB
- - The full operator tree in `analyze_plan` can be large for complex queries
- """;
-}
+namespace PlanViewer.App.Mcp;
+
+internal static class McpInstructions
+{
+ public const string Text = """
+ You are connected to Performance Studio, a SQL Server execution plan analyzer.
+
+ ## CRITICAL: Read-Only Access
+
+ This MCP server provides READ-ONLY access to execution plans and Query Store data. You CANNOT:
+ - Execute arbitrary or ad-hoc SQL queries against any server
+ - Modify any server configuration or settings
+ - Write or modify any files
+ - Change application settings
+
+ The only server-side query this MCP can run is the built-in Query Store fetch query
+ (via `get_query_store_top`), which reads from `sys.query_store_*` DMVs. No other
+ queries can be executed.
+
+ ## How Plans Get Loaded
+
+ Plans are loaded into the application by the user through:
+ - Opening .sqlplan files (File > Open)
+ - Pasting XML from the clipboard (Ctrl+V or File > Paste Plan XML)
+ - Executing queries from the built-in query editor (estimated or actual plans)
+ - Fetching from Query Store (via the Query Store dialog in the app)
+
+ Each loaded plan gets a unique `session_id`. Use `list_plans` to see all loaded plans and their session IDs.
+
+ ## Tool Reference
+
+ ### Discovery
+ | Tool | Purpose |
+ |------|---------|
+ | `list_plans` | Lists all loaded plans with session IDs, labels, and summary stats |
+ | `get_connections` | Lists saved SQL Server connections (names only, no credentials) |
+
+ ### Plan Analysis (works on loaded plans)
+ | Tool | Purpose |
+ |------|---------|
+ | `analyze_plan` | Full JSON analysis: statements, warnings, operators, parameters, memory grants |
+ | `get_plan_summary` | Concise text summary for quick assessment |
+ | `get_plan_warnings` | Warnings only, filterable by severity |
+ | `get_missing_indexes` | Missing index suggestions with CREATE INDEX statements |
+ | `get_plan_parameters` | Parameter details with compiled vs runtime value comparison |
+ | `get_expensive_operators` | Top N costly operators by cost or actual elapsed time |
+ | `get_plan_xml` | Raw showplan XML |
+ | `compare_plans` | Side-by-side comparison of two plans |
+ | `get_repro_script` | Generates paste-ready T-SQL reproduction script |
+
+ ### Query Store (uses built-in read-only query only)
+ | Tool | Purpose |
+ |------|---------|
+ | `check_query_store` | Checks if Query Store is enabled on a database |
+ | `get_query_store_top` | Fetches top N plans from Query Store; auto-loads them for analysis |
+
+ ## Recommended Workflow
+
+ ### Analyzing loaded plans
+ 1. `list_plans` — see what plans are loaded in the application
+ 2. `analyze_plan` with the target session_id — get full analysis
+ 3. Focus on critical issues: `get_plan_warnings` with severity="Critical"
+ 4. Check for parameter sniffing: `get_plan_parameters`
+ 5. Review index suggestions: `get_missing_indexes`
+ 6. Find bottlenecks: `get_expensive_operators`
+ 7. For comparison: `compare_plans` with two session_ids
+ 8. For reproduction: `get_repro_script` to generate runnable T-SQL
+
+ ### Fetching from Query Store
+ 1. `get_connections` — see available saved connections
+ 2. `check_query_store` — verify Query Store is enabled on the target database
+ 3. `get_query_store_top` — fetch top queries (auto-loads plans into the app)
+ 4. Use plan analysis tools above with the returned session_ids
+
+ ## Analysis Rules
+
+ The analyzer runs 30 rules covering:
+ - Memory: Large grants, grant vs used ratio, spills to TempDB (sort, hash, exchange)
+ - Estimates: Row estimate mismatches (10x+), zero-row actuals, row goals
+ - Indexes: Missing index suggestions, key lookups, RID lookups, scan with residual predicates
+ - Parallelism: Serial plan reasons, thread skew, ineffective parallelism, DOP reporting
+ - Joins: Nested loop high executions, many-to-many merge join worktables
+ - Filters: Late filter operators, function-wrapped predicates
+ - Functions: Scalar UDF detection (T-SQL and CLR)
+ - Parameters: Compiled vs runtime values, sniffing issue detection, local variables
+ - Patterns: Leading wildcards, implicit conversions, OPTIMIZE FOR UNKNOWN, NOT IN with nullable columns
+ - Compilation: High compile CPU, compile memory exceeded, early abort
+ - Objects: Table variables, table-valued functions, CTE multiple references, spools
+
+ Warnings have three severity levels: Critical, Warning, Info.
+
+ ## Data Characteristics
+
+ - Plans can be **estimated** (no runtime stats) or **actual** (with row counts, elapsed time, I/O stats)
+ - Estimated plans show expected costs and row estimates only
+ - Actual plans additionally show per-thread runtime data, elapsed times, logical/physical reads, wait stats
+ - Memory grant analysis is only meaningful in actual plans (when GrantedKB > 0)
+ - Wait stats are only present in actual plans captured with SET STATISTICS XML ON
+ - Query Store plans are always estimated (plan cache snapshots)
+
+ ## MCP Client Configuration
+
+ For Claude Code, add to your MCP config:
+ ```json
+ {
+ "mcpServers": {
+ "performance-studio": {
+ "type": "http",
+ "url": "http://localhost:5152/"
+ }
+ }
+ }
+ ```
+
+ ## Key Limitations
+
+ - Plans must be loaded in the application before MCP tools can access them
+ - Query Store tools require a saved connection with valid credentials
+ - Plan XML in `get_plan_xml` is truncated at 500KB
+ - The full operator tree in `analyze_plan` can be large for complex queries
+ """;
+}
diff --git a/src/PlanViewer.App/Mcp/McpPlanTools.cs b/src/PlanViewer.App/Mcp/McpPlanTools.cs
index 8e80b85..916ccd7 100644
--- a/src/PlanViewer.App/Mcp/McpPlanTools.cs
+++ b/src/PlanViewer.App/Mcp/McpPlanTools.cs
@@ -1,345 +1,345 @@
-using System;
-using System.Collections.Generic;
-using System.ComponentModel;
-using System.Linq;
-using System.Text.Json;
-using ModelContextProtocol.Server;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Models;
-using PlanViewer.Core.Output;
-using PlanViewer.Core.Services;
-
-#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
-
-namespace PlanViewer.App.Mcp;
-
-[McpServerToolType]
-public sealed class McpPlanTools
-{
- [McpServerTool(Name = "list_plans")]
- [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " +
- "statement counts, warning counts, and source type. Use this first to discover available plans.")]
- public static string ListPlans(PlanSessionManager sessionManager)
- {
- var sessions = sessionManager.GetAllSessions();
- if (sessions.Count == 0)
- return "No plans are currently loaded in the application. Open a .sqlplan file or paste plan XML to get started.";
-
- return JsonSerializer.Serialize(new { plans = sessions }, McpHelpers.JsonOptions);
- }
-
- [McpServerTool(Name = "get_connections")]
- [Description("Lists saved SQL Server connections. Returns server names and authentication types only — " +
- "credentials are never exposed. Use connection names with Query Store tools.")]
- public static string GetConnections(ConnectionStore connectionStore)
- {
- var connections = connectionStore.Load();
- if (connections.Count == 0)
- return "No saved connections. Add a connection in the application via the query editor toolbar.";
-
- var safe = connections.Select(c => new
- {
- name = c.ServerName,
- display_name = string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : c.DisplayName,
- auth_type = c.AuthenticationDisplay
- });
-
- return JsonSerializer.Serialize(new { connections = safe }, McpHelpers.JsonOptions);
- }
-
- [McpServerTool(Name = "analyze_plan")]
- [Description("Returns the full JSON analysis result for a loaded plan. Includes all statements, warnings, " +
- "missing indexes, parameters, operator tree, memory grants, and wait stats. " +
- "This is the primary tool for understanding plan quality. Use list_plans first to get session_id values.")]
- public static string AnalyzePlan(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- try
- {
- var result = ResultMapper.Map(session.Plan, session.Source);
- return JsonSerializer.Serialize(result, McpHelpers.JsonOptions);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("analyze_plan", ex);
- }
- }
-
- [McpServerTool(Name = "get_plan_summary")]
- [Description("Returns a concise human-readable text summary of a loaded plan: statement count, warnings, " +
- "missing indexes, cost, DOP, memory grants. Faster than analyze_plan for quick assessment.")]
- public static string GetPlanSummary(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- try
- {
- var result = ResultMapper.Map(session.Plan, session.Source);
- return TextFormatter.Format(result);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("get_plan_summary", ex);
- }
- }
-
- [McpServerTool(Name = "get_plan_warnings")]
- [Description("Returns only the warnings and analysis findings for a loaded plan. " +
- "Optionally filter by severity (Critical, Warning, or Info).")]
- public static string GetPlanWarnings(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id,
- [Description("Optional severity filter: Critical, Warning, or Info.")] string? severity = null)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- try
- {
- var result = ResultMapper.Map(session.Plan, session.Source);
- var allWarnings = result.Statements
- .SelectMany(s => s.Warnings.Select(w => new
- {
- severity = w.Severity,
- type = w.Type,
- message = w.Message,
- node_id = w.NodeId,
- @operator = w.Operator,
- statement = McpHelpers.Truncate(s.StatementText, 200)
- }))
- .Where(w => severity == null ||
- w.severity.Equals(severity, StringComparison.OrdinalIgnoreCase))
- .ToList();
-
- if (allWarnings.Count == 0)
- {
- return severity != null
- ? $"No {severity} warnings found in this plan."
- : "No warnings found in this plan.";
- }
-
- return JsonSerializer.Serialize(new { warning_count = allWarnings.Count, warnings = allWarnings },
- McpHelpers.JsonOptions);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("get_plan_warnings", ex);
- }
- }
-
- [McpServerTool(Name = "get_missing_indexes")]
- [Description("Returns missing index suggestions from a loaded plan with impact scores and " +
- "ready-to-run CREATE INDEX statements.")]
- public static string GetMissingIndexes(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- var indexes = session.Plan.AllMissingIndexes;
- if (indexes.Count == 0)
- return "No missing index suggestions in this plan.";
-
- var result = indexes.Select(idx => new
- {
- database = idx.Database,
- schema_name = idx.Schema,
- table = idx.Table,
- impact = idx.Impact,
- equality_columns = idx.EqualityColumns,
- inequality_columns = idx.InequalityColumns,
- include_columns = idx.IncludeColumns,
- create_statement = idx.CreateStatement
- });
-
- return JsonSerializer.Serialize(new { missing_index_count = indexes.Count, indexes = result },
- McpHelpers.JsonOptions);
- }
-
- [McpServerTool(Name = "get_plan_parameters")]
- [Description("Returns parameter details from a loaded plan including names, data types, " +
- "compiled values, and runtime values. Highlights parameter sniffing when compiled and runtime values differ.")]
- public static string GetPlanParameters(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- var statements = session.Plan.Batches
- .SelectMany(b => b.Statements)
- .Where(s => s.Parameters.Count > 0)
- .Select(s => new
- {
- statement = McpHelpers.Truncate(s.StatementText, 200),
- parameters = s.Parameters.Select(p => new
- {
- name = p.Name,
- data_type = p.DataType,
- compiled_value = p.CompiledValue,
- runtime_value = p.RuntimeValue,
- sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null
- && p.CompiledValue != p.RuntimeValue
- })
- })
- .ToList();
-
- if (statements.Count == 0)
- return "No parameters found in this plan (ad-hoc query or local variables only).";
-
- return JsonSerializer.Serialize(new { statements }, McpHelpers.JsonOptions);
- }
-
- [McpServerTool(Name = "get_expensive_operators")]
- [Description("Returns the top N most expensive operators from a loaded plan, ranked by cost percentage " +
- "or actual elapsed time (if available). Useful for quickly finding bottleneck operators.")]
- public static string GetExpensiveOperators(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id,
- [Description("Number of operators to return. Default 10.")] int top = 10)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- var topError = McpHelpers.ValidateTop(top);
- if (topError != null) return topError;
-
- var allNodes = new List<(PlanNode Node, string Statement)>();
- foreach (var stmt in session.Plan.Batches.SelectMany(b => b.Statements))
- {
- if (stmt.RootNode == null) continue;
- CollectNodes(stmt.RootNode, McpHelpers.Truncate(stmt.StatementText, 100) ?? "", allNodes);
- }
-
- var hasActuals = allNodes.Any(n => n.Node.ActualElapsedMs > 0);
- var ranked = hasActuals
- ? allNodes.OrderByDescending(n => n.Node.ActualElapsedMs)
- : allNodes.OrderByDescending(n => n.Node.CostPercent);
-
- var result = ranked.Take(top).Select(n => new
- {
- node_id = n.Node.NodeId,
- physical_op = n.Node.PhysicalOp,
- logical_op = n.Node.LogicalOp,
- cost_percent = n.Node.CostPercent,
- estimated_rows = n.Node.EstimateRows,
- actual_rows = n.Node.ActualRows,
- actual_elapsed_ms = n.Node.ActualElapsedMs,
- actual_cpu_ms = n.Node.ActualCPUMs,
- logical_reads = n.Node.ActualLogicalReads,
- physical_reads = n.Node.ActualPhysicalReads,
- object_name = n.Node.ObjectName,
- statement = n.Statement
- });
-
- return JsonSerializer.Serialize(new { ranked_by = hasActuals ? "actual_elapsed_ms" : "cost_percent", operators = result },
- McpHelpers.JsonOptions);
- }
-
- [McpServerTool(Name = "get_plan_xml")]
- [Description("Returns the raw showplan XML for a loaded plan. Useful when you need to examine " +
- "plan details not captured in the structured analysis. Truncated at 500KB.")]
- public static string GetPlanXml(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- return McpHelpers.Truncate(session.Plan.RawXml, 512_000) ?? "No plan XML available.";
- }
-
- [McpServerTool(Name = "compare_plans")]
- [Description("Compares two loaded plans side by side. Returns differences in cost, DOP, warnings, " +
- "memory grants, runtime stats, and operator shapes.")]
- public static string ComparePlans(
- PlanSessionManager sessionManager,
- [Description("Session ID of the first plan (from list_plans).")] string session_id_a,
- [Description("Session ID of the second plan (from list_plans).")] string session_id_b)
- {
- var sessionA = sessionManager.GetSession(session_id_a);
- if (sessionA == null)
- return SessionNotFound(sessionManager, session_id_a);
-
- var sessionB = sessionManager.GetSession(session_id_b);
- if (sessionB == null)
- return SessionNotFound(sessionManager, session_id_b);
-
- try
- {
- var resultA = ResultMapper.Map(sessionA.Plan, sessionA.Source);
- var resultB = ResultMapper.Map(sessionB.Plan, sessionB.Source);
- return ComparisonFormatter.Compare(resultA, resultB, sessionA.Label, sessionB.Label);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("compare_plans", ex);
- }
- }
-
- [McpServerTool(Name = "get_repro_script")]
- [Description("Generates a paste-ready T-SQL reproduction script from a loaded plan. " +
- "Extracts parameters, SET options, and database context into a runnable sp_executesql call.")]
- public static string GetReproScript(
- PlanSessionManager sessionManager,
- [Description("The session_id from list_plans.")] string session_id)
- {
- var session = sessionManager.GetSession(session_id);
- if (session == null)
- return SessionNotFound(sessionManager, session_id);
-
- try
- {
- var stmt = session.Plan.Batches
- .SelectMany(b => b.Statements)
- .FirstOrDefault(s => s.RootNode != null);
-
- if (stmt == null)
- return "No executable statement found in this plan.";
-
- var queryText = session.QueryText ?? stmt.StatementText ?? "";
-
- // Extract database from first operator node's DatabaseName property
- string? databaseName = null;
- if (stmt.RootNode?.DatabaseName != null)
- databaseName = stmt.RootNode.DatabaseName;
-
- return ReproScriptBuilder.BuildReproScript(
- queryText, databaseName, session.Plan.RawXml, null);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("get_repro_script", ex);
- }
- }
-
- private static string SessionNotFound(PlanSessionManager sessionManager, string sessionId)
- {
- var available = sessionManager.GetAllSessions();
- if (available.Count == 0)
- return "No plans are currently loaded in the application.";
- return $"Session '{sessionId}' not found. Use list_plans to see available sessions.";
- }
-
- private static void CollectNodes(PlanNode node, string statement, List<(PlanNode, string)> nodes)
- {
- nodes.Add((node, statement));
- foreach (var child in node.Children)
- CollectNodes(child, statement, nodes);
- }
-}
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using ModelContextProtocol.Server;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Output;
+using PlanViewer.Core.Services;
+
+#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
+
+namespace PlanViewer.App.Mcp;
+
+[McpServerToolType]
+public sealed class McpPlanTools
+{
+ [McpServerTool(Name = "list_plans")]
+ [Description("Lists all execution plans currently loaded in the application. Returns session IDs, labels, " +
+ "statement counts, warning counts, and source type. Use this first to discover available plans.")]
+ public static string ListPlans(PlanSessionManager sessionManager)
+ {
+ var sessions = sessionManager.GetAllSessions();
+ if (sessions.Count == 0)
+ return "No plans are currently loaded in the application. Open a .sqlplan file or paste plan XML to get started.";
+
+ return JsonSerializer.Serialize(new { plans = sessions }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_connections")]
+ [Description("Lists saved SQL Server connections. Returns server names and authentication types only — " +
+ "credentials are never exposed. Use connection names with Query Store tools.")]
+ public static string GetConnections(ConnectionStore connectionStore)
+ {
+ var connections = connectionStore.Load();
+ if (connections.Count == 0)
+ return "No saved connections. Add a connection in the application via the query editor toolbar.";
+
+ var safe = connections.Select(c => new
+ {
+ name = c.ServerName,
+ display_name = string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : c.DisplayName,
+ auth_type = c.AuthenticationDisplay
+ });
+
+ return JsonSerializer.Serialize(new { connections = safe }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "analyze_plan")]
+ [Description("Returns the full JSON analysis result for a loaded plan. Includes all statements, warnings, " +
+ "missing indexes, parameters, operator tree, memory grants, and wait stats. " +
+ "This is the primary tool for understanding plan quality. Use list_plans first to get session_id values.")]
+ public static string AnalyzePlan(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ return JsonSerializer.Serialize(result, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("analyze_plan", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_plan_summary")]
+ [Description("Returns a concise human-readable text summary of a loaded plan: statement count, warnings, " +
+ "missing indexes, cost, DOP, memory grants. Faster than analyze_plan for quick assessment.")]
+ public static string GetPlanSummary(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ return TextFormatter.Format(result);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_plan_summary", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_plan_warnings")]
+ [Description("Returns only the warnings and analysis findings for a loaded plan. " +
+ "Optionally filter by severity (Critical, Warning, or Info).")]
+ public static string GetPlanWarnings(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id,
+ [Description("Optional severity filter: Critical, Warning, or Info.")] string? severity = null)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var result = ResultMapper.Map(session.Plan, session.Source);
+ var allWarnings = result.Statements
+ .SelectMany(s => s.Warnings.Select(w => new
+ {
+ severity = w.Severity,
+ type = w.Type,
+ message = w.Message,
+ node_id = w.NodeId,
+ @operator = w.Operator,
+ statement = McpHelpers.Truncate(s.StatementText, 200)
+ }))
+ .Where(w => severity == null ||
+ w.severity.Equals(severity, StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (allWarnings.Count == 0)
+ {
+ return severity != null
+ ? $"No {severity} warnings found in this plan."
+ : "No warnings found in this plan.";
+ }
+
+ return JsonSerializer.Serialize(new { warning_count = allWarnings.Count, warnings = allWarnings },
+ McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_plan_warnings", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_missing_indexes")]
+ [Description("Returns missing index suggestions from a loaded plan with impact scores and " +
+ "ready-to-run CREATE INDEX statements.")]
+ public static string GetMissingIndexes(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var indexes = session.Plan.AllMissingIndexes;
+ if (indexes.Count == 0)
+ return "No missing index suggestions in this plan.";
+
+ var result = indexes.Select(idx => new
+ {
+ database = idx.Database,
+ schema_name = idx.Schema,
+ table = idx.Table,
+ impact = idx.Impact,
+ equality_columns = idx.EqualityColumns,
+ inequality_columns = idx.InequalityColumns,
+ include_columns = idx.IncludeColumns,
+ create_statement = idx.CreateStatement
+ });
+
+ return JsonSerializer.Serialize(new { missing_index_count = indexes.Count, indexes = result },
+ McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_plan_parameters")]
+ [Description("Returns parameter details from a loaded plan including names, data types, " +
+ "compiled values, and runtime values. Highlights parameter sniffing when compiled and runtime values differ.")]
+ public static string GetPlanParameters(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var statements = session.Plan.Batches
+ .SelectMany(b => b.Statements)
+ .Where(s => s.Parameters.Count > 0)
+ .Select(s => new
+ {
+ statement = McpHelpers.Truncate(s.StatementText, 200),
+ parameters = s.Parameters.Select(p => new
+ {
+ name = p.Name,
+ data_type = p.DataType,
+ compiled_value = p.CompiledValue,
+ runtime_value = p.RuntimeValue,
+ sniffing_mismatch = p.CompiledValue != null && p.RuntimeValue != null
+ && p.CompiledValue != p.RuntimeValue
+ })
+ })
+ .ToList();
+
+ if (statements.Count == 0)
+ return "No parameters found in this plan (ad-hoc query or local variables only).";
+
+ return JsonSerializer.Serialize(new { statements }, McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_expensive_operators")]
+ [Description("Returns the top N most expensive operators from a loaded plan, ranked by cost percentage " +
+ "or actual elapsed time (if available). Useful for quickly finding bottleneck operators.")]
+ public static string GetExpensiveOperators(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id,
+ [Description("Number of operators to return. Default 10.")] int top = 10)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ var topError = McpHelpers.ValidateTop(top);
+ if (topError != null) return topError;
+
+ var allNodes = new List<(PlanNode Node, string Statement)>();
+ foreach (var stmt in session.Plan.Batches.SelectMany(b => b.Statements))
+ {
+ if (stmt.RootNode == null) continue;
+ CollectNodes(stmt.RootNode, McpHelpers.Truncate(stmt.StatementText, 100) ?? "", allNodes);
+ }
+
+ var hasActuals = allNodes.Any(n => n.Node.ActualElapsedMs > 0);
+ var ranked = hasActuals
+ ? allNodes.OrderByDescending(n => n.Node.ActualElapsedMs)
+ : allNodes.OrderByDescending(n => n.Node.CostPercent);
+
+ var result = ranked.Take(top).Select(n => new
+ {
+ node_id = n.Node.NodeId,
+ physical_op = n.Node.PhysicalOp,
+ logical_op = n.Node.LogicalOp,
+ cost_percent = n.Node.CostPercent,
+ estimated_rows = n.Node.EstimateRows,
+ actual_rows = n.Node.ActualRows,
+ actual_elapsed_ms = n.Node.ActualElapsedMs,
+ actual_cpu_ms = n.Node.ActualCPUMs,
+ logical_reads = n.Node.ActualLogicalReads,
+ physical_reads = n.Node.ActualPhysicalReads,
+ object_name = n.Node.ObjectName,
+ statement = n.Statement
+ });
+
+ return JsonSerializer.Serialize(new { ranked_by = hasActuals ? "actual_elapsed_ms" : "cost_percent", operators = result },
+ McpHelpers.JsonOptions);
+ }
+
+ [McpServerTool(Name = "get_plan_xml")]
+ [Description("Returns the raw showplan XML for a loaded plan. Useful when you need to examine " +
+ "plan details not captured in the structured analysis. Truncated at 500KB.")]
+ public static string GetPlanXml(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ return McpHelpers.Truncate(session.Plan.RawXml, 512_000) ?? "No plan XML available.";
+ }
+
+ [McpServerTool(Name = "compare_plans")]
+ [Description("Compares two loaded plans side by side. Returns differences in cost, DOP, warnings, " +
+ "memory grants, runtime stats, and operator shapes.")]
+ public static string ComparePlans(
+ PlanSessionManager sessionManager,
+ [Description("Session ID of the first plan (from list_plans).")] string session_id_a,
+ [Description("Session ID of the second plan (from list_plans).")] string session_id_b)
+ {
+ var sessionA = sessionManager.GetSession(session_id_a);
+ if (sessionA == null)
+ return SessionNotFound(sessionManager, session_id_a);
+
+ var sessionB = sessionManager.GetSession(session_id_b);
+ if (sessionB == null)
+ return SessionNotFound(sessionManager, session_id_b);
+
+ try
+ {
+ var resultA = ResultMapper.Map(sessionA.Plan, sessionA.Source);
+ var resultB = ResultMapper.Map(sessionB.Plan, sessionB.Source);
+ return ComparisonFormatter.Compare(resultA, resultB, sessionA.Label, sessionB.Label);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("compare_plans", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_repro_script")]
+ [Description("Generates a paste-ready T-SQL reproduction script from a loaded plan. " +
+ "Extracts parameters, SET options, and database context into a runnable sp_executesql call.")]
+ public static string GetReproScript(
+ PlanSessionManager sessionManager,
+ [Description("The session_id from list_plans.")] string session_id)
+ {
+ var session = sessionManager.GetSession(session_id);
+ if (session == null)
+ return SessionNotFound(sessionManager, session_id);
+
+ try
+ {
+ var stmt = session.Plan.Batches
+ .SelectMany(b => b.Statements)
+ .FirstOrDefault(s => s.RootNode != null);
+
+ if (stmt == null)
+ return "No executable statement found in this plan.";
+
+ var queryText = session.QueryText ?? stmt.StatementText ?? "";
+
+ // Extract database from first operator node's DatabaseName property
+ string? databaseName = null;
+ if (stmt.RootNode?.DatabaseName != null)
+ databaseName = stmt.RootNode.DatabaseName;
+
+ return ReproScriptBuilder.BuildReproScript(
+ queryText, databaseName, session.Plan.RawXml, null);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_repro_script", ex);
+ }
+ }
+
+ private static string SessionNotFound(PlanSessionManager sessionManager, string sessionId)
+ {
+ var available = sessionManager.GetAllSessions();
+ if (available.Count == 0)
+ return "No plans are currently loaded in the application.";
+ return $"Session '{sessionId}' not found. Use list_plans to see available sessions.";
+ }
+
+ private static void CollectNodes(PlanNode node, string statement, List<(PlanNode, string)> nodes)
+ {
+ nodes.Add((node, statement));
+ foreach (var child in node.Children)
+ CollectNodes(child, statement, nodes);
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
index d3ca2e4..40933c3 100644
--- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
+++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs
@@ -1,246 +1,246 @@
-using System;
-using System.ComponentModel;
-using System.Linq;
-using System.Text.Json;
-using System.Threading.Tasks;
-using ModelContextProtocol.Server;
-using PlanViewer.App.Services;
-using PlanViewer.Core.Interfaces;
-using PlanViewer.Core.Models;
-using PlanViewer.Core.Services;
-
-#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
-
-namespace PlanViewer.App.Mcp;
-
-[McpServerToolType]
-public sealed class McpQueryStoreTools
-{
- [McpServerTool(Name = "check_query_store")]
- [Description("Checks whether Query Store is enabled and accessible on a database. " +
- "Use this before calling get_query_store_top to verify the target database supports Query Store.")]
- public static async Task CheckQueryStore(
- ConnectionStore connectionStore,
- ICredentialService credentialService,
- [Description("Server name from get_connections.")] string connection_name,
- [Description("Database name to check.")] string database)
- {
- try
- {
- var conn = FindConnection(connectionStore, connection_name);
- if (conn == null)
- return ConnectionNotFound(connectionStore, connection_name);
-
- var connectionString = conn.GetConnectionString(credentialService, database);
- var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
-
- return JsonSerializer.Serialize(new
- {
- server = conn.ServerName,
- database,
- query_store_enabled = enabled,
- state
- }, McpHelpers.JsonOptions);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("check_query_store", ex);
- }
- }
-
- [McpServerTool(Name = "get_query_store_top")]
- [Description("Fetches the top N queries from Query Store ranked by the specified metric. " +
- "Uses the application's built-in Query Store query — no arbitrary SQL is executed. " +
- "Each fetched plan is automatically loaded into the application for further analysis " +
- "with analyze_plan, get_plan_warnings, etc. Returns summary stats and session IDs. " +
- "Optional filters narrow results server-side by query_id, plan_id, query_hash, " +
- "plan_hash, or module name (schema.name, supports % wildcards).")]
- public static async Task GetQueryStoreTop(
- PlanSessionManager sessionManager,
- ConnectionStore connectionStore,
- ICredentialService credentialService,
- [Description("Server name from get_connections.")] string connection_name,
- [Description("Database name to query.")] string database,
- [Description("Number of top queries to return. Default 10, max 50.")] int top = 10,
- [Description("Ranking metric: cpu, avg-cpu, duration, avg-duration, reads, avg-reads, " +
- "writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions. " +
- "Default: cpu.")] string order_by = "cpu",
- [Description("Hours of history to include. Default 24, max 168.")] int hours_back = 24,
- [Description("Filter by Query Store query ID.")] long? query_id = null,
- [Description("Filter by Query Store plan ID.")] long? plan_id = null,
- [Description("Filter by query hash (hex, e.g. 0x1AB2C3D4).")] string? query_hash = null,
- [Description("Filter by query plan hash (hex, e.g. 0x1AB2C3D4).")] string? plan_hash = null,
- [Description("Filter by module name (schema.name, supports % wildcards).")] string? module = null)
- {
- try
- {
- var conn = FindConnection(connectionStore, connection_name);
- if (conn == null)
- return ConnectionNotFound(connectionStore, connection_name);
-
- // Validate parameters
- if (top < 1 || top > 50)
- return "Invalid top value. Must be between 1 and 50.";
- if (hours_back < 1 || hours_back > 168)
- return "Invalid hours_back value. Must be between 1 and 168.";
-
- QueryStoreFilter? filter = null;
- if (query_id != null || plan_id != null ||
- query_hash != null || plan_hash != null || module != null)
- {
- filter = new QueryStoreFilter
- {
- QueryId = query_id,
- PlanId = plan_id,
- QueryHash = query_hash,
- QueryPlanHash = plan_hash,
- ModuleName = module,
- };
- }
-
- var connectionString = conn.GetConnectionString(credentialService, database);
-
- // Check Query Store is enabled first
- var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
- if (!enabled)
- return $"Query Store is not enabled on [{database}]. State: {state ?? "unknown"}.";
-
- // Fetch plans using the app's built-in query
- var plans = await QueryStoreService.FetchTopPlansAsync(
- connectionString, top, order_by, hours_back, filter);
-
- if (plans.Count == 0)
- return $"No Query Store data found in [{database}] for the last {hours_back} hours.";
-
- // Fetch server metadata for Rule 38 (Standard Edition DOP limitation)
- ServerMetadata? serverMetadata = null;
- try
- {
- var isAzure = conn.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) ||
- conn.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase);
- serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(connectionString, isAzure);
- }
- catch
- {
- // Non-fatal: analysis continues without server context
- }
-
- // Parse and register each plan with PlanSessionManager
- var results = plans.Select(qsPlan =>
- {
- var sessionId = Guid.NewGuid().ToString();
- var label = $"QS:{database} Q{qsPlan.QueryId} P{qsPlan.PlanId}";
-
- try
- {
- var xml = qsPlan.PlanXml
- .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
- var parsed = ShowPlanParser.Parse(xml);
- PlanAnalyzer.Analyze(parsed, serverMetadata: serverMetadata);
- BenefitScorer.Score(parsed);
-
- var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList();
-
- sessionManager.Register(sessionId, new PlanSession
- {
- SessionId = sessionId,
- Label = label,
- Source = "query-store",
- Plan = parsed,
- QueryText = qsPlan.QueryText,
- ConnectionInfo = conn.ServerName,
- StatementCount = allStatements.Count,
- HasActualStats = false, // Query Store plans are always estimated
- WarningCount = allStatements.Sum(s => s.PlanWarnings.Count),
- CriticalWarningCount = allStatements.Sum(s =>
- s.PlanWarnings.Count(w => w.Severity == Core.Models.PlanWarningSeverity.Critical)),
- MissingIndexCount = parsed.AllMissingIndexes.Count
- });
-
- return new
- {
- session_id = sessionId,
- query_id = qsPlan.QueryId,
- plan_id = qsPlan.PlanId,
- query_hash = qsPlan.QueryHash,
- query_plan_hash = qsPlan.QueryPlanHash,
- module_name = string.IsNullOrEmpty(qsPlan.ModuleName) ? (string?)null : qsPlan.ModuleName,
- label,
- query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
- executions = qsPlan.CountExecutions,
- total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
- avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
- total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
- avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
- total_logical_reads = qsPlan.TotalLogicalIoReads,
- avg_logical_reads = qsPlan.AvgLogicalIoReads,
- warning_count = allStatements.Sum(s => s.PlanWarnings.Count),
- missing_index_count = parsed.AllMissingIndexes.Count,
- last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
- loaded = true
- };
- }
- catch
- {
- // Plan XML couldn't be parsed — return stats without loading
- return new
- {
- session_id = (string)"",
- query_id = qsPlan.QueryId,
- plan_id = qsPlan.PlanId,
- query_hash = qsPlan.QueryHash,
- query_plan_hash = qsPlan.QueryPlanHash,
- module_name = string.IsNullOrEmpty(qsPlan.ModuleName) ? (string?)null : qsPlan.ModuleName,
- label,
- query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
- executions = qsPlan.CountExecutions,
- total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
- avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
- total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
- avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
- total_logical_reads = qsPlan.TotalLogicalIoReads,
- avg_logical_reads = qsPlan.AvgLogicalIoReads,
- warning_count = 0,
- missing_index_count = 0,
- last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
- loaded = false
- };
- }
- }).ToList();
-
- return JsonSerializer.Serialize(new
- {
- server = conn.ServerName,
- database,
- order_by,
- hours_back,
- plan_count = results.Count,
- plans = results
- }, McpHelpers.JsonOptions);
- }
- catch (Exception ex)
- {
- return McpHelpers.FormatError("get_query_store_top", ex);
- }
- }
-
- private static Core.Models.ServerConnection? FindConnection(
- ConnectionStore store, string name)
- {
- var connections = store.Load();
- return connections.FirstOrDefault(c =>
- c.ServerName.Equals(name, StringComparison.OrdinalIgnoreCase) ||
- (!string.IsNullOrEmpty(c.DisplayName) &&
- c.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)));
- }
-
- private static string ConnectionNotFound(ConnectionStore store, string name)
- {
- var connections = store.Load();
- if (connections.Count == 0)
- return "No saved connections. Add a connection in the application via the query editor toolbar.";
- var available = string.Join(", ", connections.Select(c =>
- string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : $"{c.DisplayName} ({c.ServerName})"));
- return $"Connection '{name}' not found. Available: {available}";
- }
-}
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using ModelContextProtocol.Server;
+using PlanViewer.App.Services;
+using PlanViewer.Core.Interfaces;
+using PlanViewer.Core.Models;
+using PlanViewer.Core.Services;
+
+#pragma warning disable CA1707 // Identifiers should not contain underscores (MCP snake_case convention)
+
+namespace PlanViewer.App.Mcp;
+
+[McpServerToolType]
+public sealed class McpQueryStoreTools
+{
+ [McpServerTool(Name = "check_query_store")]
+ [Description("Checks whether Query Store is enabled and accessible on a database. " +
+ "Use this before calling get_query_store_top to verify the target database supports Query Store.")]
+ public static async Task CheckQueryStore(
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ [Description("Server name from get_connections.")] string connection_name,
+ [Description("Database name to check.")] string database)
+ {
+ try
+ {
+ var conn = FindConnection(connectionStore, connection_name);
+ if (conn == null)
+ return ConnectionNotFound(connectionStore, connection_name);
+
+ var connectionString = conn.GetConnectionString(credentialService, database);
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
+
+ return JsonSerializer.Serialize(new
+ {
+ server = conn.ServerName,
+ database,
+ query_store_enabled = enabled,
+ state
+ }, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("check_query_store", ex);
+ }
+ }
+
+ [McpServerTool(Name = "get_query_store_top")]
+ [Description("Fetches the top N queries from Query Store ranked by the specified metric. " +
+ "Uses the application's built-in Query Store query — no arbitrary SQL is executed. " +
+ "Each fetched plan is automatically loaded into the application for further analysis " +
+ "with analyze_plan, get_plan_warnings, etc. Returns summary stats and session IDs. " +
+ "Optional filters narrow results server-side by query_id, plan_id, query_hash, " +
+ "plan_hash, or module name (schema.name, supports % wildcards).")]
+ public static async Task GetQueryStoreTop(
+ PlanSessionManager sessionManager,
+ ConnectionStore connectionStore,
+ ICredentialService credentialService,
+ [Description("Server name from get_connections.")] string connection_name,
+ [Description("Database name to query.")] string database,
+ [Description("Number of top queries to return. Default 10, max 50.")] int top = 10,
+ [Description("Ranking metric: cpu, avg-cpu, duration, avg-duration, reads, avg-reads, " +
+ "writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions. " +
+ "Default: cpu.")] string order_by = "cpu",
+ [Description("Hours of history to include. Default 24, max 168.")] int hours_back = 24,
+ [Description("Filter by Query Store query ID.")] long? query_id = null,
+ [Description("Filter by Query Store plan ID.")] long? plan_id = null,
+ [Description("Filter by query hash (hex, e.g. 0x1AB2C3D4).")] string? query_hash = null,
+ [Description("Filter by query plan hash (hex, e.g. 0x1AB2C3D4).")] string? plan_hash = null,
+ [Description("Filter by module name (schema.name, supports % wildcards).")] string? module = null)
+ {
+ try
+ {
+ var conn = FindConnection(connectionStore, connection_name);
+ if (conn == null)
+ return ConnectionNotFound(connectionStore, connection_name);
+
+ // Validate parameters
+ if (top < 1 || top > 50)
+ return "Invalid top value. Must be between 1 and 50.";
+ if (hours_back < 1 || hours_back > 168)
+ return "Invalid hours_back value. Must be between 1 and 168.";
+
+ QueryStoreFilter? filter = null;
+ if (query_id != null || plan_id != null ||
+ query_hash != null || plan_hash != null || module != null)
+ {
+ filter = new QueryStoreFilter
+ {
+ QueryId = query_id,
+ PlanId = plan_id,
+ QueryHash = query_hash,
+ QueryPlanHash = plan_hash,
+ ModuleName = module,
+ };
+ }
+
+ var connectionString = conn.GetConnectionString(credentialService, database);
+
+ // Check Query Store is enabled first
+ var (enabled, state) = await QueryStoreService.CheckEnabledAsync(connectionString);
+ if (!enabled)
+ return $"Query Store is not enabled on [{database}]. State: {state ?? "unknown"}.";
+
+ // Fetch plans using the app's built-in query
+ var plans = await QueryStoreService.FetchTopPlansAsync(
+ connectionString, top, order_by, hours_back, filter);
+
+ if (plans.Count == 0)
+ return $"No Query Store data found in [{database}] for the last {hours_back} hours.";
+
+ // Fetch server metadata for Rule 38 (Standard Edition DOP limitation)
+ ServerMetadata? serverMetadata = null;
+ try
+ {
+ var isAzure = conn.ServerName.Contains(".database.windows.net", StringComparison.OrdinalIgnoreCase) ||
+ conn.ServerName.Contains(".database.azure.com", StringComparison.OrdinalIgnoreCase);
+ serverMetadata = await ServerMetadataService.FetchServerMetadataAsync(connectionString, isAzure);
+ }
+ catch
+ {
+ // Non-fatal: analysis continues without server context
+ }
+
+ // Parse and register each plan with PlanSessionManager
+ var results = plans.Select(qsPlan =>
+ {
+ var sessionId = Guid.NewGuid().ToString();
+ var label = $"QS:{database} Q{qsPlan.QueryId} P{qsPlan.PlanId}";
+
+ try
+ {
+ var xml = qsPlan.PlanXml
+ .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\"");
+ var parsed = ShowPlanParser.Parse(xml);
+ PlanAnalyzer.Analyze(parsed, serverMetadata: serverMetadata);
+ BenefitScorer.Score(parsed);
+
+ var allStatements = parsed.Batches.SelectMany(b => b.Statements).ToList();
+
+ sessionManager.Register(sessionId, new PlanSession
+ {
+ SessionId = sessionId,
+ Label = label,
+ Source = "query-store",
+ Plan = parsed,
+ QueryText = qsPlan.QueryText,
+ ConnectionInfo = conn.ServerName,
+ StatementCount = allStatements.Count,
+ HasActualStats = false, // Query Store plans are always estimated
+ WarningCount = allStatements.Sum(s => s.PlanWarnings.Count),
+ CriticalWarningCount = allStatements.Sum(s =>
+ s.PlanWarnings.Count(w => w.Severity == Core.Models.PlanWarningSeverity.Critical)),
+ MissingIndexCount = parsed.AllMissingIndexes.Count
+ });
+
+ return new
+ {
+ session_id = sessionId,
+ query_id = qsPlan.QueryId,
+ plan_id = qsPlan.PlanId,
+ query_hash = qsPlan.QueryHash,
+ query_plan_hash = qsPlan.QueryPlanHash,
+ module_name = string.IsNullOrEmpty(qsPlan.ModuleName) ? (string?)null : qsPlan.ModuleName,
+ label,
+ query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
+ executions = qsPlan.CountExecutions,
+ total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
+ avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
+ total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
+ avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
+ total_logical_reads = qsPlan.TotalLogicalIoReads,
+ avg_logical_reads = qsPlan.AvgLogicalIoReads,
+ warning_count = allStatements.Sum(s => s.PlanWarnings.Count),
+ missing_index_count = parsed.AllMissingIndexes.Count,
+ last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
+ loaded = true
+ };
+ }
+ catch
+ {
+ // Plan XML couldn't be parsed — return stats without loading
+ return new
+ {
+ session_id = (string)"",
+ query_id = qsPlan.QueryId,
+ plan_id = qsPlan.PlanId,
+ query_hash = qsPlan.QueryHash,
+ query_plan_hash = qsPlan.QueryPlanHash,
+ module_name = string.IsNullOrEmpty(qsPlan.ModuleName) ? (string?)null : qsPlan.ModuleName,
+ label,
+ query_text = McpHelpers.Truncate(qsPlan.QueryText, 500),
+ executions = qsPlan.CountExecutions,
+ total_cpu_ms = qsPlan.TotalCpuTimeUs / 1000.0,
+ avg_cpu_ms = qsPlan.AvgCpuTimeUs / 1000.0,
+ total_duration_ms = qsPlan.TotalDurationUs / 1000.0,
+ avg_duration_ms = qsPlan.AvgDurationUs / 1000.0,
+ total_logical_reads = qsPlan.TotalLogicalIoReads,
+ avg_logical_reads = qsPlan.AvgLogicalIoReads,
+ warning_count = 0,
+ missing_index_count = 0,
+ last_executed_utc = qsPlan.LastExecutedUtc.ToString("yyyy-MM-dd HH:mm:ss"),
+ loaded = false
+ };
+ }
+ }).ToList();
+
+ return JsonSerializer.Serialize(new
+ {
+ server = conn.ServerName,
+ database,
+ order_by,
+ hours_back,
+ plan_count = results.Count,
+ plans = results
+ }, McpHelpers.JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return McpHelpers.FormatError("get_query_store_top", ex);
+ }
+ }
+
+ private static Core.Models.ServerConnection? FindConnection(
+ ConnectionStore store, string name)
+ {
+ var connections = store.Load();
+ return connections.FirstOrDefault(c =>
+ c.ServerName.Equals(name, StringComparison.OrdinalIgnoreCase) ||
+ (!string.IsNullOrEmpty(c.DisplayName) &&
+ c.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ private static string ConnectionNotFound(ConnectionStore store, string name)
+ {
+ var connections = store.Load();
+ if (connections.Count == 0)
+ return "No saved connections. Add a connection in the application via the query editor toolbar.";
+ var available = string.Join(", ", connections.Select(c =>
+ string.IsNullOrEmpty(c.DisplayName) ? c.ServerName : $"{c.DisplayName} ({c.ServerName})"));
+ return $"Connection '{name}' not found. Available: {available}";
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/McpSettings.cs b/src/PlanViewer.App/Mcp/McpSettings.cs
index 7caf431..da7232c 100644
--- a/src/PlanViewer.App/Mcp/McpSettings.cs
+++ b/src/PlanViewer.App/Mcp/McpSettings.cs
@@ -1,37 +1,37 @@
-using System;
-using System.IO;
-using System.Text.Json;
-
-namespace PlanViewer.App.Mcp;
-
-internal sealed class McpSettings
-{
- public bool Enabled { get; set; }
- public int Port { get; set; } = 5152;
-
- public static McpSettings Load()
- {
- var path = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
- ".planview", "settings.json");
-
- if (!File.Exists(path))
- return new McpSettings();
-
- try
- {
- var json = File.ReadAllText(path);
- using var doc = JsonDocument.Parse(json);
- var root = doc.RootElement;
- return new McpSettings
- {
- Enabled = root.TryGetProperty("mcp_enabled", out var e) && e.GetBoolean(),
- Port = root.TryGetProperty("mcp_port", out var p) ? p.GetInt32() : 5152
- };
- }
- catch
- {
- return new McpSettings();
- }
- }
-}
+using System;
+using System.IO;
+using System.Text.Json;
+
+namespace PlanViewer.App.Mcp;
+
+internal sealed class McpSettings
+{
+ public bool Enabled { get; set; }
+ public int Port { get; set; } = 5152;
+
+ public static McpSettings Load()
+ {
+ var path = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".planview", "settings.json");
+
+ if (!File.Exists(path))
+ return new McpSettings();
+
+ try
+ {
+ var json = File.ReadAllText(path);
+ using var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+ return new McpSettings
+ {
+ Enabled = root.TryGetProperty("mcp_enabled", out var e) && e.GetBoolean(),
+ Port = root.TryGetProperty("mcp_port", out var p) ? p.GetInt32() : 5152
+ };
+ }
+ catch
+ {
+ return new McpSettings();
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Mcp/PlanSessionManager.cs b/src/PlanViewer.App/Mcp/PlanSessionManager.cs
index 65da26a..b20a5ee 100644
--- a/src/PlanViewer.App/Mcp/PlanSessionManager.cs
+++ b/src/PlanViewer.App/Mcp/PlanSessionManager.cs
@@ -1,70 +1,70 @@
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using PlanViewer.Core.Models;
-
-namespace PlanViewer.App.Mcp;
-
-///
-/// Thread-safe bridge between UI plan state and MCP tools.
-/// The UI registers/unregisters plans as tabs are opened/closed.
-/// MCP tools read plan data without touching the UI thread.
-///
-public sealed class PlanSessionManager
-{
- public static PlanSessionManager Instance { get; } = new();
-
- private readonly ConcurrentDictionary _sessions = new();
-
- public void Register(string sessionId, PlanSession session) =>
- _sessions[sessionId] = session;
-
- public void Unregister(string sessionId) =>
- _sessions.TryRemove(sessionId, out _);
-
- public PlanSession? GetSession(string sessionId) =>
- _sessions.TryGetValue(sessionId, out var session) ? session : null;
-
- public IReadOnlyList GetAllSessions() =>
- _sessions.Values.Select(s => new PlanSessionSummary
- {
- SessionId = s.SessionId,
- Label = s.Label,
- Source = s.Source,
- StatementCount = s.StatementCount,
- WarningCount = s.WarningCount,
- CriticalWarningCount = s.CriticalWarningCount,
- MissingIndexCount = s.MissingIndexCount,
- HasActualStats = s.HasActualStats
- }).ToList();
-}
-
-///
-/// Immutable snapshot of a loaded plan, safe for cross-thread reads by MCP tools.
-///
-public sealed class PlanSession
-{
- public required string SessionId { get; init; }
- public required string Label { get; init; }
- public required string Source { get; init; }
- public required ParsedPlan Plan { get; init; }
- public string? QueryText { get; init; }
- public string? ConnectionInfo { get; init; }
- public int StatementCount { get; init; }
- public bool HasActualStats { get; init; }
- public int WarningCount { get; init; }
- public int CriticalWarningCount { get; init; }
- public int MissingIndexCount { get; init; }
-}
-
-public sealed class PlanSessionSummary
-{
- public string SessionId { get; set; } = "";
- public string Label { get; set; } = "";
- public string Source { get; set; } = "";
- public int StatementCount { get; set; }
- public int WarningCount { get; set; }
- public int CriticalWarningCount { get; set; }
- public int MissingIndexCount { get; set; }
- public bool HasActualStats { get; set; }
-}
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using PlanViewer.Core.Models;
+
+namespace PlanViewer.App.Mcp;
+
+///
+/// Thread-safe bridge between UI plan state and MCP tools.
+/// The UI registers/unregisters plans as tabs are opened/closed.
+/// MCP tools read plan data without touching the UI thread.
+///
+public sealed class PlanSessionManager
+{
+ public static PlanSessionManager Instance { get; } = new();
+
+ private readonly ConcurrentDictionary _sessions = new();
+
+ public void Register(string sessionId, PlanSession session) =>
+ _sessions[sessionId] = session;
+
+ public void Unregister(string sessionId) =>
+ _sessions.TryRemove(sessionId, out _);
+
+ public PlanSession? GetSession(string sessionId) =>
+ _sessions.TryGetValue(sessionId, out var session) ? session : null;
+
+ public IReadOnlyList GetAllSessions() =>
+ _sessions.Values.Select(s => new PlanSessionSummary
+ {
+ SessionId = s.SessionId,
+ Label = s.Label,
+ Source = s.Source,
+ StatementCount = s.StatementCount,
+ WarningCount = s.WarningCount,
+ CriticalWarningCount = s.CriticalWarningCount,
+ MissingIndexCount = s.MissingIndexCount,
+ HasActualStats = s.HasActualStats
+ }).ToList();
+}
+
+///
+/// Immutable snapshot of a loaded plan, safe for cross-thread reads by MCP tools.
+///
+public sealed class PlanSession
+{
+ public required string SessionId { get; init; }
+ public required string Label { get; init; }
+ public required string Source { get; init; }
+ public required ParsedPlan Plan { get; init; }
+ public string? QueryText { get; init; }
+ public string? ConnectionInfo { get; init; }
+ public int StatementCount { get; init; }
+ public bool HasActualStats { get; init; }
+ public int WarningCount { get; init; }
+ public int CriticalWarningCount { get; init; }
+ public int MissingIndexCount { get; init; }
+}
+
+public sealed class PlanSessionSummary
+{
+ public string SessionId { get; set; } = "";
+ public string Label { get; set; } = "";
+ public string Source { get; set; } = "";
+ public int StatementCount { get; set; }
+ public int WarningCount { get; set; }
+ public int CriticalWarningCount { get; set; }
+ public int MissingIndexCount { get; set; }
+ public bool HasActualStats { get; set; }
+}
diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj
index 6b970fc..5156d92 100644
--- a/src/PlanViewer.App/PlanViewer.App.csproj
+++ b/src/PlanViewer.App/PlanViewer.App.csproj
@@ -6,7 +6,7 @@
app.manifest
EDD.ico
true
- 1.9.0
+ 1.10.0
Erik Darling
Darling Data LLC
Performance Studio
diff --git a/src/PlanViewer.App/Program.cs b/src/PlanViewer.App/Program.cs
index a7d2132..ca4b1fd 100644
--- a/src/PlanViewer.App/Program.cs
+++ b/src/PlanViewer.App/Program.cs
@@ -1,67 +1,67 @@
-using Avalonia;
-using System;
-using System.IO;
-using System.IO.Pipes;
-using System.Threading;
-using Velopack;
-
-namespace PlanViewer.App;
-
-class Program
-{
- private const string PipeName = "SQLPerformanceStudio_OpenFile";
- private const string MutexName = "SQLPerformanceStudio_SingleInstance";
-
- [STAThread]
- public static void Main(string[] args)
- {
- VelopackApp.Build().Run();
-
- // If another instance is running, send the file path to it and exit
- if (args.Length > 0 && TrySendToRunningInstance(args[0]))
- return;
-
- BuildAvaloniaApp()
- .StartWithClassicDesktopLifetime(args);
- }
-
- // Avalonia configuration, don't remove; also used by visual designer.
- public static AppBuilder BuildAvaloniaApp()
- => AppBuilder.Configure()
- .UsePlatformDetect()
- .WithInterFont()
- .LogToTrace();
-
- ///
- /// Tries to connect to an already-running instance and send the file path.
- /// Returns true if the message was delivered (caller should exit).
- ///
- private static bool TrySendToRunningInstance(string filePath)
- {
- bool createdNew;
- using var mutex = new Mutex(true, MutexName, out createdNew);
-
- if (createdNew)
- {
- // We're the first instance — release and let normal startup proceed
- mutex.ReleaseMutex();
- return false;
- }
-
- // Another instance owns the mutex — try sending the file path
- try
- {
- using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
- client.Connect(3000); // 3 second timeout
- using var writer = new StreamWriter(client);
- writer.WriteLine(filePath);
- writer.Flush();
- return true;
- }
- catch
- {
- // Pipe not available — fall through to launch normally
- return false;
- }
- }
-}
+using Avalonia;
+using System;
+using System.IO;
+using System.IO.Pipes;
+using System.Threading;
+using Velopack;
+
+namespace PlanViewer.App;
+
+class Program
+{
+ private const string PipeName = "SQLPerformanceStudio_OpenFile";
+ private const string MutexName = "SQLPerformanceStudio_SingleInstance";
+
+ [STAThread]
+ public static void Main(string[] args)
+ {
+ VelopackApp.Build().Run();
+
+ // If another instance is running, send the file path to it and exit
+ if (args.Length > 0 && TrySendToRunningInstance(args[0]))
+ return;
+
+ BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+ }
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+
+ ///
+ /// Tries to connect to an already-running instance and send the file path.
+ /// Returns true if the message was delivered (caller should exit).
+ ///
+ private static bool TrySendToRunningInstance(string filePath)
+ {
+ bool createdNew;
+ using var mutex = new Mutex(true, MutexName, out createdNew);
+
+ if (createdNew)
+ {
+ // We're the first instance — release and let normal startup proceed
+ mutex.ReleaseMutex();
+ return false;
+ }
+
+ // Another instance owns the mutex — try sending the file path
+ try
+ {
+ using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
+ client.Connect(3000); // 3 second timeout
+ using var writer = new StreamWriter(client);
+ writer.WriteLine(filePath);
+ writer.Flush();
+ return true;
+ }
+ catch
+ {
+ // Pipe not available — fall through to launch normally
+ return false;
+ }
+ }
+}
diff --git a/src/PlanViewer.App/Services/AdviceContentBuilder.cs b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
index 875644b..ab68bad 100644
--- a/src/PlanViewer.App/Services/AdviceContentBuilder.cs
+++ b/src/PlanViewer.App/Services/AdviceContentBuilder.cs
@@ -1,1227 +1,1227 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text.RegularExpressions;
-using Avalonia.Controls;
-using Avalonia.Controls.Documents;
-using Avalonia.Layout;
-using Avalonia.Media;
-using PlanViewer.Core.Output;
-using PlanViewer.Core.Services;
-
-namespace PlanViewer.App.Services;
-
-///
-/// Builds styled content for the Advice for Humans window.
-/// Shared between MainWindow (file mode) and QuerySessionControl (query mode).
-///
-internal static class AdviceContentBuilder
-{
- private static readonly SolidColorBrush HeaderBrush = new(Color.Parse("#4FA3FF"));
- private static readonly SolidColorBrush CriticalBrush = new(Color.Parse("#E57373"));
- private static readonly SolidColorBrush WarningBrush = new(Color.Parse("#FFB347"));
- private static readonly SolidColorBrush InfoBrush = new(Color.Parse("#6BB5FF"));
- private static readonly SolidColorBrush LabelBrush = new(Color.Parse("#E4E6EB"));
- private static readonly SolidColorBrush ValueBrush = new(Color.Parse("#E4E6EB"));
- private static readonly SolidColorBrush CodeBrush = new(Color.Parse("#7BCF7B"));
- private static readonly SolidColorBrush MutedBrush = new(Color.Parse("#E4E6EB"));
- private static readonly SolidColorBrush OperatorBrush = new(Color.Parse("#C792EA"));
- private static readonly SolidColorBrush SqlKeywordBrush = new(Color.Parse("#569CD6"));
- private static readonly SolidColorBrush SeparatorBrush = new(Color.Parse("#2A2D35"));
- private static readonly SolidColorBrush WarningAccentBrush = new(Color.Parse("#332A1A"));
- private static readonly SolidColorBrush CardBackgroundBrush = new(Color.Parse("#1A2233"));
- private static readonly SolidColorBrush AmberBarBrush = new(Color.Parse("#FFB347"));
- private static readonly SolidColorBrush BlueBarBrush = new(Color.Parse("#4FA3FF"));
- private static readonly FontFamily MonoFont = new("Consolas, Menlo, monospace");
-
- private const double MaxBarWidth = 200.0;
-
- private static readonly HashSet PhysicalOperators = new(StringComparer.OrdinalIgnoreCase)
- {
- "Sort", "Filter", "Bitmap", "Hash Match", "Merge Join", "Nested Loops",
- "Stream Aggregate", "Compute Scalar", "Table Scan", "Index Scan",
- "Clustered Index Scan", "Table Spool", "Index Spool", "Constant Scan",
- "Concatenation", "Assert", "Segment", "Sequence Project", "Window Aggregate",
- "Adaptive Join", "Row Count Spool", "Lazy Spool", "Eager Spool",
- "Columnstore Index Scan", "Batch Hash Table Build", "Parallelism",
- "Top", "Index Seek", "Clustered Index Seek", "RID Lookup",
- "Key Lookup", "Clustered Index Update", "Clustered Index Insert",
- "Clustered Index Delete", "Table Insert", "Table Delete"
- };
-
- private static readonly HashSet SqlKeywords = new(StringComparer.OrdinalIgnoreCase)
- {
- "SELECT", "FROM", "WHERE", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "CROSS",
- "ON", "AND", "OR", "NOT", "IN", "EXISTS", "BETWEEN", "LIKE", "IS", "NULL",
- "INSERT", "INTO", "UPDATE", "SET", "DELETE", "MERGE", "USING", "MATCHED",
- "GROUP", "BY", "ORDER", "HAVING", "UNION", "ALL", "EXCEPT", "INTERSECT",
- "TOP", "DISTINCT", "AS", "WITH", "CTE", "OVER", "PARTITION", "ROW_NUMBER",
- "CASE", "WHEN", "THEN", "ELSE", "END", "CAST", "CONVERT", "COALESCE",
- "CREATE", "ALTER", "DROP", "INDEX", "TABLE", "VIEW", "PROCEDURE", "FUNCTION",
- "EXEC", "EXECUTE", "DECLARE", "BEGIN", "RETURN", "IF", "WHILE",
- "ASC", "DESC", "OFFSET", "FETCH", "NEXT", "ROWS", "ONLY",
- "COUNT", "SUM", "AVG", "MIN", "MAX", "APPLY", "PIVOT", "UNPIVOT",
- "NONCLUSTERED", "CLUSTERED", "INCLUDE", "OPTION", "RECOMPILE", "MAXDOP",
- "NOLOCK", "READUNCOMMITTED", "READCOMMITTED", "SERIALIZABLE", "HOLDLOCK"
- };
-
- private static readonly Regex CpuPercentRegex = new(@"(\d+)%\)", RegexOptions.Compiled);
-
- // Matches "Node N" or "(Node N)" references in text
- private static readonly Regex NodeRefRegex = new(@"(?<=\(?)\bNode\s+(\d+)\b(?=\)?)", RegexOptions.Compiled);
-
- private static readonly SolidColorBrush LinkBrush = new(Color.Parse("#4FC3F7"));
- private static readonly Avalonia.Input.Cursor HandCursor = new(Avalonia.Input.StandardCursorType.Hand);
-
- public static StackPanel Build(string content)
- {
- return Build(content, null, null);
- }
-
- public static StackPanel Build(string content, AnalysisResult? analysis)
- {
- return Build(content, analysis, null);
- }
-
- public static StackPanel Build(string content, AnalysisResult? analysis, Action? onNodeClick)
- {
- var panel = new StackPanel { Margin = new Avalonia.Thickness(4, 0) };
- var lines = content.Split('\n');
- var inCodeBlock = false;
- var codeBlockIndent = 0;
- var isStatementText = false;
- var inSubSection = false; // tracks sub-sections within a statement
- var statementIndex = -1; // tracks which statement we're in (0-based)
- var needsTriageCard = false; // inject card on next blank line after SQL text
-
- for (int i = 0; i < lines.Length; i++)
- {
- var line = lines[i].TrimEnd('\r');
-
- // Empty lines — small spacer
- if (string.IsNullOrWhiteSpace(line))
- {
- // Inject triage card on the blank line between SQL text and details
- if (needsTriageCard && analysis != null && statementIndex >= 0
- && statementIndex < analysis.Statements.Count)
- {
- var card = CreateTriageSummaryCard(analysis.Statements[statementIndex]);
- if (card != null)
- panel.Children.Add(card);
- needsTriageCard = false;
- }
-
- panel.Children.Add(new Border { Height = 8 });
- inCodeBlock = false;
- isStatementText = false;
- inSubSection = false;
- continue;
- }
-
- // Section headers: === ... ===
- if (line.StartsWith("===") && line.EndsWith("==="))
- {
- inCodeBlock = false;
- isStatementText = false;
- inSubSection = false;
-
- // Strip === markers, just show the text
- var headerText = line.Trim('=', ' ');
-
- // Add separator before non-first headers
- if (panel.Children.Count > 0)
- {
- panel.Children.Add(new Border
- {
- Height = 1,
- Background = SeparatorBrush,
- Margin = new Avalonia.Thickness(0, 10, 0, 6)
- });
- }
-
- panel.Children.Add(new SelectableTextBlock
- {
- Text = headerText,
- FontFamily = MonoFont,
- FontSize = 14,
- FontWeight = FontWeight.Bold,
- Foreground = HeaderBrush,
- Margin = new Avalonia.Thickness(0, 0, 0, 6),
- TextWrapping = TextWrapping.Wrap
- });
-
- // Statement text follows "Statement N:"
- if (headerText.StartsWith("Statement"))
- {
- isStatementText = true;
- statementIndex++;
- needsTriageCard = true;
- }
-
- continue;
- }
-
- // Statement text (SQL) — highlight keywords
- if (isStatementText)
- {
- panel.Children.Add(BuildSqlHighlightedLine(line));
- continue;
- }
-
- // Warning lines: [Critical], [Warning], [Info] — with left accent border
- if (line.Contains("[Critical]"))
- {
- panel.Children.Add(CreateWarningBlock(line, CriticalBrush));
- continue;
- }
- if (line.Contains("[Warning]"))
- {
- panel.Children.Add(CreateWarningBlock(line, WarningBrush));
- continue;
- }
- if (line.Contains("[Info]"))
- {
- panel.Children.Add(CreateWarningBlock(line, InfoBrush));
- continue;
- }
-
- var trimmed = line.TrimStart();
-
- // Grouped explanation line: " -> The overestimate may have..."
- if (trimmed.StartsWith("-> "))
- {
- panel.Children.Add(new SelectableTextBlock
- {
- Text = trimmed[3..],
- FontFamily = MonoFont,
- FontSize = 12,
- FontStyle = Avalonia.Media.FontStyle.Italic,
- Foreground = MutedBrush,
- Margin = new Avalonia.Thickness(20, 2, 0, 4),
- TextWrapping = TextWrapping.Wrap
- });
- continue;
- }
-
- // SNIFFING marker
- if (line.Contains("[SNIFFING]"))
- {
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(12, 1, 0, 1)
- };
- var sniffIdx = line.IndexOf("[SNIFFING]");
- tb.Inlines!.Add(new Run(line[..sniffIdx].TrimStart()) { Foreground = ValueBrush });
- tb.Inlines.Add(new Run("[SNIFFING]")
- { Foreground = CriticalBrush, FontWeight = FontWeight.SemiBold });
- panel.Children.Add(tb);
- continue;
- }
-
- // Missing index impact line: "dbo.Posts (impact: 95%)"
- if (trimmed.Contains("(impact:") && trimmed.EndsWith("%)"))
- {
- panel.Children.Add(CreateMissingIndexImpactLine(trimmed));
- continue;
- }
-
- // CREATE INDEX lines (multi-line: CREATE..., ON..., INCLUDE..., WHERE...)
- if (trimmed.StartsWith("CREATE", StringComparison.OrdinalIgnoreCase))
- {
- inCodeBlock = true;
- codeBlockIndent = line.Length - trimmed.Length;
- }
- else if (inCodeBlock)
- {
- if (trimmed.StartsWith("ON ", StringComparison.OrdinalIgnoreCase) ||
- trimmed.StartsWith("INCLUDE ", StringComparison.OrdinalIgnoreCase) ||
- trimmed.StartsWith("WHERE ", StringComparison.OrdinalIgnoreCase) ||
- trimmed.StartsWith("WITH ", StringComparison.OrdinalIgnoreCase))
- { /* still in code block */ }
- else
- inCodeBlock = false;
- }
-
- if (inCodeBlock)
- {
- var currentIndent = line.Length - trimmed.Length;
- var displayLine = currentIndent < codeBlockIndent
- ? new string(' ', codeBlockIndent) + trimmed
- : line;
-
- panel.Children.Add(new SelectableTextBlock
- {
- Text = displayLine,
- FontFamily = MonoFont,
- FontSize = 12,
- Foreground = CodeBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(12, 1, 0, 1)
- });
- continue;
- }
-
- // Expensive operators section: highlight operator name + grouped timing + stats
- // Handles both "Operator (Object):" and bare "Sort:" forms
- if (trimmed.EndsWith("):") ||
- (trimmed.EndsWith(":") && PhysicalOperators.Contains(trimmed[..^1])))
- {
- // Peek ahead for timing line and stats line to group with operator
- string? timingLine = null;
- string? statsLine = null;
- var peekIdx = i + 1;
- if (peekIdx < lines.Length)
- {
- var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart();
- if ((nextTrimmed.Contains("ms CPU") || nextTrimmed.Contains("ms elapsed"))
- && nextTrimmed.Length > 0 && char.IsDigit(nextTrimmed[0]))
- {
- timingLine = nextTrimmed;
- peekIdx++;
- }
- }
- // Stats line: "17,142,169 rows, 4,691,534 logical reads, 884 physical reads"
- if (peekIdx < lines.Length)
- {
- var nextTrimmed = lines[peekIdx].TrimEnd('\r').TrimStart();
- if (nextTrimmed.Contains("rows") && nextTrimmed.Length > 0
- && char.IsDigit(nextTrimmed[0]))
- {
- statsLine = nextTrimmed;
- peekIdx++;
- }
- }
- i = peekIdx - 1; // skip consumed lines
- panel.Children.Add(CreateOperatorGroup(line, timingLine, statsLine));
- continue;
- }
-
- // Standalone timing lines (fallback for lines not grouped with an operator)
- if ((trimmed.Contains("ms CPU") || trimmed.Contains("ms elapsed"))
- && trimmed.Length > 0 && char.IsDigit(trimmed[0]))
- {
- panel.Children.Add(CreateOperatorTimingLine(trimmed));
- continue;
- }
-
- // Sub-section labels within a statement: "Warnings:", "Parameters:", "Wait stats:", etc.
- // These have a space or end with ":" after trimming
- if (IsSubSectionLabel(trimmed))
- {
- inSubSection = true;
- panel.Children.Add(new SelectableTextBlock
- {
- Text = trimmed,
- FontFamily = MonoFont,
- FontSize = 13,
- FontWeight = FontWeight.SemiBold,
- Foreground = LabelBrush,
- Margin = new Avalonia.Thickness(8, 6, 0, 4),
- TextWrapping = TextWrapping.Wrap
- });
- continue;
- }
-
- // Bullet lines: " * ..."
- if (trimmed.StartsWith("* "))
- {
- panel.Children.Add(new SelectableTextBlock
- {
- Text = line,
- FontFamily = MonoFont,
- FontSize = 12,
- Foreground = MutedBrush,
- Margin = new Avalonia.Thickness(12, 1, 0, 1),
- TextWrapping = TextWrapping.Wrap
- });
- continue;
- }
-
- // Wait stats lines: " WAITTYPE: 1,234ms" — color by category with proportional bars
- // Collect entire group, find global max, then render all with consistent bar scaling
- if (trimmed.Contains("ms") && trimmed.Contains(':'))
- {
- var waitColon = trimmed.IndexOf(':');
- if (waitColon > 0 && waitColon < trimmed.Length - 1)
- {
- var waitName = trimmed[..waitColon];
- var waitValue = trimmed[(waitColon + 1)..].Trim();
- if (waitValue.EndsWith("ms") && waitName == waitName.ToUpperInvariant() && !waitName.Contains(' '))
- {
- // Collect all wait stat lines in this group
- var waitGroup = new List<(string name, string value)>
- {
- (waitName, waitValue)
- };
- while (i + 1 < lines.Length)
- {
- var nextLine = lines[i + 1].TrimEnd('\r').TrimStart();
- if (string.IsNullOrWhiteSpace(nextLine)) break;
- var nextColon = nextLine.IndexOf(':');
- if (nextColon <= 0 || nextColon >= nextLine.Length - 1) break;
- var nextName = nextLine[..nextColon];
- var nextVal = nextLine[(nextColon + 1)..].Trim();
- if (!nextVal.EndsWith("ms") || nextName != nextName.ToUpperInvariant()
- || nextName.Contains(' '))
- break;
- waitGroup.Add((nextName, nextVal));
- i++;
- }
-
- // Find global max for bar scaling
- var maxWaitMs = 0.0;
- foreach (var (_, val) in waitGroup)
- {
- var ms = ParseWaitMs(val);
- if (ms > maxWaitMs) maxWaitMs = ms;
- }
-
- // Render all lines with consistent scaling
- foreach (var (name, val) in waitGroup)
- panel.Children.Add(CreateWaitStatLine(name, val, maxWaitMs));
-
- continue;
- }
- }
- }
-
- // Key-value lines: "Label: value"
- var colonIdx = line.IndexOf(':');
- if (colonIdx > 0 && colonIdx < line.Length - 1)
- {
- var labelPart = line[..colonIdx].TrimStart();
- if (labelPart.Length < 40 && !labelPart.Contains('(') && !labelPart.Contains('='))
- {
- var indent = inSubSection ? 12.0 : 8.0;
- var tb = new SelectableTextBlock
- {
- FontFamily = MonoFont,
- FontSize = 12,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(indent, 1, 0, 1)
- };
- tb.Inlines!.Add(new Run(labelPart + ":") { Foreground = LabelBrush });
- tb.Inlines.Add(new Run(line[(colonIdx + 1)..]) { Foreground = ValueBrush });
- panel.Children.Add(tb);
- continue;
- }
- }
-
- // Default: regular text
- panel.Children.Add(new SelectableTextBlock
- {
- Text = line,
- FontFamily = MonoFont,
- FontSize = 12,
- Foreground = ValueBrush,
- TextWrapping = TextWrapping.Wrap,
- Margin = new Avalonia.Thickness(0, 1)
- });
- }
-
- // Post-process: make "Node N" references clickable
- if (onNodeClick != null)
- MakeNodeRefsClickable(panel, onNodeClick);
-
- return panel;
- }
-
- ///