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