diff --git a/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml b/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml
index 8416b52..95df545 100644
--- a/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/HetznerImportScreen.axaml
@@ -149,6 +149,52 @@
ToolTip.Tip="The SSH port every imported host gets." />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
index 836902f..d91eba9 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml
@@ -457,11 +457,12 @@
something that refuses it, leaving the window with nothing focused and the keystrokes going
nowhere.
- ◆ EVERY GROUP, ONE AFTER ANOTHER, "No group" FIRST. Binds HostSections rather than VisibleHosts:
- the old grid held one level of a tree at a time, and this holds every group's cards at once, the
- way SidebarRows always has for the phone. A keychain nobody has filed anything in draws one
- section with no heading at all — see HostSectionViewModel.HasHeader — which is what makes the
- feature invisible until it is used.
+ ◆ EVERY VAULT, AND INSIDE EACH ONE EVERY GROUP, "No group" FIRST. Binds HostSections rather
+ than VisibleHosts: the old grid held one level of a tree at a time, and this holds every group's
+ cards at once, the way SidebarRows always has for the phone. A keychain nobody has filed
+ anything in draws one section with no heading at all — see HostSectionViewModel.HasHeader —
+ 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.
-->
@@ -472,6 +473,27 @@
+
+
+
+
+
+
+
@@ -600,16 +634,26 @@
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
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
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
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.
+
+ ◆ 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.
-->
-
+
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
index 678cb34..aef33aa 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
@@ -263,7 +263,9 @@ internal sealed partial class HostsScreen : UserControl
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;
Board.Focus();
diff --git a/src/DodoSSH.Client.Shell/ViewModels/HetznerImportViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/HetznerImportViewModel.cs
index 33a866a..51d08fb 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/HetznerImportViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/HetznerImportViewModel.cs
@@ -148,6 +148,38 @@ internal sealed partial class HetznerRowViewModel(HetznerServer server) : Observ
}
}
+///
+/// One entry in the import's key picker: a key already in the keychain, or none at all.
+///
+/// The key, or null for "no key".
+/// The vault that key lives in, or null for the "no key" entry.
+/// What the picker shows.
+///
+///
+/// ◆ An id rather than the key, because nothing here reads key material. The import writes
+/// HostSecret.SshKeyId 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 VaultViewModel.ImportHostsAsync
+/// 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.
+///
+///
+/// A sentinel entry for "no key" rather than a null selection, for the reason GroupChoice.None 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.
+///
+///
+internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
+{
+ /// The "bind nothing" entry, always first and selected until somebody chooses otherwise.
+ ///
+ /// 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.
+ ///
+ internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
+}
+
///
/// Importing the servers in a Hetzner Cloud project.
///
@@ -310,14 +342,76 @@ internal sealed partial class HetznerImportViewModel(
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
///
- /// The vault every import lands in — see .
+ /// Where the imported hosts go — see .
///
///
- /// Fixed rather than offered as a picker, exactly as the ssh_config importer's is: the import goes
- /// through the same active vault every other bulk write does, and there is no per-import target to bind.
- /// Printed as a fact instead of drawn as a dropdown.
+ ///
+ /// ◆ A picker rather than the active vault, which is what the ssh_config importer still uses.
+ /// The two are not the same question. That screen reads ~/.ssh/config — 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.
+ ///
+ ///
+ /// Its own selection rather than 's, on the reasoning
+ /// VaultViewModel.EditorVaultChoices 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.
+ ///
///
- internal string VaultName => vault.VaultName;
+ internal ObservableCollection VaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedVault;
+
+ /// Whether there is more than one vault to choose between.
+ ///
+ /// 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.
+ ///
+ internal bool HasVaultChoice => VaultChoices.Count > 1;
+
+ /// The vault the import writes to, or null for the active one.
+ internal Guid? TargetVaultId => SelectedVault?.VaultId;
+
+ /// What the summary line calls the vault the hosts are going into.
+ internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
+
+ ///
+ /// The keys every imported host could be bound to: "no key", then everything in the keychain.
+ ///
+ ///
+ ///
+ /// ◆ The one thing this import could never do, done by pointing rather than by bringing. Hetzner
+ /// has never held a private half, so no key can arrive with 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.
+ ///
+ ///
+ /// 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 rather than prevented here.
+ ///
+ ///
+ internal ObservableCollection KeyChoices { get; } = [];
+
+ [ObservableProperty]
+ private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
+
+ /// Whether the keychain has a key to offer at all.
+ ///
+ /// 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.
+ ///
+ internal bool HasKeyChoice => KeyChoices.Count > 1;
+
+ /// The key every imported host is bound to, where one was chosen.
+ internal Guid? BoundKeyId => SelectedKey?.KeyId;
/// How many ticked, out of how many, and where they land.
internal string SelectionSummary
@@ -331,18 +425,45 @@ internal sealed partial class HetznerImportViewModel(
}
///
- /// The sentence that says an imported host will not connect on its own.
+ /// The sentence that says how an imported host will authenticate.
///
///
+ ///
/// Said before the button rather than after, on exactly the reasoning ImportScreen.axaml gives
/// 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
/// import was broken.
+ ///
+ ///
+ /// 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.
+ ///
///
- internal static string AuthenticationNote =>
- "Hetzner has never held the private half of an SSH key, so nothing here can bring one. A server "
- + "imported this way arrives asking for a password until you bind it to a key already in your "
- + "keychain — from the host's own editor, or in bulk from the Hosts screen.";
+ internal string AuthenticationNote => SelectedKey?.KeyId is null
+ ? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
+ + "chosen above, a server imported this way arrives asking for a password until you bind it to "
+ + "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}";
+
+ ///
+ /// What is said when the chosen key sits in a different vault from the hosts.
+ ///
+ ///
+ /// 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
+ /// this 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.
+ ///
+ 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;
/// What the token step says about the token it is asking for.
///
@@ -363,31 +484,13 @@ internal sealed partial class HetznerImportViewModel(
private async Task ScanAsync(CancellationToken cancellationToken)
{
ClearRows();
+ RefreshChoices();
IsBusy = true;
try
{
- var import = 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);
+ Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
if (Rows.Count == 0)
{
@@ -445,28 +548,25 @@ internal sealed partial class HetznerImportViewModel(
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
.Select(row => new ImportedHostRequest(
- row.Server.ToSecret(row.Resolved!, Username, port),
+ row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
Key: null,
KeyPath: null))
.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" —
// which is what makes coming back to the list and importing the rest of the project harmless
// and visible rather than harmless and confusing.
Refresh(resetSelection: true);
- // 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.
- 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.";
+ Status = WhatWasStored(outcome.Hosts);
Step = HetznerImportStep.Done;
}
@@ -538,6 +638,108 @@ internal sealed partial class HetznerImportViewModel(
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
+ /// What the import did, in the sentence the screen keeps after it.
+ ///
+ /// 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.
+ ///
+ 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.",
+ };
+
+ /// Fills the list from what the project said, and resolves every row against the choices.
+ ///
+ /// Its own method rather than part of , 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// Selections are carried across by id, 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.
+ ///
+ ///
+ 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));
+ }
+
/// Drops the list, unsubscribing from what it held.
///
/// 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));
}
+ ///
+ /// 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.
+ ///
+ partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
+ {
+ OnPropertyChanged(nameof(VaultName));
+ OnPropertyChanged(nameof(SelectionSummary));
+ OnPropertyChanged(nameof(AuthenticationNote));
+ }
+
+ ///
+ partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
+ OnPropertyChanged(nameof(AuthenticationNote));
+
partial void OnAddressKindChanged(HetznerAddressKind value)
{
OnPropertyChanged(nameof(UsesPublicIPv4));
diff --git a/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
index 439d600..75ffa46 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/ImportViewModel.cs
@@ -371,7 +371,7 @@ internal sealed partial class ImportViewModel(
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
// pressing the button twice harmless and visible rather than harmless and confusing.
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index 00073ef..9ecc870 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -55,6 +55,22 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// Whether this heading has a vault to name.
internal bool HasVaultBadge => VaultBadge.Length > 0;
+ ///
+ /// What this heading is folded and unfolded by, in VaultViewModel.collapsedGroups.
+ ///
+ ///
+ /// ◆ The group's id for a group, and something else for "No group" — which is why this is not
+ /// with a fallback. It was exactly that until the desktop's board grew a
+ /// heading per vault: there is now one "No group" heading per vault, and every one of them
+ /// answers with null. Keyed on 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 . Neither can collide
+ /// with a group id, and is not a legal one — HostSecret.TryValidate
+ /// refuses it.
+ ///
+ internal Guid CollapseKey { get; init; }
+
/// The chevron, as text, because the heading is drawn in the list's own item template.
internal string Chevron => IsExpanded ? "▾" : "▸";
@@ -167,6 +183,31 @@ internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnly
internal bool HasHeader => header is not null;
internal IReadOnlyList Hosts => hosts;
+
+ ///
+ /// The vault this section opens, or empty where it is not the first section of one.
+ ///
+ ///
+ ///
+ /// ◆ The board's outer level, and groups are the inner one. 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.
+ ///
+ ///
+ /// 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 has always applied to itself.
+ ///
+ ///
+ internal string VaultHeading { get; init; } = string.Empty;
+
+ /// Whether this section opens a vault and should draw its name above the group heading.
+ internal bool StartsAVault => VaultHeading.Length > 0;
}
/// An entry in the toolbar's Group ▾ filter flyout.
@@ -411,6 +452,19 @@ internal sealed partial class HostRowViewModel(
CultureInfo.InvariantCulture,
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
+ ///
+ /// What a card says when the pointer rests on it: the name in full, and the address under it.
+ ///
+ ///
+ /// ◆ The name is here because the card trims it and the tooltip was the one place it was not said.
+ /// A tile is a fixed 214 wide (see Border.tile in App.axaml) and the name is drawn with
+ /// TextTrimming, 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.
+ ///
+ internal string Tooltip => $"{Label}\n{Address}";
+
///
/// 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));
}
+ ///
+ /// The board, vault by vault: every vault's own groups under its own heading.
+ ///
+ ///
+ ///
+ /// ◆ Two levels, and the outer one is the vault. See
+ /// 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.
+ ///
+ ///
+ /// One vault on the board and this is exactly what it was. 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 and shown, so hiding the second vault takes the headings away with it.
+ ///
+ ///
private List 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;
+ }
+
+ /// The vaults the board draws a heading for, in the order it draws them.
+ ///
+ /// The active vault first and then by name, which is the order , 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 .
+ ///
+ 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)),
+ ];
+
+ ///
+ /// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
+ ///
+ /// Headings and rows in draw order, from .
+ ///
+ /// 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.
+ ///
+ ///
+ /// Rows arriving before any heading are the no-groups case — 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.
+ ///
+ private static List SectionsFrom(List flat, string? vaultHeading)
+ {
+ var sections = new List();
if (flat.Count == 0)
{
- return [];
+ return sections;
}
- if (Groups.Count == 0)
- {
- return [new HostSectionViewModel(null, [.. flat.Cast()])];
- }
-
- var sections = new List();
SidebarGroupHeader? header = null;
var members = new List();
- var firstHeading = true;
+ var loose = new List();
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.
///
/// What that heading says — "UNGROUPED" on the phone, "No group" here.
+ ///
+ /// 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
+ /// , 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.
+ ///
///
/// No groups means no headings, 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.
///
private List FlattenIntoSections(
- IReadOnlyList shown, bool ungroupedFirst, string ungroupedLabel)
+ IReadOnlyList shown,
+ bool ungroupedFirst,
+ string ungroupedLabel,
+ Guid? vaultId = null)
{
var rows = new List();
- 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(
/// Which of the shown hosts fall under it.
/// What the heading says when is null.
/// Whether an empty section is left out altogether.
+ ///
+ /// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
+ /// per-vault board, on the phone's single list. See
+ /// .
+ ///
private void AddFlatSection(
List rows,
IReadOnlyList shown,
HostGroupRowViewModel? group,
Func 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();
}
+ ///
+ /// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
+ ///
+ /// The card that was Ctrl-clicked.
+ ///
+ ///
+ /// ◆ The lit card was not in the set, and every list this one is modelled on says it should be.
+ /// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
+ /// under it, see — 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.
+ ///
+ ///
+ /// Only into an empty 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.
+ ///
+ ///
+ /// Separate from 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.
+ ///
+ ///
+ [RelayCommand]
+ private void ExtendHostChoice(HostRowViewModel? row)
+ {
+ if (row is null)
+ {
+ return;
+ }
+
+ FoldTheLitCardIn(besides: row);
+ ToggleHostChoice(row);
+ }
+
+ /// Puts the lit card into an empty set, which is what a Ctrl gesture builds on.
+ /// A card to leave out — the one the gesture is about. See
+ /// for why.
+ ///
+ /// The board's order rather than , 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. is left to the
+ /// caller, which runs it once for the whole gesture.
+ ///
+ 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);
+ }
+
///
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
///
@@ -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.
///
+ /// The hosts to store, and the key files any of them named.
+ ///
+ /// Which vault to write them into, or null for the active one.
+ ///
+ /// Cancellation.
///
///
- /// For the ssh_config 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 which 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
+ /// which hosts and where 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.
+ ///
+ ///
+ /// ◆ The vault is a parameter rather than the active one, and null still means the active one.
+ /// The ssh_config 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.
///
///
/// 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(
///
internal async Task ImportHostsAsync(
IReadOnlyList 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 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;
}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
index 4faf6bc..0215839 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
@@ -250,12 +250,19 @@ public sealed class HostGridTests : IAsyncLifetime
/// way down rather than acted on as it bubbles.
///
///
+ /// ◆ And the set the first Ctrl-click starts has the lit card in it. 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 VaultViewModel.ExtendHostChoice.
+ ///
+ ///
/// 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.
///
///
[Fact]
- public async Task CtrlClickingCardsTicksThemWithoutMovingTheSelection()
+ public async Task CtrlClickingCardsTicksThemAndTheLitCardWithoutMovingTheSelection()
{
await OnTheBoardAsync((screen, window) =>
{
@@ -267,16 +274,19 @@ public sealed class HostGridTests : IAsyncLifetime
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");
vault.SelectedHost.ShouldBeSameAs(first, "ticking a card is not selecting it");
vault.SelectedSidebarRow.ShouldBeSameAs(first, "and the section lists were never told otherwise");
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);
- vault.ChosenHostCount.ShouldBe(1, "the same click again takes the tick off");
+ vault.ChosenHostCount.ShouldBe(2);
Click(CardFor(screen, first), window);
@@ -387,6 +397,12 @@ public sealed class HostGridTests : IAsyncLifetime
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);
RightClick(tickedCard, window);
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Hetzner.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Hetzner.cs
index a23deb9..8afb62d 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Hetzner.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Hetzner.cs
@@ -405,6 +405,84 @@ public sealed partial class ShellFlowTests
// ---- Helpers ----
/// A server carrying enough to be imported, used wherever the shape is not what is being tested.
+ ///
+ /// The two answers this import used to decide on somebody's behalf: which vault, and which key.
+ ///
+ ///
+ ///
+ /// ◆ A Hetzner project is the case where "wherever I happen to be standing" is the wrong vault.
+ /// The ssh_config 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.
+ ///
+ ///
+ /// 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; 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.
+ ///
+ ///
+ /// 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
+ /// this account can read, so these hosts connect here and not for a colleague who holds only
+ /// the destination's key.
+ ///
+ ///
+ [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 = """
{
"id": 2,
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index e505a0b..a39f59d 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -6485,6 +6485,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Key: null,
KeyPath: null),
],
+ vaultId: null,
Token);
vault.Groups.ShouldBeEmpty("nothing in this keychain answers to that id");
@@ -7022,6 +7023,60 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").IsChosen.ShouldBeFalse();
}
+ ///
+ /// The desktop's Ctrl-click, and the card the board already had lit.
+ ///
+ ///
+ ///
+ /// ◆ The board always has a selection, so "one card lit, nothing ticked" is the state this screen
+ /// opens in — a fresh unlock lights the first host so that CONNECT has something under it, see
+ /// VaultViewModel.SelectionAfterReload. 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [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();
+ }
+
///
/// The set is held as entity ids rather than as rows, and this is why: every row object in the list is
/// replaced on every synchronisation pass, so a set of rows would empty itself once a minute under
@@ -7237,6 +7292,97 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
Host(vault, "staging").Host.GroupId.ShouldBeNull("it was never ticked");
}
+ ///
+ /// The board's outer level, which is the vault, and its inner one, which is the group.
+ ///
+ ///
+ ///
+ /// ◆ A group belongs to exactly one vault, and the board used to flatten that away. 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
+ /// HostSectionViewModel.VaultHeading.
+ ///
+ ///
+ /// 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 Guid.Empty as they were,
+ /// folding one vault's unfiled hosts away folded every other vault's with it. See
+ /// SidebarGroupHeader.CollapseKey.
+ ///
+ ///
+ [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");
+ }
+
///
/// The other refusal at the same door, and the one that needs two keychains to raise: a group is an
/// item of one vault, so filing a mixed set under it would leave everyone else in the shared vault
@@ -10112,6 +10258,33 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
shell.State.ShouldBe(ShellState.Unlocked, shell.StatusMessage);
}
+ /// Adds a host to a named vault, optionally filed under one of that vault's groups.
+ ///
+ /// 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
+ /// VaultViewModel.EditorGroupChoices.
+ ///
+ private static async Task AddHostInAsync(
+ VaultViewModel vault,
+ Guid vaultId,
+ string label,
+ Guid? groupId)
+ {
+ vault.NewHostCommand.Execute(null);
+
+ vault.EditorSelectedVault =
+ vault.EditorVaultChoices.Single(choice => choice.VaultId == vaultId);
+ vault.EditorSelectedGroup =
+ vault.EditorGroupChoices.Single(choice => choice.EntityId == groupId);
+
+ vault.EditorLabel = label;
+ vault.EditorHostname = "db.internal";
+ vault.EditorUsername = "deploy";
+
+ await vault.SaveHostCommand.ExecuteAsync(null);
+ }
+
private static async Task AddHostAsync(VaultViewModel vault, string label)
{
vault.NewHostCommand.Execute(null);