Sweep out the group-card navigation nothing reaches any more

This commit is contained in:
2026-08-07 18:14:54 +02:00
parent dca2e888d6
commit 281e828e25
5 changed files with 115 additions and 596 deletions
+2 -2
View File
@@ -141,8 +141,8 @@ Nothing calls it with a proxy yet. That is deliberate and it is one step wide: s
- The hop's own connection, and a `ForwardedPortDynamic("127.0.0.1", 0)` started on it, whose `BoundPort`
becomes the `SshLoopbackProxy` for the connection after it. A chain of two is that twice.
- `VaultViewModel` resolves `JumpHostIds` to hosts in the same vault, applying group inheritance per hop the
way the target already gets it, and refuses a chain that crosses a vault — the same refusal
`RefusesTheDrop` and the group picker already make, for the same reason.
way the target already gets it, and refuses a chain that crosses a vault — the same refusal the group
picker already makes, for the same reason.
-**The target's address is resolved at the last hop, not here.** A SOCKS CONNECT names the target and the
bastion resolves it, so what has to be stored on the host is the address *the bastion* can reach — which
is what an `ssh_config` means by `HostName` beside a `ProxyJump`, and what the importer is already
+1 -29
View File
@@ -713,7 +713,7 @@
chosen card that also drew an accent outline would be indistinguishable from the selected one, and the
question "which of these is the drawer showing" would have no answer. Declared after that rule anyway,
because it sets the same Background and Avalonia settles two matching rules by declaration order —
the trap this file records for Button.tab, Border.rowmark and the drop target below.
the trap this file records for Button.tab and Border.rowmark.
-->
<Style Selector="Border.tile.chosen">
<Setter Property="Background" Value="{StaticResource Active}" />
@@ -990,34 +990,6 @@
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
</Style>
<!--
The group card a host is about to be dropped on. A wash rather than the accent strip a selection
carries, because it is not a selection: it says "let go here", it lasts as long as the pointer is over
the card, and it has to be legible on top of whatever that card already looks like — including the
selected one, which is the group the grid is currently narrowed to and so often the one being dragged
onto.
Painted on the tile rather than on the item's own presenter, which is where this used to go when the
target was a full-width heading. A wash behind a 10-pixel-rounded tile is a square of colour with the
card floating on it — the same four grey corners the tiles list clears the theme's own hover and
selection brushes to avoid. Declared after that rule and after the selected-tile rule, because
Avalonia has no specificity and settles two matching rules by declaration order.
◆ THE DOUBLED BORDER IS THE MARK, not the fill, and that is what the move to the tile forced. The fill
a selected tile already carries is the strongest one this palette has, so a target that only filled
would be invisible on the card the grid is narrowed to — which is the card a host most often has to be
dragged off, into another group. Two pixels of accent is legible on both.
Set from the code-behind rather than by a binding, for the reason the whole gesture is code: the rows
are rebuilt from scratch on every filter keystroke and every sync pass, so a flag on the view model
would be state the list throws away halfway through the drag. See HostsScreen.axaml.cs.
-->
<Style Selector="ListBoxItem.droptarget Border.tile">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
<!--
A context menu, in this window's palette rather than the theme's. The Fluent default is a lighter grey
than anything else here, which on a near-black chrome reads as a dialog from another application.
@@ -125,17 +125,6 @@ internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}
/// <summary>One step of the path into the groups, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the step is called.</param>
/// <param name="Group">The group it opens, or null for the step that shows every host again.</param>
/// <remarks>
/// The same shape the transfers screen's trail uses — see <c>CrumbViewModel</c> — and drawn the same way,
/// because it answers the same question: a directory pane and a grid of groups both have to say where the
/// thing on screen came from and offer a way back out. The row rather than its id, because
/// <see cref="VaultViewModel.GroupFilter"/> holds a row, and an id would only be looked up again.
/// </remarks>
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
/// <summary>An entry in the host editor's group picker.</summary>
/// <param name="EntityId">The group, or null for "no group".</param>
/// <param name="Label">What to show.</param>
@@ -194,16 +183,6 @@ internal sealed record GroupFilterChoice(Guid GroupId, string Label, bool IsChec
/// <remarks>A host passes the filter by wearing <em>any</em> checked tag, not all of them.</remarks>
internal sealed record TagFilterChoice(Guid TagId, string Label, bool IsChecked);
/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
/// <remarks>
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
/// carries the vault the edit has to return to.
/// </remarks>
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
/// <summary>One snippet, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
@@ -1764,50 +1743,12 @@ internal sealed partial class VaultViewModel(
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
/// <remarks>
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
/// from; <see cref="VisibleGroups"/> is the desktop's one level of it.
/// Every one of them, flat. This is what a group is looked up in and what both heads' headings are built
/// from — v5 retired the desktop's own grid of group cards in favour of the flat board the phone always
/// drew; see <see cref="HostSections"/>.
/// </remarks>
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
/// <summary>
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
/// groups when none is.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Groups"/> is to this what <see cref="Hosts"/> is to <see cref="VisibleHosts"/>: the whole
/// collection beside the part of it on screen. The grid used to draw every group at once, which was the
/// only honest thing to do while pressing a card meant nothing but "narrow the list" — a card was a
/// filter, and every filter has to be reachable. Opening one is navigation, so the cards became the
/// contents of wherever the trail says you are.
/// </para>
/// <para>
/// A group whose parent this vault has not got is drawn at the outermost level rather than nowhere, and
/// so is one caught in a parent cycle. Both are states two offline edits can produce and neither can be
/// repaired from a screen that will not draw the group — see <see cref="HostGroupSecret.ParentId"/>.
/// </para>
/// </remarks>
internal ObservableCollection<HostGroupRowViewModel> VisibleGroups { get; } = [];
/// <summary>
/// The path down to the group that is open: every host, then each group above it, then it.
/// </summary>
/// <remarks>
/// Always at least one crumb, and the first one is the way back to every host — which is what it is for.
/// A grid whose cards are one level of a tree needs somewhere to say which level, and the same control
/// is the way out of it; without that the only way back would be a button that says so, which is what
/// SHOW ALL was and what this replaces.
/// </remarks>
internal ObservableCollection<GroupCrumbViewModel> GroupTrail { get; } = [];
/// <summary>Whether there are any group cards to draw at this level.</summary>
/// <remarks>
/// Separate from <see cref="HasGroups"/>, which is about the vault. A group with nothing inside it is an
/// ordinary thing to open, and the trail and the group's own buttons have to stay on screen when it is —
/// so it is the card grid alone that folds away, not the panel around it.
/// </remarks>
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
/// <remarks>
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
@@ -1893,8 +1834,9 @@ internal sealed partial class VaultViewModel(
/// </summary>
/// <remarks>
/// The application's selection, which everything that acts on a host reads — connecting, editing,
/// deleting. <b>It shares one selection with <see cref="SelectedGroup"/>:</b> selecting a host takes the
/// mark off a group card and the other way about. See <see cref="OnSelectedHostChanged"/>.
/// deleting. Through v4 this was one of two things a click could light, sharing itself with a group
/// card's own selection; v5's flat board draws no group cards to share it with, so a host is the only
/// thing this screen ever marks. See <see cref="OnSelectedHostChanged"/>.
/// </remarks>
[ObservableProperty]
private HostRowViewModel? selectedHost;
@@ -1920,66 +1862,25 @@ internal sealed partial class VaultViewModel(
private ISidebarRow? selectedSidebarRow;
/// <summary>
/// The group card that is selected, or null when none is.
/// The group <see cref="GroupTarget"/> falls back to when a command is not handed one directly.
/// </summary>
/// <remarks>
/// <para>
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
/// it deliberately no longer does is narrow the grid — see <see cref="OpenGroup"/>.
/// </para>
/// <para>
/// <b>It shares one selection with <see cref="SelectedHost"/>.</b> Setting either clears the other, so
/// exactly one card on the screen is ever lit. See <see cref="OnSelectedHostChanged"/>.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
/// <summary>
/// The group that is open: the one whose contents the screen is showing, or null for every host.
/// </summary>
/// <remarks>
/// <para>
/// Set by <see cref="OpenGroup"/> and by nothing else. It decides three things at once — which hosts the
/// grid holds, which groups the cards hold, and what the trail says — which is what makes it "where you
/// are" rather than a filter that happens to be on.
/// </para>
/// <para>
/// <b>Separate from <see cref="SelectedGroup"/>, and no longer sets it.</b> The two answer different
/// questions — "what is on screen" and "which card is chosen" — and while one click meant
/// both there was no way to name a group without also narrowing the grid to it. Two gestures, two
/// properties; <see cref="GroupTarget"/> is where the two meet.
/// ◆ <b>v3 named this "the group that is open" and set it from a double-click on a card, the way a
/// directory pane's own breadcrumb worked.</b> v5 retired that grid, its cards and its trail in favour of
/// the flat board both heads now draw — see <see cref="HostSections"/> — and nothing on either head
/// opens a group any more, so nothing sets this to a value other than null. It stays rather than being
/// removed with the card grid because <see cref="RebuildGroups"/> still re-resolves it against every
/// reload, and <see cref="GroupTarget"/>, <see cref="EditGroup"/>, <see cref="DeleteGroup"/> and
/// <see cref="MoveGroup"/> still read it as their fallback when handed no group directly — a caller none
/// of them currently has, since <see cref="EditGroupFromHeading"/> and its two siblings always resolve
/// and pass a row of their own. Removing the fallback itself would mean re-proving the same thing about
/// three more commands than removing the trail did about one property.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? groupFilter;
/// <summary>
/// Opens a group, or every host when handed null.
/// </summary>
/// <remarks>
/// <para>
/// A double-click on a card, or a press on a crumb of the trail. Deliberately not a single click, which
/// is what it was: a card is the only place a group can be selected, and a gesture that both selected a
/// group and threw the rest of the grid away left no way to rename one without first losing sight of
/// everything else. Double-clicking to go inside something is what the transfers screen's directories do
/// and what the host cards beneath these do to open a shell, so the grid now has one vocabulary rather
/// than one per list.
/// </para>
/// <para>
/// The selection is dropped first, and it has to be: the cards are about to be redrawn one level along,
/// and a selection pointing at a card that is no longer on screen would aim EDIT and DELETE at something
/// nobody can see. Null is a real argument here rather than a missing one — it is the trail's first
/// crumb, and it is the way back out.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenGroup(HostGroupRowViewModel? group)
{
SelectedGroup = null;
GroupFilter = group;
}
/// <summary>What the group name box holds, for both creating and renaming.</summary>
[ObservableProperty]
private string groupEditorLabel = string.Empty;
@@ -2986,10 +2887,10 @@ internal sealed partial class VaultViewModel(
/// Whether the panel asking which group the chosen hosts should be filed under is up.
/// </summary>
/// <remarks>
/// The one action in the bar that has always existed and had no phone control: filing is
/// <see cref="MoveHostToGroupAsync"/>, which is what dragging a card onto a group does on the desktop, and
/// there is no dragging here. It is the reason the set is worth having at all — thirty imported machines
/// under one heading is one gesture rather than thirty rounds of open, pick, save.
/// The one action in the bar that has always had no phone control and, since v5 retired the desktop's own
/// drag, no desktop gesture either: filing a set is now only ever this panel, on both heads, through
/// <see cref="ConfirmRegroupChosenHostsAsync"/>. It is the reason the set is worth having at all — thirty
/// imported machines under one heading is one gesture rather than thirty rounds of open, pick, save.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AChosenHostPanelIsOpen))]
@@ -3678,23 +3579,15 @@ internal sealed partial class VaultViewModel(
internal bool AChosenHostPanelIsOpen =>
IsSendingChosenHostsToAVault || IsRegroupingChosenHosts || IsConfirmingChosenHostDeletion;
/// <summary>
/// What a group command with no argument acts on: the card that is selected, or the group that is open.
/// </summary>
/// <summary>What a group command with no argument acts on.</summary>
/// <remarks>
/// Two answers, because a card is no longer where the user is. Selecting one aims at it, which is what a
/// click has always done; with nothing selected the answer is the group whose contents are on screen —
/// the one the trail ends with. That fallback is what makes + NEW HOST open on the group somebody is
/// standing in rather than on none, and it is what a file manager does: act on the selection, and on the
/// current folder when there is none.
/// <para>
/// The desktop's Edit and Delete reach this through the card menu, which selects whatever was
/// right-clicked first, so the fallback is not what they read — see <c>HostsScreen.OnGroupContextRequested</c>.
/// They used to be a pair of buttons beside the GROUPS heading, which had no card under a pointer to
/// mean and so leaned on it.
/// </para>
/// Equal to <see cref="GroupFilter"/> today, and <see cref="GroupFilter"/>'s own remarks say why nothing
/// currently makes that non-null: the card-and-trail navigation this was a fallback for is gone from both
/// heads. <see cref="EditGroupFromHeading"/>, <see cref="MoveGroupFromHeading"/> and
/// <see cref="DeleteGroupFromHeading"/> resolve a row from the heading they were pressed on and pass it
/// directly — every current caller hands a group in, so this is read but never leaned on.
/// </remarks>
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
internal HostGroupRowViewModel? GroupTarget => GroupFilter;
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
internal bool HasGroups => Groups.Count > 0;
@@ -4271,21 +4164,14 @@ internal sealed partial class VaultViewModel(
/// <summary>Which host a freshly filled <see cref="Hosts"/> leaves selected.</summary>
/// <param name="selectedId">Whatever was selected before the list was refilled.</param>
/// <remarks>
/// <para>
/// The selection survives a reload, because losing it on every sync would move the terminal's target out
/// from under the user. The first host is the fallback rather than nothing, so that a fresh unlock has
/// something under CONNECT.
/// </para>
/// <para>
/// That fallback is skipped while a group card holds the selection, and it has to be: the two grids
/// share one mark — see <see cref="OnSelectedHostChanged"/> — so a sync that invented a host would
/// quietly unselect a group nobody had touched, once a minute. Read before <see cref="RebuildGroups"/>
/// runs, which is where <see cref="SelectedGroup"/> is re-resolved against the rows this pass makes.
/// </para>
/// something under CONNECT. Through v4 that fallback was skipped while a group card held the selection
/// instead of a host; v5's board draws no group cards to hold it, so there is no longer a second case to
/// skip the fallback for.
/// </remarks>
private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
Hosts.FirstOrDefault(row => row.EntityId == selectedId)
?? (SelectedGroup is null ? Hosts.FirstOrDefault() : null);
Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
/// <returns>How many buckets would not decrypt.</returns>
/// <remarks>
@@ -4725,7 +4611,6 @@ internal sealed partial class VaultViewModel(
/// </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
@@ -4752,70 +4637,20 @@ internal sealed partial class VaultViewModel(
});
}
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
// group holding the old one would go on showing a group that is no longer in the list — and the
// crumb the user could press to leave it would be a different object that never matched. A group
// deleted by a sync closes itself, which is the honest answer: the screen comes back to every host
// rather than to none.
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so
// GroupFilter holding the old one would go on naming a group that is no longer in the list. A group
// deleted by a sync clears it, which is the honest answer.
//
// This assignment is a new row object whenever a group is open at all, so it always fires
// This assignment is a new row object whenever GroupFilter names one at all, so it always fires
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
// the same thing — and it is left rather than dodged by writing the backing field, because writing
// the field would skip the cards, the trail and GroupTarget with it.
// the field would skip GroupTarget's own change notification with it.
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
// Unconditionally, because the assignment above is a no-op — and fires nothing — whenever no group
// was open, and the cards still have to be rebuilt out of the row objects this pass just made.
RebuildGroupLevel();
// Out of the cards on screen rather than out of every group, and after the level has been rebuilt:
// this is the card ListBox's own selection, and a row it is not showing is one the control would
// null straight back out again.
//
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
// EDIT and DELETE aim at, and a background sync that picked a group would point them at one nobody
// chose.
SelectedGroup = VisibleGroups.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasGroups));
}
/// <summary>
/// Refills the group cards and the trail from whichever group is open.
/// </summary>
/// <remarks>
/// Both together because they are two halves of one answer: the cards are what is inside the open group
/// and the trail is how it was reached, and a pass that rebuilt one without the other would draw a level
/// under a path that does not lead to it.
/// </remarks>
private void RebuildGroupLevel()
{
var parents = EffectiveParents();
var open = GroupFilter?.EntityId;
VisibleGroups.Clear();
foreach (var row in Groups.Where(row => parents.GetValueOrDefault(row.EntityId) == open))
{
VisibleGroups.Add(row);
}
GroupTrail.Clear();
// Always first, always there, and it is the way out — see GroupTrail. The name is what the grid
// below shows when nothing is open, rather than the vault's, because that is the choice being
// offered: this crumb widens the screen back to every machine in it.
GroupTrail.Add(new GroupCrumbViewModel("ALL HOSTS", null));
foreach (var row in Ancestry(open, parents))
{
GroupTrail.Add(new GroupCrumbViewModel(row.Label, row));
}
OnPropertyChanged(nameof(HasVisibleGroups));
}
/// <summary>
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
/// outermost level.
@@ -4871,29 +4706,6 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>The groups from the outermost down to the one that is open, or nothing when none is.</summary>
/// <remarks>
/// Walked against <see cref="EffectiveParents"/> rather than against the stated parents, so the trail
/// cannot lead through a group the cards will not draw — and so that it terminates, which is what the
/// promotion above buys: a cycle has no parent left to follow.
/// </remarks>
private List<HostGroupRowViewModel> Ancestry(Guid? open, Dictionary<Guid, Guid?> parents)
{
var trail = new List<HostGroupRowViewModel>();
var current = open;
while (current is { } id
&& Groups.FirstOrDefault(row => row.EntityId == id) is { } row)
{
trail.Add(row);
current = parents.GetValueOrDefault(id);
}
trail.Reverse();
return trail;
}
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
/// <remarks>
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
@@ -5351,126 +5163,6 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>
/// Files one host under one group, or under none.
/// </summary>
/// <remarks>
/// <para>
/// What dragging a host card onto a group card does, and the only thing in this application that changes
/// a host without opening the editor. That is the justification for it existing at all: filing thirty
/// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one
/// field of a host that is about arrangement rather than about the machine.
/// </para>
/// <para>
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
/// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
/// 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 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
/// method that takes one cancels the previous execution's token on every invocation, and two drops in
/// quick succession are two writes rather than one superseding the other. This is one row's one field
/// and it is over in a moment.
/// </para>
/// </remarks>
/// <param name="request">The host to move, and where to.</param>
[RelayCommand]
private async Task MoveHostToGroupAsync(HostGroupMove? request)
{
if (request is not { Host: { } row })
{
return;
}
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 = card?.Label ?? "no group";
await RunAsync(
$"Filing {row.Label} under {name}…",
async () =>
{
await session.Hosts
.UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
.ConfigureAwait(true);
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
// Re-found rather than kept: the reload replaces every row, so the object that was dragged is
// no longer the one in the list, and leaving the selection pointing at it would light nothing.
SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);
Status = target is null
? $"'{row.Label}' is no longer in a group."
: $"Filed '{row.Label}' under '{name}'.";
}).ConfigureAwait(true);
// Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
// editor.
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>
@@ -6668,8 +6360,8 @@ internal sealed partial class VaultViewModel(
/// <para>
/// <b>One keychain's groups, so a selection spanning two is refused rather than half-filed.</b> A group is
/// an item of one vault: filing a host from another under it would leave everybody but the person who did
/// it seeing a machine filed under nothing. That is the same refusal <see cref="RefusesTheDrop"/> makes
/// for a card dragged across the boundary on the desktop, made once here instead of per host.
/// it seeing a machine filed under nothing — checked once here, over the whole set, rather than inside
/// <see cref="ConfirmRegroupChosenHostsAsync"/> once per host.
/// </para>
/// <para>
/// "No group" is an entry rather than an omission, because unfiling a run of machines is exactly as
@@ -6718,9 +6410,11 @@ internal sealed partial class VaultViewModel(
/// Files every chosen host under the group that was picked.
/// </summary>
/// <remarks>
/// The same write <see cref="MoveHostToGroupAsync"/> makes for one dragged card, and deliberately the same
/// one: the group is the single field of a host that is about arrangement rather than about the machine,
/// which is why it is the only one anything changes without opening the editor.
/// The only way a set is filed since v5: dragging a ticked card onto a group card went with the group
/// grid it was dragged onto, and nothing replaced the gesture — the picker this confirms was always the
/// phone's only route and is now both heads'. The group is the single field of a host that is about
/// arrangement rather than about the machine, which is why it is the only one anything changes without
/// opening the editor.
/// </remarks>
[RelayCommand]
private async Task ConfirmRegroupChosenHostsAsync(CancellationToken cancellationToken)
@@ -6741,58 +6435,16 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// Files the ticked hosts under the group card a drag of them was let go of.
/// </summary>
/// <param name="card">The group card the drop landed on.</param>
/// <remarks>
/// <para>
/// ◆ <b>The desktop's drag, once more than one card is ticked.</b> Dragging one host onto a group is
/// <see cref="MoveHostToGroupAsync"/> and always has been; a selection dragged onto one has to file all of
/// it, because the alternative — moving whichever card the pointer happened to be holding and leaving the
/// other five where they are — is a gesture that quietly does a fraction of what it looks like it does.
/// </para>
/// <para>
/// The two refusals are the ones <see cref="RefusesTheDrop"/> makes for a single card, made once for the
/// set: a write under an open editor is a save nobody asked for, and a group belongs to one vault, so a
/// selection spanning two cannot be filed under it — the hosts from the other keychain would carry an id
/// only its holders can resolve. A read-only host inside the set is skipped rather than refusing the
/// whole drop; that is counted and said, in the run below.
/// </para>
/// <para>
/// No cancellation token, for the reason <see cref="MoveHostToGroupAsync"/> has none: two drops in quick
/// succession are two writes rather than one superseding the other.
/// </para>
/// </remarks>
[RelayCommand]
private Task FileChosenHostsUnderAsync(HostGroupRowViewModel? card)
{
if (card is null || !IsChoosingHosts || AHostEditorIsInTheWay())
{
return Task.CompletedTask;
}
if (ChosenHosts.Any(row => row.VaultId != card.VaultId))
{
Status = $"'{card.Label}' is in another keychain to some of these hosts, and a group belongs to "
+ "one. Move them to that keychain first, or file the ones already in it.";
return Task.CompletedTask;
}
return FileTheChosenHostsUnderAsync(card.EntityId, card.Label, CancellationToken.None);
}
/// <summary>
/// The write behind both ways a set of ticked hosts is filed: the group picker, and a drag of them onto a
/// group card.
/// The write behind filing a set of ticked hosts under a group, from the group picker.
/// </summary>
/// <param name="groupId">The group they end up in, or null for none.</param>
/// <param name="name">What to call it on the status line.</param>
/// <param name="cancellationToken">The caller's lifetime.</param>
/// <remarks>
/// Shared rather than written twice, because what "filing a set" means — the hosts a newer version wrote
/// are skipped, the ones already there are counted as done, and the selection is dropped once the reload
/// has replaced every row — is the part that would drift between two copies of it.
/// <see cref="ConfirmRegroupChosenHostsAsync"/>'s one caller today; kept as a method of its own rather
/// than folded back into it because what "filing a set" means — the hosts a newer version wrote are
/// skipped, the ones already there are counted as done, and the selection is dropped once the reload has
/// replaced every row — reads better named than inlined, regardless of how many callers it has.
/// </remarks>
private async Task FileTheChosenHostsUnderAsync(
Guid? groupId, string name, CancellationToken cancellationToken)
@@ -7264,14 +6916,12 @@ internal sealed partial class VaultViewModel(
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
BuildEditorVaultChoices(editingHostVaultId);
// A new host opens in the group the screen is already about — the card that is selected, or failing
// that the group whose contents are showing. Adding three machines to the group somebody has just
// made is the ordinary case, and since the grid holds one level at a time the alternative is worse
// than a default nobody chose: a host created inside a group and filed under none would vanish from
// the screen it was created on. Still filtered by the vault being written to, which is that group's
// own wherever this session can write there: what the filter is left holding is the case where it
// cannot. Before the authentication picker, because whether there is a group to inherit from decides
// whether that one offers to.
// A new host opens in the group GroupTarget names, on the terms that property's own remarks give —
// nothing sets it to a value today, so this is the fallback path rather than the ordinary one it was
// through v4. Still filtered by the vault being written to, which is that group's own wherever this
// session can write there: what the filter is left holding is the case where it cannot. Before the
// authentication picker, because whether there is a group to inherit from decides whether that one
// offers to.
BuildGroupChoices(GroupInEditingVault(inherited?.EntityId));
BuildAuthenticationChoices(
@@ -7514,16 +7164,14 @@ internal sealed partial class VaultViewModel(
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
/// <param name="group">
/// The group to edit, or null for whatever the screen is aimed at — the selected card, or the open group
/// when no card is selected. See <see cref="GroupTarget"/>. The desktop's card menu passes nothing and
/// means the card that was right-clicked, which opening the menu has already selected; the phone has no
/// card to select and passes the group its heading names.
/// The group to edit, or null to fall back to <see cref="GroupTarget"/> — see that property's own
/// remarks for why nothing today actually relies on the fallback.
/// </param>
/// <remarks>
/// Taking it as an argument is what keeps the phone from having to select a group in order to edit one.
/// A selection is shared with the host grid now — see <see cref="OnSelectedHostChanged"/> — so a command
/// reachable only through <see cref="SelectedGroup"/> would deselect the machine somebody was about to
/// connect to, on a screen that draws no group cards at all.
/// Taking it as an argument rather than reading a selection is what lets both heads reach this the same
/// way: <see cref="EditGroupFromHeading"/> resolves a row from the heading that was pressed and hands it
/// straight in, on the desktop and the phone alike, so neither has to put a group on a selection a host
/// card might be holding — see <see cref="SelectedHost"/>.
/// </remarks>
[RelayCommand]
private void EditGroup(HostGroupRowViewModel? group)
@@ -7700,31 +7348,29 @@ internal sealed partial class VaultViewModel(
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
/// <remarks>
/// <para>
/// The desktop never needed this command: its group editor is a bar that is always on screen, so
/// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
/// editor is a card that has to be raised — and raising it from a stale state would offer the last
/// group's default key to the new one without anybody choosing it, which is what
/// <see cref="ClearGroupEditor"/> prevents.
/// ◆ <b>Both heads need this command now, and both reach it the same way: a menu entry rather than a
/// button that stands on its own.</b> That has been true since v4's drawer replaced the desktop's own bar
/// with an editor asked for on demand; v5 changes only where the entry lives — "New group…" at the foot
/// of the Group ▾ filter flyout, since the flyout is what the toolbar now has where GROUPS used to be a
/// heading of its own. The phone reaches the same command from its add sheet. Raising a stale editor
/// would offer the last group's default key to the new one without anybody choosing it, which is what
/// <see cref="ClearGroupEditor"/> prevents, on both heads alike.
/// </para>
/// <para>
/// The parent is defaulted after that clearing rather than inside it, and only here. This is the one
/// path that means "make one", and a group made inside the group that is open is what + NEW GROUP has to
/// mean now that the cards are one level of a tree — filed at the outermost level it would disappear
/// from the screen it was made on. The other two callers are a cancel and a save, and neither is asking
/// for a group anywhere.
/// path that means "make one"; the other two callers are a cancel and a save, and neither is asking for a
/// group anywhere.
/// </para>
/// <para>
/// <b>The group that is open, and deliberately not the card that is selected</b> — which is where this
/// differs from <see cref="NewHost"/>. A selected card is what EDIT and DELETE are aimed at; reading it
/// 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.
/// <b>The group <see cref="GroupTarget"/> names, and deliberately not the card that is selected</b> —
/// which is where this differs from <see cref="NewHost"/> only in which selection it would have read.
/// Nothing today sets <see cref="GroupFilter"/> to a value worth reading, per that property's own
/// remarks, so in practice this always falls through to the standing "new items go to" preference below.
/// </para>
/// <para>
/// <b>And in the open group's vault</b>, which <see cref="NewHost"/> now does too and for the same
/// reason: a parent — or a group — in a second vault is a level half the readers cannot resolve, so the
/// thing being made goes where the thing it is going inside already is. Defaulting to the standing
/// "new items go to" 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.
/// <b>And in that group's vault</b>, which <see cref="NewHost"/> does too and for the same reason: a
/// parent — or a group — in a second vault is a level half the readers cannot resolve, so the thing being
/// made goes where the thing it is going inside already is, on the rare occasion there is one.
/// </para>
/// </remarks>
[RelayCommand]
@@ -7990,18 +7636,16 @@ internal sealed partial class VaultViewModel(
/// <para>
/// <b>Refused with a host editor open</b>, which the deletion of a single host is not. The difference is
/// that this one writes to hosts: unfiling rewrites every machine under the heading, and doing that
/// beneath a half-typed edit of one of them is the save nobody asked for that
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
/// beneath a half-typed edit of one of them is the save nobody asked for.
/// </para>
/// <para>
/// Aims where Edit does: at the group it is handed, and failing that at the selected card, which on the
/// desktop is the one the menu opened on. See <see cref="GroupTarget"/>.
/// Aims where Edit does: at the group it is handed, and failing that at <see cref="GroupTarget"/> — see
/// that property's own remarks for why nothing today actually reaches this through the fallback.
/// </para>
/// <para>
/// <b>The argument is the phone's and the fallback is the desktop's.</b> The desktop's menu passes
/// nothing, because the code-behind has already selected whatever was right-clicked; the phone has no
/// group selection to make — a heading is not a thing its list can select — so its sheet passes the row
/// it was opened on. See <see cref="DeleteGroupFromHeading"/>.
/// <b>Both heads pass the row directly rather than leaning on a selection.</b> <see cref="DeleteGroupFromHeading"/>
/// resolves it from the heading that was pressed, on the desktop's menu and the phone's sheet alike —
/// see that command's own remarks.
/// </para>
/// </remarks>
/// <param name="group">The group to ask about, or null to use <see cref="GroupTarget"/>.</param>
@@ -8823,8 +8467,8 @@ internal sealed partial class VaultViewModel(
/// <b>The parent is left behind and the tags are dropped</b>, and both are the same rule the host's move
/// follows: a parent group and a tag are items of the vault being left, so a reference carried across
/// would resolve on this machine — groups and tags resolve over every readable vault — and dangle for
/// everybody else in the destination. The moved group becomes a root, which is what the trail will show,
/// and it is said in the sentence afterwards rather than discovered.
/// everybody else in the destination. The moved group becomes a root, and it is said in the sentence
/// afterwards rather than discovered.
/// </para>
/// <para>
/// <b>Keys and passwords are kept</b>, on the host and on the group's own defaults, because those do
@@ -8834,11 +8478,10 @@ internal sealed partial class VaultViewModel(
/// </para>
/// <para>
/// <b>The group is resolved from the panel's own id rather than from <see cref="GroupTarget"/>.</b> That
/// property is the desktop's selection and is null on the phone, whose sheet aims the move by handing
/// the row in — so reading it here would leave the phone's MOVE button doing nothing at all. It also
/// says the honest thing on both heads: what this moves is the shelf the panel was opened on, and a
/// selection that has since gone elsewhere has already folded the panel away. See
/// <see cref="CloseTheGroupMovePanelIfAimedElsewhere"/>.
/// property's own remarks say why nothing sets it to a value worth reading here — resolving from the
/// panel is what lets both heads' MOVE button work regardless, and it says the honest thing besides: what
/// this moves is the shelf the panel was opened on, and a selection that has since gone elsewhere has
/// already folded the panel away. See <see cref="CloseTheGroupMovePanelIfAimedElsewhere"/>.
/// </para>
/// </remarks>
[RelayCommand]
@@ -8879,15 +8522,11 @@ internal sealed partial class VaultViewModel(
"Moving…",
async () =>
{
var landed = await ReSealTheSubtreeAsync(subtree, filed, target.VaultId, cancellationToken)
await ReSealTheSubtreeAsync(subtree, filed, target.VaultId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the buttons aimed at a card that no
// longer exists would read as the shelf having been deleted rather than moved.
SelectedGroup = VisibleGroups.FirstOrDefault(card => card.EntityId == landed);
Status = $"Moved '{row.Label}' to {name}.{WhatCameAlong(subtree.Count, filed.Count)}"
+ $"{WhatStayedBehind(uprooted, tagged)}{stranded}";
}).ConfigureAwait(true);
@@ -12188,16 +11827,6 @@ internal sealed partial class VaultViewModel(
partial void OnSelectedHostChanged(HostRowViewModel? value)
{
// One selection, across both grids. The two lists are drawn one above the other and they are marked
// the same way, so two lit cards read as two things chosen — and the buttons underneath them are two
// pairs, only one of which would act. Losing a selection leaves the other alone: a null here is what
// a filter matching nothing writes, and taking the mark off a group card because a search box
// emptied the grid beneath it would be this rule firing at something that is not a choice.
if (value is not null)
{
SelectedGroup = null;
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
@@ -12276,35 +11905,12 @@ internal sealed partial class VaultViewModel(
}
/// <remarks>
/// The other half of the shared selection; see <see cref="OnSelectedHostChanged"/>. Clearing the host
/// takes the drawer with it, and that is the point rather than a side effect: a pane about one machine
/// cannot go on standing beside a marked group, since nothing on it would be about what is selected.
/// </remarks>
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
{
if (value is not null)
{
SelectedHost = null;
}
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
}
/// <remarks>
/// <para>
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
/// from <see cref="OpenGroup"/>, and that command has already dropped the selection before assigning
/// here — see the property.
/// </para>
/// <para>
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
/// against <see cref="GroupTarget"/> rather than against the group itself: a reload hands this a new row
/// object for the group already open, and taking a question away from under somebody because the row
/// behind it was replaced is exactly what <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid.
/// </para>
/// The deletion is disarmed against <see cref="GroupTarget"/> rather than against the group itself: a
/// reload hands this a new row object for whatever <see cref="GroupFilter"/> names, and taking a question
/// away from under somebody because the row behind it was replaced is exactly what
/// <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid. <see cref="RebuildVisibleHosts"/> runs
/// after, since a filter narrowing the board is still live even though nothing sets it any more — see
/// <see cref="GroupFilter"/>'s own remarks.
/// </remarks>
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
{
@@ -12313,7 +11919,6 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(GroupTarget));
RebuildGroupLevel();
RebuildVisibleHosts();
}
@@ -33,8 +33,8 @@ namespace DodoSSH.Client.App.Layout.Tests;
/// editor or the chosen-hosts menu's "Change group…" rather than a drag. The card-navigation surface this
/// was written against — <c>OpenGroupCommand</c>, <c>VisibleGroups</c>, <c>GroupTrail</c>,
/// <c>SelectedGroup</c>, <c>GroupCrumbViewModel</c>, and the drag command <c>MoveHostToGroupCommand</c> dragged
/// onto — is no longer reached from either head's markup; <c>GroupFilter</c> stays, since group-heading
/// commands still fall back to it. Section-level
/// onto — is no longer on <c>VaultViewModel</c> at all, confirmed unreachable from both heads' markup before
/// removal; <c>GroupFilter</c> stays, since group-heading commands still fall back to it. Section-level
/// coverage — flattening order, the headerless invariant, per-section collapse, filter composition, monogram
/// stability — is in <c>HostSectionsTests</c> instead, which is where a heading now belongs.
/// </para>
@@ -1342,8 +1342,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
group.VaultId.ShouldBe(sharedVaultId, vault.Status);
group.VaultBadge.ShouldBe("PLATFORM SECRETS", "a card in a session holding two vaults says which");
vault.SelectedGroup = group;
vault.EditGroupCommand.Execute(null);
vault.EditGroupCommand.Execute(group);
vault.ShowsGroupEditorVaultChoice.ShouldBeFalse("an item cannot be moved between vaults");
@@ -1484,10 +1483,9 @@ public sealed class VaultSharingTests : IAsyncLifetime
production.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
production.Group.ParentId.ShouldNotBeNull("it was nested, which is what has to stay behind");
// As the card's menu does before it runs the command; see HostsScreen.OnGroupContextRequested.
vault.SelectedGroup = production;
vault.MoveGroupCommand.Execute(null);
// Passed directly, the way MoveGroupFromHeading resolves and hands in a row now that there are no
// group cards to select one from.
vault.MoveGroupCommand.Execute(production);
vault.IsMovingGroup.ShouldBeTrue(vault.Status);
vault.MoveGroupVaultChoices.ShouldNotContain(choice => choice.VaultId == production.VaultId);
@@ -1520,7 +1518,6 @@ public sealed class VaultSharingTests : IAsyncLifetime
// The group it was nested under is the one thing that stayed, and it stayed where it was.
Named(vault, "estate").VaultId.ShouldBe(production.VaultId);
vault.SelectedGroup?.EntityId.ShouldBe(moved.EntityId, "the buttons follow the group they moved");
vault.Status.ShouldContain("Platform secrets");
vault.Status.ShouldContain("top level");
}
@@ -1544,9 +1541,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.SelectedGroup = vault.Groups.ShouldHaveSingleItem();
vault.MoveGroupCommand.Execute(null);
vault.MoveGroupCommand.Execute(vault.Groups.ShouldHaveSingleItem());
vault.IsMovingGroup.ShouldBeFalse();
vault.MoveGroupVaultChoices.ShouldBeEmpty();
@@ -1655,66 +1650,13 @@ public sealed class VaultSharingTests : IAsyncLifetime
/// <remarks>
/// <para>
/// Dragging a host card onto a group card is the one gesture that files a host without opening its
/// editor, and it can now be aimed across a vault boundary, because both grids draw every readable
/// vault. The write it would make is the exact thing the host editor's group picker was fixed to
/// prevent: an id only the other vault's holders can resolve.
/// </para>
/// <para>
/// Refused and said so, rather than quietly treated as "no group" — the user is plainly filing
/// something, and unfiling it instead would be the wrong answer delivered silently.
/// </para>
/// </remarks>
[Fact]
public async Task AHostDraggedOntoAnotherVaultsGroup_IsRefusedRatherThanFiledUnderIt()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var vault = shell.Vault!;
var sharedVaultId = vaults.SelectedVault!.VaultId;
await vault.LoadAsync(Token);
vault.NewGroupCommand.Execute(null);
vault.GroupEditorSelectedVault =
vault.GroupEditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
// The host stays in the personal vault, which is where a new one goes without being told otherwise.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "prod-db";
vault.EditorHostname = "db.internal";
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal));
var group = vault.Groups.Single(row => row.VaultId == sharedVaultId);
host.VaultId.ShouldNotBe(sharedVaultId, "this test is meaningless with both in one vault");
await vault.MoveHostToGroupCommand.ExecuteAsync(new HostGroupMove(host, group.EntityId));
vault.Status.ShouldContain("its own vault");
vault.Hosts
.Single(row => string.Equals(row.Label, "prod-db", StringComparison.Ordinal))
.Host.GroupId
.ShouldBeNull("the host is left where it was rather than filed under an unresolvable group");
}
/// <remarks>
/// <para>
/// The same rule as the drag above, arrived at from the other end: a group lives in exactly one vault, so
/// + NEW HOST inside one has to open on that vault as well as on that group. The two defaults were
/// decided separately — the group came from the screen, the vault from the standing "new items go to"
/// preference — so pressing the button inside a shared vault's group opened a form bound for the personal
/// vault, with the group silently dropped by the picker that keeps the two in step.
/// A group lives in exactly one vault, so + NEW HOST inside one has to open on that vault as well as on
/// that group. The two defaults were decided separately — the group from <see cref="VaultViewModel.GroupTarget"/>,
/// the vault from the standing "new items go to" preference — so pressing the button inside a shared
/// vault's group opened a form bound for the personal vault, with the group silently dropped by the
/// picker that keeps the two in step. <c>GroupFilter</c> is written directly here rather than through
/// <c>OpenGroupCommand</c>, which left with the group cards it used to open — see that property's own
/// remarks.
/// </para>
/// <para>
/// The second half is the same test read backwards, and it is what says which of the two wins. The
@@ -1752,7 +1694,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
vault.GroupEditorLabel = "production";
await vault.SaveGroupCommand.ExecuteAsync(null);
vault.OpenGroupCommand.Execute(Named(vault, "production"));
vault.GroupFilter = Named(vault, "production");
vault.NewHostCommand.Execute(null);
@@ -1773,7 +1715,7 @@ public sealed class VaultSharingTests : IAsyncLifetime
// And backwards: the preference names the shared vault and the open group is in the personal one.
vault.SelectedTargetVault = vault.TargetVaults.Single(choice => choice.VaultId == sharedVaultId);
vault.OpenGroupCommand.Execute(Named(vault, "staging"));
vault.GroupFilter = Named(vault, "staging");
vault.NewHostCommand.Execute(null);