Public Access
Seven things reported from a day's use of v5b's session shell, and they are one commit because five of them are the same complaint from different angles: the window spends too much of itself on chrome describing the session, and the parts that are not chrome do not behave. ◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design. Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the address on the left, a cross-surface button on the right. Both facts are worth having and the strip they sat in was not — the tab already names the host, and a full-width bar repeating it was the cheapest 60 pixels in the layout to give back. The address is now the first line of the sidebar and the button is stretched across the column under it, so nothing is lost and the pane is taller. Which word that button carries and which command it runs used to be handed in from the two usage sites in MainWindow.axaml, because the control was drawn twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress already decides which surface's fact to read. The two directions underneath are untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync for a fresh terminal at whatever SFTP has open. SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops subtracting its 60 pixels from every session screen's budget — the same treatment the retired window-wide tab strip got, and for the same reason: a constant for chrome nobody draws is a suite quietly measuring the wrong rectangle. ◆ AND THE SIDEBAR CLOSES, which the design has no state for at all. 300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is often two rows long. The column now folds to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel that vanishes leaving no trace is one people report as lost rather than as closed. Both states live in the one control and swap on IsSessionSidebarOpen, so MainWindow's own "Auto" column takes whichever width is showing without knowing the state exists. Written through to ClientSettings.SessionSidebarOpen rather than held for the session. It is a decision about how much of the window a terminal gets, and one that had to be made again on every launch would not really be on offer. ---- THE FOUR SMALLER ONES ---- A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it got there. Two causes with nothing in common. The click moved Win32 focus onto the sidebar row, and term.focus() in the page cannot take it back — only the host can, so the shell raises TerminalFocusRequested and the window answers with the same posted focus every other path here uses. The highlight was bash: xterm wraps a paste in bracketed-paste markers, readline marks what arrives inside them as an active region, and it stays in reverse video until the next keystroke. Right for a clipboard paste, wrong for a snippet picked off a sidebar. Single-line snips are typed rather than pasted now, which needs no markers; multi-line still pastes, because "runs three commands unasked" is the worse of the two failures and the markers are the whole of what prevents it. A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling viewport #000 — its own comment explains why, and it is a macOS scrollbar concern. Everywhere else that black is covered by the rows, except along the bottom: the fit addon floors the row count, so the remainder below the last whole row is bare viewport, up to a line tall, against this page's #171a26. The light square at its right-hand end is where WebView2's classic scrollbar corner lands. The viewport is repainted in the page's own background, and the scrollbar with it — thin and in these colours rather than a grey Windows channel down the side of a near-black terminal, and kept rather than hidden, because a surface that scrolls with no sign that it does is worse than a quiet bar. THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face at all — that file is the 2019 build and its cmap skips E944 and E946 — so the rail's Pins row and the hosts screen's own pin badge have both been drawing a missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage is U+F10D, verified against the file rather than against a codepoints table for a later release of the font. Every other icon codepoint in the repository was audited the same way; this was the only miss. THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip reading ⌘K, and this build substitutes CTRL K — six characters and a space, wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's footprint survives for the day this face has a ⌘ to draw. ---- AND THE POPOVER UNDER THE USER CHIP ---- Reported as not matching the design, and it was not: Button.poprow set a corner radius and a padding and never touched the Background, so every row wore the Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the design draws as six lines of text — and the hover rule underneath was already correct and simply invisible against a fill that never went away. Set on the ContentPresenter as well as on the Button, the same as Button.flat, because the theme binds its brush there and a Background set only on the control loses to it. The panel itself gets this window's own radius-12 card treatment through a FlyoutPresenter class rather than by widening the shared context-menu rule, and Vaults and Preferences stop being drawn one step dimmer than Settings and Logout, which read as two disabled entries in a menu of five live ones. ---- WHAT PROVES IT ---- Three tests in the layout suite, two of them checked against the defect they describe: the popover row's resting fill (fails with #33ffffff without the style), and the kbd chip against the natural width of its own text, measured on a detached copy because a TextBlock's DesiredSize is already clipped to what it was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests is new — the sidebar has never been laid out by a test, and it now holds a string of unbounded length beside a button that has to stay clickable. In the shell suite: the cross-surface row in both directions, the closed state surviving to disk, and the focus request being made when a snip lands and not made when it does not.
185 lines
7.5 KiB
C#
185 lines
7.5 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.VisualTree;
|
|
using DodoSSH.Client.App.Views;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Shell.ViewModels;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Terminal;
|
|
using NSubstitute;
|
|
|
|
namespace DodoSSH.Client.App.Layout.Tests;
|
|
|
|
/// <summary>
|
|
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block
|
|
/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that
|
|
/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width
|
|
/// column holding a string of unbounded length beside a button that must stay clickable; the second is a
|
|
/// width the rest of the window has to cope with, and <c>MainWindow.axaml</c> copes with it by asking this
|
|
/// control how wide it is rather than by knowing.
|
|
/// </remarks>
|
|
public sealed class SessionSidebarTests : IAsyncLifetime
|
|
{
|
|
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
|
|
private const double OpenWidth = 300;
|
|
|
|
/// <inheritdoc cref="OpenWidth" />
|
|
private const double RailWidth = 34;
|
|
|
|
/// <remarks>
|
|
/// Long on purpose, and longer than the column is wide at this font: the address is the one string here
|
|
/// whose length nobody controls, and it shares its row with the button that closes the column.
|
|
/// </remarks>
|
|
private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
|
|
|
private string directory = null!;
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
// A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written
|
|
// through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths
|
|
// would be editing the preferences of whoever ran it.
|
|
directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}");
|
|
caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}");
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
new ClientPaths(directory),
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>())
|
|
{
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
|
|
if (Directory.Exists(directory))
|
|
{
|
|
Directory.Delete(directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
|
|
/// <see cref="LayoutHarness.Unreachable"/> rather than by reading the address's own width, because what
|
|
/// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing
|
|
/// beside it was pushed out of the column to make room.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, window) =>
|
|
{
|
|
// DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on
|
|
// the Border inside it, so what the surrounding "Auto" column is given — and what this asks for
|
|
// — is what the control asks for, not how wide a host window happened to stretch it.
|
|
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
|
|
shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one");
|
|
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cross-surface button that used to live in the header row. Read off the control rather than off the
|
|
/// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still
|
|
/// draw as an empty button — fails this.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, _) =>
|
|
{
|
|
var button = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(candidate => candidate.Classes.Contains("headerghost"));
|
|
|
|
button.Content.ShouldBe("Open SFTP");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
|
|
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
|
|
/// on it is the difference between a panel somebody closed and a panel somebody lost.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
|
|
{
|
|
await OnTheSidebarAsync((sidebar, window) =>
|
|
{
|
|
shell.IsSessionSidebarOpen = false;
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
|
|
|
|
var grip = sidebar.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
|
|
|
|
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
|
|
?? throw new InvalidOperationException("the grip is not in this window's tree");
|
|
|
|
window.MouseDown(centre, MouseButton.Left);
|
|
window.MouseUp(centre, MouseButton.Left);
|
|
|
|
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
|
|
|
|
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
});
|
|
}
|
|
|
|
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
|
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
|
|
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
try
|
|
{
|
|
body(sidebar, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|