Skip to content

Commit 7cd9218

Browse files
Harden every clipboard path against a busy Windows clipboard
The Windows clipboard is a shared resource; when another process holds it, SetTextAsync throws CLIPBRD_E_CANT_OPEN, and an unguarded await in an async void handler crashes the app (issue #415, Robot Advice Copy to Clipboard). - New ClipboardHelper: all reads/writes retry briefly and never throw - Convert all 15 write sites and both paste sites in PlanViewer.App - Replace DataGrid's built-in Ctrl+C (unguarded inside Avalonia) with a guarded copy on all four grids, mirroring its exact key/modifier gate - Intercept TextBox Copy/Cut/Paste app-wide via class handlers; Avalonia's own implementations are unguarded async void / TimeoutException-only - Cut (editor and TextBoxes) only deletes the selection after the text actually reached the clipboard - Clipboard-busy feedback on Advice window, Copy Repro, and MCP copy button - Log AppDomain/TaskScheduler unhandled exceptions to LocalApplicationData\PerformanceStudio\crash.log Fixes #415 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1fa0e8e commit 7cd9218

22 files changed

Lines changed: 329 additions & 87 deletions

‎src/PlanViewer.App/AboutWindow.axaml.cs‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
using System.Runtime.InteropServices;
1111
using Avalonia.Controls;
1212
using Avalonia.Input;
13-
using Avalonia.Input.Platform;
1413
using Avalonia.Interactivity;
1514
using PlanViewer.App.Mcp;
1615
using PlanViewer.App.Services;
@@ -111,12 +110,9 @@ private async void CopyMcpCommand_Click(object? sender, RoutedEventArgs e)
111110
{
112111
var port = int.TryParse(McpPortInput.Text, out var p) && p >= 1024 && p <= 65535 ? p : 5152;
113112
var command = $"claude mcp add --transport streamable-http --scope user performance-studio http://localhost:{port}/";
114-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
115-
if (clipboard != null)
116-
{
117-
await clipboard.SetTextAsync(command);
118-
McpCopyStatus.Text = "Copied to clipboard!";
119-
}
113+
McpCopyStatus.Text = await ClipboardHelper.TrySetTextAsync(this, command)
114+
? "Copied to clipboard!"
115+
: "Clipboard busy - try again";
120116
}
121117

122118
private string? _updateUrl;

‎src/PlanViewer.App/App.axaml.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ public override void Initialize()
2121

2222
public override void OnFrameworkInitializationCompleted()
2323
{
24+
// Before any window exists: route every TextBox clipboard operation through
25+
// the guarded helper so a locked clipboard can't crash the app (issue #415).
26+
TextBoxClipboardGuard.Register();
27+
2428
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
2529
{
2630
desktop.MainWindow = new MainWindow();

‎src/PlanViewer.App/Controls/PlanViewerControl.Interaction.cs‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Avalonia.Interactivity;
88
using Avalonia.Media;
99
using Avalonia.Platform.Storage;
10+
using PlanViewer.App.Services;
1011
using PlanViewer.Core.Models;
1112

1213
namespace PlanViewer.App.Controls;
@@ -138,12 +139,8 @@ private ContextMenu BuildCanvasContextMenu()
138139
return menu;
139140
}
140141

141-
private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
142-
{
143-
var topLevel = TopLevel.GetTopLevel(this);
144-
if (topLevel?.Clipboard != null)
145-
await topLevel.Clipboard.SetTextAsync(text);
146-
}
142+
private System.Threading.Tasks.Task SetClipboardTextAsync(string text)
143+
=> ClipboardHelper.TrySetTextAsync(this, text);
147144

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

‎src/PlanViewer.App/Controls/PlanViewerControl.Schema.cs‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using Avalonia.Media;
1111
using AvaloniaEdit.TextMate;
1212
using Microsoft.Data.SqlClient;
13+
using PlanViewer.App.Services;
1314
using PlanViewer.Core.Interfaces;
1415
using PlanViewer.Core.Models;
1516
using PlanViewer.Core.Services;
@@ -117,19 +118,13 @@ private void ShowSchemaResult(string title, string content)
117118
var copyItem = new MenuItem { Header = "Copy" };
118119
copyItem.Click += async (_, _) =>
119120
{
120-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
121-
if (clipboard == null) return;
122121
var sel = editor.TextArea.Selection;
123122
if (!sel.IsEmpty)
124-
await clipboard.SetTextAsync(sel.GetText());
123+
await ClipboardHelper.TrySetTextAsync(this, sel.GetText());
125124
};
126125
var copyAllItem = new MenuItem { Header = "Copy All" };
127126
copyAllItem.Click += async (_, _) =>
128-
{
129-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
130-
if (clipboard == null) return;
131-
await clipboard.SetTextAsync(editor.Text);
132-
};
127+
await ClipboardHelper.TrySetTextAsync(this, editor.Text);
133128
var selectAllItem = new MenuItem { Header = "Select All" };
134129
selectAllItem.Click += (_, _) => editor.SelectAll();
135130
editor.TextArea.ContextMenu = new ContextMenu

‎src/PlanViewer.App/Controls/PlanViewerControl.Statements.cs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Avalonia.Interactivity;
99
using Avalonia.Layout;
1010
using Avalonia.Media;
11+
using PlanViewer.App.Services;
1112
using PlanViewer.Core.Models;
1213
using PlanViewer.Core.Services;
1314

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

171-
var topLevel = TopLevel.GetTopLevel(this);
172-
if (topLevel?.Clipboard != null)
173-
await topLevel.Clipboard.SetTextAsync(text);
172+
await ClipboardHelper.TrySetTextAsync(this, text);
174173
}
175174

176175
private void OpenInEditor_Click(object? sender, RoutedEventArgs e)

‎src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ public PlanViewerControl()
164164
_zoomTransform = (ScaleTransform)layoutTransform.LayoutTransform!;
165165

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

168170
// Wire minimap resize grip (defined in AXAML, not in canvas)
169171
MinimapResizeGrip.PointerPressed += MinimapResizeGrip_PointerPressed;

‎src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
using System.Xml.Linq;
1111
using Avalonia.Controls;
1212
using Avalonia.Input;
13-
using Avalonia.Input.Platform;
1413
using Avalonia.Interactivity;
1514
using Avalonia.Layout;
1615
using Avalonia.Media;
@@ -42,31 +41,27 @@ private void SetupEditorContextMenu()
4241
var cutItem = new MenuItem { Header = "Cut" };
4342
cutItem.Click += async (_, _) =>
4443
{
45-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
46-
if (clipboard == null) return;
4744
var selection = QueryEditor.TextArea.Selection;
4845
if (selection.IsEmpty) return;
4946
var text = selection.GetText();
50-
await clipboard.SetTextAsync(text);
51-
selection.ReplaceSelectionWithText("");
47+
// Only remove the selection once the text has actually reached the
48+
// clipboard; a failed copy must not destroy the user's text.
49+
if (await ClipboardHelper.TrySetTextAsync(this, text))
50+
selection.ReplaceSelectionWithText("");
5251
};
5352

5453
var copyItem = new MenuItem { Header = "Copy" };
5554
copyItem.Click += async (_, _) =>
5655
{
57-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
58-
if (clipboard == null) return;
5956
var selection = QueryEditor.TextArea.Selection;
6057
if (selection.IsEmpty) return;
61-
await clipboard.SetTextAsync(selection.GetText());
58+
await ClipboardHelper.TrySetTextAsync(this, selection.GetText());
6259
};
6360

6461
var pasteItem = new MenuItem { Header = "Paste" };
6562
pasteItem.Click += async (_, _) =>
6663
{
67-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
68-
if (clipboard == null) return;
69-
var text = await clipboard.TryGetTextAsync();
64+
var text = await ClipboardHelper.TryGetTextAsync(this);
7065
if (string.IsNullOrEmpty(text)) return;
7166
QueryEditor.TextArea.PerformTextInput(text);
7267
};

‎src/PlanViewer.App/Controls/QuerySessionControl.Format.cs‎

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,10 @@ otherwise fall back to the currently selected database */
6060
source: "Performance Studio",
6161
isAzureSqlDb: IsAzureConnection);
6262

63-
try
64-
{
65-
var topLevel = TopLevel.GetTopLevel(this);
66-
if (topLevel?.Clipboard != null)
67-
{
68-
await topLevel.Clipboard.SetTextAsync(reproScript);
69-
SetStatus("Repro script copied to clipboard");
70-
}
71-
}
72-
catch (Exception ex)
73-
{
74-
SetStatus($"Clipboard error: {ex.Message}");
75-
}
63+
if (await ClipboardHelper.TrySetTextAsync(this, reproScript))
64+
SetStatus("Repro script copied to clipboard");
65+
else
66+
SetStatus("Clipboard busy - could not copy repro script");
7667
}
7768

7869
private async void Format_Click(object? sender, RoutedEventArgs e)

‎src/PlanViewer.App/Controls/QuerySessionControl.Schema.cs‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,13 @@ private void AddSchemaTab(string label, string content, bool isSql)
104104
var schemaCopy = new MenuItem { Header = "Copy" };
105105
schemaCopy.Click += async (_, _) =>
106106
{
107-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
108-
if (clipboard == null) return;
109107
var sel = editor.TextArea.Selection;
110108
if (!sel.IsEmpty)
111-
await clipboard.SetTextAsync(sel.GetText());
109+
await ClipboardHelper.TrySetTextAsync(this, sel.GetText());
112110
};
113111
var schemaCopyAll = new MenuItem { Header = "Copy All" };
114112
schemaCopyAll.Click += async (_, _) =>
115-
{
116-
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
117-
if (clipboard == null) return;
118-
await clipboard.SetTextAsync(editor.Text);
119-
};
113+
await ClipboardHelper.TrySetTextAsync(this, editor.Text);
120114
var schemaSelectAll = new MenuItem { Header = "Select All" };
121115
schemaSelectAll.Click += (_, _) => editor.SelectAll();
122116
editor.TextArea.ContextMenu = new ContextMenu

‎src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEve
159159
CopyPlanHashItem.Tag = row.QueryPlanHash;
160160
CopyModuleItem.Tag = row.ModuleName;
161161
CopyQueryTextItem.Tag = row.FullQueryText;
162-
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}";
162+
CopyRowItem.Tag = FormatRowForClipboard(row);
163163

164164
CopyQueryIdItem.Click += CopyMenuItem_Click;
165165
CopyPlanIdItem.Click += CopyMenuItem_Click;
@@ -176,10 +176,10 @@ private async void CopyMenuItem_Click(object? sender, RoutedEventArgs e)
176176
await SetClipboardTextAsync(text);
177177
}
178178

179-
private async System.Threading.Tasks.Task SetClipboardTextAsync(string text)
180-
{
181-
var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
182-
if (topLevel?.Clipboard != null)
183-
await topLevel.Clipboard.SetTextAsync(text);
184-
}
179+
/// <summary>One results row as a tab-separated line, shared by Copy Row and Ctrl+C.</summary>
180+
private static string FormatRowForClipboard(QueryStoreRow row) =>
181+
$"{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}";
182+
183+
private System.Threading.Tasks.Task SetClipboardTextAsync(string text)
184+
=> ClipboardHelper.TrySetTextAsync(this, text);
185185
}

0 commit comments

Comments
 (0)