Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3e33ed7
Surface Query Store plan-load failures instead of silent "No Plan Loa…
erikdarlingdata Jun 15, 2026
970d68f
Bump all dependencies that can move (everything except Avalonia 12 / …
erikdarlingdata Jun 19, 2026
5df509b
Fix SSMS extension version targeting so the gallery shows SSMS 21/22 …
erikdarlingdata Jun 19, 2026
04cb828
Extract shared NodeTimeAttribution service to Core
erikdarlingdata Jun 19, 2026
28cb112
Split Index.razor god file into components + share service
erikdarlingdata Jun 19, 2026
0c4e2b3
Split PlanViewerControl.Properties.cs into feature partials
erikdarlingdata Jun 19, 2026
01464fb
Split QueryStore History + Overview controls into partials
erikdarlingdata Jun 19, 2026
0bb7947
Extract shared DdlScripter to Core (dedupe drifted copies)
erikdarlingdata Jun 19, 2026
3303fd6
Split ShowPlanParser.ParseRelOp into focused helpers
erikdarlingdata Jun 19, 2026
d3b3815
Extract shared CLI helpers (CliConnectionResolver + PlanAnalysisRunner)
erikdarlingdata Jun 19, 2026
ad7c3de
Add golden-master warning characterization test for PlanAnalyzer
erikdarlingdata Jun 19, 2026
497c403
Extract PlanAnalyzer inline rules into named methods
erikdarlingdata Jun 19, 2026
174af6d
Merge pull request #372 from erikdarlingdata/refactor/god-file-splits
erikdarlingdata Jun 19, 2026
0cd3ac1
Preserve Unix execute bit in Linux/macOS release zips
erikdarlingdata Jun 24, 2026
70cf90a
Register .sqlplan document type in macOS app bundle
erikdarlingdata Jun 24, 2026
7f6d6a9
Bump version to 1.14.0
erikdarlingdata Jun 24, 2026
582c88e
Fix culture-dependent percent formatting in analyzer warnings
erikdarlingdata Jun 24, 2026
d63e7c5
Merge pull request #373 from erikdarlingdata/fix/linux-macos-exec-bit
erikdarlingdata Jun 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/scripts/zip_with_exec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Create a .zip whose entries carry Unix permission bits.

Why this exists
---------------
The release/ nightly workflows run on windows-latest, where PowerShell's
Compress-Archive cannot store Unix file modes. The .NET ZipArchive API can set
external attributes but hardcodes the archive's "host system" byte to Windows
(0), so unzip / macOS / Linux ignore the mode and the self-contained .NET
apphost (PlanViewer.App) extracts WITHOUT its execute bit -- the app then fails
to launch on Linux/macOS ("permission denied").

Python's zipfile lets us set create_system = 3 (Unix) explicitly, so extractors
honor the mode. Files default to 0644, directories to 0755, and any path passed
via --exec / --exec-optional is marked 0755 (rwxr-xr-x).

Usage
-----
python zip_with_exec.py SOURCE_DIR DEST_ZIP
[--exec RELPATH ...] [--exec-optional RELPATH ...]

SOURCE_DIR directory whose *contents* are zipped (the directory itself
is not stored as a top-level entry -- matches the behavior of
`Compress-Archive -Path SOURCE_DIR/*`)
DEST_ZIP output .zip path (overwritten if present)
--exec forward-slash relpath that MUST exist; marked executable.
A missing --exec path is a hard error so a renamed/absent
apphost fails the release loudly instead of shipping a zip
that won't launch.
--exec-optional relpath marked executable if present, skipped if absent
(e.g. createdump, which only some runtime IDs include).
"""
import argparse
import os
import shutil
import stat
import sys
import zipfile

UNIX = 3 # ZIP "version made by" host system: 3 = Unix (so the high word of
# external_attr is read as st_mode by unzip / macOS / Linux).


def main():
ap = argparse.ArgumentParser(description="Zip a directory preserving Unix exec bits.")
ap.add_argument("source_dir")
ap.add_argument("dest_zip")
ap.add_argument("--exec", action="append", default=[], dest="execs",
metavar="RELPATH", help="path that must exist; marked 0755")
ap.add_argument("--exec-optional", action="append", default=[], dest="execs_optional",
metavar="RELPATH", help="path marked 0755 if present")
args = ap.parse_args()

source = os.path.abspath(args.source_dir)
if not os.path.isdir(source):
sys.exit(f"error: source dir not found: {source}")

required = {p.replace("\\", "/").strip("/") for p in args.execs}
optional = {p.replace("\\", "/").strip("/") for p in args.execs_optional}

# Walk first so we can validate that every required exec path is present
# before we start writing the archive.
dir_entries = [] # arcname with trailing slash
file_entries = [] # (abs_path, arcname)
present = set()
for root, dirs, files in os.walk(source):
dirs.sort()
files.sort()
rel_root = os.path.relpath(root, source)
if rel_root != ".":
dir_entries.append(rel_root.replace("\\", "/") + "/")
for fn in files:
abs_path = os.path.join(root, fn)
arc = os.path.relpath(abs_path, source).replace("\\", "/")
file_entries.append((abs_path, arc))
present.add(arc)

missing = sorted(required - present)
if missing:
sys.exit("error: required --exec path(s) not found under {}: {}".format(
source, ", ".join(missing)))

exec_paths = set(required) | (set(optional) & present)

dest = os.path.abspath(args.dest_zip)
os.makedirs(os.path.dirname(dest), exist_ok=True)
if os.path.exists(dest):
os.remove(dest)

marked = []
with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for dir_arc in dir_entries:
zi = zipfile.ZipInfo(dir_arc)
zi.create_system = UNIX
# S_IFDIR | 0755 in the Unix high word; 0x10 = FILE_ATTRIBUTE_DIRECTORY
# in the DOS low word so Windows tools also see it as a directory.
zi.external_attr = ((stat.S_IFDIR | 0o755) << 16) | 0x10
zf.writestr(zi, b"")

for abs_path, arc in file_entries:
zi = zipfile.ZipInfo.from_file(abs_path, arc) # carries file mtime
zi.create_system = UNIX
zi.compress_type = zipfile.ZIP_DEFLATED
if arc in exec_paths:
zi.external_attr = (stat.S_IFREG | 0o755) << 16
marked.append(arc)
else:
zi.external_attr = (stat.S_IFREG | 0o644) << 16
with open(abs_path, "rb") as src, zf.open(zi, "w") as dst:
shutil.copyfileobj(src, dst)

print(f"Created {dest}")
print(f" {len(file_entries)} file(s), {len(dir_entries)} dir(s)")
print(f" executable: {', '.join(sorted(marked)) if marked else '(none)'}")


if __name__ == "__main__":
main()
21 changes: 14 additions & 7 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ jobs:
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
}
# Windows: flat zip via Compress-Archive (no Unix execute bit needed).
if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/win-x64/' }
if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/win-x64/' }
Compress-Archive -Path 'publish/win-x64/*' -DestinationPath "releases/PerformanceStudio-win-x64-$env:VERSION.zip" -Force

# Linux: flat zip via zip_with_exec.py so the apphost keeps its execute
# bit. Compress-Archive stamps the archive host byte as Windows, so the
# Unix mode is dropped and the binary extracts non-executable.
if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/linux-x64/' }
if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/linux-x64/' }
python .github/scripts/zip_with_exec.py publish/linux-x64 "releases/PerformanceStudio-linux-x64-$env:VERSION.zip" --exec PlanViewer.App --exec-optional createdump

# Package macOS as proper .app bundles
foreach ($rid in @('osx-x64', 'osx-arm64')) {
Expand Down Expand Up @@ -115,7 +120,9 @@ jobs:
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
# Zip the bundle with zip_with_exec.py so the apphost inside the .app
# keeps its execute bit (Compress-Archive would strip it).
python .github/scripts/zip_with_exec.py "$wrapperDir" "releases/PerformanceStudio-$rid-$env:VERSION.zip" --exec "PerformanceStudio.app/Contents/MacOS/PlanViewer.App" --exec-optional "PerformanceStudio.app/Contents/MacOS/createdump"
}

- name: Generate checksums
Expand Down
28 changes: 20 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,9 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.VERSION }}
run: |
dotnet tool install -g vpk
# Pin vpk to match the Velopack PackageReference (Velopack recommends the
# CLI and library versions match for compatible packages + reproducible releases).
dotnet tool install -g vpk --version 1.2.0
New-Item -ItemType Directory -Force -Path releases/velopack

# Download previous release for delta generation
Expand All @@ -191,12 +193,19 @@ jobs:
run: |
New-Item -ItemType Directory -Force -Path releases

# Package Windows (signed) 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.zip" -Force
}
# Windows (signed): flat zip via Compress-Archive. Windows has no Unix
# execute bit, so the signed .exe needs no special handling.
if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/win-x64/' }
if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/win-x64/' }
Compress-Archive -Path 'publish/win-x64/*' -DestinationPath 'releases/PerformanceStudio-win-x64.zip' -Force

# Linux: flat zip via zip_with_exec.py so the apphost keeps its execute
# bit. Compress-Archive (and .NET's ZipArchive) stamp the archive's host
# byte as Windows, so unzip/macOS/Linux drop the Unix mode and the binary
# extracts non-executable -- it then won't launch ("permission denied").
if (Test-Path 'README.md') { Copy-Item 'README.md' 'publish/linux-x64/' }
if (Test-Path 'LICENSE') { Copy-Item 'LICENSE' 'publish/linux-x64/' }
python .github/scripts/zip_with_exec.py publish/linux-x64 releases/PerformanceStudio-linux-x64.zip --exec PlanViewer.App --exec-optional createdump

# Package macOS as proper .app bundles
foreach ($rid in @('osx-x64', 'osx-arm64')) {
Expand Down Expand Up @@ -231,7 +240,10 @@ jobs:
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.zip" -Force
# Zip the bundle with zip_with_exec.py so the apphost inside the .app
# keeps its execute bit (Compress-Archive would strip it, leaving a
# bundle macOS refuses to launch).
python .github/scripts/zip_with_exec.py "$wrapperDir" "releases/PerformanceStudio-$rid.zip" --exec "PerformanceStudio.app/Contents/MacOS/PlanViewer.App" --exec-optional "PerformanceStudio.app/Contents/MacOS/createdump"
}

# Checksums (zips only, Velopack has its own checksums)
Expand Down
2 changes: 1 addition & 1 deletion server/PlanShare/PlanShare.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.5" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.13.0</Version>
<Version>1.14.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
80 changes: 80 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.MissingIndexes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using PlanViewer.App.Services;
using PlanViewer.Core.Models;
using PlanViewer.Core.Output;
using PlanViewer.Core.Services;

namespace PlanViewer.App.Controls;

public partial class PlanViewerControl : UserControl
{
private void ShowMissingIndexes(List<MissingIndex> 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;
}
}

}
Loading
Loading