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
@@ -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.";
}