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

@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// <summary>Whether this heading has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
/// </summary>
/// <remarks>
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
/// refuses it.
/// </remarks>
internal Guid CollapseKey { get; init; }
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
internal bool HasHeader => header is not null;
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
/// <summary>
/// The vault this section opens, or empty where it is not the first section of one.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
/// "production" made the point: they were two headings with the same word on them and a small grey
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
/// and groups sort the hosts inside one, which is the shape the data has.
/// </para>
/// <para>
/// Carried on the first section of each vault rather than as a section of its own, because a vault
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
/// </para>
/// </remarks>
internal string VaultHeading { get; init; } = string.Empty;
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
internal bool StartsAVault => VaultHeading.Length > 0;
}
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
CultureInfo.InvariantCulture,
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
/// <summary>
/// What a card says when the pointer rests on it: the name in full, and the address under it.
/// </summary>
/// <remarks>
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
/// six servers behind one address differ only by the half that was trimmed away.
/// </remarks>
internal string Tooltip => $"{Label}\n{Address}";
/// <remarks>
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
@@ -5170,25 +5224,109 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>
/// The board, vault by vault: every vault's own groups under its own heading.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
/// are flattened on their own, and the first section that comes back wears the vault's name.
/// </para>
/// <para>
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
/// and the single-vault board is what most of this application's users have. The count is of vaults
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
/// </para>
/// </remarks>
private List<HostSectionViewModel> BuildHostSections()
{
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
var vaults = BoardVaults();
var sections = vaults.Count < 2
? SectionsFrom(
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
vaultHeading: null)
: vaults
.SelectMany(vault => SectionsFrom(
FlattenIntoSections(
[.. shown.Where(row => row.VaultId == vault.VaultId)],
ungroupedFirst: true,
ungroupedLabel: "No group",
vaultId: vault.VaultId),
vault.Name.ToUpperInvariant()))
.ToList();
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
// that row for the control to sit on.
for (var index = 0; index < sections.Count; index++)
{
if (sections[index].Header is not { } first)
{
continue;
}
sections[index] = new HostSectionViewModel(
first with { IsFirstBoardSection = true },
sections[index].Hosts)
{
VaultHeading = sections[index].VaultHeading,
};
break;
}
return sections;
}
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
/// <remarks>
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
/// </remarks>
private List<(Guid VaultId, string Name)> BoardVaults() =>
[
.. session.ReadableVaults
.Where(vault => IsVaultShown(vault.VaultId))
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => (vault.VaultId, vault.Name)),
];
/// <summary>
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
/// </summary>
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
/// <param name="vaultHeading">
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
/// it is given, the group headings drop their own vault badge: the name is already on the row above
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
/// </param>
/// <remarks>
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
/// </remarks>
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
{
var sections = new List<HostSectionViewModel>();
if (flat.Count == 0)
{
return [];
return sections;
}
if (Groups.Count == 0)
{
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
}
var sections = new List<HostSectionViewModel>();
SidebarGroupHeader? header = null;
var members = new List<HostRowViewModel>();
var firstHeading = true;
var loose = new List<HostRowViewModel>();
foreach (var row in flat)
{
@@ -5199,15 +5337,12 @@ internal sealed partial class VaultViewModel(
sections.Add(new HostSectionViewModel(header, members));
}
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
// all — see IsFirstBoardSection and the XAML.
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
firstHeading = false;
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
members = [];
}
else if (row is HostRowViewModel host)
{
members.Add(host);
(header is null ? loose : members).Add(host);
}
}
@@ -5215,6 +5350,18 @@ internal sealed partial class VaultViewModel(
{
sections.Add(new HostSectionViewModel(header, members));
}
else
{
sections.Add(new HostSectionViewModel(null, loose));
}
if (vaultHeading is { Length: > 0 })
{
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
{
VaultHeading = vaultHeading,
};
}
return sections;
}
@@ -5230,6 +5377,12 @@ internal sealed partial class VaultViewModel(
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
/// </param>
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
/// <param name="vaultId">
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
/// vault. The phone's single list passes null and is unchanged by any of this.
/// </param>
/// <remarks>
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
@@ -5240,17 +5393,27 @@ internal sealed partial class VaultViewModel(
/// left empty.
/// </remarks>
private List<ISidebarRow> FlattenIntoSections(
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
IReadOnlyList<HostRowViewModel> shown,
bool ungroupedFirst,
string ungroupedLabel,
Guid? vaultId = null)
{
var rows = new List<ISidebarRow>();
if (Groups.Count == 0)
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
// vault nobody has made a group in falls into the case below, drawing its cards flat.
var groups = vaultId is { } only
? Groups.Where(group => group.VaultId == only).ToList()
: [.. Groups];
if (groups.Count == 0)
{
rows.AddRange(shown);
return rows;
}
var known = Groups.Select(group => group.EntityId).ToHashSet();
var known = groups.Select(group => group.EntityId).ToHashSet();
void AddUngrouped() => AddFlatSection(
rows,
@@ -5258,14 +5421,15 @@ internal sealed partial class VaultViewModel(
null,
host => host.Host.GroupId is not { } id || !known.Contains(id),
ungroupedLabel,
onlyWhenOccupied: true);
onlyWhenOccupied: true,
ungroupedKey: vaultId ?? Guid.Empty);
if (ungroupedFirst)
{
AddUngrouped();
}
foreach (var group in Groups)
foreach (var group in groups)
{
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
}
@@ -5289,13 +5453,19 @@ internal sealed partial class VaultViewModel(
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
/// <param name="ungroupedKey">
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
/// </param>
private void AddFlatSection(
List<ISidebarRow> rows,
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs,
string ungroupedLabel,
bool onlyWhenOccupied = false)
bool onlyWhenOccupied = false,
Guid ungroupedKey = default)
{
var members = shown.Where(belongs).ToArray();
@@ -5304,7 +5474,8 @@ internal sealed partial class VaultViewModel(
return;
}
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
var key = group?.EntityId ?? ungroupedKey;
var expanded = !collapsedGroups.Contains(key);
rows.Add(new SidebarGroupHeader(
group?.EntityId,
@@ -5313,6 +5484,7 @@ internal sealed partial class VaultViewModel(
expanded)
{
VaultBadge = group?.VaultBadge ?? string.Empty,
CollapseKey = key,
});
if (!expanded)
@@ -5458,7 +5630,7 @@ internal sealed partial class VaultViewModel(
return;
}
var key = header.GroupId ?? Guid.Empty;
var key = header.CollapseKey;
if (!collapsedGroups.Remove(key))
{
@@ -5490,7 +5662,7 @@ internal sealed partial class VaultViewModel(
foreach (var header in headers)
{
var key = header.GroupId ?? Guid.Empty;
var key = header.CollapseKey;
if (collapsing)
{
@@ -6259,6 +6431,69 @@ internal sealed partial class VaultViewModel(
ApplyTheChosenHosts();
}
/// <summary>
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
/// </summary>
/// <param name="row">The card that was Ctrl-clicked.</param>
/// <remarks>
/// <para>
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
/// user could act on without noticing.
/// </para>
/// <para>
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
/// to tell it was heard.
/// </para>
/// <para>
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
/// press has already put something in it, and a selection nothing draws is not one anybody can be
/// surprised to find in a count.
/// </para>
/// </remarks>
[RelayCommand]
private void ExtendHostChoice(HostRowViewModel? row)
{
if (row is null)
{
return;
}
FoldTheLitCardIn(besides: row);
ToggleHostChoice(row);
}
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
/// <param name="besides">A card to leave out — the one the gesture is about. See
/// <see cref="ExtendHostChoice"/> for why.</param>
/// <remarks>
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
/// caller, which runs it once for the whole gesture.
/// </remarks>
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
{
if (chosenHostIds.Count > 0
|| SelectedHost is not { } lit
|| ReferenceEquals(lit, besides)
|| !HostBoardOrder.Contains(lit))
{
return;
}
chosenHostIds.Add(lit.EntityId);
}
/// <summary>
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
/// </summary>
@@ -6288,6 +6523,14 @@ internal sealed partial class VaultViewModel(
{
chosenHostIds.Clear();
}
else
{
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
// for the whole of the argument. No card is left out here, because a band adds rather than
// toggles and cannot undo its own fold.
FoldTheLitCardIn();
}
foreach (var row in rows)
{
@@ -8283,12 +8526,25 @@ internal sealed partial class VaultViewModel(
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
/// too.
/// </summary>
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
/// <param name="vaultId">
/// Which vault to write them into, or null for the active one.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// <para>
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
/// be written and no second place for the sync wiring to be forgotten.
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
/// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
/// key to be written and no second place for the sync wiring to be forgotten.
/// </para>
/// <para>
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
/// end up split across two.
/// </para>
/// <para>
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
@@ -8315,10 +8571,12 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal async Task<ImportOutcome> ImportHostsAsync(
IReadOnlyList<ImportedHostRequest> requests,
Guid? vaultId,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(requests);
var target = vaultId ?? session.ActiveVaultId;
var hosts = 0;
var keys = 0;
var refused = 0;
@@ -8340,7 +8598,8 @@ internal sealed partial class VaultViewModel(
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
var (keyId, created) = await BindImportedKeyAsync(
request, bound, target, cancellationToken)
.ConfigureAwait(true);
if (created)
@@ -8351,7 +8610,7 @@ internal sealed partial class VaultViewModel(
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.CreateAsync(target, host, cancellationToken)
.ConfigureAwait(true);
hosts++;
@@ -8381,6 +8640,7 @@ internal sealed partial class VaultViewModel(
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
ImportedHostRequest request,
Dictionary<string, Guid> bound,
Guid vaultId,
CancellationToken cancellationToken)
{
if (request.Key is not { } key || request.KeyPath is not { } path)
@@ -8407,7 +8667,7 @@ internal sealed partial class VaultViewModel(
}
var id = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.CreateAsync(vaultId, key, cancellationToken)
.ConfigureAwait(true);
bound[path] = id;
@@ -8482,6 +8742,12 @@ internal sealed partial class VaultViewModel(
// destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingHostId = row.EntityId;
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
// whole of what makes the board's context menu entry work, since a right click on a card does not
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
IsHostPaneOpen = true;
IsMovingHost = true;
Status = string.Empty;
}