diff --git a/Directory.Packages.props b/Directory.Packages.props
index adfe7d5..ff9358b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -109,6 +109,14 @@
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
index 1e0272f..5489fa2 100644
--- a/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
+++ b/src/DodoSSH.Client.App/DodoSSH.Client.App.csproj
@@ -36,6 +36,12 @@
state machine is testable as ordinary code. That is the whole reason the sign-in step is a delegate.
-->
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml b/src/DodoSSH.Client.App/Views/MainWindow.axaml
index 6063abf..40a4941 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml
@@ -1,6 +1,7 @@
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index 9deca5d..16beb0d 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -44,7 +44,9 @@ internal sealed partial class MainWindow : Window
// posted by the page arrives in Body verbatim.
if (string.Equals(e.Body, ReleaseFocusMessage, StringComparison.Ordinal))
{
- ReleaseKeyboardTo(HostList);
+ // The column decides which of its lists the keyboard belongs to; this window only decides
+ // that the keyboard should leave the terminal.
+ ReleaseKeyboardTo(VaultPane.KeyboardTarget);
}
};
}
diff --git a/src/DodoSSH.Client.App/Views/VaultColumn.axaml b/src/DodoSSH.Client.App/Views/VaultColumn.axaml
new file mode 100644
index 0000000..ab4a8dd
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/VaultColumn.axaml
@@ -0,0 +1,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/VaultColumn.axaml.cs b/src/DodoSSH.Client.App/Views/VaultColumn.axaml.cs
new file mode 100644
index 0000000..3349197
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/VaultColumn.axaml.cs
@@ -0,0 +1,27 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// The vault's left-hand column: the lists of items, and the editor for whichever one is open.
+///
+///
+/// Its data context is the VaultViewModel, so every binding in the markup is a property of the vault
+/// rather than of the shell. The shell hands it over; see .
+///
+internal sealed partial class VaultColumn : UserControl
+{
+ public VaultColumn() => InitializeComponent();
+
+ ///
+ /// Where the keyboard should land when the terminal hands it back.
+ ///
+ ///
+ /// Exposed as a property rather than left for the window to find by name, because the name is now inside
+ /// this control's template and the window cannot see it. Which is the better arrangement anyway: when
+ /// this column shows one list at a time, "the list the keyboard belongs to" is a question only the column
+ /// can answer, and answering it here means the window never has to know how many lists there are.
+ ///
+ internal IInputElement KeyboardTarget => HostList;
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/DodoSSH.Client.App.Layout.Tests.csproj b/tests/DodoSSH.Client.App.Layout.Tests/DodoSSH.Client.App.Layout.Tests.csproj
new file mode 100644
index 0000000..7b4f7a0
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/DodoSSH.Client.App.Layout.Tests.csproj
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HeadlessApp.cs b/tests/DodoSSH.Client.App.Layout.Tests/HeadlessApp.cs
new file mode 100644
index 0000000..70db435
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/HeadlessApp.cs
@@ -0,0 +1,36 @@
+using Avalonia;
+using Avalonia.Headless;
+using DodoSSH.Client.App.Layout.Tests;
+
+[assembly: AvaloniaTestApplication(typeof(HeadlessApp))]
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// Builds the real application for a headless renderer.
+///
+///
+///
+/// itself, not a stand-in, because the thing being measured is how the shipped
+/// styles lay out — a harness with its own theme would measure a window nobody sees. Its
+/// OnFrameworkInitializationCompleted only composes the object graph when the lifetime is a classic
+/// desktop one, and a headless session's is not, so loading it here brings the Fluent theme and the dark
+/// variant and starts no vault, no listener and no browser.
+///
+///
+/// Skia rather than the headless drawing stub, which is the one deliberate cost in this file. The stub's
+/// font manager returns invented glyph metrics, and text height is an input to every stacked panel in this
+/// window — measuring against it would produce numbers that are self-consistent and unrelated to the
+/// application. With Skia and the same Inter font the application registers, a measured height is the
+/// height a user gets.
+///
+///
+internal static class HeadlessApp
+{
+ /// Found by name; is the contract.
+ public static AppBuilder BuildAvaloniaApp() =>
+ AppBuilder.Configure()
+ .UseSkia()
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false })
+ .WithInterFont();
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
new file mode 100644
index 0000000..4b756d6
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarness.cs
@@ -0,0 +1,185 @@
+using System.Globalization;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// Lays out real XAML at a real size and reports anything a user could not click.
+///
+///
+///
+/// The defect this exists for is a control arranged past the edge of its container. It is invisible to every
+/// other suite here — no other test loads a .axaml — and this window has shipped it once, when the
+/// setup screens rendered sliced with their buttons unreachable at the default width.
+///
+///
+/// A control inside a is exempt, and that exemption is load-bearing rather than a
+/// convenience: a list longer than its viewport is the normal case, and treating a row scrolled out of sight
+/// as a defect would make this harness cry wolf on every populated list. What is left after the exemption is
+/// the class of thing that has no way to come back into view.
+///
+///
+internal static class LayoutHarness
+{
+ /// The window's own declared minimum, which is the size that has to work.
+ ///
+ /// Taken from MainWindow.axaml's MinWidth/MinHeight by hand. A test asserts these
+ /// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
+ /// than the one a user is allowed to drag to.
+ ///
+ internal const double MinimumWidth = 820;
+
+ ///
+ internal const double MinimumHeight = 520;
+
+ /// The vault column's fixed width, from MainWindow's ColumnDefinitions.
+ internal const double VaultColumnWidth = 340;
+
+ ///
+ /// What the account bar takes off the top before the column gets any height at all.
+ ///
+ ///
+ /// The bar is Padding="12,8" around a row whose tallest child is a themed , so
+ /// its height is the button's plus sixteen. Stated as a constant with a test holding the button to
+ /// thirty-two rather than measured from the bar itself, because measuring the bar would mean showing
+ /// MainWindow, and that cannot be done here at all — see the harness's own tests.
+ ///
+ internal const double AccountBarHeight = 48;
+
+ /// The height the column actually gets at the window's minimum.
+ internal static double VaultColumnHeight => MinimumHeight - AccountBarHeight;
+
+ private static readonly HeadlessUnitTestSession Session =
+ HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
+
+ ///
+ /// Runs one body on Avalonia's dispatcher thread.
+ ///
+ ///
+ /// Everything that touches a control has to happen here. The session owns the thread and the
+ /// application, so this is also what serialises the suite — Avalonia's platform is process-global and
+ /// two tests laying out windows at once would share one dispatcher.
+ ///
+ internal static Task OnTheUiThreadAsync(Action body, CancellationToken cancellationToken) =>
+ Session.Dispatch(body, cancellationToken);
+
+ /// Shows a window at a given size and lets layout finish.
+ internal static void Settle(Window window, double width, double height)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ window.Width = width;
+ window.Height = height;
+
+ // None, because a headless window still reserves space for decorations it does not draw, and the
+ // budget being measured is the client area the application actually gets.
+ window.WindowDecorations = WindowDecorations.None;
+
+ window.Show();
+
+ // Show() queues layout rather than performing it. Without this the tree is measured but not
+ // arranged, and every Bounds read below would be a zero rectangle — which would make this harness
+ // report the whole window as unreachable, or worse, report nothing at all.
+ Dispatcher.UIThread.RunJobs();
+ window.UpdateLayout();
+ }
+
+ /// Wraps a control in a host window sized to the application's minimum.
+ internal static Window HostAtMinimumSize(Control content, double width, double height)
+ {
+ var window = new Window { Content = content };
+ Settle(window, width, height);
+ return window;
+ }
+
+ ///
+ /// Every interactive control that is laid out where it cannot be used, described for a failure message.
+ ///
+ ///
+ /// Returns descriptions rather than controls because the value of this harness is entirely in what it
+ /// says when it fails: "something is clipped" sends the reader back to a 500-line XAML file, while
+ /// "Button 'Save' at 8,486 486x32 falls outside 820x520" names the control and the edge it crossed.
+ ///
+ internal static IReadOnlyList Unreachable(Window window)
+ {
+ ArgumentNullException.ThrowIfNull(window);
+
+ var client = new Rect(window.ClientSize);
+ var found = new List();
+
+ foreach (var control in window.GetVisualDescendants().OfType())
+ {
+ if (Fault(control, client, window) is { } fault)
+ {
+ found.Add(fault);
+ }
+ }
+
+ return found;
+ }
+
+ private static string? Fault(Control control, Rect client, Visual window)
+ {
+ if (!IsInteractive(control) || !control.IsEffectivelyVisible || IsScrollable(control))
+ {
+ return null;
+ }
+
+ if (control.TranslatePoint(default, window) is not { } origin)
+ {
+ return null;
+ }
+
+ var box = new Rect(origin, control.Bounds.Size);
+
+ // Zero size is a defect for a control the theme gives a height to and a normal state for one sized by
+ // its content: an empty list is zero pixels tall and correct, a squashed button is neither. Learned
+ // from this firing on KeyList in a vault with no keys in it.
+ if (control is not ListBox && (box.Width <= 0 || box.Height <= 0))
+ {
+ return Describe(control, box, client, "was arranged with no size");
+ }
+
+ return client.Contains(box) ? null : Describe(control, box, client, "falls outside the window");
+ }
+
+ ///
+ /// The controls a user has to be able to reach. A clipped is a cosmetic problem
+ /// and a clipped is a dead end, so only the second kind is worth failing a build
+ /// over — and keeping the list short is what stops this harness from becoming a pixel-diff nobody
+ /// trusts.
+ ///
+ private static bool IsInteractive(Control control) =>
+ control is Button or TextBox or CheckBox or ComboBox or NumericUpDown or ListBox;
+
+ private static bool IsScrollable(Control control) =>
+ control.GetVisualAncestors().OfType().Any();
+
+ private static string Describe(Control control, Rect box, Rect client, string fault)
+ {
+ var name = control.Name is { Length: > 0 } named ? $" '{named}'" : Label(control);
+
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"{control.GetType().Name}{name} {fault}: {Format(box)} is not inside {Format(client)}");
+ }
+
+ ///
+ /// A button's caption, because "Save" identifies the control to a reader far better than its position
+ /// in a visual tree does.
+ ///
+ private static string Label(Control control) => control switch
+ {
+ Button { Content: string caption } => $" '{caption}'",
+ TextBox { PlaceholderText: { Length: > 0 } placeholder } => $" (placeholder '{placeholder}')",
+ _ => string.Empty,
+ };
+
+ private static string Format(Rect rect) => string.Create(
+ CultureInfo.InvariantCulture,
+ $"{rect.X:0.#},{rect.Y:0.#} {rect.Width:0.#}x{rect.Height:0.#}");
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
new file mode 100644
index 0000000..a31b33a
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/LayoutHarnessTests.cs
@@ -0,0 +1,192 @@
+using System.Runtime.InteropServices;
+using Avalonia.Controls;
+using DodoSSH.Client.App.Views;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// The harness measuring itself.
+///
+///
+/// A clipping detector that never fires is worse than no detector, because it reads as a guarantee. So the
+/// deliberately-broken window below is the most important test in this file: it is the one that proves the
+/// green ones mean something. This is the same practice §5 of the handoff describes — a test proves nothing
+/// until it has been seen to fail — applied to the instrument rather than to the code.
+///
+public sealed class LayoutHarnessTests
+{
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ [Fact]
+ public async Task AWindowWithRoomToSpare_ReportsNothing()
+ {
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var window = LayoutHarness.HostAtMinimumSize(
+ new Button { Content = "Save" },
+ LayoutHarness.MinimumWidth,
+ LayoutHarness.MinimumHeight);
+
+ try
+ {
+ LayoutHarness.Unreachable(window).ShouldBeEmpty();
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ [Fact]
+ public async Task AButtonPushedPastTheBottomEdge_IsReportedByName()
+ {
+ // The instrument's own calibration. A stack taller than its window is exactly the shape of the
+ // defect the vault column is one editor away from, and if this passes silently the harness is
+ // decoration.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var stack = new StackPanel();
+
+ for (var i = 0; i < 8; i++)
+ {
+ stack.Children.Add(new Button { Content = i == 7 ? "Save" : $"filler {i}" });
+ }
+
+ var window = LayoutHarness.HostAtMinimumSize(stack, 300, 120);
+
+ try
+ {
+ var faults = LayoutHarness.Unreachable(window);
+
+ faults.ShouldNotBeEmpty();
+ faults.ShouldContain(fault => fault.Contains("'Save'", StringComparison.Ordinal));
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ [Fact]
+ public async Task AListLongerThanItsViewport_IsNotAFault()
+ {
+ // The exemption that keeps this harness usable. Scrolling is how a list is supposed to handle more
+ // rows than fit; without this the host list would fail the moment it had content.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var stack = new StackPanel();
+
+ for (var i = 0; i < 40; i++)
+ {
+ stack.Children.Add(new Button { Content = $"row {i}" });
+ }
+
+ var window = LayoutHarness.HostAtMinimumSize(
+ new ScrollViewer { Content = stack },
+ 300,
+ 120);
+
+ try
+ {
+ LayoutHarness.Unreachable(window).ShouldBeEmpty();
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ [Fact]
+ public async Task TheWholeWindowsXamlParses()
+ {
+ // Constructing it runs InitializeComponent, so this is what catches malformed XAML, a style selector
+ // that no longer resolves or a converter reference that has gone stale. Cheap, and it covers the
+ // whole file including the setup cards no other test here touches.
+ //
+ // Constructed and never shown, deliberately — see WhyTheWindowItselfIsNeverShown.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var window = new MainWindow();
+
+ try
+ {
+ window.Content.ShouldNotBeNull();
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ [Fact]
+ public async Task WhyTheWindowItselfIsNeverShown()
+ {
+ // Measured, not assumed, and pinned here so nobody spends an afternoon rediscovering it.
+ //
+ // Showing MainWindow attaches the terminal's NativeWebView, whose Win32 adapter initialises WebView2
+ // on attach — and WebView2 refuses an MTA thread, which is exactly why Program.Main is [STAThread].
+ // A HeadlessUnitTestSession owns its dispatcher thread and does not offer an apartment choice, so
+ // the whole window cannot be laid out here at any size.
+ //
+ // That is the reason this harness measures VaultColumn rather than MainWindow: the column is the
+ // part with a height budget to blow, and it has no native child window in it. If a future Avalonia
+ // makes the adapter lazy, this test starts failing and the harness can be widened.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var window = new MainWindow();
+
+ try
+ {
+ var showing = Should.Throw(() => LayoutHarness.Settle(
+ window,
+ LayoutHarness.MinimumWidth,
+ LayoutHarness.MinimumHeight));
+
+ // RPC_E_CHANGED_MODE. Asserted on the code rather than the message so a localised
+ // Windows does not break the build.
+ showing.HResult.ShouldBe(unchecked((int)0x80010106));
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+
+ [Fact]
+ public async Task TheHarnessMeasuresTheSizeTheWindowDeclares()
+ {
+ // Pins the two constants against the XAML. A harness measuring 820x520 while the window lets itself
+ // be dragged to something smaller would be certifying a size no user is held to.
+ await LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var window = new MainWindow();
+
+ try
+ {
+ window.MinWidth.ShouldBe(LayoutHarness.MinimumWidth);
+ window.MinHeight.ShouldBe(LayoutHarness.MinimumHeight);
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/VaultColumnLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/VaultColumnLayoutTests.cs
new file mode 100644
index 0000000..c557f95
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/VaultColumnLayoutTests.cs
@@ -0,0 +1,174 @@
+using Avalonia.Controls;
+using DodoSSH.Client.App.ViewModels;
+using DodoSSH.Client.App.Views;
+using DodoSSH.Client.Session;
+using DodoSSH.Client.Session.Tests;
+using DodoSSH.Client.Ssh;
+using DodoSSH.Client.Storage;
+using DodoSSH.Client.Terminal;
+using DodoSSH.Crypto;
+using NSubstitute;
+
+namespace DodoSSH.Client.App.Layout.Tests;
+
+///
+/// Whether the vault column fits in the space the window gives it.
+///
+///
+///
+/// The column is 340 pixels wide and holds a list and an editor per item type, and the only thing keeping it
+/// from clipping its own Save button at the window's minimum height is a state rule — one editor open at a
+/// time. That rule was added on the strength of an argument, not a measurement, and this suite is the
+/// measurement.
+///
+///
+/// A real VaultViewModel over a real unlocked vault, rather than a stand-in. Compiled bindings resolve
+/// against the declared data type, so a stand-in would have to be the same type anyway — and the editors'
+/// height depends on real content: a key with a real armour block in the box is taller than an empty one.
+///
+///
+public sealed class VaultColumnLayoutTests : IAsyncLifetime
+{
+ private const string Passphrase = "a sufficiently long passphrase";
+ private const string ServerUrl = "https://dodossh.example";
+
+ /// Far below the shipped profile: nothing here attacks a wrap.
+ private static readonly Argon2Profile CheapProfile =
+ Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
+
+ private readonly FakeAccountServer server = new();
+ private readonly StubKeyBinding keyBinding = new();
+ private readonly VaultKnownHostStore knownHosts = new();
+
+ private ClientCacheFactory caches = null!;
+ private TerminalWorkspace workspace = null!;
+ private VaultSession session = null!;
+ private VaultViewModel vault = null!;
+
+ private static CancellationToken Token => TestContext.Current.CancellationToken;
+
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ caches = ClientCacheFactory.ForMemory($"layout-{Guid.CreateVersion7():N}");
+ await caches.MigrateAsync(Token);
+
+ await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
+ .EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
+
+ var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
+ outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
+ session = outcome.Session!;
+
+ // Never started and never connected through: the column's layout does not depend on the terminal, and
+ // the substitute is here only because the view model's constructor asks for one.
+ workspace = new TerminalWorkspace(
+ new InMemoryTerminalAssetProvider(new Dictionary(StringComparer.Ordinal)),
+ Substitute.For(),
+ TimeProvider.System);
+
+ await knownHosts.OpenAsync(session, Token);
+
+ // Offline. A null connection is what the column shows on a laptop with no network, and it keeps every
+ // sync pass out of a suite that is only measuring rectangles.
+ vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
+
+ await SeedAsync();
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await vault.DisposeAsync();
+ knownHosts.Close();
+ await workspace.DisposeAsync();
+ await session.DisposeAsync();
+ caches.Dispose();
+ }
+
+ [Fact]
+ public async Task TheColumnFitsWithNoEditorOpen()
+ {
+ await MeasureAsync(faults => faults.ShouldBeEmpty());
+ }
+
+ [Fact]
+ public async Task TheColumnFitsWithTheHostEditorOpen()
+ {
+ vault.NewHostCommand.Execute(null);
+ vault.IsEditing.ShouldBeTrue();
+
+ await MeasureAsync(faults => faults.ShouldBeEmpty());
+ }
+
+ [Fact]
+ public async Task TheColumnFitsWithTheKeyEditorOpen()
+ {
+ // The tall one: a private key needs a real text area, and this is the editor the MaxHeight on the key
+ // list exists to make room for.
+ vault.NewKeyCommand.Execute(null);
+ vault.IsEditingKey.ShouldBeTrue();
+
+ vault.KeyEditorPrivateKey = string.Join(
+ '\n',
+ Enumerable.Repeat("b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gt", 6));
+
+ await MeasureAsync(faults => faults.ShouldBeEmpty());
+ }
+
+ [Fact]
+ public async Task BothEditorsAtOnce_DoNotFit_WhichIsWhyTheRuleExists()
+ {
+ // The justification for KeyEditorIsInTheWay/HostEditorIsInTheWay, turned from an argument in a comment
+ // into a number. The flags are set directly because the commands refuse this on purpose — the point is
+ // to measure what the refusal is protecting.
+ //
+ // If this test ever starts passing, the rule has become unnecessary and the comments claiming it is
+ // load-bearing have become false. That is a finding, not a flake: read it as "the column has room
+ // now", check what changed, and delete the rule rather than this test.
+ vault.IsEditing = true;
+ vault.IsEditingKey = true;
+
+ await MeasureAsync(faults => faults.ShouldNotBeEmpty(
+ "one editor at a time is a workaround for a column that cannot hold two"));
+ }
+
+ /// Lays the column out at the size the window gives it and hands the faults to an assertion.
+ private Task MeasureAsync(Action> assert) =>
+ LayoutHarness.OnTheUiThreadAsync(
+ () =>
+ {
+ var window = LayoutHarness.HostAtMinimumSize(
+ new VaultColumn { DataContext = vault },
+ LayoutHarness.VaultColumnWidth,
+ LayoutHarness.VaultColumnHeight);
+
+ try
+ {
+ assert(LayoutHarness.Unreachable(window));
+ }
+ finally
+ {
+ window.Close();
+ }
+ },
+ Token);
+
+ ///
+ /// Enough rows that the lists are not empty, because an empty list is the easiest case and the one least
+ /// worth certifying.
+ ///
+ private async Task SeedAsync()
+ {
+ for (var i = 0; i < 6; i++)
+ {
+ vault.NewHostCommand.Execute(null);
+ vault.EditorLabel = $"host-{i}";
+ vault.EditorHostname = $"host-{i}.internal";
+ vault.EditorUsername = "deploy";
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ }
+
+ await vault.LoadAsync(Token);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json b/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json
new file mode 100644
index 0000000..7bef767
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/packages.lock.json
@@ -0,0 +1,722 @@
+{
+ "version": 2,
+ "dependencies": {
+ "net10.0": {
+ "Avalonia.Headless": {
+ "type": "Direct",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "wyRPmeYuQrdcgZg5hgbxXbU8+zSvIDH9J2Dk4QmxStHcqrEKhhyGqVvDzAKo99SmTNFUbTBlO7Ql5HMMxqRyeA==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.Fonts.Inter": "12.1.0",
+ "Avalonia.HarfBuzz": "12.1.0"
+ }
+ },
+ "Meziantou.Analyzer": {
+ "type": "Direct",
+ "requested": "[3.0.134, )",
+ "resolved": "3.0.134",
+ "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ=="
+ },
+ "Microsoft.CodeAnalysis.BannedApiAnalyzers": {
+ "type": "Direct",
+ "requested": "[5.6.0, )",
+ "resolved": "5.6.0",
+ "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
+ },
+ "NSubstitute": {
+ "type": "Direct",
+ "requested": "[6.0.0, )",
+ "resolved": "6.0.0",
+ "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==",
+ "dependencies": {
+ "Castle.Core": "5.1.1"
+ }
+ },
+ "Shouldly": {
+ "type": "Direct",
+ "requested": "[4.3.0, )",
+ "resolved": "4.3.0",
+ "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==",
+ "dependencies": {
+ "DiffEngine": "11.3.0",
+ "EmptyFiles": "4.4.0"
+ }
+ },
+ "xunit.v3": {
+ "type": "Direct",
+ "requested": "[3.2.2, )",
+ "resolved": "3.2.2",
+ "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==",
+ "dependencies": {
+ "xunit.v3.mtp-v1": "[3.2.2]"
+ }
+ },
+ "Avalonia.Angle.Windows.Natives": {
+ "type": "Transitive",
+ "resolved": "2.1.27548.20260419",
+ "contentHash": "l17nI3XVDN3oMnpjf2pnmJg0YTwK4m6NLsn/itAjDMdObTFxN77D5F1M9sRMSfViSY3KKcse1ROczwgoWLJsnA=="
+ },
+ "Avalonia.BuildServices": {
+ "type": "Transitive",
+ "resolved": "11.3.2",
+ "contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
+ },
+ "Avalonia.FreeDesktop": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "89mrS7dSYtisJrjQufCOomHeAynlVVKZ+dq4leKtzHXXKVoWsE0Nb2ymiNYPMlirwzwpemflGj+K34opwyLeJQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Tmds.DBus.Protocol": "0.94.1"
+ }
+ },
+ "Avalonia.FreeDesktop.AtSpi": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "WWahMjzKtDl2PGHa8mS6NHIVMT+JNKVIeT5xMLp9SBTMvjHNpEbXTX3PNbbIQ7hRMSETAJ/PAnvgOzatGktEKQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.HarfBuzz": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "uWPa/kg+fmqhrUR5GzFC2ZL2MPszxcEy14hTSwu3VhjwTnaVarUozoPmggQjZG4A4s6w2bcGepsYYaCM/eOoMA==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "HarfBuzzSharp": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3"
+ }
+ },
+ "Avalonia.Native": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "mfNMtGP7rEVWSZoii0l40mlNhgNR6ZISTvHP7OAMn5YQHiK66EjfN26SL/kLnz1buy2VtMIiGVBbIttL8FYCZg==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.Remote.Protocol": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "p6OKt6O7vOub4TS2pAjaeW0Y13oxrPs4uixeVZpJByiSQKKk+LyApN5yRy2JerpfTMtI86Y5pNwugyKTHZJnAw=="
+ },
+ "Avalonia.Skia": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "K63pwExQkcjVbsYJOiOq0hYAO4G5d7T42yK8MGNrvwBKv/bJVlV14jGvV4wXcsuYAU8IWlOHgqq5sMUiDfj4vw==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "HarfBuzzSharp": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.Linux": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.3",
+ "SkiaSharp": "3.119.4",
+ "SkiaSharp.NativeAssets.Linux": "3.119.4",
+ "SkiaSharp.NativeAssets.WebAssembly": "3.119.4"
+ }
+ },
+ "Avalonia.Win32": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "D0xxPtFeOK8cKK991ul92rlFtDO3II0E44dHM0ix4/8LVc9+1LaMdbf1eMnp1RclFwX2bt7HPhbySnrG5uArxA==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.Angle.Windows.Natives": "2.1.27548.20260419"
+ }
+ },
+ "Avalonia.X11": {
+ "type": "Transitive",
+ "resolved": "12.1.0",
+ "contentHash": "6+YHVGf44ictmGj88diMCw9pC9tiwnMlUgosDi0VDmyUQFuy/mJIa4J0rZu6G+UMbjGuyLDQLmtvU4tTOhLMPg==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.FreeDesktop": "12.1.0",
+ "Avalonia.FreeDesktop.AtSpi": "12.1.0",
+ "Avalonia.Skia": "12.1.0"
+ }
+ },
+ "Castle.Core": {
+ "type": "Transitive",
+ "resolved": "5.1.1",
+ "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==",
+ "dependencies": {
+ "System.Diagnostics.EventLog": "6.0.0"
+ }
+ },
+ "DiffEngine": {
+ "type": "Transitive",
+ "resolved": "11.3.0",
+ "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==",
+ "dependencies": {
+ "EmptyFiles": "4.4.0",
+ "System.Management": "6.0.1"
+ }
+ },
+ "EmptyFiles": {
+ "type": "Transitive",
+ "resolved": "4.4.0",
+ "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw=="
+ },
+ "HarfBuzzSharp": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "NGZ2+ZVNPM+NdHB/asW0/ykWngyHWwcqjrbN2nDeH1B/aptPGlCUl8wkQ2cSJxw5fdWgdmIPmNuTPWpLwNVXWg==",
+ "dependencies": {
+ "HarfBuzzSharp.NativeAssets.Win32": "8.3.1.3",
+ "HarfBuzzSharp.NativeAssets.macOS": "8.3.1.3"
+ }
+ },
+ "HarfBuzzSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "RI6A1LgmooU30+4QIyFt5rmBCzP0VzTR+587IJSGvYIsHHWlahFufihYxtraLfsIhW7I8dn6+xX+DZGygOPKWQ=="
+ },
+ "HarfBuzzSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "KPTq0xnslkI6nAo0jh3ptcQPJvZZr7MWYXa2jUe4SnHc9q+JlHElmNXp0sfFoiTgoCX7WOYpYsurypuH9Gehxw=="
+ },
+ "HarfBuzzSharp.NativeAssets.WebAssembly": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "w2QfdNm9Uz/sUa0B5D+OnVQhyq3G/fBq6ibQMdWBlQqqwh0g0/5j3RFvYqZAmRZ5+RzvjVe8o8SFFnWYUSkuxA=="
+ },
+ "HarfBuzzSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "8.3.1.3",
+ "contentHash": "bx8CE8Js+XGX8PUxAHCBDEORt5aaBYtMN4Hr9QFs57Xithh6yjUyYqksizH6eRDhJkwsGI+SXWmPmMm8lZC9Pw=="
+ },
+ "MicroCom.Runtime": {
+ "type": "Transitive",
+ "resolved": "0.11.6",
+ "contentHash": "NdNWGDiZ6eS/Mf/9+QHR91cj1K7Hy+PX9yrHI/zM7xFYuj9IWT2uxtB6sCHjrnxAeLV9fut1R6zHDUGKX6f9lQ=="
+ },
+ "Microsoft.ApplicationInsights": {
+ "type": "Transitive",
+ "resolved": "2.23.0",
+ "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
+ },
+ "Microsoft.Bcl.AsyncInterfaces": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg=="
+ },
+ "Microsoft.Data.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.11"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "bOzrFCl6uZCjaSh2bG1ToRQRdx+iXvxosCg9hFyG9OWeAzOFI4xev9OqKeWfKf/kAHyox2JnbcvLVf2ceA7sqA=="
+ },
+ "Microsoft.EntityFrameworkCore.Analyzers": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "2gLDordUCGf3aNOOuqtTbP5mxhiP9nk6TnvGiE3RnqT891O+Zf/qKu1PIREubs1M16A0SImr4vULBfU5BTDs1Q=="
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "YbVWMIouzwTKBiLms8boa7xeRT88wI14R1msv3XExFk9n0/sa8nU7MwDa1CKtfLGMJs7O7QWuS9/xhcQ72AD2A==",
+ "dependencies": {
+ "Microsoft.Data.Sqlite.Core": "10.0.10",
+ "Microsoft.EntityFrameworkCore.Relational": "10.0.10",
+ "Microsoft.Extensions.Caching.Memory": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.DependencyModel": "10.0.10",
+ "Microsoft.Extensions.Logging": "10.0.10",
+ "SQLitePCLRaw.core": "2.1.11"
+ }
+ },
+ "Microsoft.Extensions.Caching.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Caching.Memory": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "N1w5H7uK6gCTnCBZAWzE0/EQYSPysij/uYwDqntqBVvBa6bjMmBKitsnEFd6yh/SX3wLm67nO6+OnZ84K+gZWg==",
+ "dependencies": {
+ "Microsoft.Extensions.Caching.Abstractions": "10.0.10",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Configuration.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==",
+ "dependencies": {
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "ANyvsgkNBRvcJh2XLgn8veGmajf+8m0AbKK+HPWdRL1yraSNVVSmQhFntLtdz/C795jxqqup+k05cs/3jZQPOA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.DependencyInjection.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA=="
+ },
+ "Microsoft.Extensions.DependencyModel": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA=="
+ },
+ "Microsoft.Extensions.Logging": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "Tf6z5HsL0VDYRTfvsoNrTGHGheCwkTsZBA2FFh5ATJUbkAwug+FFNISJK2gjpUNemlAOoWllAK52HOWCjto3EQ==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection": "10.0.10",
+ "Microsoft.Extensions.Logging.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Options": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Logging.Abstractions": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Options": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==",
+ "dependencies": {
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Primitives": "10.0.10"
+ }
+ },
+ "Microsoft.Extensions.Primitives": {
+ "type": "Transitive",
+ "resolved": "10.0.10",
+ "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ=="
+ },
+ "Microsoft.Testing.Extensions.Telemetry": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==",
+ "dependencies": {
+ "Microsoft.ApplicationInsights": "2.23.0",
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Testing.Platform": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA=="
+ },
+ "Microsoft.Testing.Platform.MSBuild": {
+ "type": "Transitive",
+ "resolved": "1.9.1",
+ "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==",
+ "dependencies": {
+ "Microsoft.Testing.Platform": "1.9.1"
+ }
+ },
+ "Microsoft.Win32.Registry": {
+ "type": "Transitive",
+ "resolved": "5.0.0",
+ "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg=="
+ },
+ "SkiaSharp": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "53NOSUZ1Us+91Sm0uCkIivh/k7jOowRErZT2sIWwPFN9mLUvdxnE6rS4sWo4255+Rd2MWUSF+j0NMZHD6Cke+Q==",
+ "dependencies": {
+ "SkiaSharp.NativeAssets.Win32": "3.119.4",
+ "SkiaSharp.NativeAssets.macOS": "3.119.4"
+ }
+ },
+ "SkiaSharp.NativeAssets.Linux": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "UAyVzbqNfZsZbKbzj68zXLyUyF/SbTKmzTfOO6qDu++dtIUMMTzPBe8oOuzU/DiewpfKoUUlOSsJmqWc6blxBw=="
+ },
+ "SkiaSharp.NativeAssets.macOS": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "fgBOWEqbY012x7gMfJU4ezgz6dfhJb30Z6YdW35h85Zoe39+a8YNbAAwL29ihPfWoppg5AjvyKNzD1oCvlqWwA=="
+ },
+ "SkiaSharp.NativeAssets.WebAssembly": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "S1HOxtBbD4bYDtA2e9WH5TX+lxqRrTPvKjrjttRhxnHNNu7YY8VFo/LeCP7tNqoTA6PV+8vsvNbmRUEC2ip8RQ=="
+ },
+ "SkiaSharp.NativeAssets.Win32": {
+ "type": "Transitive",
+ "resolved": "3.119.4",
+ "contentHash": "XOpbx/4CReO2wYsq2s6rbvdauc6dntG4Zv499sHGTJ87bwZaFXszFkwql3+FIZMc8kUPeaj3Mx2ezIJmo8a1Kg=="
+ },
+ "System.CodeDom": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA=="
+ },
+ "System.Diagnostics.EventLog": {
+ "type": "Transitive",
+ "resolved": "6.0.0",
+ "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw=="
+ },
+ "System.Management": {
+ "type": "Transitive",
+ "resolved": "6.0.1",
+ "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==",
+ "dependencies": {
+ "System.CodeDom": "6.0.0"
+ }
+ },
+ "Tmds.DBus.Protocol": {
+ "type": "Transitive",
+ "resolved": "0.94.1",
+ "contentHash": "11YMr7FnAbL83bQmVxlhbIKHvSLxjO81D12Ej0QMSGXMDTxNA9MTOa4MQxx43nv5el/efuPHwzyrj6a5ha2gug=="
+ },
+ "xunit.analyzers": {
+ "type": "Transitive",
+ "resolved": "1.27.0",
+ "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g=="
+ },
+ "xunit.v3.assert": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA=="
+ },
+ "xunit.v3.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==",
+ "dependencies": {
+ "Microsoft.Bcl.AsyncInterfaces": "6.0.0"
+ }
+ },
+ "xunit.v3.core.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==",
+ "dependencies": {
+ "Microsoft.Testing.Extensions.Telemetry": "1.9.1",
+ "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1",
+ "Microsoft.Testing.Platform": "1.9.1",
+ "Microsoft.Testing.Platform.MSBuild": "1.9.1",
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.inproc.console": "[3.2.2]"
+ }
+ },
+ "xunit.v3.extensibility.core": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==",
+ "dependencies": {
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.mtp-v1": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==",
+ "dependencies": {
+ "xunit.analyzers": "1.27.0",
+ "xunit.v3.assert": "[3.2.2]",
+ "xunit.v3.core.mtp-v1": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.common": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==",
+ "dependencies": {
+ "Microsoft.Win32.Registry": "[5.0.0]",
+ "xunit.v3.common": "[3.2.2]"
+ }
+ },
+ "xunit.v3.runner.inproc.console": {
+ "type": "Transitive",
+ "resolved": "3.2.2",
+ "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==",
+ "dependencies": {
+ "xunit.v3.extensibility.core": "[3.2.2]",
+ "xunit.v3.runner.common": "[3.2.2]"
+ }
+ },
+ "dodossh.client.api": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Auth": "[1.0.0, )",
+ "DodoSSH.Contracts": "[1.0.0, )",
+ "DodoSSH.Crypto": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.app": {
+ "type": "Project",
+ "dependencies": {
+ "Avalonia": "[12.1.0, )",
+ "Avalonia.Controls.WebView": "[12.0.1, )",
+ "Avalonia.Desktop": "[12.1.0, )",
+ "Avalonia.Fonts.Inter": "[12.1.0, )",
+ "Avalonia.Themes.Fluent": "[12.1.0, )",
+ "CommunityToolkit.Mvvm": "[8.4.2, )",
+ "DodoSSH.Client.Session": "[1.0.0, )",
+ "DodoSSH.Client.Ssh": "[1.0.0, )",
+ "DodoSSH.Client.Terminal": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.auth": {
+ "type": "Project"
+ },
+ "dodossh.client.domain": {
+ "type": "Project"
+ },
+ "dodossh.client.session": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Api": "[1.0.0, )",
+ "DodoSSH.Client.Auth": "[1.0.0, )",
+ "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.ssh": {
+ "type": "Project",
+ "dependencies": {
+ "SSH.NET": "[2025.1.0, )"
+ }
+ },
+ "dodossh.client.storage": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Contracts": "[1.0.0, )",
+ "DodoSSH.Crypto": "[1.0.0, )",
+ "EFCore.NamingConventions": "[10.0.1, )",
+ "Microsoft.EntityFrameworkCore.Sqlite": "[10.0.10, )"
+ }
+ },
+ "dodossh.client.sync": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Api": "[1.0.0, )",
+ "DodoSSH.Client.Domain": "[1.0.0, )",
+ "DodoSSH.Client.Storage": "[1.0.0, )",
+ "DodoSSH.Contracts": "[1.0.0, )",
+ "DodoSSH.Crypto": "[1.0.0, )"
+ }
+ },
+ "dodossh.client.terminal": {
+ "type": "Project",
+ "dependencies": {
+ "DodoSSH.Client.Ssh": "[1.0.0, )"
+ }
+ },
+ "dodossh.contracts": {
+ "type": "Project"
+ },
+ "dodossh.crypto": {
+ "type": "Project",
+ "dependencies": {
+ "NSec.Cryptography": "[26.4.0, )"
+ }
+ },
+ "Avalonia": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "an4ugAy2q6GTdaFl635V8W/LKrWNL+mnSFUprbgyf8m1Zzf9WgoGaF5ajGv5i4gXefMZrzsUPV1QU+kKrgwCWg==",
+ "dependencies": {
+ "Avalonia.BuildServices": "11.3.2",
+ "Avalonia.Remote.Protocol": "12.1.0",
+ "MicroCom.Runtime": "0.11.6"
+ }
+ },
+ "Avalonia.Controls.WebView": {
+ "type": "CentralTransitive",
+ "requested": "[12.0.1, )",
+ "resolved": "12.0.1",
+ "contentHash": "GrCIpIIBL7ueFDsNu3lyYc1mgO3QGGl1c1MCK8YAgjaNZwF9PV5PF2UB3lm1uuqj/MWOKNhemLwcSDLyYv0JjQ==",
+ "dependencies": {
+ "Avalonia": "12.0.0"
+ }
+ },
+ "Avalonia.Desktop": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "mxhz50At61IBQbB/bCo5JGp53rPi3GerGO9mFo/v93uBHa4J3cz3NSSqnVWSyQXKbPCwQhrogfEFWbKBgecy1w==",
+ "dependencies": {
+ "Avalonia": "12.1.0",
+ "Avalonia.HarfBuzz": "12.1.0",
+ "Avalonia.Native": "12.1.0",
+ "Avalonia.Skia": "12.1.0",
+ "Avalonia.Win32": "12.1.0",
+ "Avalonia.X11": "12.1.0"
+ }
+ },
+ "Avalonia.Fonts.Inter": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "2mK5Rv6aMWgXfQ2JZOq1Wo2bTNAfiidg2GO4b3MgLRN89ezfvfsSfp4P5Pl4ssRcWWwdV0jGzIw8n0xc9B26VA==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "Avalonia.Themes.Fluent": {
+ "type": "CentralTransitive",
+ "requested": "[12.1.0, )",
+ "resolved": "12.1.0",
+ "contentHash": "MVi5L9HymnNm+gP2aNXNcyrP2iKGJWFubQ5Bv8/Przflxca1aIT9QBpTUV6c3olA9rLYFY7MJRR/C/BZaUhemQ==",
+ "dependencies": {
+ "Avalonia": "12.1.0"
+ }
+ },
+ "BouncyCastle.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[2.6.2, )",
+ "resolved": "2.6.2",
+ "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
+ },
+ "CommunityToolkit.Mvvm": {
+ "type": "CentralTransitive",
+ "requested": "[8.4.2, )",
+ "resolved": "8.4.2",
+ "contentHash": "WadCzGEc2U+3e20avRLng4qNtt4zoOGWrdUISqJWrHe3/FSnrYjuM5Sb4yQb09LhkBXrrI4Zt3dLKgRMbItsrg=="
+ },
+ "EFCore.NamingConventions": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.1, )",
+ "resolved": "10.0.1",
+ "contentHash": "Xs5k8XfNKPkkQSkGmZkmDI1je0prLTdxse+s8PgTFZxyBrlrTLzTBUTVJtQKSsbvu4y+luAv8DdtO5SALJE++A==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "[10.0.1, 11.0.0)",
+ "Microsoft.EntityFrameworkCore.Relational": "[10.0.1, 11.0.0)",
+ "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
+ }
+ },
+ "libsodium": {
+ "type": "CentralTransitive",
+ "requested": "[1.0.22, )",
+ "resolved": "1.0.22",
+ "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
+ },
+ "Microsoft.EntityFrameworkCore": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "a0V7zj/VbYP6dTdWpUgE/r2PuLKtUGe2aJ0lVKkn/wP9ZhaxUz2kQydVfvOjCv2SKxlrqdBfHhPD4Cvlf+4ffA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Abstractions": "10.0.10",
+ "Microsoft.EntityFrameworkCore.Analyzers": "10.0.10",
+ "Microsoft.Extensions.Caching.Memory": "10.0.10",
+ "Microsoft.Extensions.Logging": "10.0.10"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Relational": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "wNonj40aZxia+GtuBiiD6ZqVh4h6y5Nje1bGdmzZ8/ui0QRsAN+S0SIrLHFCEGbG9cDbeaE40sh+Lr7o9rRs6g==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore": "10.0.10",
+ "Microsoft.Extensions.Caching.Memory": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.Logging": "10.0.10"
+ }
+ },
+ "Microsoft.EntityFrameworkCore.Sqlite": {
+ "type": "CentralTransitive",
+ "requested": "[10.0.10, )",
+ "resolved": "10.0.10",
+ "contentHash": "kzg9MuQNJvZQxAU+piSkEzc7/1tpW6n1nVSGGMObu2GgxLK8Nf+6fvZundaznTZ+O2KhfPZ8HFNCzMH3PWDUmA==",
+ "dependencies": {
+ "Microsoft.EntityFrameworkCore.Sqlite.Core": "10.0.10",
+ "Microsoft.Extensions.Caching.Memory": "10.0.10",
+ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10",
+ "Microsoft.Extensions.DependencyModel": "10.0.10",
+ "Microsoft.Extensions.Logging": "10.0.10",
+ "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11",
+ "SQLitePCLRaw.core": "2.1.11"
+ }
+ },
+ "NSec.Cryptography": {
+ "type": "CentralTransitive",
+ "requested": "[26.4.0, )",
+ "resolved": "26.4.0",
+ "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
+ "dependencies": {
+ "libsodium": "[1.0.22, 1.0.23)"
+ }
+ },
+ "SQLitePCLRaw.bundle_e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==",
+ "dependencies": {
+ "SQLitePCLRaw.lib.e_sqlite3": "2.1.12",
+ "SQLitePCLRaw.provider.e_sqlite3": "2.1.12"
+ }
+ },
+ "SQLitePCLRaw.core": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg=="
+ },
+ "SQLitePCLRaw.lib.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w=="
+ },
+ "SQLitePCLRaw.provider.e_sqlite3": {
+ "type": "CentralTransitive",
+ "requested": "[2.1.12, )",
+ "resolved": "2.1.12",
+ "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==",
+ "dependencies": {
+ "SQLitePCLRaw.core": "2.1.12"
+ }
+ },
+ "SSH.NET": {
+ "type": "CentralTransitive",
+ "requested": "[2025.1.0, )",
+ "resolved": "2025.1.0",
+ "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
+ "dependencies": {
+ "BouncyCastle.Cryptography": "2.6.2",
+ "Microsoft.Extensions.Logging.Abstractions": "8.0.3"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file