Public Access
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.
444 lines
18 KiB
C#
444 lines
18 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.Presenters;
|
|
using Avalonia.Headless;
|
|
using Avalonia.Input;
|
|
using Avalonia.Media;
|
|
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>
|
|
/// How the tab strip answers a pointer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
|
|
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
|
|
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
|
|
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
|
|
/// binding.
|
|
/// </para>
|
|
/// <para>
|
|
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
|
|
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
|
|
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
|
|
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
|
|
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TerminalTabsTests : IAsyncLifetime
|
|
{
|
|
private ClientCacheFactory caches = null!;
|
|
private TerminalWorkspace workspace = null!;
|
|
private MainWindowViewModel shell = null!;
|
|
|
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask InitializeAsync()
|
|
{
|
|
caches = ClientCacheFactory.ForMemory($"tabs-{Guid.CreateVersion7():N}");
|
|
|
|
workspace = new TerminalWorkspace(
|
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
Substitute.For<ISshConnectionFactory>(),
|
|
TimeProvider.System);
|
|
|
|
shell = new MainWindowViewModel(
|
|
ClientPaths.Default,
|
|
caches,
|
|
workspace,
|
|
new VaultKnownHostStore(),
|
|
Substitute.For<IDeviceKeyStore>(),
|
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
TimeProvider.System,
|
|
Substitute.For<ISftpSessionFactory>())
|
|
{
|
|
// The only state the strip is ever interactive in. Assigned rather than reached through an
|
|
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
|
|
State = ShellState.Unlocked,
|
|
};
|
|
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await shell.DisposeAsync();
|
|
await workspace.DisposeAsync();
|
|
caches.Dispose();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
|
|
/// one, and the strip answered nothing but a left click before.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AMiddleClickOnATabClosesThatTab()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
var doomed = shell.Tabs[0];
|
|
var survivor = shell.Tabs[1];
|
|
|
|
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
|
|
|
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
|
|
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
|
|
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
|
|
/// the same feature with a way to get it wrong.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
// Well right of two short tabs and the button after them, and inside the strip's own height.
|
|
window.MouseDown(new Point(700, 17), MouseButton.Middle);
|
|
|
|
shell.Tabs.Count.ShouldBe(2);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
|
|
|
|
shell.Tabs.Count.ShouldBe(2);
|
|
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
|
|
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
var survivor = shell.Tabs[1];
|
|
|
|
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
|
|
|
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
|
|
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
|
|
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
|
|
/// terminal switching to something that is about to disappear.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
var doomed = shell.Tabs[0];
|
|
var survivor = shell.Tabs[1];
|
|
|
|
shell.SelectTabCommand.Execute(survivor);
|
|
|
|
var cross = CloseButton(strip, doomed);
|
|
window.MouseDown(Centre(cross, window), MouseButton.Left);
|
|
window.MouseUp(Centre(cross, window), MouseButton.Left);
|
|
|
|
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
|
|
shell.SelectedTab.ShouldBe(survivor);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
var wanted = shell.Tabs[1];
|
|
|
|
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
shell.IsTerminalShowing.ShouldBeFalse();
|
|
|
|
var button = TabButton(strip, wanted);
|
|
window.MouseDown(Centre(button, window), MouseButton.Left);
|
|
window.MouseUp(Centre(button, window), MouseButton.Left);
|
|
|
|
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
|
|
shell.SelectedTab.ShouldBe(wanted);
|
|
shell.IsTerminalShowing.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
/// <remarks>
|
|
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
|
|
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
|
|
/// claim this project is willing to make without a screenshot.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheButtonThatOpensAConnectionOpensThePalette()
|
|
{
|
|
await OnTheStripAsync((strip, window) =>
|
|
{
|
|
var plus = PlusButton(strip);
|
|
window.MouseDown(Centre(plus, window), MouseButton.Left);
|
|
window.MouseUp(Centre(plus, window), MouseButton.Left);
|
|
|
|
shell.IsSearching.ShouldBeTrue();
|
|
});
|
|
}
|
|
|
|
/// <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
|
|
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
|
|
{
|
|
await LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
for (var i = 0; i < 12; i++)
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
|
|
}
|
|
|
|
var strip = new TerminalTabs { DataContext = shell };
|
|
var window = LayoutHarness.HostAtMinimumSize(
|
|
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
|
|
try
|
|
{
|
|
// What it asks for, not what this host window gave it. Hosting it at 34 and then
|
|
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
|
|
// exactly the regression the budget needs catching.
|
|
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
|
|
|
|
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A tab lights under the pointer, and the button that opens one is not drawn as a tab.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The only test in this suite that reads a brush rather than a rectangle, and it is here because that
|
|
/// was the gap a real regression went through. Everything else measures heights and reachability, so a
|
|
/// strip whose tabs had silently stopped answering the pointer passed all of it.
|
|
/// </para>
|
|
/// <para>
|
|
/// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity —
|
|
/// the later declaration wins — and when the tab became a pill that paints its own background, that
|
|
/// background was declared *after* the hover rule it relied on and after the exceptions the <c>+</c>
|
|
/// is made of. So every tab lost its pointer feedback and the <c>+</c> gained a fill and an outline it
|
|
/// is specifically not supposed to have. Both are one assertion each below.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab()
|
|
{
|
|
await OnTheStripAsync(
|
|
(strip, window) =>
|
|
{
|
|
var tab = TabButton(strip, shell.Tabs[0]);
|
|
var resting = Fill(tab);
|
|
|
|
window.MouseMove(Centre(tab, window));
|
|
LayoutHarness.Settle(window, 900, 600);
|
|
|
|
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
|
|
|
Fill(tab).ShouldNotBe(
|
|
resting,
|
|
"a tab that does not change under the pointer is one nobody can tell is clickable");
|
|
|
|
// Off the strip again, so the plus is measured at rest rather than under the pointer.
|
|
window.MouseMove(new Point(0, 0));
|
|
LayoutHarness.Settle(window, 900, 600);
|
|
|
|
var plus = PlusButton(strip);
|
|
|
|
Fill(plus).ShouldNotBe(
|
|
Fill(TabButton(strip, shell.Tabs[0])),
|
|
"the button that opens a connection is not one of the connections");
|
|
|
|
Presenter(plus).BorderThickness.ShouldBe(
|
|
default(Thickness),
|
|
"it carries no outline, because it is not a thing being chosen between");
|
|
});
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <summary>The presenter the Fluent theme actually paints, which is where every button style lands.</summary>
|
|
private static ContentPresenter Presenter(Visual button) =>
|
|
button.GetVisualDescendants()
|
|
.OfType<ContentPresenter>()
|
|
.First(presenter => presenter.Name is "PART_ContentPresenter");
|
|
|
|
/// <remarks>
|
|
/// The colour rather than the brush. Two <see cref="ISolidColorBrush"/> instances holding the same
|
|
/// colour are not equal, and it is the colour a user sees.
|
|
/// </remarks>
|
|
private static Color? Fill(Visual button) =>
|
|
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
|
|
|
|
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
|
|
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
|
|
LayoutHarness.OnTheUiThreadAsync(
|
|
() =>
|
|
{
|
|
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
|
|
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
|
|
|
|
var strip = new TerminalTabs { DataContext = shell };
|
|
var window = new Window { Content = strip };
|
|
LayoutHarness.Settle(window, 900, 600);
|
|
|
|
try
|
|
{
|
|
body(strip, window);
|
|
}
|
|
finally
|
|
{
|
|
window.Close();
|
|
}
|
|
},
|
|
Token);
|
|
|
|
/// <remarks>
|
|
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
|
|
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
|
|
/// classes tell them apart.
|
|
/// </remarks>
|
|
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
|
|
strip.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
|
|
|
|
/// <inheritdoc cref="TabButton" />
|
|
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
|
|
strip.GetVisualDescendants()
|
|
.OfType<Button>()
|
|
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
|
|
|
|
/// <inheritdoc cref="TabButton" />
|
|
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");
|
|
}
|