Public Access
Merge branch 'claude/main-page-group-hierarchy-3a3210'
This commit is contained in:
@@ -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.";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user