Move SFTP and S3 into the tab strip, and the host list into a card grid

Four asks in one pass over the desktop head, and two of them are furniture
moving rather than anything new.

THE STRIP IS THE WINDOW'S NOW, not the terminal's. Vaults, SFTP and S3 sit at
its head and cannot be closed, and open terminals follow them. SFTP and S3 left
the nav rail to get there, which is the one semantic change: they are the two
destinations you stay in while something runs, and a rail entry is for
somewhere you go and come back from. So the rail belongs to the Vaults tab and
collapses with it, which also means SFTP, S3 and a terminal each get the full
1016 rather than the 826 a page gets.

The tab is expressed as "a page, and not one of the two the strip took" rather
than as a fourth ShellSurface. Both are still ShellScreen members and have to
be — that is what they are on the phone, where they are two rows in a hub — so
a surface each would have been a second way to say a thing Screen already says.
IsTransfersShowing and IsBucketsShowing light the other two tabs unchanged.
What is new is one field: the page Vaults comes back to, because it is the one
tab with sub-navigation and therefore the one that can return to the wrong
place. That is not the hidden field ShellSurface argues against — that one
would be a second copy of "which page"; this is a tab remembering its own.

THE HOSTS SCREEN IS A GRID, and the 268-pixel sidebar is gone. That column was
choosing among forty machines and editing one of them at two-thirds width, and
it was narrow so the editor beneath it could be a column at all. Cards took the
first job at full width; a 304-pixel drawer took the second and collapses when
nothing is selected. Pressing a group card narrows the grid; SHOW ALL is the
way back. The group editor moved into the drawer as well, which finally makes
IsEditingGroup mean the same thing on both heads — it was the phone's alone,
because the desktop's editor was a bar that was always on screen.

AreHostsExpanded and ToggleHosts went with the control that used them. They
folded the whole list away under one heading, an affordance that existed
because the column was narrow. Folding a single group is a different thing and
is still here.

THE TYPE SCALE IS A POINT LARGER and the text ramp is white. The base size was
never stated anywhere — a bare TextBlock took TextElement's default of 12 — so
raising the scale meant naming it, on Window and on UserControl. The second
selector is not redundancy: the layout harness hosts a UserControl in a window
it builds itself, and without it the suite would measure every screen a point
smaller than it ships, silently. A selector on TextBlock would have been the
obvious way and is wrong, because a style setter beats an inherited value and
would collapse every deliberate step back to one number.

#E3E7F4 is a blue-tinted white on blue-black surfaces, which costs contrast
twice — once for being darker than white and once for sharing a hue with what
it is drawn on. Pure white is 18.3:1 against the canvas where that was 15.5:1.
Every step below moved with the top, so the intervals the design chose are
kept and TextDim clears 9:1 against 6.4:1. The palette is shared, so the phone
has both changes too.

TWO DEFECTS THE HARNESS STRUCTURALLY CANNOT SEE, found by rendering the screen
rather than by measuring it, and both now covered.

The tile was 232 and was first written as 248, from arithmetic that left out
the scrolling stack's own margins. Every layout test passed — the harness asks
whether a control is inside the window, never how many fit on a line — so the
grid quietly became one column wide at exactly the minimum this application
guarantees, which is the shape cards exist to avoid.
TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen counts columns
instead, and fails at 248.

And a card's text ran past its own border, because a horizontal StackPanel
measures children with infinite width: a TextBlock inside one never learns it
is short of room, so TextTrimming never fires. Both card rows are grids with a
star column that gives way and an Auto column that does not — a hostname with
its tail cut is still the machine you were looking for, where a badge or the
word naming an auth method is not.

The keychain header changed shape for the same class of reason. It was
Auto,Auto,*,Auto with the buttons last, so the slack column was the only thing
absorbing a change of width and five buttons fell off the right edge the moment
the type grew. That is how GENERATE lost the word KEY once already. The summary
sits in the star column and trims now, so the buttons always get their width.

HostSidebarTests became HostGridTests and moved to the grid with the gestures
it drives. docs/design-import-gaps.md gains a v3 section naming the five
toolbar controls in the design with nothing behind them — a view-mode switch, a
tag filter, a calendar, a share control and Serial — and manual-checks.md and
the README follow the controls that moved.
This commit is contained in:
2026-08-03 15:08:48 +02:00
parent 416f233657
commit 208443b932
38 changed files with 2252 additions and 1432 deletions
@@ -16,23 +16,29 @@ using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the host list answers a pointer.
/// How the grid of host cards 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.
/// This was <c>HostSidebarTests</c>, and it moved with the list: the cards are on
/// <see cref="HostsScreen"/> now, and so is every handler that was wired to them. See
/// <c>HostsScreen.axaml.cs</c>.
/// </para>
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
/// 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
/// one: compiled bindings resolve against the declared type, and the grid is built out of the vault's own
/// hosts and groups.
/// </para>
/// </remarks>
public sealed class HostSidebarTests : IAsyncLifetime
public sealed class HostGridTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
@@ -93,18 +99,18 @@ public sealed class HostSidebarTests : IAsyncLifetime
[Fact]
public async Task ARightClickSelectsTheHostUnderThePointer()
{
await OnTheSidebarAsync((sidebar, window) =>
await OnTheGridAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
vault.SelectedHost = first;
RightClick(RowFor(sidebar, other), window);
RightClick(CardFor(screen, other), window);
vault.SelectedHost.ShouldBeSameAs(other);
var menu = sidebar.HostList.ContextMenu.ShouldNotBeNull();
var menu = screen.HostGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
// The commands are the vault's, which is the other half of putting the menu on the list rather
@@ -121,19 +127,19 @@ public sealed class HostSidebarTests : IAsyncLifetime
}
/// <remarks>
/// A heading is a row in the same list and the control will happily select it, but it is not a host —
/// A heading is an item 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.
/// or act on a machine somewhere else in the grid.
/// </remarks>
[Fact]
public async Task ARightClickOnAGroupHeadingOpensNothingAndMovesNothing()
{
await OnTheSidebarAsync((sidebar, window) =>
await OnTheGridAsync((screen, window) =>
{
var selected = Row(vault, "prod-db");
vault.SelectedHost = selected;
var heading = sidebar.HostList
var heading = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => item.DataContext is SidebarGroupHeader);
@@ -141,10 +147,39 @@ public sealed class HostSidebarTests : IAsyncLifetime
RightClick(heading, window);
vault.SelectedHost.ShouldBeSameAs(selected, "the selection the menu would have acted on");
sidebar.HostList.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
screen.HostGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <remarks>
/// Pressing a group card narrows the grid to that group, and pressing SHOW ALL brings the rest back.
/// Driven through the property the card's <c>ListBox</c> binds rather than through a click, because
/// what is worth holding is the rule — the filter is a property of the grid, and it also moves the
/// selection the group's own EDIT and DELETE act on. A click would test Avalonia's <c>SelectedItem</c>
/// binding, which is not this application's code.
/// </remarks>
[Fact]
public async Task ChoosingAGroupNarrowsTheGridAndAimsTheGroupButtonsAtIt()
{
var production = vault.Groups.Single();
vault.MoveHostToGroupCommand.Execute(
new HostGroupMove(Row(vault, "prod-db"), production.EntityId));
vault.GroupFilter = production;
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["prod-db"], "only what is filed under the chosen group");
vault.SelectedGroup.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.IsFilteredByGroup.ShouldBeTrue();
vault.ClearGroupFilterCommand.Execute(null);
vault.VisibleHosts.Count.ShouldBe(2, "SHOW ALL brings back the hosts outside the group");
vault.SelectedGroup.ShouldBeNull("nothing is aimed at once the filter is off");
}
// ---- Helpers ----
private static void RightClick(Visual row, Visual window)
@@ -155,18 +190,18 @@ public sealed class HostSidebarTests : IAsyncLifetime
((Window)window).MouseUp(at, MouseButton.Right);
}
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
private Task OnTheGridAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var screen = new HostsScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
body(screen, window);
}
finally
{
@@ -175,8 +210,8 @@ public sealed class HostSidebarTests : IAsyncLifetime
},
Token);
private static ListBoxItem RowFor(Visual sidebar, HostRowViewModel host) =>
sidebar.GetVisualDescendants()
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
@@ -36,8 +36,13 @@ internal static class LayoutHarness
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
/// <summary>The host sidebar's fixed width, from the hosts screen's <c>ColumnDefinitions</c>.</summary>
internal const double HostSidebarWidth = 268;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <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.
/// </remarks>
internal const double HostDrawerWidth = 304;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
@@ -137,22 +137,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
caches.Dispose();
}
// ---- The host sidebar ----
// ---- The hosts drawer ----
//
// 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.
[Fact]
public async Task TheHostSidebarFitsWithNoEditorOpen()
public async Task TheHostDrawerFitsShowingAHost()
{
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
vault.SelectedHost = vault.Hosts[0];
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// The tight one, and the reason this suite still exists. The sidebar is 268 pixels wide against the old
/// column's 340, and the host editor is the tallest thing in it: six fields, an authentication picker
/// with a two-line item template, a checkbox, a paragraph of hint text and three buttons, all sharing a
/// column with the list above them.
/// 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.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsEditorOpen()
public async Task TheHostDrawerFitsWithTheHostEditorOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
@@ -165,101 +170,41 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.First(choice => choice.Kind is AuthenticationKind.Credential);
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Folding the list away is the one thing a user can do to this control that changes which of its parts
/// is on screen, so it is a shape worth laying out on its own.
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
/// three pickers are the same width as the host editor's and its labels are longer.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithItsListFoldedAway()
{
vault.ToggleHostsCommand.Execute(null);
vault.AreHostsExpanded.ShouldBeFalse();
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// Headings are rows in the same list as the hosts, drawn from a different template, and they are the
/// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithGroupHeadingsInTheList()
public async Task TheHostDrawerFitsWithTheGroupEditorOpen()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
vault.GroupFilter = vault.Groups[0];
vault.EditGroupCommand.Execute(null);
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
vault.IsEditingGroup.ShouldBeTrue("the desktop raises this now, as the phone always did");
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
/// The question in place of the three buttons. Its tallest shape is a host with a terminal open on it,
/// which adds a disclosure the ordinary case has not got.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// A <c>ListBox</c> is not focusable by default, so the call returns false against a list that has not
/// asked to be — and <c>Focus()</c> on a collapsed control is a no-op that is not replayed when it is
/// revealed, which is exactly what the folded-away case would hit.
/// Still worth measuring although the drawer scrolls as a whole now — see <c>HostDrawer.axaml</c> — and
/// the reason has changed rather than gone. The harness skips anything inside a <c>ScrollViewer</c>, so
/// what this holds is not that the buttons are on screen but that the drawer itself does not blow its
/// column sideways. The question is the widest thing it draws: a sentence with a host name in it.
/// </para>
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostList);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is showing");
});
vault.ToggleHostsCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.KeyboardTarget.ShouldBeSameAs(sidebar.HostFilter);
sidebar.KeyboardTarget.Focus().ShouldBeTrue("the list is folded away, so the filter takes it");
});
}
/// <remarks>
/// The editor open with the list still on screen behind it, which is the state a user is most likely to
/// leave the sidebar in — so it is the state the keyboard answer most has to hold in.
/// </remarks>
[Fact]
public async Task TheSidebarsKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheSidebarAsync((sidebar, _) =>
{
sidebar.HostList.IsEffectivelyVisible.ShouldBeTrue();
sidebar.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// <para>
/// The strip along the sidebar's bottom edge with the question in it instead of the three buttons. Its
/// tallest shape is a host with a terminal open on it, which adds a disclosure the ordinary case has
/// not got — in a 268-pixel column whose middle is a list that has already taken every spare pixel.
/// </para>
/// <para>
/// Worth measuring rather than assuming, because this is the one card in the application a user cannot
/// scroll: the sidebar's only <c>ScrollViewer</c> is inside the host list, so a button pushed past the
/// bottom edge here would leave the question unanswerable in either direction.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostSidebarFitsWithADeletionInQuestion()
public async Task TheHostDrawerFitsWithADeletionInQuestion()
{
vault.SelectedHost = vault.Hosts[0];
vault.SelectedHost.IsConnected = true;
@@ -268,7 +213,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.IsConfirmingDeletion.ShouldBeTrue();
vault.PendingDeletion.ShouldNotBeNull().HasUsage.ShouldBeTrue("the open terminal is the long shape");
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
@@ -303,15 +248,15 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.SelectedHost = null;
vault.Status = string.Empty;
await OnTheSidebarAsync((sidebar, window) =>
await OnTheHostsScreenAsync((screen, window) =>
{
var row = sidebar.HostList.GetVisualDescendants()
var card = screen.HostGrid.GetVisualDescendants()
.OfType<ListBoxItem>()
.First();
.First(item => item.DataContext is HostRowViewModel);
var centre = row.TranslatePoint(
new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the row is not in this window's tree");
var centre = card.TranslatePoint(
new Point(card.Bounds.Width / 2, card.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the card is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
@@ -320,7 +265,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
vault.SelectedHost.ShouldNotBeNull("a press on a card selects it");
vault.Status.ShouldContain(
"not in this keychain any more",
Case.Insensitive,
@@ -328,6 +273,145 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
});
}
/// <remarks>
/// <para>
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
/// the keyboard off the terminal's native child window first and then focuses this target, so a target
/// that cannot take focus leaves the user with no focused element and no way back except the mouse.
/// </para>
/// <para>
/// Which is why this asserts that focus was <i>taken</i> rather than that the right control was named.
/// A <c>ListBox</c> is not focusable by default, so the call returns false against a list that has not
/// asked to be — and an empty one has no item to take it either, which is the second shape below.
/// </para>
/// <para>
/// The empty shape used to be the sidebar's folded-away list and is now a filter that matches nothing.
/// That is a state a user reaches far more often than the old one: it is one keystroke away from every
/// search.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreensKeyboardTargetTakesFocusInBothOfItsShapes()
{
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostGrid);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid has cards in it");
});
vault.HostFilter = "nothing matches this";
vault.HasVisibleHosts.ShouldBeFalse();
await OnTheHostsScreenAsync((screen, _) =>
{
screen.KeyboardTarget.ShouldBeSameAs(screen.HostFilter);
screen.KeyboardTarget.Focus().ShouldBeTrue("the grid is empty, so the find box takes it");
});
}
/// <remarks>
/// The editor open with the grid still on screen beside it, which is the state a user is most likely to
/// leave this screen in — so it is the state the keyboard answer most has to hold in.
/// </remarks>
[Fact]
public async Task TheHostsScreensKeyboardTargetStillTakesFocusWithTheEditorOpen()
{
vault.NewHostCommand.Execute(null);
await OnTheHostsScreenAsync((screen, _) =>
{
screen.HostGrid.IsEffectivelyVisible.ShouldBeTrue();
screen.KeyboardTarget.Focus().ShouldBeTrue();
});
}
/// <remarks>
/// Headings are items in the same list as the cards, drawn from a different template, and they span a
/// whole row of the wrap rather than sitting in the flow as another card. Measured with one group
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithGroupHeadingsInTheGrid()
{
await SeedGroupsAsync(3);
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
}
/// <remarks>
/// The narrowest the grid ever gets, and the width the tile was sized against: the window at its
/// minimum, less the nav rail and less the drawer.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.IsDrawerOpen.ShouldBeTrue();
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with a host selected and the drawer out"));
}
/// <summary>
/// The grid is still a grid at the window's minimum with the drawer open.
/// </summary>
/// <remarks>
/// <para>
/// <b>The harness cannot see this and never will.</b> Its one rule is that a control is inside the
/// window, so a wrap that has quietly collapsed to a single column reports perfectly clean — every card
/// is inside, just one above the other. That is exactly what happened: the tile's width was set from
/// arithmetic that left out the scrolling stack's own margins, and the grid became a list with extra
/// padding at precisely the size this application guarantees.
/// </para>
/// <para>
/// Two per row rather than a width assertion, because the number that matters is the number of columns.
/// A width is one of the inputs — the margins, the padding and the scrollbar are the others — and
/// pinning the input would go on passing while any of the rest moved.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsGridKeepsTwoColumnsAtTheMinimumWithTheDrawerOpen()
{
vault.SelectedHost = vault.Hosts[0];
vault.IsDrawerOpen.ShouldBeTrue("the drawer is what takes the width away");
await OnTheHostsScreenAsync((screen, window) =>
{
var cards = screen.HostGrid
.GetVisualDescendants()
.OfType<ListBoxItem>()
.Where(item => item.DataContext is HostRowViewModel)
.Select(item => item.TranslatePoint(default, window)
?? throw new InvalidOperationException("a card is not in this window's tree"))
.ToList();
cards.Count.ShouldBeGreaterThan(1, "the seed has to put more than one host in the grid");
cards.GroupBy(point => Math.Round(point.Y))
.Max(row => row.Count())
.ShouldBeGreaterThanOrEqualTo(
2,
"at the window's minimum, with the drawer out, the cards still wrap two to a row");
});
}
/// <remarks>
/// The widest the drawer's own contents get while the grid is beside them: the host editor open, which
/// is what EDIT does to a screen that already has both columns up.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheDrawerEditingAHost()
{
vault.SelectedHost = vault.Hosts[0];
vault.EditSelectedHostCommand.Execute(null);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the editor out beside the grid"));
}
// ---- The hosts screen ----
//
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
@@ -394,22 +478,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The group panel is a row of its own at the foot of this screen, so it competes with the overview above
/// it for the same column — and it grows sideways as groups are added, which is the direction a
/// fixed-width column has least of. Six, because that is more than anybody's first three and enough to
/// need the horizontal scroller rather than to overflow silently.
/// The group cards are a wrap above the host cards, so more of them than a row holds is the case that
/// pushes the hosts down rather than one that overflows sideways. Six, because that is more than
/// anybody's first three and enough to need a second row at the window's minimum.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor()
public async Task TheHostsScreenFitsWithMoreGroupsThanARowHasRoomFor()
{
await SeedGroupsAsync(6);
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom"));
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six group cards above the hosts"));
}
/// <remarks>
/// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own
/// deletion follows — and it is the taller of the two, because it says how many hosts are about to move.
/// The question replaces the group's two buttons rather than stacking under them — the same rule every
/// other pair in this application follows — and it is the taller of the two, because it says how many
/// hosts are about to move.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
@@ -900,15 +984,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// Eight destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
/// rather than width — at the window's minimum the entries have to leave room for each other, which is
/// the same failure the old four-button selector was one label away from. It got tighter when the host
/// keys left the keychain screen and became a destination of their own, and tighter again with snippets
/// and then the logs — which is why the count is asserted rather than left to the fit check: an entry
/// silently dropping off the bottom would still pass every other assertion here.
/// <para>
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
/// the entries have to leave room for each other, which is the same failure the old four-button selector
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
/// pass every other assertion here.
/// </para>
/// <para>
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
/// in. See <c>NavRail.axaml</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsNineDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -921,7 +1013,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(9, "one per screen the rail reaches");
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
foreach (var button in buttons)
{
@@ -1062,22 +1154,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
// ---- Helpers ----
/// <summary>Lays the sidebar out at the width the hosts screen gives it.</summary>
private Task MeasureSidebarAsync(Action<IReadOnlyList<string>> assert) =>
OnTheSidebarAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
/// <summary>Lays the drawer out at the width it declares for itself.</summary>
private Task MeasureDrawerAsync(Action<IReadOnlyList<string>> assert) =>
OnTheDrawerAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheSidebarAsync(Action<HostSidebar, Window> body) =>
private Task OnTheDrawerAsync(Action<HostDrawer, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var sidebar = new HostSidebar { DataContext = vault };
var drawer = new HostDrawer { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.HostSidebarWidth, LayoutHarness.ScreenHeight);
drawer, LayoutHarness.HostDrawerWidth, LayoutHarness.ScreenHeight);
try
{
body(sidebar, window);
body(drawer, window);
}
finally
{
@@ -1088,25 +1180,25 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
/// <remarks>
/// The shell is the data context, not the vault — the sidebar is handed the vault from inside the
/// screen's own markup. <see cref="MainWindowViewModel.Vault"/> is assigned rather than reached through
/// an unlock, which would be a second enrollment for no extra rectangle.
/// The vault is the data context and the shell is not, which it used to be. The screen handed the vault
/// to the sidebar from inside its own markup and needed the shell to do it; the drawer is a plain child
/// and inherits what the screen has, so the indirection went away with the sidebar.
/// </remarks>
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
OnTheHostsScreenAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
private Task OnTheHostsScreenAsync(Action<HostsScreen, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Vault = vault;
shell.State = ShellState.Unlocked;
var screen = new HostsScreen { DataContext = shell };
var screen = new HostsScreen { DataContext = vault };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
body(screen, window);
}
finally
{
@@ -206,6 +206,69 @@ public sealed class TerminalTabsTests : IAsyncLifetime
});
}
/// <summary>
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
/// </summary>
/// <remarks>
/// <para>
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
/// Hosts, which is most of the time.
/// </para>
/// <para>
/// Driven through the strip rather than through the commands, because what is being checked is that
/// three buttons in the markup are wired to three different things. Three commands called directly
/// would pass on a strip whose SFTP tab was bound to the S3 one.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
{
await OnTheStripAsync((strip, window) =>
{
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
Click(FixedTab(strip, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
Click(FixedTab(strip, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Click(FixedTab(strip, "Vaults"), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.Screen.ShouldBe(
ShellScreen.Snippets,
"the Vaults tab comes back to the page it was left on, not to Hosts");
});
}
/// <remarks>
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
/// SFTP.
/// </remarks>
[Fact]
public async Task TheFixedTabsCarryNoCloseBox()
{
await OnTheStripAsync((strip, _) =>
{
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
{
FixedTab(strip, label)
.GetVisualDescendants()
.OfType<Button>()
.ShouldBeEmpty($"{label} is a destination, not a session");
}
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
@@ -352,6 +415,28 @@ public sealed class TerminalTabsTests : IAsyncLifetime
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
/// <remarks>
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
/// first, because a terminal tab could be opened on a host called SFTP.
/// </remarks>
private static Button FixedTab(Visual strip, string label) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("fixed")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static void Click(Visual control, Window window)
{
var at = Centre(control, window);
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
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");