Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using OutWit.Database.Studio.Tests.Helpers;
using OutWit.Database.Studio.ViewModels.Tabs;

namespace OutWit.Database.Studio.Tests.ViewModels;

/// <summary>
/// The status line may keep saying what HAPPENED; it may not keep saying what IS.
/// </summary>
/// <remarks>
/// <para>
/// <b>Measured in the running application on 2026-08-19</b>, with no editor open anywhere: the line
/// read <i>Editing table: Products</i>. The tab had been opened and closed again, and the sentence
/// stayed behind it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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<TableEditTabViewModel> 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<Studio.Models.DatabaseNode> Flatten(Studio.Models.DatabaseNode node)
{
yield return node;

foreach (var child in node.Children.SelectMany(Flatten))
yield return child;
}

#endregion
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
45 changes: 45 additions & 0 deletions Tools/OutWit.Database.Studio/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ namespace OutWit.Database.Studio.ViewModels;
/// </summary>
public sealed class MainWindowViewModel : ViewModelBase<ApplicationViewModel>
{
#region Fields

/// <summary>Who said what is on the status line, when what it says is a state rather than an event.</summary>
private object? m_statusOwner;
private string? m_statusSaid;

#endregion

#region Constructors

public MainWindowViewModel(ApplicationViewModel applicationVm)
Expand Down Expand Up @@ -624,6 +632,43 @@ private void OnNotificationsChanged(object? sender, EventArgs e)
[Notify]
public string StatusText { get; set; } = null!;

/// <summary>
/// Says something that is true only WHILE <paramref name="owner"/> is there.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public void SayThisWhile(object owner, string text)
{
StatusText = text;

m_statusOwner = owner;
m_statusSaid = text;
}

/// <summary>
/// Takes back what <paramref name="owner"/> said, if it is still on the line.
/// </summary>
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; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -363,6 +366,8 @@ private async Task CloseAllTabsAsync()
tab.PropertyChanged -= OnTabPropertyChanged;
tab.OnClosed();
Tabs.Remove(tab);

ApplicationVm.MainWindowVm.ForgetWhatWasSaidBy(tab);
}

SelectedTab ??= Tabs.LastOrDefault();
Expand All @@ -385,6 +390,8 @@ private async Task CloseOtherTabsAsync(WorkspaceTabViewModel? keepTab)
tab.PropertyChanged -= OnTabPropertyChanged;
tab.OnClosed();
Tabs.Remove(tab);

ApplicationVm.MainWindowVm.ForgetWhatWasSaidBy(tab);
}

SelectedTab = keepTab;
Expand Down
Loading