diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
index c65ec0f..7a3cc5d 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
@@ -262,6 +262,11 @@
opens a shell on a host. One click used to mean both, and a card was then the only place a
group could be named while also being the control that threw the rest of the grid away.
+ ◆ ONE SELECTION, TWO LISTS. Selecting a card here takes the mark off the host grid below, and
+ selecting a host takes it off this one. Two controls each keep their own SelectedItem, so the
+ vault is what joins them — see VaultViewModel.OnSelectedHostChanged. Without it both grids
+ could be lit at once, which is two chosen things under two pairs of buttons.
+
◆ THIS IS ONE LEVEL, NOT EVERY GROUP. It binds VisibleGroups: what is inside the group the
trail above ends with, or the outermost groups when it ends at ALL HOSTS. Folded away entirely
at a group with nothing inside it, which is an ordinary thing to open — the trail and the two
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index 1ec1398..860fbf8 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -1408,6 +1408,14 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private VaultChoiceViewModel? selectedTargetVault;
+ ///
+ /// The host card that is selected, or null when none is.
+ ///
+ ///
+ /// The application's selection, which everything that acts on a host reads — connecting, editing,
+ /// deleting. It shares one selection with : selecting a host takes the
+ /// mark off a group card and the other way about. See .
+ ///
[ObservableProperty]
private HostRowViewModel? selectedHost;
@@ -1435,8 +1443,14 @@ internal sealed partial class VaultViewModel(
/// The group card that is selected, or null when none is.
///
///
+ ///
/// 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 .
+ ///
+ ///
+ /// It shares one selection with . Setting either clears the other, so
+ /// exactly one card on the screen is ever lit. See .
+ ///
///
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
@@ -3023,9 +3037,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.
@@ -3035,6 +3047,25 @@ internal sealed partial class VaultViewModel(
return unreadable;
}
+ /// Which host a freshly filled leaves selected.
+ /// Whatever was selected before the list was refilled.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// That fallback is skipped while a group card holds the selection, and it has to be: the two grids
+ /// share one mark — see — so a sync that invented a host would
+ /// quietly unselect a group nobody had touched, once a minute. Read before
+ /// runs, which is where is re-resolved against the rows this pass makes.
+ ///
+ ///
+ private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
+ Hosts.FirstOrDefault(row => row.EntityId == selectedId)
+ ?? (SelectedGroup is null ? Hosts.FirstOrDefault() : null);
+
/// How many buckets would not decrypt.
///
/// The selection survives a reload and a reload never invents one, as the key and credential lists do and
@@ -4710,11 +4741,21 @@ internal sealed partial class VaultViewModel(
}
/// Loads the group being acted on into the box, so saving renames it.
- /// The selected card, or the open group when no card is selected. See .
+ ///
+ /// 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 . 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.
+ ///
+ ///
+ /// 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 — so a command
+ /// reachable only through would deselect the machine somebody was about to
+ /// connect to, on a screen that draws no group cards at all.
+ ///
[RelayCommand]
- private void EditGroup()
+ private void EditGroup(HostGroupRowViewModel? group)
{
- if (GroupTarget is not { } row)
+ if ((group ?? GroupTarget) is not { } row)
{
return;
}
@@ -4769,6 +4810,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.
///
+ ///
+ /// The row is handed to 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.
+ ///
///
[RelayCommand]
private void EditGroupFromHeading(SidebarGroupHeader? header)
@@ -4779,8 +4826,7 @@ internal sealed partial class VaultViewModel(
return;
}
- SelectedGroup = row;
- EditGroupCommand.Execute(null);
+ EditGroupCommand.Execute(row);
}
/// Starts a new group, inside whichever one the screen is showing.
@@ -7918,6 +7964,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));
@@ -7996,8 +8052,18 @@ internal sealed partial class VaultViewModel(
}
}
+ ///
+ /// The other half of the shared selection; see . 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.
+ ///
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
{
+ if (value is not null)
+ {
+ SelectedHost = null;
+ }
+
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
OnPropertyChanged(nameof(GroupTarget));
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
index 773eb43..0311c7b 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
@@ -336,6 +336,99 @@ public sealed class HostGridTests : IAsyncLifetime
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
+ ///
+ /// The two grids share one selection, so at most one card on the screen is ever lit.
+ ///
+ ///
+ ///
+ /// They are two ListBoxes, 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [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");
+ });
+ }
+
+ ///
+ /// 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 VaultViewModel.EditGroup. 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.
+ ///
+ [Fact]
+ public async Task TheGroupsEditButtonStillActsOnTheSelectedCard()
+ {
+ await OnTheGridAsync((screen, _) =>
+ {
+ vault.SelectedGroup = vault.VisibleGroups.Single();
+ Dispatcher.UIThread.RunJobs();
+
+ var edit = screen.GetVisualDescendants()
+ .OfType