Take the group headings out of the host grid, and drop onto a group card

A first group turned the wrap of host cards into an accordion: SidebarRows
interleaves a full-width fold-away heading — chevron, name, count — between the
cards, and in a grid that reads as a dropdown somebody left open. The desktop
grid binds VisibleHosts now. The headings and the fold stay for the phone, whose
list has no room for the row of group cards the desktop draws above the grid.

What a heading said, the card says: HostRowViewModel.GroupLabel, resolved once
per reload like the tag names, drawn as an accent chip and absent from a host in
no group — or in one that has been deleted, which is the same thing to look at.

What a heading also was is the drop target, and that moves to the group cards.
Two things go with it. A host dropped onto another host card used to be filed
beside it, which was legible while a heading named the band of cards it landed
in and is guesswork now; it is refused. And UNGROUPED was how a host was dragged
back out of a group; the way out is the picker in its own editor, which is the
one place "no group" can be said in words.

A drag held at either edge of the grid scrolls it. Without that the gesture only
works for whoever can see both ends of it: the group cards are the first thing in
the scroller, the host may be the fortieth card down, and a drag cannot use the
wheel. A step per drag event rather than a timer, so it follows the pointer and
stops when it stops.

The two heading-shaped tests are replaced. TheHostsGridHoldsCardsAndNoGroupHeadings
asserts the grid's contents rather than only measuring them, because a heading
that came back would lay out perfectly cleanly. TheGroupCardsAreWhatAcceptsADroppedHost
raises a real DragOver over both kinds of card and checks the effects and the
mark — the nearest a headless test gets to a gesture no headless test can
synthesise. manual-checks 3.1-3.2 and 7.6-7.9 follow.
This commit is contained in:
2026-08-03 15:49:58 +02:00
parent 1b7df47537
commit f9d08b738c
8 changed files with 432 additions and 147 deletions
+25 -10
View File
@@ -276,9 +276,12 @@
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<Style Selector="Button.grouphead">
<Setter Property="Padding" Value="10,7,10,4" />
</Style>
<!--
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
cards. The headings went when the grid became cards and the group cards above it became the thing that
names a group — see HostsScreen.axaml — and a style with nothing wearing it is a shape somebody will
later reintroduce by reaching for it.
-->
<!-- A category in the vault's rail: the same accent strip a selected row carries, at full row height. -->
<Style Selector="Button.cat">
@@ -661,19 +664,31 @@
</Style>
<!--
The row a host is about to be dropped on. A wash rather than the accent strip a selection carries,
because it is not a selection: it says "let go here", it lasts as long as the pointer is over the row,
and it has to be legible on top of whatever that row already looks like — including the selected row,
which is often the one being dragged onto.
The group card a host is about to be dropped on. A wash rather than the accent strip a selection
carries, because it is not a selection: it says "let go here", it lasts as long as the pointer is over
the card, and it has to be legible on top of whatever that card already looks like — including the
selected one, which is the group the grid is currently narrowed to and so often the one being dragged
onto.
Painted on the tile rather than on the item's own presenter, which is where this used to go when the
target was a full-width heading. A wash behind a 10-pixel-rounded tile is a square of colour with the
card floating on it — the same four grey corners the tiles list clears the theme's own hover and
selection brushes to avoid. Declared after that rule and after the selected-tile rule, because
Avalonia has no specificity and settles two matching rules by declaration order.
◆ THE DOUBLED BORDER IS THE MARK, not the fill, and that is what the move to the tile forced. The fill
a selected tile already carries is the strongest one this palette has, so a target that only filled
would be invisible on the card the grid is narrowed to — which is the card a host most often has to be
dragged off, into another group. Two pixels of accent is legible on both.
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
-->
<Style Selector="ListBoxItem.droptarget /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
<Style Selector="ListBoxItem.droptarget Border.tile">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<!--
+68 -40
View File
@@ -151,8 +151,17 @@
</StackPanel>
<!-- ============ THE GRID ============ -->
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Disabled">
<!--
============ THE GRID ============
A drop target that takes nothing itself. Everything the pointer can be over during a drag is inside
this control, and it has to accept one so that the group cards at the top of it stay reachable from
the bottom of a long grid: a host dragged off the fortieth card cannot be dropped on a group card
that has scrolled out of sight, so a drag held at either edge scrolls this while it is in flight.
See HostsScreen.axaml.cs.
-->
<ScrollViewer Grid.Row="3" x:Name="Scroll" HorizontalScrollBarVisibility="Disabled"
DragDrop.AllowDrop="True">
<StackPanel Margin="16,14" Spacing="16">
<!-- ============ GROUPS ============ -->
@@ -198,10 +207,18 @@
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.
◆ 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.
-->
<ListBox Classes="tiles" x:Name="GroupGrid"
DragDrop.AllowDrop="True"
ItemsSource="{Binding Groups}"
SelectedItem="{Binding GroupFilter}">
SelectedItem="{Binding GroupFilter}"
ToolTip.Tip="Press a group to show only its hosts. Drag a host card onto one to file it there.">
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
@@ -257,15 +274,31 @@
focus off the terminal's child window and then calls Focus() on something that refuses it,
leaving the window with nothing focused and the keystrokes going nowhere.
It binds SidebarRows rather than VisibleHosts, so the group headings are still in it. Two
reasons, and neither is decoration: a grid showing every group at once has to say which cards
belong to which, and a heading is the drop target that takes a host out of one group and into
another. See VaultViewModel.RebuildSidebarRows for why a heading is a row rather than a
container, and the code-behind for what a drag does with one.
◆ CARDS ONLY, AND NO HEADINGS. It binds VisibleHosts rather than SidebarRows, which is the
collection with the group headings folded into it. Those headings were a full-width fold-away
bar between the cards — a chevron, a name and a count — and in a wrap of cards they read as a
collapsed dropdown that had somehow been dealt the hosts: the moment a first group existed the
grid stopped being a grid. Two controls saying "group" on one screen was one too many, and the
cards above are the better of the two. SidebarRows and the fold are still the phone's, whose
list has no room for a row of group cards; see Android's HostsScreen.
What a heading also was is the drop target, and that moved to the group cards rather than
going away with it — see the note on them above, and the code-behind for the gesture. A host is
taken back out of a group through its own editor, which is the one place "no group" can be said
plainly.
Which group a card is in is on the card, as a chip. That is what the heading above it used to
say, and one chip per card says it without an accordion.
SelectedItem is still SelectedSidebarRow and deliberately not SelectedHost, though every item
here is now a host. That property swallows the null the ListBox writes back whenever the
collection is rebuilt — which is every filter keystroke and every background sync — where
binding the host selection directly would let that null through, and a null host selection
closes the drawer and disarms a delete somebody is in the middle of confirming. See the
property, and RebuildVisibleHosts on where the real clearing is done.
-->
<ListBox Classes="tiles" x:Name="HostGrid" Focusable="True"
DragDrop.AllowDrop="True"
ItemsSource="{Binding SidebarRows}"
ItemsSource="{Binding VisibleHosts}"
SelectedItem="{Binding SelectedSidebarRow}">
<ListBox.ItemsPanel>
@@ -279,7 +312,8 @@
one per card: a ContextMenu inside a DataTemplate would have the row for its data context,
and the commands here are the vault's. The code-behind selects whatever was right-clicked
before the menu opens, so the selection-based commands act on the card under the pointer,
and cancels the menu outright over a group heading, which has no host to act on.
and cancels the menu outright over the space around the cards, where there is no host to act
on.
-->
<ListBox.ContextMenu>
<ContextMenu>
@@ -290,35 +324,9 @@
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.DataTemplates>
<ListBox.ItemTemplate>
<!--
A heading, and it takes a whole row of the wrap rather than sitting in the flow as another
card: it names the run of cards under it, and a heading the width of one card would read
as the first of them. The stretch is what MinWidth buys — a WrapPanel gives a child the
width it asks for and starts a new line when the line cannot hold it, so a child asking
for more than one line holds is alone on its own.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" MinWidth="640"
Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Text="{Binding Chevron}"
Foreground="{StaticResource TextFaint}" FontSize="9"
VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" HorizontalAlignment="Left" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<DataTemplate x:DataType="vm:HostRowViewModel">
<Border Classes="tile">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -389,6 +397,26 @@
VerticalAlignment="Top" Margin="6,3,0,0" />
</Grid>
<!--
The group this host is filed under, and only when it is filed under one — which is
the same rule the group cards above follow, and the reason a keychain nobody has
filed anything in looks exactly as it did before groups existed.
This is what the fold-away heading over a run of cards used to say. A chip on the
card says it per host instead, which is the shape the rest of this card already is
and the shape a wrap of cards can carry: it survives the cards being reflowed, it
needs no row of its own, and it is what changes when a card is dropped onto a group.
Accent rather than plain, so the one chip that is a *place* is not read as another
tag. A group whose name the vault cannot resolve leaves this empty rather than
printing an id, for the reason a tag does. See HostRowViewModel.GroupLabel.
-->
<Border Classes="chip accent" Padding="5,0" HorizontalAlignment="Left"
IsVisible="{Binding HasGroup}">
<TextBlock Text="{Binding GroupLabel}" FontSize="9.5"
TextTrimming="CharacterEllipsis" />
</Border>
<!--
The tags this host wears. Under the summary rather than beside it, because a host
can wear several and the line above is three fixed-width facts where these are not.
@@ -412,7 +440,7 @@
</Border>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox.ItemTemplate>
</ListBox>
<!--
+109 -35
View File
@@ -25,7 +25,7 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostsScreen : UserControl
{
/// <summary>
/// How a host travels from the card it was picked up on to the heading it is dropped on.
/// How a host travels from the card it was picked up on to the group card it is dropped on.
/// </summary>
/// <remarks>
/// An in-process format carrying the row itself, rather than text carrying an id. The drag never leaves
@@ -43,6 +43,21 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private const double DragThreshold = 5;
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
/// <remarks>
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
/// carrying something the user is looking at — a band the width of a hairline would only be found by
/// accident, and only by somebody who did not need it.
/// </remarks>
private const double EdgeBand = 48;
/// <summary>How far one drag event inside that band moves the grid.</summary>
/// <remarks>
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
/// </remarks>
private const double EdgeStep = 24;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
@@ -55,7 +70,7 @@ internal sealed partial class HostsScreen : UserControl
private Point origin;
/// <summary>The card or heading the pointer is currently over, while a drag is in flight.</summary>
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
public HostsScreen()
@@ -78,9 +93,22 @@ internal sealed partial class HostsScreen : UserControl
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
DragDrop.AddDragOverHandler(HostGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(HostGrid, OnDragLeave);
DragDrop.AddDropHandler(HostGrid, OnDrop);
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
// same control: the target was a heading among the cards, and with the headings gone the group cards
// are the only thing on this screen that names a group. A card dropped onto another card is refused
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
// would be, and a gesture whose result you cannot see before you let go is one that files machines
// somewhere the user did not intend.
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
DragDrop.AddDropHandler(GroupGrid, OnDrop);
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
// platform is concerned. This is where all of them but a group card are turned down — and where a
// drag held at the top or bottom edge pulls the grid towards the target.
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
}
/// <remarks>
@@ -117,9 +145,9 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// Only over a card. A double-tap on a group heading folds it and unfolds it again, and must not also
// connect to whichever host was selected before — which is what an unguarded handler would do, on a
// machine the user is not even pointing at.
// Only over a card. A double-tap on the space around them must not connect to whichever host was
// selected before — which is what an unguarded handler would do, on a machine the user is not even
// pointing at.
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
@@ -137,9 +165,9 @@ internal sealed partial class HostsScreen : UserControl
/// against.
/// </para>
/// <para>
/// Cancelled outright over a group heading and over the space around the cards. Neither is a host, and a
/// menu offering Connect, Edit and Delete over one would be three buttons that either do nothing or act
/// on something else entirely.
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
/// Edit and Delete over it would be three buttons that either do nothing or act on something else
/// entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
@@ -233,9 +261,9 @@ internal sealed partial class HostsScreen : UserControl
/// Says whether what is under the pointer would take this host, and marks it if it would.
/// </summary>
/// <remarks>
/// A host over its own group's heading is refused, which is not pedantry: <c>DragDropEffects.None</c> is
/// what turns the cursor into the "no" one, and a drag that looks like it would do something and then
/// does nothing is worse than one that says so while it is still in the air.
/// A host over the card of the group it is already in is refused, which is not pedantry:
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
/// would do something and then does nothing is worse than one that says so while it is still in the air.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
@@ -254,6 +282,60 @@ internal sealed partial class HostsScreen : UserControl
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <summary>
/// Carries the grid under a drag that is over the cards rather than over a group.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
/// list.
/// </para>
/// <para>
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
/// </para>
/// <para>
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
/// everything that is not a group.
/// </para>
/// </remarks>
private void OnDragOverScroll(object? sender, DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is null)
{
return;
}
e.Handled = true;
e.DragEffects = DragDropEffects.None;
Unmark();
var at = e.GetPosition(Scroll).Y;
var height = Scroll.Bounds.Height;
var step = at switch
{
_ when at < EdgeBand => -EdgeStep,
_ when at > height - EdgeBand => EdgeStep,
_ => 0,
};
if (step == 0)
{
return;
}
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
}
/// <remarks>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
@@ -279,13 +361,12 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// <para>
/// A heading is the obvious target and a card is the useful one: dropping onto a machine files the
/// dragged host beside it, which means the whole band of cards under a heading is a target rather than
/// one line of text. The ungrouped heading is a target like any other, and it is how a host is taken out
/// of a group without opening the editor.
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
/// dropped on is what it says on it. The space around the cards takes nothing.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the grid already does with
/// A group the vault no longer has is read as no group at all, which is what the list already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
@@ -293,25 +374,13 @@ internal sealed partial class HostsScreen : UserControl
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { } dragged
|| Container(e.Source) is not { } container)
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|| dragged.Host.GroupId == group.EntityId)
{
return null;
}
Guid? group = container.DataContext switch
{
SidebarGroupHeader header => header.GroupId,
HostRowViewModel row => row.Host.GroupId,
_ => null,
};
if (container.DataContext is not (SidebarGroupHeader or HostRowViewModel)
|| dragged.Host.GroupId == group)
{
return null;
}
return new DropTarget(dragged, group, container);
return new DropTarget(dragged, group.EntityId, container);
}
private void Mark(ListBoxItem container)
@@ -353,5 +422,10 @@ internal sealed partial class HostsScreen : UserControl
: null;
/// <summary>A drag in flight, and where it would land.</summary>
private sealed record DropTarget(HostRowViewModel Host, Guid? GroupId, ListBoxItem Container);
/// <remarks>
/// The group is a <see cref="Guid"/> rather than a nullable one, which it was while the ungrouped
/// heading was also a target. Every target is now a group card and every group card has an id; the way
/// out of a group is the host's own editor, which is the one place "no group" can be said in words.
/// </remarks>
private sealed record DropTarget(HostRowViewModel Host, Guid GroupId, ListBoxItem Container);
}
@@ -222,6 +222,31 @@ internal sealed partial class HostRowViewModel(
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// The name of the group this host is filed under, or empty for a host that is in none.
/// </summary>
/// <remarks>
/// <para>
/// What the desktop's grid draws as a chip on the card. It is the one thing the fold-away group headings
/// between the cards used to say, and the reason removing them cost nothing: a card that names its own
/// group answers the question per host, where a heading answered it per run of cards and needed the grid
/// to be sorted into runs to do it. The phone still draws headings — its list has no room for the row of
/// group cards the desktop puts above the grid — so <see cref="SidebarGroupHeader"/> stays.
/// </para>
/// <para>
/// Resolved once when the list is built, like <see cref="TagLabels"/> and for the same two reasons: the
/// row stores an id and a chip shows a name, and the answer cannot change without the list being rebuilt.
/// <b>A group id that does not resolve leaves this empty</b> rather than printing the id — the reference
/// is allowed to dangle, deleting a group deliberately does not rewrite the hosts in it, and "the group
/// this names is not here" and "this names no group" are the same thing to look at. See
/// <see cref="HostSecret.GroupId"/>.
/// </para>
/// </remarks>
internal string GroupLabel { get; init; } = string.Empty;
/// <summary>Whether this host is filed under a group the vault can name.</summary>
internal bool HasGroup => GroupLabel.Length > 0;
internal HostSecret Host => host.Secret;
/// <summary>
@@ -1051,8 +1076,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. Drag a host onto its heading in the grid, or choose the "
+ "group in a host's own editor.",
"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.",
_ => "No host matches that. The name, the address and the notes are all searched.",
};
@@ -2358,6 +2383,12 @@ internal sealed partial class VaultViewModel(
// Only when there is something to tell apart. A badge on every row of a
// single-vault list is noise that says the same thing on all of them.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
// Every readable vault's groups, not the active one's, because a host in a team's
// vault is filed under that team's group — and looked up here rather than on the row
// for the reason the tag names are: the map is the list's, and a row that reached for
// it would be a lookup per chip per redraw.
GroupLabel = GroupLabelFor(item.Secret.GroupId),
}));
}
@@ -2674,6 +2705,17 @@ internal sealed partial class VaultViewModel(
.OrderBy(label => label, StringComparer.CurrentCulture),
];
/// <summary>
/// The name behind a host's group id, or empty where there is none to show.
/// </summary>
/// <remarks>
/// Empty covers both "this host is in no group" and "the group it names is not in this vault any more",
/// which is the same answer the list gives a dangling reference everywhere else. See
/// <see cref="HostRowViewModel.GroupLabel"/>.
/// </remarks>
private string GroupLabelFor(Guid? groupId) =>
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
private void RebuildGroups()
{
@@ -2857,10 +2899,10 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// <para>
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
/// host that is about arrangement rather than about the machine.
/// What dragging a host card onto a group card does, and the only thing in this application that changes
/// a host without opening the editor. That is the justification for it existing at all: filing thirty
/// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one
/// field of a host that is about arrangement rather than about the machine.
/// </para>
/// <para>
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A