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

@@ -149,6 +149,52 @@
ToolTip.Tip="The SSH port every imported host gets." /> ToolTip.Tip="The SSH port every imported host gets." />
</Grid> </Grid>
<!--
◆ WHERE THEY LAND, AND WHAT THEY AUTHENTICATE WITH. The two answers this import used to
decide on somebody's behalf: the active vault, and nothing.
A project belongs to a team more often than to a person, so the vault is asked rather than
assumed — see HetznerImportViewModel.VaultChoices, and the host editor's own picker for why
this is its own selection rather than the keychain screen's standing preference. It is hidden
at one vault, which is a question with one answer.
The key is the half Hetzner cannot supply and the keychain usually already has: the public
half went to Hetzner when the servers were made, the private half never did. Pointing forty
hosts at it here is one answer; without it, it was forty visits to forty host editors. The
picker is hidden entirely on a keychain with no keys in it.
-->
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="18">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="230" Height="34" ItemsSource="{Binding VaultChoices}"
SelectedItem="{Binding SelectedVault}"
ToolTip.Tip="Which vault the imported hosts are written into. A shared one is readable by everybody who holds its key.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding HasKeyChoice}">
<TextBlock Classes="label" Text="KEY" FontSize="9.5" LetterSpacing="1"
VerticalAlignment="Center" />
<ComboBox Width="260" Height="34" ItemsSource="{Binding KeyChoices}"
SelectedItem="{Binding SelectedKey}"
ToolTip.Tip="A key already in your keychain, which every imported host is pointed at. Nothing is copied — the host stores the key's id, exactly as one bound by hand does.">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerKeyChoice">
<TextBlock Text="{Binding Label}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11.5" LineHeight="17" TextWrapping="Wrap"
Text="{Binding AddressNote}" /> Text="{Binding AddressNote}" />
+52 -8
View File
@@ -457,11 +457,12 @@
something that refuses it, leaving the window with nothing focused and the keystrokes going something that refuses it, leaving the window with nothing focused and the keystrokes going
nowhere. nowhere.
◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts: ◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
the old grid held one level of a tree at a time, and this holds every group's cards at once, the than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
feature invisible until it is used. which is what makes the feature invisible until it is used. A session holding one vault draws no
vault headings either, for the same reason and by the same rule.
--> -->
<ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}"> <ItemsControl x:Name="Board" Focusable="True" ItemsSource="{Binding HostSections}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
@@ -472,6 +473,27 @@
<DataTemplate x:DataType="vm:HostSectionViewModel"> <DataTemplate x:DataType="vm:HostSectionViewModel">
<StackPanel Spacing="10"> <StackPanel Spacing="10">
<!--
============ THE VAULT HEADING ============
◆ THE BOARD'S OUTER LEVEL. A vault holds groups and a group holds hosts, and until this
row existed the board drew only the inner two — every group of every vault in one run,
told apart by a small grey vault name beside each heading. Two vaults each holding a
"production" were two headings with the same word on them.
Drawn on the first section of each vault and nowhere else — see
HostSectionViewModel.VaultHeading — and on no section at all where the board has one
vault, which is what keeps a single-vault keychain looking exactly as it did. The rule
to its right is the whole of the emphasis: this level is a divider with a name on it,
not a heading competing with the group headings under it.
-->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding StartsAVault}" Margin="0,2,0,2">
<TextBlock Grid.Column="0" Text="{Binding VaultHeading}" FontSize="11.5"
FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Height="1" Margin="10,0,0,0" VerticalAlignment="Center"
Background="{StaticResource BorderSubtle}" />
</Grid>
<!-- <!--
============ THE SECTION HEADING ============ ============ THE SECTION HEADING ============
"{label} · {count}", the vault name beside it when more than one vault is in play, a "{label} · {count}", the vault name beside it when more than one vault is in play, a
@@ -569,6 +591,18 @@
Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" /> Command="{Binding #Board.((vm:VaultViewModel)DataContext).OpenHostPaneCommand}" />
<MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" <MenuItem Header="Edit…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" /> Command="{Binding #Board.((vm:VaultViewModel)DataContext).EditSelectedHostCommand}" />
<!--
◆ THE SAME ENTRY THE DRAWER'S ⋯ MENU HAS, on the board where the host is. Moving
one machine used to mean opening its pane first and then finding the menu inside
it, while moving six was a right click away — the one-host case was the harder of
the two, which is backwards. The command raises the drawer itself (see
VaultViewModel.MoveHost); the picker it opens lives in that footer and nowhere
else, and an entry that armed a panel nobody could see would be a menu item that
did nothing.
-->
<MenuItem Header="Move to another vault…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).MoveHostCommand}" />
<Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" /> <Separator IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" />
<MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}" <MenuItem Header="Delete…" IsVisible="{Binding !#Board.((vm:VaultViewModel)DataContext).IsChoosingHosts}"
Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" /> Command="{Binding #Board.((vm:VaultViewModel)DataContext).DeleteHostCommand}" />
@@ -600,16 +634,26 @@
The full address is on the tooltip and nowhere else on the card — the design's own The full address is on the tooltip and nowhere else on the card — the design's own
rule, kept: a card read while scanning a section is read for its name and whether rule, kept: a card read while scanning a section is read for its name and whether
it is live, and an address is what somebody reads once they have found the machine, it is live, and an address is what somebody reads once they have found the machine,
which the tooltip and the drawer both still say. which the tooltip and the drawer both still say. The name is on the tooltip too,
and that is not duplication of the line six pixels above it: 214 pixels of card
trims it, and the tooltip is where the untrimmed one lives. See
HostRowViewModel.Tooltip.
MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's MinHeight rather than a fixed Height, unlike the mock's own 120: this codebase's
Border.tile is deliberately a fixed width and a free height (see App.axaml), so a Border.tile is deliberately a fixed width and a free height (see App.axaml), so a
card wearing four tags grows a second line rather than clipping the fourth one — a card wearing four tags grows a second line rather than clipping the fourth one — a
silent truncation is exactly the kind of thing the honesty rule in silent truncation is exactly the kind of thing the honesty rule in
HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid. HANDOFF-hosts-v5.md exists to catch, and it costs nothing here to avoid.
◆ 104 RATHER THAN THE MOCK'S 120, and the floor is the only thing that moved. What
a card with no tags actually needs is about 87 — 20 of padding, 43 of name and
address, 24 for the row along the bottom — so 120 was thirty pixels of air below
every card on a board that is scrolled. It is still a floor with room in it: the
number is deliberately above what the content measures, so the common card is a
uniform height rather than each one shrink-wrapping its own last line.
--> -->
<Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="120" <Border Classes="tile" Classes.chosen="{Binding IsChosen}" MinHeight="104"
ToolTip.Tip="{Binding Address}"> ToolTip.Tip="{Binding Tooltip}">
<Panel> <Panel>
<Grid RowDefinitions="Auto,*,Auto"> <Grid RowDefinitions="Auto,*,Auto">
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
if (control) if (control)
{ {
vault.ToggleHostChoiceCommand.Execute(row); // Extend rather than toggle: the set this starts has the lit card in it, which is the half a
// plain toggle left out. See VaultViewModel.ExtendHostChoice.
vault.ExtendHostChoiceCommand.Execute(row);
anchor = row; anchor = row;
Board.Focus(); Board.Focus();
@@ -148,6 +148,38 @@ internal sealed partial class HetznerRowViewModel(HetznerServer server) : Observ
} }
} }
/// <summary>
/// One entry in the import's key picker: a key already in the keychain, or none at all.
/// </summary>
/// <param name="KeyId">The key, or null for "no key".</param>
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
/// <param name="Label">What the picker shows.</param>
/// <remarks>
/// <para>
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
/// is no file at all, only a key the keychain has had all along.
/// </para>
/// <para>
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
/// same thing — and on this screen the second is a promise about how forty machines will behave.
/// </para>
/// </remarks>
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
{
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
/// <remarks>
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
/// typed. Chosen, it is one answer to one question the person running the import already knows.
/// </remarks>
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
}
/// <summary> /// <summary>
/// Importing the servers in a Hetzner Cloud project. /// Importing the servers in a Hetzner Cloud project.
/// </summary> /// </summary>
@@ -310,14 +342,76 @@ internal sealed partial class HetznerImportViewModel(
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy; internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
/// <summary> /// <summary>
/// The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>. /// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Fixed rather than offered as a picker, exactly as the ssh_config importer's is: the import goes /// <para>
/// through the same active vault every other bulk write does, and there is no per-import target to bind. /// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
/// Printed as a fact instead of drawn as a dropdown. /// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
/// items moved one at a time afterwards.
/// </para>
/// <para>
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
/// preference about where new items go, and this is a field of the import in front of you. Filled from
/// the same list, so it offers what every other "file this into" control offers — vaults this session
/// can both read and write.
/// </para>
/// </remarks> /// </remarks>
internal string VaultName => vault.VaultName; internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedVault;
/// <summary>Whether there is more than one vault to choose between.</summary>
/// <remarks>
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
/// offering one option is a question with no answer.
/// </remarks>
internal bool HasVaultChoice => VaultChoices.Count > 1;
/// <summary>The vault the import writes to, or null for the active one.</summary>
internal Guid? TargetVaultId => SelectedVault?.VaultId;
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
/// <summary>
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
/// already in the keychain is almost always the key that opens them, because it is the one whose public
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
/// estate; without it, it was forty visits to forty host editors.
/// </para>
/// <para>
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
/// </para>
/// </remarks>
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
[ObservableProperty]
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
/// <summary>Whether the keychain has a key to offer at all.</summary>
/// <remarks>
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
/// would do something.
/// </remarks>
internal bool HasKeyChoice => KeyChoices.Count > 1;
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
internal Guid? BoundKeyId => SelectedKey?.KeyId;
/// <summary>How many ticked, out of how many, and where they land.</summary> /// <summary>How many ticked, out of how many, and where they land.</summary>
internal string SelectionSummary internal string SelectionSummary
@@ -331,18 +425,45 @@ internal sealed partial class HetznerImportViewModel(
} }
/// <summary> /// <summary>
/// The sentence that says an imported host will not connect on its own. /// The sentence that says how an imported host will authenticate.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives /// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
/// for its own version: whether the credential comes with the host is the difference between a bookmark /// for its own version: whether the credential comes with the host is the difference between a bookmark
/// that connects and one that asks for a password, and somebody who is not told will conclude the /// that connects and one that asks for a password, and somebody who is not told will conclude the
/// import was broken. /// import was broken.
/// </para>
/// <para>
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
/// </para>
/// </remarks> /// </remarks>
internal static string AuthenticationNote => internal string AuthenticationNote => SelectedKey?.KeyId is null
"Hetzner has never held the private half of an SSH key, so nothing here can bring one. A server " ? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
+ "imported this way arrives asking for a password until you bind it to a key already in your " + "chosen above, a server imported this way arrives asking for a password until you bind it to "
+ "keychain — from the host's own editor, or in bulk from the Hosts screen."; + "one — from the host's own editor, or in bulk from the Hosts screen."
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
+ $"itself is not copied and not read.{StrandedKeyNote}";
/// <summary>
/// What is said when the chosen key sits in a different vault from the hosts.
/// </summary>
/// <remarks>
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
/// because it is being done to a whole estate at once: a binding resolves across every vault
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
/// hosts in three vaults is what keys are for.
/// </remarks>
private string StrandedKeyNote =>
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
+ "see hosts they cannot authenticate."
: string.Empty;
/// <summary>What the token step says about the token it is asking for.</summary> /// <summary>What the token step says about the token it is asking for.</summary>
/// <remarks> /// <remarks>
@@ -363,31 +484,13 @@ internal sealed partial class HetznerImportViewModel(
private async Task ScanAsync(CancellationToken cancellationToken) private async Task ScanAsync(CancellationToken cancellationToken)
{ {
ClearRows(); ClearRows();
RefreshChoices();
IsBusy = true; IsBusy = true;
try try
{ {
var import = await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true); Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
foreach (var server in import.Servers)
{
var row = new HetznerRowViewModel(server);
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound
// from the item template, because a template binding cannot reach the list it is inside
// without a parent binding — and the count is on the parent by definition.
row.PropertyChanged += OnRowChanged;
Rows.Add(row);
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
Refresh(resetSelection: true);
if (Rows.Count == 0) if (Rows.Count == 0)
{ {
@@ -445,28 +548,25 @@ internal sealed partial class HetznerImportViewModel(
try try
{ {
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
// leaves the host bound to nothing, which is what it always was.
var requests = chosen var requests = chosen
.Select(row => new ImportedHostRequest( .Select(row => new ImportedHostRequest(
row.Server.ToSecret(row.Resolved!, Username, port), row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
Key: null, Key: null,
KeyPath: null)) KeyPath: null))
.ToList(); .ToList();
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true); var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
.ConfigureAwait(true);
// Re-resolved rather than cleared, so a row that was just imported now says "already here" — // Re-resolved rather than cleared, so a row that was just imported now says "already here" —
// which is what makes coming back to the list and importing the rest of the project harmless // which is what makes coming back to the list and importing the rest of the project harmless
// and visible rather than harmless and confusing. // and visible rather than harmless and confusing.
Refresh(resetSelection: true); Refresh(resetSelection: true);
// This screen's own sentence rather than the vault's, which the push that follows an import Status = WhatWasStored(outcome.Hosts);
// overwrites with a sync report — right on a screen somebody is about to leave, wrong on the one
// they are still standing on.
Status = outcome.Hosts == 1
? "Imported 1 server. It is on the Hosts screen, asking for a password until you bind it "
+ "to a key."
: $"Imported {outcome.Hosts} servers. They are on the Hosts screen, asking for a password "
+ "until you bind them to a key.";
Step = HetznerImportStep.Done; Step = HetznerImportStep.Done;
} }
@@ -538,6 +638,108 @@ internal sealed partial class HetznerImportViewModel(
[RelayCommand] [RelayCommand]
private void Cancel() => onCancel?.Invoke(); private void Cancel() => onCancel?.Invoke();
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
/// <remarks>
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
/// standing on. It names the vault and the key because those are the two answers that were chosen above
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
/// there is a key is what they would otherwise find out at the next connection.
/// </remarks>
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
{
(true, true) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
(true, false) =>
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
+ "you bind it to a key.",
(false, true) =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
+ $"'{SelectedKey!.Label}'.",
_ =>
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
+ "password until you bind them to a key.",
};
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
/// <remarks>
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
/// them and presses the button.
/// </remarks>
private void Fill(HetznerImport import)
{
foreach (var server in import.Servers)
{
var row = new HetznerRowViewModel(server);
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
// the item template, because a template binding cannot reach the list it is inside without a
// parent binding — and the count is on the parent by definition.
row.PropertyChanged += OnRowChanged;
Rows.Add(row);
}
foreach (var warning in import.Warnings)
{
Warnings.Add(warning);
}
Refresh(resetSelection: true);
}
/// <summary>
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
/// </summary>
/// <remarks>
/// <para>
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
/// </para>
/// <para>
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
/// the import would not actually write to. Anything that has since gone falls back to the default —
/// the active vault, and no key.
/// </para>
/// </remarks>
private void RefreshChoices()
{
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
var keyId = SelectedKey?.KeyId;
VaultChoices.Clear();
foreach (var choice in vault.TargetVaults)
{
VaultChoices.Add(choice);
}
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
?? VaultChoices.FirstOrDefault();
KeyChoices.Clear();
KeyChoices.Add(HetznerKeyChoice.None);
foreach (var key in vault.Keys)
{
// The vault name where there is more than one to be in, exactly as the key list itself prints
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
// has to say which. HasVaultBadge is that same test, already answered.
KeyChoices.Add(new HetznerKeyChoice(
key.EntityId,
key.VaultId,
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
}
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
OnPropertyChanged(nameof(HasVaultChoice));
OnPropertyChanged(nameof(HasKeyChoice));
}
/// <summary>Drops the list, unsubscribing from what it held.</summary> /// <summary>Drops the list, unsubscribing from what it held.</summary>
/// <remarks> /// <remarks>
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would /// The detach matters: rows outlive the list only through this handler, and a scan run four times would
@@ -605,6 +807,21 @@ internal sealed partial class HetznerImportViewModel(
OnPropertyChanged(nameof(StepTitle)); OnPropertyChanged(nameof(StepTitle));
} }
/// <remarks>
/// The vault is named in three places on this step — the summary line, the note under it and the
/// sentence about a key left in another vault — and none of them is a control the picker owns.
/// </remarks>
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
{
OnPropertyChanged(nameof(VaultName));
OnPropertyChanged(nameof(SelectionSummary));
OnPropertyChanged(nameof(AuthenticationNote));
}
/// <inheritdoc cref="OnSelectedVaultChanged" />
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
OnPropertyChanged(nameof(AuthenticationNote));
partial void OnAddressKindChanged(HetznerAddressKind value) partial void OnAddressKindChanged(HetznerAddressKind value)
{ {
OnPropertyChanged(nameof(UsesPublicIPv4)); OnPropertyChanged(nameof(UsesPublicIPv4));
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
var requests = chosen.Select(BuildRequest).ToList(); var requests = chosen.Select(BuildRequest).ToList();
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true); var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes // Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
// pressing the button twice harmless and visible rather than harmless and confusing. // pressing the button twice harmless and visible rather than harmless and confusing.
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// <summary>Whether this heading has a vault to name.</summary> /// <summary>Whether this heading has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0; 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> /// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸"; internal string Chevron => IsExpanded ? "▾" : "▸";
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
internal bool HasHeader => header is not null; internal bool HasHeader => header is not null;
internal IReadOnlyList<HostRowViewModel> Hosts => hosts; 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> /// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}"); $"{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> /// <remarks>
/// An em dash for "nobody", which covers both a host that states no username and a host whose group /// 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, /// 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)); 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() private List<HostSectionViewModel> BuildHostSections()
{ {
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray(); 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) 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; SidebarGroupHeader? header = null;
var members = new List<HostRowViewModel>(); var members = new List<HostRowViewModel>();
var firstHeading = true; var loose = new List<HostRowViewModel>();
foreach (var row in flat) foreach (var row in flat)
{ {
@@ -5199,15 +5337,12 @@ internal sealed partial class VaultViewModel(
sections.Add(new HostSectionViewModel(header, members)); sections.Add(new HostSectionViewModel(header, members));
} }
// Marked on the first heading only, which is where the mock draws Collapse all / Expand header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
// all — see IsFirstBoardSection and the XAML.
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
firstHeading = false;
members = []; members = [];
} }
else if (row is HostRowViewModel host) 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)); 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; 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. /// desktop's first, and there is no reason the two heads have to agree once there are two callers.
/// </param> /// </param>
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</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> /// <remarks>
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has /// <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 /// 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. /// left empty.
/// </remarks> /// </remarks>
private List<ISidebarRow> FlattenIntoSections( 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>(); 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); rows.AddRange(shown);
return rows; return rows;
} }
var known = Groups.Select(group => group.EntityId).ToHashSet(); var known = groups.Select(group => group.EntityId).ToHashSet();
void AddUngrouped() => AddFlatSection( void AddUngrouped() => AddFlatSection(
rows, rows,
@@ -5258,14 +5421,15 @@ internal sealed partial class VaultViewModel(
null, null,
host => host.Host.GroupId is not { } id || !known.Contains(id), host => host.Host.GroupId is not { } id || !known.Contains(id),
ungroupedLabel, ungroupedLabel,
onlyWhenOccupied: true); onlyWhenOccupied: true,
ungroupedKey: vaultId ?? Guid.Empty);
if (ungroupedFirst) if (ungroupedFirst)
{ {
AddUngrouped(); AddUngrouped();
} }
foreach (var group in Groups) foreach (var group in groups)
{ {
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel); 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="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="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="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( private void AddFlatSection(
List<ISidebarRow> rows, List<ISidebarRow> rows,
IReadOnlyList<HostRowViewModel> shown, IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group, HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs, Func<HostRowViewModel, bool> belongs,
string ungroupedLabel, string ungroupedLabel,
bool onlyWhenOccupied = false) bool onlyWhenOccupied = false,
Guid ungroupedKey = default)
{ {
var members = shown.Where(belongs).ToArray(); var members = shown.Where(belongs).ToArray();
@@ -5304,7 +5474,8 @@ internal sealed partial class VaultViewModel(
return; return;
} }
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty); var key = group?.EntityId ?? ungroupedKey;
var expanded = !collapsedGroups.Contains(key);
rows.Add(new SidebarGroupHeader( rows.Add(new SidebarGroupHeader(
group?.EntityId, group?.EntityId,
@@ -5313,6 +5484,7 @@ internal sealed partial class VaultViewModel(
expanded) expanded)
{ {
VaultBadge = group?.VaultBadge ?? string.Empty, VaultBadge = group?.VaultBadge ?? string.Empty,
CollapseKey = key,
}); });
if (!expanded) if (!expanded)
@@ -5458,7 +5630,7 @@ internal sealed partial class VaultViewModel(
return; return;
} }
var key = header.GroupId ?? Guid.Empty; var key = header.CollapseKey;
if (!collapsedGroups.Remove(key)) if (!collapsedGroups.Remove(key))
{ {
@@ -5490,7 +5662,7 @@ internal sealed partial class VaultViewModel(
foreach (var header in headers) foreach (var header in headers)
{ {
var key = header.GroupId ?? Guid.Empty; var key = header.CollapseKey;
if (collapsing) if (collapsing)
{ {
@@ -6259,6 +6431,69 @@ internal sealed partial class VaultViewModel(
ApplyTheChosenHosts(); 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> /// <summary>
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of. /// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
/// </summary> /// </summary>
@@ -6288,6 +6523,14 @@ internal sealed partial class VaultViewModel(
{ {
chosenHostIds.Clear(); 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) 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 /// Stores several hosts at once, the way one save does, with the keys they named where those are coming
/// too. /// too.
/// </summary> /// </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> /// <remarks>
/// <para> /// <para>
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes /// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
/// through the same repositories, the same outbox and the same automatic push as saving one — the import /// repositories, the same outbox and the same automatic push as saving one — an import screen decides
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to /// <em>which</em> hosts and <em>where</em> and nothing else, so there is no second way for a host or a
/// be written and no second place for the sync wiring to be forgotten. /// 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>
/// <para> /// <para>
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be /// 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> /// </remarks>
internal async Task<ImportOutcome> ImportHostsAsync( internal async Task<ImportOutcome> ImportHostsAsync(
IReadOnlyList<ImportedHostRequest> requests, IReadOnlyList<ImportedHostRequest> requests,
Guid? vaultId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(requests); ArgumentNullException.ThrowIfNull(requests);
var target = vaultId ?? session.ActiveVaultId;
var hosts = 0; var hosts = 0;
var keys = 0; var keys = 0;
var refused = 0; var refused = 0;
@@ -8340,7 +8598,8 @@ internal sealed partial class VaultViewModel(
continue; continue;
} }
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken) var (keyId, created) = await BindImportedKeyAsync(
request, bound, target, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
if (created) if (created)
@@ -8351,7 +8610,7 @@ internal sealed partial class VaultViewModel(
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host; var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken) .CreateAsync(target, host, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
hosts++; hosts++;
@@ -8381,6 +8640,7 @@ internal sealed partial class VaultViewModel(
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync( private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
ImportedHostRequest request, ImportedHostRequest request,
Dictionary<string, Guid> bound, Dictionary<string, Guid> bound,
Guid vaultId,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (request.Key is not { } key || request.KeyPath is not { } path) 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 var id = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken) .CreateAsync(vaultId, key, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
bound[path] = id; bound[path] = id;
@@ -8482,6 +8742,12 @@ internal sealed partial class VaultViewModel(
// destroys it, is not a pane anybody should have to read carefully. // destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null; PendingDeletion = null;
movingHostId = row.EntityId; 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; IsMovingHost = true;
Status = string.Empty; Status = string.Empty;
} }
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
/// way down rather than acted on as it bubbles. /// way down rather than acted on as it bubbles.
/// </para> /// </para>
/// <para> /// <para>
/// ◆ <b>And the set the first Ctrl-click starts has the lit card in it.</b> 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. A Ctrl-click there used to produce a set of
/// one, leaving the machine the user was looking at out of the count and out of everything Delete, Move
/// and Change group then acted on. See <c>VaultViewModel.ExtendHostChoice</c>.
/// </para>
/// <para>
/// And a plain click is the way out, which is the other half of the same rule: after one, exactly one /// And a plain click is the way out, which is the other half of the same rule: after one, exactly one
/// card is in play and every command on this screen is about the same host. /// card is in play and every command on this screen is about the same host.
/// </para> /// </para>
/// </remarks> /// </remarks>
[Fact] [Fact]
public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection() public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
{ {
await OnTheBoardAsync((screen, window) => await OnTheBoardAsync((screen, window) =>
{ {
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
Click(CardFor(screen, second), window, RawInputModifiers.Control); Click(CardFor(screen, second), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1); vault.ChosenHostCount.ShouldBe(2, "the card the board had lit is in the set the Ctrl-click began");
first.IsChosen.ShouldBeTrue("which is the half that used to be left out of it");
second.IsChosen.ShouldBeTrue("the tick is drawn on the card"); second.IsChosen.ShouldBeTrue("the tick is drawn on the card");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it"); vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise"); vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
Click(CardFor(screen, first), window, RawInputModifiers.Control); Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(2);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
first.IsChosen.ShouldBeFalse("the lit card is not folded back in over a set that already exists");
Click(CardFor(screen, first), window, RawInputModifiers.Control); Click(CardFor(screen, first), window, RawInputModifiers.Control);
vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off"); vault.ChosenHostCount.ShouldBe(2);
Click(CardFor(screen, first), window); Click(CardFor(screen, first), window);
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
var tickedCard = CardFor(screen, ticked); var tickedCard = CardFor(screen, ticked);
// Lit first, and it matters: a Ctrl-click folds the lit card into the set it starts — see
// ExtendHostChoice — so ticking one card while another is lit would leave no card outside the
// set for the second half of this test to right-click.
vault.SelectedHost = ticked;
Dispatcher.UIThread.RunJobs();
Click(tickedCard, window, RawInputModifiers.Control); Click(tickedCard, window, RawInputModifiers.Control);
RightClick(tickedCard, window); RightClick(tickedCard, window);
@@ -405,6 +405,84 @@ public sealed partial class ShellFlowTests
// ---- Helpers ---- // ---- Helpers ----
/// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary> /// <summary>A server carrying enough to be imported, used wherever the shape is not what is being tested.</summary>
/// <summary>
/// The two answers this import used to decide on somebody's behalf: which vault, and which key.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.</b>
/// The <c>ssh_config</c> importer reads this machine's own account and files into the active vault
/// fairly; a project belongs to a team more often than to a person, and forty servers landing in
/// somebody's personal vault are either forty items nobody else can see or forty moved one at a time.
/// </para>
/// <para>
/// <b>The key is the half Hetzner cannot supply and the keychain usually already holds.</b> The public
/// half went to Hetzner when the servers were made; the private half never did. So the import points at
/// a key rather than bringing one — the host stores its id, exactly as one bound by hand does, and the
/// material is neither read nor copied. Without it this screen's honest answer was forty hosts asking
/// for a password.
/// </para>
/// <para>
/// The key here is deliberately left in the personal vault while the hosts go to the shared one, which
/// is allowed and is the one thing about it worth saying out loud: a binding resolves across every vault
/// <em>this</em> account can read, so these hosts connect here and not for a colleague who holds only
/// the destination's key.
/// </para>
/// </remarks>
[Fact]
public async Task ImportingIntoAChosenVault_BindsEveryServerToTheChosenKey()
{
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);
await AddKeyAsync(vault, "deploy");
var keyId = vault.Keys.ShouldHaveSingleItem().EntityId;
var wizard = Wizard(Project(WebServer));
wizard.Token = HetznerToken;
await wizard.ScanCommand.ExecuteAsync(null);
wizard.HasVaultChoice.ShouldBeTrue("there are two vaults to choose between");
wizard.HasKeyChoice.ShouldBeTrue("the keychain has a key to point at");
wizard.SelectedKey.ShouldNotBeNull().KeyId.ShouldBeNull("binding a key is chosen, never defaulted");
wizard.SelectedVault = wizard.VaultChoices.Single(choice => choice.VaultId == sharedVaultId);
wizard.SelectedKey = wizard.KeyChoices.Single(choice => choice.KeyId == keyId);
wizard.AuthenticationNote.ShouldContain("deploy");
wizard.AuthenticationNote.ShouldContain(
"another vault",
Case.Sensitive,
"the key stayed behind, and the colleagues these hosts just joined cannot resolve it");
await wizard.ImportCommand.ExecuteAsync(null);
var imported = vault.Hosts.Single(row => string.Equals(row.Label, "web-01", StringComparison.Ordinal));
imported.VaultId.ShouldBe(sharedVaultId, wizard.Status);
imported.Host.SshKeyId.ShouldBe(keyId, "pointed at the key rather than carrying one");
wizard.Status.ShouldContain("Platform secrets");
// Through the ordinary path, as every other write here is: it reached the server without anything
// pressing Sync.
server.LiveRowCount.ShouldBe(2, "the key and the host");
}
private const string WebServer = """ private const string WebServer = """
{ {
"id": 2, "id": 2,
@@ -6485,6 +6485,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Key: null, Key: null,
KeyPath: null), KeyPath: null),
], ],
vaultId: null,
Token); Token);
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id"); 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(); 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> /// <remarks>
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is /// 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 /// 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"); 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> /// <remarks>
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an /// 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 /// 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); 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) private static async Task AddHostAsync(VaultViewModel vault, string label)
{ {
vault.NewHostCommand.Execute(null); vault.NewHostCommand.Execute(null);