|
|
|
@@ -982,6 +982,27 @@ internal sealed record DeletionRequest(
|
|
|
|
|
{
|
|
|
|
|
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
|
|
|
|
|
internal bool HasUsage => Usage.Length > 0;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The second question this deletion has to ask, or empty where it has none.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Only a group has one, and it is the one deletion in this application whose scope is not decided by
|
|
|
|
|
/// what is being deleted: a group is a heading, and the machines under it may be the reason the heading
|
|
|
|
|
/// existed or may be forty perfectly good hosts that want a different shelf. Nothing here can tell which,
|
|
|
|
|
/// so it is asked — see <see cref="VaultViewModel.DeletionTakesTheHostsToo"/>, which is the answer.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// A sentence rather than a flag, because the card that draws it is shared by six kinds of deletion and
|
|
|
|
|
/// must not grow a branch per kind. Empty is "there is no second question", which is also what a group
|
|
|
|
|
/// with nothing filed under it gets.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal string Choice { get; init; } = string.Empty;
|
|
|
|
|
|
|
|
|
|
/// <summary>Whether this deletion has a second question to put.</summary>
|
|
|
|
|
internal bool HasChoice => Choice.Length > 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
@@ -1540,13 +1561,19 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// <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
|
|
|
|
|
/// decision that makes the thing shared, it cannot be changed <em>by saving</em>, 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>
|
|
|
|
|
/// Changed afterwards it can be, by <see cref="MoveGroup"/>, which is a separate act for the reason
|
|
|
|
|
/// moving a host is: it re-seals the group, everything nested inside it and every host filed under them
|
|
|
|
|
/// into a second vault's key, and nothing that happens as a side effect of pressing SAVE on a form should
|
|
|
|
|
/// be that.
|
|
|
|
|
/// </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>
|
|
|
|
@@ -1984,6 +2011,48 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
SelectedHost is { IsReadOnly: false } row
|
|
|
|
|
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Whether the panel asking which vault to move the group to is up.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The host's panel again — see <see cref="IsMovingHost"/> — in the one place a group can be acted on,
|
|
|
|
|
/// which is the GROUPS section of the hosts screen rather than the drawer. It takes that section's two
|
|
|
|
|
/// buttons the way the deletion question does, and for the same reason: a MOVE that could be pressed
|
|
|
|
|
/// again while its own picker was open would be a second panel about the same shelf.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(ShowsGroupActions))]
|
|
|
|
|
private bool isMovingGroup;
|
|
|
|
|
|
|
|
|
|
/// <summary>Which group the open move panel is about. Null when it is closed.</summary>
|
|
|
|
|
/// <inheritdoc cref="movingHostId" path="/remarks" />
|
|
|
|
|
private Guid? movingGroupId;
|
|
|
|
|
|
|
|
|
|
/// <summary>Where the group could be moved: every vault this session can write to but its own.</summary>
|
|
|
|
|
internal ObservableCollection<VaultChoiceViewModel> MoveGroupVaultChoices { get; } = [];
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private VaultChoiceViewModel? selectedMoveGroupVault;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Whether the group the buttons are aimed at has anywhere to move to.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Counted per group rather than asked once of the session, exactly as <see cref="CanMoveSelectedHost"/>
|
|
|
|
|
/// is: a group already in the only other writable vault has nowhere to go.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The read-only test is the group's own and not the whole subtree's. That is deliberate rather than
|
|
|
|
|
/// sloppy — this decides whether a button is worth drawing, and walking every descendant and every host
|
|
|
|
|
/// under them to answer it would be that walk on every selection. The subtree is checked once, where it
|
|
|
|
|
/// decides something, in <see cref="ConfirmMoveGroupAsync"/>.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal bool CanMoveGroupTarget =>
|
|
|
|
|
GroupTarget is { IsReadOnly: false } row
|
|
|
|
|
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// What the drawer's header says it is about.
|
|
|
|
|
/// </summary>
|
|
|
|
@@ -2004,10 +2073,12 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The vault's name and not a picker for it, although the design draws one with a chevron. An item
|
|
|
|
|
/// cannot be moved between vaults here — that is a delete and a retype, because the two are encrypted
|
|
|
|
|
/// under different keys — so a control offering the move would be offering something no layer below
|
|
|
|
|
/// this can do. Where a *new* item goes is chosen in the editor's own picker; see <see cref="TargetVaults"/>
|
|
|
|
|
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host and a
|
|
|
|
|
/// group can both be moved between vaults — see <see cref="MoveHost"/> and <see cref="MoveGroup"/> — but
|
|
|
|
|
/// not from here and not by saving: the two vaults are encrypted under different keys, so a move is a
|
|
|
|
|
/// re-seal into one and a tombstone in the other, and every item involved takes a new id. A chevron on a
|
|
|
|
|
/// subtitle implies an edit and would be describing something else. 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
|
|
|
|
@@ -2559,6 +2630,26 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private DeletionRequest? pendingDeletion;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The answer to <see cref="DeletionRequest.Choice"/>: whether a group's hosts go with it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Off is the answer that keeps the machines</b>, and it is off by default and reset to off on every
|
|
|
|
|
/// question — see <see cref="OnPendingDeletionChanged"/>. A tick left standing from the last group
|
|
|
|
|
/// deleted would delete forty hosts on behalf of somebody who was only tidying a heading away, and there
|
|
|
|
|
/// is no undo on either side of it.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// A tick rather than a pair of options, and deliberately not two equally weighted answers: they are not
|
|
|
|
|
/// equally weighted. Keeping the hosts is recoverable — they turn up under UNGROUPED and can be filed
|
|
|
|
|
/// again — and deleting them is not, so the safe answer is the one that needs no decision and the
|
|
|
|
|
/// destructive one is the one that has to be reached for.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private bool deletionTakesTheHostsToo;
|
|
|
|
|
|
|
|
|
|
internal bool IsConfirmingDeletion => PendingDeletion is not null;
|
|
|
|
|
|
|
|
|
|
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
|
|
|
|
@@ -2598,11 +2689,17 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
|
|
|
|
|
/// <summary>Whether the group panel's buttons are showing.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Hidden with nothing to act on as well as while the question is up. A pair of buttons over a grid of
|
|
|
|
|
/// cards nobody has chosen between is a pair whose subject the user has to work out, and the answer
|
|
|
|
|
/// would be "neither" — see <see cref="GroupTarget"/>.
|
|
|
|
|
/// Hidden with nothing to act on as well as while the question is up. A row of buttons over a grid of
|
|
|
|
|
/// cards nobody has chosen between is a row whose subject the user has to work out, and the answer
|
|
|
|
|
/// would be "none of them" — see <see cref="GroupTarget"/>.
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The move panel takes the row as well, on the same reasoning the host pane's menu hides for it: both
|
|
|
|
|
/// panels are drawn where the buttons were, and leaving them there would offer to open a panel that is
|
|
|
|
|
/// already open.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal bool ShowsGroupActions => GroupTarget is not null && !IsConfirmingGroupDeletion;
|
|
|
|
|
internal bool ShowsGroupActions =>
|
|
|
|
|
GroupTarget is not null && !IsConfirmingGroupDeletion && !IsMovingGroup;
|
|
|
|
|
|
|
|
|
|
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
|
|
|
|
|
internal bool HasGroups => Groups.Count > 0;
|
|
|
|
@@ -4768,9 +4865,10 @@ 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
|
|
|
|
|
// The group's own vault, and no save moves it — 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.
|
|
|
|
|
// copy behind rather than move anything. The picker is not drawn for an existing group at all, and
|
|
|
|
|
// the move that does work is MoveGroup, from the buttons over the cards.
|
|
|
|
|
editingGroupVaultId = row.VaultId;
|
|
|
|
|
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
|
|
|
|
|
|
|
|
|
@@ -4997,9 +5095,10 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// 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.
|
|
|
|
|
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> gives: a save cannot
|
|
|
|
|
/// move a group 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. Moving one is
|
|
|
|
|
/// <see cref="MoveGroup"/>, which does the re-seal and the tombstone this could not.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
partial void OnGroupEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
|
|
|
|
{
|
|
|
|
@@ -5103,12 +5202,28 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
Status = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Asks whether the group being acted on should go.</summary>
|
|
|
|
|
/// <summary>Asks whether the group being acted on should go, and what should become of its hosts.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
|
|
|
|
|
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
|
|
|
|
|
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
|
|
|
|
|
/// to move, and that none of them are going anywhere else.
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>The hosts are a second question rather than a stated consequence, and that is a reversal.</b> This
|
|
|
|
|
/// used to say what would happen to them — they stay, and turn up under UNGROUPED — because a group's
|
|
|
|
|
/// deletion did not touch them at all. That answer was right for one of the two things people delete a
|
|
|
|
|
/// group for and wrong for the other: a heading being tidied away should leave its machines alone, and a
|
|
|
|
|
/// project that has been decommissioned is a shelf and everything on it. Nothing here can tell which of
|
|
|
|
|
/// the two it is looking at, so it is asked. See <see cref="DeletionTakesTheHostsToo"/>.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The count is still the reason this asks rather than acting, and it now counts twice over: it is the
|
|
|
|
|
/// number of machines about to be unfiled, or — with the box ticked — the number about to be destroyed.
|
|
|
|
|
/// A group with nothing under it gets no second question and no tick, because there is nothing for
|
|
|
|
|
/// either to be about.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <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.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
|
|
|
|
|
/// <see cref="GroupTarget"/>.
|
|
|
|
@@ -5122,6 +5237,17 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only where it would write to a host. A heading with nothing under it is deleted with an editor
|
|
|
|
|
// open, because nothing on that form is about to be rewritten underneath it.
|
|
|
|
|
if (row.HostCount > 0 && AHostEditorIsInTheWay())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Disarms a move aimed at the same group, on the reasoning MoveHost uses in the other direction: two
|
|
|
|
|
// panels about one shelf, one of which destroys it, is not something to make anybody read carefully.
|
|
|
|
|
CancelMoveGroupCommand.Execute(null);
|
|
|
|
|
|
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
|
|
|
DeletionTarget.Group,
|
|
|
|
|
row.EntityId,
|
|
|
|
@@ -5136,13 +5262,52 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
row.HostCount switch
|
|
|
|
|
{
|
|
|
|
|
0 => string.Empty,
|
|
|
|
|
1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
|
|
|
|
|
_ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
|
|
|
|
|
});
|
|
|
|
|
1 => "1 host is filed under it. Left as it is, the host stays and moves to UNGROUPED.",
|
|
|
|
|
_ => $"{row.HostCount} hosts are filed under it. Left as it is, they stay and move to "
|
|
|
|
|
+ "UNGROUPED.",
|
|
|
|
|
})
|
|
|
|
|
{
|
|
|
|
|
Choice = row.HostCount switch
|
|
|
|
|
{
|
|
|
|
|
0 => string.Empty,
|
|
|
|
|
1 => "Delete the host filed under it as well.",
|
|
|
|
|
_ => $"Delete the {row.HostCount} hosts filed under it as well.",
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Queues a tombstone for the group that was agreed to.</summary>
|
|
|
|
|
private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Queues the tombstone for the group that was agreed to, and settles what pointed at it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Nothing is left pointing at the group.</b> That is the change: the hosts filed under it are
|
|
|
|
|
/// rewritten — unfiled, or deleted where that was asked for — and the groups nested inside it take its
|
|
|
|
|
/// place in the tree rather than being orphaned into roots. It costs one write per item and it is the
|
|
|
|
|
/// honest cost of the question above it. The dangling reference the list used to absorb is still handled
|
|
|
|
|
/// everywhere it is read, because a group deleted on <em>another</em> machine still arrives that way.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Everything that points at it is written before the group's own tombstone.</b> A crash in the middle
|
|
|
|
|
/// then leaves a heading standing over fewer things, which is visible and can simply be deleted again;
|
|
|
|
|
/// the other order leaves a group gone with its members still naming it, which is exactly the state this
|
|
|
|
|
/// exists to stop producing. It also matters against a merge: the group's tombstone can lose one, and if
|
|
|
|
|
/// it does, the unfiling has already been recorded on its own items rather than riding on it.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>A read-only host is skipped and counted rather than rewritten.</b> Unfiling it would re-encode a
|
|
|
|
|
/// payload this build cannot fully represent, which is the same refusal editing and moving one already
|
|
|
|
|
/// make — and the cost of skipping is a dangling id, which is the behaviour every reader here already
|
|
|
|
|
/// survives. Deleting one is <em>not</em> skipped: a tombstone re-encodes nothing.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
/// <param name="entityId">The group.</param>
|
|
|
|
|
/// <param name="takesTheHosts">Whether the hosts filed under it were agreed to go too.</param>
|
|
|
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
|
|
|
private async Task DeleteGroupNowAsync(
|
|
|
|
|
Guid entityId,
|
|
|
|
|
bool takesTheHosts,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
|
|
|
{
|
|
|
|
@@ -5150,10 +5315,24 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Its own vault's, both of them. A host in another vault naming this group is a reference the editor
|
|
|
|
|
// cannot make and the drop gesture refuses, so the only way to hold one is a hand-edited payload —
|
|
|
|
|
// and rewriting somebody else's vault because a group in this one went is worse than the dangle.
|
|
|
|
|
var filed = Hosts
|
|
|
|
|
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId == entityId)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
var nested = Groups
|
|
|
|
|
.Where(group => group.VaultId == row.VaultId && group.Group.ParentId == entityId)
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
await RunAsync(
|
|
|
|
|
"Deleting…",
|
|
|
|
|
async () =>
|
|
|
|
|
{
|
|
|
|
|
var skipped = await ReleaseWhatPointedAtAsync(row, filed, nested, takesTheHosts, cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
@@ -5168,12 +5347,91 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
|
Status = $"Deleted the group '{row.Label}'.";
|
|
|
|
|
|
|
|
|
|
Status = $"Deleted the group '{row.Label}'."
|
|
|
|
|
+ WhatBecameOfTheHosts(filed.Count, takesTheHosts, skipped);
|
|
|
|
|
}).ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Rewrites everything naming the group about to go, and says how many could not be.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Before the tombstone and in this order, which <see cref="DeleteGroupNowAsync"/> gives the reasons for.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
/// <returns>How many hosts were left holding the reference because they could not be rewritten.</returns>
|
|
|
|
|
private async Task<int> ReleaseWhatPointedAtAsync(
|
|
|
|
|
HostGroupRowViewModel row,
|
|
|
|
|
IEnumerable<HostRowViewModel> filed,
|
|
|
|
|
IEnumerable<HostGroupRowViewModel> nested,
|
|
|
|
|
bool takesTheHosts,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var skipped = 0;
|
|
|
|
|
|
|
|
|
|
foreach (var host in filed)
|
|
|
|
|
{
|
|
|
|
|
if (takesTheHosts)
|
|
|
|
|
{
|
|
|
|
|
await session.Hosts
|
|
|
|
|
.DeleteAsync(host.VaultId, host.EntityId, cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (host.IsReadOnly)
|
|
|
|
|
{
|
|
|
|
|
skipped++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await session.Hosts
|
|
|
|
|
.UpdateAsync(
|
|
|
|
|
host.VaultId,
|
|
|
|
|
host.EntityId,
|
|
|
|
|
host.Host with { GroupId = null },
|
|
|
|
|
cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Promoted to where the group they were under sat, rather than to the top level. A nested group whose
|
|
|
|
|
// parent goes has not been moved by anybody, and dropping it to a root would rearrange a tree on a
|
|
|
|
|
// delete that was about one heading.
|
|
|
|
|
foreach (var child in nested.Where(child => !child.IsReadOnly))
|
|
|
|
|
{
|
|
|
|
|
await session.HostGroups
|
|
|
|
|
.UpdateAsync(
|
|
|
|
|
child.VaultId,
|
|
|
|
|
child.EntityId,
|
|
|
|
|
child.Group with { ParentId = row.Group.ParentId },
|
|
|
|
|
cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return skipped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>What the group's deletion did to the machines under it, said only when there were any.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The skipped count is named rather than folded into the total, because those hosts are the ones still
|
|
|
|
|
/// holding the deleted group's id: they show up under UNGROUPED like the rest, so nothing looks wrong,
|
|
|
|
|
/// and the sentence is the only place anybody is told that updating this client is what finishes the job.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private static string WhatBecameOfTheHosts(int filed, bool takesTheHosts, int skipped) =>
|
|
|
|
|
(filed, takesTheHosts, skipped) switch
|
|
|
|
|
{
|
|
|
|
|
(0, _, _) => string.Empty,
|
|
|
|
|
(1, true, _) => " Its host went with it.",
|
|
|
|
|
(_, true, _) => $" Its {filed} hosts went with it.",
|
|
|
|
|
(1, false, 0) => " Its host moved to UNGROUPED.",
|
|
|
|
|
(_, false, 0) => $" Its {filed} hosts moved to UNGROUPED.",
|
|
|
|
|
_ => $" Its {filed} hosts moved to UNGROUPED, except {skipped} written by a newer version of "
|
|
|
|
|
+ "DodoSSH — those still name the group that has gone. Update to unfile them.",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>Abandons the editor.</summary>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void CancelEdit()
|
|
|
|
@@ -5457,11 +5715,7 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
{
|
|
|
|
|
MoveVaultChoices.Clear();
|
|
|
|
|
|
|
|
|
|
foreach (var choice in session.ReadableVaults
|
|
|
|
|
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
|
|
|
|
|
.OrderByDescending(vault => vault.IsPersonal)
|
|
|
|
|
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
|
|
|
|
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)))
|
|
|
|
|
foreach (var choice in WritableVaultsBesides(vaultId))
|
|
|
|
|
{
|
|
|
|
|
MoveVaultChoices.Add(choice);
|
|
|
|
|
}
|
|
|
|
@@ -5469,6 +5723,357 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Every vault this session can write to except one, in the order a picker should offer them.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Shared by the host's picker and the group's rather than written twice. The order is the one every
|
|
|
|
|
/// vault picker in this application uses: your own first, then the shared ones by name — a list that
|
|
|
|
|
/// re-sorted itself per control would make "the second entry" mean something different on each.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private IEnumerable<VaultChoiceViewModel> WritableVaultsBesides(Guid vaultId) =>
|
|
|
|
|
session.ReadableVaults
|
|
|
|
|
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
|
|
|
|
|
.OrderByDescending(vault => vault.IsPersonal)
|
|
|
|
|
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
|
|
|
|
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Opens the panel that asks which vault the group — and everything under it — should move to.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The host's panel, one level up, and the reason it exists at all is that the host's did not go far
|
|
|
|
|
/// enough. Moving twenty machines into a shared vault one at a time meant twenty round trips through a
|
|
|
|
|
/// menu, and each one arrived stripped of the group it had been filed under, so the shelf had to be
|
|
|
|
|
/// rebuilt by hand on the other side. Moving the shelf is the operation people were actually attempting.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
|
|
|
|
|
/// as <see cref="DeleteGroup"/> is: this rewrites hosts.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void MoveGroup()
|
|
|
|
|
{
|
|
|
|
|
if (GroupTarget is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.IsReadOnly)
|
|
|
|
|
{
|
|
|
|
|
Status = "This group was written by a newer version of DodoSSH. Moving it would re-encode it "
|
|
|
|
|
+ "here and lose what this build cannot read. Update first.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
BuildMoveGroupVaultChoices(row.VaultId);
|
|
|
|
|
|
|
|
|
|
if (MoveGroupVaultChoices.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// As MoveHost disarms a deletion aimed at the same host.
|
|
|
|
|
PendingDeletion = null;
|
|
|
|
|
movingGroupId = row.EntityId;
|
|
|
|
|
IsMovingGroup = true;
|
|
|
|
|
Status = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Abandons the group's move panel.</summary>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void CancelMoveGroup()
|
|
|
|
|
{
|
|
|
|
|
if (!IsMovingGroup)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
IsMovingGroup = false;
|
|
|
|
|
movingGroupId = null;
|
|
|
|
|
MoveGroupVaultChoices.Clear();
|
|
|
|
|
SelectedMoveGroupVault = null;
|
|
|
|
|
Status = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Moves the group, everything nested inside it and every host filed under any of them.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>The whole subtree goes, and taking less than that was never coherent.</b> A group's children are
|
|
|
|
|
/// items of the vault it is leaving: move the parent alone and they are left naming a tombstone, so they
|
|
|
|
|
/// surface as roots in the vault the user has just emptied — half a shelf here and half there, from one
|
|
|
|
|
/// gesture that said "move this". The hosts are the same argument and are the half the user asked about.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>The groups go first, top down, and the hosts last.</b> Each item is re-sealed under the
|
|
|
|
|
/// destination's key and takes a new id — see <c>VaultItemRepository.MoveAsync</c> — so nothing that
|
|
|
|
|
/// points at a group can be written until that group has landed and its new id is known. Top down for
|
|
|
|
|
/// the same reason one level up: a child's parent must already be over there. What an interruption
|
|
|
|
|
/// leaves is therefore hosts still in the vault they started in, under UNGROUPED, which is visible and
|
|
|
|
|
/// re-movable; the reverse order would leave hosts in the destination filed under nothing.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <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.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Keys and passwords are kept</b>, on the host and on the group's own defaults, because those do
|
|
|
|
|
/// genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they
|
|
|
|
|
/// exist for. What is reported is a binding now outside the destination, since that is precisely what
|
|
|
|
|
/// the other holders of it will not be able to resolve.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
if (GroupTarget is not { } row
|
|
|
|
|
|| movingGroupId != row.EntityId
|
|
|
|
|
|| SelectedMoveGroupVault is not { } target)
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var subtree = SubtreeOf(row);
|
|
|
|
|
var moving = subtree.Select(group => group.EntityId).ToHashSet();
|
|
|
|
|
|
|
|
|
|
var filed = Hosts
|
|
|
|
|
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId is { } id && moving.Contains(id))
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
// Asked once, over everything that is about to be re-encoded, and refused as a whole rather than
|
|
|
|
|
// half-done: a move that skipped the items it could not represent would file some of the shelf in one
|
|
|
|
|
// vault and leave the rest in the other, which is the state this operation exists to prevent.
|
|
|
|
|
if (subtree.Any(group => group.IsReadOnly) || filed.Any(host => host.IsReadOnly))
|
|
|
|
|
{
|
|
|
|
|
Status = "Something under this group was written by a newer version of DodoSSH. Moving it would "
|
|
|
|
|
+ "re-encode it here and lose what this build cannot read. Update first.";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var name = target.Name;
|
|
|
|
|
var stranded = BindingsOutside(subtree, filed, target.VaultId);
|
|
|
|
|
var uprooted = row.Group.ParentId is not null;
|
|
|
|
|
var tagged = filed.Count(host => host.Host.TagIds.Count > 0);
|
|
|
|
|
|
|
|
|
|
CancelMoveGroupCommand.Execute(null);
|
|
|
|
|
|
|
|
|
|
await RunAsync(
|
|
|
|
|
"Moving…",
|
|
|
|
|
async () =>
|
|
|
|
|
{
|
|
|
|
|
var landed = 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);
|
|
|
|
|
|
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Re-seals a group, its nested groups and their hosts under another vault's key.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The order and what it costs an interruption are <see cref="ConfirmMoveGroupAsync"/>'s to explain. What
|
|
|
|
|
/// lives here is the id map every write after the first depends on: each item lands with an id of the
|
|
|
|
|
/// destination's making, so a parent's is looked up rather than reused, and a host's group is the entry
|
|
|
|
|
/// its old group left behind.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
/// <param name="subtree">The group and its nested groups, each after its parent.</param>
|
|
|
|
|
/// <param name="filed">The hosts under any of them.</param>
|
|
|
|
|
/// <param name="vaultId">The vault they are all going to.</param>
|
|
|
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
|
|
|
/// <returns>The id the group at the root of it has in its new vault.</returns>
|
|
|
|
|
private async Task<Guid> ReSealTheSubtreeAsync(
|
|
|
|
|
List<HostGroupRowViewModel> subtree,
|
|
|
|
|
IEnumerable<HostRowViewModel> filed,
|
|
|
|
|
Guid vaultId,
|
|
|
|
|
CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var root = subtree[0].EntityId;
|
|
|
|
|
var remapped = new Dictionary<Guid, Guid>();
|
|
|
|
|
|
|
|
|
|
foreach (var group in subtree)
|
|
|
|
|
{
|
|
|
|
|
// Null for the root, whose parent is staying behind in the vault it came from; every other group
|
|
|
|
|
// in the list was discovered by its own parent, so that parent has already landed.
|
|
|
|
|
var parent = group.EntityId == root
|
|
|
|
|
? (Guid?)null
|
|
|
|
|
: remapped[group.Group.ParentId!.Value];
|
|
|
|
|
|
|
|
|
|
remapped[group.EntityId] = await session.HostGroups
|
|
|
|
|
.MoveAsync(
|
|
|
|
|
group.VaultId,
|
|
|
|
|
vaultId,
|
|
|
|
|
group.EntityId,
|
|
|
|
|
group.Group with { ParentId = parent },
|
|
|
|
|
cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var host in filed)
|
|
|
|
|
{
|
|
|
|
|
await session.Hosts
|
|
|
|
|
.MoveAsync(
|
|
|
|
|
host.VaultId,
|
|
|
|
|
vaultId,
|
|
|
|
|
host.EntityId,
|
|
|
|
|
host.Host with
|
|
|
|
|
{
|
|
|
|
|
GroupId = remapped[host.Host.GroupId!.Value],
|
|
|
|
|
TagIds = TagSet.Empty,
|
|
|
|
|
},
|
|
|
|
|
cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return remapped[root];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The group and every group nested under it, each one after the group it hangs from.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// A breadth-first walk from the root, and the order is the whole reason it is one: every group but the
|
|
|
|
|
/// first is discovered <em>by</em> its parent, so a caller writing the list in order always has the
|
|
|
|
|
/// parent's new id in hand before it needs it.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Its own vault's only, because a child in another vault is a level half the readers cannot resolve and
|
|
|
|
|
/// the editor refuses to make one.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Cycle-safe, and it has to be.</b> A group already found is not walked again and not added twice —
|
|
|
|
|
/// the same visited set every walk over this tree carries, for the reason
|
|
|
|
|
/// <see cref="HostGroupSecret.ParentId"/> gives: two offline clients can each re-parent one group under
|
|
|
|
|
/// the other, and the pair that results was never shown to an editor. Without the set, a group that is
|
|
|
|
|
/// its own parent would be appended to this list for as long as there was memory to append to.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private List<HostGroupRowViewModel> SubtreeOf(HostGroupRowViewModel root)
|
|
|
|
|
{
|
|
|
|
|
var ordered = new List<HostGroupRowViewModel> { root };
|
|
|
|
|
var found = new HashSet<Guid> { root.EntityId };
|
|
|
|
|
|
|
|
|
|
for (var index = 0; index < ordered.Count; index++)
|
|
|
|
|
{
|
|
|
|
|
var parent = ordered[index].EntityId;
|
|
|
|
|
|
|
|
|
|
foreach (var child in Groups.Where(
|
|
|
|
|
group => group.VaultId == root.VaultId && group.Group.ParentId == parent))
|
|
|
|
|
{
|
|
|
|
|
if (found.Add(child.EntityId))
|
|
|
|
|
{
|
|
|
|
|
ordered.Add(child);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ordered;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>What the move brought with it, said as the counts the user can check against the cards.</summary>
|
|
|
|
|
private static string WhatCameAlong(int groups, int hosts)
|
|
|
|
|
{
|
|
|
|
|
var nested = groups switch
|
|
|
|
|
{
|
|
|
|
|
1 => string.Empty,
|
|
|
|
|
2 => " with the group inside it",
|
|
|
|
|
_ => $" with the {groups - 1} groups inside it",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return hosts switch
|
|
|
|
|
{
|
|
|
|
|
0 when groups == 1 => string.Empty,
|
|
|
|
|
0 => $" It went{nested}, and no hosts were filed under any of them.",
|
|
|
|
|
1 => $" Its host came too{nested}.",
|
|
|
|
|
_ => $" Its {hosts} hosts came too{nested}.",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The two things a group cannot take across, said only where it had one.</summary>
|
|
|
|
|
private static string WhatStayedBehind(bool uprooted, int tagged) =>
|
|
|
|
|
(uprooted, tagged > 0) switch
|
|
|
|
|
{
|
|
|
|
|
(true, true) => " The group it was nested under stayed behind and the hosts' tags were dropped —"
|
|
|
|
|
+ " both belong to the vault it came from, so it now sits at the top level.",
|
|
|
|
|
(true, false) => " The group it was nested under stayed behind — a parent belongs to the vault it"
|
|
|
|
|
+ " is in — so it now sits at the top level.",
|
|
|
|
|
(false, true) => " The hosts' tags were left behind — a tag belongs to the vault it is in.",
|
|
|
|
|
_ => string.Empty,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The warning about keys or passwords that are not in the vault the group has moved to.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Counted and named to one, where <see cref="BindingOutside"/> names the only one there can be. A shelf
|
|
|
|
|
/// of twenty machines may strand five different keys, and twenty sentences is not a status line — but a
|
|
|
|
|
/// bare number is not actionable either, so the first is named and the rest are counted. Both the hosts'
|
|
|
|
|
/// own bindings and the groups' defaults are asked, because a group that lends a key is the one case
|
|
|
|
|
/// where a machine can be unable to connect without naming anything itself.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string BindingsOutside(
|
|
|
|
|
IEnumerable<HostGroupRowViewModel> groups,
|
|
|
|
|
IEnumerable<HostRowViewModel> hosts,
|
|
|
|
|
Guid vaultId)
|
|
|
|
|
{
|
|
|
|
|
var stranded = groups
|
|
|
|
|
.SelectMany(group => new[] { group.Group.DefaultSshKeyId, group.Group.DefaultCredentialId })
|
|
|
|
|
.Concat(hosts.SelectMany(host => new[] { host.Host.SshKeyId, host.Host.CredentialId }))
|
|
|
|
|
.OfType<Guid>()
|
|
|
|
|
.Distinct()
|
|
|
|
|
.Select(LabelOfBindingOutside)
|
|
|
|
|
.OfType<string>()
|
|
|
|
|
.ToList();
|
|
|
|
|
|
|
|
|
|
return stranded.Count switch
|
|
|
|
|
{
|
|
|
|
|
0 => string.Empty,
|
|
|
|
|
1 => $" It still authenticates with '{stranded[0]}', which is in another vault — everybody else "
|
|
|
|
|
+ "in this one will find that binding unresolvable.",
|
|
|
|
|
_ => $" It still authenticates with '{stranded[0]}' and {stranded.Count - 1} other key(s) or "
|
|
|
|
|
+ "password(s) in other vaults — everybody else in this one will find those bindings "
|
|
|
|
|
+ "unresolvable.",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
string? LabelOfBindingOutside(Guid entityId) =>
|
|
|
|
|
(Keys.FirstOrDefault(key => key.EntityId == entityId) is { } key && key.VaultId != vaultId
|
|
|
|
|
? key.Label
|
|
|
|
|
: null)
|
|
|
|
|
?? (Credentials.FirstOrDefault(row => row.EntityId == entityId) is { } credential
|
|
|
|
|
&& credential.VaultId != vaultId
|
|
|
|
|
? credential.Label
|
|
|
|
|
: null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Fills the group move panel's picker with every vault this session can write to but that one.</summary>
|
|
|
|
|
private void BuildMoveGroupVaultChoices(Guid vaultId)
|
|
|
|
|
{
|
|
|
|
|
MoveGroupVaultChoices.Clear();
|
|
|
|
|
|
|
|
|
|
foreach (var choice in WritableVaultsBesides(vaultId))
|
|
|
|
|
{
|
|
|
|
|
MoveGroupVaultChoices.Add(choice);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Asks whether the selected host should go.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
|
|
|
@@ -6314,6 +6919,10 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Read before the disarming below, which resets it. The answer belongs to the question that was on
|
|
|
|
|
// screen, and taking it afterwards would read whatever the next question starts from.
|
|
|
|
|
var takesTheHosts = DeletionTakesTheHostsToo;
|
|
|
|
|
|
|
|
|
|
PendingDeletion = null;
|
|
|
|
|
|
|
|
|
|
switch (request.Target)
|
|
|
|
@@ -6331,7 +6940,8 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case DeletionTarget.Group:
|
|
|
|
|
await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
|
|
|
await DeleteGroupNowAsync(request.EntityId, takesTheHosts, cancellationToken)
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case DeletionTarget.ObjectStore:
|
|
|
|
@@ -8065,9 +8675,11 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
|
|
|
|
CloseTheGroupMovePanelIfAimedElsewhere();
|
|
|
|
|
|
|
|
|
|
OnPropertyChanged(nameof(GroupTarget));
|
|
|
|
|
OnPropertyChanged(nameof(ShowsGroupActions));
|
|
|
|
|
OnPropertyChanged(nameof(CanMoveGroupTarget));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
@@ -8086,16 +8698,38 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
|
|
|
|
{
|
|
|
|
|
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
|
|
|
|
CloseTheGroupMovePanelIfAimedElsewhere();
|
|
|
|
|
|
|
|
|
|
OnPropertyChanged(nameof(GroupTarget));
|
|
|
|
|
OnPropertyChanged(nameof(ShowsGroupActions));
|
|
|
|
|
OnPropertyChanged(nameof(CanMoveGroupTarget));
|
|
|
|
|
|
|
|
|
|
RebuildGroupLevel();
|
|
|
|
|
RebuildVisibleHosts();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Folds the group's move panel away once the buttons under it point at something else.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// By entity id and not by row, for the reason <see cref="DisarmIfAimedElsewhere"/> compares ids: every
|
|
|
|
|
/// row object in the list is replaced on every reload, so a panel closed on row identity would fold up
|
|
|
|
|
/// once a minute under somebody who was still choosing a vault in it. It is cleared rather than re-aimed,
|
|
|
|
|
/// because which vault to move to is a choice about the shelf it was asked for.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private void CloseTheGroupMovePanelIfAimedElsewhere()
|
|
|
|
|
{
|
|
|
|
|
if (IsMovingGroup && movingGroupId != GroupTarget?.EntityId)
|
|
|
|
|
{
|
|
|
|
|
CancelMoveGroupCommand.Execute(null);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
|
|
|
|
{
|
|
|
|
|
// Back to "keep them" on every question, including the one that disarms it. A tick is the answer to
|
|
|
|
|
// the group that was named in the sentence above it and to nothing else; carried into the next
|
|
|
|
|
// question it would delete a second group's machines on the strength of a decision about the first.
|
|
|
|
|
DeletionTakesTheHostsToo = false;
|
|
|
|
|
|
|
|
|
|
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
|
|
|
|
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
|
|
|
|
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
|
|
|
|