Put a host inside the group it is filed under, rather than beside it

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.
This commit is contained in:
2026-08-04 09:51:55 +02:00
parent e82a25c912
commit 2a56ae3efb
6 changed files with 368 additions and 53 deletions
+16 -1
View File
@@ -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
@@ -1145,11 +1145,17 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
/// <remarks>
/// A second collection rather than a filtered view over the first, because the sidebar's list has to be
/// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
/// <para>
/// A second collection rather than a filtered view over the first, because the grid has to be one
/// <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
/// terminal gives it back, and neither of those survives being split across several lists.
/// </para>
/// <para>
/// What "one level" means, and why the box escapes it, is in <see cref="Matches"/>. It is the desktop's
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
/// </para>
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
@@ -1165,16 +1171,21 @@ internal sealed partial class VaultViewModel(
/// What the hosts grid says when it has nothing in it.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <see cref="Matches"/>.
/// </para>
/// </remarks>
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<HostRowViewModel, bool> 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);
}
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
/// <remarks>
/// <para>
/// <b>The grid holds one level of the tree, the way a directory pane holds one directory.</b> 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.
/// </para>
/// <para>
/// <b>The find box is the exception, and deliberately.</b> 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.
/// </para>
/// </remarks>
private bool Matches(HostRowViewModel row, Dictionary<Guid, Guid?> 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);
}
/// <summary>Whether one host survives the vault switches and the find box — where it sits aside.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The group a host is actually drawn under, or none.</summary>
/// <remarks>
/// 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
/// <see cref="RebuildSidebarRows"/>. 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.
/// </remarks>
private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary<Guid, Guid?> parents) =>
row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null;
/// <summary>Whether a group is the open one or lies somewhere beneath it.</summary>
/// <remarks>
/// 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
/// <see cref="EffectiveParents"/>, where anything caught in a cycle has already been promoted to a root.
/// </remarks>
private static bool IsUnder(Guid? open, Guid? group, Dictionary<Guid, Guid?> 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;
}
/// <returns>How many keys would not decrypt.</returns>
/// <remarks>
/// Unlike the host list, the selection is <em>not</em> 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);
}
/// <summary>Starts a new group.</summary>
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="ClearGroupEditor"/> prevents.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>The group that is open, and deliberately not the card that is selected</b> — which is where this
/// differs from <see cref="NewHost"/>. 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.
/// </para>
/// </remarks>
[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.";
}