diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..8eddc00 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,65 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json + +language: "en-US" +early_access: false +enable_free_tier: true + +reviews: + profile: "chill" + high_level_summary: true + review_status: true + commit_status: true + collapse_walkthrough: true + sequence_diagrams: false + poem: false + + path_filters: + - "!**/*.Designer.cs" + - "!**/bin/**" + - "!**/obj/**" + - "!**/publish/**" + - "!**/*.user" + - "!**/*.suo" + - "!**/screenshots/**" + + path_instructions: + - path: "src/PlanViewer.App/**/*.cs" + instructions: > + Avalonia 11.3 desktop app using code-behind pattern (not MVVM). + Watch for: null reference risks, proper disposal of resources, + async/await patterns, and Avalonia-specific UI threading. + - path: "src/PlanViewer.Core/**/*.cs" + instructions: > + Core library with execution plan analysis (PlanAnalyzer), XML parsing, + and shared services. Watch for: XML parsing safety, null handling, + and performance with large execution plans. + - path: "src/PlanViewer.Mcp/**/*.cs" + instructions: > + MCP (Model Context Protocol) server integration for AI tools. + Watch for: input validation, proper error responses, and serialization safety. + - path: "tests/**/*.cs" + instructions: > + Unit and integration tests. Watch for: test isolation, + meaningful assertions, and proper test data setup. + + auto_review: + enabled: true + drafts: false + base_branches: + - "dev" + - "main" + + tools: + gitleaks: + enabled: true + github-checks: + enabled: true + +chat: + auto_reply: true + +knowledge_base: + learnings: + scope: "local" + pull_requests: + scope: "local" diff --git a/README.md b/README.md index aff80fe..e5a3b71 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,14 @@ Pre-built binaries are available on the [Releases](https://github.com/erikdarlin 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: diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 363d967..6851fdb 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -90,8 +90,6 @@ public partial class PlanViewerControl : UserControl private static readonly SolidColorBrush OrangeBrush = new(Colors.Orange); - // Current property section for collapsible groups - private StackPanel? _currentPropertySection; // Track all property section grids for synchronized column resize private readonly List _sectionLabelColumns = new(); private double _propertyLabelWidth = 140; @@ -118,7 +116,8 @@ public partial class PlanViewerControl : UserControl public PlanViewerControl() { InitializeComponent(); - PlanScrollViewer.PointerWheelChanged += PlanScrollViewer_PointerWheelChanged; + // 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); @@ -820,7 +819,6 @@ private async System.Threading.Tasks.Task SetClipboardTextAsync(string text) private void ShowPropertiesPanel(PlanNode node) { PropertiesContent.Children.Clear(); - _currentPropertySection = null; _sectionLabelColumns.Clear(); _currentSectionGrid = null; _currentSectionRowIndex = 0; @@ -1771,7 +1769,6 @@ private void AddPropertySection(string title) HorizontalContentAlignment = HorizontalAlignment.Stretch }; PropertiesContent.Children.Add(expander); - _currentPropertySection = null; // No longer used — rows go into _currentSectionGrid } private void AddPropertyRow(string label, string value, bool isCode = false, bool indent = false) @@ -2187,7 +2184,7 @@ private void ShowParameters(PlanStatement statement) if (parameters.Count == 0) { - var localVars = FindUnresolvedVariables(statement.StatementText, parameters); + var localVars = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); if (localVars.Count > 0) { ParametersHeader.Text = "Parameters"; @@ -2303,7 +2300,7 @@ private void ShowParameters(PlanStatement statement) } } - var unresolved = FindUnresolvedVariables(statement.StatementText, parameters); + var unresolved = FindUnresolvedVariables(statement.StatementText, parameters, statement.RootNode); if (unresolved.Count > 0) { AddParameterAnnotation( @@ -2350,7 +2347,8 @@ private void AddParameterAnnotation(string text, string color) }); } - private static List FindUnresolvedVariables(string queryText, List parameters) + private static List FindUnresolvedVariables(string queryText, List parameters, + PlanNode? rootNode = null) { var unresolved = new List(); if (string.IsNullOrEmpty(queryText)) @@ -2359,6 +2357,11 @@ private static List FindUnresolvedVariables(string queryText, List( 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); @@ -2369,6 +2372,8 @@ private static List FindUnresolvedVariables(string queryText, List FindUnresolvedVariables(string queryText, List 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); @@ -2961,9 +2979,17 @@ private async void SavePlan_Click(object? sender, RoutedEventArgs e) if (file != null) { - await using var stream = await file.OpenWriteAsync(); - await using var writer = new StreamWriter(stream); - await writer.WriteAsync(_currentPlan.RawXml); + try + { + await using var stream = await file.OpenWriteAsync(); + await using var writer = new StreamWriter(stream); + await writer.WriteAsync(_currentPlan.RawXml); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SavePlan failed: {ex.Message}"); + CostText.Text = $"Save failed: {(ex.Message.Length > 60 ? ex.Message[..60] + "..." : ex.Message)}"; + } } } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index fadeed6..76f6534 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -60,8 +60,8 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore // Keybindings: F5/Ctrl+E for Execute, Ctrl+L for Estimated Plan KeyDown += OnKeyDown; - // Ctrl+mousewheel for font zoom - QueryEditor.PointerWheelChanged += OnEditorPointerWheel; + // 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; @@ -74,6 +74,8 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore QueryEditor.TextArea.Focus(); }; + DetachedFromVisualTree += (_, _) => _textMateInstallation?.Dispose(); + // Focus the editor when the Editor tab is selected; toggle plan-dependent buttons SubTabControl.SelectionChanged += (_, _) => { @@ -320,6 +322,7 @@ private string GetTextFromCursor() private void SetStatus(string text, bool autoClear = true) { _statusClearCts?.Cancel(); + _statusClearCts?.Dispose(); StatusText.Text = text; if (autoClear && !string.IsNullOrEmpty(text)) @@ -335,44 +338,7 @@ private void SetStatus(string text, bool autoClear = true) private async void Connect_Click(object? sender, RoutedEventArgs e) { - 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"; - - // Populate database dropdown - await PopulateDatabases(); - - // Collect server metadata for advice output - await FetchServerMetadataAsync(); - - // Select the database chosen in the dialog - if (_selectedDatabase != null) - { - for (int i = 0; i < DatabaseBox.Items.Count; i++) - { - if (DatabaseBox.Items[i]?.ToString() == _selectedDatabase) - { - DatabaseBox.SelectedIndex = i; - break; - } - } - } - - // Collect database metadata for the initial database - await FetchDatabaseMetadataAsync(); - - ExecuteButton.IsEnabled = true; - ExecuteEstButton.IsEnabled = true; - } + await ShowConnectionDialogAsync(); } private async Task ShowConnectionDialogAsync() @@ -495,12 +461,16 @@ private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e) private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null) { - if (_connectionString == null || _selectedDatabase == 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(); @@ -511,6 +481,7 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride } _executionCts?.Cancel(); + _executionCts?.Dispose(); _executionCts = new CancellationTokenSource(); var ct = _executionCts.Token; @@ -896,6 +867,8 @@ 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(); } @@ -915,6 +888,8 @@ private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e) case "Close": if (item.Tag is TabItem tab) { + if (tab.Content is PlanViewerControl closeViewer) + closeViewer.Clear(); SubTabControl.Items.Remove(tab); UpdateCompareButtonState(); } @@ -929,7 +904,11 @@ private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e) .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(); } @@ -941,7 +920,11 @@ private void PlanTabContextMenu_Click(object? sender, RoutedEventArgs e) .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; @@ -980,11 +963,17 @@ private static string GetTabLabel(TabItem tab) return "Plan"; } + private bool HasQueryStoreTab() + { + return SubTabControl.Items.OfType() + .Any(t => t.Content is QueryStoreGridControl); + } + private async void QueryStore_Click(object? sender, RoutedEventArgs e) { - if (_connectionString == null || _selectedDatabase == null) + // If a QS tab already exists, always show connection dialog for a fresh tab + if (HasQueryStoreTab() || _connectionString == null || _selectedDatabase == null) { - // No connection — open the connection dialog and wait for it await ShowConnectionDialogAsync(); if (_connectionString == null || _selectedDatabase == null) return; @@ -1009,7 +998,11 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) SetStatus(""); - var grid = new QueryStoreGridControl(_connectionString, _selectedDatabase!); + // Build database list from the current DatabaseBox + var databases = DatabaseBox.Items.OfType().ToList(); + + var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, + _selectedDatabase!, databases); grid.PlansSelected += OnQueryStorePlansSelected; var headerText = new TextBlock @@ -1019,6 +1012,12 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e) 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", @@ -1319,6 +1318,7 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) if (!confirmed) return; _executionCts?.Cancel(); + _executionCts?.Dispose(); _executionCts = new CancellationTokenSource(); var ct = _executionCts.Token; diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml index 6fdc840..02cbd46 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml @@ -8,23 +8,19 @@ - -