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:
@@ -43,18 +43,31 @@ internal static class LayoutHarness
|
||||
internal const double NavRailWidth = 54;
|
||||
|
||||
/// <summary>
|
||||
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
|
||||
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
|
||||
/// pixel.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both are fixed heights declared in their own markup — 38 and 24 — rather than shapes that grow with
|
||||
/// their contents, which is what makes stating them here honest. Two tests hold the two controls to
|
||||
/// those numbers, so the budget below cannot drift away from what the window actually leaves.
|
||||
/// All three are fixed heights declared in their own markup — 38, 34 and 24 — rather than shapes that
|
||||
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
|
||||
/// controls to those numbers, so the budget below cannot drift away from what the window actually
|
||||
/// leaves.
|
||||
/// </remarks>
|
||||
internal const double TitleBarHeight = 38;
|
||||
|
||||
/// <inheritdoc cref="TitleBarHeight" />
|
||||
internal const double StatusBarHeight = 24;
|
||||
|
||||
/// <summary>
|
||||
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
|
||||
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
|
||||
/// and went would move every screen up and down by 34 pixels each time the last tab closed — so this is
|
||||
/// a fixed cost rather than a conditional one, and the budget can be a constant.
|
||||
/// </remarks>
|
||||
internal const double TerminalTabsHeight = 34;
|
||||
|
||||
/// <summary>
|
||||
/// What a setup card leaves its contents: its maximum width, less the padding on both sides.
|
||||
/// </summary>
|
||||
@@ -67,10 +80,24 @@ internal static class LayoutHarness
|
||||
internal const double CardContentWidth = 520 - (2 * 24);
|
||||
|
||||
/// <inheritdoc cref="CardContentWidth" />
|
||||
internal static double CardContentHeight => ScreenHeight - (2 * 24);
|
||||
/// <remarks>
|
||||
/// Measured against <see cref="ContentHeight"/> and not against <see cref="ScreenHeight"/>, which is a
|
||||
/// distinction the tab strip introduced and which is worth stating: a setup card is shown while the
|
||||
/// vault is <em>not</em> open, and the strip lives inside the unlocked half of the window. So the card
|
||||
/// gets the whole area between the titlebar and the status bar, and taking the strip off its budget
|
||||
/// would have this harness fail a card that fits.
|
||||
/// </remarks>
|
||||
internal static double CardContentHeight => ContentHeight - (2 * 24);
|
||||
|
||||
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
|
||||
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
||||
|
||||
/// <summary>The height a screen actually gets at the window's minimum.</summary>
|
||||
internal static double ScreenHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
|
||||
/// <remarks>
|
||||
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
|
||||
/// collapse when there are no tabs.
|
||||
/// </remarks>
|
||||
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
|
||||
|
||||
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
|
||||
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
|
||||
|
||||
@@ -4,10 +4,10 @@ using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
@@ -4,10 +4,12 @@ using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
@@ -179,6 +181,24 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Headings are rows in the same list as the hosts, drawn from a different template, and they are the
|
||||
/// widest thing in a 268-pixel column: a name, a chevron and a count on one line. Measured with one group
|
||||
/// folded, because a folded heading is the shape whose row is on screen without any of its hosts.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostSidebarFitsWithGroupHeadingsInTheList()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SidebarRows.OfType<SidebarGroupHeader>().Count()
|
||||
.ShouldBe(3, "one heading per group, and no ungrouped heading while nothing is ungrouped");
|
||||
|
||||
vault.ToggleGroupCommand.Execute(vault.SidebarRows.OfType<SidebarGroupHeader>().First());
|
||||
|
||||
await MeasureSidebarAsync(faults => faults.ShouldBeEmpty("with three headings and one folded"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one thing a wrong answer here breaks is unrecoverable from the keyboard: <c>MainWindow</c> takes
|
||||
@@ -302,12 +322,108 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull("a press on a row selects it");
|
||||
vault.Status.ShouldContain(
|
||||
"not in this vault any more",
|
||||
"not in this keychain any more",
|
||||
Case.Insensitive,
|
||||
"the double-click has to reach the connect command");
|
||||
});
|
||||
}
|
||||
|
||||
// ---- The hosts screen ----
|
||||
//
|
||||
// Measurable for the first time. Every rectangle below lived in MainWindow.axaml until the terminal
|
||||
// moved out from under it, and nothing in that window can be laid out here — so the connect banner, the
|
||||
// two host key prompts and the conflict log had never been through this harness at all. They are also
|
||||
// the four worst candidates for that: each appears only in a state somebody has to reproduce by hand.
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithNothingToAnnounce()
|
||||
{
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("the ordinary shape"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithAHostSelected()
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[0];
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the overview showing a host"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsBeingApproved()
|
||||
{
|
||||
vault.PendingHostKey = new HostKeyPresentation(
|
||||
"db.internal", 22, "ssh-ed25519", "SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the unknown-key prompt up"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAHostKeyIsRefused()
|
||||
{
|
||||
vault.HostKeyMismatch =
|
||||
"db.internal:22 presented ssh-ed25519 SHA256:8jkLPQ2mVvTnBqXfWzYc4RdEuHgNsA1oIpKlZbCxMv0, "
|
||||
+ "and this keychain has SHA256:6dPPMHRQGYRSHXBEmqBBIQVMlBfsAcHRDbmfMPWtpvI pinned for it.";
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the mismatch refusal up"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Twenty, because one is not the case that broke. The log sits on an <c>Auto</c> row above the overview,
|
||||
/// and an <c>ItemsControl</c> with no ceiling grows for as long as it has rows — so a pass that merged a
|
||||
/// vault's worth of items pushed everything below it off the bottom of a screen with nothing to scroll.
|
||||
/// It survived as long as it did because this markup was inside the window, where no test could reach it;
|
||||
/// finding it is what the extraction was for. The fix is the <c>ScrollViewer</c> and <c>MaxHeight</c> in
|
||||
/// <c>HostsScreen.axaml</c>, and this is what holds them there.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithAConflictLogTooLongToShow()
|
||||
{
|
||||
for (var i = 0; i < 20; i++)
|
||||
{
|
||||
vault.Conflicts.Add(new ConflictRowViewModel(new ConflictNotice(
|
||||
Guid.CreateVersion7(),
|
||||
Guid.CreateVersion7(),
|
||||
ConflictKind.FieldOverridden,
|
||||
$"'host-{i}' was changed on two machines, and the other machine's value was kept.",
|
||||
[],
|
||||
TimeProvider.System.GetUtcNow())));
|
||||
}
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with twenty merged conflicts to report"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The group panel is a row of its own at the foot of this screen, so it competes with the overview above
|
||||
/// it for the same column — and it grows sideways as groups are added, which is the direction a
|
||||
/// fixed-width column has least of. Six, because that is more than anybody's first three and enough to
|
||||
/// need the horizontal scroller rather than to overflow silently.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWithMoreGroupsThanTheRowHasRoomFor()
|
||||
{
|
||||
await SeedGroupsAsync(6);
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with six groups along the bottom"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The question replaces the buttons rather than stacking under them — the same rule the sidebar's own
|
||||
/// deletion follows — and it is the taller of the two, because it says how many hosts are about to move.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
|
||||
{
|
||||
await SeedGroupsAsync(3);
|
||||
|
||||
vault.SelectedGroup = vault.Groups[0];
|
||||
vault.DeleteGroupCommand.Execute(null);
|
||||
|
||||
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
|
||||
|
||||
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
|
||||
}
|
||||
|
||||
// ---- The vault screen ----
|
||||
|
||||
[Fact]
|
||||
@@ -315,7 +431,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
foreach (var section in new[]
|
||||
{
|
||||
VaultSection.All, VaultSection.Keys, VaultSection.Credentials, VaultSection.KnownHosts,
|
||||
VaultSection.All, VaultSection.Keys, VaultSection.Credentials,
|
||||
})
|
||||
{
|
||||
vault.Section = section;
|
||||
@@ -353,21 +469,197 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The detail pane with something selected, which is what the design's right-hand column is really about
|
||||
/// — and the pin is the one carrying a full fingerprint on a wrapped monospace line.
|
||||
/// The generate form, in the 244-pixel detail pane — two algorithm buttons side by side plus two
|
||||
/// paragraphs of explanation, in the narrowest column in the application. The paragraphs are the risk:
|
||||
/// they are what says the file has no passphrase, and a sentence pushed off the bottom is a limitation
|
||||
/// nobody was told about.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheVaultScreenFitsWithAPinSelected()
|
||||
public async Task TheVaultScreenFitsWithTheGenerateFormOpen()
|
||||
{
|
||||
vault.Section = VaultSection.KnownHosts;
|
||||
vault.VaultItems.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
|
||||
|
||||
vault.SelectedVaultItem = vault.VaultItems[0];
|
||||
vault.SelectedItemIsPin.ShouldBeTrue();
|
||||
vault.NewGeneratedKeyCommand.Execute(null);
|
||||
vault.IsGeneratingKey.ShouldBeTrue();
|
||||
|
||||
await MeasureVaultAsync(faults => faults.ShouldBeEmpty());
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Both drop highlights forced on at once, which is a state the screen never actually reaches — the
|
||||
/// point is that an overlay covering a whole pane does not change the layout of anything beneath it.
|
||||
/// It cannot check the thing most likely to be wrong, which is <c>IsHitTestVisible="False"</c>: an
|
||||
/// overlay that hit-tests lays out identically and swallows the events that would clear it. That one is
|
||||
/// in docs/manual-checks.md.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheTransfersScreenFitsWithTheDropHighlightsShowing()
|
||||
{
|
||||
transfers.IsLocalDropTarget = true;
|
||||
transfers.IsRemoteDropRefused = true;
|
||||
|
||||
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
|
||||
}
|
||||
|
||||
// ---- The import screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
|
||||
{
|
||||
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
|
||||
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
|
||||
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
|
||||
/// understands and one they think is broken.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
|
||||
{
|
||||
await MeasureImportAsync(
|
||||
faults => faults.ShouldBeEmpty("with a scanned list"),
|
||||
await ScannedImportAsync());
|
||||
}
|
||||
|
||||
// ---- The host keys screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostKeysScreenFitsWithNothingApprovedYet()
|
||||
{
|
||||
foreach (var pin in vault.KnownHostPins.ToList())
|
||||
{
|
||||
await knownHosts.ForgetAsync(pin.Host, pin.Port, Token);
|
||||
}
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
vault.KnownHostPins.ShouldBeEmpty();
|
||||
|
||||
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("the empty state"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The shape the column widths were chosen for. A fingerprint is never trimmed — comparing a shortened
|
||||
/// one against a published one is not something anybody can do — so this table has one column that
|
||||
/// refuses to give ground, and this is what says the rest still fits beside it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheHostKeysScreenFitsWithPinsAndOneSelected()
|
||||
{
|
||||
var pins = new KnownHostsViewModel(vault);
|
||||
pins.VisiblePins.ShouldNotBeEmpty("an empty list is the easy case and proves nothing here");
|
||||
pins.Selected = pins.VisiblePins[0];
|
||||
|
||||
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with a pin selected"), pins);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheHostKeysScreenFitsWhenTheFilterMatchesNothing()
|
||||
{
|
||||
var pins = new KnownHostsViewModel(vault) { Filter = "no such fingerprint" };
|
||||
pins.VisiblePins.ShouldBeEmpty();
|
||||
|
||||
await MeasurePinsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), pins);
|
||||
}
|
||||
|
||||
// ---- The logs screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheLogsScreenFitsWithNeitherLogWrittenTo()
|
||||
{
|
||||
await MeasureLogsAsync(faults => faults.ShouldBeEmpty("the empty state"), LogSection.Connections);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Six columns in one row, and the two widest — an address and a device name — are both variable. A
|
||||
/// connection still open is measured alongside the finished ones because its row carries the longest
|
||||
/// value the LASTED column ever holds: the words "still open" rather than a duration.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheConnectionLogFitsWithALiveRowAndAFinishedOne()
|
||||
{
|
||||
var logs = await SeedLogsAsync();
|
||||
|
||||
logs.Connections.ShouldNotBeEmpty();
|
||||
logs.Connections.Any(row => row.IsLive).ShouldBeTrue("the live row is the wide one");
|
||||
|
||||
await MeasureLogsAsync(
|
||||
faults => faults.ShouldBeEmpty("with a live connection above a finished one"),
|
||||
LogSection.Connections,
|
||||
logs);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has eleven of them.
|
||||
/// Measured with an edit that touched several, because one field name fits anywhere.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
|
||||
{
|
||||
var logs = await SeedLogsAsync();
|
||||
|
||||
logs.Section = LogSection.Activity;
|
||||
logs.Activity.ShouldNotBeEmpty();
|
||||
|
||||
await MeasureLogsAsync(
|
||||
faults => faults.ShouldBeEmpty("with the keychain log showing"), LogSection.Activity, logs);
|
||||
}
|
||||
|
||||
// ---- The snippets screen ----
|
||||
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWithNothingSavedYet()
|
||||
{
|
||||
vault.Snippets.ShouldBeEmpty("the seed makes none, which is what a new keychain looks like");
|
||||
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("the empty state"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The detail pane's longest shape: a multi-line command in a box, its notes, two buttons and the
|
||||
/// paragraph saying what a terminal will do with it — in a 300-pixel column. Measured with a snippet
|
||||
/// that runs, because that is the one with the extra button.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWithAMultiLineSnippetSelected()
|
||||
{
|
||||
await SeedSnippetsAsync();
|
||||
|
||||
var snippets = NewSnippetsScreen(new InsertTarget(1, "prod-db"));
|
||||
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
|
||||
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with a running snippet selected"), snippets);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The editor, which is the tallest thing on this screen: a name, a 140-pixel command box, notes, the
|
||||
/// checkbox and the paragraph explaining what leaving it off buys.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWithItsEditorOpen()
|
||||
{
|
||||
await SeedSnippetsAsync();
|
||||
|
||||
var snippets = NewSnippetsScreen();
|
||||
snippets.Selected = snippets.Visible[0];
|
||||
snippets.EditCommand.Execute(null);
|
||||
|
||||
snippets.IsEditing.ShouldBeTrue();
|
||||
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
|
||||
{
|
||||
await SeedSnippetsAsync();
|
||||
|
||||
var snippets = NewSnippetsScreen();
|
||||
snippets.Filter = "no such command";
|
||||
snippets.Visible.ShouldBeEmpty();
|
||||
|
||||
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the filter matching nothing"), snippets);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The detail pane with the question in place of EDIT and DELETE, in its longest shape: a key several
|
||||
/// hosts authenticate with, which is three sentences and a box in the narrowest column in the
|
||||
@@ -574,12 +866,15 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Five destinations in a 54-pixel column. The rail runs vertically, so what runs out here is height
|
||||
/// rather than width — at the window's minimum the five entries have to leave room for each other, which
|
||||
/// is the same failure the old four-button selector was one label away from.
|
||||
/// Eight destinations in a 54-pixel column. 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheNavRailHoldsFiveDestinationsAtTheWindowsMinimum()
|
||||
public async Task TheNavRailHoldsEightDestinationsAtTheWindowsMinimum()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
@@ -592,7 +887,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
{
|
||||
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
|
||||
|
||||
buttons.Count.ShouldBe(5, "one per screen the rail reaches");
|
||||
buttons.Count.ShouldBe(8, "one per screen the rail reaches");
|
||||
|
||||
foreach (var button in buttons)
|
||||
{
|
||||
@@ -640,7 +935,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
shell.StatusMessage = "Your sign-in has expired, so this machine is offline: the token endpoint "
|
||||
+ "returned 400: Invalid refresh token. Sign in again from Preferences to start syncing.";
|
||||
|
||||
await MeasureCardAsync(new UnlockCard());
|
||||
await MeasureCardAsync(static () => new UnlockCard());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -698,14 +993,22 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
shell.LiveSessionCount = 1;
|
||||
shell.Transfers.IsConnected = true;
|
||||
|
||||
await MeasureCardAsync(new SignOutCard());
|
||||
await MeasureCardAsync(static () => new SignOutCard());
|
||||
}
|
||||
|
||||
/// <summary>Lays a setup-screen card out in the space <c>Border.card</c> gives its contents.</summary>
|
||||
private Task MeasureCardAsync(Control card) =>
|
||||
/// <remarks>
|
||||
/// The card is <em>built</em> inside the dispatched call rather than passed in already constructed, and
|
||||
/// that is not style. Avalonia binds <c>Dispatcher.UIThread</c> to whichever thread first asks for it, so
|
||||
/// a control constructed on the test thread before any other test has dispatched makes that thread the
|
||||
/// UI thread — and every later property set from the harness's own thread then throws. It depends on the
|
||||
/// order the tests happen to run in, which is why it survived until a phase that added new ones.
|
||||
/// </remarks>
|
||||
private Task MeasureCardAsync(Func<Control> build) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var card = build();
|
||||
card.DataContext = shell;
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
@@ -748,6 +1051,250 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the hosts screen out at the size it gets beside the nav rail and under the strip.</summary>
|
||||
/// <remarks>
|
||||
/// The shell is the data context, not the vault — the sidebar is handed the vault from inside the
|
||||
/// screen's own markup. <see cref="MainWindowViewModel.Vault"/> is assigned rather than reached through
|
||||
/// an unlock, which would be a second enrollment for no extra rectangle.
|
||||
/// </remarks>
|
||||
private Task MeasureHostsAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
shell.Vault = vault;
|
||||
shell.State = ShellState.Unlocked;
|
||||
|
||||
var screen = new HostsScreen { DataContext = shell };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasureImportAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
ImportViewModel? import = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new ImportScreen
|
||||
{
|
||||
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
|
||||
};
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>
|
||||
/// An import view model that has scanned a real file, so the table has rows in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through a temporary directory rather than by populating the rows directly, because the shape being
|
||||
/// measured is what the parser produces — an entry with two warnings under it is taller than one
|
||||
/// without, and inventing the rows would measure a layout nothing generates.
|
||||
/// </remarks>
|
||||
private async Task<ImportViewModel> ScannedImportAsync()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(directory, "config"),
|
||||
"""
|
||||
Host *
|
||||
ServerAliveInterval 30
|
||||
|
||||
Host prod-db
|
||||
HostName database.production.internal
|
||||
User deploy
|
||||
Port 2222
|
||||
IdentityFile ~/.ssh/id_ed25519
|
||||
|
||||
Host bastion-eu-west-1
|
||||
HostName bastion.eu-west-1.example.com
|
||||
User ops
|
||||
ProxyCommand nc %h %p
|
||||
Compression yes
|
||||
compression no
|
||||
|
||||
Match host anything
|
||||
User root
|
||||
""");
|
||||
|
||||
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
|
||||
|
||||
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
|
||||
// table — which is the other test.
|
||||
await import.ScanCommand.ExecuteAsync(null);
|
||||
|
||||
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
|
||||
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
|
||||
|
||||
return import;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasurePinsAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
KnownHostsViewModel? pins = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new KnownHostsScreen { DataContext = pins ?? new KnownHostsViewModel(vault) };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <summary>Lays the logs screen out at the size it gets beside the nav rail.</summary>
|
||||
private Task MeasureLogsAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
LogSection section,
|
||||
LogsViewModel? logs = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var model = logs ?? NewLogsScreen();
|
||||
model.Section = section;
|
||||
|
||||
var screen = new LogsScreen { DataContext = model };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private LogsViewModel NewLogsScreen(params LiveConnection[] live) =>
|
||||
new(session, () => live);
|
||||
|
||||
/// <summary>
|
||||
/// Writes one of each kind of entry and reads them back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Through the repositories the recorders write to, rather than through the recorders themselves: those
|
||||
/// write on a background task on purpose, and a layout suite that waited on one would be measuring
|
||||
/// rectangles behind a race.
|
||||
/// </remarks>
|
||||
private async Task<LogsViewModel> SeedLogsAsync()
|
||||
{
|
||||
await session.ConnectionLog.CreateAsync(
|
||||
session.ActiveVaultId,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = "customer-a-production-database",
|
||||
Address = "deployment-account@db-01.customer-a.internal:22022",
|
||||
StartedAt = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
Duration = TimeSpan.FromMinutes(74),
|
||||
Outcome = ConnectionOutcome.Refused,
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
await session.ActivityLog.CreateAsync(
|
||||
session.ActiveVaultId,
|
||||
new ActivityLogSecret
|
||||
{
|
||||
ItemKind = "Host",
|
||||
ItemId = Guid.CreateVersion7(),
|
||||
ItemLabel = "customer-a-production-database",
|
||||
Operation = ActivityOperation.Updated,
|
||||
ChangedFields = "Hostname, Port, Username, Options, Group",
|
||||
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
Token);
|
||||
|
||||
var logs = NewLogsScreen(new LiveConnection(
|
||||
"customer-a-production-database",
|
||||
"deployment-account@db-01.customer-a.internal:22022",
|
||||
new DateTimeOffset(2026, 7, 31, 8, 0, 0, TimeSpan.Zero),
|
||||
"jaap-jan-workstation"));
|
||||
|
||||
await logs.ReloadAsync(Token);
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
/// <summary>Lays the snippets screen out at the size it gets beside the nav rail.</summary>
|
||||
/// <remarks>
|
||||
/// The insert function throws. Nothing measured here presses a button, and a substitute that returned a
|
||||
/// plausible answer would make it possible to write a layout test that quietly exercised the transport.
|
||||
/// </remarks>
|
||||
private Task MeasureSnippetsAsync(
|
||||
Action<IReadOnlyList<string>> assert,
|
||||
SnippetsViewModel? snippets = null) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new SnippetsScreen { DataContext = snippets ?? NewSnippetsScreen() };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
assert(LayoutHarness.Unreachable(window));
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
|
||||
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
|
||||
new(
|
||||
vault,
|
||||
() => target ?? InsertTarget.None,
|
||||
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>
|
||||
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
|
||||
LayoutHarness.OnTheUiThreadAsync(
|
||||
@@ -868,4 +1415,69 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
|
||||
await vault.LoadAsync(Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds snippets, including the two shapes that decide this screen's height.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not part of <see cref="SeedAsync"/>, so the empty state stays measurable — and because most keychains
|
||||
/// have none, which is the shape somebody sees the first time they open the screen.
|
||||
/// </remarks>
|
||||
private async Task SeedSnippetsAsync()
|
||||
{
|
||||
await vault.SaveSnippetAsync(
|
||||
null,
|
||||
new SnippetSecret
|
||||
{
|
||||
Label = "tail the application log",
|
||||
Command = "sudo journalctl -u dodossh-api -f --since '10 minutes ago'",
|
||||
Notes = "Ctrl+C to stop.",
|
||||
},
|
||||
Token);
|
||||
|
||||
await vault.SaveSnippetAsync(
|
||||
null,
|
||||
new SnippetSecret
|
||||
{
|
||||
Label = "restart the api",
|
||||
Command = "sudo systemctl daemon-reload\nsudo systemctl restart dodossh-api\nsystemctl status dodossh-api --no-pager",
|
||||
Notes = "Check the on-call rota before running this in production.",
|
||||
RunsOnInsert = true,
|
||||
},
|
||||
Token);
|
||||
|
||||
vault.Snippets.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds groups and files the seeded hosts across them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not part of <see cref="SeedAsync"/>, on purpose. A vault with no groups is what a new one is and what
|
||||
/// most of them stay, and it is the shape in which the sidebar draws no headings at all — so it has to
|
||||
/// remain the one every other test here measures.
|
||||
/// </remarks>
|
||||
private async Task SeedGroupsAsync(int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
vault.GroupEditorLabel = $"customer-{i}-production";
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
vault.Groups.Count.ShouldBe(count);
|
||||
|
||||
// Filed through the host editor, which is the only way a user can do it, so this also exercises the
|
||||
// picker the sidebar's headings are built out of.
|
||||
for (var i = 0; i < vault.Hosts.Count; i++)
|
||||
{
|
||||
vault.SelectedHost = vault.Hosts[i];
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices
|
||||
.First(choice => choice.EntityId == vault.Groups[i % count].EntityId);
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -485,6 +485,8 @@
|
||||
"Avalonia.Fonts.Inter": "[12.1.1, )",
|
||||
"Avalonia.Themes.Fluent": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Shell": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
@@ -498,6 +500,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -506,7 +523,8 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.shell": {
|
||||
@@ -514,6 +532,8 @@
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -523,6 +543,7 @@
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -618,6 +639,21 @@
|
||||
"Avalonia": "12.1.1"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
Reference in New Issue
Block a user