Skip to content

Commit a093055

Browse files
authored
Query history improvments (#336)
* Fix sort issues on the QueryHistory * What changed 1. QueryStoreGridControl.Selection.cs — ViewHistory_Click now walks up the visual tree to find the parent QuerySessionControl and calls AddHistorySubTab(), placing the history as a sub-tab alongside "Query Store — DB" and "QS Overview" (instead of at the top-level MainTabControl). 2. QuerySessionControl.QueryStore.cs — Added two new methods: • AddHistorySubTab() — creates a closeable sub-tab with the history control, including long-press (~500ms) to detach into a free-floating window • DetachHistorySubTabToWindow() — pops the history content into a standalone non-modal window (movable to another screen). When that window is minimized or closed, the content automatically re-docks back as a sub-tab 3. MainWindow.Tabs.cs — Removed the now-unused AddHistoryTab() method since history tabs no longer live at the top level User flow • View History → opens as a sub-tab next to Query Store / QS Overview • Long-press the history sub-tab → detaches into a free window (can move to another screen) • Minimize or close the free window → content returns to the sub-tab * What was added 1. QueryStoreService.FetchPlanByHashAsync() — New method that fetches a plan XML from sys.query_store_plan by query_plan_hash. The oldest parameter controls whether it returns the first (smallest plan_id) or last (largest plan_id) plan. 2. Context menu on QueryStoreHistoryControl — Right-clicking on the DataGrid or chart shows: • "Load the First Plan" — fetches the oldest plan for the selected plan hash • "Load the Last Plan" — fetches the most recent plan for the selected plan hash The selected plan hash is determined from either the grid selection or chart selection. 3. PlanLoadRequested event — Raised when the user picks a plan from the context menu. The parent QuerySessionControl subscribes to this event and opens the plan XML as a new sub-tab (using the existing AddPlanTab mechanism). User flow • Select a row in the history grid (or a dot on the chart) • Right-click → "Load the First Plan" or "Load the Last Plan" • The plan opens as a new sub-tab (e.g., "QS 42 / 17") * Code review : Bugs Fixed 1. Shared ContextMenu — BuildContextMenu() now creates separate ContextMenu instances for DataGrid and Chart via CreatePlanContextMenu() 2. Silent catch — LoadPlanFromSelection now shows errors in StatusText with catch (Exception ex) 3. Wrong type check — Close_Click now checks is not PlanViewer.App.MainWindow instead of IClassicDesktopStyleApplicationLifetime 4. Event handler leak — AddHistorySubTab now unsubscribes before subscribing: -= OnHistoryPlanLoadRequested before += Improvements 5. Loading feedback — Shows "Loading plan…" / "Plan not found" / error in StatusText 6. Disable menu when no selection — Opening handler disables items when GetSelectedPlanHash() is null 7. IndexOf — Added clarifying comment (list is <500 items, no better API available) 8. ScrollIntoView — Moved after ItemsSource reset so the target row exists 9. Long-press duplication — Extracted TabHeaderLongPressBehavior helper, used in both MainWindow.Tabs.cs and QuerySessionControl.QueryStore.cs 10. HistoryPlanLoadEventArgs — Moved to its own file Nits 11. Removed unnecessary ?. on non-nullable orderBy parameter 12. Changed bare catch to catch (Exception) in tooltip handler 13. Changed bare catch to catch (Exception ex) in LoadPlanFromSelection * When the MainWindow closes, it now iterates all open windows via IClassicDesktopStyleApplicationLifetime.Windows and closes any that aren't the MainWindow itself. This ensures all detached free-floating windows (from tab detach, history detach, advice windows, etc.) are closed when the app's main window is closed. * Must-fix fixed * use pin/unpin button to fix ux-must-fix * fix error truncation to 80 chars : truncation removed * fix most remaining nice to have * add spinner and cancel for history fetches
1 parent d774c7b commit a093055

11 files changed

Lines changed: 1704 additions & 1205 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System;
2+
using PlanViewer.Core.Models;
3+
4+
namespace PlanViewer.App.Controls;
5+
6+
/// <summary>
7+
/// Event args for when a plan is loaded from the history context menu.
8+
/// </summary>
9+
public class HistoryPlanLoadEventArgs : EventArgs
10+
{
11+
public QueryStorePlan Plan { get; }
12+
13+
public HistoryPlanLoadEventArgs(QueryStorePlan plan)
14+
{
15+
Plan = plan;
16+
}
17+
}

src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs

Lines changed: 137 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
using AvaloniaEdit.TextMate;
2020
using Microsoft.Data.SqlClient;
2121
using PlanViewer.App.Dialogs;
22+
using PlanViewer.App.Helpers;
2223
using PlanViewer.App.Services;
2324
using PlanViewer.Core.Interfaces;
2425
using PlanViewer.Core.Models;
@@ -38,31 +39,15 @@ private bool HasQueryStoreTab()
3839

3940
public void TriggerQueryStore() => QueryStore_Click(null, new RoutedEventArgs());
4041

41-
private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
42+
/// <summary>
43+
/// Creates a sub-tab with a standard header (label + optional extra buttons + close button).
44+
/// Returns the TabItem. The close button removes the tab from SubTabControl.
45+
/// </summary>
46+
private TabItem CreateSubTab(string label, Control content, Action<TabItem>? onClose = null, params Button[] extraButtons)
4247
{
43-
if (_serverConnection == null || _connectionString == null)
44-
{
45-
await ShowConnectionDialogAsync();
46-
if (_serverConnection == null || _connectionString == null)
47-
return;
48-
}
49-
50-
SetStatus("Loading Query Store Overview...");
51-
52-
var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
53-
var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService,
54-
supportsWaitStats: supportsWaitStats);
55-
overview.DrillDownRequested += async (_, args) =>
56-
{
57-
// Open a single-database Query Store tab directly (no connection dialog)
58-
_selectedDatabase = args.Database;
59-
_connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database);
60-
await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc);
61-
};
62-
6348
var headerText = new TextBlock
6449
{
65-
Text = "QS Overview",
50+
Text = label,
6651
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
6752
FontSize = 12
6853
};
@@ -73,7 +58,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
7358
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
7459
Padding = new Avalonia.Thickness(0),
7560
FontSize = 11,
76-
Margin = new Avalonia.Thickness(6, 0, 0, 0),
61+
Margin = new Avalonia.Thickness(2, 0, 0, 0),
7762
Background = Brushes.Transparent,
7863
BorderThickness = new Avalonia.Thickness(0),
7964
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
@@ -85,17 +70,58 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
8570
var header = new StackPanel
8671
{
8772
Orientation = Avalonia.Layout.Orientation.Horizontal,
88-
Children = { headerText, closeBtn }
73+
Background = Brushes.Transparent
8974
};
75+
header.Children.Add(headerText);
76+
foreach (var btn in extraButtons)
77+
header.Children.Add(btn);
78+
header.Children.Add(closeBtn);
9079

91-
var tab = new TabItem { Header = header, Content = overview };
80+
var tab = new TabItem { Header = header, Content = content };
9281
closeBtn.Tag = tab;
9382
closeBtn.Click += (s, _) =>
9483
{
9584
if (s is Button btn && btn.Tag is TabItem t)
85+
{
86+
onClose?.Invoke(t);
9687
SubTabControl.Items.Remove(t);
88+
}
89+
};
90+
91+
return tab;
92+
}
93+
94+
/// <summary>Gets the header TextBlock from a sub-tab created via CreateSubTab.</summary>
95+
private static TextBlock? GetSubTabHeaderText(TabItem tab)
96+
{
97+
if (tab.Header is StackPanel sp && sp.Children.Count > 0 && sp.Children[0] is TextBlock tb)
98+
return tb;
99+
return null;
100+
}
101+
102+
private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
103+
{
104+
if (_serverConnection == null || _connectionString == null)
105+
{
106+
await ShowConnectionDialogAsync();
107+
if (_serverConnection == null || _connectionString == null)
108+
return;
109+
}
110+
111+
SetStatus("Loading Query Store Overview...");
112+
113+
var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false;
114+
var overview = new QueryStoreOverviewControl(_serverConnection, _credentialService,
115+
supportsWaitStats: supportsWaitStats);
116+
overview.DrillDownRequested += async (_, args) =>
117+
{
118+
// Open a single-database Query Store tab directly (no connection dialog)
119+
_selectedDatabase = args.Database;
120+
_connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database);
121+
await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc);
97122
};
98123

124+
var tab = CreateSubTab("QS Overview", overview);
99125
SubTabControl.Items.Add(tab);
100126
SubTabControl.SelectedItem = tab;
101127

@@ -106,7 +132,7 @@ private async void QueryStoreOverview_Click(object? sender, RoutedEventArgs e)
106132
}
107133
catch (Exception ex)
108134
{
109-
SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
135+
SetStatus(ex.Message, autoClear: false);
110136
}
111137
}
112138

@@ -127,7 +153,7 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini
127153
}
128154
catch (Exception ex)
129155
{
130-
SetStatus(ex.Message.Length > 80 ? ex.Message[..80] + "..." : ex.Message, autoClear: false);
156+
SetStatus(ex.Message, autoClear: false);
131157
return;
132158
}
133159

@@ -152,41 +178,11 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini
152178
grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value);
153179
grid.PlansSelected += OnQueryStorePlansSelected;
154180

155-
var headerText = new TextBlock
156-
{
157-
Text = $"Query Store — {database}",
158-
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
159-
FontSize = 12
160-
};
161-
grid.DatabaseChanged += (_, db) => headerText.Text = $"Query Store — {db}";
162-
163-
var closeBtn = new Button
164-
{
165-
Content = "\u2715",
166-
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
167-
Padding = new Avalonia.Thickness(0),
168-
FontSize = 11,
169-
Margin = new Avalonia.Thickness(6, 0, 0, 0),
170-
Background = Brushes.Transparent,
171-
BorderThickness = new Avalonia.Thickness(0),
172-
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
173-
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
174-
HorizontalContentAlignment = HorizontalAlignment.Center,
175-
VerticalContentAlignment = VerticalAlignment.Center
176-
};
177-
178-
var header = new StackPanel
179-
{
180-
Orientation = Avalonia.Layout.Orientation.Horizontal,
181-
Children = { headerText, closeBtn }
182-
};
183-
184-
var tab = new TabItem { Header = header, Content = grid };
185-
closeBtn.Tag = tab;
186-
closeBtn.Click += (s, _) =>
181+
var tab = CreateSubTab($"Query Store — {database}", grid);
182+
grid.DatabaseChanged += (_, db) =>
187183
{
188-
if (s is Button btn && btn.Tag is TabItem t)
189-
SubTabControl.Items.Remove(t);
184+
if (GetSubTabHeaderText(tab) is TextBlock tb)
185+
tb.Text = $"Query Store — {db}";
190186
};
191187

192188
SubTabControl.Items.Add(tab);
@@ -244,62 +240,112 @@ private async void QueryStore_Click(object? sender, RoutedEventArgs e)
244240
_selectedDatabase!, databases, supportsWaitStats);
245241
grid.PlansSelected += OnQueryStorePlansSelected;
246242

247-
var headerText = new TextBlock
248-
{
249-
Text = $"Query Store — {_selectedDatabase}",
250-
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
251-
FontSize = 12
252-
};
253-
243+
var tab = CreateSubTab($"Query Store — {_selectedDatabase}", grid);
254244
// Update tab header when database is changed via the grid's picker
255245
grid.DatabaseChanged += (_, db) =>
256246
{
257-
headerText.Text = $"Query Store — {db}";
247+
if (GetSubTabHeaderText(tab) is TextBlock tb)
248+
tb.Text = $"Query Store — {db}";
258249
};
259250

260-
var closeBtn = new Button
251+
SubTabControl.Items.Add(tab);
252+
SubTabControl.SelectedItem = tab;
253+
}
254+
255+
private void OnQueryStorePlansSelected(object? sender, List<QueryStorePlan> plans)
256+
{
257+
foreach (var qsPlan in plans)
261258
{
262-
Content = "\u2715",
259+
var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
260+
AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
261+
}
262+
263+
SetStatus($"{plans.Count} Query Store plans loaded");
264+
HumanAdviceButton.IsEnabled = true;
265+
RobotAdviceButton.IsEnabled = true;
266+
}
267+
268+
/// <summary>
269+
/// Adds a Query Store History control as a sub-tab in this session.
270+
/// Supports long-press to detach into a free-floating window.
271+
/// </summary>
272+
public void AddHistorySubTab(string label, QueryStoreHistoryControl control)
273+
{
274+
// Wire up plan load from context menu (unsubscribe first to prevent leaks on re-dock)
275+
control.PlanLoadRequested -= OnHistoryPlanLoadRequested;
276+
control.PlanLoadRequested += OnHistoryPlanLoadRequested;
277+
278+
var detachBtn = new Button
279+
{
280+
Content = "↗",
263281
MinWidth = 22, MinHeight = 22, Width = 22, Height = 22,
264282
Padding = new Avalonia.Thickness(0),
265283
FontSize = 11,
266-
Margin = new Avalonia.Thickness(6, 0, 0, 0),
284+
Margin = new Avalonia.Thickness(4, 0, 0, 0),
267285
Background = Brushes.Transparent,
268286
BorderThickness = new Avalonia.Thickness(0),
269-
Foreground = new SolidColorBrush(Color.FromRgb(0xE4, 0xE6, 0xEB)),
287+
Foreground = new SolidColorBrush(Color.FromRgb(0xA0, 0xA0, 0xA0)),
270288
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
271289
HorizontalContentAlignment = HorizontalAlignment.Center,
272290
VerticalContentAlignment = VerticalAlignment.Center
273291
};
292+
Avalonia.Controls.ToolTip.SetTip(detachBtn, "Detach to Window");
274293

275-
var header = new StackPanel
276-
{
277-
Orientation = Avalonia.Layout.Orientation.Horizontal,
278-
Children = { headerText, closeBtn }
279-
};
294+
var tab = CreateSubTab(label, control,
295+
onClose: t => { if (t.Content is QueryStoreHistoryControl hc) hc.CancelFetch(); },
296+
detachBtn);
280297

281-
var tab = new TabItem { Header = header, Content = grid };
282-
closeBtn.Tag = tab;
283-
closeBtn.Click += (s, _) =>
298+
detachBtn.Tag = tab;
299+
detachBtn.Click += (s, _) =>
284300
{
285301
if (s is Button btn && btn.Tag is TabItem t)
286-
SubTabControl.Items.Remove(t);
302+
DetachHistorySubTabToWindow(t);
287303
};
288304

289305
SubTabControl.Items.Add(tab);
290306
SubTabControl.SelectedItem = tab;
291307
}
292308

293-
private void OnQueryStorePlansSelected(object? sender, List<QueryStorePlan> plans)
309+
private void OnHistoryPlanLoadRequested(object? sender, HistoryPlanLoadEventArgs e)
294310
{
295-
foreach (var qsPlan in plans)
296-
{
297-
var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}";
298-
AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel);
299-
}
311+
var plan = e.Plan;
312+
var tabLabel = $"QS {plan.QueryId} / {plan.PlanId}";
313+
AddPlanTab(plan.PlanXml, plan.QueryText, estimated: true, labelOverride: tabLabel);
314+
}
300315

301-
SetStatus($"{plans.Count} Query Store plans loaded");
302-
HumanAdviceButton.IsEnabled = true;
303-
RobotAdviceButton.IsEnabled = true;
316+
/// <summary>
317+
/// Detaches a history sub-tab into a standalone free-floating window.
318+
/// Close = destroy. A Re-dock button allows explicit return to sub-tabs.
319+
/// </summary>
320+
private void DetachHistorySubTabToWindow(TabItem tab)
321+
{
322+
var content = tab.Content as QueryStoreHistoryControl;
323+
if (content == null) return;
324+
325+
var tabLabel = GetSubTabHeaderText(tab)?.Text ?? "History";
326+
327+
// Remove from sub-tabs
328+
SubTabControl.Items.Remove(tab);
329+
tab.Content = null;
330+
331+
var mainWindow = Avalonia.Controls.TopLevel.GetTopLevel(this) as Window;
332+
333+
content.ShowCloseButton(false);
334+
335+
DetachedWindowHelper.ShowDetached(
336+
content,
337+
title: tabLabel,
338+
icon: mainWindow?.Icon,
339+
backgroundBrush: (Avalonia.Media.IBrush?)this.FindResource("BackgroundBrush"),
340+
onRedock: c =>
341+
{
342+
if (mainWindow is not MainWindow mw || !mw.IsShuttingDown)
343+
AddHistorySubTab(tabLabel, (QueryStoreHistoryControl)c);
344+
},
345+
onClosing: c =>
346+
{
347+
if (c is QueryStoreHistoryControl hc)
348+
hc.CancelFetch();
349+
});
304350
}
305351
}

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ private static List<QueryStorePlan> CollectLeafPlans(QueryStoreRow row)
8787
return plans;
8888
}
8989

90-
private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
90+
private void ViewHistory_Click(object? sender, RoutedEventArgs e)
9191
{
9292
if (ResultsGrid.SelectedItem is not QueryStoreRow row) return;
9393
if (string.IsNullOrEmpty(row.QueryHash)) return;
9494

9595
var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy);
9696

97-
var window = new QueryStoreHistoryWindow(
97+
var control = new QueryStoreHistoryControl(
9898
_connectionString,
9999
row.QueryHash,
100100
row.FullQueryText,
@@ -104,11 +104,28 @@ private async void ViewHistory_Click(object? sender, RoutedEventArgs e)
104104
slicerEndUtc: _slicerEndUtc,
105105
slicerDaysBack: _slicerDaysBack);
106106

107-
var topLevel = Avalonia.Controls.TopLevel.GetTopLevel(this);
108-
if (topLevel is Window parentWindow)
109-
await window.ShowDialog(parentWindow);
107+
var shortHash = row.QueryHash.Length > 8 ? row.QueryHash[..8] + "…" : row.QueryHash;
108+
109+
// Walk up the visual tree to find the parent QuerySessionControl
110+
var session = this.FindAncestorOfType<QuerySessionControl>();
111+
if (session != null)
112+
{
113+
session.AddHistorySubTab($"History: {shortHash}", control);
114+
}
110115
else
116+
{
117+
// Fallback: open as standalone window
118+
var window = new QueryStoreHistoryWindow(
119+
_connectionString,
120+
row.QueryHash,
121+
row.FullQueryText,
122+
_database,
123+
initialMetricTag: metricTag,
124+
slicerStartUtc: _slicerStartUtc,
125+
slicerEndUtc: _slicerEndUtc,
126+
slicerDaysBack: _slicerDaysBack);
111127
window.Show();
128+
}
112129
}
113130

114131
private void ContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)

0 commit comments

Comments
 (0)