Merge branch 'claude/groups-vault-sharing-e4b154'
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / build and test (push) Canceled after 2s

# Conflicts:
#	src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
This commit is contained in:
2026-08-04 16:28:29 +02:00
8 changed files with 765 additions and 112 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;
@@ -1889,15 +1989,23 @@ internal sealed partial class VaultViewModel(
/// The line under it: which keychain this is filed in, or what a group is for.
/// </summary>
/// <remarks>
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host can
/// be moved between vaults now — see <see cref="MoveHost"/> — and it is still not a field: the move is
/// a re-seal under another key and a tombstone under this one, so binding it to a control that saves
/// with the rest of the form would let somebody correcting a port move a machine by leaving a picker
/// where they found it. It has its own panel and its own button. Where a *new* item goes is chosen on
/// the keychain screen's own picker; see <see cref="TargetVaults"/>.
/// <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 in the editor's own picker; see <see cref="TargetVaults"/>
/// </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,
@@ -3059,42 +3167,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)
@@ -3108,6 +3209,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] =
@@ -3116,14 +3219,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;
@@ -3240,21 +3348,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
@@ -3488,41 +3615,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);
}
}
@@ -3568,8 +3711,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
@@ -3587,33 +3738,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}…",
@@ -3639,6 +3781,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>
@@ -4473,7 +4649,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]
@@ -4506,13 +4689,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);
}
@@ -4543,10 +4726,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);
@@ -4613,6 +4805,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()
@@ -4626,6 +4826,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 =
@@ -4676,21 +4887,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));
}
}
@@ -4706,6 +4927,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
@@ -4769,6 +5037,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);
}
@@ -4803,7 +5079,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
{
@@ -4826,8 +5108,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)
@@ -7758,8 +8043,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>