Public Access
Stop making people wait for a handshake, and give the host list a pointer
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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user