Stop making people wait for a handshake, and give the host list a pointer
ci / build and test (push) Failing after 3s
ci / android head (push) Failing after 2s

Connecting held the vault's busy gate, which meant a window that did nothing visible for
as long as a machine took to answer — and against one that is merely asleep, that is the
whole timeout. The gate is gone from that one command. A tab now appears in the strip in
the same turn as the click, carrying "connecting…" rather than a pane, and the terminal's
rectangle draws a card naming the host and the address being dialled. Every other screen
stays usable, and two connections can be in flight at once.

That splits the vault's one connection event into three, carrying an attempt id, because
"which tab is this about" can no longer be answered by "the most recent one". The id also
buys the two kinds of not-connecting their different endings: a refusal stays in the strip
as a tab holding its reason, since by then the user is quite likely three screens away and
a status line they are not looking at is not where a failure should end; a host key
question takes the tab away and puts the window back on HOSTS, because the prompt is drawn
there and a tab claiming failure would be competing with the thing about to resume it.

ConnectAsync takes no CancellationToken any more, and that is load-bearing rather than
tidying. A [RelayCommand] over a method that takes one generates a command that cancels
the previous execution's token on every invocation — so asking for a second machine
silently abandoned the first, measured as the first tab disappearing with "Cancelled." the
instant the second was asked for. Giving up on a connection is closing its tab, and a
session that lands after that is adopted rather than dropped: a shell running with nothing
naming it cannot be closed at all.

A tab is marked active on IsShowing rather than IsSelected. The selection survives
navigating away — that is what makes the strip a way back to a terminal instead of a way
to lose one — so a tab lit while preferences filled the window was a second "you are here"
mark pointing at something nobody could see. The nav rail's own entries have always made
this distinction.

The host list grows the two gestures it looked like it already had. A right click selects
the row under the pointer before opening a menu of Connect, Edit and Delete — the menu is
on the list rather than in the item template, so its entries are the vault's own commands
and not a row's, and it is cancelled outright over a group heading. Dragging a host onto a
heading files it there, onto a host files it beside that one, and onto UNGROUPED takes it
out of a group; the write is one field of one host through the same repository a save
uses, refused while the editor is open because a drop is a gesture on the list and not on
a half-typed form.

Clicking a result in the palette connects, which is what a list of hosts under a search
box looks like it does. It went through the shell's own command, so the pointer and Enter
take one path.

And the files screen's two pickers followed the vault's lists once, at unlock: a host or a
bucket created afterwards could not be picked until the keychain had been locked and
opened again, with nothing on screen explaining why the machine plainly in the host list
was missing. They follow the collections now, re-finding the selection by id across the
rebuild a sync pass causes every minute.

165 shell tests and 69 layout tests green, including the connecting tab, both failure
endings, two connections at once, a connection in flight across a lock, and the right
click acting on the row under the pointer rather than on the selection. The drag itself is
in docs/manual-checks.md with the rest of phase 7 — headless Avalonia has no platform
drag, and a test that claimed to have dropped something would pass while confirming
nothing.
This commit is contained in:
2026-07-31 22:59:33 +02:00
parent 7a3a521c59
commit 4300d917a8
21 changed files with 2003 additions and 112 deletions
@@ -0,0 +1,208 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the host list answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures this control rather than driving it. What
/// is here is the one gesture that cannot be expressed as a binding and cannot be checked by measuring: a
/// right click has to move the selection <em>before</em> the menu opens, because all three of that menu's
/// commands read the vault's host selection. A menu that quietly acted on whichever host happened to be
/// selected would delete the wrong machine, which is the version of this mistake worth a suite.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
/// one: compiled bindings resolve against the declared type, and the list is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostSidebarTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"sidebar-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
session = outcome.Session!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
await SeedAsync();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
knownHosts.Close();
await workspace.DisposeAsync();
await vault.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The rule the menu depends on. Without it the three commands would act on whatever was selected
/// before, which for Delete is a question asked about one machine and answered about another.
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheSidebarAsync((sidebar, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(RowFor(sidebar, other), window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = sidebar.HostList.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// 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…");
edit.Command.ShouldBeSameAs(vault.EditSelectedHostCommand);
edit.Command!.Execute(null);
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe(other.Label, "the row that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// A heading is a row in the same list and the control will happily select it, but it is not a host —
/// and a menu offering Connect, Edit and Delete over one would be three entries that either do nothing
/// or act on a machine somewhere else in the list.
/// </remarks>
[Fact]
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
{
await OnTheSidebarAsync((sidebar, window) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = sidebar.HostList
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
RightClick(heading, window);
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
sidebar.HostList.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
// ---- Helpers ----
private static void RightClick(Visual row, Visual window)
{
var at = Centre(row, window);
((Window)window).MouseDown(at, MouseButton.Right);
((Window)window).MouseUp(at, MouseButton.Right);
}
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
}
finally
{
window.Close();
}
},
Token);
private static ListBoxItem RowFor(Visual sidebar, HostRowViewModel host) =>
sidebar.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
/// <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()
{
foreach (var label in new[] { "prod-db", "stage-web" })
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = label;
vault.EditorHostname = $"{label}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
await vault.LoadAsync(Token);
}
}
@@ -215,6 +215,27 @@ public sealed class QuickConnectTests : IAsyncLifetime
});
}
/// <remarks>
/// The pointer's version of Enter, and the gesture a list of hosts under a search box plainly looks like
/// it offers. It did not: a click moved the highlight and left the palette open over a choice that had
/// already been made, so the second thing everybody tried was to click and then press Enter.
/// </remarks>
[Fact]
public async Task ClickingAResultConnectsToIt()
{
await OnThePaletteAsync((palette, window) =>
{
var wanted = shell.SearchResults[2];
var row = RowFor(palette, wanted);
window.MouseDown(Centre(row, window), MouseButton.Left);
window.MouseUp(Centre(row, window), MouseButton.Left);
shell.IsSearching.ShouldBeFalse("connecting closes the palette, as Enter does");
vault.SelectedHost?.EntityId.ShouldBe(wanted.EntityId);
});
}
/// <remarks>
/// The palette is a box somebody is expected to start typing into, and for a while it was not: the window
/// focused it from the view model's <c>PropertyChanged</c>, which runs before the binding that reveals the
@@ -280,6 +301,12 @@ public sealed class QuickConnectTests : IAsyncLifetime
},
Token);
/// <summary>The list row showing one result.</summary>
private static ListBoxItem RowFor(Visual palette, HostRowViewModel host) =>
palette.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
@@ -561,6 +561,37 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
}
// ---- The connecting card ----
//
// It fills the terminal's own rectangle, which is the one part of this window no other test can lay out:
// the WebView it stands in for cannot be attached here at all. That makes it worth measuring for exactly
// the reason the harness exists — its two buttons are the only way out of a connection that is not
// going to happen.
[Fact]
public async Task TheConnectingCardFitsWhileAConnectionIsBeingMade()
{
await MeasureConnectingAsync(
faults => faults.ShouldBeEmpty("while connecting"),
new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22"));
}
/// <remarks>
/// The taller of the two shapes, and the one with something variable in it: a refusal is whatever the
/// SSH layer said, which is a sentence rather than a word.
/// </remarks>
[Fact]
public async Task TheConnectingCardFitsWithARefusalInIt()
{
var tab = new TerminalTabViewModel("customer-production-database-01", "deployment@db.internal:22");
tab.Failed(
"Permission denied (publickey,keyboard-interactive). The server closed the connection after "
+ "three attempts.");
await MeasureConnectingAsync(faults => faults.ShouldBeEmpty("with a refusal to explain"), tab);
}
// ---- The logs screen ----
[Fact]
@@ -1080,6 +1111,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.State = ShellState.Unlocked;
shell.Tabs.Clear();
shell.Tabs.Add(tab);
shell.SelectedTab = tab;
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
+19 -3
View File
@@ -15,6 +15,17 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
/// <summary>Thrown instead of connecting, when set. Used for the host-key paths.</summary>
internal Exception? Failure { get; set; }
/// <summary>
/// Holds a connection open until it is completed, when set.
/// </summary>
/// <remarks>
/// A handshake takes as long as a network takes, and connecting is deliberately no longer allowed to
/// hold the window still while it does — so there is now behaviour that only exists <em>during</em> a
/// connection: a tab in the strip with no session behind it. This is how a test gets to look at that
/// moment rather than at the two on either side of it.
/// </remarks>
internal TaskCompletionSource? Gate { get; set; }
/// <summary>Requests this factory was asked for, in order.</summary>
internal List<SshConnectionRequest> Requests { get; } = [];
@@ -27,15 +38,20 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
internal List<SshConnectionRequest> SftpRequests { get; } = [];
/// <inheritdoc />
public Task<ISshConnection> ConnectAsync(
public async Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
CancellationToken cancellationToken)
{
Requests.Add(request);
if (Gate is { } gate)
{
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
}
return Failure is { } failure
? Task.FromException<ISshConnection>(failure)
: Task.FromResult<ISshConnection>(new FakeSshConnection(request));
? throw failure
: new FakeSshConnection(request);
}
/// <inheritdoc />
@@ -653,6 +653,244 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.IsTerminalShowing.ShouldBeTrue();
}
// ---- Connecting, while it is still happening ----
//
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
// in which a tab exists and its session does not. Everything below is about that stretch: what the strip
// shows, what the window draws in the terminal's rectangle, and what happens to the tab when the
// connection answers — or does not.
/// <remarks>
/// The point of the whole thing, stated as one assertion: the tab is in the strip before the connection
/// has answered, and the vault is not busy while it waits. A user who asked for a machine that is asleep
/// used to get a status line and a window that did nothing for as long as the timeout took.
/// </remarks>
[Fact]
public async Task Connecting_ShowsATabImmediatelyAndLeavesTheVaultUsable()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsConnecting.ShouldBeTrue();
tab.HasSession.ShouldBeFalse();
tab.Label.ShouldBe("prod-db");
tab.Address.ShouldBe("deploy@db.internal:22", "named for what is being dialled, not for what answered");
// The card, not the renderer. They share one rectangle and there is no pane to put in it yet.
shell.SelectedTab.ShouldBe(tab);
shell.IsConnectingShowing.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse();
// The gate this command does not hold. Everything else on this screen still works, which is the
// difference between waiting and being stuck.
vault.IsBusy.ShouldBeFalse();
vault.Hosts.ShouldNotBeEmpty();
ssh.Gate.SetResult();
await connecting;
tab.HasSession.ShouldBeTrue();
tab.IsLive.ShouldBeTrue();
tab.Status.ShouldBeEmpty("the pane speaks for itself from here on");
shell.IsTerminalShowing.ShouldBeTrue();
shell.IsConnectingShowing.ShouldBeFalse();
}
/// <remarks>
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
/// and it stays until it is closed.
/// </remarks>
[Fact]
public async Task ARefusedConnection_LeavesATabCarryingTheReason()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new InvalidOperationException("No route to host.");
await vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsFailed.ShouldBeTrue();
tab.IsLive.ShouldBeFalse();
tab.Status.ShouldBe("No route to host.");
shell.IsConnectingShowing.ShouldBeTrue("the card is where the reason is drawn");
shell.IsTerminalShowing.ShouldBeFalse();
// Closed like any other tab, and without asking the workspace to end a session that never existed.
await shell.CloseTabCommand.ExecuteAsync(tab);
shell.Tabs.ShouldBeEmpty();
shell.IsHostsShowing.ShouldBeTrue();
}
/// <remarks>
/// The other kind of not-connecting. An unknown host key is a question drawn on the hosts screen rather
/// than a failure, so the tab goes and the window is put back where the question is — a tab saying the
/// connection failed would be competing with the prompt that is about to resume it.
/// </remarks>
[Fact]
public async Task AnUnknownHostKey_TakesTheTabAwayAndShowsTheQuestion()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Failure = new SshHostKeyUnknownException(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:unknown"));
shell.ShowScreenCommand.Execute(ShellScreen.Transfers);
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.ShouldBeEmpty();
vault.HasPendingHostKey.ShouldBeTrue();
shell.IsHostsShowing.ShouldBeTrue("the prompt is drawn there, and it has to be reachable");
// And answering it connects, which is the whole reason the tab was not left saying it had failed.
ssh.Failure = null;
await vault.TrustHostKeyCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue();
}
/// <remarks>
/// Giving up on a connection that is still in flight. The tab goes at once — that is what the button
/// promises — and the handshake that finishes afterwards is adopted rather than dropped, because a shell
/// running with nothing in the window naming it is worse than a tab that comes back.
/// </remarks>
[Fact]
public async Task ClosingATabThatIsStillConnecting_TakesItAwayAndKeepsWhateverArrives()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
shell.Tabs.ShouldBeEmpty();
shell.IsHostsShowing.ShouldBeTrue();
ssh.Gate.SetResult();
await connecting;
shell.Tabs.ShouldHaveSingleItem().HasSession.ShouldBeTrue("the session is real, so it gets a tab");
}
/// <remarks>
/// Two at once, which is the other thing not holding the vault made possible — and the reason an attempt
/// carries an id rather than being found by the host's name.
/// </remarks>
[Fact]
public async Task TwoConnectionsCanBeInFlightAtOnce()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await AddHostAsync(vault, "stage-web");
ssh.Gate = new TaskCompletionSource();
vault.SelectedHost = Host(vault, "prod-db");
var first = vault.ConnectCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "stage-web");
var second = vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.Select(tab => tab.Label).ShouldBe(["prod-db", "stage-web"]);
shell.Tabs.ShouldAllBe(tab => tab.IsConnecting);
ssh.Gate.SetResult();
await first;
await second;
shell.Tabs.ShouldAllBe(tab => tab.HasSession);
}
/// <remarks>
/// Locking does not end a handshake any more than it ends a shell, and the tab standing in for one is
/// shell state that survives a lock. So the answer still has to arrive somewhere: without it the tab
/// would say "connecting…" for ever and the session it opened would have nothing naming it, and so no
/// way to be closed.
/// </remarks>
[Fact]
public async Task AConnectionInFlightWhenTheVaultLocks_StillLandsInItsTab()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
await shell.LockCommand.ExecuteAsync(null);
shell.State.ShouldBe(ShellState.Locked);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(tab, "tabs outlive the vault that opened them");
ssh.Gate.SetResult();
await connecting;
tab.HasSession.ShouldBeTrue();
tab.IsLive.ShouldBeTrue();
}
/// <remarks>
/// A tab is marked by whether its terminal is the thing on screen, not by whether it is the selected
/// one — the selection survives navigating away, which is what makes the strip a way back rather than a
/// way to lose a shell. Two "you are here" marks at once is one too many, and the rail's own entries
/// already make the same distinction.
/// </remarks>
[Fact]
public async Task ATabIsMarkedOnlyWhileItsTerminalIsShowing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
ssh.Gate = new TaskCompletionSource();
var connecting = vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
tab.IsShowing.ShouldBeTrue("the connecting card is what the window is showing");
// Navigating away during the connection, which is the case this most exists for: the connection goes
// on, the tab stays selected, and nothing in the strip claims to be on screen.
shell.ShowScreenCommand.Execute(ShellScreen.Vault);
tab.IsShowing.ShouldBeFalse();
tab.IsSelected.ShouldBeTrue("navigating away is not deselecting");
ssh.Gate.SetResult();
await connecting;
tab.IsShowing.ShouldBeFalse("a connection that finishes while you are elsewhere does not grab the window");
shell.SelectTabCommand.Execute(tab);
tab.IsShowing.ShouldBeTrue();
// And the palette, which draws over the same rectangle.
shell.ToggleSearchCommand.Execute(null);
tab.IsShowing.ShouldBeFalse();
}
/// <remarks>
/// A shell outlives a lock, so there can be a selected tab while the unlock card is up. The card and the
/// terminal share a rectangle, and the card is the one that has to win.
@@ -2847,6 +3085,64 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SelectedSidebarRow.ShouldBeSameAs(host, "the heading hands the highlight straight back");
}
/// <remarks>
/// What dragging a row onto a heading does. It is the same write the editor makes — one field of the
/// host, pushed straight away — reached without opening a form, because filing thirty imported machines
/// through the editor is thirty rounds of open, pick, save.
/// </remarks>
[Fact]
public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
var group = vault.Groups.Single().EntityId;
var host = vault.Hosts.Single();
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row");
// Under the group's own heading now, which is the thing the drop was aiming at.
vault.SidebarRows.OfType<SidebarGroupHeader>()
.Single(header => header.GroupId == group)
.Count.ShouldBe(1);
// And back out again, which is what the ungrouped heading is a target for.
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
}
/// <remarks>
/// A drop is a gesture on the list, not on the form. Rewriting the saved host while a half-typed edit of
/// one is open would be a save nobody asked for, and one they could then not cancel.
/// </remarks>
[Fact]
public async Task MovingAHostWhileTheEditorIsOpen_IsRefused()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddGroupAsync(vault, "production");
vault.SelectedHost = vault.Hosts.Single();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(vault.Hosts.Single(), vault.Groups.Single().EntityId));
vault.Hosts.Single().Host.GroupId.ShouldBeNull("nothing was written");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
vault.Status.ShouldContain("editing");
}
/// <remarks>
/// Deleting a group deliberately does not rewrite the hosts in it — one delete would otherwise become N
/// writes, N outbox rows and N chances to merge against a change nobody made — so those hosts keep an id
@@ -3669,17 +3965,28 @@ public sealed class ShellFlowTests : IAsyncLifetime
// ---- File transfer ----
/// <remarks>
/// The list is followed rather than copied at unlock, which is the whole of this test. A snapshot taken
/// when the vault opened meant a host created five seconds later could not be picked here until the
/// keychain had been locked and opened again — and nothing on the screen explained why the machine that
/// was plainly in the host list was missing from the picker.
/// </remarks>
[Fact]
public async Task TheTransfersScreen_TakesItsHostListFromTheUnlockedVault()
public async Task TheTransfersScreen_FollowsTheVaultsHostList()
{
await UnlockedAsync();
shell.Transfers.Hosts.ShouldBeEmpty("the vault has no hosts yet");
await AddHostAsync(shell.Vault!, "prod-db");
// Attached at unlock, after the vault has loaded. Before that ordering was right the picker was
// empty until something else happened to reload it.
shell.Transfers.Hosts.ShouldBeEmpty("the vault had no hosts when it was attached");
shell.Transfers.Hosts.Select(host => host.Label).ShouldBe(["prod-db"]);
shell.Transfers.SelectedHost?.Label.ShouldBe("prod-db", "the only host is the one to offer");
await shell.LockCommand.ExecuteAsync(null);
shell.Transfers.Hosts.ShouldBeEmpty("those rows carry decrypted secrets");
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);