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
@@ -1994,6 +1994,7 @@ internal sealed partial class VaultViewModel(
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(ShowsConnectControls))]
private bool isMovingHost;
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
@@ -2056,11 +2057,22 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveGroupVault;
/// <summary>The group the open move panel is about, by name.</summary>
/// <remarks>
/// For the phone, which draws this panel over its host list rather than beside the card it was opened
/// from — so unlike the desktop, where the group's own tile is on screen underneath the picker, there is
/// nothing left saying which shelf is about to move. The same reason its connect bar names the host.
/// Held rather than read back through <see cref="GroupTarget"/>, which is a desktop selection and is
/// null on the head that needs this.
/// </remarks>
[ObservableProperty]
private string movingGroupLabel = string.Empty;
// There is deliberately no CanMoveGroup to match CanMoveSelectedHost. That one exists so the phone can
// leave a button out rather than draw one that answers with a refusal; a group is reached through the
// card's right-click menu, which is not drawn until it is opened and whose entries do not move. The one
// place the question decides anything is MoveGroup, which asks it by building the picker and saying so
// when it comes back empty.
// leave a button out rather than draw one that answers with a refusal; a group is reached through a menu
// on both heads — the desktop's right-click, the phone's sheet — and a menu is not drawn until it is
// opened and its entries do not move. The one place the question decides anything is MoveGroup, which
// asks it by building the picker and saying so when it comes back empty.
/// <summary>
/// What the drawer's header says it is about.
@@ -2118,16 +2130,47 @@ internal sealed partial class VaultViewModel(
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
private bool isAddSheetOpen;
/// <summary>
/// The group heading the phone's action sheet is open on, or null when it is closed.
/// </summary>
/// <remarks>
/// <para>
/// The phone's answer to the desktop's right-click menu, and it holds a heading rather than a row
/// because a heading is what its list draws — see <see cref="EditGroupFromHeading"/> for why a group
/// there is not a thing that can be selected, which is what leaves the three commands with nothing to
/// aim at unless the gesture carries it.
/// </para>
/// <para>
/// One nullable property rather than a flag beside a field, so that "open" and "open on what" cannot
/// disagree — the sheet names the group in its own title, and a flag left true beside a cleared header
/// would be a menu about nothing.
/// </para>
/// <para>
/// The heading is not resolved to a group until one of the entries is pressed. A sheet is a menu and
/// deciding not to use it is a perfectly good outcome, so nothing is looked up on the way in; a heading
/// whose group has gone by the time an entry is pressed is dropped there. See <see cref="GroupOf"/>.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
[NotifyPropertyChangedFor(nameof(GroupSheetLabel))]
private SidebarGroupHeader? groupSheet;
/// <summary>Which group the sheet says it is about.</summary>
internal string GroupSheetLabel => GroupSheet?.Label ?? string.Empty;
/// <summary>
/// Whether anything the host screen can put over its list is showing.
/// </summary>
/// <remarks>
/// One property rather than three tests at each call site, and it exists because two controls need
/// One property rather than four tests at each call site, and it exists because two controls need
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
/// does nothing — and the back gesture closes them before it considers leaving the screen.
/// </remarks>
internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;
internal bool AnEditorIsOpen =>
IsAddSheetOpen || GroupSheet is not null || IsEditing || IsEditingGroup;
/// <summary>
/// Whether the phone's connect bar has anything to be about.
@@ -2140,6 +2183,22 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
/// <summary>
/// Whether the phone's connect bar is showing its own controls rather than one of the two panels that
/// take their place.
/// </summary>
/// <remarks>
/// The bar carries three things and draws one of them: connecting to the host, the picker asking which
/// vault to move it to, and the question asking whether to delete it. This is the first, and the reason
/// it is a property rather than two conditions in the markup is that Avalonia's bindings have no "and".
/// <para>
/// It is the same rule <see cref="ShowsHostPaneActions"/> carries for the desktop's drawer, minus that
/// one's <c>IsShowingHostDetail</c>: the phone has no pane to open, so its bar's own
/// <see cref="ShowsConnectBar"/> already answers whether there is a host at all.
/// </para>
/// </remarks>
internal bool ShowsConnectControls => !IsMovingHost && !IsConfirmingHostDeletion;
[ObservableProperty]
private string editorLabel = string.Empty;
@@ -4985,6 +5044,62 @@ internal sealed partial class VaultViewModel(
Status = $"Editing {row.Label}.";
}
/// <summary>
/// Raises the phone's menu of the three things that can be done to a group.
/// </summary>
/// <remarks>
/// <para>
/// The desktop's right-click menu, as a bottom sheet, and the same three entries in the same order for
/// the same reasons: opening is a gesture on the desktop and so is offered here as well, moving sits
/// above the separator because it is not a deletion, and deleting sits below it.
/// </para>
/// <para>
/// Open is the entry this one does <em>not</em> carry, and the difference is real rather than an
/// abbreviation: the desktop's grid holds one level of the tree at a time and the phone's list holds all
/// of it flattened, so there is nothing on this head to open a group <em>into</em>. See the note on
/// <c>SidebarRows</c> in the desktop's HostsScreen.
/// </para>
/// <para>
/// It is a menu rather than three buttons on the heading row, and that is a width decision before it is
/// a taste one: the row already carries a chevron, a name, a vault badge and a count at 360dp, and three
/// icons after them would leave the name a dozen characters. It is also what makes the two destructive
/// entries reachable without either of them being a control a thumb can brush.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenGroupSheet(SidebarGroupHeader? header)
{
// The ungrouped heading has no group behind it, so there is nothing for the three entries to act on.
// The button is left off that row as well; this is the guard for the path a stale row would take.
if (header?.GroupId is not null)
{
GroupSheet = header;
}
}
/// <summary>Closes the group's menu without doing anything.</summary>
/// <remarks>
/// Reached from CANCEL and from a tap on the scrim, and the sheet is dismissible that way for the reason
/// the add sheet is and the host key sheet deliberately is not: "which of these three" has no wrong
/// answer, and none of them is one of them.
/// </remarks>
[RelayCommand]
private void CloseGroupSheet() => GroupSheet = null;
/// <summary>
/// The group a heading in the host list names, or null where it names none.
/// </summary>
/// <remarks>
/// A heading carries an id, a label and a count; every one of the three commands below needs the record.
/// A heading whose group has gone — the ungrouped heading, or one a sync deleted between the list being
/// drawn and the entry being pressed — resolves to nothing and is dropped, rather than opening an editor
/// or arming a question on nothing.
/// </remarks>
private HostGroupRowViewModel? GroupOf(SidebarGroupHeader? header) =>
header?.GroupId is { } groupId
? Groups.FirstOrDefault(row => row.EntityId == groupId)
: null;
/// <summary>
/// Opens a group's editor from its heading in the host list.
/// </summary>
@@ -4994,8 +5109,8 @@ internal sealed partial class VaultViewModel(
/// desktop reaches the group editor through the groups panel, which selects a
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
/// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
/// the header rather than the selection.
/// not a thing to be selected. So the heading needs a menu, and the menu's entries need commands that
/// take the header rather than the selection.
/// </para>
/// <para>
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
@@ -5003,28 +5118,66 @@ internal sealed partial class VaultViewModel(
/// inherited: getting one wrong is wrong for every host beneath it at once.
/// </para>
/// <para>
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
/// 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>
/// <para>
/// The sheet is closed first and unconditionally, including where the heading resolves to nothing. A
/// menu left standing over a command that declined to run is a menu somebody presses again.
/// </para>
/// </remarks>
[RelayCommand]
private void EditGroupFromHeading(SidebarGroupHeader? header)
{
if (header?.GroupId is not { } groupId
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
{
return;
}
var row = GroupOf(header);
EditGroupCommand.Execute(row);
GroupSheet = null;
if (row is not null)
{
EditGroupCommand.Execute(row);
}
}
/// <summary>Opens the group's move panel from its heading in the host list.</summary>
/// <remarks>
/// Aimed by the header rather than by <see cref="GroupTarget"/>, which is the whole reason this exists:
/// that property reads the selected card or the open group, and the phone has neither — so
/// <see cref="MoveGroup"/> called bare on this head would silently do nothing at all. The sheet is
/// closed first, on <see cref="EditGroupFromHeading"/>'s terms.
/// </remarks>
[RelayCommand]
private void MoveGroupFromHeading(SidebarGroupHeader? header)
{
var row = GroupOf(header);
GroupSheet = null;
if (row is not null)
{
MoveGroupCommand.Execute(row);
}
}
/// <summary>Asks the group's deletion question from its heading in the host list.</summary>
/// <remarks>
/// Aimed by the header for the reason <see cref="MoveGroupFromHeading"/> is, and it matters more here:
/// a <see cref="DeleteGroup"/> that quietly aimed at nothing would be a DELETE that appeared to have
/// been pressed and had not.
/// </remarks>
[RelayCommand]
private void DeleteGroupFromHeading(SidebarGroupHeader? header)
{
var row = GroupOf(header);
GroupSheet = null;
if (row is not null)
{
DeleteGroupCommand.Execute(row);
}
}
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
@@ -5325,14 +5478,21 @@ internal sealed partial class VaultViewModel(
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
/// </para>
/// <para>
/// Aims where Edit does: at the selected card, which on the desktop is the one the menu opened on. See
/// <see cref="GroupTarget"/>.
/// Aims where Edit does: at the group it is handed, and failing that at the selected card, which on the
/// desktop is the one the menu opened on. See <see cref="GroupTarget"/>.
/// </para>
/// <para>
/// <b>The argument is the phone's and the fallback is the desktop's.</b> The desktop's menu passes
/// nothing, because the code-behind has already selected whatever was right-clicked; the phone has no
/// group selection to make — a heading is not a thing its list can select — so its sheet passes the row
/// it was opened on. See <see cref="DeleteGroupFromHeading"/>.
/// </para>
/// </remarks>
/// <param name="group">The group to ask about, or null to use <see cref="GroupTarget"/>.</param>
[RelayCommand]
private void DeleteGroup()
private void DeleteGroup(HostGroupRowViewModel? group)
{
if (GroupTarget is not { } row)
if ((group ?? GroupTarget) is not { } row)
{
return;
}
@@ -5850,11 +6010,16 @@ internal sealed partial class VaultViewModel(
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
/// as <see cref="DeleteGroup"/> is: this rewrites hosts.
/// </para>
/// <para>
/// <b>The argument is the phone's and the fallback is the desktop's</b>, exactly as it is on
/// <see cref="DeleteGroup"/>. See <see cref="MoveGroupFromHeading"/>.
/// </para>
/// </remarks>
/// <param name="group">The group to move, or null to use <see cref="GroupTarget"/>.</param>
[RelayCommand]
private void MoveGroup()
private void MoveGroup(HostGroupRowViewModel? group)
{
if (GroupTarget is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
if ((group ?? GroupTarget) is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
{
return;
}
@@ -5877,6 +6042,7 @@ internal sealed partial class VaultViewModel(
// As MoveHost disarms a deletion aimed at the same host.
PendingDeletion = null;
movingGroupId = row.EntityId;
MovingGroupLabel = row.Label;
IsMovingGroup = true;
Status = string.Empty;
}
@@ -5892,6 +6058,7 @@ internal sealed partial class VaultViewModel(
IsMovingGroup = false;
movingGroupId = null;
MovingGroupLabel = string.Empty;
MoveGroupVaultChoices.Clear();
SelectedMoveGroupVault = null;
Status = string.Empty;
@@ -5928,12 +6095,20 @@ internal sealed partial class VaultViewModel(
/// exist for. What is reported is a binding now outside the destination, since that is precisely what
/// the other holders of it will not be able to resolve.
/// </para>
/// <para>
/// <b>The group is resolved from the panel's own id rather than from <see cref="GroupTarget"/>.</b> That
/// property is the desktop's selection and is null on the phone, whose sheet aims the move by handing
/// the row in — so reading it here would leave the phone's MOVE button doing nothing at all. It also
/// says the honest thing on both heads: what this moves is the shelf the panel was opened on, and a
/// selection that has since gone elsewhere has already folded the panel away. See
/// <see cref="CloseTheGroupMovePanelIfAimedElsewhere"/>.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
{
if (GroupTarget is not { } row
|| movingGroupId != row.EntityId
if (movingGroupId is not { } moved
|| Groups.FirstOrDefault(group => group.EntityId == moved) is not { } row
|| SelectedMoveGroupVault is not { } target)
{
return;
@@ -8831,6 +9006,7 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
OnPropertyChanged(nameof(ShowsHostActions));
OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(ShowsConnectControls));
OnPropertyChanged(nameof(ShowsItemActions));
}