Ask before a detached window throws away unsaved query changes (#473) - #477
Conversation
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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx
|
Reviewed the detached-window unsaved-changes fix. The design and test coverage are solid — the pure One gap, same class of bug the PR explicitly fixes for the Save As picker:
Low severity — it only surfaces on a save I/O failure during close — but worth a follow-up since the PR otherwise goes out of its way to get window ownership right everywhere else. |
Closes #473.
What was actually wrong
#462 gave query tabs an unsaved-changes prompt on tab close and on window close. Detach to Window walked out past both of them.
Two separate holes, one root cause — a detached session stops being a tab:
The window's own close asked nobody anything.
DetachedWindowHelper.ShowDetachedowns that path, and itsClosinghandler did exactly one thing: tell the content it was going away so a Query Store fetch could be cancelled. There was no question in it, because the helper is shared with plan and Query Store content and neither of those has anything to lose. A dirty query session went out the same door.The shutdown prompt could not see it.
TabsWithUnsavedChanges()is overMainTabControl.Items, and a detached session is not inMainTabControl.Items. So the app closed reporting nothing to save, honestly, while the edit sat in a window two inches to the left.The second one is the nastier of the two. The first is at least a window you are looking at when you close it. The second discards work in a window you are not.
The guard
ShowDetachedgains one parameter:It is asked, synchronously, once, before anything is destroyed, and it answers in one of two shapes:
AGuardlessDetachIsUntouchedAndClosesOnTheFirstPasspins that, and the mutation that gives everyone the detour reddens it.That is the same cancel-then-reissue
MainWindow.OnClosingdoes, and for the same reason:Window.Closingis synchronous and a prompt is not. AcloseConfirmedlatch is what stops the second pass asking the question all over again. The re-issuedClose()is posted rather than called, so a guard that answers without ever actually waiting cannot landClose()in the middle of theClosinghandler that called it — safe to post precisely because the guard returns null during shutdown, so nothing is ever queued against a dispatcher that is going away.Re-dock never reaches the guard. The
redockedflag is set beforeClose(), and the handler checks it first: the content is being moved, not destroyed, so there is nothing to save it from.The shutdown design decision, and why it is not where you would first put it
The obvious place to ask about detached windows is where they are closed —
MainWindow.OnClosed, which force-closes every other window. That is the wrong place, and the issue says as much: by then the main window is already gone, the app is on its way out, and a modal raised there is at best a window nobody expects and at worst a shutdown that never finishes. A detached window that answers "Cancel" fromOnClosedis a cancelled close on a window whose parent no longer exists.So the question is asked one step earlier, from
ConfirmWindowCloseAsync, while every window is still up.DetachedContentNeedsSavePromptthen returns false wheneverIsShuttingDown, which is what letsOnClosedforce those windows shut with no dialog and no cancelled close — the guard waves the force-close through because the question has already been asked and answered.That inverts the usual reading of the flag:
IsShuttingDownis not "skip the prompt to be safe", it is "the prompt already happened". The three waysDetachedContentNeedsSavePromptanswers no are all in that method with the reasoning attached, because the shutdown one is the one a future reader will otherwise delete.The walk itself is now a single list:
Tabs first in tab order, then the detached windows. One list rather than two walks, because two walks is how the second one gets forgotten — which is the whole of #473.
Tabis null for a detached session; there is no tab.Owneris the window that has to own its prompt.No known gap on shutdown. It is not merely lossy-but-clean; the shutdown confirmation genuinely counts detached dirty sessions and cancels on any Cancel.
Which window the prompt belongs to
The prompt for a detached session is owned by the detached window, not the main one. A dialog parented to a window behind the one you are looking at is a question about something you cannot see, and at shutdown it is a question about a window that is closing.
The Save As picker had the same problem in a quieter way.
SaveQueryAsyncreached forStorageProvider— an unqualifiedthis.StorageProvider, the main window's — so a detached window's Save As would have opened its picker on the wrong window. It now takes anIStorageProviderand the detached path hands itowner.StorageProvider.SaveQueryToPathalso had to learn thattabcan be null. There is no tab to retitle. Everything else about the save is unchanged, including which side of the write settles the dirty state: the mutation that movesMarkClean()before the write is still red under #462's tests.Red-then-green
Every test was written against the fix, then proven red by reverting the specific behaviour it covers, then green again. Run in the shared single-session harness the rest of the suite uses, not in isolation.
DetachTabToWindowpasses nocloseGuard(the pre-fix close path)ClosingADetachedWindowWithUnsavedWorkDoesNotJustCloseItClosingADetachedWindowTakesTheSessionOffTheRegisterRedockingDoesNotPromptCloseNeedsConfirmationcounts tabs only (the #462 state)TheShutdownPromptCountsDetachedSessionsAsWellAsTabsUnsavedWorkOnClosestops at the tab stripTheShutdownWalkAsksAboutTabsAndDetachedWindowsAlikeDetachedContentNeedsSavePromptignoresisShuttingDownOnlyADirtySessionIsAskedAboutAndNotOnceTheAppIsShuttingDownDetachedContentNeedsSavePromptclaims read-only content tooRedockingDoesNotPromptcloseConfirmedlatch — the re-issued close is questioned againTheGuardCancelsACloseAndTheAnswerIsOnlyAskedForOnceonClosingfires before the question is answeredSaveQueryToPathstill insists on a tabADetachedSessionSavesWithNoTabToRetitleTwo of these are worth calling out because they caught me rather than confirming me.
Re-dock. My first version of
RedockingDoesNotPromptasserted the session came back to a tab with its edit intact — and it stayed green when I wired the guard into the re-dock path. Re-dock hands the content back whether or not the window agreed to close, so the tab coming back proves nothing on its own. What a firing guard actually leaves behind is the emptied window still open, with a prompt on it, asking about a session that is already somewhere else. The test assertsdetached.IsVisibleis false anddetached.OwnedWindowsis empty, and it reddens now.The latch. Removing it makes the helper re-ask its way around the post-and-close loop forever, which is a hang, and a test that hangs tells you nothing. The test's stub guard carries a fuse — it refuses after three asks — so the failure is
asked == 4instead of a wedged runner. The fuse is scaffolding, not contract, and says so.Tests
376 passed / 0 failed / 2 skipped→385 passed / 0 failed / 2 skipped, measured onorigin/devatba50e6dand on this branch. Run with the in-process runner;dotnet testreports zero tests under SDK 10.0.302 here, which is pre-existing and unrelated.Covered: the dirty-detached walk in both directions, the shutdown list including its order and which window owns each prompt, the guard's three no-answers, the read-only path staying silent, Re-dock not asking, the cancel-and-keep-open behaviour, the ask-once latch, and a save with no tab behind it in both the written and the threw case.
The new tests put a real
QuerySessionControlin a real detached window, and every one of them puts that window away from afinally— #474 is what a leaked window costs in a single-application headless session, and the run where cleanup matters is the run where an assertion above it failed. Nothing here puts aPlanViewerControlin aWindow; read-only content is represented by aQueryStoreHistoryControl, which detaches through the same helper and takes the same silent path a plan does.Known gaps
IStorageProviderthe Save As picker gets is not covered by a test. The mutation that reverts it to the main window's provider stays green. A file picker cannot be driven headlessly, so which window it parents to is not observable from a test — the same limit Warn about unsaved query changes, and mark modified tabs #462 hit and said so about. It rests on reading the code.DetachedContentNeedsSavePromptfor whether to ask,DecideClosefor what to do with the answer.ClosingADetachedWindowWithUnsavedWorkDoesNotJustCloseItdrives a real close to a real prompt and asserts the window is still there with the edit intact, which is as far as headless goes.Window.Closingon every platform. Unchanged from Warn about unsaved query changes, and mark modified tabs #462, not chased here.🤖 Generated with Claude Code
https://claude.ai/code/session_017xj7HmCKrnsz2PWkRKT2Jx