Public Access
Merge branch 'claude/groups-vault-sharing-e4b154'
# Conflicts: # src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user