Give the window its v5b chrome and each session surface its own shell

This commit is contained in:
2026-08-08 00:49:59 +02:00
parent 1b76c51fbb
commit 43c939b697
30 changed files with 4433 additions and 1772 deletions
@@ -825,7 +825,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
// usage is "deliberately not given the session shell above."
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
/// <remarks>
@@ -1144,18 +1147,30 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
// ---- The transfers screen ----
//
// ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
// control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
// MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
// container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
// it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
// TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
// SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
// the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.
/// <remarks>
/// <para>
/// The widest thing in this window and the one with the least room to give: two file listings side by
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
/// a button and a chip on one line.
/// The widest this screen gets and the one with the least room to give once connected: two file listings
/// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
/// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
/// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
/// actually chosen against; see its own remark on the column widths.
/// </para>
/// <para>
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
/// of real length rather than with fixtures chosen to fit.
/// Measured disconnected here, though, which is the state the screen opens in and the one where the
/// local pane is at its fullest it lists this machine's home directory, so the row template is
/// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
/// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
/// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
/// </para>
/// </remarks>
[Fact]
@@ -1253,14 +1268,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
/// that already carries UP, REFRESH and DELETE.
/// The state the screen is in once something is open, and the tightest one wave C's restyle has to
/// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width.
/// </para>
/// <para>
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
/// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
/// trimming where the header prints it.
/// </para>
/// </remarks>
[Fact]
@@ -1303,6 +1322,214 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
/// <remarks>
/// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
/// the fixture's own home directory — whose contents this suite does not control — by adding one row of
/// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
/// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
/// not proves alongside the two shared columns.
/// </remarks>
[Fact]
public async Task TheRowTemplateShowsSizeAndDateColumns()
{
var local = new LocalEntryRowViewModel(new LocalEntry(
"notes.txt",
Path.Combine(Path.GetTempPath(), "notes.txt"),
IsDirectory: false,
Length: 4_096,
new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));
// Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
// than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
// row added after all of those would never be built at all, which is a false pass rather than a
// proof — the assertion below would find nothing to have missed.
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(local);
var remote = new RemoteEntryRowViewModel(new SftpEntry(
"deploy.log",
"/srv/releases/deploy.log",
SftpEntryKind.File,
49_152,
new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
"-rw-r--r--"));
transfers.RemoteEntries.Add(remote);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();
texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
/// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
/// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
/// rendered onto the strip itself.
/// </remarks>
[Fact]
public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
{
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");
var expected = new[] { "queued", "64%", "done", "stopped", "failed" };
transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var words = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.Classes.Contains("transferstatus"))
.Select(text => text.Text ?? string.Empty)
.ToList();
words.ShouldBe(expected);
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
/// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
/// absent with nothing queued, and a queued transfer brings the full strip straight back.
/// </remarks>
[Fact]
public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
{
transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");
var collapsed = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Text is { } spoken
&& spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));
collapsed.IsEffectivelyVisible.ShouldBeTrue(
"the honest sentence stays reachable in the collapsed shape");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane.
/// </remarks>
[Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{
transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
0, TimeProvider.System.GetUtcNow(), string.Empty)));
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
TransferState.Running, bytesPerSecond: 6_500_000);
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
TransferState.Cancelled);
await MeasureTransfersAsync(
faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
}
// ---- The chrome ----
/// <remarks>
@@ -1355,27 +1582,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <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.
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
/// rows below it and the user chip at the foot, both the rail's full content width. A single
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
/// own width now.
/// </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>.
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
/// entry silently dropping off the bottom would still pass every other assertion here.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail();
var rail = new NavRail { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
@@ -1383,18 +1610,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
var rows = buttons.Where(button => button.Classes.Contains("nav")
&& !button.Classes.Contains("navseg")).ToList();
var chip = buttons.Single(button => button.Classes.Contains("navuser"));
foreach (var button in buttons)
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
rows.Count.ShouldBe(
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
foreach (var segment in segments)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
segment.Bounds.Height.ShouldBeGreaterThan(20);
}
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
// each side that v2 gives the rows so a filled one reads as a rounded row rather
// than as a full-width band. Stated exactly rather than as a lower bound: a button
// that stopped filling the row would leave a dead strip beside a destination, which
// is precisely the kind of near-miss a bound hides.
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
foreach (var row in rows.Append(chip))
{
row.Bounds.Height.ShouldBeGreaterThan(20);
// 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
// inset v5b's own padding gives each side. Stated exactly rather than as a lower
// bound: a row that stopped filling the width would leave a dead strip beside a
// destination, which is precisely the kind of near-miss a bound hides.
row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
}
LayoutHarness.Unreachable(window).ShouldBeEmpty();
@@ -1579,8 +1817,21 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// <para>
/// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
/// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
/// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
/// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
/// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
/// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
/// not the plain screen budget.
/// </para>
/// <para>
/// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
/// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
/// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
/// either — see the two callers below, both of which select one before calling this.
/// </para>
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1594,7 +1845,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
@@ -1609,12 +1860,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
/// stop fitting when the strip is what it is drawn over.
/// <para>
/// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
/// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
/// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
/// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
/// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
/// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
/// the intent the two states share, not this control's actual bounds.
/// </para>
/// <para>
/// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
/// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
/// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
/// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
/// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
/// records is that no gap was left for a later wave to close.
/// </para>
/// </remarks>
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1852,7 +2114,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
/// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
/// <remarks>
/// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen,
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
LayoutHarness.SessionScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
/// <remarks>
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
/// the same budget every other full-bleed page gets.
/// </remarks>
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
@@ -1896,8 +2193,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
/// takes 190 and the vault list 268, leaving 558 for everything above.
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
/// widened the rail, because the minimum grew by exactly what the rail did.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see