Merge branch 'main' into the desktop updater, and give way on two numbers

Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
This commit is contained in:
2026-08-04 17:52:57 +02:00
52 changed files with 6205 additions and 341 deletions
@@ -29,10 +29,11 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// <para>
/// Separate from <see cref="ScreenLayoutTests"/>, which measures these controls rather than driving them.
/// What is here is the one gesture that cannot be expressed as a binding and cannot be checked by
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because all three of
/// that menu's commands read the vault's host selection. A menu that quietly acted on whichever host
/// measuring: a right click has to move the selection <em>before</em> the menu opens, because the commands
/// on both of that screen's menus read the vault's selection. A menu that quietly acted on whichever host
/// happened to be selected would delete the wrong machine, which is the version of this mistake worth a
/// suite.
/// suite — and the group cards have the same menu with a fallback behind it that makes getting it wrong
/// quieter still.
/// </para>
/// <para>
/// A real <see cref="VaultViewModel"/> over a real unlocked vault, for the reason the other suites here use
@@ -159,6 +160,82 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
/// <summary>
/// The same rule on the cards above, where getting it wrong is quieter and worse.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's menu acts on nothing when it is not aimed; this one acts on the <em>wrong group</em>.
/// <c>GroupTarget</c> falls back to the group whose contents are on screen when no card is selected, and
/// a menu that opened on a card would then offer to delete a group the pointer is nowhere near. It is
/// also the only way to Edit or Delete a group on the desktop, so this is the only place it is aimed.
/// </para>
/// <para>
/// Open is the one entry that takes a parameter, because <c>OpenGroupCommand</c>'s null is a real
/// argument — it is ALL HOSTS. That makes its <c>CommandParameter</c> binding the half most likely to
/// rot: a path that resolves to nothing compiles, draws, and quietly leaves the grid at the top level.
/// </para>
/// </remarks>
[Fact]
public async Task ARightClickSelectsTheGroupUnderThePointer()
{
await AddGroupAsync("staging");
await OnTheGridAsync((screen, window) =>
{
var first = GroupRow(vault, "production");
var other = GroupRow(vault, "staging");
vault.SelectedGroup = first;
RightClick(CardFor(screen, other), window);
vault.SelectedGroup.ShouldBeSameAs(other);
var menu = screen.GroupGrid.ContextMenu.ShouldNotBeNull();
menu.IsOpen.ShouldBeTrue();
var items = menu.Items.OfType<MenuItem>().ToList();
var open = items.Single(item => item.Header is "Open");
open.Command.ShouldBeSameAs(vault.OpenGroupCommand);
open.CommandParameter.ShouldBeSameAs(other, "the card under the pointer, not ALL HOSTS");
var edit = items.Single(item => item.Header is "Edit…");
edit.Command.ShouldBeSameAs(vault.EditGroupCommand);
edit.Command!.Execute(null);
vault.IsEditingGroup.ShouldBeTrue();
vault.GroupEditorLabel.ShouldBe(
other.Label, "the card that was right-clicked, not the one selected before");
});
}
/// <remarks>
/// The space around the group cards, where a menu would be at its most misleading: nothing is under the
/// pointer, so an unguarded one would open against the fallback and offer Delete about the group the
/// trail ends with — which, once it is open, is not a card on screen at all.
/// </remarks>
[Fact]
public async Task ARightClickOffAnyGroupCardOpensNothingAndMovesNothing()
{
await OnTheGridAsync((screen, _) =>
{
var selected = GroupRow(vault, "production");
vault.SelectedGroup = selected;
screen.GroupGrid.RaiseEvent(new ContextRequestedEventArgs
{
RoutedEvent = Control.ContextRequestedEvent,
Source = screen.GroupGrid,
});
vault.SelectedGroup.ShouldBeSameAs(selected, "the selection the menu would have acted on");
screen.GroupGrid.ContextMenu.ShouldNotBeNull().IsOpen.ShouldBeFalse();
});
}
/// <summary>
/// A host held over a group card would be filed there, and one held over another host card would not.
/// </summary>
@@ -210,10 +287,10 @@ public sealed class HostGridTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rule one press was split into two gestures for. Selecting a group aims its EDIT and DELETE at it
/// and does nothing else; opening one is what narrows the grid, and the trail is the way back out of it.
/// While a single press meant both, a group could not be named without every host outside it leaving the
/// screen at the same moment.
/// The rule one press was split into two gestures for. Selecting a group marks it and does nothing else;
/// opening one is what narrows the grid, and the trail is the way back out of it. While a single press
/// meant both, a group could not be named without every host outside it leaving the screen at the same
/// moment.
/// </para>
/// <para>
/// Driven through the properties the cards bind rather than through a click, because what is worth
@@ -222,7 +299,7 @@ public sealed class HostGridTests : IAsyncLifetime
/// </para>
/// </remarks>
[Fact]
public async Task SelectingAGroupAimsItsButtonsAtItAndOpeningOneNarrowsTheGrid()
public async Task SelectingAGroupMarksItAndOpeningOneNarrowsTheGrid()
{
await vault.MoveHostToGroupCommand.ExecuteAsync(
new HostGroupMove(Row(vault, "prod-db"), vault.Groups.Single().EntityId));
@@ -235,8 +312,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it");
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group");
vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on");
vault.ShowsGroupActions.ShouldBeTrue();
vault.GroupTarget.ShouldBeSameAs(production, "what a group command with no argument acts on");
vault.OpenGroupCommand.Execute(production);
@@ -247,7 +323,7 @@ public sealed class HostGridTests : IAsyncLifetime
vault.SelectedGroup.ShouldBeNull("the card it was on is not one of the cards on screen any more");
vault.GroupTarget.ShouldBeSameAs(
production, "so the buttons fall back to the group whose contents are showing");
production, "so an unaimed command falls back to the group whose contents are showing");
// Back out, which is the trail's first crumb and nothing else: SHOW ALL was a second control for the
// same job and went with the change.
@@ -256,7 +332,93 @@ public sealed class HostGridTests : IAsyncLifetime
vault.VisibleHosts.Select(row => row.Label)
.ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain");
vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected");
vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown");
}
/// <summary>
/// The two grids share one selection, so at most one card on the screen is ever lit.
/// </summary>
/// <remarks>
/// <para>
/// They are two <c>ListBox</c>es, each holding a selection of its own and each drawing it the same way.
/// Left to themselves both stay marked — a group above and a host below — under two pairs of buttons of
/// which only one acts on whichever card the eye has settled on. The vault is what joins them.
/// </para>
/// <para>
/// Driven on the screen rather than on the view model alone, because half of the rule lives in the
/// controls: clearing the property has to reach the list that is drawing the card, and a selection
/// nulled in the view model while the card stays highlighted is the exact failure this is about.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostAndGroupGridsShareOneSelection()
{
await OnTheGridAsync((screen, _) =>
{
var host = Row(vault, "stage-web");
vault.OpenHostPaneCommand.Execute(host);
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeNull("choosing a group is choosing something else");
vault.SelectedSidebarRow.ShouldBeNull("and the list that draws the hosts is told");
screen.HostGrid.SelectedItem.ShouldBeNull();
CardFor(screen, host).IsSelected.ShouldBeFalse("the card the pointer left has to go dark");
vault.IsDrawerOpen.ShouldBeFalse("a pane about one host cannot stand beside a marked group");
vault.SelectedHost = host;
Dispatcher.UIThread.RunJobs();
vault.SelectedGroup.ShouldBeNull("and the same in the other direction");
screen.GroupGrid.SelectedItem.ShouldBeNull();
GroupCard(screen).IsSelected.ShouldBeFalse();
});
}
/// <remarks>
/// A pair of EDIT and DELETE buttons used to sit beside the GROUPS heading, and the card's own menu is
/// the whole of both now — the menu came second and did the same job better, since it acts on the card
/// under the pointer rather than on <c>GroupTarget</c>. Held here because a button coming back is not a
/// compile error and barely a visible one: it would draw itself in place, aimed with no card selected at
/// the group the trail ends with, which is the mistake the two menu tests above exist to catch.
/// </remarks>
[Fact]
public async Task AGroupsEditAndDeleteAreOnItsCardsMenuAndNowhereElse()
{
await OnTheGridAsync((screen, _) =>
{
vault.SelectedGroup = vault.VisibleGroups.Single();
Dispatcher.UIThread.RunJobs();
screen.GetVisualDescendants()
.OfType<Button>()
.Where(button => ReferenceEquals(button.Command, vault.EditGroupCommand)
|| ReferenceEquals(button.Command, vault.DeleteGroupCommand))
.ShouldBeEmpty("a selected group card puts no buttons on the screen");
});
}
/// <remarks>
/// The half of the shared selection that is nobody's gesture. A reload falls back to the first host when
/// nothing is selected, which is what puts a target under CONNECT on a fresh unlock — and with one mark
/// between the two grids that fallback would quietly unselect a group card every time a sync landed.
/// </remarks>
[Fact]
public async Task ASyncDoesNotTakeTheSelectionOffAGroupCard()
{
var production = vault.VisibleGroups.Single();
vault.SelectedGroup = production;
vault.SelectedHost.ShouldBeNull("the seed's load left a host selected, and the group took the mark");
await vault.LoadAsync(Token);
vault.SelectedHost.ShouldBeNull("the reload invented none under the card that was chosen");
vault.SelectedGroup
.ShouldNotBeNull("re-found by id, since the reload replaces every row object in the list")
.EntityId.ShouldBe(production.EntityId);
}
/// <remarks>
@@ -474,14 +636,22 @@ public sealed class HostGridTests : IAsyncLifetime
.OfType<ListBoxItem>()
.Single(item => item.DataContext is HostGroupRowViewModel);
private static ListBoxItem CardFor(Visual screen, HostRowViewModel host) =>
/// <remarks>Any row: a host card or a group card, which are both items of a list on this screen.</remarks>
private static ListBoxItem CardFor(Visual screen, object row) =>
screen.GetVisualDescendants()
.OfType<ListBoxItem>()
.First(item => ReferenceEquals(item.DataContext, host));
.First(item => ReferenceEquals(item.DataContext, row));
private static HostRowViewModel Row(VaultViewModel vault, string label) =>
vault.Hosts.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <remarks>
/// Out of the cards on screen rather than out of every group, because that is what the card's own data
/// context is — <c>Groups</c> holds the same row objects, but only one level of them is drawn.
/// </remarks>
private static HostGroupRowViewModel GroupRow(VaultViewModel vault, string label) =>
vault.VisibleGroups.First(row => string.Equals(row.Label, label, StringComparison.Ordinal));
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");
@@ -223,6 +223,36 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// The move panel, which takes the footer as the deletion question does and is the taller of the two: a
/// heading, a combo box, a wrapping paragraph and two buttons, in a 304-pixel column. The paragraph is
/// the risk — it is what says the group and the tags stay behind — and the footer is one of the two
/// parts of this drawer that is not inside a <c>ScrollViewer</c>, so nothing brings it back into view.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveHostCommand</c>, which would refuse: this fixture's
/// account holds one vault, and the command declines rather than open a picker with nothing in it. What
/// this test is about is the rectangle, and the flow that fills it is covered in
/// <c>DodoSSH.Client.App.Tests</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostDrawerFitsWithTheMovePanelOpen()
{
vault.OpenHostPaneCommand.Execute(vault.Hosts[0]);
vault.MoveVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
vault.SelectedMoveVault = vault.MoveVaultChoices[0];
vault.IsMovingHost = true;
vault.ShowsHostPaneActions.ShouldBeFalse("the panel takes the footer rather than sharing it");
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
/// <remarks>
/// <para>
/// What a double-click on a machine does everywhere else, and did not do here: it opens a shell on it.
@@ -527,9 +557,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The question replaces the group's two buttons rather than stacking under them — the same rule every
/// other pair in this application follows — and it is the taller of the two, because it says how many
/// hosts are about to move.
/// The question opens under the GROUPS heading and pushes the cards down, and it is the tallest thing
/// this section draws: a heading, a consequence, a boxed count, and now a tick with a sentence beside it
/// asking whether the machines go too. The tick is the part worth measuring, because it is a wrapping
/// paragraph inside a control whose own height the layout does not obviously account for.
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWhileAGroupDeletionIsBeingConfirmed()
@@ -540,10 +571,43 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.DeleteGroupCommand.Execute(null);
vault.IsConfirmingGroupDeletion.ShouldBeTrue("the question has to be up for this to measure it");
vault.PendingDeletion.ShouldNotBeNull().HasChoice
.ShouldBeTrue("the hosts filed under it are what makes this the long shape");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group question up"));
}
/// <remarks>
/// <para>
/// The group's move panel, under the GROUPS heading beside the deletion question and the wordier of the
/// two: a heading, a combo box, a wrapping paragraph naming everything that travels and everything that
/// does not, and two buttons — above a wrap of group cards and the host grid, all of which still have to
/// fit under it.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveGroupCommand</c>, which would refuse: this fixture's
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostsScreenFitsWithTheGroupMovePanelOpen()
{
await SeedGroupsAsync(3);
vault.SelectedGroup = vault.Groups[0];
vault.MoveGroupVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
vault.SelectedMoveGroupVault = vault.MoveGroupVaultChoices[0];
vault.IsMovingGroup = true;
vault.IsConfirmingGroupDeletion.ShouldBeFalse("the two panels share the space and never the moment");
await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with the group move panel up"));
}
// ---- The vault screen ----
[Fact]
@@ -57,6 +57,17 @@ internal sealed class StubTeamServer : IVaultServer, ITeamApi, IVaultGrantApi
/// <inheritdoc />
public IKeyBindingAuthorizer KeyBinding => throw new NotSupportedException();
/// <summary>
/// A push channel that never pushes.
/// </summary>
/// <remarks>
/// Not <c>NotSupportedException</c> like its neighbours: the background synchronisation loop reads
/// this on every wait, so a layout test that opened a screen would throw from a timer thread rather
/// than draw anything. Waiting for ever is the honest stand-in — an offline layout test has no
/// server to be pushed from.
/// </remarks>
public IVaultEventStream Events => IdleVaultEventStream.Instance;
/// <inheritdoc />
public SyncOptions SyncOptions => new();