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
24 changes: 16 additions & 8 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,15 @@ jobs:
# Drafts aren't ready for review, and a fork PR gets no secrets (so
# CLAUDE_CODE_OAUTH_TOKEN would be empty) and a read-only token it could
# not post with. Skip rather than fail a contributor's PR with a red X.
#
# Also skip the dev -> main release PR. It is an aggregation of commits that
# were each already reviewed on their own PR, so re-reviewing the whole
# release adds nothing — and the diff is large enough that it reliably
# exhausts the turn budget and fails, putting a red X on the release.
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository
github.event.pull_request.head.repo.full_name == github.repository &&
!(github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main')
runs-on: ubuntu-latest

steps:
Expand Down Expand Up @@ -91,14 +97,16 @@ jobs:
`confirmed: true`) to flag specific lines.
Only post GitHub comments - do not return review text as a message.

# --allowedTools REPLACES the default tool set rather than adding to it.
# Without Read/Grep/Glob the reviewer cannot open a single file in the
# checked-out branch, which is exactly what the prompt above asks it to
# do. PR #399 burned all 20 turns on denied calls
# (permission_denials_count was 10) and failed without posting anything.
# --allowedTools REPLACES the default tool set rather than adding to it,
# so anything omitted here is denied at runtime. A denied call still
# consumes a turn, so a too-narrow list burns the budget and the review
# fails having posted nothing — that is how #399 (10 denials) and the
# v1.19.0 release PR (20 denials) both died.
#
# Everything here is read-only except the two comment-posting tools.
claude_args: |
--max-turns 40
--allowedTools "Read,Grep,Glob,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git diff:*),Bash(git log:*)"
--max-turns 60
--allowedTools "Read,Grep,Glob,TodoWrite,mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api:*),Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git status:*),Bash(ls:*),Bash(cat:*),Bash(head:*),Bash(tail:*),Bash(wc:*),Bash(find:*),Bash(rg:*)"

# Fork PRs: GitHub withholds repository secrets from `pull_request` runs on
# forks and issues a read-only GITHUB_TOKEN, so this workflow cannot review
Expand Down
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.19.0"
date-released: "2026-07-25"
version: "1.19.1"
date-released: "2026-07-29"
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.19.0</Version>
<Version>1.19.1</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
10 changes: 3 additions & 7 deletions src/PlanViewer.App/AboutWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using System.Runtime.InteropServices;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using PlanViewer.App.Mcp;
using PlanViewer.App.Services;
Expand Down Expand Up @@ -111,12 +110,9 @@ 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!";
}
McpCopyStatus.Text = await ClipboardHelper.TrySetTextAsync(this, command)
? "Copied to clipboard!"
: "Clipboard busy - try again";
}

private string? _updateUrl;
Expand Down
4 changes: 4 additions & 0 deletions src/PlanViewer.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public override void Initialize()

public override void OnFrameworkInitializationCompleted()
{
// Before any window exists: route every TextBox clipboard operation through
// the guarded helper so a locked clipboard can't crash the app (issue #415).
TextBoxClipboardGuard.Register();

if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
Expand Down
9 changes: 3 additions & 6 deletions src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using PlanViewer.App.Services;
using PlanViewer.Core.Models;

namespace PlanViewer.App.Controls;
Expand Down Expand Up @@ -138,12 +139,8 @@ private ContextMenu BuildCanvasContextMenu()
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 System.Threading.Tasks.Task SetClipboardTextAsync(string text)
=> ClipboardHelper.TrySetTextAsync(this, text);

private void ZoomIn_Click(object? sender, RoutedEventArgs e) => SetZoom(_zoomLevel + ZoomStep);

Expand Down
11 changes: 3 additions & 8 deletions src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Avalonia.Media;
using AvaloniaEdit.TextMate;
using Microsoft.Data.SqlClient;
using PlanViewer.App.Services;
using PlanViewer.Core.Interfaces;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
Expand Down Expand Up @@ -117,19 +118,13 @@ private void ShowSchemaResult(string title, string content)
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());
await ClipboardHelper.TrySetTextAsync(this, 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);
};
await ClipboardHelper.TrySetTextAsync(this, editor.Text);
var selectAllItem = new MenuItem { Header = "Select All" };
selectAllItem.Click += (_, _) => editor.SelectAll();
editor.TextArea.ContextMenu = new ContextMenu
Expand Down
5 changes: 2 additions & 3 deletions src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using PlanViewer.App.Services;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;

Expand Down Expand Up @@ -168,9 +169,7 @@ private async void CopyStatementText_Click(object? sender, RoutedEventArgs e)
var text = row.Statement.StatementText;
if (string.IsNullOrEmpty(text)) return;

var topLevel = TopLevel.GetTopLevel(this);
if (topLevel?.Clipboard != null)
await topLevel.Clipboard.SetTextAsync(text);
await ClipboardHelper.TrySetTextAsync(this, text);
}

private void OpenInEditor_Click(object? sender, RoutedEventArgs e)
Expand Down
2 changes: 2 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ public PlanViewerControl()
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;

Helpers.DataGridBehaviors.Attach(StatementsGrid);
Helpers.DataGridBehaviors.AttachCopyGuard(StatementsGrid,
item => item is StatementRow row ? row.Statement.StatementText : null);

// Wire minimap resize grip (defined in AXAML, not in canvas)
MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;
Expand Down
17 changes: 6 additions & 11 deletions src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using System.Xml.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
Expand Down Expand Up @@ -42,31 +41,27 @@ 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("");
// Only remove the selection once the text has actually reached the
// clipboard; a failed copy must not destroy the user's text.
if (await ClipboardHelper.TrySetTextAsync(this, 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());
await ClipboardHelper.TrySetTextAsync(this, 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();
var text = await ClipboardHelper.TryGetTextAsync(this);
if (string.IsNullOrEmpty(text)) return;
QueryEditor.TextArea.PerformTextInput(text);
};
Expand Down
17 changes: 4 additions & 13 deletions src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,10 @@ otherwise fall back to the currently selected database */
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}");
}
if (await ClipboardHelper.TrySetTextAsync(this, reproScript))
SetStatus("Repro script copied to clipboard");
else
SetStatus("Clipboard busy - could not copy repro script");
}

private async void Format_Click(object? sender, RoutedEventArgs e)
Expand Down
10 changes: 2 additions & 8 deletions src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,19 +104,13 @@ private void AddSchemaTab(string label, string content, bool isSql)
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());
await ClipboardHelper.TrySetTextAsync(this, 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);
};
await ClipboardHelper.TrySetTextAsync(this, editor.Text);
var schemaSelectAll = new MenuItem { Header = "Select All" };
schemaSelectAll.Click += (_, _) => editor.SelectAll();
editor.TextArea.ContextMenu = new ContextMenu
Expand Down
14 changes: 7 additions & 7 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEve
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}";
CopyRowItem.Tag = FormatRowForClipboard(row);

CopyQueryIdItem.Click += CopyMenuItem_Click;
CopyPlanIdItem.Click += CopyMenuItem_Click;
Expand All @@ -176,10 +176,10 @@ private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
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);
}
/// <summary>One results row as a tab-separated line, shared by Copy Row and Ctrl+C.</summary>
private static string FormatRowForClipboard(QueryStoreRow row) =>
$"{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}";

private System.Threading.Tasks.Task SetClipboardTextAsync(string text)
=> ClipboardHelper.TrySetTextAsync(this, text);
}
2 changes: 2 additions & 0 deletions src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi

ResultsGrid.ItemsSource = _filteredRows;
Helpers.DataGridBehaviors.Attach(ResultsGrid);
Helpers.DataGridBehaviors.AttachCopyGuard(ResultsGrid,
item => item is QueryStoreRow row ? FormatRowForClipboard(row) : null);
EnsureFilterPopup();
SetupColumnHeaders();
PopulateDatabaseBox(databases, initialDatabase);
Expand Down
31 changes: 28 additions & 3 deletions src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.VisualTree;
using PlanViewer.App.Services;
using PlanViewer.Core.Models;
using PlanViewer.Core.Services;
using ScottPlot;
Expand Down Expand Up @@ -135,6 +136,8 @@ public QueryStoreHistoryControl(string connectionString, string queryHash,
InitializeComponent();

Helpers.DataGridBehaviors.Attach(HistoryDataGrid);
Helpers.DataGridBehaviors.AttachCopyGuard(HistoryDataGrid,
item => item is QueryStoreHistoryRow row ? FormatRowForClipboard(row) : null);

QueryIdentifierText.Text = $"Query Store History: {queryHash} in [{database}]";
QueryTextBox.Text = queryText;
Expand Down Expand Up @@ -307,11 +310,33 @@ private void MetricSelector_SelectionChanged(object? sender, SelectionChangedEve
private async void CopyQuery_Click(object? sender, RoutedEventArgs e)
{
if (string.IsNullOrEmpty(_queryText)) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard != null)
await clipboard.SetTextAsync(_queryText);
await ClipboardHelper.TrySetTextAsync(this, _queryText);
}

/// <summary>One history row as a tab-separated line matching the grid's column order.</summary>
private static string FormatRowForClipboard(QueryStoreHistoryRow row) =>
string.Join("\t",
row.QueryPlanHash,
row.IntervalStartLocal,
row.CountExecutions,
row.AvgDurationMsDisplay,
row.AvgCpuMsDisplay,
row.AvgLogicalReadsDisplay,
row.AvgLogicalWritesDisplay,
row.AvgPhysicalReadsDisplay,
row.AvgMemoryMbDisplay,
row.AvgRowcountDisplay,
row.TotalDurationMsDisplay,
row.TotalCpuMsDisplay,
row.TotalLogicalReadsDisplay,
row.TotalLogicalWritesDisplay,
row.TotalPhysicalReadsDisplay,
row.TotalMemoryMbDisplay,
row.MinDop,
row.MaxDop,
row.LastExecutionLocal,
row.ExecutionTypeDesc);

private void Close_Click(object? sender, RoutedEventArgs e)
{
// When in a detached window, close it (this destroys the history view)
Expand Down
3 changes: 3 additions & 0 deletions src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,9 @@ private Control BuildScriptOptionsSection()
FontSize = 13,
};

Helpers.DataGridBehaviors.AttachCopyGuard(_formatGrid,
item => item is FormatOptionRow row ? $"{row.Name}\t{row.CurrentValue}" : null);

_formatGrid.Columns.Add(new DataGridTextColumn
{
Header = "Setting",
Expand Down
Loading