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
+57 -15
View File
@@ -177,15 +177,13 @@
<!--
The group's own two actions, beside the group cards rather than in the toolbar, because they
act on the selected card and this is where the selection is made. Hidden rather than
disabled while DELETE's question is up, as every other pair in this application is, so it
cannot be pressed twice — see VaultViewModel.ShowsGroupActions.
act on the card that is selected and this is where the selection is made. Hidden rather
than disabled while DELETE's question is up, as every other pair in this application is, so
it cannot be pressed twice, and hidden again when there is nothing for them to act on. What
that is, with no card selected, is the group the trail below ends with; see
VaultViewModel.GroupTarget.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SHOW ALL"
Command="{Binding ClearGroupFilterCommand}"
IsVisible="{Binding IsFilteredByGroup}"
ToolTip.Tip="Stops narrowing the grid to one group." />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
@@ -193,6 +191,43 @@
</StackPanel>
</Grid>
<!--
============ WHERE YOU ARE ============
The trail, drawn exactly as the transfers screen draws a directory path, because it is the same
control answering the same question — and a window with two breadcrumbs that look different has
two ideas of what a path is. See TransfersScreen.axaml.
It exists because the cards below are one level of a tree rather than every group at once, and
a level nobody can name is a grid that has quietly hidden things. The first crumb is always
there and always goes back to every host, which is what the SHOW ALL button used to be: a
button that only says "stop" beside a trail that says where you are is the second control for
the same job, and this one also gets you halfway back rather than all the way.
-->
<ItemsControl ItemsSource="{Binding GroupTrail}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:GroupCrumbViewModel">
<StackPanel Orientation="Horizontal">
<!--
The command is the vault's and the item is a crumb, so it is reached through the
ItemsControl's data context — the same way every other templated command on the
desktop is bound.
-->
<Button Classes="flat" Padding="3,1"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding Group}">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11"
Foreground="{StaticResource TextDim}" />
</Button>
<TextBlock Classes="mono" Text="" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
@@ -204,21 +239,28 @@
every other list in this application draws a selected row. Buttons would have needed a
Classes.active binding per card and a second copy of "which one".
SelectedItem is GroupFilter and not SelectedGroup. The two are nearly the same thing here —
GroupFilter assigns SelectedGroup — but only one of them may narrow the grid; see the property
for why the phone's own use of SelectedGroup must not.
◆ ONE CLICK SELECTS, TWO OPEN. SelectedItem is the selection and nothing else now — the
gesture that opens a group is a double-click, wired in the code-behind beside the one that
opens a shell on a host. One click used to mean both, and a card was then the only place a
group could be named while also being the control that threw the rest of the grid away.
◆ THIS IS ONE LEVEL, NOT EVERY GROUP. It binds VisibleGroups: what is inside the group the
trail above ends with, or the outermost groups when it ends at ALL HOSTS. Folded away entirely
at a group with nothing inside it, which is an ordinary thing to open — the trail and the two
buttons stay, because leaving it is a gesture and editing it is a button.
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
that is the whole of what a drag does on this screen. It used to be a heading inside the host
grid, and the headings are gone — see the note on the host grid below. A group is a thing that
is drawn once, at the top, where the eye already goes to narrow the grid; a target that is also
the label of what you are aiming at needs no explaining. See the code-behind for the gesture.
is drawn once, at the top, where the eye already goes; a target that is also the label of what
you are aiming at needs no explaining. See the code-behind for the gesture.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
DragDrop.AllowDrop="True"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}"
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
IsVisible="{Binding HasVisibleGroups}"
ItemsSource="{Binding VisibleGroups}"
SelectedItem="{Binding SelectedGroup}"
ToolTip.Tip="Press a group to select it, double-press to open it. Drag a host card onto one to file it there.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
@@ -83,6 +83,13 @@ internal sealed partial class HostsScreen : UserControl
// password box above it, and a host that asks for a password still needs it typed first.
HostGrid.DoubleTapped += OnHostActivated;
// And a group card opens the group, on the same gesture, for the same reason: going inside
// something by double-clicking it is what the transfers screen's directories do and what every file
// manager does. One click used to open a group, which made the card that names a group and the
// control that narrows the grid to it the same press — so there was no way to select a group in
// order to rename it without also losing sight of every host outside it.
GroupGrid.DoubleTapped += OnGroupActivated;
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
// Bubbling would work for the drag but not for the menu: by then the control has already decided
// what is selected, and the menu is about to open against it.
@@ -154,6 +161,22 @@ internal sealed partial class HostsScreen : UserControl
}
}
/// <summary>
/// Opens the group card that was double-clicked.
/// </summary>
/// <remarks>
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
/// </remarks>
private void OnGroupActivated(object? sender, TappedEventArgs e)
{
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
{
vault.OpenGroupCommand.Execute(group);
}
}
/// <summary>
/// Points the menu at whatever was right-clicked.
/// </summary>
@@ -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();
}