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
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
50 changes: 49 additions & 1 deletion src/PlanViewer.App/Helpers/DataGridBehaviors.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Attaches middle-mouse-button pan behavior to a DataGrid.
/// Attached input behaviors for DataGrids: middle-mouse pan and guarded clipboard copy.
/// </summary>
public static class DataGridBehaviors
{
Expand All @@ -19,6 +21,52 @@ public static void Attach(DataGrid grid)
AttachMiddleClickPan(grid);
}

/// <summary>
/// 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.
/// <paramref name="formatRow"/> turns one selected row item into its clipboard
/// line; return null to skip the item.
/// </summary>
public static void AttachCopyGuard(DataGrid grid, Func<object, string?> formatRow)
{
grid.AddHandler(InputElement.KeyDownEvent, (_, e) =>
{
// Mirror the built-in copy's exact gate: platform command modifier
// (Ctrl; Cmd on macOS), no Shift, no Alt.
var commandModifiers = TopLevel.GetTopLevel(grid)?.PlatformSettings?.HotkeyConfiguration.CommandModifiers
?? KeyModifiers.Control;
if (e.Key is not (Key.C or Key.Insert)
|| !e.KeyModifiers.HasFlag(commandModifiers)
|| e.KeyModifiers.HasFlag(KeyModifiers.Shift)
|| e.KeyModifiers.HasFlag(KeyModifiers.Alt))
return;

// In-cell editors own their Ctrl+C (e.g. the settings grid's TextBoxes).
if (e.Source is Visual source && source.FindAncestorOfType<TextBox>(includeSelf: true) != null)
return;

var selected = grid.SelectedItems;
if (selected == null || selected.Count == 0)
return;

// Take over whenever rows are selected so the DataGrid's unguarded
// built-in copy never runs, even if no line ends up on the clipboard.
e.Handled = true;

var lines = new List<string>();
foreach (var item in selected)
{
if (item != null && formatRow(item) is { } line)
lines.Add(line);
}

if (lines.Count > 0)
_ = ClipboardHelper.TrySetTextAsync(grid, string.Join(Environment.NewLine, lines));
}, RoutingStrategies.Tunnel);
}

// ─────────────────────────────────────────────────────────────────────────
// Middle-mouse-button drag → pan (scroll) the grid
// ─────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading