Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ authors:
website: "https://erikdarling.com"
repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
license: MIT
version: "1.22.0"
date-released: "2026-08-31"
version: "1.23.0"
date-released: "2026-09-02"
keywords:
- sql-server
- execution-plan
Expand Down
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.22.0</Version>
<Version>1.23.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
21 changes: 21 additions & 0 deletions src/PlanViewer.App/App.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml"/>

<!-- Fluent defaults AllowAutoHide to true, which collapses a scrollbar to a
sliver until it is hovered. That is a bad trade on a horizontal bar under
a wide grid or a long query: the thing you need to grab is a couple of
pixels tall until after you have found it. Full size everywhere. -->
<Style Selector="ScrollViewer">
<Setter Property="AllowAutoHide" Value="False"/>
</Style>

<Style Selector="ScrollBar">
<Setter Property="AllowAutoHide" Value="False"/>
</Style>

<!-- DataGrid does not scroll through a ScrollViewer, and the ScrollBar rule above
cannot reach its bars either: it assigns AllowAutoHide to them in code, and a
local value outranks any style. There is no DataGrid.AllowAutoHide to set, but
the value it pushes down is the ATTACHED ScrollViewer property read off itself,
so setting that here is what reaches the grids. -->
<Style Selector="DataGrid">
<Setter Property="ScrollViewer.AllowAutoHide" Value="False"/>
</Style>
</Application.Styles>

<Application.Resources>
Expand Down
55 changes: 54 additions & 1 deletion src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,49 @@ private void StatementsGrid_SelectionChanged(object? sender, SelectionChangedEve
RenderStatement(row.Statement);
}

private void StatementsContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
{
UpdateStatementMenuForSelection();
}

/// <summary>
/// Relabels the statement context menu for the selected statement, and shows the parameterized
/// variant only when there is a difference between the two forms to choose from (#467).
///
/// <para>Runs when the menu opens rather than when selection changes, so it describes whatever
/// is selected at the moment it is shown — the same row the two click handlers act on.</para>
/// </summary>
private void UpdateStatementMenuForSelection()
{
var substitutable = StatementsGrid.SelectedItem is StatementRow row
&& ParameterSubstitution.Apply(row.Statement.StatementText, row.Statement.Parameters)
.SubstitutionCount > 0;

// Say so on the label rather than substituting silently: the text the plan records and the
// text that will run are different things, and which one you just copied matters.
CopyStatementTextItem.Header = substitutable ? "Copy Query Text (with values)" : "Copy Query Text";
OpenStatementInEditorItem.Header = substitutable
? "Open in Query Editor (with values)"
: "Open in Query Editor";

ParameterizedStatementSeparator.IsVisible = substitutable;
CopyParameterizedStatementTextItem.IsVisible = substitutable;
}

private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
{
if (StatementsGrid.SelectedItem is not StatementRow row) return;
var text = RunnableStatementText(row.Statement);
if (string.IsNullOrEmpty(text)) return;

await ClipboardHelper.TrySetTextAsync(this, text);
}

/// <summary>
/// Copies the statement exactly as the plan records it, parameter names and all. Reachable only
/// when that differs from the substituted form.
/// </summary>
private async void CopyParameterizedStatementText_Click(object? sender, RoutedEventArgs e)
{
if (StatementsGrid.SelectedItem is not StatementRow row) return;
var text = row.Statement.StatementText;
Expand All @@ -175,12 +217,23 @@ private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
private void OpenInEditor_Click(object? sender, RoutedEventArgs e)
{
if (StatementsGrid.SelectedItem is not StatementRow row) return;
var text = row.Statement.StatementText;
var text = RunnableStatementText(row.Statement);
if (string.IsNullOrEmpty(text)) return;

OpenInEditorRequested?.Invoke(this, text);
}

/// <summary>
/// The statement text with the plan's parameter values put back, or the text as-is when the plan
/// carries no values to put back.
///
/// <para>The plan is the only source used. The query editor's buffer holds the original text in
/// exactly one case — the user just executed it from that tab — and is wrong for a plan opened
/// from a file, from Query Store, or from another session.</para>
/// </summary>
private static string RunnableStatementText(PlanStatement statement) =>
ParameterSubstitution.Apply(statement.StatementText, statement.Parameters).Text;

private static void CollectNodeWarnings(PlanNode node, List<PlanWarning> warnings)
{
warnings.AddRange(node.Warnings);
Expand Down
13 changes: 10 additions & 3 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,16 @@
Background="{DynamicResource BackgroundDarkBrush}"
BorderThickness="0">
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Header="Copy Query Text" Click="CopyStatementText_Click"/>
<MenuItem Header="Open in Query Editor" Click="OpenInEditor_Click"/>
<ContextMenu Opening="StatementsContextMenu_Opening">
<MenuItem x:Name="CopyStatementTextItem"
Header="Copy Query Text" Click="CopyStatementText_Click"/>
<MenuItem x:Name="OpenStatementInEditorItem"
Header="Open in Query Editor" Click="OpenInEditor_Click"/>
<Separator x:Name="ParameterizedStatementSeparator" IsVisible="False"/>
<MenuItem x:Name="CopyParameterizedStatementTextItem"
Header="Copy Query Text (as parameterized)"
Click="CopyParameterizedStatementText_Click"
IsVisible="False"/>
</ContextMenu>
</DataGrid.ContextMenu>
</DataGrid>
Expand Down
4 changes: 3 additions & 1 deletion src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,10 @@ public PlanViewerControl()
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;

Helpers.DataGridBehaviors.Attach(StatementsGrid);
// Same text the Copy Query Text menu entry produces (#467) — Ctrl+C is that entry's
// unlabelled twin, and handing the two of them different statements is its own bug report.
Helpers.DataGridBehaviors.AttachCopyGuard(StatementsGrid,
item => item is StatementRow row ? row.Statement.StatementText : null);
item => item is StatementRow row ? RunnableStatementText(row.Statement) : null);

// Wire minimap resize grip (defined in AXAML, not in canvas)
MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
Expand Down
37 changes: 37 additions & 0 deletions src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ public partial class QuerySessionControl : UserControl
/// </summary>
public string? SourceFilePath { get; set; }

/// <summary>
/// The editor text as of the last load or save. A new session starts empty, so a
/// never-saved scratch tab with anything typed into it is dirty too (#462).
/// </summary>
private string _savedText = "";

/// <summary>
/// Whether the editor holds work that is not on disk.
///
/// <para>This compares text rather than latching a "was edited" bool on the first
/// keystroke, so typing something and typing it back out again leaves the session
/// clean — an undo to the original is not unsaved work, and prompting about it is
/// how a save prompt teaches people to dismiss save prompts.</para>
/// </summary>
public bool IsDirty => !string.Equals(QueryEditor.Text, _savedText, StringComparison.Ordinal);

/// <summary>
/// Raised whenever the editor text changes or the session is marked clean. The tab
/// header subscribes to this to keep its modified marker honest; the session cannot
/// reach its own tab, and polling <see cref="IsDirty"/> per render would be worse.
/// </summary>
public event EventHandler? DirtyStateChanged;

/// <summary>
/// Declares the current text to be what is on disk. Called after a load and after a
/// successful save — not after a failed one, which must leave the session dirty.
/// </summary>
public void MarkClean()
{
_savedText = QueryEditor.Text;
DirtyStateChanged?.Invoke(this, EventArgs.Empty);
}

private ServerConnection? _serverConnection;
private string? _connectionString;
private string? _selectedDatabase;
Expand Down Expand Up @@ -72,6 +105,10 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore
QueryEditor.TextArea.TextEntering += OnTextEntering;
QueryEditor.TextArea.TextEntered += OnTextEntered;

// #462: every edit is a chance for the tab's modified marker to change, in both
// directions — an undo back to the saved text clears it again.
QueryEditor.TextChanged += (_, _) => DirtyStateChanged?.Invoke(this, EventArgs.Empty);

// 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 += (_, _) =>
Expand Down
2 changes: 1 addition & 1 deletion src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,7 @@ private void ResetAll_Click(object? sender, RoutedEventArgs e)
var fresh = new AppSettings
{
RecentPlans = _settings.RecentPlans,
OpenPlans = _settings.OpenPlans,
OpenTabs = _settings.OpenTabs,
AccuracyRatioDivergenceLimit = _settings.AccuracyRatioDivergenceLimit
};
_settings = fresh;
Expand Down
102 changes: 102 additions & 0 deletions src/PlanViewer.App/Dialogs/UnsavedChangesDialog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;

namespace PlanViewer.App.Dialogs;

/// <summary>
/// What the user said when asked about a query tab with unsaved changes.
/// </summary>
public enum UnsavedChangesChoice
{
/// <summary>Write the query out, then close.</summary>
Save,

/// <summary>Close and lose the edit — an explicit answer, not a default.</summary>
DontSave,

/// <summary>Do not close anything.</summary>
Cancel
}

/// <summary>
/// The Save / Don't Save / Cancel prompt for a modified query tab (#462).
///
/// <para>Separate from <see cref="ConfirmationDialog"/> rather than a parameter on it: this
/// has three answers, and the third one has to be distinguishable from the second. A yes/no
/// dialog collapses "don't save" and "cancel" into the same false, which is the one mistake
/// this prompt cannot make — it is the difference between losing a tab and losing the app.</para>
/// </summary>
public static class UnsavedChangesDialog
{
/// <summary>
/// Asks about one tab. Dismissing the window any other way — title-bar close, Escape —
/// is <see cref="UnsavedChangesChoice.Cancel"/>, because the safe answer to a question
/// nobody answered is to leave the work where it is.
/// </summary>
public static async Task<UnsavedChangesChoice> ShowAsync(Window owner, string tabLabel)
{
var choice = UnsavedChangesChoice.Cancel;

var messageText = new TextBlock
{
Text = $"Do you want to save the changes you made to {tabLabel}?\n\nYour changes will be lost if you don't save them.",
TextWrapping = TextWrapping.Wrap,
FontSize = 13,
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
Margin = new Avalonia.Thickness(0, 0, 0, 16)
};

var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right
};

var dialog = new Window
{
Title = "Unsaved Changes",
Width = 460,
Height = 220,
MinWidth = 460,
MinHeight = 220,
Icon = owner.Icon,
Background = new SolidColorBrush(Color.Parse("#1A1D23")),
Foreground = new SolidColorBrush(Color.Parse("#E4E6EB")),
WindowStartupLocation = WindowStartupLocation.CenterOwner
};

Button MakeButton(string caption, UnsavedChangesChoice answer)
{
var button = new Button
{
Content = caption,
Height = 32,
MinWidth = 96,
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)owner.FindResource("AppButton")!
};
button.Click += (_, _) => { choice = answer; dialog.Close(); };
buttonPanel.Children.Add(button);
return button;
}

MakeButton("Save", UnsavedChangesChoice.Save);
MakeButton("Don't Save", UnsavedChangesChoice.DontSave);
MakeButton("Cancel", UnsavedChangesChoice.Cancel);

dialog.Content = new StackPanel
{
Margin = new Avalonia.Thickness(20),
Children = { messageText, buttonPanel }
};

await dialog.ShowDialog(owner);
return choice;
}
}
Loading
Loading