Public Access
Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
This commit is contained in:
@@ -67,6 +67,17 @@ internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, in
|
||||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||||
}
|
||||
|
||||
/// <summary>One step of the path into the groups, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the step is called.</param>
|
||||
/// <param name="Group">The group it opens, or null for the step that shows every host again.</param>
|
||||
/// <remarks>
|
||||
/// The same shape the transfers screen's trail uses — see <c>CrumbViewModel</c> — and drawn the same way,
|
||||
/// because it answers the same question: a directory pane and a grid of groups both have to say where the
|
||||
/// thing on screen came from and offer a way back out. The row rather than its id, because
|
||||
/// <see cref="VaultViewModel.GroupFilter"/> holds a row, and an id would only be looked up again.
|
||||
/// </remarks>
|
||||
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
|
||||
|
||||
/// <summary>An entry in the host editor's group picker.</summary>
|
||||
/// <param name="EntityId">The group, or null for "no group".</param>
|
||||
/// <param name="Label">What to show.</param>
|
||||
@@ -1076,8 +1087,8 @@ internal sealed partial class VaultViewModel(
|
||||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's "
|
||||
+ "~/.ssh/config from Preferences.",
|
||||
(_, not null, 0) =>
|
||||
"Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's "
|
||||
+ "card — or choose the group in a host's own editor.",
|
||||
"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.",
|
||||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||||
};
|
||||
|
||||
@@ -1100,8 +1111,51 @@ internal sealed partial class VaultViewModel(
|
||||
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
|
||||
|
||||
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
|
||||
/// <remarks>
|
||||
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
|
||||
/// from; <see cref="VisibleGroups"/> is the desktop's one level of it.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
|
||||
/// groups when none is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="Groups"/> is to this what <see cref="Hosts"/> is to <see cref="VisibleHosts"/>: the whole
|
||||
/// collection beside the part of it on screen. The grid used to draw every group at once, which was the
|
||||
/// only honest thing to do while pressing a card meant nothing but "narrow the list" — a card was a
|
||||
/// filter, and every filter has to be reachable. Opening one is navigation, so the cards became the
|
||||
/// contents of wherever the trail says you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A group whose parent this vault has not got is drawn at the outermost level rather than nowhere, and
|
||||
/// so is one caught in a parent cycle. Both are states two offline edits can produce and neither can be
|
||||
/// repaired from a screen that will not draw the group — see <see cref="HostGroupSecret.ParentId"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostGroupRowViewModel> VisibleGroups { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The path down to the group that is open: every host, then each group above it, then it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Always at least one crumb, and the first one is the way back to every host — which is what it is for.
|
||||
/// A grid whose cards are one level of a tree needs somewhere to say which level, and the same control
|
||||
/// is the way out of it; without that the only way back would be a button that says so, which is what
|
||||
/// SHOW ALL was and what this replaces.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<GroupCrumbViewModel> GroupTrail { get; } = [];
|
||||
|
||||
/// <summary>Whether there are any group cards to draw at this level.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="HasGroups"/>, which is about the vault. A group with nothing inside it is an
|
||||
/// ordinary thing to open, and the trail and the group's own buttons have to stay on screen when it is —
|
||||
/// so it is the card grid alone that folds away, not the panel around it.
|
||||
/// </remarks>
|
||||
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
|
||||
|
||||
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
|
||||
/// <remarks>
|
||||
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
|
||||
@@ -1205,41 +1259,59 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private ISidebarRow? selectedSidebarRow;
|
||||
|
||||
/// <summary>
|
||||
/// The group card that is selected, or null when none is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
|
||||
/// it deliberately no longer does is narrow the grid — see <see cref="OpenGroup"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? selectedGroup;
|
||||
|
||||
/// <summary>
|
||||
/// The group the hosts grid is narrowed to, or null for every host.
|
||||
/// The group that is open: the one whose contents the screen is showing, or null for every host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||||
/// Set by <see cref="OpenGroup"/> and by nothing else. It decides three things at once — which hosts the
|
||||
/// grid holds, which groups the cards hold, and what the trail says — which is what makes it "where you
|
||||
/// are" rather than a filter that happens to be on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||||
/// <b>Separate from <see cref="SelectedGroup"/>, and no longer sets it.</b> The two answer different
|
||||
/// questions — "what is on screen" and "what would EDIT and DELETE act on" — and while one click meant
|
||||
/// both there was no way to name a group without also narrowing the grid to it. Two gestures, two
|
||||
/// properties; <see cref="GroupTarget"/> is where the two meet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private HostGroupRowViewModel? groupFilter;
|
||||
|
||||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||||
|
||||
/// <summary>Shows every host again.</summary>
|
||||
/// <summary>
|
||||
/// Opens a group, or every host when handed null.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A double-click on a card, or a press on a crumb of the trail. Deliberately not a single click, which
|
||||
/// is what it was: a card is the only place a group can be selected, and a gesture that both selected a
|
||||
/// group and threw the rest of the grid away left no way to rename one without first losing sight of
|
||||
/// everything else. Double-clicking to go inside something is what the transfers screen's directories do
|
||||
/// and what the host cards beneath these do to open a shell, so the grid now has one vocabulary rather
|
||||
/// than one per list.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The selection is dropped first, and it has to be: the cards are about to be redrawn one level along,
|
||||
/// and a selection pointing at a card that is no longer on screen would aim EDIT and DELETE at something
|
||||
/// nobody can see. Null is a real argument here rather than a missing one — it is the trail's first
|
||||
/// crumb, and it is the way back out.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ClearGroupFilter()
|
||||
private void OpenGroup(HostGroupRowViewModel? group)
|
||||
{
|
||||
GroupFilter = null;
|
||||
SelectedGroup = null;
|
||||
GroupFilter = group;
|
||||
}
|
||||
|
||||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||||
@@ -2089,8 +2161,26 @@ internal sealed partial class VaultViewModel(
|
||||
/// <inheritdoc cref="IsConfirmingHostDeletion" />
|
||||
internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
|
||||
|
||||
/// <summary>
|
||||
/// What the group panel's EDIT and DELETE act on: the card that is selected, or the group that is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two answers, because a card is no longer where the user is. Selecting one aims the buttons at it,
|
||||
/// which is what a click has always done; with nothing selected they aim at the group whose contents are
|
||||
/// on screen — the one the trail ends with. Without that fallback a group with no groups inside it could
|
||||
/// be opened and then never edited, because opening a group is exactly what takes its own card off the
|
||||
/// screen. It is also what a file manager does: act on the selection, and on the current folder when
|
||||
/// there is none.
|
||||
/// </remarks>
|
||||
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
|
||||
|
||||
/// <summary>Whether the group panel's buttons are showing.</summary>
|
||||
internal bool ShowsGroupActions => !IsConfirmingGroupDeletion;
|
||||
/// <remarks>
|
||||
/// Hidden with nothing to act on as well as while the question is up. A pair of buttons over a grid of
|
||||
/// cards nobody has chosen between is a pair whose subject the user has to work out, and the answer
|
||||
/// would be "neither" — see <see cref="GroupTarget"/>.
|
||||
/// </remarks>
|
||||
internal bool ShowsGroupActions => GroupTarget is not null && !IsConfirmingGroupDeletion;
|
||||
|
||||
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
|
||||
internal bool HasGroups => Groups.Count > 0;
|
||||
@@ -2769,27 +2859,148 @@ internal sealed partial class VaultViewModel(
|
||||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||||
}
|
||||
|
||||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||||
// RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||||
// chose.
|
||||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||||
// longer in the list — and the card the user could press to clear it would be a different object
|
||||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||||
// grid comes back to every host rather than to none.
|
||||
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
|
||||
// group holding the old one would go on showing a group that is no longer in the list — and the
|
||||
// crumb the user could press to leave it would be a different object that never matched. A group
|
||||
// deleted by a sync closes itself, which is the honest answer: the screen comes back to every host
|
||||
// rather than to none.
|
||||
//
|
||||
// This assignment is a new row object whenever there is a filter at all, so it always fires
|
||||
// This assignment is a new row object whenever a group is open at all, so it always fires
|
||||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||||
// the field would skip SelectedGroup and IsFilteredByGroup with it.
|
||||
// the field would skip the cards, the trail and GroupTarget with it.
|
||||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||||
|
||||
// Unconditionally, because the assignment above is a no-op — and fires nothing — whenever no group
|
||||
// was open, and the cards still have to be rebuilt out of the row objects this pass just made.
|
||||
RebuildGroupLevel();
|
||||
|
||||
// Out of the cards on screen rather than out of every group, and after the level has been rebuilt:
|
||||
// this is the card ListBox's own selection, and a row it is not showing is one the control would
|
||||
// null straight back out again.
|
||||
//
|
||||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||||
// EDIT and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||||
// chose.
|
||||
SelectedGroup = VisibleGroups.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasGroups));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the group cards and the trail from whichever group is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both together because they are two halves of one answer: the cards are what is inside the open group
|
||||
/// and the trail is how it was reached, and a pass that rebuilt one without the other would draw a level
|
||||
/// under a path that does not lead to it.
|
||||
/// </remarks>
|
||||
private void RebuildGroupLevel()
|
||||
{
|
||||
var parents = EffectiveParents();
|
||||
var open = GroupFilter?.EntityId;
|
||||
|
||||
VisibleGroups.Clear();
|
||||
|
||||
foreach (var row in Groups.Where(row => parents.GetValueOrDefault(row.EntityId) == open))
|
||||
{
|
||||
VisibleGroups.Add(row);
|
||||
}
|
||||
|
||||
GroupTrail.Clear();
|
||||
|
||||
// Always first, always there, and it is the way out — see GroupTrail. The name is what the grid
|
||||
// below shows when nothing is open, rather than the vault's, because that is the choice being
|
||||
// offered: this crumb widens the screen back to every machine in it.
|
||||
GroupTrail.Add(new GroupCrumbViewModel("ALL HOSTS", null));
|
||||
|
||||
foreach (var row in Ancestry(open, parents))
|
||||
{
|
||||
GroupTrail.Add(new GroupCrumbViewModel(row.Label, row));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasVisibleGroups));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
|
||||
/// outermost level.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things are promoted, and both are states this application has decided to survive rather than
|
||||
/// prevent. A parent id this vault has not got is a group deleted on another machine — the reference is
|
||||
/// allowed to dangle, because preventing it would mean one delete rewriting every item naming the
|
||||
/// deleted thing. A cycle is two clients each re-parenting A under B and B under A while offline, which
|
||||
/// no merge can see because the pointer is inside the payload. See
|
||||
/// <see cref="HostGroupSecret.ParentId"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Both have to end up somewhere the user can reach.</b> The repair for either is the group's own
|
||||
/// editor, and the editor is opened from the card — so a group left inside a card nobody can open, or
|
||||
/// inside a cycle no walk terminates in, would be a broken state with the fix locked inside it. Promoting
|
||||
/// to a root is the same degradation the resolver's visited set produces for inheritance: a cycle reads
|
||||
/// as a flat run of top-level groups.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private Dictionary<Guid, Guid?> EffectiveParents()
|
||||
{
|
||||
var stated = Groups.ToDictionary(row => row.EntityId, row => row.Group.ParentId);
|
||||
var parents = new Dictionary<Guid, Guid?>(stated.Count);
|
||||
|
||||
foreach (var (id, parent) in stated)
|
||||
{
|
||||
parents[id] = parent is { } wanted && stated.ContainsKey(wanted) ? wanted : null;
|
||||
}
|
||||
|
||||
foreach (var id in stated.Keys)
|
||||
{
|
||||
if (!ReachesTheTop(id))
|
||||
{
|
||||
parents[id] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return parents;
|
||||
|
||||
bool ReachesTheTop(Guid id)
|
||||
{
|
||||
var visited = new HashSet<Guid>();
|
||||
Guid? current = id;
|
||||
|
||||
while (current is { } step && visited.Add(step))
|
||||
{
|
||||
current = parents[step];
|
||||
}
|
||||
|
||||
return current is null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The groups from the outermost down to the one that is open, or nothing when none is.</summary>
|
||||
/// <remarks>
|
||||
/// Walked against <see cref="EffectiveParents"/> rather than against the stated parents, so the trail
|
||||
/// cannot lead through a group the cards will not draw — and so that it terminates, which is what the
|
||||
/// promotion above buys: a cycle has no parent left to follow.
|
||||
/// </remarks>
|
||||
private List<HostGroupRowViewModel> Ancestry(Guid? open, Dictionary<Guid, Guid?> parents)
|
||||
{
|
||||
var trail = new List<HostGroupRowViewModel>();
|
||||
var current = open;
|
||||
|
||||
while (current is { } id
|
||||
&& Groups.FirstOrDefault(row => row.EntityId == id) is { } row)
|
||||
{
|
||||
trail.Add(row);
|
||||
current = parents.GetValueOrDefault(id);
|
||||
}
|
||||
|
||||
trail.Reverse();
|
||||
|
||||
return trail;
|
||||
}
|
||||
|
||||
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
|
||||
/// <remarks>
|
||||
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
|
||||
@@ -3690,11 +3901,12 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Loads the selected group's name into the box, so saving renames it.</summary>
|
||||
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
|
||||
/// <remarks>The selected card, or the open group when no card is selected. See <see cref="GroupTarget"/>.</remarks>
|
||||
[RelayCommand]
|
||||
private void EditGroup()
|
||||
{
|
||||
if (SelectedGroup is not { } row)
|
||||
if (GroupTarget is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -3923,17 +4135,21 @@ internal sealed partial class VaultViewModel(
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Asks whether the selected group should go.</summary>
|
||||
/// <summary>Asks whether the group being acted on should go.</summary>
|
||||
/// <remarks>
|
||||
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
|
||||
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
|
||||
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
|
||||
/// to move, and that none of them are going anywhere else.
|
||||
/// <para>
|
||||
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
|
||||
/// <see cref="GroupTarget"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void DeleteGroup()
|
||||
{
|
||||
if (SelectedGroup is not { } row)
|
||||
if (GroupTarget is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -6553,20 +6769,33 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||||
|
||||
OnPropertyChanged(nameof(GroupTarget));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||||
/// property. Assigning the same value again is a no-op, so this and
|
||||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||||
/// <para>
|
||||
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
|
||||
/// from <see cref="OpenGroup"/>, and that command has already dropped the selection before assigning
|
||||
/// here — see the property.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
|
||||
/// against <see cref="GroupTarget"/> rather than against the group itself: a reload hands this a new row
|
||||
/// object for the group already open, and taking a question away from under somebody because the row
|
||||
/// behind it was replaced is exactly what <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||||
{
|
||||
SelectedGroup = value;
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||||
|
||||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||||
OnPropertyChanged(nameof(GroupTarget));
|
||||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||||
|
||||
RebuildGroupLevel();
|
||||
RebuildVisibleHosts();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user