Merge branch 'claude/main-page-group-hierarchy-3a3210'

This commit is contained in:
2026-08-04 10:10:19 +02:00
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.";
}