From 7dd666b83065822b03b5b80269e069e25b88cc0d Mon Sep 17 00:00:00 2001 From: Dmitry Ratner <6830384+dmitrat@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:41:25 +0300 Subject: [PATCH] The status line takes back what is no longer true Found by clicking through the application before a release: with no editor open anywhere, the line read "Editing table: Products". The tab had been opened and closed again and the sentence stayed behind it. Most of what this line carries is an EVENT - "executed in 9 ms", "loaded 28 rows" - and an event stays true after it happens, which is why the line is not simply cleared whenever something changes. "Editing table X" is not an event. It is a state, it belongs to the tab, and it ends when the tab does. So a state is said WITH AN OWNER: SayThisWhile(tab, ...) remembers who said it, and closing that tab takes it back - by reference AND by comparing the line to what was set, so a message that arrived from anywhere else in the meantime is never taken away by the owner closing afterwards. Both of those are cases in the fixture, beside the third: closing a query tab leaves what it reported on the line. Red with the call removed from the close path. Studio: 1030 green. Co-Authored-By: Claude Opus 5 --- ...tusLineTakesBackWhatIsNoLongerTrueTests.cs | 141 ++++++++++++++++++ .../ViewModels/DatabaseExplorerViewModel.cs | 6 +- .../ViewModels/MainWindowViewModel.cs | 45 ++++++ .../ViewModels/WorkspaceTabsViewModel.cs | 7 + 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 Tools/OutWit.Database.Studio.Tests/ViewModels/TheStatusLineTakesBackWhatIsNoLongerTrueTests.cs diff --git a/Tools/OutWit.Database.Studio.Tests/ViewModels/TheStatusLineTakesBackWhatIsNoLongerTrueTests.cs b/Tools/OutWit.Database.Studio.Tests/ViewModels/TheStatusLineTakesBackWhatIsNoLongerTrueTests.cs new file mode 100644 index 0000000..974f10b --- /dev/null +++ b/Tools/OutWit.Database.Studio.Tests/ViewModels/TheStatusLineTakesBackWhatIsNoLongerTrueTests.cs @@ -0,0 +1,141 @@ +using OutWit.Database.Studio.Tests.Helpers; +using OutWit.Database.Studio.ViewModels.Tabs; + +namespace OutWit.Database.Studio.Tests.ViewModels; + +/// +/// The status line may keep saying what HAPPENED; it may not keep saying what IS. +/// +/// +/// +/// Measured in the running application on 2026-08-19, with no editor open anywhere: the line +/// read Editing table: Products. The tab had been opened and closed again, and the sentence +/// stayed behind it. +/// +/// +/// Most of what this line carries is an event - «executed in 9 ms», «loaded 28 rows» - and an event +/// stays true after it happens, which is why the line is not simply cleared when anything changes. +/// «Editing table X» is not an event. It is a state, it belongs to the tab, and it ends when the tab +/// does. +/// +/// +[TestFixture] +public class TheStatusLineTakesBackWhatIsNoLongerTrueTests +{ + #region Fields + + private StudioFixture m_studio = null!; + + #endregion + + #region Setup + + [SetUp] + public async Task SetUp() + { + m_studio = await StudioFixture.CreateAsync(); + + await m_studio.Explorer.RefreshAsync(); + } + + [TearDown] + public async Task TearDown() + { + await m_studio.DisposeAsync(); + } + + #endregion + + #region The rule + + [Test] + public async Task ClosingTheEditorTakesEditingOffTheLineTest() + { + var ready = m_studio.App.Localization["Status.Ready"]; + + var tab = await OpenTheEditorAsync(); + + Assume.That(m_studio.MainWindow.StatusText, Does.Contain("Customers"), + "the line says which table is being edited"); + + await CloseAsync(tab); + + Assert.That(m_studio.MainWindow.StatusText, Is.EqualTo(ready), + "nothing is being edited, so the line does not say that anything is - and what it " + + "falls back to is the same sentence the window starts with, not whatever happened " + + "to be there before the editor opened"); + } + + /// + /// The other direction, and the reason the owner is remembered rather than the line being + /// cleared: a message that arrived AFTER the editor opened belongs to whoever sent it, and + /// closing the editor must not take it away. + /// + [Test] + public async Task ClosingTheEditorLeavesSomebodyElsesMessageAloneTest() + { + var tab = await OpenTheEditorAsync(); + + const string SINCE_THEN = "Something else happened"; + + m_studio.MainWindow.StatusText = SINCE_THEN; + + await CloseAsync(tab); + + Assert.That(m_studio.MainWindow.StatusText, Is.EqualTo(SINCE_THEN), + "the editor only takes back what is still its own sentence"); + } + + /// + /// And an EVENT stays. This is the case that stops the rule above from becoming "clear the line + /// whenever a tab closes", which would throw away the answer to the last thing the user did. + /// + [Test] + public async Task ClosingAQueryTabLeavesWhatItReportedOnTheLineTest() + { + var query = m_studio.Workspace.OpenQueryTab("SELECT 1", "one", m_studio.Database); + + const string HAPPENED = "Query executed successfully in 9 ms"; + + m_studio.MainWindow.StatusText = HAPPENED; + + await CloseAsync(query); + + Assert.That(m_studio.MainWindow.StatusText, Is.EqualTo(HAPPENED), + "what happened stays true after the tab that did it has gone"); + } + + #endregion + + #region Tools + + private async Task OpenTheEditorAsync() + { + var tab = await m_studio.Workspace.OpenTableEditTabAsync(m_studio.Database, "Customers"); + + // Through the explorer, because that is where the sentence is written. + m_studio.Explorer.SelectedNode = m_studio.Explorer.Nodes + .SelectMany(Flatten) + .First(node => node.NodeType == Studio.Models.DatabaseNodeType.Table + && node.Name == "Customers"); + + await m_studio.Explorer.OpenWhatItIsAsync(); + + return tab; + } + + private async Task CloseAsync(WorkspaceTabViewModel tab) + { + await StudioFixture.PressAsync(m_studio.Workspace.CloseTabCommand, tab); + } + + private static IEnumerable Flatten(Studio.Models.DatabaseNode node) + { + yield return node; + + foreach (var child in node.Children.SelectMany(Flatten)) + yield return child; + } + + #endregion +} diff --git a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs index a4d6e12..1594d04 100644 --- a/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs +++ b/Tools/OutWit.Database.Studio/ViewModels/DatabaseExplorerViewModel.cs @@ -249,8 +249,10 @@ private async Task EditDataAsync() // The flag is the tab's own IsReadOnly, which is what CanAddRow, CanDeleteRow and CanCommit // are computed from; the connection's read-only mode is a DIFFERENT thing that WorkspaceTab // holds under the same name. - ApplicationVm.MainWindowVm.StatusText = Localization.Format( - tab.IsReadOnly ? "Explorer.Viewing" : "Explorer.Editing", tableName); + // The TAB owns this line: «editing Products» is true while the editor is open and false the + // moment it is closed, which is not something an event-shaped message has to think about. + ApplicationVm.MainWindowVm.SayThisWhile(tab, Localization.Format( + tab.IsReadOnly ? "Explorer.Viewing" : "Explorer.Editing", tableName)); Logger.LogInformation("Edit data for table {TableName} in {Connection}", tableName, session.DisplayName); } diff --git a/Tools/OutWit.Database.Studio/ViewModels/MainWindowViewModel.cs b/Tools/OutWit.Database.Studio/ViewModels/MainWindowViewModel.cs index def8079..e78612b 100644 --- a/Tools/OutWit.Database.Studio/ViewModels/MainWindowViewModel.cs +++ b/Tools/OutWit.Database.Studio/ViewModels/MainWindowViewModel.cs @@ -16,6 +16,14 @@ namespace OutWit.Database.Studio.ViewModels; /// public sealed class MainWindowViewModel : ViewModelBase { + #region Fields + + /// Who said what is on the status line, when what it says is a state rather than an event. + private object? m_statusOwner; + private string? m_statusSaid; + + #endregion + #region Constructors public MainWindowViewModel(ApplicationViewModel applicationVm) @@ -624,6 +632,43 @@ private void OnNotificationsChanged(object? sender, EventArgs e) [Notify] public string StatusText { get; set; } = null!; + /// + /// Says something that is true only WHILE is there. + /// + /// + /// + /// Most of what this line carries is an EVENT - «executed in 9 ms», «loaded 28 rows» - and an + /// event stays true after it happens. «Editing table Products» is not an event, it is a state, + /// and it was still on the line after the tab it described had been closed: measured on + /// 2026-08-19 with no editor open at all. + /// + /// + /// The owner is compared by reference AND the text is compared to what was set, so a later + /// message from anywhere else is never taken away by the owner closing afterwards. + /// + /// + public void SayThisWhile(object owner, string text) + { + StatusText = text; + + m_statusOwner = owner; + m_statusSaid = text; + } + + /// + /// Takes back what said, if it is still on the line. + /// + public void ForgetWhatWasSaidBy(object owner) + { + if (!ReferenceEquals(m_statusOwner, owner) || StatusText != m_statusSaid) + return; + + m_statusOwner = null; + m_statusSaid = null; + + StatusText = Localization["Status.Ready"]; + } + [Notify] public bool IsLoading { get; set; } diff --git a/Tools/OutWit.Database.Studio/ViewModels/WorkspaceTabsViewModel.cs b/Tools/OutWit.Database.Studio/ViewModels/WorkspaceTabsViewModel.cs index cf673d1..73ace7d 100644 --- a/Tools/OutWit.Database.Studio/ViewModels/WorkspaceTabsViewModel.cs +++ b/Tools/OutWit.Database.Studio/ViewModels/WorkspaceTabsViewModel.cs @@ -339,6 +339,9 @@ private async Task CloseTabAsync(WorkspaceTabViewModel? tab) SelectedTab = Tabs[index]; } + // A tab that said something about itself on the status line takes it back with it. + ApplicationVm.MainWindowVm.ForgetWhatWasSaidBy(tab); + Logger.LogInformation("Closed tab: {Title}", tab.Title); } @@ -363,6 +366,8 @@ private async Task CloseAllTabsAsync() tab.PropertyChanged -= OnTabPropertyChanged; tab.OnClosed(); Tabs.Remove(tab); + + ApplicationVm.MainWindowVm.ForgetWhatWasSaidBy(tab); } SelectedTab ??= Tabs.LastOrDefault(); @@ -385,6 +390,8 @@ private async Task CloseOtherTabsAsync(WorkspaceTabViewModel? keepTab) tab.PropertyChanged -= OnTabPropertyChanged; tab.OnClosed(); Tabs.Remove(tab); + + ApplicationVm.MainWindowVm.ForgetWhatWasSaidBy(tab); } SelectedTab = keepTab;