Public Access
Let a snippet be shared to a vault, the way a host already can
A snippet was a first-class vault item everywhere except where it mattered: the crypto, the sync, the server table and every registry already treated it exactly as they treat a host, and the screen read it out of the active vault alone. So the one command a team most obviously wants to hold in common — the incantation somebody worked out once and everybody else retypes — was the only item kind that could not leave the machine that wrote it. The read is the half that had to come first, and it is why this is not simply a MoveAsync. ReloadSnippetsAsync now lists every readable vault rather than the active one, in the shape ReloadHostsAsync and ReloadKeysAsync already use: the vault new items go into first, then by vault name, then by label, with a badge on the row only where there is more than one vault to tell apart. Without that, a snippet moved into a team vault would have disappeared from the very screen that moved it, and one a colleague wrote there would never have arrived at all — sharing would have looked like losing. Three writes were pinned to the active vault and each one broke differently once the list spanned several. The delete tombstoned in the wrong vault, which tombstones nothing and leaves the snippet on screen. The save is the bad one: an update sent to the active vault creates a second snippet there and leaves the team original untouched, so the person editing sees their fix and nobody else ever does. That is a fork with no symptom, which is why the vault is now a parameter and the screen latches it when the editor opens — the chosen vault for a new snippet, the row own vault for an existing one — rather than reading it back off a selection that can move under a half-typed form. VaultViewModel has carried editingHostVaultId for the same reason since hosts crossed vaults. Two controls rather than one, and that is the same line the host pane draws. The editor asks which vault a new snippet is filed into; MOVE re-seals an existing one under another key and tombstones the first. Putting the second inside the first would let somebody correcting a typo hand a command to a team by leaving a picker where they found it, so the picker is not drawn for an existing snippet at all. Both live on SnippetsViewModel rather than VaultViewModel because this screen owns its editor, unlike the host drawer; the writing they ask for is still the vault. A snippet crosses whole, which is the one way this is simpler than the host it copies. A host leaves its group and its tags behind because both are items of the vault it came from and would dangle for everybody in the destination. A snippet is a label, a command and a note, and none of them points at anything — so there is nothing to strip, nothing to report as left behind, and what the copy says instead is the thing that is actually at stake: who can read the command afterwards. For a command that may carry a hostname or a path, that is the whole decision. Two judgement calls worth finding later. A hidden vault now hides its snippets, filtered in the screen projection rather than in VaultViewModel.Snippets, which is the rule keys and passwords already follow: the list stays whole so nothing that resolves against it breaks, and the projection is what a preference about reading gets to change. And the nav rail count is left spanning vaults unfiltered, because Vault.Hosts.Count beside it is unfiltered too — filtering one of the four would make the rail disagree with itself. Four flow tests in VaultSharingTests, beside the host ones they mirror: the move re-seals with a new id and carries the runs-on-insert flag across, the move with nowhere to go refuses rather than opening an empty picker, the editor files into the vault chosen on it, and the edit of a shared snippet goes back to its own vault instead of forking. That last one is the regression the latch exists for and the only one whose absence has no visible symptom. Plus a layout test with the move panel open, since that paragraph wraps in a 300-pixel column and the desktop pane it lands in is measured. The whole suite passes: 1660 tests, none failing.
This commit is contained in:
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
|
||||
/// editor asks which vault a <em>new</em> snippet goes into, and the move panel re-seals an existing one into
|
||||
/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
|
||||
/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
|
||||
/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
|
||||
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
|
||||
/// ask for is still the vault's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
{
|
||||
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool HasSnippets => vault.Snippets.Count > 0;
|
||||
/// <summary>
|
||||
/// The vault the open editor will write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
|
||||
/// existing one — rather than read back off the selection at save time. The list this screen shows spans
|
||||
/// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
|
||||
/// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
|
||||
/// whichever row happened to be highlighted. The same reason <c>VaultViewModel.editingHostVaultId</c>
|
||||
/// exists.
|
||||
/// </remarks>
|
||||
private Guid editorVaultId;
|
||||
|
||||
/// <summary>Which vault the open move panel would send the snippet to.</summary>
|
||||
private SnippetRowViewModel? moving;
|
||||
|
||||
/// <summary>
|
||||
/// The vaults a new snippet may be filed into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filled from <c>VaultViewModel.TargetVaults</c>, which is already the readable-and-writable set: a
|
||||
/// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
|
||||
/// team vault this account is a viewer of. The options are the shared objects rather than copies, so
|
||||
/// this picker and the keychain screen's show the same names without either being able to move the
|
||||
/// other — what they do not share is the selection.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? editorSelectedVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor should be asking which vault this snippet goes into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating, and only where there is more than one vault to choose between. An existing
|
||||
/// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
|
||||
/// save, offered by <see cref="Move"/> — and a control offering one option is a question nobody was
|
||||
/// asked.
|
||||
/// </remarks>
|
||||
internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
|
||||
|
||||
/// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
|
||||
/// <remarks>
|
||||
/// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
|
||||
/// the question is not "are you sure" but "which vault".
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
|
||||
private bool isMoving;
|
||||
|
||||
/// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
|
||||
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private VaultChoiceViewModel? selectedMoveVault;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected snippet has anywhere to move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
|
||||
/// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
|
||||
/// answer is per snippet: one already in the only other writable vault has nowhere to go.
|
||||
/// </remarks>
|
||||
internal bool CanMove =>
|
||||
Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
|
||||
|
||||
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
|
||||
/// <remarks>
|
||||
/// Off while the move panel is up, which takes their place — the same rule the host pane's
|
||||
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
|
||||
/// otherwise still be there offering to open it again.
|
||||
/// </remarks>
|
||||
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
|
||||
|
||||
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
|
||||
/// <remarks>
|
||||
/// For the detail pane, which is where somebody decides whether to insert a command into a production
|
||||
/// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
|
||||
/// is being read.
|
||||
/// </remarks>
|
||||
internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
|
||||
|
||||
/// <summary>Whether there is a vault to name beside the selected snippet.</summary>
|
||||
internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
|
||||
|
||||
/// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
|
||||
/// <remarks>
|
||||
/// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
|
||||
/// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
|
||||
/// nothing.
|
||||
/// </remarks>
|
||||
internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));
|
||||
|
||||
internal bool HasVisible => Visible.Count > 0;
|
||||
|
||||
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
+ "typing it again.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
/// <remarks>
|
||||
/// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
|
||||
/// unless that has been changed — because a snippet put in a team's vault is a command everybody in
|
||||
/// that team can read, and that has to be chosen rather than defaulted into.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void New()
|
||||
{
|
||||
CloseMovePanel();
|
||||
|
||||
EditingId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorCommand = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRunsOnInsert = false;
|
||||
BuildEditorVaultChoices(vault.TargetVaultId);
|
||||
IsEditing = true;
|
||||
Status = "Adding a snippet.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
/// <remarks>
|
||||
/// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
|
||||
/// is not drawn for an existing snippet: its vault is not a field of this form.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
return;
|
||||
}
|
||||
|
||||
CloseMovePanel();
|
||||
|
||||
EditingId = row.EntityId;
|
||||
EditorLabel = row.Snippet.Label;
|
||||
EditorCommand = row.Snippet.Command;
|
||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||
BuildEditorVaultChoices(row.VaultId);
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
RunsOnInsert = EditorRunsOnInsert,
|
||||
};
|
||||
|
||||
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
|
||||
var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the panel that asks which vault the selected snippet should move to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
|
||||
/// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
|
||||
/// tombstone in the source — see <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
|
||||
/// side effect of saving a corrected typo.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
|
||||
/// forms about the same snippet, one of which moves it, is not something anybody should have to read
|
||||
/// carefully.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void Move()
|
||||
{
|
||||
if (Selected is not { } row || IsEditing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet 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 choices = vault.MoveTargetsBesides(row.VaultId);
|
||||
|
||||
if (choices.Count == 0)
|
||||
{
|
||||
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
|
||||
// somebody in a team whose only other vault is read-only sees.
|
||||
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
|
||||
return;
|
||||
}
|
||||
|
||||
MoveVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in choices)
|
||||
{
|
||||
MoveVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
SelectedMoveVault = MoveVaultChoices[0];
|
||||
moving = row;
|
||||
IsMoving = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the move panel.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelMove()
|
||||
{
|
||||
CloseMovePanel();
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the selected snippet into the chosen vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The snippet crosses whole.</b> Nothing on it points at an item of the vault it is leaving — a
|
||||
/// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
|
||||
/// and nothing to report as left behind.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
|
||||
/// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
|
||||
/// snippet having been deleted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (moving is not { } row || SelectedMoveVault is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CloseMovePanel();
|
||||
|
||||
var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The reload inside the move refilled the list, which rebuilt this one and dropped a selection
|
||||
// keyed on an id that no longer exists.
|
||||
if (moved is { } entityId)
|
||||
{
|
||||
Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
|
||||
}
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
/// <remarks>
|
||||
/// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
|
||||
/// matching the snippet would leave a picker on screen aimed at a row nobody can see.
|
||||
/// </remarks>
|
||||
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||
{
|
||||
if (IsMoving && value?.EntityId != moving?.EntityId)
|
||||
{
|
||||
CloseMovePanel();
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
OnPropertyChanged(nameof(CanMove));
|
||||
OnPropertyChanged(nameof(ShowsSelectionActions));
|
||||
OnPropertyChanged(nameof(SelectionVaultBadge));
|
||||
OnPropertyChanged(nameof(HasSelectionVaultBadge));
|
||||
}
|
||||
|
||||
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
|
||||
partial void OnEditingIdChanged(Guid? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsCreating));
|
||||
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves a half-typed snippet into the vault just chosen for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
|
||||
/// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — but not this
|
||||
/// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
|
||||
/// vault and leave the original behind, which is a fork rather than a move.
|
||||
/// </remarks>
|
||||
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||||
{
|
||||
if (value is null || EditingId is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editorVaultId = value.VaultId;
|
||||
}
|
||||
|
||||
/// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
|
||||
private void BuildEditorVaultChoices(Guid vaultId)
|
||||
{
|
||||
editorVaultId = vaultId;
|
||||
|
||||
EditorVaultChoices.Clear();
|
||||
|
||||
foreach (var choice in vault.TargetVaults)
|
||||
{
|
||||
EditorVaultChoices.Add(choice);
|
||||
}
|
||||
|
||||
// Null where the snippet's vault is one this session cannot write — a team vault this account is a
|
||||
// viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
|
||||
// answer than an option that would move the snippet if it were touched.
|
||||
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
|
||||
|
||||
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
|
||||
}
|
||||
|
||||
private void CloseMovePanel()
|
||||
{
|
||||
IsMoving = false;
|
||||
moving = null;
|
||||
MoveVaultChoices.Clear();
|
||||
SelectedMoveVault = null;
|
||||
}
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
internal bool IsCreating => EditingId is null;
|
||||
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasSnippets));
|
||||
OnPropertyChanged(nameof(HasVisible));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
OnPropertyChanged(nameof(CanMove));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
||||
/// about a saved command is a word that was in it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, which is
|
||||
/// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
|
||||
/// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
if (!vault.IsVaultShown(row.VaultId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
|
||||
@@ -151,10 +151,31 @@ internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
|
||||
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
|
||||
/// per click.
|
||||
/// </remarks>
|
||||
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||||
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet, Guid vaultId, string vaultName)
|
||||
{
|
||||
internal Guid EntityId => snippet.EntityId;
|
||||
|
||||
/// <summary>Which vault this snippet lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
/// <remarks>
|
||||
/// What makes a snippet shareable rather than private. The list spans every readable vault now, so an
|
||||
/// edit and a deletion both have to return to the vault the snippet came out of — saving a team's
|
||||
/// snippet into the active vault instead would leave the original untouched and put a second copy
|
||||
/// somewhere only the person editing it can see.
|
||||
/// </remarks>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
/// <summary>
|
||||
/// The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
internal SnippetSecret Snippet => snippet.Secret;
|
||||
|
||||
internal string Label => snippet.Secret.Label;
|
||||
@@ -3285,37 +3306,79 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <returns>How many snippets would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
|
||||
/// list changing the way every other screen does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every readable vault, not the active one, which is what makes a snippet shareable.</b> The read is
|
||||
/// the half that has to come first: a snippet moved into a team's vault by the machine that owns it would
|
||||
/// otherwise vanish from the list that moved it, and one a colleague wrote there would never appear at
|
||||
/// all — sharing would look like losing. The same shape as <see cref="ReloadHostsAsync"/> and
|
||||
/// <see cref="ReloadKeysAsync"/>, down to the ordering: the vault new items go into first, then by vault
|
||||
/// name, then by label, because two vaults may hold a snippet called the same thing and which vault it
|
||||
/// is in is the only thing that tells them apart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Snippets
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var unreadable = 0;
|
||||
var rows = new List<SnippetRowViewModel>();
|
||||
|
||||
var readable = session.ReadableVaults.ToList();
|
||||
var several = readable.Count > 1;
|
||||
|
||||
foreach (var vault in readable)
|
||||
{
|
||||
var listing = await session.Snippets
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new SnippetRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart, as the host grid's badge is.
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
}));
|
||||
}
|
||||
|
||||
Snippets.Clear();
|
||||
|
||||
foreach (var snippet in listing.Items
|
||||
.OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
|
||||
foreach (var snippet in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Snippets.Add(new SnippetRowViewModel(snippet));
|
||||
Snippets.Add(snippet);
|
||||
}
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
|
||||
/// <param name="vaultId">The vault to write it into.</param>
|
||||
/// <param name="entityId">The snippet to replace, or null to create one.</param>
|
||||
/// <param name="snippet">What to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
|
||||
/// same immediate push as every other save. The screen decides <em>what</em> a snippet is and nothing
|
||||
/// else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The vault is a parameter rather than the active one</b>, and that is not tidiness: the screen
|
||||
/// latches it when the editor opens — the chosen vault for a new snippet, the row's own for an existing
|
||||
/// one — because an update sent to the active vault would write a second copy there and leave the team's
|
||||
/// original untouched, which is a fork nobody would see until a colleague asked why the change never
|
||||
/// arrived. The same rule <c>editingHostVaultId</c> carries for hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<bool> SaveSnippetAsync(
|
||||
Guid vaultId,
|
||||
Guid? entityId,
|
||||
SnippetSecret snippet,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -3335,13 +3398,13 @@ internal sealed partial class VaultViewModel(
|
||||
if (entityId is { } existing)
|
||||
{
|
||||
await session.Snippets
|
||||
.UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
|
||||
.UpdateAsync(vaultId, existing, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await session.Snippets
|
||||
.CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
|
||||
.CreateAsync(vaultId, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -3358,6 +3421,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for one snippet.</summary>
|
||||
/// <remarks>
|
||||
/// The tombstone goes to the vault the row came out of, which the row carries. Deleting out of the
|
||||
/// active vault instead would tombstone nothing and leave the snippet on screen.
|
||||
/// </remarks>
|
||||
internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
@@ -3371,7 +3438,7 @@ internal sealed partial class VaultViewModel(
|
||||
async () =>
|
||||
{
|
||||
await session.Snippets
|
||||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||||
.DeleteAsync(row.VaultId, entityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -3381,6 +3448,76 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-seals one snippet under another vault's key and tombstones the original.
|
||||
/// </summary>
|
||||
/// <param name="row">The snippet to move.</param>
|
||||
/// <param name="target">The vault it should end up in.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Its id in the destination, or null when nothing was moved.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The write half of sharing a snippet; the panel that asks which vault belongs to the screen, as the
|
||||
/// snippet editor does. See <c>SnippetsViewModel.Move</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A snippet crosses whole</b>, which is the one way this is simpler than <see cref="MoveHost"/>.
|
||||
/// A host leaves its group and its tags behind because both are items of the vault it came from; a
|
||||
/// snippet is a label, a command and a note, and none of them points at anything — so there is nothing
|
||||
/// to strip and nothing to warn about. What the caller still has to say is that the command is now
|
||||
/// readable by everybody holding the destination's key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a snippet a newer client wrote, exactly as editing one is: the move re-encodes the
|
||||
/// payload here, so a field this build cannot represent would be dropped on the way across.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<Guid?> MoveSnippetAsync(
|
||||
SnippetRowViewModel row,
|
||||
VaultChoiceViewModel target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet 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 null;
|
||||
}
|
||||
|
||||
Guid? moved = null;
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
async () =>
|
||||
{
|
||||
moved = await session.Snippets
|
||||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, row.Snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Moved '{row.Label}' to {target.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||||
// them and not the other shows the snippet twice or not at all until the next pass.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>Every vault this session can write to except one, for a screen that owns its own picker.</summary>
|
||||
/// <remarks>
|
||||
/// The snippets screen's move panel lives on <c>SnippetsViewModel</c> — its editor does too — so it
|
||||
/// needs the same list <see cref="BuildMoveVaultChoices"/> fills the host's panel from, in the same
|
||||
/// order. Shared rather than written twice, for the reason <see cref="WritableVaultsBesides"/> gives.
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<VaultChoiceViewModel> MoveTargetsBesides(Guid vaultId) =>
|
||||
[.. WritableVaultsBesides(vaultId)];
|
||||
|
||||
/// <returns>How many groups would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
Reference in New Issue
Block a user