Files
DodoSSH/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
T
jaap-jan a86731ee08 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.
2026-08-04 17:04:06 +02:00

68 lines
3.5 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
x:DataType="vm:VaultViewModel">
<!--
The question in front of deleting something in the vault.
One control used in three places — the hosts drawer, where it takes the place of the row of buttons that
opened it; the hosts screen's GROUPS section, where it takes the place of that group's EDIT and DELETE;
and the vault screen's detail pane. The three moments are different and what has to be said is not,
which is why this is a shared control rather than three blocks that would drift apart. The sign-out
confirmation is the same arrangement, for the same reason; see SignOutCard.
A bare StackPanel and not a card, because all three hosts frame it themselves — each wraps it in its own
DangerWash border in the place its buttons were.
Everything it says is something the view model can answer. The question names the item, the consequence
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
that actually authenticate with the thing about to go — see VaultViewModel.HostsBoundTo. A confirmation
that only asked "are you sure?" would be a click to train people out of.
-->
<StackPanel Spacing="8">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Question}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Consequence}" />
<!--
What else in this vault leans on it. In a box of its own because it is the line that changes the
answer: everything above is true of every deletion, and this is about the one being made.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8,6"
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
<TextBlock Foreground="{StaticResource Info}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Usage}" />
</Border>
<!--
The second question, and only a group's deletion has one: whether the machines filed under it go with
it. See VaultViewModel.DeleteGroup for why it is asked rather than stated, and DeletionTakesTheHostsToo
for why it is a tick rather than a pair of options — the two answers are not equally weighted, and the
recoverable one is the one that needs no decision.
Below the usage box on purpose: that box is the count this is about, and a tick offered above the
number it applies to is a tick somebody answers before reading how many.
-->
<CheckBox IsVisible="{Binding PendingDeletion.HasChoice, FallbackValue=False}"
IsChecked="{Binding DeletionTakesTheHostsToo}">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Choice}" />
</CheckBox>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel>
</StackPanel>
</UserControl>