From 2a56ae3efb14c4b4702a76a7201f0e61e4f878c0 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 4 Aug 2026 09:51:55 +0200 Subject: [PATCH] Put a host inside the group it is filed under, rather than beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The group cards were headings with a navigation gesture bolted on. Opening one narrowed the grid to its hosts, but the level above already held every host in the keychain — so a card could only ever subtract, filing something changed nothing but a chip, and a keychain with forty machines was forty cards however carefully anybody had arranged them. The trail said ALL HOSTS and meant it. So 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; the outermost level is what nothing has been filed into. A group is a place now, and the cards, the trail and the drop target were already the vocabulary for saying so. The find box is the exception and had to be one. Typed into, it searches the open group and everything under it, which from the outermost level is every machine in the keychain. A box scoped to the level it was typed on would answer "no host matches that" about a host this keychain has got, and finding a machine without first remembering where it was filed is most of what the box is for. The accent chip on a card is what tells a searched-up host from one that lives at this level. Two things came free with the change and are handled rather than left. The phone would have broken. Its list binds SidebarRows, which was a projection over VisibleHosts, and it has no group cards and nowhere to open one into — so level-scoping would have left it drawing only the hosts nobody had filed. RebuildSidebarRows takes its own pass over the hosts now, narrowed by the vault switches and the box and by nothing else, which is the whole tree flattened under headings: exactly what it drew before. And anything created inside a group disappeared the moment it was saved. The host editor opens on the group the screen is about rather than only on a selected card, and + NEW GROUP defaults its parent to the group that is open. Deliberately not the selected card there: a highlighted card is what EDIT and DELETE are aimed at, and reading it as "and the next group goes inside it" would nest one because somebody had clicked something. The host editor takes both because it always took the selection, and its picker shows the answer before anything is written. Dropping a host on a group card now takes the card off the grid, and the selection goes with it — Connect, Edit and Delete all read that property and none of them should be aimed at a card that has left the screen. The status line is what says where it went, which is why the manual check now asks for it to be read. Coming back out through the editor lands the host on this level again, and there the selection survives. An empty grid has two more things it can say: that every host is filed away, which the level-at-a-time grid made reachable and which is not the same sentence as "there are none", and that nothing under this group matches what was typed — with ALL HOSTS named as the way to widen it. Not changed, and next door: a group card counts the hosts filed directly under it, so a group holding only subgroups reads "0 hosts". That was already true and is more visible now that its subgroups' hosts are not spilled onto the level above. 1522 tests pass. Four are new — the level rule and the phone's flat list asserted together, the box reaching two levels down, the all-filed sentence, and where a host and a group made inside a group end up. MovingAHostToAGroup was asserting the old outcome and is rewritten rather than adjusted: it held that the host stayed selected, and what it holds now is that the host leaves the level it came from. --- docs/manual-checks.md | 59 +++++-- .../Views/HostsScreen.axaml | 17 +- .../ViewModels/VaultViewModel.cs | 166 +++++++++++++++--- .../HostGridTests.cs | 6 +- .../ScreenLayoutTests.cs | 10 +- .../ShellFlowTests.cs | 163 ++++++++++++++++- 6 files changed, 368 insertions(+), 53 deletions(-) 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"); } ///