From 920bdf65ddf108a6d724ac286e4d1f52f6d9f6ee Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:44:15 -0600 Subject: [PATCH] Add execute selection, from cursor, and current batch support (#53) - F5/Ctrl+E/Ctrl+L now execute only selected text when a selection exists - Right-click context menu adds "Execute from Cursor" and "Execute Current Batch" - Current batch parses GO separators (case-insensitive, own line) matching SSMS - Fix multi-statement plan capture: both executors now collect all result sets and merge ShowPlanXML documents so all statements appear in the viewer Co-Authored-By: Claude Opus 4.6 --- .../Controls/QuerySessionControl.axaml.cs | 66 ++++++++++++++++++- .../Services/ActualPlanExecutor.cs | 9 ++- .../Services/EstimatedPlanExecutor.cs | 48 +++++++++++--- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 9a15199..8e88615 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -3,6 +3,7 @@ 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; @@ -132,9 +133,25 @@ private void SetupEditorContextMenu() 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); + }; + QueryEditor.TextArea.ContextMenu = new ContextMenu { - Items = { cutItem, copyItem, pasteItem, new Separator(), selectAllItem } + Items = { cutItem, copyItem, pasteItem, new Separator(), selectAllItem, new Separator(), executeFromCursorItem, executeCurrentBatchItem } }; } @@ -259,6 +276,47 @@ private void OnTextEntered(object? sender, TextInputEventArgs e) 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) { _statusClearCts?.Cancel(); @@ -435,7 +493,7 @@ private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e) await CaptureAndShowPlan(estimated: true); } - private async Task CaptureAndShowPlan(bool estimated) + private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null) { if (_connectionString == null || _selectedDatabase == null) { @@ -443,7 +501,9 @@ private async Task CaptureAndShowPlan(bool estimated) return; } - var queryText = QueryEditor.Text?.Trim(); + var queryText = queryTextOverride?.Trim() + ?? GetSelectedTextOrNull()?.Trim() + ?? QueryEditor.Text?.Trim(); if (string.IsNullOrEmpty(queryText)) { SetStatus("Enter a query", autoClear: false); diff --git a/src/PlanViewer.Core/Services/ActualPlanExecutor.cs b/src/PlanViewer.Core/Services/ActualPlanExecutor.cs index 7413fa5..d5fc41a 100644 --- a/src/PlanViewer.Core/Services/ActualPlanExecutor.cs +++ b/src/PlanViewer.Core/Services/ActualPlanExecutor.cs @@ -7,6 +7,7 @@ */ using System; +using System.Collections.Generic; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -55,7 +56,7 @@ public static class ActualPlanExecutor sb.AppendLine("SET STATISTICS XML OFF;"); var fullScript = sb.ToString(); - string? capturedPlanXml = null; + var capturedPlanXmls = new List(); /* Override database in connection string */ var builder = new SqlConnectionStringBuilder(connectionString); @@ -89,7 +90,7 @@ The plan result set has a single row with a single XML column. */ if (value != null && value.TrimStart().StartsWith("(); using (var queryCmd = new SqlCommand(queryText, connection)) { queryCmd.CommandTimeout = timeoutSeconds; @@ -49,12 +52,16 @@ public static class EstimatedPlanExecutor }); using var reader = await queryCmd.ExecuteReaderAsync(cancellationToken); - if (await reader.ReadAsync(cancellationToken)) + do { - var value = reader.GetValue(0)?.ToString(); - if (value != null && value.TrimStart().StartsWith(" + /// Merges multiple ShowPlanXML documents into one by combining all Batch elements. + /// + internal static string MergeShowPlanXmls(List planXmls) + { + XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; + var baseDoc = XDocument.Parse(planXmls[0]); + var batchSequence = baseDoc.Root!.Element(ns + "BatchSequence")!; + + for (int i = 1; i < planXmls.Count; i++) + { + var doc = XDocument.Parse(planXmls[i]); + var batches = doc.Root!.Element(ns + "BatchSequence")?.Elements(ns + "Batch"); + if (batches != null) + { + foreach (var batch in batches) + batchSequence.Add(batch); + } + } + + return baseDoc.ToString(SaveOptions.DisableFormatting); } }