Skip to content

Release v1.23.0 - #480

Merged
erikdarlingdata merged 9 commits into
mainfrom
dev
Sep 2, 2026
Merged

erikdarlingdata merged 9 commits into
mainfrom
dev

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Release v1.23.0.

Queries stop being second-class

Open Query and Save Query got the rest of what they needed. Query tabs now come back when the app restarts, the way plan tabs always have, and Copy Path appears on their context menu for the first time — both were the same missing branch in GetTabFilePath (#463). Editing a query and closing the tab, the window, or a detached window now asks Save / Don't Save / Cancel instead of discarding the work silently, and a modified tab wears a dot in place of its × until you point at it (#462, #473).

The detached-window half of that was the worse bug: a detached session is off the tab strip, so the shutdown prompt was walking tabs and honestly reporting nothing to save while a dirty edit sat in another window.

Copying a statement out of a plan gives you something you can run

Under PARAMETERIZATION FORCED, stored procedures, or sp_executesql, the statement text a plan records is full of @0, @1 — correct, and unrunnable. Copy Query Text and Open in Query Editor now put the plan's captured values back, runtime first and compiled as fallback, with (as parameterized) alongside for when you want the text that matches the plan cache. Grid Ctrl+C produces the same thing as the menu entry (#467).

Fixes

Internal

The headless test harness could be poisoned by a single test leaving a render pass pending — Avalonia disposes the font manager before running queued jobs, and the resulting throw leaked a locator scope, failing every UI test that ran afterwards on something that was not its fault. Fixed at the harness, with a health check that now blames the guilty test (#474).

388 tests, 0 failures.

🤖 Generated with Claude Code

https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR

erikdarlingdata and others added 9 commits September 1, 2026 20:59
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Plan tabs came back after a restart. Query tabs did not, even when the
query had been opened from a file and the path was sitting on the session
control.

GetTabFilePath knew exactly one tab shape: a DockPanel with a
PlanViewerControl inside it. A query tab is a QuerySessionControl with no
wrapper, and it has had a SourceFilePath of its own since #459. It was
simply never asked, so SaveOpenPlans wrote nothing down and
RestoreOpenPlans had nothing to bring back.

The same blind spot hid Copy Path on the tab context menu, which is shown
only when GetTabFilePath answers. It had never appeared on a query tab.

With both kinds of file in one saved list, restore routes on extension
through the existing OpenFileByExtension instead of assuming a plan.
Handing a .sql file to LoadPlanFile produced an "XML is not valid" box
where the user's query should have been.

The setting is renamed open_plans -> open_tabs now that it holds both. A
file written by the previous version is still read: the old key
deserializes into a migration-only property that is merged into OpenTabs
on load and then nulled, so it drops out of the file on the next save
rather than taking a user's restored tabs with it.

Scope: this restores query tabs that came from a file. A scratch tab that
was never saved has no path and still does not come back; persisting
unsaved buffers is #462.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
A query session tracked where it came from but never what was in it, so nothing
in the app could tell an edited tab from an untouched one. Closing either lost
the same amount of work: all of it, silently.

The dirty flag is the feature; the close prompt and the modified marker are both
consumers of it. QuerySessionControl now keeps the text as of the last load or
save and compares against it, so typing something back to how it started leaves
the session clean rather than latching modified on the first keystroke.

Close now asks, on every path that discards a tab — the x, middle-click, the
context menu, Ctrl+W — and on the window itself, which walks every tab rather
than the one in front. Cancel cancels. A never-saved scratch tab answering Save
routes through Save As, because saving it in place would write over nothing.

The tab's x becomes a filled dot while there are unsaved changes and reverts to
the x under the pointer, so a marked tab is still a closable one. Both glyphs
are \u escapes, matching the "✕" they replaced.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#470)

Copying a statement out of a plan handed back @0, @1 ... where the literals
had been. The text was correct — the reporter's database has PARAMETERIZATION
FORCED, so SQL Server rewrote the statement before compiling it and the plan's
StatementText carries the rewritten form — but a query full of undeclared
parameters does not run, and running it is why you copy a statement out of a
plan.

The values were already in the plan. ParameterList carries a compiled and a
runtime value for every one of them, and ShowParameters has been parsing and
displaying them all along. Nothing new needed reading; the substitution was
simply not offered anywhere.

ParameterSubstitution.Apply does the rewrite: runtime value where present,
compiled value otherwise, parentheses off numerics, quotes kept on strings,
showplan's {guid'...'} braces off. It matches whole tokens against a scan that
skips string literals, delimited identifiers, and comments, so @1 cannot land
inside @11 and an @0 inside a literal is left where it is. A name with no
captured value stays visible rather than being filled with an invention.

The two existing menu entries produce the substituted form and say so in their
labels; the plan's own text stays reachable through a third entry that appears
only when the two forms differ. Ctrl+C on the statements grid follows the same
path, since it is the unlabelled twin of Copy Query Text.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
ShowError ended with an unconditional ShowDialog(this), which throws
InvalidOperationException: "Cannot show window with non-visible owner"
when the owner has not been shown yet. Both the command-line open and
the restore of the previous session's tabs run from the MainWindow
constructor, so a plan file that fails XML validation at startup died
on the report instead of showing it.

Same guard ShowFileError got in #459, plus a doc comment that says why
it is there so the next person does not tidy it away.

Test: ShowErrorBeforeVisibleTests drives LoadPlanFile against a
never-shown MainWindow on both arms of ValidatePlanXml — malformed XML
and well-formed XML that is not a plan. Red without the guard with the
non-visible-owner exception, green with it.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
CreateTab decided Copy Path's visibility once, while the tab was being built, and
never asked again. Open a scratch query and save it — the session gains a
SourceFilePath, the tab is retitled after the file, and Copy Path is still not on
the menu, because it was told there was nothing to copy back when that was true.
Only a restart, which rebuilds the tab, put it right.

The gate now recomputes on ContextMenu.Opening. The menu is only consulted when it
opens, so that is the honest moment to answer, and it covers the other direction
for free: a tab that stops having a file stops offering to copy one, rather than
leaving a menu item that copies nothing when clicked.

The tests drive the real gesture rather than the handler. Avalonia raises
ContextMenu.Opening from its ContextRequested handler on the attached control, not
from ContextMenu.Open() — calling Open() directly sets IsOpen and never asks — so
they raise ContextRequestedEvent on the tab header, which is the path a right-click
takes. Both fail against the one-shot code, each on the assertion after the change
rather than the one before it.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#477)

A query session detached into its own window dodged both of #462's prompts.
The window's close path is DetachedWindowHelper's own and asked nobody
anything, and while detached the session is out of MainTabControl.Items, so
TabsWithUnsavedChanges() honestly reported nothing to save with a dirty edit
sitting in another window.

ShowDetached gains a closeGuard: a synchronous question asked once, before
anything is destroyed. Null means close now with nothing asked - read-only
content, an unmodified session, and app shutdown - so a plan or Query Store
window takes exactly the path it always took. A task means cancel the close
until it answers, the same cancel-then-reissue OnClosing uses (#462).

MainWindow keeps a register of the query sessions living in detached windows,
and UnsavedWorkOnClose() is now one walk over both the tab strip and that
register rather than two walks, because two walks is how the second one gets
forgotten. The shutdown prompt asks about detached sessions from
ConfirmWindowCloseAsync, while every window is still up, rather than from
OnClosed where they are force-closed after the main window is already gone.


Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…478)

A PlanViewerControl shown in a headless Window left every window built
afterwards throwing KeyNotFoundException for fonts:SystemFonts. The test
that did it passed, and which of the others went red depended on the
order the runner picked that day.

The session is not one Application per process, which is what the
harness comment claimed. HeadlessUnitTestSession runs at PerTest
isolation: each dispatch enters an AvaloniaLocator scope, builds an
Application inside it, and tears both down afterwards. That teardown
disposes the FontManager first and calls Dispatcher.ResetForUnitTests
second, and ResetForUnitTests executes whatever is still queued. A
window whose content left a deferred render pass behind therefore
renders text against a font manager that was disposed a line earlier,
throws, and the throw escapes the teardown delegate before it reaches
scope.Dispose(). The scope is never popped. Every later dispatch nests
inside the leaked one, resolves the disposed font manager through its
parent chain, and dies constructing any Window at all.

HeadlessUi.Run now drains the dispatcher queue at the end of every
dispatch, so the teardown has nothing left to run against a font manager
it has already disposed. It also follows each test with a one-object
health check, so a future case that defeats the drain fails on the test
that caused it instead of on the thirty that follow.

StatementParameterMenuTests drops the workaround it was documenting: it
now shows the control in a window and raises the context request a
right-click raises, which is the path that decides the labels.
UpdateStatementMenuForSelection goes back to being private.


Claude-Session: https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016a1AnKAHwcALrwdYVVrpgR

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 3f16bb6 into main Sep 2, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant