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
@@ -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