diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs
index 1432a2e..5db3a71 100644
--- a/src/PlanViewer.App/AboutWindow.axaml.cs
+++ b/src/PlanViewer.App/AboutWindow.axaml.cs
@@ -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;
@@ -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;
diff --git a/src/PlanViewer.App/App.axaml.cs b/src/PlanViewer.App/App.axaml.cs
index 29fd241..0f328bf 100644
--- a/src/PlanViewer.App/App.axaml.cs
+++ b/src/PlanViewer.App/App.axaml.cs
@@ -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();
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
index 0848235..a67609e 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs
@@ -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;
@@ -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);
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
index 5f55059..e772bb5 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs
@@ -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;
@@ -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
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
index a03c712..daaaaf5 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs
@@ -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;
@@ -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)
diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
index 195fdb5..ed97937 100644
--- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs
@@ -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;
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
index 883292d..9a612cc 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs
@@ -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;
@@ -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);
};
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
index e913411..577504d 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Format.cs
@@ -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)
diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs
index 492ca1d..9682cda 100644
--- a/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs
+++ b/src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs
@@ -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
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
index d9c63ea..0d5b62c 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs
@@ -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;
@@ -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);
- }
+ /// One results row as a tab-separated line, shared by Copy Row and Ctrl+C.
+ 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);
}
diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
index f2690c1..41ba627 100644
--- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs
@@ -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);
diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
index a3910c3..155eff9 100644
--- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
+++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs
@@ -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;
@@ -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;
@@ -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);
}
+ /// One history row as a tab-separated line matching the grid's column order.
+ 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)
diff --git a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
index 36eefa4..9aaa24b 100644
--- a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
+++ b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml.cs
@@ -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",
diff --git a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
index 65c2bfa..cd3d9a8 100644
--- a/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
+++ b/src/PlanViewer.App/Helpers/DataGridBehaviors.cs
@@ -1,15 +1,17 @@
using System;
+using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
+using PlanViewer.App.Services;
namespace PlanViewer.App.Helpers;
///
-/// Attaches middle-mouse-button pan behavior to a DataGrid.
+/// Attached input behaviors for DataGrids: middle-mouse pan and guarded clipboard copy.
///
public static class DataGridBehaviors
{
@@ -19,6 +21,52 @@ public static void Attach(DataGrid grid)
AttachMiddleClickPan(grid);
}
+ ///
+ /// Replaces the DataGrid's built-in Ctrl+C / Ctrl+Insert copy, which awaits the
+ /// clipboard unguarded inside Avalonia and can crash the app when the clipboard
+ /// is locked by another process (issue #415). The tunnel handler runs before the
+ /// grid's own key handling and routes the copy through ClipboardHelper instead.
+ /// turns one selected row item into its clipboard
+ /// line; return null to skip the item.
+ ///
+ public static void AttachCopyGuard(DataGrid grid, Func