Merge branch 'claude/group-double-click-breadcrumb-7bf3f8'
ci / build and test (push) Successful in 1m21s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 32s

This commit is contained in:
2026-08-03 16:22:45 +02:00
6 changed files with 549 additions and 80 deletions
@@ -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();
}