Merge branch 'claude/windows-multiselect-support-37541c'
ci / build and test (push) Successful in 2m7s
ci / android head (push) Successful in 3m24s
ci / desktop nightly (push) Successful in 41s
ci / api image (push) Successful in 24s

This commit is contained in:
2026-08-06 14:12:07 +02:00
8 changed files with 1418 additions and 64 deletions
@@ -119,7 +119,12 @@ public sealed class HostGridTests : IAsyncLifetime
// The commands are the vault's, which is the other half of putting the menu on the list rather
// than in the item template: a menu inside the template would have the row for its data context,
// and every one of these would silently bind to nothing.
var edit = menu.Items.OfType<MenuItem>().Single(item => item.Header is "Edit…");
//
// Drawn rather than merely present, because this menu carries a second Edit… that is about the
// ticked set rather than about the selection; with nothing ticked that one is collapsed. See
// TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise.
var edit = menu.Items.OfType<MenuItem>()
.Single(item => item.IsVisible && item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
edit.Command!.Execute(null);
@@ -260,7 +265,7 @@ public sealed class HostGridTests : IAsyncLifetime
await OnTheGridAsync((screen, _) =>
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, Row(vault, "prod-db")));
carried.Add(DataTransferItem.Create(HostFormat, (IReadOnlyList<HostRowViewModel>)[Row(vault, "prod-db")]));
var onto = screen.GroupGrid
.GetVisualDescendants()
@@ -574,6 +579,245 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
// ---- ◆ Choosing more than one card ----
//
// The set is the phone's — the same ids, the same seven actions, the same tick on the row — built here
// with a pointer instead of a long press. What these hold is the half that belongs to this head: which
// gesture means what, and the rule that keeps the list's own selection and the set from ever both being
// about something at the same moment. See HostsScreen.axaml.cs.
/// <remarks>
/// <para>
/// The modifier click, and the assertion that matters is the one about the selection: a Ctrl-click that
/// also moved the <c>ListBox</c>'s own mark would light the card it had just unticked and open the drawer
/// on a machine somebody is removing from a set. Stopping that is why the press is handled on the way
/// down rather than acted on as it bubbles.
/// </para>
/// <para>
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
/// card is in play and every command on this screen is about the same host.
/// </para>
/// </remarks>
[Fact]
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
{
await OnTheGridAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var second = Row(vault, "stage-web");
vault.SelectedHost = first;
Dispatcher.UIThread.RunJobs();
Click(CardFor(screen, second), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1);
second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
screen.HostGrid.SelectedItem.ShouldBeSameAs(first, "and the list was never told otherwise");
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
Click(CardFor(screen, first), window);
vault.IsChoosingHosts.ShouldBeFalse("a plain click drops the set");
vault.SelectedHost.ShouldBeSameAs(first, "and selects the card it landed on, as it always has");
});
}
/// <remarks>
/// The run is measured from the anchor every time rather than added to, which is what makes a Shift-click
/// that overshot recoverable by clicking nearer — the behaviour every list of this kind has. The order is
/// the grid's own, so "between" means between as the cards are laid out.
/// </remarks>
[Fact]
public async Task ShiftClickingTicksTheRunBetweenTheTwoCards()
{
await AddHostAsync("dev-box");
await OnTheGridAsync((screen, window) =>
{
var order = vault.VisibleHosts.ToList();
order.Count.ShouldBe(3, "three cards, so a run can have something in the middle of it");
Click(CardFor(screen, order[0]), window);
Click(CardFor(screen, order[2]), window, RawInputModifiers.Shift);
vault.ChosenHostCount.ShouldBe(3);
order.ShouldAllBe(row => row.IsChosen);
Click(CardFor(screen, order[1]), window, RawInputModifiers.Shift);
vault.ChosenHostCount.ShouldBe(2);
order[2].IsChosen.ShouldBeFalse("the run is re-measured from the anchor, not extended");
});
}
/// <summary>
/// The band, dragged out over the space around the cards.
/// </summary>
/// <remarks>
/// <para>
/// It starts below the cards rather than on one, which is the whole rule: a press on a card is a
/// selection or the start of a drag of that host, and the band is what the space between and below them
/// is for. The rectangle ticks what it touches rather than what it swallows — a card is 232 pixels wide,
/// and a band that had to contain one would tick nothing at all when drawn down a column.
/// </para>
/// <para>
/// The second half is the same press without the drag: a click on the empty space is how somebody who
/// never finds Esc gets out of a selection.
/// </para>
/// </remarks>
[Fact]
public async Task ABandDraggedOverTheCardsTicksThemAndAClickOnNothingDropsThem()
{
await OnTheGridAsync((screen, window) =>
{
var first = CardFor(screen, Row(vault, "prod-db"));
var last = CardFor(screen, Row(vault, "stage-web"));
var topLeft = Corner(first, window);
var bottomRight = Corner(last, window)
+ new Point(last.Bounds.Width, last.Bounds.Height);
// Below every card, so the press lands on the scroller rather than on a list item.
var from = bottomRight.WithY(bottomRight.Y + 24);
var to = topLeft + new Point(2, 2);
// The button has to be named on the moves as well as on the press: a headless move carries the
// button state in its modifiers, and one sent without it is the pointer being let go of.
window.MouseDown(from, MouseButton.Left);
window.MouseMove(new Point(to.X, from.Y), RawInputModifiers.LeftMouseButton);
window.MouseMove(to, RawInputModifiers.LeftMouseButton);
Dispatcher.UIThread.RunJobs();
vault.ChosenHostCount.ShouldBe(2, "the band was over both cards");
window.MouseUp(to, MouseButton.Left);
vault.ChosenHostCount.ShouldBe(2, "and letting go keeps what it was over");
window.MouseDown(from, MouseButton.Left);
window.MouseUp(from, MouseButton.Left);
Dispatcher.UIThread.RunJobs();
vault.IsChoosingHosts.ShouldBeFalse("a press and a release with no band between them is a click");
});
}
/// <summary>
/// One menu with two halves, and which half is drawn is whether anything is ticked.
/// </summary>
/// <remarks>
/// This is the multi-card version of the mistake the first test in this file exists for. The entries that
/// act on the vault's selection and the entries that act on the set are in one markup, so the thing that
/// must never happen is both being offered at once: a Delete… asking about the card under the pointer
/// while six sit ticked behind the menu is the wrong machine deleted, arrived at from the other
/// direction. A right click outside the set is what drops it, so the two are never both meaningful.
/// </remarks>
[Fact]
public async Task TheMenuIsAboutTheSetWhileOneIsUpAndAboutTheCardOtherwise()
{
await OnTheGridAsync((screen, window) =>
{
var ticked = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
Click(CardFor(screen, ticked), window, RawInputModifiers.Control);
RightClick(CardFor(screen, ticked), window);
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
vault.ChosenHostCount.ShouldBe(1, "a right click inside the set leaves it alone");
var offered = menu.Items.OfType<MenuItem>().Where(item => item.IsVisible).ToList();
offered.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteChosenHostsCommand));
offered.ShouldNotContain(
item => ReferenceEquals(item.Command, vault.DeleteHostCommand),
"the entries about the selection are not offered beside the entries about the set");
menu.Close();
RightClick(CardFor(screen, other), window);
vault.IsChoosingHosts.ShouldBeFalse("a right click on a card outside the set drops it");
vault.SelectedHost.ShouldBeSameAs(other, "and aims the ordinary menu, as it always has");
menu.Items.OfType<MenuItem>()
.Where(item => item.IsVisible)
.ShouldContain(item => ReferenceEquals(item.Command, vault.DeleteHostCommand));
});
}
/// <remarks>
/// Ctrl+A is every card <em>on the screen</em> and not every host in the keychain, which is the
/// difference that matters the moment there is something in the find box: a shortcut that quietly ticked
/// the machines it is not showing would be the worst possible input to Delete. Esc is the way back out,
/// and the grid is where both are handled — Ctrl+A in the find box above has to go on selecting text.
/// </remarks>
[Fact]
public async Task CtrlAChoosesEveryCardOnTheScreenAndEscapeDropsThem()
{
await AddHostAsync("dev-box");
vault.HostFilter = "prod";
await OnTheGridAsync((screen, window) =>
{
vault.VisibleHosts.Count.ShouldBe(1, "the filter is what makes this test about the screen");
screen.HostGrid.Focus();
Dispatcher.UIThread.RunJobs();
screen.HostGrid.IsFocused.ShouldBeTrue("the keys are the grid's");
window.KeyPressQwerty(PhysicalKey.A, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the one card being drawn, not the three hosts there are");
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
vault.IsChoosingHosts.ShouldBeFalse();
});
}
/// <remarks>
/// The drag carries a list because a drag that starts on a ticked card carries every ticked card, and the
/// group cards have to answer for the whole of it. Refused only when there is nothing in it left to file:
/// a set with one host from somewhere else in it is a real move, and offering the "no" cursor for it
/// would be a drop the user can see the point of and cannot make.
/// </remarks>
[Fact]
public async Task AGroupCardTakesAWholeSetOfDraggedHosts()
{
await OnTheGridAsync((screen, _) =>
{
IReadOnlyList<HostRowViewModel> set = [Row(vault, "prod-db"), Row(vault, "stage-web")];
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, set));
var onto = screen.GroupGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
var over = Over(onto, carried);
over.DragEffects.ShouldBe(DragDropEffects.Move, "both of them can be filed there");
});
}
// ---- Helpers ----
/// <summary>The same in-process format the screen's own drag carries.</summary>
@@ -582,8 +826,8 @@ public sealed class HostGridTests : IAsyncLifetime
/// the name and the type — and a test holding the screen's own field would go on passing if the screen
/// started carrying something else under it.
/// </remarks>
private static readonly DataFormat<HostRowViewModel> HostFormat =
DataFormat.CreateInProcessFormat<HostRowViewModel>("dodossh-host-row");
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
/// <summary>Holds a dragged host over one control and returns what the screen said about it.</summary>
/// <remarks>
@@ -610,6 +854,26 @@ public sealed class HostGridTests : IAsyncLifetime
((Window)window).MouseUp(at, MouseButton.Right);
}
/// <summary>A press and a release on one card, with whatever was being held down at the time.</summary>
/// <remarks>
/// Both halves, because the two say different things here: the press is where a modifier is read and
/// where the set is dropped, and the release is where a press on a ticked card that turned out not to be
/// a drag collapses onto it.
/// </remarks>
private static void Click(Visual card, Window window, RawInputModifiers held = RawInputModifiers.None)
{
var at = Centre(card, window);
window.MouseDown(at, MouseButton.Left, held);
window.MouseUp(at, MouseButton.Left, held);
Dispatcher.UIThread.RunJobs();
}
private static Point Corner(Visual control, Visual window) =>
control.TranslatePoint(default, window)
?? throw new InvalidOperationException("the control is not in this window's tree");
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -677,6 +941,18 @@ public sealed class HostGridTests : IAsyncLifetime
await vault.SaveGroupCommand.ExecuteAsync(null);
}
/// <summary>One more machine, the way somebody adds one: through the editor.</summary>
private async Task AddHostAsync(string label)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
}
/// <remarks>Two hosts and a group, so there is a heading in the list and a selection to move off.</remarks>
private async Task SeedAsync()
{
@@ -630,6 +630,115 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
}
// ---- ◆ The hosts screen with a set of cards ticked ----
//
// Ctrl, Shift and a band put the phone's chosen-hosts set on this screen — see HostsScreen.axaml.cs — and
// with it come one strip and three panels that had never been drawn in a window. All four sit between the
// HOSTS heading and the grid, so every one of them shortens the grid rather than overflowing it; that is
// the property these measure. The gestures themselves are HostGridTests'.
/// <remarks>
/// The strip: a count, CLEAR, and the sentence saying the actions are on the menu — squeezed between the
/// heading and the grid's own count on the same row. It is the one thing on this screen whose width is
/// set by nothing but its text, so what is really being measured is that the sentence trims instead of
/// running out over the number at the far end. Measured with the drawer open, which is the width at which
/// it does not fit and has to.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileCardsAreTicked()
{
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
vault.IsChoosingHosts.ShouldBeTrue("the strip is only drawn while something is ticked");
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with two cards ticked"));
}
/// <summary>
/// The tallest of the three panels: the vault picker with the key question under it.
/// </summary>
/// <remarks>
/// <para>
/// A heading, a picker, a wrapping paragraph naming everything that travels and everything that does not,
/// a tick with a second wrapping sentence beside it, and two buttons — all above the group cards and the
/// grid, which still have to fit under it.
/// </para>
/// <para>
/// The host is given a key first, because the tick is drawn only for a move of exactly one host that has
/// something to bring; without that this would measure the short shape and say the long one fits. The
/// panel is opened by hand rather than through <c>MoveChosenHostsToVault</c> for the reason the group
/// move test gives: this fixture's account holds one vault, and the command declines rather than open a
/// picker with nothing in it.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheChosenHostsVaultPanelOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.SshKey);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
vault.ChosenHostVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
vault.SelectedChosenHostVault = vault.ChosenHostVaultChoices[0];
vault.IsSendingChosenHostsToAVault = true;
vault.HasAChosenBindingToBring
.ShouldBeTrue("the key question is the part of this panel worth measuring");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's vault panel up"));
}
/// <remarks>
/// The group picker over the set, which is the same panel the phone draws and the same write a set
/// dragged onto a group card makes. Shorter than the vault panel above and drawn in the same place, so
/// what this adds is the picker being filled from one keychain's groups rather than from nothing.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheChosenHostsGroupPanelOpen()
{
await SeedGroupsAsync(3);
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
vault.ChosenHostGroupChoices.Count.ShouldBeGreaterThan(1, "no group, and the three seeded ones");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's group panel up"));
}
/// <remarks>
/// One question for the whole set, drawn by the same card the group deletion above uses. The count is
/// what makes it a confirmation somebody reads rather than one they press past, and the consequence line
/// wraps — which is the part a narrower column would push out of the window.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileTheChosenHostsAreBeingDeleted()
{
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
vault.ToggleHostChoiceCommand.Execute(vault.Hosts[1]);
vault.DeleteChosenHostsCommand.Execute(null);
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue("the question has to be up for this to measure it");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the set's deletion question up"));
}
// ---- The vault screen ----
[Fact]
@@ -5312,6 +5312,100 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
}
/// <summary>
/// A question about six hosts does not survive the set becoming seven.
/// </summary>
/// <remarks>
/// The question names a count and the run that answers it reads the set again, and the panel is drawn
/// above the list rather than over it — deliberately, so the ticked rows stay in view — which leaves
/// every one of them still tickable while it is up. One more tick between the question and the answer
/// used to delete a machine nobody had been asked about. The desktop is where this became easy: a
/// Ctrl-click or a band is a second's work. See <c>VaultViewModel.deletionAskedAbout</c>.
/// </remarks>
[Fact]
public async Task TickingAnotherHost_DropsTheDeletionQuestionAlreadyOnScreen()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "prod-web");
await AddHostAsync(vault, "staging");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
vault.DeleteChosenHostsCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 2 hosts?");
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
vault.IsConfirmingChosenHostDeletion
.ShouldBeFalse("the question was about two of them and there are three now");
vault.IsChoosingHosts.ShouldBeTrue("the set is what was just chosen, so it stays");
vault.DeleteChosenHostsCommand.Execute(null);
vault.PendingDeletion.ShouldNotBeNull().Question.ShouldBe("Delete these 3 hosts?");
// And unticking back to the set it was asked about does not bring a stale question back up.
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
}
/// <summary>
/// The desktop's drag, once more than one card is ticked.
/// </summary>
/// <remarks>
/// <para>
/// Dragging one host onto a group card has always been <c>MoveHostToGroup</c>; a set dragged onto one has
/// to file all of it, because 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. It
/// is the picker's write with the picker skipped — see <c>ConfirmRegroupChosenHosts</c>, which shares it.
/// </para>
/// <para>
/// The refusal is the one <c>RefusesTheDrop</c> makes for a single card, made once for the set: a drop is
/// a gesture on the grid, and rewriting a host under a half-typed edit of it is a save nobody asked for
/// and could not then cancel.
/// </para>
/// </remarks>
[Fact]
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "prod-web");
await AddHostAsync(vault, "staging");
await AddGroupAsync(vault, "production");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-web"));
var card = vault.Groups.Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.NewHostCommand.Execute(null);
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
Host(vault, "prod-db").Host.GroupId.ShouldBeNull("nothing is written under an open editor");
vault.Status.ShouldNotBeEmpty("and it says which editor is in the way");
vault.CancelEditCommand.Execute(null);
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
vault.IsChoosingHosts.ShouldBeFalse("the run finishes by leaving selection mode");
}
/// <remarks>
/// Duplicating keeps the group and the tags, which is the whole difference between it and a copy into
/// another vault: the copy stays in the same keychain, so everything it points at is still there.