Restyle the drawer, pin folders on a host, and say when it was last connected

This commit is contained in:
2026-08-07 18:12:31 +02:00
parent c3ef4bd8b4
commit 2ba7c14e35
8 changed files with 1392 additions and 202 deletions
@@ -40,9 +40,11 @@ internal static class LayoutHarness
/// <remarks>
/// This was <c>HostSidebarWidth</c> at 268, taken from a column definition on the hosts screen. The
/// drawer states its own width instead — it is the only thing in its column and the column is
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it.
/// <c>Auto</c> — so the number lives on the control now, and this constant follows it. It was 304 through
/// v4; v5 widened it to 320 for the ADDRESS field's own breathing room, and <c>App.axaml</c>'s
/// <c>Border.tile</c> narrowed to keep two columns fitting the grid beside it at the window's minimum.
/// </remarks>
internal const double HostDrawerWidth = 304;
internal const double HostDrawerWidth = 320;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
@@ -141,7 +141,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
//
// This was the host sidebar's section. The control kept the half of that column that is about one host
// and lost the list; see HostDrawer. What it is measured at changed with it: 304 rather than 268, and on
// the right.
// the right. v5 widened it again, to 320, for the ADDRESS field's own breathing room; see
// LayoutHarness.HostDrawerWidth.
/// <remarks>
/// Opened through the command rather than by assigning the selection, which is the whole of what changed
@@ -161,7 +162,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// The tight one, and the reason this suite still exists. The host editor is the tallest thing the
/// drawer holds: six fields, an authentication picker with a two-line item template, a group picker, a
/// wrapped row of tag chips, a checkbox, a paragraph of hint text and three buttons.
/// wrapped row of tag chips, a checkbox, a relay card, QUICK ACCESS's own rows and its add row, a
/// paragraph of hint text and three buttons.
///
/// A pin is staged so QUICK ACCESS draws at least one row rather than only its empty add box — the
/// harness skips the scrolled content's height (see the remark above
/// <see cref="TheHostDrawerFitsWithADeletionInQuestion"/>) but not its width, and a row's own mono path
/// is the one place in this card long text could push the column sideways if TextTrimming ever slipped.
/// </remarks>
[Fact]
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
@@ -177,6 +184,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
vault.AddEditorPinCommand.Execute(null);
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
@@ -226,7 +236,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
/// heading, a combo box, a wrapping paragraph and two buttons, in a 320-pixel column. The paragraph is
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
/// </para>
+522 -119
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
@@ -996,6 +997,126 @@ public sealed class ShellFlowTests : IAsyncLifetime
IsReadOnly: false),
isLive: false);
// ---- Last connected ----
//
// The card's ago-text: VaultViewModel.DescribeElapsed is the pure word-choice, RefreshLastConnectedAsync
// is the log read, and the two triggers below — the hosts screen coming back on screen and a session
// ending on its own — are decision 4's whole "when" in HANDOFF-hosts-v5.md.
/// <remarks>
/// Every boundary the wording changes at: fifty-nine seconds is still "just now" and sixty is the first
/// "1 min ago"; the same shape repeats crossing into hours and into days. A pure function of the gap, so
/// none of this needs a fake clock — see the remark on <c>VaultViewModel.DescribeElapsed</c> itself.
/// </remarks>
[Theory]
[InlineData(0, "just now")]
[InlineData(59, "just now")]
[InlineData(60, "1 min ago")]
[InlineData(150, "2 min ago")]
[InlineData(3599, "59 min ago")]
[InlineData(3600, "1 hr ago")]
[InlineData(7200, "2 hr ago")]
[InlineData(86399, "23 hr ago")]
[InlineData(86400, "1 day ago")]
[InlineData(172800, "2 days ago")]
public void DescribeElapsed_MatchesTheWordACardShouldShowAtEachBoundary(int seconds, string expected) =>
VaultViewModel.DescribeElapsed(TimeSpan.FromSeconds(seconds)).ShouldBe(expected);
/// <remarks>
/// The hosts screen's own activation — one of the two moments <c>VaultViewModel.RefreshLastConnectedAsync</c>
/// is read on. <c>ReadyToConnectAsync</c> already visited this screen once, before the log held anything
/// worth reading, so the log is seeded only afterwards and the screen is left and returned to — the
/// transition <c>MainWindowViewModel.UpdateLastConnectedVisibility</c> actually keys off, rather than the
/// level, which fired already.
/// </remarks>
[Fact]
public async Task ReturningToTheHostsScreen_FillsInLastConnectedFromTheLog()
{
var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0];
await vault.Session.ConnectionLog.CreateAsync(
vault.Session.ActiveVaultId,
new ConnectionLogSecret
{
HostLabel = host.Label,
Address = host.Address,
HostId = host.EntityId,
StartedAt = TimeProvider.System.GetUtcNow().AddDays(-3),
DeviceName = "a workstation",
},
Token);
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
await EventuallyAsync(
() => host.LastConnectedText.Length > 0,
"activating the hosts screen should have read the log");
host.LastConnectedText.ShouldBe("3 days ago");
}
/// <remarks>
/// A host the log has never named. Empty rather than a dash or the word "never" — see
/// <c>HostRowViewModel.LastConnectedText</c>'s own remarks: a host nobody has connected to and a host
/// whose log simply has not been read yet look identical from this row, and neither is a claim it can
/// make on its own.
/// </remarks>
[Fact]
public async Task AHostTheLogHasNeverNamed_ShowsNoAgoText()
{
var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0];
await vault.RefreshLastConnectedAsync(Token);
host.LastConnectedText.ShouldBeEmpty();
}
/// <remarks>
/// Decision 4's second half: a host with a session open right now shows the green dot instead of an
/// ago-text, even with an entry in the log to offer. Printing both would answer the same question twice,
/// and the older answer is the one likeliest to be misread as current.
/// </remarks>
[Fact]
public async Task AConnectedHost_ShowsNoAgoTextEvenWithAnEntryInTheLog()
{
var vault = await ReadyToConnectAsync();
var host = vault.Hosts[0];
await vault.Session.ConnectionLog.CreateAsync(
vault.Session.ActiveVaultId,
new ConnectionLogSecret
{
HostLabel = host.Label,
Address = host.Address,
HostId = host.EntityId,
StartedAt = TimeProvider.System.GetUtcNow().AddMinutes(-5),
DeviceName = "a workstation",
},
Token);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
host.IsConnected.ShouldBeTrue();
await vault.RefreshLastConnectedAsync(Token);
host.LastConnectedText.ShouldBeEmpty("the dot already says this host is open right now");
}
// The other trigger — a session ending on its own — has no test here. It runs inside
// MainWindowViewModel.OnWorkspaceSessionEnded's existing Dispatcher.UIThread.Post, the same one
// RefreshConnectedHosts() already ran inside before this wave touched the method, and this suite has no
// window pumping that dispatcher — see TransferQueueingTests's own remark on why it built a posted-action
// queue rather than depend on Dispatcher.UIThread at all. A test posted there would time out proving
// nothing about the one line this wave added, since the untestable half is wiring this wave did not
// write. The call itself — `_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);`, placed
// directly beside the pre-existing `RefreshConnectedHosts();` — is covered indirectly: it is the same
// VaultViewModel.RefreshLastConnectedAsync the activation test above already exercises.
/// <remarks>
/// The phone's connect menu is drawn over the terminal's own rectangle, so it obeys the rule the palette
/// does: whatever covers the renderer collapses it instead. The surface stays, because the bar the menu
@@ -2774,8 +2895,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "platform");
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
vault.GroupEditorDefaultPort = 2222;
await AddKeyAsync(vault, "deploy");
@@ -4267,17 +4387,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// <para>
/// What dragging a host card onto a group card 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.
/// ◆ v5: dragging a host card onto a group card is gone, so this files through the editor instead — see
/// <see cref="FileAsync"/> — which is the one thing every head can still do. What survives to measure is
/// <see cref="VaultViewModel.Matches"/> and <see cref="VaultViewModel.RebuildVisibleHosts"/>'s own
/// "one level of the tree" filtering, fed by <see cref="VaultViewModel.GroupFilter"/> directly now that
/// nothing sets it through a command — see that property's own remarks.
/// </para>
/// <para>
/// <b>The card goes into the group and off the level it was dragged from</b>, which is the whole of what
/// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was
/// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection
/// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all
/// read that property, and none of them should be aimed at a card that has left the screen. See
/// <c>VaultViewModel.Matches</c> and <c>RebuildVisibleHosts</c>.
/// <b>The card goes into the group and off the level it was filed from</b>, which is the whole of what
/// filing looks like on a grid that holds one level of the tree — the host is inside the card it was
/// filed under now, and that is where it is drawn. It used to stay put and gain a chip.
/// </para>
/// </remarks>
[Fact]
@@ -4290,13 +4409,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
var group = vault.Groups.Single().EntityId;
var host = vault.Hosts.Single();
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group));
await FileAsync(vault, "prod-db", "production");
vault.Hosts.Single().Host.GroupId.ShouldBe(group);
vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group");
vault.SelectedHost.ShouldBeNull("nothing on screen is it any more");
// Under the group's own heading now, which is what the phone's list draws — that list is the whole
// tree flattened, so the host is still in it.
@@ -4310,22 +4427,22 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts.Single().HasGroup.ShouldBeTrue();
// Opening the group is where it went, and the way to it.
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.GroupFilter = vault.Groups.Single();
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
// And back out again, which is what the host's own editor is for now that the drop has one target.
vault.OpenGroupCommand.Execute(null);
// And back out again.
vault.GroupFilter = null;
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null));
vault.SelectedHost = vault.Hosts.Single();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
choice => choice.EntityId is null);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Hosts.Single().Host.GroupId.ShouldBeNull();
vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it");
// Coming out of a group is the direction that lands the host back on this level, so here the
// selection does survive the move.
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <summary>
@@ -4355,11 +4472,11 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.SidebarRows.OfType<HostRowViewModel>().Select(row => row.Label)
.ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened");
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.GroupFilter = vault.Groups.Single();
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]);
vault.OpenGroupCommand.Execute(null);
vault.GroupFilter = null;
vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]);
}
@@ -4392,8 +4509,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
.ShouldBe("prod-db", "two levels down, and the box reaches it");
// And inside a group it is that group's subtree: estate holds production, which holds the host.
vault.OpenGroupCommand.Execute(
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
vault.GroupFilter =
vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
@@ -4427,8 +4544,10 @@ public sealed class ShellFlowTests : IAsyncLifetime
/// <remarks>
/// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created
/// inside a group and filed under none would vanish from the screen it was created on, which is the
/// papercut that comes free with a grid holding one level — so the editor opens on the group the user
/// is standing in, and the picker shows it before anything is saved.
/// papercut that comes free with a grid holding one level — so the editor opens on the group
/// <see cref="VaultViewModel.GroupTarget"/> names, and the picker shows it before anything is saved.
/// <c>GroupFilter</c> is written directly rather than through the deleted <c>OpenGroupCommand</c> — see
/// that property's own remarks.
/// </remarks>
[Fact]
public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt()
@@ -4438,7 +4557,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
vault.OpenGroupCommand.Execute(vault.Groups.Single());
vault.GroupFilter = vault.Groups.Single();
vault.NewHostCommand.Execute(null);
@@ -4457,31 +4576,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
.ShouldBe("production", "+ NEW GROUP inside a group makes one inside it");
}
/// <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>
/// <para>
/// Deleting a group leaves the machines under it alone <em>and</em> stops them naming it. It used to do
@@ -4505,8 +4599,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
var question = vault.PendingDeletion.ShouldNotBeNull();
@@ -4549,8 +4642,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
vault.PendingDeletion.ShouldNotBeNull().Choice.ShouldContain("host");
vault.DeletionTakesTheHostsToo = true;
@@ -4580,17 +4672,13 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "staging");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.DeleteGroupCommand.Execute(null);
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal)));
vault.DeletionTakesTheHostsToo = true;
vault.CancelDeleteCommand.Execute(null);
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "staging", StringComparison.Ordinal));
vault.DeleteGroupCommand.Execute(null);
vault.DeleteGroupCommand.Execute(vault.Groups.Single(
row => string.Equals(row.Label, "staging", StringComparison.Ordinal)));
vault.DeletionTakesTheHostsToo.ShouldBeFalse("every question starts from keeping the machines");
}
@@ -4615,8 +4703,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.EditSelectedHostCommand.Execute(null);
vault.EditorLabel = "half-typed";
vault.SelectedGroup = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(null);
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
vault.PendingDeletion.ShouldBeNull("the question was never put");
vault.IsEditing.ShouldBeTrue("and the edit is still there to finish");
@@ -4676,8 +4763,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single();
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(vault.Groups.Single());
vault.GroupEditorLabel.ShouldBe("production", "renaming loads the current name into the box");
@@ -4879,10 +4965,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await SetGroupParentAsync(vault, "production", "estate");
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal)));
vault.GroupEditorParentChoices
.Select(choice => choice.Label)
@@ -5355,24 +5439,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
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.
/// ◆ v5: the drag this used to cover — a ticked set dropped straight onto a group card — left with the
/// cards, and <c>FileChosenHostsUnderCommand</c> went with it; <c>ChangingTheGroupOfTheChosenHosts_FilesThemAllAtOnce</c>
/// covers what a set files to now that the group picker is the only route. What survives here is the
/// guard: a drop was a gesture on the grid, refused under a half-typed edit because rewriting a host
/// underneath one was a save nobody asked for and could not then cancel. The picker inherits the same
/// refusal at the point it is raised instead — see <see cref="VaultViewModel.RegroupChosenHosts"/> —
/// rather than at the point it is answered, since a picker cannot be dropped onto something mid-edit the
/// way a card once was.
/// </para>
/// </remarks>
[Fact]
public async Task DroppingTheChosenHostsOnAGroupCard_FilesEveryOneOfThem()
public async Task RegroupingTheChosenHosts_IsRefusedWhileTheEditorIsOpen()
{
await UnlockedAsync();
var vault = shell.Vault!;
@@ -5385,25 +5465,29 @@ public sealed class ShellFlowTests : IAsyncLifetime
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);
vault.RegroupChosenHostsCommand.Execute(null);
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.IsRegroupingChosenHosts.ShouldBeFalse("nothing is filed under an open editor");
vault.CancelEditCommand.Execute(null);
await vault.FileChosenHostsUnderCommand.ExecuteAsync(card);
vault.RegroupChosenHostsCommand.Execute(null);
Host(vault, "prod-db").Host.GroupId.ShouldBe(card.EntityId, vault.Status);
Host(vault, "prod-web").Host.GroupId.ShouldBe(card.EntityId);
vault.IsRegroupingChosenHosts.ShouldBeTrue("the editor is out of the way now");
var group = vault.Groups.Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.SelectedChosenHostGroup = vault.ChosenHostGroupChoices
.Single(choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
await vault.ConfirmRegroupChosenHostsCommand.ExecuteAsync(null);
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>
@@ -5555,12 +5639,119 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.Status.ShouldContain("password");
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
// ---- The terminal pin strip ----
/// <summary>
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
/// the connection already open and the tab it opened already selected.
/// </summary>
private async Task<VaultViewModel> ConnectedHostWithAPinAsync(string path = "/var/www/app")
{
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
// selection bounces back to the host on purpose. The command has to work off the heading it was
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
await BindKeyAsync(vault, vault.Hosts[0], vault.Keys[0].EntityId);
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = path;
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
return vault;
}
[Fact]
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
{
var vault = await ReadyToConnectAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
// that happens to be selected on the hosts screen.
shell.ShowsPinStrip.ShouldBeFalse();
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
{
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(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
}
/// <remarks>
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
/// entry independent of Requests.
/// </remarks>
[Fact]
public async Task ClickingAPinChip_OpensFilesAtThatPath()
{
var vault = await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.Transfers.IsConnected.ShouldBeTrue();
shell.Transfers.RemotePath.ShouldBe("/var/www/app");
ssh.SftpRequests.ShouldHaveSingleItem();
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
{
// Both heads' only route into a group editor since v5: neither draws a group card to select one
// from any more, so the command has to work off the heading it was pressed on. Two groups exist here
// so a bug reading the wrong one would show up as the wrong label rather than passing by accident.
await UnlockedAsync();
var vault = shell.Vault!;
@@ -5569,23 +5760,19 @@ public sealed class ShellFlowTests : IAsyncLifetime
await AddGroupAsync(vault, "production");
await FileAsync(vault, "prod-db", "production");
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
vault.EditGroupFromHeadingCommand.Execute(heading);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not some other group");
}
/// <remarks>
/// The heading hands its group to the editor rather than selecting it first, and this is why. A group
/// selection clears the host selection the desktop's two grids share one mark — and the phone draws no
/// group cards at all, so selecting one here would take the highlight off the machine in the list with
/// nothing on screen to say where it had gone, or how to get it back.
/// The heading hands its group straight to the editor rather than selecting a card first, and this is
/// why: through v4 a group selection cleared the host selection, since the desktop's two grids shared one
/// mark, and neither head has drawn a group card to select since v5 — see <c>EditGroup</c>'s own remarks.
/// </remarks>
[Fact]
public async Task AGroupsHeading_LeavesTheChosenMachineChosen()
@@ -5920,6 +6107,226 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").TagLabels.ShouldBeEmpty("and the tagging was");
}
// ---- Pinned paths (QUICK ACCESS) ----
[Fact]
public async Task APinAddedThroughTheEditor_LandsOnTheHostOnSave()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorPinnedPaths.ShouldBeEmpty();
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
vault.EditorNewPin.ShouldBeEmpty("the box empties so a second one can be typed straight away");
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
await vault.SaveHostCommand.ExecuteAsync(null);
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task PinsAddedInOrder_KeepThatOrderOnTheHost()
{
// Order is the whole feature — see PinnedPathList's own remarks — so the editor's staging list has
// to preserve it as faithfully as the domain type it is about to become.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
foreach (var path in new[] { "/var/www/app", "/etc/nginx", "/var/log/pm2" })
{
vault.EditorNewPin = path;
vault.AddEditorPinCommand.Execute(null);
}
await vault.SaveHostCommand.ExecuteAsync(null);
Host(vault, "prod-db").Host.PinnedPaths
.ShouldBe(["/var/www/app", "/etc/nginx", "/var/log/pm2"]);
}
[Fact]
public async Task APinRemovedInTheEditor_IsGoneFromTheHostOnSave()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
vault.EditorNewPin = "/etc/nginx";
vault.AddEditorPinCommand.Execute(null);
vault.RemoveEditorPinCommand.Execute("/var/www/app");
vault.EditorPinnedPaths.ShouldBe(["/etc/nginx"]);
await vault.SaveHostCommand.ExecuteAsync(null);
Host(vault, "prod-db").Host.PinnedPaths.ShouldBe(["/etc/nginx"]);
}
[Fact]
public async Task CancellingAHostEdit_DropsThePinningEntirely()
{
// Unlike a tag, a pin has no id and nowhere else to live — so cancelling loses it outright rather
// than leaving it behind for next time, which is what CancellingAHostEdit_DropsTheTaggingAndKeepsThe
// Tag holds a tag's own name to.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
vault.CancelEditCommand.Execute(null);
Host(vault, "prod-db").Host.PinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ReopeningAPinnedHostsEditor_StagesItsExistingPins()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorPinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task ANewHostsEditor_OpensWithNoPinsStaged()
{
// NewHostCommand has to clear whatever the previous host's edit left in EditorPinnedPaths — the same
// reason every other editor field is reset there.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.NewHostCommand.Execute(null);
vault.EditorPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ABlankPin_IsRefusedAtTheAddBox()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = " ";
vault.AddEditorPinCommand.Execute(null);
vault.EditorPinnedPaths.ShouldBeEmpty();
vault.Status.ShouldContain("blank");
}
[Fact]
public async Task APinAlreadyStaged_IsRefusedRatherThanRepeated()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
vault.EditorNewPin = "/var/www/app";
vault.AddEditorPinCommand.Execute(null);
vault.EditorPinnedPaths.ShouldHaveSingleItem();
vault.Status.ShouldContain("already pinned");
}
[Fact]
public async Task AnOverLongPin_IsRefusedAtTheAddBoxBeforeSave()
{
// The add affordance has to catch this itself rather than letting it ride to TryValidate: a refusal
// that waits for SAVE throws away every other field typed on the form since, where one at the box
// that caused it costs nothing else.
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewPin = new string('a', HostSecret.MaxPinnedPathLength + 1);
vault.AddEditorPinCommand.Execute(null);
vault.EditorPinnedPaths.ShouldBeEmpty();
vault.Status.ShouldContain(HostSecret.MaxPinnedPathLength.ToString(CultureInfo.InvariantCulture));
}
[Fact]
public async Task AThirtyThirdPin_IsRefusedAtTheAddBox()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
vault.SelectedHost = Host(vault, "prod-db");
vault.EditSelectedHostCommand.Execute(null);
for (var i = 0; i < HostSecret.MaxPinnedPaths; i++)
{
vault.EditorNewPin = $"/pin/{i}";
vault.AddEditorPinCommand.Execute(null);
}
vault.EditorPinnedPaths.Count.ShouldBe(HostSecret.MaxPinnedPaths);
vault.EditorNewPin = "/one/too/many";
vault.AddEditorPinCommand.Execute(null);
vault.EditorPinnedPaths.Count.ShouldBe(
HostSecret.MaxPinnedPaths, "the add box refused the 33rd rather than staging it");
vault.Status.ShouldContain(HostSecret.MaxPinnedPaths.ToString(CultureInfo.InvariantCulture));
}
[Fact]
public async Task EditingAHostsPort_KeepsTheTagsItAlreadyWore()
{
@@ -6014,10 +6421,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
string? username = null,
string? key = null)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
vault.GroupEditorDefaultPort = port;
vault.GroupEditorDefaultUsername = username ?? string.Empty;
@@ -6033,10 +6438,8 @@ public sealed class ShellFlowTests : IAsyncLifetime
private static async Task SetGroupParentAsync(VaultViewModel vault, string group, string parent)
{
vault.SelectedGroup = vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal));
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(vault.Groups.Single(
row => string.Equals(row.Label, group, StringComparison.Ordinal)));
vault.GroupEditorSelectedParent = vault.GroupEditorParentChoices.Single(
choice => string.Equals(choice.Label, parent, StringComparison.Ordinal));