Choose more than one host card on the desktop, the way the phone already can

The chosen-hosts set has been in VaultViewModel since the phone's connect card became a contextual action
bar: a set of entity ids, a tick on the row, and seven things that can be done to it. Only one head could
fill it. The desktop's grid answered a press with one selection — the card the drawer, CONNECT and the
context menu are about — so filing eleven imported machines under a heading was eleven drags, and clearing
out a vault was eleven rounds of the deletion question.

So the pointer gets three ways into the same set. Ctrl-clicks a card to tick it, Shift-clicks to tick the run
between the anchor and the card, and drags a band out over the space between and below the cards to tick
everything it touches. Esc, CLEAR, a plain click on a card and a click on the empty space each drop it, and
Ctrl+A takes every card being drawn — VisibleHosts, so with something in the find box that is the ones on
screen and not the ones it is hiding, which is the version of that shortcut whose result can be checked
before Delete is pressed.

TWO SELECTIONS ON ONE SCREEN, AND KEEPING THEM FROM DISAGREEING IS MOST OF THE CHANGE. Ctrl and Shift are
answered on the tunnel and marked handled, so the ListBox never moves its own mark onto the card: a
Ctrl-click that also selected would light the card it had just unticked and open the drawer on a machine
somebody is removing from a set. A plain press drops the set unless it lands on a ticked card, and that case
is deferred to the release, because the press may be the start of a drag of all of it. After any ordinary
click exactly one card is in play, which is what makes every command on the screen unambiguous again.

The context menu is where the seven live, and it is one markup with two halves gated on IsChoosingHosts.
Connect, Browse files and Edit… are drawn only for a single ticked host, as the phone's sheet collapses them
and for the same reason; the other four read better for a count. A right click on a card outside the set
drops the set first, so a Delete… about the card under the pointer can never be offered while six sit ticked
behind the menu — the same rule OnContextRequested has always enforced for the selection, reached from the
other direction. No bar of buttons: the phone raises one because it has no other way to hold seven entries,
and a strip repeating a menu that already exists would be a second home for the wording that matters most.
What the desktop gains instead is a count beside the HOSTS heading, CLEAR, and a sentence saying where the
actions are.

A drag that starts on a ticked card carries every ticked card. The payload is a list rather than a row now,
and a drop of more than one goes through FileChosenHostsUnder, which makes the refusals once — an open
editor, and a group belonging to one keychain — and reports a count instead of forty status lines. Moving
whichever card the pointer happened to be holding and leaving the other five where they are is a gesture
that quietly does a fraction of what it looks like it does, and the five left behind look filed.

The three panels the set's actions raise had never been drawn in a window: the vault picker with its key
question, the group picker, and the deletion question. All three sit above the grid rather than over it,
which is the arrangement the GROUPS section and the phone's list already use and for the reason written
there — the ticked cards are the information the question exists to give, so the grid shortens instead.

A DEFECT FOUND BEHIND IT, AND IT WAS ALREADY LIVE ON THE PHONE. The deletion question names a count and the
run that answers it reads the set again, and nothing kept the two the same set: the panel is deliberately
above a live list, so one more tick between "Delete these 6 hosts?" and pressing DELETE deleted seven, with
the seventh named in nothing the user had read. It needed a deliberate act on a phone and a second's work
with a band, which is what turned it up. VaultViewModel now remembers which hosts the question was asked
about and drops the question when the set stops being them — the question rather than the set, because what
somebody has just chosen is what they meant. It also covers the case nobody performs: a colleague's deletion
arriving mid-question and shrinking the set under it.

VERIFIED. 354 tests in App.Tests and 111 in App.Layout.Tests, build clean, no new warnings. Six gesture tests
drive real pointer and key input through the headless window — the modifier click and what it must not do to
the selection, the run and its re-measurement from the anchor, the band and the click that drops the set,
Ctrl+A under a filter, and the menu's two halves — plus a DragOver carrying two hosts. Four layout tests
measure the strip and the three panels at the window's minimum; the vault panel binds a key to its host
first, or it would measure the short shape and certify the tall one. Two flow tests cover the multi-drop's
write and its refusal, and the deletion question dropping itself.

manual-checks gains 7.6a for dragging a set, which no test can see for the reason 7.6 gives, and 7.7a for the
gestures — the rectangle actually being painted and the tick and the fill being legible together are the two
things the harness cannot look at.
This commit is contained in:
2026-08-06 14:11:48 +02:00
parent 1e8a1f2e83
commit 507cd9ff88
8 changed files with 1418 additions and 64 deletions
@@ -2384,7 +2384,13 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal bool ShowsAddButton => !AnEditorIsOpen && !IsChoosingHosts && !AChosenHostPanelIsOpen;
// ---- ◆ The phone's chosen hosts ----
// ---- ◆ The chosen hosts ----
//
// ◆ BOTH HEADS, AND IT WAS THE PHONE'S ALONE. The set below is unchanged by that: what a head brings is
// the gesture that fills it and the furniture that acts on it. The phone has a long press, a contextual
// action bar and a sheet of seven entries; the desktop has Ctrl, Shift, a band dragged over the grid and
// one context menu — see HostsScreen.axaml.cs there, and ChooseHosts below, which is the entry those
// gestures use. Everything about *which hosts* and *what happens to them* is here, once.
//
// THE CONNECT BAR WAS HERE, AND WHAT REPLACED IT IS A SELECTION RATHER THAN A PANEL.
//
@@ -2418,13 +2424,33 @@ internal sealed partial class VaultViewModel(
/// </remarks>
private readonly HashSet<Guid> chosenHostIds = [];
/// <summary>Which hosts the deletion question on screen is about.</summary>
/// <remarks>
/// <para>
/// ◆ <b>The question names a count and the run that answers it reads the set again, so the two have to be
/// the same set.</b> Nothing kept them together: the panel is drawn <em>above</em> the list on both heads
/// rather than over it — deliberately, so the ticked rows can be seen while the question is answered —
/// which leaves every one of them still able to be ticked and unticked. One more tick between "Delete
/// these 6 hosts?" and pressing DELETE deleted seven, and the desktop's Ctrl-click and band made that a
/// second's work rather than a deliberate act.
/// </para>
/// <para>
/// Compared rather than frozen, and the question is dropped rather than the set: what somebody has just
/// chosen is what they meant, and a question they have already read is not. It also covers the case
/// nobody performs — a colleague's deletion arriving mid-question and shrinking the set under it. Ids,
/// for the reason <see cref="chosenHostIds"/> holds ids.
/// </para>
/// </remarks>
private readonly HashSet<Guid> deletionAskedAbout = [];
/// <summary>
/// Whether the phone is in selection mode: a long press has chosen at least one host.
/// Whether at least one host is ticked: a long press on the phone, a Ctrl-click or a band on the desktop.
/// </summary>
/// <remarks>
/// Derived from the set being non-empty rather than being a flag beside it, so the mode and its contents
/// cannot disagree. Unticking the last host leaves selection mode, which is what every Android list does
/// and is the second way out of it — the other being the cross at the left of the bar.
/// cannot disagree. Unticking the last host leaves selection mode, which is what every list of either
/// kind does and is the second way out of it — the others being the cross at the left of the phone's bar,
/// and CLEAR, Esc or a plain click on the desktop.
/// </remarks>
internal bool IsChoosingHosts => chosenHostIds.Count > 0;
@@ -2439,6 +2465,16 @@ internal sealed partial class VaultViewModel(
internal string ChosenHostsLabel =>
ChosenHostCount.ToString(CultureInfo.CurrentCulture);
/// <summary>The same count with the word on it, which is what the desktop prints.</summary>
/// <remarks>
/// The bare number above works on the phone because it sits in a bar that is the whole top of the
/// screen and can mean nothing else. The desktop has no such bar — the ticks arrive beside a HOSTS
/// heading that already has a count of its own at the far end of the same row — so a second bare number
/// there would be two numbers about two different things, side by side, neither of them labelled.
/// </remarks>
internal string ChosenHostsSummary =>
$"{ChosenHostCount.ToString(CultureInfo.CurrentCulture)} chosen";
/// <summary>The chosen hosts, as the rows currently in the list.</summary>
/// <remarks>
/// Rebuilt per read rather than kept, because the rows it names are replaced on every reload and this is
@@ -5356,11 +5392,11 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// Adds or removes one host, which is what a tap means once the bar is up.
/// Adds or removes one host: a tap once the phone's bar is up, and a Ctrl-click on the desktop.
/// </summary>
/// <remarks>
/// Taking the last tick off leaves selection mode, which is what every Android list does and is the
/// second way out of it — the cross at the left of the bar being the first. It goes through
/// Taking the last tick off leaves selection mode, which is what every list of either kind does and is
/// the second way out of it — the cross at the left of the bar being the first. It goes through
/// <see cref="ClearHostChoice"/> rather than merely emptying the set, so the menu and any panel it raised
/// go with it: a picker asking which vault to move nothing to is not a state worth having.
/// </remarks>
@@ -5386,6 +5422,89 @@ internal sealed partial class VaultViewModel(
ApplyTheChosenHosts();
}
/// <summary>
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
/// </summary>
/// <param name="rows">The hosts to tick.</param>
/// <param name="replacing">
/// Whether this run is the selection now, or is being added to whatever is already ticked — which is the
/// difference between a band dragged across the grid and the same band dragged with Ctrl held.
/// </param>
/// <remarks>
/// <para>
/// ◆ <b>A method rather than a command, and it is the one entry into this set that is not a control.</b>
/// Every other one is pressed: a long press, a tap, the cross on the bar. This one is handed a run the
/// head has just worked out from the pointer — the cards a rubber band crossed, or the cards between two
/// clicks — and a command takes a single argument, so binding it would mean inventing a parameter object
/// for a caller that is not a binding. See <c>HostsScreen.axaml.cs</c> on the desktop.
/// </para>
/// <para>
/// Replacing with nothing goes through <see cref="ClearHostChoice"/> rather than merely emptying the set,
/// for the reason <see cref="ToggleHostChoice"/> does: the panels and the menu are about the set, and a
/// picker asking which vault to move nothing to is not a state worth having. That is the ordinary end of
/// a band dragged across empty space, and of a plain click on the grid's background.
/// </para>
/// </remarks>
internal void ChooseHosts(IEnumerable<HostRowViewModel> rows, bool replacing)
{
if (replacing)
{
chosenHostIds.Clear();
}
foreach (var row in rows)
{
chosenHostIds.Add(row.EntityId);
}
if (chosenHostIds.Count == 0)
{
ClearHostChoice();
return;
}
ApplyTheChosenHosts();
}
/// <summary>
/// Ticks every host between two cards, which is what a shift-click on the desktop means.
/// </summary>
/// <param name="anchor">Where the run starts: the last card clicked without shift.</param>
/// <param name="to">The card that was shift-clicked.</param>
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
/// <remarks>
/// <para>
/// The order is the grid's own — <see cref="VisibleHosts"/>, which is the collection the cards are drawn
/// from — so "between" means what the eye says it means, with whatever filter is in the box and whatever
/// group is open already applied. Taking it from <see cref="Hosts"/> instead would tick machines that are
/// not on the screen, which is the version of this mistake that ends in a deletion.
/// </para>
/// <para>
/// Either end missing from that collection ticks nothing rather than guessing. That is a shift-click
/// arriving after the run's other end has been filtered away, and the honest answer to it is no run.
/// </para>
/// </remarks>
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
{
if (anchor is null || to is null)
{
return;
}
var from = VisibleHosts.IndexOf(anchor);
var until = VisibleHosts.IndexOf(to);
if (from < 0 || until < 0)
{
return;
}
var first = Math.Min(from, until);
var last = Math.Max(from, until);
ChooseHosts(VisibleHosts.Skip(first).Take(last - first + 1).ToList(), replacing);
}
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
/// <remarks>
/// It takes the menu and whichever panel was raised from it, because all three are about the set: a
@@ -5822,13 +5941,75 @@ internal sealed partial class VaultViewModel(
return;
}
var rows = ChosenHosts;
var name = choice.EntityId is null ? "no group" : choice.Label;
IsRegroupingChosenHosts = false;
ChosenHostGroupChoices.Clear();
SelectedChosenHostGroup = null;
await FileTheChosenHostsUnderAsync(
choice.EntityId,
choice.EntityId is null ? "no group" : choice.Label,
cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Files the ticked hosts under the group card a drag of them was let go of.
/// </summary>
/// <param name="card">The group card the drop landed on.</param>
/// <remarks>
/// <para>
/// ◆ <b>The desktop's drag, once more than one card is ticked.</b> Dragging one host onto a group is
/// <see cref="MoveHostToGroupAsync"/> and always has been; a selection dragged onto one has to file all of
/// it, because the alternative — moving whichever card the pointer happened to be holding and leaving the
/// other five where they are — is a gesture that quietly does a fraction of what it looks like it does.
/// </para>
/// <para>
/// The two refusals are the ones <see cref="RefusesTheDrop"/> makes for a single card, made once for the
/// set: a write under an open editor is a save nobody asked for, and a group belongs to one vault, so a
/// selection spanning two cannot be filed under it — the hosts from the other keychain would carry an id
/// only its holders can resolve. A read-only host inside the set is skipped rather than refusing the
/// whole drop; that is counted and said, in the run below.
/// </para>
/// <para>
/// No cancellation token, for the reason <see cref="MoveHostToGroupAsync"/> has none: two drops in quick
/// succession are two writes rather than one superseding the other.
/// </para>
/// </remarks>
[RelayCommand]
private Task FileChosenHostsUnderAsync(HostGroupRowViewModel? card)
{
if (card is null || !IsChoosingHosts || AHostEditorIsInTheWay())
{
return Task.CompletedTask;
}
if (ChosenHosts.Any(row => row.VaultId != card.VaultId))
{
Status = $"'{card.Label}' is in another keychain to some of these hosts, and a group belongs to "
+ "one. Move them to that keychain first, or file the ones already in it.";
return Task.CompletedTask;
}
return FileTheChosenHostsUnderAsync(card.EntityId, card.Label, CancellationToken.None);
}
/// <summary>
/// The write behind both ways a set of ticked hosts is filed: the group picker, and a drag of them onto a
/// group card.
/// </summary>
/// <param name="groupId">The group they end up in, or null for none.</param>
/// <param name="name">What to call it on the status line.</param>
/// <param name="cancellationToken">The caller's lifetime.</param>
/// <remarks>
/// Shared rather than written twice, because what "filing a set" means — the hosts a newer version wrote
/// are skipped, the ones already there are counted as done, and the selection is dropped once the reload
/// has replaced every row — is the part that would drift between two copies of it.
/// </remarks>
private async Task FileTheChosenHostsUnderAsync(
Guid? groupId, string name, CancellationToken cancellationToken)
{
var rows = ChosenHosts;
var done = 0;
var skipped = 0;
@@ -5844,7 +6025,7 @@ internal sealed partial class VaultViewModel(
continue;
}
if (row.Host.GroupId == choice.EntityId)
if (row.Host.GroupId == groupId)
{
// Already there. Counted as done rather than skipped: the user asked for these hosts
// to be under this heading, and they are.
@@ -5856,7 +6037,7 @@ internal sealed partial class VaultViewModel(
.UpdateAsync(
row.VaultId,
row.EntityId,
row.Host with { GroupId = choice.EntityId },
row.Host with { GroupId = groupId },
cancellationToken)
.ConfigureAwait(true);
@@ -5961,6 +6142,10 @@ internal sealed partial class VaultViewModel(
IsSendingChosenHostsToAVault = false;
IsRegroupingChosenHosts = false;
// What the count below is about, so that the question goes if the set stops being it.
deletionAskedAbout.Clear();
deletionAskedAbout.UnionWith(chosenHostIds);
PendingDeletion = new DeletionRequest(
DeletionTarget.ChosenHosts,
Guid.Empty,
@@ -6133,6 +6318,12 @@ internal sealed partial class VaultViewModel(
row.IsChosen = chosenHostIds.Contains(row.EntityId);
}
// A question asked about six hosts is not a question about these seven. See deletionAskedAbout.
if (IsConfirmingChosenHostDeletion && !chosenHostIds.SetEquals(deletionAskedAbout))
{
PendingDeletion = null;
}
// The panels go with the last host, wherever the set emptied from. A sync that removed the only
// chosen machine would otherwise leave a vault picker up with nothing behind it.
if (chosenHostIds.Count == 0)
@@ -6145,6 +6336,7 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(ShowsAddButton));
OnPropertyChanged(nameof(ChosenHostCount));
OnPropertyChanged(nameof(ChosenHostsLabel));
OnPropertyChanged(nameof(ChosenHostsSummary));
OnPropertyChanged(nameof(ChosenHosts));
OnPropertyChanged(nameof(TheChosenHost));
OnPropertyChanged(nameof(HasOneChosenHost));