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
@@ -125,7 +125,21 @@
<DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
<!--
The name, and the vault it is in where this session holds more than one. The badge is what
makes one heading per group survive a shared vault: two vaults may each hold a "production",
and this list has no nesting to tell the two apart with. Drawn as the same outline tag a host
row wears, so "which vault" looks the same wherever it is answered.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="7,0,0,0">
<TextBlock Classes="section" Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline" VerticalAlignment="Center"
IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
VerticalAlignment="Center" />
@@ -369,6 +383,27 @@
<TextBox Classes="field" Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<!--
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker above and
for the same reason: it is what makes the group shared, and it cannot be changed afterwards.
Above INSIDE because it decides what INSIDE can offer — a parent belongs to one vault, and one
from another is a level half the readers cannot resolve.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
SelectedItem="{Binding GroupEditorSelectedVault}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A group in a shared vault is visible to everybody holding that vault's key, and only hosts in the same vault can be filed under it." />
</StackPanel>
<TextBlock Classes="label" Text="INSIDE" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
@@ -565,6 +565,34 @@
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<!--
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker
above: asked while adding, hidden where there is only one vault to write to, and never
offered for an existing group, because the vaults are encrypted under different keys and
moving an item between them is a delete and a retype.
A group in a shared vault is what gives a team an arrangement rather than a heap: the
people holding that vault's key see the folder, and the hosts inside it inherit its port,
its username and its key.
The parent picker below follows it, for the reason the host's group picker follows the
host's vault — a parent in another vault would be a level half the readers cannot resolve.
See VaultViewModel.ShowsGroupEditorVaultChoice.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
SelectedItem="{Binding GroupEditorSelectedVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A group in a shared vault is visible to everybody holding that vault's key, and only hosts in the same vault can be filed under it." />
</StackPanel>
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
HorizontalAlignment="Stretch">
@@ -332,6 +332,17 @@
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
<!--
Which vault this group is in, on the same rule and in the same place as the host
card's: only where there is more than one vault to be in. It matters more on a
folder than on a machine — two vaults may each hold a "production", and without
this the cards are two identical folders side by side, one of which a colleague
can read. It is also what says a group is shared at all.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
</Grid>
</Border>
@@ -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>