diff --git a/docs/manual-checks.md b/docs/manual-checks.md index b82513d..caa51eb 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -306,24 +306,46 @@ card, nothing saying the hosts are unfiled. **Failure means:** the "invisible until used" property is gone, and every existing user gets a section they did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip. -### 3.2 Filing hosts, and reading which group a card is in +### 3.2 Filing hosts, and the grid being one level at a time -Make two groups and file some hosts into each through the host editor. +Make two groups and file some hosts into each through the host editor, leaving at least one host unfiled. -**Pass:** every filed card carries the group's name as an accent chip under its address; the cards stay one -flat wrap, in one order, with nothing between them. **There is no heading and no fold on the desktop** — the -headings, their chevrons and UNGROUPED are the phone's, whose list has no room for a row of group cards. +**Pass:** the filed hosts **leave the grid** as they are filed — a group is a place, not a label, and the +grid holds one level of it the way a directory pane holds one directory. What is left is the unfiled hosts. +**There is no heading and no fold on the desktop** — the headings, their chevrons and UNGROUPED are the +phone's, whose list has no room for a row of group cards and draws the whole tree flat instead. With every +host filed, the grid says so in a sentence rather than sitting empty. -**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid still holds -every host, and EDIT and DELETE now aim at that group. **Then double-press it.** The group opens: the hosts -narrow to the ones filed under it, the trail above the cards reads `ALL HOSTS › ›`, and the card grid -shows what is *inside* that group rather than every group in the keychain. Pressing ALL HOSTS brings the rest -back. +**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the +level it was, and EDIT and DELETE now aim at that group. **Then double-press it.** The group opens: its hosts +are the grid, the trail above the cards reads `ALL HOSTS › ›`, each card carrying the group's name as +an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain. +Pressing ALL HOSTS goes back to the outermost level. + +**Then type a filed host's name into the find box at the top.** It is found from the outermost level, +wherever it was filed and however deep, with the chip on its card saying which group it came out of. Clearing +the box puts it away again. Inside a group the same box reaches that group and everything under it, and the +empty-grid sentence there offers ALL HOSTS as the way to widen it. **Failure means:** if one press still narrows the grid, the card `ListBox` is bound to the wrong property — -`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. A full-width bar with a -chevron between the cards is the old grouping coming back through `SidebarRows`; the desktop grid binds -`VisibleHosts`. See `HostsScreen.axaml`. +`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. Filed hosts still on the +outermost level is `Matches` no longer comparing the host's group to the open one. A search that cannot find +a filed host is worse than either: it is the box answering "no host matches that" about a machine the +keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through +`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`. + +### 3.2b Making something while standing inside a group + +Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**. + +**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on +the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that +group as its parent, so the new group is a card inside the one that is open. + +**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut +a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate +difference between them: the host editor also takes a merely *selected* card as its group, the group editor +takes only the group that is open. ### 3.2a A group inside a group, and the way back out @@ -708,12 +730,15 @@ back. Make two groups and file a host into one. Drag a host card up onto the other group's card. **Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the -cursor shows a move rather than a refusal, and the drop files the host — the chip on its card changes to the -new group's name, the host counts under both group cards change, and the status line says where it went. +cursor shows a move rather than a refusal, and the drop files the host — **the card leaves the grid**, going +inside the group it was dropped on, the host counts under both group cards change, and the status line says +where it went. That sentence is the only thing left saying so, which is why it is worth reading: the card +itself is on the level below now, and nothing is selected once it has gone. **Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the -group the host is *already* in; over another **host** card, which is deliberately not a target now that there -are no headings to say which group it would mean; and over the empty space around the cards. +group the host is *already* in — type its name into the find box first, which is what brings a filed card +back to this level; over another **host** card, which is deliberately not a target now that there are no +headings to say which group it would mean; and over the empty space around the cards. **The targets are the cards on screen, which are one level** — see 3.2a. Filing into a group nested under another means opening the outer one first, exactly as moving a file into a subfolder does. diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml b/src/DodoSSH.Client.App/Views/HostsScreen.axaml index c1254df..77700f9 100644 --- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml @@ -52,6 +52,12 @@ connect path, the selection and the pinned host key list all read the unfiltered collection — so a filter left in the box can hide a host but can never break one. + ◆ IT IS ALSO THE WAY OUT OF THE GROUPS. The grid below holds one level of the tree, and typing here + searches that level and everything under it — every machine in the keychain while nothing is open. + That is the one thing on this screen that crosses a group boundary, and it has to be: a search that + looked only where you happen to be standing would say "no host matches that" about a host this + keychain has got. See VaultViewModel.Matches. + Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody who has found this box should know about the one that also connects on Enter. @@ -341,8 +347,17 @@ taken back out of a group through its own editor, which is the one place "no group" can be said plainly. + ◆ ONE LEVEL, AS A DIRECTORY PANE IS. A host filed under a group is inside that group and is + not also here — the cards above are the way into it, and this grid holds what is loose at + whatever level the trail names. It used to hold every host at once with the groups drawn as + headings over them, which made opening a group an operation that could only ever subtract. + See VaultViewModel.Matches, and the note on the find box above for the one thing that crosses + a group boundary. + Which group a card is in is on the card, as a chip. That is what the heading above it used to - say, and one chip per card says it without an accordion. + say, and one chip per card says it without an accordion — and it is what tells a search result + from a host that lives at this level, since searching is the one way a card from inside a + group reaches this grid. SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item here is now a host. That property swallows the null the ListBox writes back whenever the diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index 392c8ad..b447e76 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -1145,11 +1145,17 @@ internal sealed partial class VaultViewModel( /// internal ObservableCollection Hosts { get; } = []; - /// The hosts the sidebar is showing: the filter applied, nothing else. + /// The hosts the grid is showing: one level of the group tree, narrowed by the box. /// - /// A second collection rather than a filtered view over the first, because the sidebar's list has to be - /// one ListBox — it owns and it is where the keyboard lands when the + /// + /// A second collection rather than a filtered view over the first, because the grid has to be one + /// ListBox — it owns and it is where the keyboard lands when the /// terminal gives it back, and neither of those survives being split across several lists. + /// + /// + /// What "one level" means, and why the box escapes it, is in . It is the desktop's + /// alone: the phone draws , which is the same hosts flattened under headings. + /// /// internal ObservableCollection VisibleHosts { get; } = []; @@ -1165,16 +1171,21 @@ internal sealed partial class VaultViewModel( /// What the hosts grid says when it has nothing in it. /// /// - /// Four answers rather than one, because "there are no hosts", "you have set a vault aside", "this group - /// is empty" and "nothing matches what you typed" are four different situations and only the first is an - /// invitation to add something. Telling somebody with thirty machines to add their first one is - /// answering a question they did not ask. + /// One answer per reason the grid can be empty, because "there are no hosts", "you have set a vault + /// aside", "this group is empty", "they are all filed away" and "nothing matches what you typed" are + /// different situations and only the first is an invitation to add something. Telling somebody with + /// thirty machines to add their first one is answering a question they did not ask. /// /// The hidden-vault answer comes before the group and the search box, because it is the one an empty /// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group /// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen /// to read. /// + /// + /// The fourth is what the grid holding one level of the tree cost: a keychain whose every host is filed + /// under a group shows no host cards at the outermost level, and without a sentence saying so that is + /// indistinguishable from a keychain that has lost them. See . + /// /// internal string NoVisibleHostsMessage => (Hosts.Count, HasHiddenVaults, GroupFilter, HostFilter.Trim().Length) switch @@ -1188,6 +1199,12 @@ internal sealed partial class VaultViewModel( (_, _, not null, 0) => "Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto " + "this group's card — or choose the group in a host's own editor.", + (_, _, null, 0) => + "Every host here is filed under a group. Double-press one of the cards above to open it, or " + + "type in the box at the top to search all of them at once.", + (_, _, not null, _) => + "No host in this group, or in anything under it, matches that. Press ALL HOSTS above to " + + "search every machine.", _ => "No host matches that. The name, the address and the notes are all searched.", }; @@ -3281,9 +3298,13 @@ internal sealed partial class VaultViewModel( { var selected = SelectedHost; + // Built once and handed down rather than rebuilt inside the predicate: deciding where a host sits is + // a walk up the group tree, and this is the map that walk is made against. + var parents = EffectiveParents(); + VisibleHosts.Clear(); - foreach (var host in Hosts.Where(Matches)) + foreach (var host in Hosts.Where(host => Matches(host, parents))) { VisibleHosts.Add(host); } @@ -3331,9 +3352,16 @@ internal sealed partial class VaultViewModel( { SidebarRows.Clear(); + // Its own pass over the hosts rather than a read of VisibleHosts, which has been one level of the + // tree since the desktop's grid became a folder pane — see Matches. This list is the flat answer to + // the same question: every group it has as a heading, every host filed under one of them, and no way + // to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so + // a list narrowed to the outermost level would be a list showing only the hosts nobody had filed. + var shown = Hosts.Where(MatchesFilters).ToArray(); + if (Groups.Count == 0) { - foreach (var host in VisibleHosts) + foreach (var host in shown) { SidebarRows.Add(host); } @@ -3360,7 +3388,7 @@ internal sealed partial class VaultViewModel( Func belongs, bool onlyWhenOccupied = false) { - var members = VisibleHosts.Where(belongs).ToArray(); + var members = shown.Where(belongs).ToArray(); if (onlyWhenOccupied && members.Length == 0) { @@ -3496,28 +3524,51 @@ internal sealed partial class VaultViewModel( await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true); } + /// Whether one host belongs on the grid at the level it is currently showing. + /// + /// + /// The grid holds one level of the tree, the way a directory pane holds one directory. A host + /// filed under a group is inside that group and nowhere else — it is not also on the screen the group's + /// own card sits on. While it was both, a card was a heading over a grid that already held everything + /// underneath it, so opening one could only ever take hosts away; a card is now the only place its hosts + /// are, which is what makes it a folder rather than a filter that happens to be switched off. + /// + /// + /// The find box is the exception, and deliberately. Typed into, it searches the open group and + /// everything under it — which, with nothing open, is every host in the keychain. A search that looked + /// only in the level it was typed on would answer "no host matches that" about a machine this keychain + /// has got, which is the one answer a search box must never give; and finding a machine without first + /// remembering where it was filed is most of what the box is for. + /// + /// + private bool Matches(HostRowViewModel row, Dictionary parents) + { + if (!MatchesFilters(row)) + { + return false; + } + + var open = GroupFilter?.EntityId; + var group = EffectiveGroupOf(row, parents); + + return HostFilter.Trim().Length == 0 ? group == open : IsUnder(open, group, parents); + } + + /// Whether one host survives the vault switches and the find box — where it sits aside. /// /// An empty filter matches everything rather than nothing, which is the only reading that makes an empty /// box mean "not filtering". The notes are searched as well as the name and the address: what somebody /// wrote down about a machine is often the only place its purpose is recorded. /// - private bool Matches(HostRowViewModel row) + private bool MatchesFilters(HostRowViewModel row) { - // First, and ahead of both the cards and the box, because it is not a search: a hidden vault's host - // is out however the grid is narrowed, and a count taken after this reflects what is on screen. + // First, and ahead of the box, because it is not a search: a hidden vault's host is out however the + // grid is narrowed, and a count taken after this reflects what is on screen. if (!IsVaultShown(row.VaultId)) { return false; } - // The group cards, and they narrow before the box does — a host outside the chosen group is out - // whatever was typed. The two are deliberately not one control: the box is what you type when you - // know the name, and the cards are what you press when you do not. - if (GroupFilter is { } group && row.Host.GroupId != group.EntityId) - { - return false; - } - var filter = HostFilter.Trim(); if (filter.Length == 0) @@ -3531,6 +3582,45 @@ internal sealed partial class VaultViewModel( value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase); } + /// The group a host is actually drawn under, or none. + /// + /// An id this vault has not got reads as no group at all, which is what the chip on the card, the + /// ungrouped heading and the group picker each already do with one — see + /// . It matters more here than in any of them: a host naming a group + /// deleted on another machine would otherwise sit at a level nothing on screen can open, and now that + /// the grid is one level at a time there would be nothing left that ever drew it. + /// + private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary parents) => + row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null; + + /// Whether a group is the open one or lies somewhere beneath it. + /// + /// Nothing open means everything is under it, which is what makes the search box reach the whole keychain + /// from the outermost level. The walk terminates because it is made against + /// , where anything caught in a cycle has already been promoted to a root. + /// + private static bool IsUnder(Guid? open, Guid? group, Dictionary parents) + { + if (open is null) + { + return true; + } + + var current = group; + + while (current is { } id) + { + if (id == open) + { + return true; + } + + current = parents.GetValueOrDefault(id); + } + + return false; + } + /// How many keys would not decrypt. /// /// Unlike the host list, the selection is not defaulted to the first row: it is what @@ -4042,10 +4132,13 @@ internal sealed partial class VaultViewModel( EditorNewTag = string.Empty; BuildTagChoices(); - // A new host opens in whichever group is selected beside the list, if one is, because adding three - // machines to the group somebody has just made is the ordinary case. Before the picker, because - // whether there is a group to inherit from decides whether the picker offers to. - BuildGroupChoices(SelectedGroup?.EntityId); + // A new host opens in the group the screen is already about — the card that is selected, or failing + // that the group whose contents are showing. Adding three machines to the group somebody has just + // made is the ordinary case, and since the grid holds one level at a time the alternative is worse + // than a default nobody chose: a host created inside a group and filed under none would vanish from + // the screen it was created on. Before the picker, because whether there is a group to inherit from + // decides whether the picker offers to. + BuildGroupChoices(GroupTarget?.EntityId); BuildAuthenticationChoices( boundKeyId: null, @@ -4308,13 +4401,28 @@ internal sealed partial class VaultViewModel( EditGroupCommand.Execute(null); } - /// Starts a new group. + /// Starts a new group, inside whichever one the screen is showing. /// + /// /// The desktop never needed this command: its group editor is a bar that is always on screen, so /// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its /// editor is a card that has to be raised — and raising it from a stale state would offer the last /// group's default key to the new one without anybody choosing it, which is what /// prevents. + /// + /// + /// The parent is defaulted after that clearing rather than inside it, and only here. This is the one + /// path that means "make one", and a group made inside the group that is open is what + NEW GROUP has to + /// mean now that the cards are one level of a tree — filed at the outermost level it would disappear + /// from the screen it was made on. The other two callers are a cancel and a save, and neither is asking + /// for a group anywhere. + /// + /// + /// The group that is open, and deliberately not the card that is selected — which is where this + /// differs from . A selected card is what EDIT and DELETE are aimed at; reading it + /// as "and the next group goes inside it" would nest one because somebody had highlighted something, + /// while the open group is the screen everybody can see they are on. + /// /// [RelayCommand] private void NewGroup() @@ -4328,6 +4436,12 @@ internal sealed partial class VaultViewModel( ClearGroupEditor(); + // Falls back to no parent, which is both what the picker's first entry says and what the phone always + // gets: it has no group cards and no way to go inside one, so nothing there is ever open. + GroupEditorSelectedParent = + GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == GroupFilter?.EntityId) + ?? GroupChoice.None; + IsEditingGroup = true; Status = "Adding a group."; } diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs index 5e636d2..c193c7a 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs @@ -233,7 +233,8 @@ public sealed class HostGridTests : IAsyncLifetime vault.SelectedGroup = production; vault.GroupFilter.ShouldBeNull("one press selects a group and does not open it"); - vault.VisibleHosts.Count.ShouldBe(2, "so the grid still holds the hosts outside it"); + vault.VisibleHosts.Select(row => row.Label) + .ShouldBe(["stage-web"], "so the grid is still the outermost level, and prod-db is inside a group"); vault.GroupTarget.ShouldBeSameAs(production, "what EDIT and DELETE act on"); vault.ShowsGroupActions.ShouldBeTrue(); @@ -252,7 +253,8 @@ public sealed class HostGridTests : IAsyncLifetime // same job and went with the change. vault.OpenGroupCommand.Execute(vault.GroupTrail[0].Group); - vault.VisibleHosts.Count.ShouldBe(2, "ALL HOSTS brings back the hosts outside the group"); + vault.VisibleHosts.Select(row => row.Label) + .ShouldBe(["stage-web"], "ALL HOSTS is the outermost level, not every host in the keychain"); vault.GroupTarget.ShouldBeNull("and nothing is aimed at once no group is open or selected"); vault.ShowsGroupActions.ShouldBeFalse("a pair of buttons with no subject is hidden rather than shown"); } diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs index 878e9e3..820971e 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs @@ -344,7 +344,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime /// that spans the width is as inside it as a card is. /// /// - /// Measured with every host filed, which is the shape that used to produce the most headings. + /// Measured with every host filed and that group open, which is the shape that used to produce the most + /// headings. Open, because the grid holds one level of the tree — a host inside a group is drawn inside + /// that group and nowhere else — so measuring at the outermost level would be measuring an empty grid. + /// See VaultViewModel.Matches. /// /// [Fact] @@ -358,6 +361,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime new HostGroupMove(host, vault.Groups[0].EntityId)); } + vault.OpenGroupCommand.Execute(vault.Groups[0]); + await OnTheHostsScreenAsync((screen, _) => { var rows = screen.HostGrid @@ -370,7 +375,8 @@ public sealed class ScreenLayoutTests : IAsyncLifetime rows.ShouldAllBe(row => row is HostRowViewModel); }); - await MeasureHostsAsync(faults => faults.ShouldBeEmpty("with three groups and every host filed")); + await MeasureHostsAsync( + faults => faults.ShouldBeEmpty("with every host filed and the group holding them open")); } /// diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index afb55ec..35093b0 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -3584,12 +3584,22 @@ public sealed class ShellFlowTests : IAsyncLifetime } /// + /// /// What dragging a host card onto a group card does. It is the same write the editor makes — one field /// of the host, pushed straight away — reached without opening a form, because filing thirty imported /// machines through the editor is thirty rounds of open, pick, save. + /// + /// + /// The card goes into the group and off the level it was dragged from, which is the whole of what + /// a drop looks like on a grid that holds one level of the tree — the host is inside the card it was + /// dropped on now, and that is where it is drawn. It used to stay put and gain a chip. The selection + /// goes with it rather than being restored onto something nobody can see: Connect, Edit and Delete all + /// read that property, and none of them should be aimed at a card that has left the screen. See + /// VaultViewModel.Matches and RebuildVisibleHosts. + /// /// [Fact] - public async Task MovingAHostToAGroup_FilesItAndLeavesItSelected() + public async Task MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom() { await UnlockedAsync(); var vault = shell.Vault!; @@ -3603,23 +3613,166 @@ public sealed class ShellFlowTests : IAsyncLifetime await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group)); vault.Hosts.Single().Host.GroupId.ShouldBe(group); - vault.SelectedHost.ShouldNotBeNull().EntityId.ShouldBe(host.EntityId, "the reload replaces every row"); + vault.VisibleHosts.ShouldBeEmpty("the grid is the outermost level and the host is inside a group"); + vault.SelectedHost.ShouldBeNull("nothing on screen is it any more"); - // Under the group's own heading now, which is what the phone's list draws. + // Under the group's own heading now, which is what the phone's list draws — that list is the whole + // tree flattened, so the host is still in it. vault.SidebarRows.OfType() .Single(header => header.GroupId == group) .Count.ShouldBe(1); - // And the name on the card, which is what the desktop's grid draws instead of that heading — the one - // thing on screen that changes where the host was dropped rather than where it came from. + // The name is on the card, which is what the desktop's grid draws instead of that heading, and what + // says which group a searched-up card came out of. vault.Hosts.Single().GroupLabel.ShouldBe("production"); vault.Hosts.Single().HasGroup.ShouldBeTrue(); + // Opening the group is where it went, and the way to it. + vault.OpenGroupCommand.Execute(vault.Groups.Single()); + + vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); + // And back out again, which is what the host's own editor is for now that the drop has one target. + vault.OpenGroupCommand.Execute(null); + await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(vault.Hosts.Single(), null)); vault.Hosts.Single().Host.GroupId.ShouldBeNull(); vault.Hosts.Single().HasGroup.ShouldBeFalse("and the chip goes with it"); + + // Coming out of a group is the direction that lands the host back on this level, so here the + // selection does survive the move. + vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); + vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db"); + } + + /// + /// The grid holds one level of the tree, the way a directory pane holds one directory. + /// + /// + /// A host filed under a group is inside that group and is not also on the screen the group's card sits + /// on. While it was both, opening a group could only ever take hosts away — the level above already had + /// all of them — and the cards were headings rather than places. The phone's list is the deliberate + /// exception and is asserted here beside it: it draws the whole tree flat under headings, because it has + /// no cards to open and nowhere to open one into. + /// + [Fact] + public async Task AHostFiledUnderAGroup_IsDrawnInsideItAndNotAtTheLevelAbove() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await AddHostAsync(vault, "prod-db"); + await AddHostAsync(vault, "stage-web"); + await AddGroupAsync(vault, "production"); + await FileAsync(vault, "prod-db", "production"); + + vault.VisibleHosts.Select(row => row.Label) + .ShouldBe(["stage-web"], "the outermost level is what nothing has been filed into"); + + vault.SidebarRows.OfType().Select(row => row.Label) + .ShouldBe(["prod-db", "stage-web"], "the phone's list is the whole tree flattened"); + + vault.OpenGroupCommand.Execute(vault.Groups.Single()); + + vault.VisibleHosts.Select(row => row.Label).ShouldBe(["prod-db"]); + + vault.OpenGroupCommand.Execute(null); + + vault.VisibleHosts.Select(row => row.Label).ShouldBe(["stage-web"]); + } + + /// + /// The one thing on the screen that crosses a group boundary, and it has to be one. A search that looked + /// only at the level it was typed on would answer "no host matches that" about a machine this keychain + /// has got — and finding a machine without first remembering where it was filed is most of what the box + /// is for. Typed at the outermost level it reaches everything; typed inside a group it reaches that + /// group and what is under it, which is the same rule read from where you are standing. + /// + [Fact] + public async Task TheFindBox_SearchesInsideTheGroupsRatherThanOnlyTheLevelOnScreen() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await AddHostAsync(vault, "prod-db"); + await AddHostAsync(vault, "stage-web"); + await AddGroupAsync(vault, "estate"); + await AddGroupAsync(vault, "production"); + await SetGroupParentAsync(vault, "production", "estate"); + await FileAsync(vault, "prod-db", "production"); + + vault.VisibleHosts.ShouldNotContain(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal)); + + vault.HostFilter = "prod-db"; + + vault.VisibleHosts.ShouldHaveSingleItem().Label + .ShouldBe("prod-db", "two levels down, and the box reaches it"); + + // And inside a group it is that group's subtree: estate holds production, which holds the host. + vault.OpenGroupCommand.Execute( + vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal))); + + vault.VisibleHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-db"); + + vault.HostFilter = "stage"; + + vault.VisibleHosts.ShouldBeEmpty("stage-web is outside estate, so this search does not reach it"); + vault.NoVisibleHostsMessage.ShouldContain("ALL HOSTS", Case.Sensitive, "and it says how to widen it"); + } + + /// + /// A grid with no cards in it has to say why, and "they are all filed away" is a different sentence from + /// "there are none" and from "nothing matches what you typed". It is the answer the level-at-a-time grid + /// made reachable: before it, a keychain with hosts in it always drew some. + /// + [Fact] + public async Task AKeychainWhoseHostsAreAllFiled_SaysSoRatherThanLookingEmpty() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await AddHostAsync(vault, "prod-db"); + await AddGroupAsync(vault, "production"); + await FileAsync(vault, "prod-db", "production"); + + vault.HasVisibleHosts.ShouldBeFalse(); + vault.NoVisibleHostsMessage.ShouldContain("filed under a group"); + vault.NoVisibleHostsMessage.ShouldNotContain( + "No hosts yet", Case.Sensitive, "telling somebody with hosts to add their first one answers nothing"); + } + + /// + /// Where a new thing lands, now that the screen is somewhere rather than everywhere. A host created + /// inside a group and filed under none would vanish from the screen it was created on, which is the + /// papercut that comes free with a grid holding one level — so the editor opens on the group the user + /// is standing in, and the picker shows it before anything is saved. + /// + [Fact] + public async Task ANewHostOrGroupStartedInsideAGroup_IsMadeInsideIt() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await AddGroupAsync(vault, "production"); + + vault.OpenGroupCommand.Execute(vault.Groups.Single()); + + vault.NewHostCommand.Execute(null); + + vault.EditorSelectedGroup.ShouldNotBeNull().Label.ShouldBe("production"); + + vault.EditorLabel = "prod-db"; + vault.EditorHostname = "db.internal"; + await vault.SaveHostCommand.ExecuteAsync(null); + + vault.VisibleHosts.ShouldHaveSingleItem().Label + .ShouldBe("prod-db", "so it is on the screen it was made on"); + + vault.NewGroupCommand.Execute(null); + + vault.GroupEditorSelectedParent.ShouldNotBeNull().Label + .ShouldBe("production", "+ NEW GROUP inside a group makes one inside it"); } ///