Let the phone delete a host, and move or remove a group

The desktop gained three things the phone did not follow: moving a group to
another vault, the second question asking whether a group's deletion takes its
hosts with it, and — since long before either — deleting a host at all. What
that left was a head whose v3 + can fill a keychain and whose editors can
correct one, with no way to empty either.

The commands could not simply be bound. DeleteGroup and MoveGroup aim at
GroupTarget, which is the selected card or the open group, and the phone has
neither: its list draws headings, and a heading's selection deliberately bounces
back to the host. Called bare on that head they would have returned having done
nothing — a DELETE that appears to have been pressed and has not. Both now take
the row and fall back to GroupTarget for the desktop's menu, and
ConfirmMoveGroupAsync resolves from the panel's own movingGroupId rather than
from the selection, which is also the honester answer on the desktop: what moves
is the shelf the panel was opened on.

The heading's pencil became a menu. Three icons after a chevron, a name, a vault
badge and a count is what would be left of the name at 360dp, so the ⋯ raises
the add sheet's shape carrying Edit, Move to another vault, a rule, and Delete —
the desktop's card menu, in the one idiom this screen already has. It does not
carry Open: the desktop's grid holds one level of the group tree and this list
holds all of it flattened, so there is nowhere to open a group into.

DELETE under a host sits on a row of its own beneath EDIT and MOVE rather than
beside them. A phone has no hover and no tooltip, so where a thumb lands is the
only thing separating a destructive control from an ordinary one. Both questions
take the controls that asked them — ShowsConnectControls, which is the phone's
half of the rule ShowsHostPaneActions already carries for the desktop's drawer —
so DELETE cannot be pressed a second time underneath its own confirmation.

Preferences gained the running version, and the sentence saying this head does
not replace itself and that no DodoSSH server will ever offer one. It reads
Updates.CurrentVersion off the same view model the desktop's UPDATES section
does, over the null channel that reports itself unsupported.

Nothing was needed for the realtime push: it is composed in ServerConnection,
which both heads use.

Seven tests, all phone-shaped — a group acted on with nothing selected, the menu
waved away leaving nothing armed, the ungrouped heading raising none, and the
bar's three states. The rectangles remain unmeasurable for the reason phase 8
gives; the checks for them are 8.10 to 8.13 and 13.6.
This commit is contained in:
2026-08-04 19:58:10 +02:00
parent e923b12b7f
commit 50fa6fba38
10 changed files with 814 additions and 52 deletions
@@ -4576,6 +4576,180 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.IsEditingGroup.ShouldBeFalse();
}
/// <remarks>
/// <para>
/// The load-bearing half of giving the phone a group menu, and the reason the commands take a header at
/// all. <c>DeleteGroup</c> and <c>MoveGroup</c> aim at <c>GroupTarget</c>, which is the selected card or
/// the open group — and the phone has neither, because its list draws headings and a heading is not a
/// thing that list can select. Called bare on that head they would return having done nothing, which is
/// a DELETE that appears to have been pressed and has not.
/// </para>
/// <para>
/// The tick is asserted <em>off</em> as well as present. Off is "the machines stay and turn up under
/// UNGROUPED", which is recoverable; on is not, and a question that arrived with the destructive answer
/// already given would be worse than one that never asked.
/// </para>
/// </remarks>
[Fact]
public async Task AGroupsHeading_AsksAboutThatGroupWithNothingSelected()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.GroupTarget.ShouldBeNull("the phone selects no card and opens no group");
vault.OpenGroupSheetCommand.Execute(heading);
vault.GroupSheetLabel.ShouldBe("production", "the menu names what it is about");
vault.AnEditorIsOpen.ShouldBeTrue("so the + stands down, as it does under the add sheet");
vault.DeleteGroupFromHeadingCommand.Execute(heading);
vault.GroupSheet.ShouldBeNull("the menu closes behind the entry that was pressed");
vault.IsConfirmingGroupDeletion.ShouldBeTrue();
vault.PendingDeletion!.Question.ShouldContain("production");
vault.PendingDeletion.HasChoice.ShouldBeTrue("a host is filed under it, so it has a second question");
vault.DeletionTakesTheHostsToo.ShouldBeFalse("keeping them is the answer that needs no decision");
}
/// <remarks>
/// A menu is a thing you are allowed to decide against, which is why this one is dismissible where the
/// host key sheet deliberately is not. Nothing may be left armed behind it.
/// </remarks>
[Fact]
public async Task TheGroupsMenu_WavedAway_LeavesEverythingAsItWas()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.OpenGroupSheetCommand.Execute(heading);
vault.CloseGroupSheetCommand.Execute(null);
vault.GroupSheet.ShouldBeNull();
vault.AnEditorIsOpen.ShouldBeFalse();
vault.IsConfirmingDeletion.ShouldBeFalse();
vault.IsMovingGroup.ShouldBeFalse();
vault.IsEditingGroup.ShouldBeFalse();
}
[Fact]
public async Task TheUngroupedHeading_RaisesNoMenu()
{
// Nothing behind it for the three entries to act on. The button is left off that row, so this is the
// guard for the path the markup does not control.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(row => row.GroupId is null);
vault.OpenGroupSheetCommand.Execute(ungrouped);
vault.GroupSheet.ShouldBeNull();
}
// ---- Deleting a host, from the phone's bar ----
/// <remarks>
/// <para>
/// The phone's bar carries three things and draws one of them: connecting, the picker asking which vault
/// to move the host to, and this question. What is pinned here is that the question <em>takes</em> the
/// controls rather than appearing under them — DELETE pressable a second time underneath its own
/// confirmation is the reason the desktop's drawer has the same rule.
/// </para>
/// <para>
/// The bar itself stays up, and that is the other half: it is where the question is drawn. A question in
/// a bar that had collapsed would be a question nobody could answer.
/// </para>
/// </remarks>
[Fact]
public async Task DeletingAHostFromThePhonesBar_TakesTheControlsWhileItAsks()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single();
vault.ShowsConnectBar.ShouldBeTrue();
vault.ShowsConnectControls.ShouldBeTrue();
vault.DeleteHostCommand.Execute(null);
vault.IsConfirmingHostDeletion.ShouldBeTrue();
vault.ShowsConnectBar.ShouldBeTrue("the bar is where the question is drawn");
vault.ShowsConnectControls.ShouldBeFalse("so DELETE cannot be pressed under its own question");
vault.CancelDeleteCommand.Execute(null);
vault.ShowsConnectControls.ShouldBeTrue();
vault.Hosts.ShouldHaveSingleItem("KEEP keeps it");
}
[Fact]
public async Task DeletingAHostFromThePhonesBar_RemovesItOnceItIsAgreedTo()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single();
vault.DeleteHostCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Hosts.ShouldBeEmpty(vault.Status);
vault.ShowsConnectBar.ShouldBeFalse("and there is nothing left for the bar to be about");
}
/// <remarks>
/// The other panel that takes the bar, asserted here rather than left to the move's own tests: the two
/// share one property, and a change that put the controls back under one of them would be a CONNECT
/// button in a bar that is asking something else.
/// </remarks>
[Fact]
public async Task MovingAHostFromThePhonesBar_TakesTheControlsToo()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single();
vault.MoveHostCommand.Execute(null);
// One writable vault, so there is nowhere to move it and the panel does not open — which is exactly
// the state that must still leave the controls showing rather than a bar with nothing in it.
vault.IsMovingHost.ShouldBeFalse(vault.Status);
vault.ShowsConnectControls.ShouldBeTrue();
// Set rather than reached through a second vault, which is VaultSharingTests' job and needs a server
// to make one. What is being pinned here is the one line joining the flag to the bar.
vault.IsMovingHost = true;
vault.ShowsConnectControls.ShouldBeFalse();
}
// ---- Tags ----
//
// The type has been storable since the domain landed and unreachable until now. What these pin is the