Give the hosts board its vaults, and the Hetzner import a vault and a key
ci / android head (push) Successful in 4m1s
ci / build and test (push) Successful in 6m25s
ci / api image (push) Successful in 23s
ci / desktop nightly (push) Successful in 2m4s

Six things, all of them answers to a question a screen was deciding on
somebody's behalf.

The board draws a heading per vault and sorts each vault's hosts by that
vault's own groups. A group is an item of one vault, so drawing every
group of every vault in one run flattened away containment that already
exists: two vaults each holding a "production" were two headings with the
same word on them, told apart by a small grey name beside each. Single-
vault sessions are unchanged. There is now one "No group" heading per
vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey
rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts
folding every other vault's away with them.

A Ctrl-click starts its set with the card the board has lit. The board
always has a selection — a fresh unlock lights the first host so CONNECT
has something under it — so "one card lit, nothing ticked" is the state
this screen opens in, and ticking a second card there produced a set of
one: the machine the user was looking at was left out of the count and
out of everything Delete, Move and Change group then ran over.

The card tooltip says the host's name as well as its address. 214 pixels
of card trims the name, and the tooltip was the one place it was not said
in full.

The card's floor drops from 120 to 104. A card with no tags measures
about 87, so the mock's number was thirty pixels of air under every card
on a board that is scrolled.

"Move to another vault…" joins the board's context menu for one host.
Moving one machine meant opening its pane and finding the menu inside it,
while moving six was a right click away — the one-host case was the
harder of the two.

And the Hetzner import asks which vault and which key. A project belongs
to a team more often than to a person, so forty servers landing wherever
the session happens to be standing is the wrong default; ImportHostsAsync
takes the target vault, and the ssh_config importer passes null and is
unchanged. The key is the half Hetzner cannot supply and the keychain
usually already holds — the public half went to Hetzner when the servers
were made — so the import points every host at it rather than bringing
one. It defaults to no key rather than guessing, and says so when the key
it was given lives in a different vault from the hosts.
This commit is contained in:
jaap-jan committed 2026-08-26 11:54:29 +02:00
1 parent ed936380b3
commit c859705b83
9 files changed
+926 -84

No files matched your search

@@ -6485,6 +6485,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Key: null,
KeyPath: null),
],
vaultId: null,
Token);
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
@@ -7022,6 +7023,60 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
}
/// <summary>
/// The desktop's Ctrl-click, and the card the board already had lit.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The board always has a selection, so "one card lit, nothing ticked" is the state this screen
/// opens in</b> — a fresh unlock lights the first host so that CONNECT has something under it, see
/// <c>VaultViewModel.SelectionAfterReload</c>. A Ctrl-click there used to start a set of one, leaving
/// the machine the user was looking at out of the count and out of everything Delete, Move to another
/// vault and Change group then ran over. It is the omission somebody could act on without noticing:
/// two cards are marked on screen and the bar says one.
/// </para>
/// <para>
/// The fold happens only into an empty set and never onto the card being clicked. The first is what
/// makes it a starting point rather than a card that keeps re-adding itself; the second is what keeps
/// every Ctrl-click visibly doing something.
/// </para>
/// </remarks>
[Fact]
public async Task ExtendingTheChoice_StartsTheSetWithTheCardTheBoardHasLit()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "staging");
vault.SelectedHost = Host(vault, "prod-db");
vault.IsChoosingHosts.ShouldBeFalse();
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
vault.ChosenHostCount.ShouldBe(2, "the lit card is in the set the Ctrl-click began");
Host(vault, "prod-db").IsChosen.ShouldBeTrue("which is the half that used to be left out");
Host(vault, "staging").IsChosen.ShouldBeTrue();
vault.SelectedHost.ShouldBeSameAs(Host(vault, "prod-db"), "ticking a card is not selecting it");
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
vault.ChosenHostCount.ShouldBe(1, "a set that already exists is not folded into again");
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
vault.ExtendHostChoiceCommand.Execute(Host(vault, "staging"));
vault.IsChoosingHosts.ShouldBeFalse("emptying the set leaves the mode, as a tap does");
// The lit card itself, from empty: it is ticked rather than folded in and toggled straight back out,
// which would be a gesture with no effect and no way to tell it had been heard.
vault.ExtendHostChoiceCommand.Execute(Host(vault, "prod-db"));
vault.ChosenHostCount.ShouldBe(1);
Host(vault, "prod-db").IsChosen.ShouldBeTrue();
}
/// <remarks>
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
@@ -7237,6 +7292,97 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
}
/// <summary>
/// The board's outer level, which is the vault, and its inner one, which is the group.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A group belongs to exactly one vault, and the board used to flatten that away.</b> Every group
/// of every readable vault was drawn in one run with a small grey vault name beside each heading, so two
/// vaults each holding a "Production" were two headings with the same word on them, told apart only by
/// reading the second line. Vaults are the headings now and groups sort the hosts inside one — see
/// <c>HostSectionViewModel.VaultHeading</c>.
/// </para>
/// <para>
/// The fold state is the half worth a test rather than a comment. There is one "No group" heading per
/// vault now, and every one of them names no group at all — keyed on <c>Guid.Empty</c> as they were,
/// folding one vault's unfiled hosts away folded every other vault's with it. See
/// <c>SidebarGroupHeader.CollapseKey</c>.
/// </para>
/// </remarks>
[Fact]
public async Task TheHostBoard_PutsEachVaultsGroupsUnderItsOwnHeading()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await vaults.LoadAsync(Token);
vaults.NewVaultCommand.Execute(null);
vaults.NewVaultName = "Platform secrets";
await vaults.CreateVaultCommand.ExecuteAsync(null);
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
// The same group name in both vaults, which is the case the old board could not tell apart.
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "Production";
await vault.SaveGroupCommand.ExecuteAsync(null);
var sharedGroupId = vault.Groups.Single(group => group.VaultId == sharedVaultId).EntityId;
// One unfiled host in each vault, and one filed under the shared vault's own group.
await AddHostAsync(vault, "prod-db");
await AddHostInAsync(vault, sharedVaultId, "stage-web", groupId: null);
await AddHostInAsync(vault, sharedVaultId, "prod-web", sharedGroupId);
var sections = vault.HostSections.ToList();
var headings = sections.Where(section => section.StartsAVault).ToList();
headings.Count.ShouldBe(2, "one heading per vault, on the first section of each");
headings[1].VaultHeading.ShouldBe("PLATFORM SECRETS");
var opensTheShared = sections.IndexOf(headings[1]);
opensTheShared.ShouldBeGreaterThan(0, "the vault this session is standing in comes first");
sections.Take(opensTheShared).SelectMany(section => section.Hosts)
.ShouldAllBe(row => row.VaultId != sharedVaultId);
sections.Skip(opensTheShared).SelectMany(section => section.Hosts)
.ShouldAllBe(row => row.VaultId == sharedVaultId);
sections.Count(section => string.Equals(section.Header?.Label, "Production", StringComparison.Ordinal))
.ShouldBe(2, "each vault draws its own, rather than one heading for both");
sections.Where(section => section.Header is not null)
.ShouldAllBe(section => section.Header!.VaultBadge.Length == 0,
"the vault is named on the row above rather than beside every heading under it");
// ── The fold, which is per vault because the heading is ──
var unfiled = sections.Where(section => section.Header is { GroupId: null }).ToList();
unfiled.Count.ShouldBe(2, "each vault has unfiled hosts of its own");
vault.ToggleGroupCommand.Execute(unfiled[0].Header);
var folded = vault.HostSections.Where(section => section.Header is { GroupId: null }).ToList();
folded[0].Header!.IsExpanded.ShouldBeFalse();
folded[0].Hosts.ShouldBeEmpty("a folded section draws its heading and no cards");
folded[1].Header!.IsExpanded.ShouldBeTrue("one vault's unfiled hosts fold on their own");
}
/// <remarks>
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
@@ -10112,6 +10258,33 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
/// <summary>Adds a host to a named vault, optionally filed under one of that vault's groups.</summary>
/// <remarks>
/// The editor's own vault picker rather than the keychain screen's standing preference, which is the
/// order the two are meant to be used in: where a host goes is a field of the host in front of you. The
/// vault is set first because the group choices are that vault's — see
/// <c>VaultViewModel.EditorGroupChoices</c>.
/// </remarks>
private static async Task AddHostInAsync(
VaultViewModel vault,
Guid vaultId,
string label,
Guid? groupId)
{
vault.NewHostCommand.Execute(null);
vault.EditorSelectedVault =
vault.EditorVaultChoices.Single(choice => choice.VaultId == vaultId);
vault.EditorSelectedGroup =
vault.EditorGroupChoices.Single(choice => choice.EntityId == groupId);
vault.EditorLabel = label;
vault.EditorHostname = "db.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
private static async Task AddHostAsync(VaultViewModel vault, string label)
{
vault.NewHostCommand.Execute(null);