Query history improvments#336
Conversation
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
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")
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
…ssicDesktopStyleApplicationLifetime.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.
erikdarlingdata
left a comment
There was a problem hiding this comment.
Review
Substantial new feature (~1600 lines). Core design is sound; flagging real bugs and UX gaps before merge.
Must-fix
-
DataGrid numeric sort is broken on every formatted column.
QueryStoreHistoryControl.axaml:129-141—StringFormat='{}{0:N2}'on aDataGridTextColumnwithCanUserSortColumns=Truecauses lexicographic sort on the formatted string ("1,234.50"sorts before"9.10"). The PR description says numeric sort works because the binding is to a numeric property, but Avalonia's DataGrid sorts on the displayed text whenStringFormatis in play. Commit4d4ceffclaims to fix this — please verify; if not, removeStringFormatand apply formatting via a converter or template column withSortMemberPathpointing at the unformatted property. -
Shutdown race: closing MainWindow → detached window
Closing→Dispatcher.UIThread.Post(AddHistorySubTab/CreateTab)runs against a torn-downSubTabControl/MainTabControl.MainWindow.axaml.cs:204-208closes every other window duringOnClosed; theClosinghandlers inQuerySessionControl.QueryStore.cs:420andMainWindow.Tabs.cs:302re-dock via a deferred dispatcher post. Set a "shutting down" flag before iterating, or unsubscribe theClosinghandlers first. -
OnClosedisasync voidwith awaits before the window-close loop.MainWindow.axaml.cs:189-211— exceptions become unhandled, and the close loop runs on a continuation afterOnClosedreturns. Wrap the body in try/catch. -
LoadPlanFromSelection(async void) has no re-entrancy guard.QueryStoreHistoryControl.axaml.cs:1038— rapid double-click fires twoFetchPlanByHashAsynccalls and twoPlanLoadRequestedinvocations → duplicate tabs. Disable menu items or set a guard while the fetch is pending. -
Long-press timer leak when header is unparented mid-press.
Helpers/TabHeaderLongPressBehavior.cs:42— if the tab is closed (via X button) betweenPointerPressedandPointerReleased, the timer keeps ticking and firesonLongPress()on an orphan TabItem. Also stop the timer onPointerCaptureLostandheader.DetachedFromVisualTree. -
_fetchCtsonly disposed on explicit sub-tab close.QueryStoreHistoryControl.axaml.cs:28,205-210— leaks the CTS and any in-flightSqlConnectionif the host window/session closes without the X being clicked. HookDetachedFromVisualTree.
UX must-fix
-
Long-press detach has zero discoverability.
MainWindow.Tabs.cs:79-86is the only entry point — no tooltip, no context-menu item. Add"Detach to Window"to the tab right-click menu (which already has Rename/Copy Path/Close — perfect spot). Same for the QS sub-tabs. -
Minimize-as-redock will read as "the app ate my window."
MainWindow.Tabs.cs:305-316— Windows users expect minimize → taskbar, not auto-restore into another window. Recommend dropping the minimize handler and relying on Close + an explicit "Re-dock" action. -
Closing MainWindow force-closes detached windows with no session save.
MainWindow.axaml.cs:189-211saves file-based plans but not detached window state. Consider re-docking detached content back into tabs beforeSaveOpenPlansruns. -
Error messages truncated to 80 chars in
QueryStoreHistoryControl.axaml.cs:278andQuerySessionControl.QueryStore.cs:110,131. SQL error context usually lives past char 80 ("...network-related or instance-specific error..."). Drop the truncation or move to a "click for details" link.
Nice-to-have
- Tab-header creation is duplicated 5 times across
QuerySessionControl.QueryStore.cs(4 blocks) andMainWindow.Tabs.cs. The QS variants are missingBackground = Brushes.Transparenton the headerStackPanel, whichMainWindow.Tabs.cs:70has — long-press hit-testing on empty header space depends on it. - Detach inconsistency:
MainWindow.Tabs.cs:289-292callshc.ShowCloseButton(false)on re-dock;QuerySessionControl.QueryStore.cs:407-418does not. The two paths should match. - "Load the First/Last Plan" → rename to "Load Oldest Plan for This Hash" / "Load Newest Plan for This Hash". Matches the
oldest:parameter and removes ambiguity ("first in selection" vs "first in time"). - No spinner during loads, no UI Cancel. Add an indeterminate
ProgressBarnext toStatusTextduring fetches. HighlightGridRowsdoesItemsSource = null; ItemsSource = source(line 769-770) — wipes column sort and scroll position. Iterate visible rows and updateBackgrounddirectly instead.PixelToCoordinatesuses X scaling for both axes (lines 677-682, duplicated at 688-691 and 851-854). Breaks on non-uniform DPI. Extract and fix once.StatusText"Loading plan…"→""on success clobbers the data-load summary. Use a separate label or restore the summary.- No keyboard path: no
KeyBindingto detach; detached windows don't respond to Esc/Ctrl+W; context menu has no accelerators. AutomationProperties.Namemissing onLegendToggleButtonand the color-indicator column.FetchPlanByHashAsync:orderDirinterpolation is safe (literal"ASC"/"DESC");@planHashis parameterized. Note: when onequery_plan_hashmaps to multiplequery_idrows,TOP 1 ORDER BY plan_idpicks arbitrarily — rare but real.
Clean
- AvaloniaEdit style include present.
- No MVVM creep, no muted color literals, no
%LOCALAPPDATA%. - ContextMenu fix is real (two distinct instances).
ScrollIntoViewreordering fix is real.PlanLoadRequestedunsubscribe-before-subscribe inAddHistorySubTabis correct.- Designer-only parameterless ctor of
QueryStoreHistoryControlis safe.
erikdarlingdata
left a comment
There was a problem hiding this comment.
Re-review after the 4 follow-up commits
Most of the must-fix and UX items are addressed. A few notes below.
Must-fix — status
| # | Item | Status |
|---|---|---|
| 1 | DataGrid numeric sort | Verify by clicking — see note below |
| 2 | Shutdown race | ✅ IsShuttingDown flag, checked both before scheduling the Post and inside it (MainWindow.axaml.cs:44, MainWindow.Tabs.cs:285,293, QuerySessionControl.QueryStore.cs:339-347 in the redock click handler) |
| 3 | OnClosed async void |
✅ wrapped in try/catch (MainWindow.axaml.cs:194-224) |
| 4 | LoadPlanFromSelection re-entrancy |
✅ _isLoadingPlan with try/finally (QueryStoreHistoryControl.axaml.cs:1043,1047,1085) |
| 5 | Long-press timer leak | ✅ moot — long-press is gone entirely (replaced by explicit button + menu item) |
| 6 | _fetchCts disposal |
✅ DetachedFromVisualTree += (_, _) => CancelFetch() (QueryStoreHistoryControl.axaml.cs:194) |
On #1 — DataGrid sort: I want to retract some of the certainty in my first review. On closer reading, Avalonia's DataGrid sorts on the Binding's Path via reflection, not on the formatted display string — StringFormat is only the display converter. So the current pattern ({ReflectionBinding AvgDurationMs, StringFormat='{}{0:N2}'}) should sort numerically. That contradicts what my first review said. Worth a quick smoke test: click each column header on a result set where lexicographic vs numeric ordering would differ (e.g., values mixing 9.10 and 1,234.50) and confirm.
UX must-fix — status
| # | Item | Status |
|---|---|---|
| 7 | Detach discoverability | ✅ "Detach to Window" in tab context menu (MainWindow.Tabs.cs:101); ↗ button on QS sub-tabs (QuerySessionControl.QueryStore.cs:279-294) |
| 8 | Minimize-as-redock confusion | ✅ Minimize behavior removed entirely; explicit 📌 Re-dock button in detached window toolbar |
| 9 | Force-close detached windows on app exit | Acceptable design: Close now destroys (with CancelFetch), user must Re-dock before quit. Document if you care. |
| 10 | 80-char error truncation | ✅ Removed (QuerySessionControl.QueryStore.cs:109,130; QueryStoreHistoryControl.axaml.cs:281) |
Nice-to-have — status
✅ Tab-header duplication: extracted CreateSubTab helper (QuerySessionControl.QueryStore.cs:42-101); also picks up the missing Background = Brushes.Transparent on the header StackPanel.
✅ PixelToCoordinates Y scaling: fixed; PointToScaledPixel helper extracts the duplication.
✅ HighlightGridRows: no longer resets ItemsSource; iterates GetVisualDescendants().OfType<DataGridRow>() and updates Background directly. Sort + scroll state preserved.
✅ Context menu rename: "Load Oldest/Newest Plan for This Hash".
✅ StatusText data summary: captured in _dataSummaryText and restored after plan load (QueryStoreHistoryControl.axaml.cs:50,266,1078).
New issues introduced
-
📌glyph in the Re-dock button content —QuerySessionControl.QueryStore.cs:332andMainWindow.Tabs.cs:276. Hardcoded pushpin emoji renders unevenly across Windows font fallbacks (and is monochrome on some) and screen readers will announce "pushpin emoji." Consider a text label like"⤵ Re-dock"(already-used arrow style) or just"Re-dock". -
Two "Re-dock" code paths are near-duplicates —
MainWindow.Tabs.cs:272-340andQuerySessionControl.QueryStore.cs:332-374build the same wrapper DockPanel + Re-dock button + Closing handler with a few-line difference. Same kind of duplication you just fixed for sub-tab headers. Worth factoring into aDetachableWindowhelper later. -
Re-dock click order is fine but subtle — in both paths the click handler does:
wrapper.Children.Remove(content)→detachedWindow.Content = null→detachedWindow.Close()→AddHistorySubTab/CreateTab. TheClose()synchronously fires theClosinghandler which callscontent.CancelFetch(). By the timeAttachedToVisualTreere-fires on the new parent, any in-flight fetch was already cancelled — theif (_historyData.Count == 0)guard prevents a redundant reload when data is present, but if the user re-docks during the first load, they'll lose progress and the load won't auto-restart. Probably acceptable; flag for awareness.
Still open from prior review
- No loading spinner / no in-UI Cancel during fetches.
- No
KeyBindingfor detach; detached windows don't respond to Esc/Ctrl+W. AutomationProperties.Namemissing onLegendToggleButtonand the color-indicator column.
Looks good overall. The detach UX is much cleaner now.
|
emoji removed and spinner + cancel added. |
erikdarlingdata
left a comment
There was a problem hiding this comment.
Re-review after 2d6f4ed
Single commit, three of the prior open items knocked off cleanly.
Addressed
- Loading spinner + Cancel —
LoadingPanelwith an indeterminateProgressBarand aCancelbutton inQueryStoreHistoryControl.axaml:72-77. Visibility toggled inLoadHistoryAsync(set true on entry, false infinally), so it always clears even on exceptions.Cancel_Click→CancelFetch(), which cancels_fetchCts; the existingOperationCanceledExceptioncatch setsStatusText.Text = "Cancelled.". Clean. - Detach code duplication — extracted into
Helpers/DetachedWindowHelper.cswithonRedock/onClosingcallbacks. BothMainWindow.Tabs.csandQuerySessionControl.QueryStore.csare now ~15-line call sites. Theredockedflag inside the helper correctly preventsonClosingfrom firing when the close was initiated by Re-dock. 📌glyph — removed; button is now plain"Re-dock".
New minor notes
-
LoadPlanFromSelectiondoesn't use the spinner or pass a CancelToken.QueryStoreHistoryControl.axaml.cs:1056-1063callsFetchPlanByHashAsync(_connectionString, planHash, oldest)with default token, and doesn't toggleLoadingPanel. So the "Loading plan…" status has no spinner and no Cancel path. Plan fetch is normally quick; flag only if you want full parity with the history fetch. -
DetachedWindowHelper.ShowDetacheddoesn't guard against a Closing-cancelled window. If something external were to sete.Cancel = trueon the window'sClosing, the Re-dock click handler has already removedcontentfromwrapper.Children, setdetachedWindow.Content = null, and would proceed toonRedock(content)— leaving an empty detached window still visible. No code in this PR cancels Closing, so practically harmless; flag for future hardening.
Still open (unchanged)
- No
KeyBindingto detach; detached windows don't respond to Esc/Ctrl+W. AutomationProperties.Namemissing onLegendToggleButtonand the color-indicator column.- DataGrid numeric-sort verification (no change since prior comment — should work but worth a column-header click on real data).
LGTM otherwise.
- Replace deprecated DataGridRow.GetIndex() with the Index property - Remove dead _suppressGridSelectionEvent field; the suppression was only needed for the old ItemsSource-reset highlight path, which was refactored away Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


Query Store History Improvements
Summary
This PR adds a Query Store History feature that lets users view execution history for a query, visualize trends on a chart, and load plans directly from historical data. It also includes several UX improvements for tab management.
New Features
Query Store History Viewer
QueryStoreHistoryControlUserControl — displays a ScottPlot chart and DataGrid showing query execution metrics over time (CPU, duration, reads, memory, etc.)StringFormatso sorting is numeric, not lexicographicQueryStoreService.FetchPlanByHashAsyncSub-tab Hosting
QuerySessionControlrather than cluttering the main tab barLong-press to Detach
TabHeaderLongPressBehaviorhelper used by bothMainWindowtabs and session sub-tabsWindow Lifecycle
IClassicDesktopStyleApplicationLifetime.WindowsonOnClosedBug Fixes
ContextMenuinstance (Avalonia can only parent a menu to one control)Close_Click— changed fromIClassicDesktopStyleApplicationLifetimetoMainWindowPlanLoadRequestedis now unsubscribed before re-subscribing when a history tab is re-dockedImprovements
ScrollIntoViewmoved afterItemsSourcereset so the target row actually existsHistoryPlanLoadEventArgsmoved to its own filecatchblocks replaced withcatch (Exception)/catch (Exception ex)Files Changed
src/PlanViewer.App/Controls/QueryStoreHistoryControl.axamlsrc/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cssrc/PlanViewer.App/Controls/HistoryPlanLoadEventArgs.cssrc/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cssrc/PlanViewer.App/Helpers/TabHeaderLongPressBehavior.cssrc/PlanViewer.App/MainWindow.Tabs.csTabHeaderLongPressBehavior, middle-click supportsrc/PlanViewer.App/MainWindow.axaml.cssrc/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cssrc/PlanViewer.Core/Services/QueryStoreService.csFetchPlanByHashAsyncmethodWhich component(s) does this affect?
How was this tested?
Describe the testing you've done. Include:
Checklist
dotnet build -c Debug)dotnet test)