Move the shelf as well as what is on it, and ask what a deletion takes

Two things about a group, and they turn out to be the same argument twice.

**A group can be moved to another vault, and it takes everything on it.** The
host's move shipped last week and stopped one level too low: moving twenty
machines into a shared vault meant twenty trips through a menu, and each one
arrived stripped of the group it had been filed under, so the shelf had to be
rebuilt by hand on the other side. Moving the shelf is what people were
attempting. MOVE sits between EDIT and DELETE over the group cards, and on the
card's own menu above the separator DELETE is below — the same place, and the
same reasoning, as the host pane's ⋯ entry.

**The whole subtree goes, and taking less was never coherent.** A group's
children are items of the vault it is leaving, so a parent moved alone leaves
them naming a tombstone and they surface as roots in the vault the user has just
emptied: half a shelf here and half there, from one gesture that said "move
this". The hosts are the same argument and are the half the request was about.

The groups go first, top down, and the hosts last. Each item is re-sealed under
the destination's key and takes a new id — VaultItemRepository.MoveAsync, which
HostGroupRepository now exposes — so nothing pointing at a group can be written
until that group has landed and its new id is known, and a child's parent must
already be over there. What an interruption leaves is therefore hosts still in
the vault they started in, under UNGROUPED: visible, and re-movable. The reverse
order would leave hosts in the destination filed under nothing.

The parent stays behind and the tags are dropped, which is the host move's rule
one level up: both are items of the vault being left, so a reference carried
across would resolve on the machine that moved it and dangle for everybody else
in the destination. The moved group arrives at the top level, and the panel says
so before the press rather than the status line saying it after. Keys and
passwords are kept — those genuinely resolve across vaults, and clearing them
would take a working host and make one that cannot connect — and any now outside
the destination is named, because that is precisely what the other members of it
will not be able to resolve.

Refused as a whole where anything under the group was written by a newer client,
rather than skipped item by item: a move that left behind what it could not
re-encode would file some of the shelf in one vault and the rest in the other,
which is the state this exists to prevent. Refused with a host editor open, as
the drop gesture is, because it rewrites hosts. And the walk carries a visited
set, for the reason every walk over this tree does: a group that is its own
parent — which two offline clients can build and no editor was ever shown —
would otherwise be appended to the move list for as long as there was memory.

**Deleting a group now asks what should become of the hosts under it, and that
reverses a decision this repository had written down.** The deletion did not
touch them: the reference was left dangling, the list resolved it to nothing,
and the machines turned up under UNGROUPED. That was right for one of the two
things people delete a group for and wrong for the other — a heading being tidied
away should leave its machines alone, and a project that has been decommissioned
is a shelf and everything on it — and nothing in the code can tell which of the
two it is looking at. So it is asked.

A tick rather than a pair of options, because the two answers are not equally
weighted: keeping the hosts is recoverable and deleting them is not, so the safe
answer is the one that needs no decision. It is off on every question, including
the one that disarms it, or a tick left standing would destroy the next group's
machines on the strength of a decision about the last one's.

Once the deletion knows which hosts it means, leaving them naming something that
has gone is a state kept for no reason, so the unticked answer writes too: N
hosts with the reference cleared, where the ticked one writes N tombstones. That
is the N writes HostGroupRepository refuses to hide behind a DeleteAsync
overload, made where somebody asked for them and where the count is on screen
first. The nested groups take the deleted group's place in the tree rather than
being orphaned to the top level. A read-only host is skipped, counted and named,
because unfiling it would re-encode a payload this build cannot represent — and
the cost of skipping is a dangling id, which every reader here already survives.

**Both are the desktop's alone**, and that is not an omission. The phone draws
groups as headings in the host list and has never had a way to delete or move
one; the two panels take the row of buttons over the group cards, and there is
no such row on a 360dp screen to take.

One test had to change its premise rather than its assertion.
EditingAHostWhoseGroupIsGone built its dangling reference by deleting the group,
which now unfiles instead — so it imports a host naming an id nothing resolves,
which is what a group deleted on another machine actually looks like and is the
only way that state still arises. The picker's placeholder is still needed and
still covered.

Four places said an item could not be moved between vaults. Two were about a
group and were true when written; the other two were left stale by the host's
move. All four now say what is true, including the design gaps document, where
the chevron beside the vault name stays undrawn for the reason it already had.
This commit is contained in:
2026-08-04 17:04:06 +02:00
parent 742f65c204
commit a86731ee08
10 changed files with 1125 additions and 76 deletions
@@ -982,6 +982,27 @@ internal sealed record DeletionRequest(
{
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
internal bool HasUsage => Usage.Length > 0;
/// <summary>
/// The second question this deletion has to ask, or empty where it has none.
/// </summary>
/// <remarks>
/// <para>
/// Only a group has one, and it is the one deletion in this application whose scope is not decided by
/// what is being deleted: a group is a heading, and the machines under it may be the reason the heading
/// existed or may be forty perfectly good hosts that want a different shelf. Nothing here can tell which,
/// so it is asked — see <see cref="VaultViewModel.DeletionTakesTheHostsToo"/>, which is the answer.
/// </para>
/// <para>
/// A sentence rather than a flag, because the card that draws it is shared by six kinds of deletion and
/// must not grow a branch per kind. Empty is "there is no second question", which is also what a group
/// with nothing filed under it gets.
/// </para>
/// </remarks>
internal string Choice { get; init; } = string.Empty;
/// <summary>Whether this deletion has a second question to put.</summary>
internal bool HasChoice => Choice.Length > 0;
}
/// <summary>
@@ -1540,13 +1561,19 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// <para>
/// The same picker the host editor has, on the form beside it, and for the same reason: this is the
/// decision that makes the thing shared, it cannot be changed afterwards, and the only other control
/// that could have answered it is a standing preference on a different screen. A group is where hosts
/// are filed and what lends them a port, a username and a key — so putting one in a shared vault is how
/// a team gets an arrangement rather than twenty machines in a heap, which is most of what sharing is
/// decision that makes the thing shared, it cannot be changed <em>by saving</em>, and the only other
/// control that could have answered it is a standing preference on a different screen. A group is where
/// hosts are filed and what lends them a port, a username and a key — so putting one in a shared vault is
/// how a team gets an arrangement rather than twenty machines in a heap, which is most of what sharing is
/// for.
/// </para>
/// <para>
/// Changed afterwards it can be, by <see cref="MoveGroup"/>, which is a separate act for the reason
/// moving a host is: it re-seals the group, everything nested inside it and every host filed under them
/// into a second vault's key, and nothing that happens as a side effect of pressing SAVE on a form should
/// be that.
/// </para>
/// <para>
/// Filled from <see cref="TargetVaults"/>, so it offers what every other "file this into" control does:
/// vaults this session can both read and write.
/// </para>
@@ -1984,6 +2011,48 @@ internal sealed partial class VaultViewModel(
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary>
/// Whether the panel asking which vault to move the group to is up.
/// </summary>
/// <remarks>
/// The host's panel again — see <see cref="IsMovingHost"/> — in the one place a group can be acted on,
/// which is the GROUPS section of the hosts screen rather than the drawer. It takes that section's two
/// buttons the way the deletion question does, and for the same reason: a MOVE that could be pressed
/// again while its own picker was open would be a second panel about the same shelf.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsGroupActions))]
private bool isMovingGroup;
/// <summary>Which group the open move panel is about. Null when it is closed.</summary>
/// <inheritdoc cref="movingHostId" path="/remarks" />
private Guid? movingGroupId;
/// <summary>Where the group could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveGroupVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveGroupVault;
/// <summary>
/// Whether the group the buttons are aimed at has anywhere to move to.
/// </summary>
/// <remarks>
/// <para>
/// Counted per group rather than asked once of the session, exactly as <see cref="CanMoveSelectedHost"/>
/// is: a group already in the only other writable vault has nowhere to go.
/// </para>
/// <para>
/// The read-only test is the group's own and not the whole subtree's. That is deliberate rather than
/// sloppy — this decides whether a button is worth drawing, and walking every descendant and every host
/// under them to answer it would be that walk on every selection. The subtree is checked once, where it
/// decides something, in <see cref="ConfirmMoveGroupAsync"/>.
/// </para>
/// </remarks>
internal bool CanMoveGroupTarget =>
GroupTarget is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary>
/// What the drawer's header says it is about.
/// </summary>
@@ -2004,10 +2073,12 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <para>
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
/// under different keys — so a control offering the move would be offering something no layer below
/// this can do. Where a *new* item goes is chosen in the editor's own picker; see <see cref="TargetVaults"/>
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host and a
/// group can both be moved between vaults — see <see cref="MoveHost"/> and <see cref="MoveGroup"/> — but
/// not from here and not by saving: the two vaults are encrypted under different keys, so a move is a
/// re-seal into one and a tombstone in the other, and every item involved takes a new id. A chevron on a
/// subtitle implies an edit and would be describing something else. Where a *new* item goes is chosen in
/// the editor's own picker; see <see cref="TargetVaults"/>
/// </para>
/// <para>
/// A group being renamed says its vault here for the same reason a host being edited does, and it is
@@ -2559,6 +2630,26 @@ internal sealed partial class VaultViewModel(
[ObservableProperty]
private DeletionRequest? pendingDeletion;
/// <summary>
/// The answer to <see cref="DeletionRequest.Choice"/>: whether a group's hosts go with it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Off is the answer that keeps the machines</b>, and it is off by default and reset to off on every
/// question — see <see cref="OnPendingDeletionChanged"/>. A tick left standing from the last group
/// deleted would delete forty hosts on behalf of somebody who was only tidying a heading away, and there
/// is no undo on either side of it.
/// </para>
/// <para>
/// A tick rather than a pair of options, and deliberately not two equally weighted answers: they are not
/// equally weighted. Keeping the hosts is recoverable — they turn up under UNGROUPED and can be filed
/// again — and deleting them is not, so the safe answer is the one that needs no decision and the
/// destructive one is the one that has to be reached for.
/// </para>
/// </remarks>
[ObservableProperty]
private bool deletionTakesTheHostsToo;
internal bool IsConfirmingDeletion => PendingDeletion is not null;
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
@@ -2598,11 +2689,17 @@ internal sealed partial class VaultViewModel(
/// <summary>Whether the group panel's buttons are showing.</summary>
/// <remarks>
/// Hidden with nothing to act on as well as while the question is up. A pair of buttons over a grid of
/// cards nobody has chosen between is a pair whose subject the user has to work out, and the answer
/// would be "neither" — see <see cref="GroupTarget"/>.
/// Hidden with nothing to act on as well as while the question is up. A row of buttons over a grid of
/// cards nobody has chosen between is a row whose subject the user has to work out, and the answer
/// would be "none of them" — see <see cref="GroupTarget"/>.
/// <para>
/// The move panel takes the row as well, on the same reasoning the host pane's menu hides for it: both
/// panels are drawn where the buttons were, and leaving them there would offer to open a panel that is
/// already open.
/// </para>
/// </remarks>
internal bool ShowsGroupActions => GroupTarget is not null && !IsConfirmingGroupDeletion;
internal bool ShowsGroupActions =>
GroupTarget is not null && !IsConfirmingGroupDeletion && !IsMovingGroup;
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
internal bool HasGroups => Groups.Count > 0;
@@ -4768,9 +4865,10 @@ internal sealed partial class VaultViewModel(
EditingGroupId = row.EntityId;
// The group's own vault, and it does not move — the same rule an existing host's follows, and the
// The group's own vault, and no save moves it — the same rule an existing host's follows, and the
// same reason: the two are encrypted under different keys, so saving anywhere else would leave a
// copy behind rather than move anything. The picker is not drawn for an existing group at all.
// copy behind rather than move anything. The picker is not drawn for an existing group at all, and
// the move that does work is MoveGroup, from the buttons over the cards.
editingGroupVaultId = row.VaultId;
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
@@ -4997,9 +5095,10 @@ internal sealed partial class VaultViewModel(
/// Moves a half-typed group into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> gives: a group cannot
/// be moved between vaults, so a path that reassigned this on a rename would write a second group into
/// the other vault and leave the original standing with the old name.
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> gives: a save cannot
/// move a group between vaults, so a path that reassigned this on a rename would write a second group
/// into the other vault and leave the original standing with the old name. Moving one is
/// <see cref="MoveGroup"/>, which does the re-seal and the tombstone this could not.
/// </remarks>
partial void OnGroupEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
@@ -5103,12 +5202,28 @@ internal sealed partial class VaultViewModel(
Status = string.Empty;
}
/// <summary>Asks whether the group being acted on should go.</summary>
/// <summary>Asks whether the group being acted on should go, and what should become of its hosts.</summary>
/// <remarks>
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
/// to move, and that none of them are going anywhere else.
/// <para>
/// <b>The hosts are a second question rather than a stated consequence, and that is a reversal.</b> This
/// used to say what would happen to them — they stay, and turn up under UNGROUPED — because a group's
/// deletion did not touch them at all. That answer was right for one of the two things people delete a
/// group for and wrong for the other: a heading being tidied away should leave its machines alone, and a
/// project that has been decommissioned is a shelf and everything on it. Nothing here can tell which of
/// the two it is looking at, so it is asked. See <see cref="DeletionTakesTheHostsToo"/>.
/// </para>
/// <para>
/// The count is still the reason this asks rather than acting, and it now counts twice over: it is the
/// number of machines about to be unfiled, or — with the box ticked — the number about to be destroyed.
/// A group with nothing under it gets no second question and no tick, because there is nothing for
/// either to be about.
/// </para>
/// <para>
/// <b>Refused with a host editor open</b>, which the deletion of a single host is not. The difference is
/// that this one writes to hosts: unfiling rewrites every machine under the heading, and doing that
/// beneath a half-typed edit of one of them is the save nobody asked for that
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
/// </para>
/// <para>
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
/// <see cref="GroupTarget"/>.
@@ -5122,6 +5237,17 @@ internal sealed partial class VaultViewModel(
return;
}
// Only where it would write to a host. A heading with nothing under it is deleted with an editor
// open, because nothing on that form is about to be rewritten underneath it.
if (row.HostCount > 0 && AHostEditorIsInTheWay())
{
return;
}
// Disarms a move aimed at the same group, on the reasoning MoveHost uses in the other direction: two
// panels about one shelf, one of which destroys it, is not something to make anybody read carefully.
CancelMoveGroupCommand.Execute(null);
PendingDeletion = new DeletionRequest(
DeletionTarget.Group,
row.EntityId,
@@ -5136,13 +5262,52 @@ internal sealed partial class VaultViewModel(
row.HostCount switch
{
0 => string.Empty,
1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
_ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
});
1 => "1 host is filed under it. Left as it is, the host stays and moves to UNGROUPED.",
_ => $"{row.HostCount} hosts are filed under it. Left as it is, they stay and move to "
+ "UNGROUPED.",
})
{
Choice = row.HostCount switch
{
0 => string.Empty,
1 => "Delete the host filed under it as well.",
_ => $"Delete the {row.HostCount} hosts filed under it as well.",
},
};
}
/// <summary>Queues a tombstone for the group that was agreed to.</summary>
private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
/// <summary>
/// Queues the tombstone for the group that was agreed to, and settles what pointed at it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing is left pointing at the group.</b> That is the change: the hosts filed under it are
/// rewritten — unfiled, or deleted where that was asked for — and the groups nested inside it take its
/// place in the tree rather than being orphaned into roots. It costs one write per item and it is the
/// honest cost of the question above it. The dangling reference the list used to absorb is still handled
/// everywhere it is read, because a group deleted on <em>another</em> machine still arrives that way.
/// </para>
/// <para>
/// <b>Everything that points at it is written before the group's own tombstone.</b> A crash in the middle
/// then leaves a heading standing over fewer things, which is visible and can simply be deleted again;
/// the other order leaves a group gone with its members still naming it, which is exactly the state this
/// exists to stop producing. It also matters against a merge: the group's tombstone can lose one, and if
/// it does, the unfiling has already been recorded on its own items rather than riding on it.
/// </para>
/// <para>
/// <b>A read-only host is skipped and counted rather than rewritten.</b> Unfiling it would re-encode a
/// payload this build cannot fully represent, which is the same refusal editing and moving one already
/// make — and the cost of skipping is a dangling id, which is the behaviour every reader here already
/// survives. Deleting one is <em>not</em> skipped: a tombstone re-encodes nothing.
/// </para>
/// </remarks>
/// <param name="entityId">The group.</param>
/// <param name="takesTheHosts">Whether the hosts filed under it were agreed to go too.</param>
/// <param name="cancellationToken">Cancellation token.</param>
private async Task DeleteGroupNowAsync(
Guid entityId,
bool takesTheHosts,
CancellationToken cancellationToken)
{
if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
@@ -5150,10 +5315,24 @@ internal sealed partial class VaultViewModel(
return;
}
// Its own vault's, both of them. A host in another vault naming this group is a reference the editor
// cannot make and the drop gesture refuses, so the only way to hold one is a hand-edited payload —
// and rewriting somebody else's vault because a group in this one went is worse than the dangle.
var filed = Hosts
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId == entityId)
.ToList();
var nested = Groups
.Where(group => group.VaultId == row.VaultId && group.Group.ParentId == entityId)
.ToList();
await RunAsync(
"Deleting…",
async () =>
{
var skipped = await ReleaseWhatPointedAtAsync(row, filed, nested, takesTheHosts, cancellationToken)
.ConfigureAwait(true);
// The row's own vault, which is why the row is re-found above rather than the id being
// enough: a tombstone written to the active vault would delete nothing and leave a
// colleague's group standing while this machine reported it gone.
@@ -5168,12 +5347,91 @@ internal sealed partial class VaultViewModel(
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted the group '{row.Label}'.";
Status = $"Deleted the group '{row.Label}'."
+ WhatBecameOfTheHosts(filed.Count, takesTheHosts, skipped);
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Rewrites everything naming the group about to go, and says how many could not be.
/// </summary>
/// <remarks>
/// Before the tombstone and in this order, which <see cref="DeleteGroupNowAsync"/> gives the reasons for.
/// </remarks>
/// <returns>How many hosts were left holding the reference because they could not be rewritten.</returns>
private async Task<int> ReleaseWhatPointedAtAsync(
HostGroupRowViewModel row,
IEnumerable<HostRowViewModel> filed,
IEnumerable<HostGroupRowViewModel> nested,
bool takesTheHosts,
CancellationToken cancellationToken)
{
var skipped = 0;
foreach (var host in filed)
{
if (takesTheHosts)
{
await session.Hosts
.DeleteAsync(host.VaultId, host.EntityId, cancellationToken)
.ConfigureAwait(true);
continue;
}
if (host.IsReadOnly)
{
skipped++;
continue;
}
await session.Hosts
.UpdateAsync(
host.VaultId,
host.EntityId,
host.Host with { GroupId = null },
cancellationToken)
.ConfigureAwait(true);
}
// Promoted to where the group they were under sat, rather than to the top level. A nested group whose
// parent goes has not been moved by anybody, and dropping it to a root would rearrange a tree on a
// delete that was about one heading.
foreach (var child in nested.Where(child => !child.IsReadOnly))
{
await session.HostGroups
.UpdateAsync(
child.VaultId,
child.EntityId,
child.Group with { ParentId = row.Group.ParentId },
cancellationToken)
.ConfigureAwait(true);
}
return skipped;
}
/// <summary>What the group's deletion did to the machines under it, said only when there were any.</summary>
/// <remarks>
/// The skipped count is named rather than folded into the total, because those hosts are the ones still
/// holding the deleted group's id: they show up under UNGROUPED like the rest, so nothing looks wrong,
/// and the sentence is the only place anybody is told that updating this client is what finishes the job.
/// </remarks>
private static string WhatBecameOfTheHosts(int filed, bool takesTheHosts, int skipped) =>
(filed, takesTheHosts, skipped) switch
{
(0, _, _) => string.Empty,
(1, true, _) => " Its host went with it.",
(_, true, _) => $" Its {filed} hosts went with it.",
(1, false, 0) => " Its host moved to UNGROUPED.",
(_, false, 0) => $" Its {filed} hosts moved to UNGROUPED.",
_ => $" Its {filed} hosts moved to UNGROUPED, except {skipped} written by a newer version of "
+ "DodoSSH — those still name the group that has gone. Update to unfile them.",
};
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
@@ -5457,11 +5715,7 @@ internal sealed partial class VaultViewModel(
{
MoveVaultChoices.Clear();
foreach (var choice in session.ReadableVaults
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)))
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveVaultChoices.Add(choice);
}
@@ -5469,6 +5723,357 @@ internal sealed partial class VaultViewModel(
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
}
/// <summary>Every vault this session can write to except one, in the order a picker should offer them.</summary>
/// <remarks>
/// Shared by the host's picker and the group's rather than written twice. The order is the one every
/// vault picker in this application uses: your own first, then the shared ones by name — a list that
/// re-sorted itself per control would make "the second entry" mean something different on each.
/// </remarks>
private IEnumerable<VaultChoiceViewModel> WritableVaultsBesides(Guid vaultId) =>
session.ReadableVaults
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
/// <summary>
/// Opens the panel that asks which vault the group — and everything under it — should move to.
/// </summary>
/// <remarks>
/// <para>
/// The host's panel, one level up, and the reason it exists at all is that the host's did not go far
/// enough. Moving twenty machines into a shared vault one at a time meant twenty round trips through a
/// menu, and each one arrived stripped of the group it had been filed under, so the shelf had to be
/// rebuilt by hand on the other side. Moving the shelf is the operation people were actually attempting.
/// </para>
/// <para>
/// 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>
/// </remarks>
[RelayCommand]
private void MoveGroup()
{
if (GroupTarget is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This group was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
BuildMoveGroupVaultChoices(row.VaultId);
if (MoveGroupVaultChoices.Count == 0)
{
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
// As MoveHost disarms a deletion aimed at the same host.
PendingDeletion = null;
movingGroupId = row.EntityId;
IsMovingGroup = true;
Status = string.Empty;
}
/// <summary>Abandons the group's move panel.</summary>
[RelayCommand]
private void CancelMoveGroup()
{
if (!IsMovingGroup)
{
return;
}
IsMovingGroup = false;
movingGroupId = null;
MoveGroupVaultChoices.Clear();
SelectedMoveGroupVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the group, everything nested inside it and every host filed under any of them.
/// </summary>
/// <remarks>
/// <para>
/// <b>The whole subtree goes, and taking less than that was never coherent.</b> A group's children are
/// items of the vault it is leaving: move the parent alone and they are left naming a tombstone, so they
/// surface as roots in the vault the user has just emptied — half a shelf here and half there, from one
/// gesture that said "move this". The hosts are the same argument and are the half the user asked about.
/// </para>
/// <para>
/// <b>The groups go first, top down, and the hosts last.</b> Each item is re-sealed under the
/// destination's key and takes a new id — see <c>VaultItemRepository.MoveAsync</c> — so nothing that
/// points at a group can be written until that group has landed and its new id is known. Top down for
/// the same reason one level up: a child's parent must already be over there. What an interruption
/// leaves is therefore hosts still in the vault they started in, under UNGROUPED, which is visible and
/// re-movable; the reverse order would leave hosts in the destination filed under nothing.
/// </para>
/// <para>
/// <b>The parent is left behind and the tags are dropped</b>, and both are the same rule the host's move
/// follows: a parent group and a tag are items of the vault being left, so a reference carried across
/// would resolve on this machine — groups and tags resolve over every readable vault — and dangle for
/// everybody else in the destination. The moved group becomes a root, which is what the trail will show,
/// and it is said in the sentence afterwards rather than discovered.
/// </para>
/// <para>
/// <b>Keys and passwords are kept</b>, on the host and on the group's own defaults, because those do
/// genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they
/// 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>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
{
if (GroupTarget is not { } row
|| movingGroupId != row.EntityId
|| SelectedMoveGroupVault is not { } target)
{
return;
}
var subtree = SubtreeOf(row);
var moving = subtree.Select(group => group.EntityId).ToHashSet();
var filed = Hosts
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId is { } id && moving.Contains(id))
.ToList();
// Asked once, over everything that is about to be re-encoded, and refused as a whole rather than
// half-done: a move that skipped the items it could not represent would file some of the shelf in one
// vault and leave the rest in the other, which is the state this operation exists to prevent.
if (subtree.Any(group => group.IsReadOnly) || filed.Any(host => host.IsReadOnly))
{
Status = "Something under this group was written by a newer version of DodoSSH. Moving it would "
+ "re-encode it here and lose what this build cannot read. Update first.";
return;
}
var name = target.Name;
var stranded = BindingsOutside(subtree, filed, target.VaultId);
var uprooted = row.Group.ParentId is not null;
var tagged = filed.Count(host => host.Host.TagIds.Count > 0);
CancelMoveGroupCommand.Execute(null);
await RunAsync(
"Moving…",
async () =>
{
var landed = await ReSealTheSubtreeAsync(subtree, filed, target.VaultId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the buttons aimed at a card that no
// longer exists would read as the shelf having been deleted rather than moved.
SelectedGroup = VisibleGroups.FirstOrDefault(card => card.EntityId == landed);
Status = $"Moved '{row.Label}' to {name}.{WhatCameAlong(subtree.Count, filed.Count)}"
+ $"{WhatStayedBehind(uprooted, tagged)}{stranded}";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Re-seals a group, its nested groups and their hosts under another vault's key.
/// </summary>
/// <remarks>
/// The order and what it costs an interruption are <see cref="ConfirmMoveGroupAsync"/>'s to explain. What
/// lives here is the id map every write after the first depends on: each item lands with an id of the
/// destination's making, so a parent's is looked up rather than reused, and a host's group is the entry
/// its old group left behind.
/// </remarks>
/// <param name="subtree">The group and its nested groups, each after its parent.</param>
/// <param name="filed">The hosts under any of them.</param>
/// <param name="vaultId">The vault they are all going to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The id the group at the root of it has in its new vault.</returns>
private async Task<Guid> ReSealTheSubtreeAsync(
List<HostGroupRowViewModel> subtree,
IEnumerable<HostRowViewModel> filed,
Guid vaultId,
CancellationToken cancellationToken)
{
var root = subtree[0].EntityId;
var remapped = new Dictionary<Guid, Guid>();
foreach (var group in subtree)
{
// Null for the root, whose parent is staying behind in the vault it came from; every other group
// in the list was discovered by its own parent, so that parent has already landed.
var parent = group.EntityId == root
? (Guid?)null
: remapped[group.Group.ParentId!.Value];
remapped[group.EntityId] = await session.HostGroups
.MoveAsync(
group.VaultId,
vaultId,
group.EntityId,
group.Group with { ParentId = parent },
cancellationToken)
.ConfigureAwait(true);
}
foreach (var host in filed)
{
await session.Hosts
.MoveAsync(
host.VaultId,
vaultId,
host.EntityId,
host.Host with
{
GroupId = remapped[host.Host.GroupId!.Value],
TagIds = TagSet.Empty,
},
cancellationToken)
.ConfigureAwait(true);
}
return remapped[root];
}
/// <summary>
/// The group and every group nested under it, each one after the group it hangs from.
/// </summary>
/// <remarks>
/// <para>
/// A breadth-first walk from the root, and the order is the whole reason it is one: every group but the
/// first is discovered <em>by</em> its parent, so a caller writing the list in order always has the
/// parent's new id in hand before it needs it.
/// </para>
/// <para>
/// Its own vault's only, because a child in another vault is a level half the readers cannot resolve and
/// the editor refuses to make one.
/// </para>
/// <para>
/// <b>Cycle-safe, and it has to be.</b> A group already found is not walked again and not added twice —
/// the same visited set every walk over this tree carries, for the reason
/// <see cref="HostGroupSecret.ParentId"/> gives: two offline clients can each re-parent one group under
/// the other, and the pair that results was never shown to an editor. Without the set, a group that is
/// its own parent would be appended to this list for as long as there was memory to append to.
/// </para>
/// </remarks>
private List<HostGroupRowViewModel> SubtreeOf(HostGroupRowViewModel root)
{
var ordered = new List<HostGroupRowViewModel> { root };
var found = new HashSet<Guid> { root.EntityId };
for (var index = 0; index < ordered.Count; index++)
{
var parent = ordered[index].EntityId;
foreach (var child in Groups.Where(
group => group.VaultId == root.VaultId && group.Group.ParentId == parent))
{
if (found.Add(child.EntityId))
{
ordered.Add(child);
}
}
}
return ordered;
}
/// <summary>What the move brought with it, said as the counts the user can check against the cards.</summary>
private static string WhatCameAlong(int groups, int hosts)
{
var nested = groups switch
{
1 => string.Empty,
2 => " with the group inside it",
_ => $" with the {groups - 1} groups inside it",
};
return hosts switch
{
0 when groups == 1 => string.Empty,
0 => $" It went{nested}, and no hosts were filed under any of them.",
1 => $" Its host came too{nested}.",
_ => $" Its {hosts} hosts came too{nested}.",
};
}
/// <summary>The two things a group cannot take across, said only where it had one.</summary>
private static string WhatStayedBehind(bool uprooted, int tagged) =>
(uprooted, tagged > 0) switch
{
(true, true) => " The group it was nested under stayed behind and the hosts' tags were dropped —"
+ " both belong to the vault it came from, so it now sits at the top level.",
(true, false) => " The group it was nested under stayed behind — a parent belongs to the vault it"
+ " is in — so it now sits at the top level.",
(false, true) => " The hosts' tags were left behind — a tag belongs to the vault it is in.",
_ => string.Empty,
};
/// <summary>
/// The warning about keys or passwords that are not in the vault the group has moved to.
/// </summary>
/// <remarks>
/// Counted and named to one, where <see cref="BindingOutside"/> names the only one there can be. A shelf
/// of twenty machines may strand five different keys, and twenty sentences is not a status line — but a
/// bare number is not actionable either, so the first is named and the rest are counted. Both the hosts'
/// own bindings and the groups' defaults are asked, because a group that lends a key is the one case
/// where a machine can be unable to connect without naming anything itself.
/// </remarks>
private string BindingsOutside(
IEnumerable<HostGroupRowViewModel> groups,
IEnumerable<HostRowViewModel> hosts,
Guid vaultId)
{
var stranded = groups
.SelectMany(group => new[] { group.Group.DefaultSshKeyId, group.Group.DefaultCredentialId })
.Concat(hosts.SelectMany(host => new[] { host.Host.SshKeyId, host.Host.CredentialId }))
.OfType<Guid>()
.Distinct()
.Select(LabelOfBindingOutside)
.OfType<string>()
.ToList();
return stranded.Count switch
{
0 => string.Empty,
1 => $" It still authenticates with '{stranded[0]}', which is in another vault — everybody else "
+ "in this one will find that binding unresolvable.",
_ => $" It still authenticates with '{stranded[0]}' and {stranded.Count - 1} other key(s) or "
+ "password(s) in other vaults — everybody else in this one will find those bindings "
+ "unresolvable.",
};
string? LabelOfBindingOutside(Guid entityId) =>
(Keys.FirstOrDefault(key => key.EntityId == entityId) is { } key && key.VaultId != vaultId
? key.Label
: null)
?? (Credentials.FirstOrDefault(row => row.EntityId == entityId) is { } credential
&& credential.VaultId != vaultId
? credential.Label
: null);
}
/// <summary>Fills the group move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveGroupVaultChoices(Guid vaultId)
{
MoveGroupVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveGroupVaultChoices.Add(choice);
}
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
}
/// <summary>Asks whether the selected host should go.</summary>
/// <remarks>
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
@@ -6314,6 +6919,10 @@ internal sealed partial class VaultViewModel(
return;
}
// Read before the disarming below, which resets it. The answer belongs to the question that was on
// screen, and taking it afterwards would read whatever the next question starts from.
var takesTheHosts = DeletionTakesTheHostsToo;
PendingDeletion = null;
switch (request.Target)
@@ -6331,7 +6940,8 @@ internal sealed partial class VaultViewModel(
break;
case DeletionTarget.Group:
await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
await DeleteGroupNowAsync(request.EntityId, takesTheHosts, cancellationToken)
.ConfigureAwait(true);
break;
case DeletionTarget.ObjectStore:
@@ -8065,9 +8675,11 @@ internal sealed partial class VaultViewModel(
}
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(CanMoveGroupTarget));
}
/// <remarks>
@@ -8086,16 +8698,38 @@ internal sealed partial class VaultViewModel(
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
{
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
OnPropertyChanged(nameof(ShowsGroupActions));
OnPropertyChanged(nameof(CanMoveGroupTarget));
RebuildGroupLevel();
RebuildVisibleHosts();
}
/// <summary>Folds the group's move panel away once the buttons under it point at something else.</summary>
/// <remarks>
/// By entity id and not by row, for the reason <see cref="DisarmIfAimedElsewhere"/> compares ids: every
/// row object in the list is replaced on every reload, so a panel closed on row identity would fold up
/// once a minute under somebody who was still choosing a vault in it. It is cleared rather than re-aimed,
/// because which vault to move to is a choice about the shelf it was asked for.
/// </remarks>
private void CloseTheGroupMovePanelIfAimedElsewhere()
{
if (IsMovingGroup && movingGroupId != GroupTarget?.EntityId)
{
CancelMoveGroupCommand.Execute(null);
}
}
partial void OnPendingDeletionChanged(DeletionRequest? value)
{
// Back to "keep them" on every question, including the one that disarms it. A tick is the answer to
// the group that was named in the sentence above it and to nothing else; carried into the next
// question it would delete a second group's machines on the strength of a decision about the first.
DeletionTakesTheHostsToo = false;
OnPropertyChanged(nameof(IsConfirmingDeletion));
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));