Public Access
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
293 lines
11 KiB
C#
293 lines
11 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>
|
|
/// 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();
|
|
});
|
|
}
|
|
|
|
/// <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);
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
/// <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"));
|
|
|
|
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");
|
|
}
|