Let one card be selected at a time

The hosts screen draws two grids, one above the other, and each is a ListBox
with a selection of its own. Nothing joined them, so a group card and a host card
could be lit at the same moment — two chosen things, under two pairs of buttons
of which only one would act on whichever the eye had settled on. Both grids mark
a selection the same way, so there was nothing on screen to say which of the two
the next press belonged to.

They share one mark now. Selecting a host clears the group and selecting a group
clears the host, and that second one takes the detail pane down with it: a pane
about one machine cannot go on standing beside a marked group, because nothing
on it would be about what is selected.

Losing a selection deliberately clears nothing. A null arrives whenever either
list is rebuilt — every keystroke in the filter box and every background sync —
and treating that as somebody deselecting would take the mark off a group card
because a search emptied the grid beneath it.

The reload needed the same guard for the same reason. It falls back to the first
host when nothing is selected, which is what puts a target under CONNECT on a
fresh unlock; with one mark between the two grids that fallback would have
unselected a group nobody had touched, once a minute. It is skipped while a group
holds the selection, and it moved into a method of its own because the comment
saying why pushed ReloadHostsAsync past sixty lines.

One consequence needed handling rather than accepting. The phone's only route
into the group editor is a button on a heading in the host list, and it worked by
selecting the group first — which under this rule takes the highlight off the
machine somebody was about to connect to, on a screen that draws no group cards
to say where it has gone. EditGroup takes the group as an argument now: the
heading passes its own row, and the desktop's button beside the cards passes
nothing and still means "the card that is selected".

What the tests hold is the half that lives in the controls. Clearing the property
has to reach the list that is drawing the card, and a selection nulled in the
view model while the card stays highlighted is the exact failure this is about —
so the rule is driven on the real screen, in both directions, against the
ListBoxes' own SelectedItem. The desktop's EDIT is pressed through its binding
for the same kind of reason: a command refusing the button's empty parameter
would be a button that never fires, and nothing about the markup would say so.
This commit is contained in:
2026-08-04 16:27:20 +02:00
parent 7f5b871c47
commit 3f419cb19b
4 changed files with 201 additions and 8 deletions
@@ -259,6 +259,99 @@ public sealed class HostGridTests : IAsyncLifetime
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <summary>
/// The two grids share one selection, so at most one card on the screen is ever lit.
/// </summary>
/// <remarks>
/// <para>
/// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
/// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
/// which only one acts on whichever card the eye has settled on. The vault is what joins them.
/// </para>
/// <para>
/// Driven on the screen rather than on the view model alone, because half of the rule lives in the
/// controls: clearing the property has to reach the list that is drawing the card, and a selection
/// nulled in the view model while the card stays highlighted is the exact failure this is about.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostAndGroupGridsShareOneSelection()
{
await OnTheGridAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
vault.OpenHostPaneCommand.Execute(host);
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
screen.HostGrid.SelectedItem.ShouldBeNull();
CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");
vault.SelectedHost = host;
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
screen.GroupGrid.SelectedItem.ShouldBeNull();
GroupCard(screen).IsSelected.ShouldBeFalse();
vault.ShowsGroupActions.ShouldBeFalse("so the group's own two buttons have nothing to act on");
});
}
/// <remarks>
/// EDIT takes the group as an argument now, so that the phone can open the editor on a heading without
/// selecting a group and losing the host selection to it — see <c>VaultViewModel.EditGroup</c>. The
/// button beside the cards passes nothing and means "the card that is selected", which is the half of
/// that change that would fail silently: a command refusing a null parameter is a button that never
/// fires, and nothing about the markup would say so.
/// </remarks>
[Fact]
public async Task TheGroupsEditButtonStillActsOnTheSelectedCard()
{
await OnTheGridAsync((screen, _) =>
{
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
var edit = screen.GetVisualDescendants()
.OfType<Button>()
.Single(button => ReferenceEquals(button.Command, vault.EditGroupCommand));
edit.IsEffectivelyEnabled.ShouldBeTrue("the command has to accept the button's empty parameter");
edit.Command.ShouldNotBeNull().Execute(edit.CommandParameter);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe("production", "the card that was selected");
});
}
/// <remarks>
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
/// </remarks>
[Fact]
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
{
var production = vault.VisibleGroups.Single();
vault.SelectedGroup = production;
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
await vault.LoadAsync(Token);
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
vault.SelectedGroup
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
.EntityId.ShouldBe(production.EntityId);
}
/// <remarks>
/// The gesture, performed. It is wired in the control rather than bound in the markup — which is exactly
/// the sort of wiring that compiles whether or not it is connected to anything — and the first half of
@@ -4333,6 +4333,35 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
}
/// <remarks>
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
/// selection clears the host selection — the desktop's two grids share one mark — and the phone draws no
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
/// nothing on screen to say where it had gone, or how to get it back.
/// </remarks>
[Fact]
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var host = vault.Hosts.Single();
vault.SelectedHost = host;
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.EditGroupFromHeadingCommand.Execute(heading);
vault.IsEditingGroup.ShouldBeTrue("the editor still opens on the group the heading names");
vault.GroupEditorLabel.ShouldBe("production");
vault.SelectedHost.ShouldBeSameAs(host, "and the list is still on the machine it was on");
}
[Fact]
public async Task TheUngroupedHeading_OpensNothing()
{