Merge branch 'claude/group-creation-hosts-dragdrop-56c6a3'
ci / build and test (push) Canceled after 1m6s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s

This commit is contained in:
2026-08-03 15:50:12 +02:00
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.",
};
@@ -2396,6 +2421,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),
}));
}
@@ -2712,6 +2743,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()
{
@@ -2895,10 +2937,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