Share the shelf as well as what is on it, and ask a group which vault

A group is where hosts are filed and what lends them a port, a username and a key,
and until now it could only ever be made in the vault this machine files new items
into. So sharing a vault shared the machines and not the arrangement: a colleague
opened four hosts filed under a group they could read the name of and nothing else,
and the group a teammate made had no card, no heading and no way to be corrected
from the screen looking straight at the hosts inside it. Recorded as half shipped in
docs/design-import-gaps.md, and this is the other half.

The list stopped being the active vault's. It was narrow for two stated reasons —
a row shown across vaults has to carry which vault it lives in, because rename and
delete both need it, and two vaults may hold a "production" each, which a layout
with one heading per group cannot tell apart — and both are now paid for rather than
avoided. Every row carries its vault, the badge beside the name says which, and the
two cards sit side by side saying what they are.

The three shapes of the group read are now deliberately different sizes. The list is
what a person looks at, so a hidden vault's groups leave it: a card that cannot be
opened onto anything is worse than no card. The per-vault lists are what a picker
offers, because a picker is always asking about one vault. The map is what a host's
GroupId resolves through, and it stays widest of all — including over hidden vaults,
since a group lends a port and hiding a vault must never change what one of its
hosts dials. RebuildGroups is the one place hiding is applied, which is what keeps
those answers apart.

The editor asks which vault on the terms the host editor's picker set: while adding
only, hidden where there is one writable vault, and never offered afterwards, because
the two are encrypted under different keys and moving an item is a delete and a
retype. Its parent picker is that vault's alone, for the reason the host editor's
group picker is one level down — a parent in another vault is a level half the key
holders cannot resolve, and their hosts would inherit from nothing. + NEW GROUP
inside an open group departs from NewHost and takes that group's vault rather than
the standing preference: a group made inside another is in its parent's vault by
construction, and answering "inside PLATFORM" with a group elsewhere and no parent
would drop the one thing the button said.

Two smaller things follow from the cards spanning vaults. Dragging a host onto a
group card in another vault is refused with both names, because the write it would
make is exactly the id-nobody-can-resolve the host editor's picker was fixed to
prevent, and treating it as "no group" would unfile a host somebody was plainly
filing. And a group being renamed says its vault in the drawer's header, since the
picker is not drawn for an existing one and renaming a colleague's shelf without
being told whose it is is the edit most worth naming.

The save target is a nullable field behind a property that falls back to the standing
preference. The group name box is bound whether or not anything raised an editor over
it — that is what the desktop's group bar was, and typing a name into it and pressing
ADD is still a way to make a group, which would otherwise have written to no vault
at all.

1575 tests pass, five more than before: a group filed into a shared vault is listed
and renamed there, the editor's picker does not move the keychain screen's, the
parent picker offers only its own vault, a cross-vault drop is refused, and hiding a
vault takes the cards without changing what its hosts dial.
This commit is contained in:
2026-08-04 16:27:16 +02:00
parent 176df67861
commit c39df3f51e
8 changed files with 762 additions and 108 deletions
@@ -39,29 +39,70 @@ internal interface ISidebarRow;
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
: ISidebarRow
{
/// <summary>
/// The vault this heading's group lives in, or empty where there is only one vault to be in.
/// </summary>
/// <remarks>
/// The one thing a heading could not say while the headings were one vault's. Two vaults may each hold
/// a group called "production" — they are separate folders under separate keys — and a list with one
/// heading per group has nothing else to tell them apart with. Decided by the list rather than the row,
/// for the reason <see cref="HostRowViewModel.VaultBadge"/> is.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this heading has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
}
/// <summary>One group as it came out of a vault, with the vault it came out of.</summary>
/// <remarks>
/// The pair the group reload hands the group rebuild, and the vault half of it is what makes a group a
/// shared thing rather than a private one: a rename and a delete both have to go back to the vault the group
/// is in, and the row that offers them is drawn from a list that now spans every readable vault.
/// </remarks>
/// <param name="Item">The group, decrypted.</param>
/// <param name="VaultId">The vault it lives in.</param>
/// <param name="VaultName">That vault's display name.</param>
internal sealed record VaultGroupItem(VaultItem<HostGroupSecret> Item, Guid VaultId, string VaultName);
/// <summary>One group, as a row in the group list.</summary>
/// <remarks>
/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
/// count is computed from the host list rather than stored on the group — see <see cref="HostGroupSecret"/>
/// for why membership lives on the host — so it is passed in rather than read off the item.
/// </remarks>
internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, int hostCount)
internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
{
internal Guid EntityId => group.EntityId;
internal Guid EntityId => group.Item.EntityId;
internal HostGroupSecret Group => group.Secret;
/// <summary>Which vault this group lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => group.VaultId;
internal string Label => group.Secret.Label;
/// <summary>The vault's display name.</summary>
internal string VaultName => group.VaultName;
/// <summary>
/// The vault name to print on this card, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
internal HostGroupSecret Group => group.Item.Secret;
internal string Label => group.Item.Secret.Label;
internal int HostCount => hostCount;
internal bool IsReadOnly => group.IsReadOnly;
internal bool IsReadOnly => group.Item.IsReadOnly;
internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);
internal string Badge =>
ItemBadge.For(group.Item.IsBlocked, group.Item.IsReadOnly, group.Item.HasUnsyncedChanges);
/// <summary>What the row says under the name.</summary>
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
@@ -1047,13 +1088,15 @@ internal sealed partial class VaultViewModel(
private readonly SemaphoreSlim syncGate = new(1, 1);
/// <summary>
/// The groups as they came out of the vault, before the host counts are attached.
/// The groups as they came out of the vaults, before the host counts are attached.
/// </summary>
/// <remarks>
/// Held between the group reload and the host reload, which are two passes because a group row says how
/// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>.
/// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>. Every readable
/// vault's, each entry carrying which one it came from — a group is shared by being in a shared vault,
/// so the vault has to travel with it as far as the row that renames and deletes it.
/// </remarks>
private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];
private IReadOnlyList<VaultGroupItem> groupItems = [];
/// <summary>
/// The same groups by id, which is the shape the inheritance walk takes.
@@ -1467,11 +1510,43 @@ internal sealed partial class VaultViewModel(
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
/// </summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
/// <remarks>
/// One vault's, and the one this group is going into — see <see cref="BuildGroupParentChoices"/>. A
/// parent in another vault would be a group half the people holding this one's key cannot resolve, and
/// the tree they see would be missing a level nobody can point at.
/// </remarks>
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
[ObservableProperty]
private GroupChoice? groupEditorSelectedParent;
/// <summary>
/// Which vault a group being created will be filed into.
/// </summary>
/// <remarks>
/// <para>
/// The same picker the host editor has, on the form beside it, and for the same reason: this is the
/// decision that makes the thing shared, it cannot be changed afterwards, and the only other control
/// that could have answered it is a standing preference on a different screen. A group is where hosts
/// are filed and what lends them a port, a username and a key — so putting one in a shared vault is how
/// a team gets an arrangement rather than twenty machines in a heap, which is most of what sharing is
/// for.
/// </para>
/// <para>
/// Filled from <see cref="TargetVaults"/>, so it offers what every other "file this into" control does:
/// vaults this session can both read and write.
/// </para>
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> GroupEditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? groupEditorSelectedVault;
/// <summary>Whether the editor should be asking which vault this group goes into.</summary>
/// <inheritdoc cref="ShowsEditorVaultChoice" path="/remarks" />
internal bool ShowsGroupEditorVaultChoice =>
EditingGroupId is null && GroupEditorVaultChoices.Count > 1;
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
/// <remarks>
/// The host picker's list without its first entry. A group cannot default to "ask for a password each
@@ -1486,8 +1561,33 @@ internal sealed partial class VaultViewModel(
/// <summary>The group being renamed, or null when the box would create one.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsGroupEditorVaultChoice))]
private Guid? editingGroupId;
/// <summary>
/// Which vault the group editor will write to, or null until an editor has been opened.
/// </summary>
/// <remarks>
/// Nullable where <see cref="editingHostVaultId"/> is not, because the group name box is bound whether
/// or not anything raised an editor over it — that is what the desktop's group bar was, and typing a
/// name into it and pressing ADD is still a way to make a group. <see cref="GroupEditorVaultId"/> is
/// what answers for that case, and it answers with the standing preference: a group made without
/// choosing a vault is a new item like any other.
/// </remarks>
private Guid? editingGroupVaultId;
/// <summary>The vault the group editor writes to, whether or not one was ever chosen for it.</summary>
private Guid GroupEditorVaultId => editingGroupVaultId ?? TargetVaultId;
/// <summary>
/// The name of the vault a group being renamed is in, for the drawer's header, or empty.
/// </summary>
/// <remarks>
/// Kept rather than looked up per redraw, and empty wherever there is only one vault to be in — the
/// same rule the badges follow, because a header naming the only vault there is says nothing.
/// </remarks>
private string editingGroupVaultName = string.Empty;
[ObservableProperty]
private SshKeyRowViewModel? selectedKey;
@@ -1839,14 +1939,23 @@ internal sealed partial class VaultViewModel(
/// The line under it: which keychain this is filed in, or what a group is for.
/// </summary>
/// <remarks>
/// <para>
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
/// under different keys — so a control offering the move would be offering something no layer below
/// this can do. Where a *new* item goes is chosen on the keychain screen's own picker; see
/// <see cref="TargetVaults"/>.
/// this can do. Where a *new* item goes is chosen in the editor's own picker.
/// </para>
/// <para>
/// A group being renamed says its vault here for the same reason a host being edited does, and it is
/// the only line that says it: the picker is hidden for an existing group, and renaming a colleague's
/// shelf without being told whose it is is exactly the edit worth naming. A group being *made* says
/// what a group is for instead, because the picker under it is already answering "which vault".
/// </para>
/// </remarks>
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
{
(_, true) when EditingGroupId is not null && editingGroupVaultName.Length > 0 =>
editingGroupVaultName,
(_, true) => "A heading, and what its hosts inherit",
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
_ => SelectedHost?.VaultName ?? string.Empty,
@@ -3007,42 +3116,35 @@ internal sealed partial class VaultViewModel(
/// <see cref="RebuildGroups"/>, which is where the two meet.
/// </para>
/// <para>
/// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
/// resolution map is every readable vault's.
/// <b>Three shapes of the same read, and every one of them spans every readable vault.</b> The editable
/// list is what the cards and the headings are drawn from and what the editor renames; the per-vault
/// lists are what a picker offers, because a picker is always asking about one vault; the map is what a
/// host's <c>GroupId</c> resolves through.
/// </para>
/// <para>
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
/// <b>The list stopped being the active vault's, which is what makes a group shareable.</b> It was
/// narrow because a row shown across vaults has to carry the vault it lives in — a rename and a delete
/// both need it — and because two vaults may hold groups with the same name, which a list with one
/// heading per group cannot tell apart. Both are now paid for rather than avoided: the row carries the
/// vault, and the badge beside the name says which. Until it did, a group a colleague made in a shared
/// vault had no card, no heading and no way to be corrected from the machine looking straight at the
/// hosts filed under it.
/// </para>
/// <para>
/// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
/// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
/// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
/// place.
/// </para>
/// <para>
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
/// <b>The map was widened first, and it had to be, which is why it is separate.</b> While a group was
/// only a name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
/// nothing else. Since a group began lending a port, a username and a binding, the same omission
/// silently drops all three: that host would dial 22 as nobody, while the machine it names is on 2222 as
/// <c>deploy</c>. So the map answers "what does this id say" for every vault, including the ones
/// <see cref="IsVaultShown"/> is keeping off the screen — hiding a vault must never change what a host
/// dials, and the list is where hiding is applied. See <see cref="RebuildGroups"/>.
/// </para>
/// </remarks>
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var resolvable = new Dictionary<Guid, HostGroupSecret>();
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
// ever writes on a match would leave the last readable vault's groups on screen as though they were
// still this one's.
groupItems = [];
var items = new List<VaultGroupItem>();
var perVault = new Dictionary<Guid, List<GroupChoice>>();
foreach (var vault in session.ReadableVaults)
@@ -3056,6 +3158,8 @@ internal sealed partial class VaultViewModel(
foreach (var group in listing.Items)
{
resolvable[group.EntityId] = group.Secret;
items.Add(new VaultGroupItem(group, vault.VaultId, vault.Name));
}
perVault[vault.VaultId] =
@@ -3064,14 +3168,19 @@ internal sealed partial class VaultViewModel(
.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
.Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
];
if (vault.VaultId == session.ActiveVaultId)
{
groupItems =
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
}
}
// Ordered here rather than in the rebuild, and by the same three keys the host and key lists use:
// the vault new items go into first, then by vault name, then by label inside each. Two vaults may
// hold a group with the same name and both are drawn; which vault it is in is what tells them apart.
groupItems =
[
.. items
.OrderByDescending(entry => entry.VaultId == session.ActiveVaultId)
.ThenBy(entry => entry.VaultName, StringComparer.CurrentCulture)
.ThenBy(entry => entry.Item.Secret.Label, StringComparer.CurrentCulture),
];
groupsById = resolvable;
groupsByVault = perVault;
@@ -3188,21 +3297,40 @@ internal sealed partial class VaultViewModel(
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>
/// <remarks>
/// <b>Where a hidden vault's groups are dropped, and the only place they are.</b> The reload above keeps
/// every readable vault's, because the map built beside them decides what a host dials; this is the list
/// a person looks at, and a card for a vault whose forty hosts have been switched off is a folder that
/// cannot be opened onto anything. Dropping them here rather than at the read is what keeps the two
/// answers apart. See <see cref="IsVaultShown"/>.
/// </remarks>
private void RebuildGroups()
{
var selectedId = SelectedGroup?.EntityId;
var filteredId = GroupFilter?.EntityId;
// The same test the host rows are badged by, and it counts the vaults this session can read rather
// than the ones with a group in them: a badge that appeared the moment a colleague made their first
// group would be a column arriving on its own.
var several = session.ReadableVaults.Take(2).Count() > 1;
Groups.Clear();
foreach (var group in groupItems)
foreach (var group in groupItems.Where(entry => IsVaultShown(entry.VaultId)))
{
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId && IsVaultShown(row.VaultId));
var count = Hosts.Count(
row => row.Host.GroupId == group.Item.EntityId && IsVaultShown(row.VaultId));
Groups.Add(new HostGroupRowViewModel(group, count));
Groups.Add(new HostGroupRowViewModel(group, count)
{
// Only when there is something to tell apart, as on a host card — and it matters more here,
// because two vaults may each hold a "production" and the cards would otherwise be two
// identical folders side by side.
VaultBadge = several ? group.VaultName.ToUpperInvariant() : string.Empty,
});
}
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
@@ -3436,41 +3564,57 @@ internal sealed partial class VaultViewModel(
foreach (var group in Groups)
{
AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
AddSidebarSection(shown, group, host => host.Host.GroupId == group.EntityId);
}
AddSection(
AddSidebarSection(
shown,
null,
"UNGROUPED",
host => host.Host.GroupId is not { } id || !known.Contains(id),
onlyWhenOccupied: true);
}
void AddSection(
Guid? groupId,
string label,
Func<HostRowViewModel, bool> belongs,
bool onlyWhenOccupied = false)
/// <summary>Adds one heading to the sidebar, and the hosts under it when it is not folded away.</summary>
/// <param name="shown">The hosts that survived the filters, which every section draws its members from.</param>
/// <param name="group">
/// The group this heading is for, or null for the ungrouped one. The row rather than its id and label,
/// because a heading now says which vault the group is in as well — and null has no vault to name, since
/// it is every vault's unfiled hosts at once.
/// </param>
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
private void AddSidebarSection(
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs,
bool onlyWhenOccupied = false)
{
var members = shown.Where(belongs).ToArray();
if (onlyWhenOccupied && members.Length == 0)
{
var members = shown.Where(belongs).ToArray();
return;
}
if (onlyWhenOccupied && members.Length == 0)
{
return;
}
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);
SidebarRows.Add(new SidebarGroupHeader(
group?.EntityId,
group?.Label ?? "UNGROUPED",
members.Length,
expanded)
{
VaultBadge = group?.VaultBadge ?? string.Empty,
});
SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));
if (!expanded)
{
return;
}
if (!expanded)
{
return;
}
foreach (var member in members)
{
SidebarRows.Add(member);
}
foreach (var member in members)
{
SidebarRows.Add(member);
}
}
@@ -3516,8 +3660,16 @@ internal sealed partial class VaultViewModel(
/// host would be a save the user never asked for, and one they would then be unable to cancel.
/// </para>
/// <para>
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
/// A group id that is in no readable vault is not refused — it is treated as no group at all, which is
/// what the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
/// </para>
/// <para>
/// <b>A group in a different vault to the host is refused, and said so.</b> The cards are every readable
/// vault's since a group became a thing that can be shared, so this gesture can now be aimed across a
/// boundary that a save cannot cross: the host would keep an id only the other vault's holders can
/// resolve, and everybody in this one would see it filed under nothing. Refusing beats the two
/// alternatives — filing it anyway is the quiet wrong, and treating it as "no group" would unfile a host
/// somebody was plainly trying to file.
/// </para>
/// <para>
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
@@ -3535,33 +3687,24 @@ internal sealed partial class VaultViewModel(
return;
}
if (row.IsReadOnly)
{
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
// wrote would drop the fields this build has no concept of.
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
return;
}
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
return;
}
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
? wanted
var card = request.GroupId is { } wanted
? Groups.FirstOrDefault(group => group.EntityId == wanted)
: null;
if (RefusesTheDrop(row, card))
{
return;
}
Guid? target = card?.EntityId;
if (row.Host.GroupId == target)
{
return;
}
var moved = row.Host with { GroupId = target };
var name = target is null
? "no group"
: Groups.First(group => group.EntityId == target).Label;
var name = card?.Label ?? "no group";
await RunAsync(
$"Filing {row.Label} under {name}…",
@@ -3587,6 +3730,40 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Whether a drop has to be turned down, saying why on the status line when it does.</summary>
/// <param name="row">The host that was dragged.</param>
/// <param name="card">The group card it was dropped on, or null for the drop that unfiles a host.</param>
/// <remarks>
/// Three refusals rather than one, and separated from the write so that the reason reaches the status
/// line before anything is encrypted. Every one of them is a thing the layer below would either refuse
/// or, worse, accept: a newer client's item re-encoded loses fields, a write under an open editor is a
/// save nobody asked for, and a group in another vault is an id half the readers cannot resolve.
/// </remarks>
private bool RefusesTheDrop(HostRowViewModel row, HostGroupRowViewModel? card)
{
if (row.IsReadOnly)
{
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
return true;
}
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
return true;
}
if (card is not null && card.VaultId != row.VaultId)
{
Status =
$"'{card.Label}' is in {card.VaultName} and '{row.Label}' is in {row.VaultName}. "
+ "A host can only be filed under a group in its own vault.";
return true;
}
return false;
}
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
/// <remarks>
/// <para>
@@ -4421,7 +4598,14 @@ internal sealed partial class VaultViewModel(
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
/// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
/// second one would mean a name somebody chose on another machine could block one they choose here, at
/// the next sync, with the rename already saved.
/// the next sync, with the rename already saved. Two vaults holding one each is not even confusing: the
/// card and the heading both say which vault, and they are as separate as two vaults can make them.
/// </para>
/// <para>
/// Writes to <see cref="editingGroupVaultId"/>, which is the group's own vault on a rename and whatever
/// the picker said when the form opened on a create. Never the active vault, which is what it was while
/// the list held one vault's groups: a rename typed into a colleague's group would have created a second
/// group of that name in the personal vault and left theirs untouched.
/// </para>
/// </remarks>
[RelayCommand]
@@ -4454,13 +4638,13 @@ internal sealed partial class VaultViewModel(
if (renaming is { } entityId)
{
await session.HostGroups
.UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken)
.UpdateAsync(GroupEditorVaultId, entityId, group, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.HostGroups
.CreateAsync(session.ActiveVaultId, group, cancellationToken)
.CreateAsync(GroupEditorVaultId, group, cancellationToken)
.ConfigureAwait(true);
}
@@ -4491,10 +4675,19 @@ internal sealed partial class VaultViewModel(
}
EditingGroupId = row.EntityId;
// The group's own vault, and it does not move — the same rule an existing host's follows, and the
// same reason: the two are encrypted under different keys, so saving anywhere else would leave a
// copy behind rather than move anything. The picker is not drawn for an existing group at all.
editingGroupVaultId = row.VaultId;
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
GroupEditorLabel = row.Label;
GroupEditorDefaultPort = row.Group.DefaultPort;
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
// Before the parent picker, because that picker is one vault's and this is which one.
BuildGroupEditorVaultChoices(row.VaultId);
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
@@ -4561,6 +4754,14 @@ internal sealed partial class VaultViewModel(
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
/// while the open group is the screen everybody can see they are on.
/// </para>
/// <para>
/// <b>And in the open group's vault, which is where this differs from <see cref="NewHost"/> a second
/// time.</b> A host opens on the standing "new items go to" preference and takes the open group only if
/// that group happens to be in the same vault; a group made inside another group is in that group's
/// vault by construction, because a parent in a second vault is a level half the readers cannot resolve.
/// Defaulting to the preference instead would answer "+ NEW GROUP inside PLATFORM" with a group
/// somewhere else and no parent — a form that silently dropped the one thing the button said.
/// </para>
/// </remarks>
[RelayCommand]
private void NewGroup()
@@ -4574,6 +4775,17 @@ internal sealed partial class VaultViewModel(
ClearGroupEditor();
// The open group's vault, where there is one and this session can write to it. A viewer of a shared
// vault gets the standing preference instead — and, with it, no parent, because the group they were
// looking inside belongs to a vault they cannot add to.
if (GroupFilter is { } open
&& GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == open.VaultId) is { } vault)
{
// Assigned rather than written to the field, so the parent picker is refilled for it: the
// handler below is what keeps the two in step, here and when the user moves the picker by hand.
GroupEditorSelectedVault = vault;
}
// Falls back to no parent, which is both what the picker's first entry says and what the phone always
// gets: it has no group cards and no way to go inside one, so nothing there is ever open.
GroupEditorSelectedParent =
@@ -4624,21 +4836,31 @@ internal sealed partial class VaultViewModel(
/// terminates on a cycle. Walking down would need a child index this view model does not keep, and
/// building one that has to survive a cycle is the same problem twice.
/// </para>
/// <para>
/// <b>One vault's candidates, and it is the vault this group is going into rather than the one it is
/// on screen beside.</b> The list this picker used to be built from held one vault's groups, so the
/// restriction came free; it spans every readable vault now, and offering all of them would let somebody
/// file a shared group under a personal one — a parent nobody else can resolve, whose port and username
/// would then be lent to their hosts and to nobody else's. Which is the same failure the host editor's
/// group picker was fixed for, one level up.
/// </para>
/// </remarks>
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
{
GroupEditorParentChoices.Clear();
GroupEditorParentChoices.Add(GroupChoice.None);
foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
foreach (var candidate in groupItems.Where(
entry => entry.VaultId == GroupEditorVaultId && entry.Item.EntityId != groupId))
{
var descends = HostInheritance
.Chain(candidate.EntityId, groupsById)
.Chain(candidate.Item.EntityId, groupsById)
.Any(entry => entry.Id == groupId);
if (!descends)
{
GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
GroupEditorParentChoices.Add(
new GroupChoice(candidate.Item.EntityId, candidate.Item.Secret.Label));
}
}
@@ -4654,6 +4876,53 @@ internal sealed partial class VaultViewModel(
?? GroupChoice.None;
}
/// <summary>Refills the group editor's vault picker, landing on the vault the editor will write to.</summary>
/// <inheritdoc cref="BuildEditorVaultChoices" path="/remarks" />
private void BuildGroupEditorVaultChoices(Guid vaultId)
{
GroupEditorVaultChoices.Clear();
foreach (var choice in TargetVaults)
{
GroupEditorVaultChoices.Add(choice);
}
// Null where the group's vault is one this session cannot write — a shared vault this account is a
// viewer of. The picker is not drawn for an existing group anyway, and an empty box is a better
// answer than an option that would move the group if it were touched.
GroupEditorSelectedVault =
GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
OnPropertyChanged(nameof(ShowsGroupEditorVaultChoice));
}
/// <summary>
/// Moves a half-typed group into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> gives: a group cannot
/// be moved between vaults, so a path that reassigned this on a rename would write a second group into
/// the other vault and leave the original standing with the old name.
/// </remarks>
partial void OnGroupEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
if (value is null || EditingGroupId is not null || editingGroupVaultId == value.VaultId)
{
return;
}
editingGroupVaultId = value.VaultId;
// The parent picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
// to the vault just left, so it is dropped rather than carried: a group is one item in one vault,
// and there is nothing in the new one it could mean instead. The defaults below it are not touched,
// because a key or a credential may legitimately come from another vault, exactly as a host's may.
//
// Guid.Empty for the group being edited, because the guard above means there is not one: this only
// ever runs while creating, and a group that does not exist yet cannot be its own parent.
BuildGroupParentChoices(Guid.Empty, parentId: null);
}
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
/// <remarks>
/// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
@@ -4717,6 +4986,14 @@ internal sealed partial class VaultViewModel(
GroupEditorDefaultPort = null;
GroupEditorDefaultUsername = string.Empty;
// Back to the standing preference rather than to whatever the last group edited was in, which is
// the same clearing every other field here gets and matters more than any of them: a vault carried
// over from a colleague's group is where the next one would silently go.
editingGroupVaultId = TargetVaultId;
editingGroupVaultName = string.Empty;
// Before the parent choices, which are that vault's.
BuildGroupEditorVaultChoices(GroupEditorVaultId);
BuildGroupParentChoices(Guid.Empty, parentId: null);
BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
}
@@ -4751,7 +5028,13 @@ internal sealed partial class VaultViewModel(
PendingDeletion = new DeletionRequest(
DeletionTarget.Group,
row.EntityId,
$"Delete the group '{row.Label}'?",
// Named with its vault where there is more than one, because two of them may hold a group of
// this name and the question is about exactly one of the two. The badge is already the answer
// the card gives; this is the same answer at the moment it decides something.
row.HasVaultBadge
? $"Delete the group '{row.Label}' in {row.VaultName}?"
: $"Delete the group '{row.Label}'?",
HowFarADeletionGoes("The group"),
row.HostCount switch
{
@@ -4774,8 +5057,11 @@ internal sealed partial class VaultViewModel(
"Deleting…",
async () =>
{
// The row's own vault, which is why the row is re-found above rather than the id being
// enough: a tombstone written to the active vault would delete nothing and leave a
// colleague's group standing while this machine reported it gone.
await session.HostGroups
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
if (EditingGroupId == entityId)
@@ -7515,8 +7801,10 @@ internal sealed partial class VaultViewModel(
{
OnPropertyChanged(nameof(GroupSaveLabel));
// Which of the two things the group editor is doing, which its header says as well as its button.
// Which of the two things the group editor is doing, which its header says as well as its button
// and, under it, the vault a group being renamed is in, which only a rename has an answer for.
OnPropertyChanged(nameof(DrawerTitle));
OnPropertyChanged(nameof(DrawerSubtitle));
}
/// <summary>