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
@@ -1365,6 +1365,14 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private VaultChoiceViewModel? selectedTargetVault;
/// <summary>
/// The host card that is selected, or null when none is.
/// </summary>
/// <remarks>
/// The application's selection, which everything that acts on a host reads — connecting, editing,
/// deleting. <b>It shares one selection with <see cref="SelectedGroup"/>:</b> selecting a host takes the
/// mark off a group card and the other way about. See <see cref="OnSelectedHostChanged"/>.
/// </remarks>
[ObservableProperty]
private HostRowViewModel? selectedHost;
@@ -1392,8 +1400,14 @@ internal sealed partial class VaultViewModel(
/// The group card that is selected, or null when none is.
/// </summary>
/// <remarks>
/// <para>
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
/// it deliberately no longer does is narrow the grid — see <see cref="OpenGroup"/>.
/// </para>
/// <para>
/// <b>It shares one selection with <see cref="SelectedHost"/>.</b> Setting either clears the other, so
/// exactly one card on the screen is ever lit. See <see cref="OnSelectedHostChanged"/>.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
@@ -2915,9 +2929,7 @@ internal sealed partial class VaultViewModel(
Hosts.Add(host);
}
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
// under the user.
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
SelectedHost = SelectionAfterReload(selectedId);
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
// the group rows.
@@ -2927,6 +2939,25 @@ internal sealed partial class VaultViewModel(
return unreadable;
}
/// <summary>Which host a freshly filled <see cref="Hosts"/> leaves selected.</summary>
/// <param name="selectedId">Whatever was selected before the list was refilled.</param>
/// <remarks>
/// <para>
/// The selection survives a reload, because losing it on every sync would move the terminal's target out
/// from under the user. The first host is the fallback rather than nothing, so that a fresh unlock has
/// something under CONNECT.
/// </para>
/// <para>
/// That fallback is skipped while a group card holds the selection, and it has to be: the two grids
/// share one mark — see <see cref="OnSelectedHostChanged"/> — so a sync that invented a host would
/// quietly unselect a group nobody had touched, once a minute. Read before <see cref="RebuildGroups"/>
/// runs, which is where <see cref="SelectedGroup"/> is re-resolved against the rows this pass makes.
/// </para>
/// </remarks>
private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
Hosts.FirstOrDefault(row => row.EntityId == selectedId)
?? (SelectedGroup is null ? Hosts.FirstOrDefault() : null);
/// <returns>How many buckets would not decrypt.</returns>
/// <remarks>
/// The selection survives a reload and a reload never invents one, as the key and credential lists do and
@@ -4527,11 +4558,21 @@ internal sealed partial class VaultViewModel(
}
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
/// <remarks>The selected card, or the open group when no card is selected. See <see cref="GroupTarget"/>.</remarks>
/// <param name="group">
/// The group to edit, or null for whatever the screen is aimed at — the selected card, or the open group
/// when no card is selected. See <see cref="GroupTarget"/>. The desktop's EDIT button passes nothing and
/// means the second; the phone has no card to select and passes the group its heading names.
/// </param>
/// <remarks>
/// Taking it as an argument is what keeps the phone from having to select a group in order to edit one.
/// A selection is shared with the host grid now — see <see cref="OnSelectedHostChanged"/> — so a command
/// reachable only through <see cref="SelectedGroup"/> would deselect the machine somebody was about to
/// connect to, on a screen that draws no group cards at all.
/// </remarks>
[RelayCommand]
private void EditGroup()
private void EditGroup(HostGroupRowViewModel? group)
{
if (GroupTarget is not { } row)
if ((group ?? GroupTarget) is not { } row)
{
return;
}
@@ -4577,6 +4618,12 @@ internal sealed partial class VaultViewModel(
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
/// opening an editor on nothing.
/// </para>
/// <para>
/// The row is handed to <see cref="EditGroup"/> rather than selected first, which it used to be. A group
/// selection now clears the host selection — the two grids share one mark — and the phone draws no group
/// cards, so selecting one here would have taken the highlight off the machine in the list with nothing
/// on screen to say where it had gone.
/// </para>
/// </remarks>
[RelayCommand]
private void EditGroupFromHeading(SidebarGroupHeader? header)
@@ -4587,8 +4634,7 @@ internal sealed partial class VaultViewModel(
return;
}
SelectedGroup = row;
EditGroupCommand.Execute(null);
EditGroupCommand.Execute(row);
}
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
@@ -7633,6 +7679,16 @@ internal sealed partial class VaultViewModel(
partial void OnSelectedHostChanged(HostRowViewModel? value)
{
// One selection, across both grids. The two lists are drawn one above the other and they are marked
// the same way, so two lit cards read as two things chosen — and the buttons underneath them are two
// pairs, only one of which would act. Losing a selection leaves the other alone: a null here is what
// a filter matching nothing writes, and taking the mark off a group card because a search box
// emptied the grid beneath it would be this rule firing at something that is not a choice.
if (value is not null)
{
SelectedGroup = null;
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
OnPropertyChanged(nameof(ShowsConnectBar));
@@ -7711,8 +7767,18 @@ internal sealed partial class VaultViewModel(
}
}
/// <remarks>
/// The other half of the shared selection; see <see cref="OnSelectedHostChanged"/>. Clearing the host
/// takes the drawer with it, and that is the point rather than a side effect: a pane about one machine
/// cannot go on standing beside a marked group, since nothing on it would be about what is selected.
/// </remarks>
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
{
if (value is not null)
{
SelectedHost = null;
}
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
OnPropertyChanged(nameof(GroupTarget));