Give the phone a selection instead of a card under the list

A long press on a host raised a connect card over the bottom of the list: a
password box, CONNECT, EDIT, MOVE and DELETE. It was the right idea in the wrong
place. It covered rows, it had room for five things and never a sixth, and every
one of them was about exactly one machine — so filing eleven imported hosts under
a group was eleven trips through a form, and there was nowhere to put a sixth
action if anybody wanted one.

A long press now chooses the host it landed on, and the actions move into a bar
across the top of the screen, in the vault header's place rather than beside it.
That is where Android has put them since contextual action bars existed, and it
is the one strip a list can never grow into — but the real reason for it is that
while it is up the screen is unambiguously about the ticked hosts and nothing
else, which is what lets the count in the middle of it mean something. Left to
right: the cross that leaves the mode, the count, the pencil, and a ⋯ holding
Connect, Connect via SFTP, Move to vault, Copy to vault, Change group, Duplicate
and Remove.

A tap still connects and still raises nothing. Once anything is ticked it ticks
and unticks instead, which is what every Android list does and is not merely a
convention worth following: a tap that connected while five machines sat ticked
would open a terminal on top of a selection somebody was halfway through
building. Unticking the last host leaves the mode, so there are two ways out of
it and the cross is only one of them.

Both gestures now read the row from the element under the finger rather than from
the list's selection, and that is a correctness change rather than tidying. A tap
on a group heading moves the selection and the view model bounces it straight back
to whichever host was chosen before — which answered "a host, or nothing" for free
while a tap only ever connected. It stops answering it the moment a tap can tick
one: the heading would tick a machine the user was not pointing at, into a set
they are about to delete.

Three of the seven entries are about one machine and are drawn only for one. A
terminal, a file-transfer session and a form each have no reading over six, so
they are collapsed rather than refused. The other four read better for a count
than without one — it is the reason the set exists — and each of them says
afterwards how many hosts it wrote and how many it left alone. Skipping beats
refusing the whole run: a selection of eleven with one read-only row would
otherwise do nothing at all and then report about the wrong ten.

Copy to vault and Duplicate are new, and the difference between them is what each
can safely carry. A copy crosses a key boundary, so it drops the group and the
tags exactly as a move does — both are items of the vault being left, and a host
arriving with either would point at something the destination does not contain,
resolvable on the machine that sent it and dangling for everybody else. A
duplicate stays in the same keychain, so everything it points at is still there
and it keeps both. Change group is the write dragging a card onto a group already
makes on the desktop, run over a selection; it refuses one spanning two keychains
rather than half-filing it, which is the refusal a drop across that boundary
already makes one host at a time.

Connect via SFTP is the one action that leaves the vault. Which machine is a
decrypted item and so is this object's business; the screen it leads to and the
transfers view model behind it are the shell's — so it is an event, on the same
division SessionOpened already draws for a shell. The host is re-found in that
screen's own copy of the list, because the picker binds to rows in that copy and
handing it the vault's object would select nothing.

What is left of the card is the password box, and only because it had nowhere
else to go: a host that authenticates with a typed password cannot be reached by
a tap alone. That tap now raises a sheet rather than the bar, and the difference
is that a sheet is up only while a question is on screen — the bar was raised by
a long press and stayed, so it was a password box sitting over the list whether or
not anything was being asked. Dismissing it empties the box, which is not tidiness
either: a secret left behind would satisfy the emptiness check that decides
whether to raise the sheet at all, so the next tap would dial with somebody else's
password.

The pencil moving into that bar takes the host editor with it. It was a card in
the list's own row, under the search box and the sync line — twenty controls
sharing a screen with two rows of chrome about the list it had replaced. It is a
page now, and PhoneShell stands all four of its rows down for it, which is what
"opens with all the options" means at 360dp. That needed a second subscription in
that control: two of its flags are questions about the vault rather than about the
shell, and the shell does not forward the vault's notifications.

The ticks are held as entity ids rather than as rows, and written back onto the
rows after every reload. Every row object in the list is replaced on every filter
keystroke and every synchronisation pass, so a set of rows would empty itself once
a minute under somebody choosing what to do with eleven machines. Ids that no
longer resolve are dropped, so a colleague's deletion arriving mid-selection
leaves a count that matches what is on screen.

One caller had to change with it. ConnectToRecent opened the pane about a host,
which was the desktop's drawer and the phone's card; the phone's answer is now a
tick, and nothing on that list means "selected" any more — so arriving with the
host merely selected would be arriving at a screen with nothing to press. Both are
raised together, and the one the head in front of the user does not draw is inert.
This commit is contained in:
2026-08-06 09:15:37 +02:00
parent 174ef7c420
commit c882fa0cd3
11 changed files with 2669 additions and 722 deletions
+352 -131
View File
@@ -4768,53 +4768,14 @@ public sealed class ShellFlowTests : IAsyncLifetime
host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
}
[Fact]
public async Task TheConnectBar_GoesAwayWhileAnEditorIsUpRatherThanGreyingOut()
{
// The editors replace the list rather than floating over it, so a bar left in place would carry
// CONNECT and EDIT for a host that is no longer on screen — and under the host editor, for the very
// record being typed into. This was disabled rather than hidden first, which reads as a screen that
// has broken rather than one that is busy.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
// The long press, which is the only thing that raises this bar. Selecting used to be enough.
vault.OpenHostPaneCommand.Execute(Host(vault, "prod-db"));
vault.ShowsConnectBar.ShouldBeTrue();
vault.OpenAddSheetCommand.Execute(null);
vault.ShowsConnectBar.ShouldBeFalse("the sheet is over the list");
vault.NewHostCommand.Execute(null);
vault.ShowsConnectBar.ShouldBeFalse("and the editor is in place of it");
vault.CancelEditCommand.Execute(null);
vault.ShowsConnectBar.ShouldBeTrue("and it comes back with the list");
}
[Fact]
public async Task TheConnectBar_StaysAwayWithNoHostChosen()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = null;
vault.ShowsConnectBar.ShouldBeFalse();
}
/// <remarks>
/// ◆ <b>The gesture split, from the side that costs something to get wrong.</b> Choosing a host used to
/// raise the bar — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list — which
/// meant a tap on a machine's name put five controls in the way of the one thing it obviously means.
/// A tap connects now, and this pins that it raises nothing on the way past.
/// raise a connect card — a password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list —
/// which meant a tap on a machine's name put five controls in the way of the one thing it obviously
/// means. A tap connects now, and this pins that it raises nothing on the way past.
/// </remarks>
[Fact]
public async Task ATapOnAHost_ConnectsAndLeavesTheBarWhereItWas()
public async Task ATapOnAHost_ConnectsAndRaisesNothing()
{
var vault = await ReadyToConnectAsync();
@@ -4829,17 +4790,18 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("Connected", Case.Insensitive);
vault.SelectedHost.ShouldNotBeNull("the row a tap landed on is what was connected to");
vault.ShowsConnectBar.ShouldBeFalse("and nothing was raised over the list to do it");
vault.IsAskingForConnectPassword.ShouldBeFalse("and nothing was raised over the list to do it");
vault.IsChoosingHosts.ShouldBeFalse("a tap is not a way into selection mode");
}
/// <remarks>
/// The one tap that cannot finish, and the reason the bar still exists. A host that authenticates with a
/// typed password has nowhere to be given one from a list, so the tap raises the bar with the box in it
/// and says so. What it must never do is connect with no password, or leave somebody tapping a row that
/// silently does nothing.
/// The one tap that cannot finish, and the whole of what is left of the connect card. A host that
/// authenticates with a typed password has nowhere to be given one from a list, so the tap raises the
/// password sheet and says so. What it must never do is connect with no password, or leave somebody
/// tapping a row that silently does nothing.
/// </remarks>
[Fact]
public async Task ATapOnAHostThatWantsAPassword_RaisesTheBarInsteadOfConnecting()
public async Task ATapOnAHostThatWantsAPassword_RaisesTheSheetInsteadOfConnecting()
{
var vault = await ReadyToConnectAsync();
@@ -4847,13 +4809,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
vault.ShowsConnectBar.ShouldBeTrue("there is nowhere else to type it");
vault.IsAskingForConnectPassword.ShouldBeTrue("there is nowhere else to type it");
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
vault.Status.ShouldContain("password");
ssh.Requests.ShouldBeEmpty("nothing was dialled with no password");
// The second tap, with the box filled in, is the one that goes through — otherwise the bar would be
// answering the instruction it just gave with the same instruction again.
// The second tap, with the box filled in, is the one that goes through — otherwise the sheet would
// be answering the instruction it just gave with the same instruction again.
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ConnectPassword = "typed-in";
@@ -4861,33 +4823,344 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
ssh.Requests.ShouldHaveSingleItem().Credential.ShouldBeOfType<SshPasswordCredential>();
vault.ShowsConnectBar.ShouldBeFalse("and the bar goes with the tap that succeeded");
vault.IsAskingForConnectPassword.ShouldBeFalse("and the sheet goes with the tap that succeeded");
}
/// <remarks>
/// The pencil in the phone's header, which is where EDIT went when the bar stopped being raised by
/// choosing a machine. Both halves matter: it is offered for a chosen host, and it is taken away while an
/// editor is up — a pencil that opens the form already on screen is a control with nothing to do.
/// Dismissing takes the typed password with it, which is the same bargain the files screen's own picker
/// makes: a secret left in the box would be somebody else's password sitting in the field the next tap
/// reads — and, worse, it would satisfy the emptiness check that decides whether to raise the sheet at
/// all, so the next tap would dial with it.
/// </remarks>
[Fact]
public async Task TheHeaderPencil_IsOfferedForAChosenHostAndNotOverAnEditor()
public async Task DismissingThePasswordSheet_EmptiesTheBox()
{
var vault = await ReadyToConnectAsync();
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts[0]);
vault.ConnectPassword = "half-typed";
vault.RemembersConnectPassword = true;
vault.CancelConnectPasswordCommand.Execute(null);
vault.IsAskingForConnectPassword.ShouldBeFalse();
vault.ConnectPassword.ShouldBeEmpty();
vault.RemembersConnectPassword.ShouldBeFalse("and the tick beside it is not carried either");
}
// ---- ◆ Choosing hosts, and the seven things the action bar does to them ----
//
// The connect card is gone and a long press chooses instead. What these pin is the shape of that: the
// set survives the things that used to empty it, the entries that are about one machine are offered only
// for one, and every run over the set says what it left alone.
/// <remarks>
/// The two gestures, from the side the phone drives them. A long press adds rather than toggling — a
/// second one on a machine somebody is holding down on must not take the tick off — and a tap toggles
/// once the mode is up. Emptying the set leaves the mode, which is the other way out of it.
/// </remarks>
[Fact]
public async Task ALongPressChoosesAHost_AndTapsTickAndUntickFromThereOn()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "staging");
vault.IsChoosingHosts.ShouldBeFalse();
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.IsChoosingHosts.ShouldBeTrue();
vault.ChosenHostCount.ShouldBe(1);
vault.HasOneChosenHost.ShouldBeTrue();
Host(vault, "prod-db").IsChosen.ShouldBeTrue("the tick is drawn on the row");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ChosenHostCount.ShouldBe(1, "a second long press on the same row is not an untick");
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
vault.ChosenHostCount.ShouldBe(2);
vault.HasOneChosenHost.ShouldBeFalse("neither the pencil nor CONNECT is about two machines");
vault.ToggleHostChoiceCommand.Execute(Host(vault, "staging"));
vault.ToggleHostChoiceCommand.Execute(Host(vault, "prod-db"));
vault.IsChoosingHosts.ShouldBeFalse("unticking the last one leaves selection mode");
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
}
/// <remarks>
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
/// somebody choosing what to do with eleven machines.
/// </remarks>
[Fact]
public async Task TheChosenHosts_SurviveTheListBeingRebuilt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "staging");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.SyncCommand.ExecuteAsync(null);
vault.ChosenHostCount.ShouldBe(1, vault.Status);
Host(vault, "prod-db").IsChosen.ShouldBeTrue("written back onto the row the reload made");
Host(vault, "staging").IsChosen.ShouldBeFalse();
}
/// <remarks>
/// The cross at the left of the bar, and everything it has to take with it: a picker asking which vault
/// to move nothing to is not a state worth having.
/// </remarks>
[Fact]
public async Task ClearingTheChoice_TakesTheMenuAndItsPanelsWithIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = null;
vault.CanEditSelectedHost.ShouldBeFalse("there is nothing for it to be about");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.OpenHostActionSheetCommand.Execute(null);
vault.DeleteChosenHostsCommand.Execute(null);
vault.SelectedHost = Host(vault, "prod-db");
vault.CanEditSelectedHost.ShouldBeTrue("and a tap is enough — it does not need the bar");
vault.IsHostActionSheetOpen.ShouldBeFalse("choosing an entry lowers the menu");
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
vault.AChosenHostPanelIsOpen.ShouldBeTrue();
vault.ShowsAddButton.ShouldBeFalse("and the + stands down under a question");
vault.EditSelectedHostCommand.Execute(null);
vault.CanEditSelectedHost.ShouldBeFalse("the form it opens is already on screen");
vault.ClearHostChoiceCommand.Execute(null);
vault.IsChoosingHosts.ShouldBeFalse();
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse();
vault.ShowsAddButton.ShouldBeTrue();
}
/// <remarks>
/// The pencil at the right of the bar. It edits the one ticked host and leaves selection mode, because
/// the editor is a page over the list and a bar counting hosts above a form about one of them would be
/// two answers to what the screen is about.
/// </remarks>
[Fact]
public async Task ThePencil_OpensTheEditorOnTheOneChosenHostAndLeavesSelectionMode()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "staging");
vault.ChooseHostCommand.Execute(Host(vault, "staging"));
vault.EditChosenHostCommand.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe("staging");
vault.IsChoosingHosts.ShouldBeFalse();
vault.CancelEditCommand.Execute(null);
vault.CanEditSelectedHost.ShouldBeTrue();
// Two ticked, and the pencil has nothing to be about — the bar collapses it rather than refusing it,
// but the command has to agree or a stale binding would open the editor on a guess.
vault.ChooseHostCommand.Execute(Host(vault, "staging"));
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.EditChosenHostCommand.Execute(null);
vault.IsEditing.ShouldBeFalse("there is no sensible reading of editing two machines");
}
/// <remarks>
/// Filing is the reason the set is worth having: thirty imported machines under one heading used to be
/// thirty rounds of open, pick, save. It is the same write dragging a card onto a group makes on the
/// desktop, run over the whole selection.
/// </remarks>
[Fact]
public async Task ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce()
{
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"));
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
var group = vault.Groups.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal));
Host(vault, "prod-db").Host.GroupId.ShouldBe(group.EntityId, vault.Status);
Host(vault, "prod-web").Host.GroupId.ShouldBe(group.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.
/// </remarks>
[Fact]
public async Task DuplicatingTheChosenHosts_WritesACopyBesideEachOne()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.DuplicateChosenHostsCommand.ExecuteAsync(null);
vault.Hosts.Count.ShouldBe(2, vault.Status);
var copy = vault.Hosts.Single(row => row.Label.EndsWith("copy", StringComparison.Ordinal));
copy.Label.ShouldBe("prod-db copy");
copy.Host.Hostname.ShouldBe(Host(vault, "prod-db").Host.Hostname);
copy.Host.GroupId.ShouldNotBeNull("a duplicate stays on the shelf it was made from");
}
/// <remarks>
/// One question naming a count, rather than one question per host: six copies of "delete prod-db?" is
/// not a confirmation anybody reads. What is pinned as well is that the question is answerable — the
/// panel it is drawn in is above the list rather than in place of it.
/// </remarks>
[Fact]
public async Task RemovingTheChosenHosts_AsksOnceAndThenTakesThemAll()
{
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.IsConfirmingChosenHostDeletion.ShouldBeTrue();
vault.PendingDeletion!.Question.ShouldContain("2 hosts");
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Hosts.ShouldHaveSingleItem(vault.Status).Label.ShouldBe("staging");
vault.IsChoosingHosts.ShouldBeFalse();
}
/// <remarks>
/// KEEP leaves everything alone, including the ticks: the question was about the selection and declining
/// it is not a reason to throw the selection away.
/// </remarks>
[Fact]
public async Task KeepingTheChosenHosts_LeavesTheTicksWhereTheyWere()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.DeleteChosenHostsCommand.Execute(null);
vault.CancelDeleteCommand.Execute(null);
vault.Hosts.ShouldHaveSingleItem();
vault.IsChoosingHosts.ShouldBeTrue();
vault.ChosenHostCount.ShouldBe(1);
}
/// <remarks>
/// With one writable keychain there is nowhere to send anything, and the honest answer is a sentence
/// rather than an empty picker. It is also what somebody in a team whose only other vault is read-only
/// sees. The two-vault path is <c>VaultSharingTests</c>' job, which is where a second vault exists.
/// </remarks>
[Fact]
public async Task MovingTheChosenHostsWithNowhereToPutThem_SaysSoRatherThanOpeningAnEmptyPicker()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.MoveChosenHostsToVaultCommand.Execute(null);
vault.IsSendingChosenHostsToAVault.ShouldBeFalse();
vault.Status.ShouldContain("only keychain");
vault.IsChoosingHosts.ShouldBeTrue("and the selection is left alone to be used for something else");
}
/// <remarks>
/// The bar's own CONNECT, which is the entry a tap already is — it is in the menu because the bar is
/// what a long press leaves you in, and without it connecting to the machine you had just chosen would
/// mean leaving selection mode first.
/// </remarks>
[Fact]
public async Task ConnectingFromTheActionBar_OpensTheHostAndLeavesSelectionMode()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ChooseHostCommand.Execute(vault.Hosts[0]);
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
vault.Status.ShouldContain("Connected", Case.Insensitive);
vault.IsChoosingHosts.ShouldBeFalse();
}
/// <remarks>
/// ◆ <b>"Connect via SFTP", which crosses from the vault to the shell.</b> Which machine is a decrypted
/// item and so is the vault's; the screen it leads to and the transfers view model behind it are the
/// shell's. This pins the join — the host arrives chosen in the file screen's own list, which is a copy
/// rebuilt from the vault's, so handing it the vault's row object would select nothing.
/// </remarks>
[Fact]
public async Task BrowsingAChosenHost_GoesToTheFilesScreenWithThatHostChosen()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.BrowseChosenHostCommand.Execute(null);
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
vault.IsChoosingHosts.ShouldBeFalse();
// A host with no key and no credential wants a typed password, and there is nowhere on a list to
// give it one — so the picker opens with the machine already chosen and the box beside it, which is
// the same branch a tap on the hosts screen makes.
shell.Transfers.IsChoosingRemote.ShouldBeTrue();
shell.Transfers.Status.ShouldContain("password");
}
[Fact]
@@ -5058,86 +5331,34 @@ public sealed class ShellFlowTests : IAsyncLifetime
// ---- Deleting a host, from the phone's bar ----
/// <remarks>
/// <para>
/// The phone's bar carries three things and draws one of them: connecting, the picker asking which vault
/// to move the host to, and this question. What is pinned here is that the question <em>takes</em> the
/// controls rather than appearing under them — DELETE pressable a second time underneath its own
/// confirmation is the reason the desktop's drawer has the same rule.
/// </para>
/// <para>
/// The bar itself stays up, and that is the other half: it is where the question is drawn. A question in
/// a bar that had collapsed would be a question nobody could answer.
/// </para>
/// ◆ <b>The three panels the action bar's menu can raise, and the rule that at most one is up.</b> They
/// are drawn above the list rather than over it — the ticked rows are the information the question exists
/// to give — so each one has to disarm the other two on the way up, or two questions about the same six
/// machines would be stacked, one of them destructive.
/// </remarks>
[Fact]
public async Task DeletingAHostFromThePhonesBar_TakesTheControlsWhileItAsks()
public async Task TheActionBarsPanels_TakeEachOthersPlaceRatherThanStacking()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
vault.ShowsConnectBar.ShouldBeTrue();
vault.ShowsConnectControls.ShouldBeTrue();
vault.DeleteChosenHostsCommand.Execute(null);
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
vault.DeleteHostCommand.Execute(null);
vault.RegroupChosenHostsCommand.Execute(null);
vault.IsConfirmingHostDeletion.ShouldBeTrue();
vault.ShowsConnectBar.ShouldBeTrue("the bar is where the question is drawn");
vault.ShowsConnectControls.ShouldBeFalse("so DELETE cannot be pressed under its own question");
vault.IsRegroupingChosenHosts.ShouldBeTrue(vault.Status);
vault.IsConfirmingChosenHostDeletion.ShouldBeFalse("the question was disarmed on the way up");
vault.CancelDeleteCommand.Execute(null);
vault.DeleteChosenHostsCommand.Execute(null);
vault.ShowsConnectControls.ShouldBeTrue();
vault.Hosts.ShouldHaveSingleItem("KEEP keeps it");
}
[Fact]
public async Task DeletingAHostFromThePhonesBar_RemovesItOnceItIsAgreedTo()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.OpenHostPaneCommand.Execute(vault.Hosts.Single());
vault.DeleteHostCommand.Execute(null);
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.Hosts.ShouldBeEmpty(vault.Status);
vault.ShowsConnectBar.ShouldBeFalse("and there is nothing left for the bar to be about");
}
/// <remarks>
/// The other panel that takes the bar, asserted here rather than left to the move's own tests: the two
/// share one property, and a change that put the controls back under one of them would be a CONNECT
/// button in a bar that is asking something else.
/// </remarks>
[Fact]
public async Task MovingAHostFromThePhonesBar_TakesTheControlsToo()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = vault.Hosts.Single();
vault.MoveHostCommand.Execute(null);
// One writable vault, so there is nowhere to move it and the panel does not open — which is exactly
// the state that must still leave the controls showing rather than a bar with nothing in it.
vault.IsMovingHost.ShouldBeFalse(vault.Status);
vault.ShowsConnectControls.ShouldBeTrue();
// Set rather than reached through a second vault, which is VaultSharingTests' job and needs a server
// to make one. What is being pinned here is the one line joining the flag to the bar.
vault.IsMovingHost = true;
vault.ShowsConnectControls.ShouldBeFalse();
vault.IsConfirmingChosenHostDeletion.ShouldBeTrue();
vault.IsRegroupingChosenHosts.ShouldBeFalse("and the picker folded away in return");
}
// ---- Tags ----