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);