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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user