Public Access
Merge branch 'main' into the Android head
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.
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
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");
|
||||
}
|
||||
Reference in New Issue
Block a user