diff --git a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
index 1e87883..29e1c39 100644
--- a/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
+++ b/src/DodoSSH.Client.Android/Views/SnippetsScreen.axaml
@@ -64,6 +64,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
+
+
+
+
+
@@ -151,20 +180,66 @@
IsEnabled="{Binding !IsEditing}">
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
index ccfd6e1..2e2327c 100644
--- a/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
@@ -63,6 +63,14 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
+
+
+
+
+
@@ -102,6 +120,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -150,18 +199,51 @@
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
-
+
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
index 77621ad..7b5d3a2 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/SnippetsViewModel.cs
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
/// there", which is what says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see .
///
+///
+/// A snippet can be shared, which is why this screen has two vault controls rather than none. The
+/// editor asks which vault a new 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
+/// because this screen owns its editor, unlike the host pane; the writing they
+/// ask for is still the vault's.
+///
///
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;
+ ///
+ /// The vault the open editor will write to.
+ ///
+ ///
+ /// 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 VaultViewModel.editingHostVaultId
+ /// exists.
+ ///
+ private Guid editorVaultId;
+
+ /// Which vault the open move panel would send the snippet to.
+ private SnippetRowViewModel? moving;
+
+ ///
+ /// The vaults a new snippet may be filed into.
+ ///
+ ///
+ /// Filled from VaultViewModel.TargetVaults, 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.
+ ///
+ internal ObservableCollection EditorVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? editorSelectedVault;
+
+ ///
+ /// Whether the editor should be asking which vault this snippet goes into.
+ ///
+ ///
+ /// 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 — and a control offering one option is a question nobody was
+ /// asked.
+ ///
+ internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
+
+ /// Whether the panel asking which vault to move the selected snippet to is up.
+ ///
+ /// 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".
+ ///
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
+ private bool isMoving;
+
+ /// Where the selected snippet could be moved: every vault this session can write to but its own.
+ internal ObservableCollection MoveVaultChoices { get; } = [];
+
+ [ObservableProperty]
+ private VaultChoiceViewModel? selectedMoveVault;
+
+ ///
+ /// Whether the selected snippet has anywhere to move to.
+ ///
+ ///
+ /// 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.
+ ///
+ internal bool CanMove =>
+ Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
+
+ /// Whether the buttons under the selected snippet are showing.
+ ///
+ /// Off while the move panel is up, which takes their place — the same rule the host pane's
+ /// ShowsHostPaneActions carries, and for the same reason: the button that opened the panel would
+ /// otherwise still be there offering to open it again.
+ ///
+ internal bool ShowsSelectionActions => HasSelection && !IsMoving;
+
+ /// The vault the selected snippet lives in, named, or empty when there is only one.
+ ///
+ /// 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.
+ ///
+ internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
+
+ /// Whether there is a vault to name beside the selected snippet.
+ internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
+
+ /// Whether this keychain holds any snippet the screen would draw.
+ ///
+ /// 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.
+ ///
+ 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.";
/// Starts a new snippet.
+ ///
+ /// 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.
+ ///
[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.";
}
/// Opens the selected snippet for editing.
+ ///
+ /// 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.
+ ///
[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;
}
+ ///
+ /// Opens the panel that asks which vault the selected snippet should move to.
+ ///
+ ///
+ ///
+ /// 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 VaultItemRepository.MoveAsync — and that must not happen as a
+ /// side effect of saving a corrected typo.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [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;
+ }
+
+ /// Abandons the move panel.
+ [RelayCommand]
+ private void CancelMove()
+ {
+ CloseMovePanel();
+ Status = string.Empty;
+ }
+
+ ///
+ /// Moves the selected snippet into the chosen vault.
+ ///
+ ///
+ ///
+ /// The snippet crosses whole. 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [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;
+ }
+
///
/// Types the selected snippet into the selected terminal, without pressing Enter.
///
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
partial void OnFilterChanged(string value) => Rebuild();
+ ///
+ /// 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.
+ ///
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));
+ }
+
+ ///
+ /// Moves a half-typed snippet into the vault just chosen for it.
+ ///
+ ///
+ /// Only while creating, and this guard is what makes that true rather than the view merely not drawing
+ /// the control. An existing snippet can change vaults — see — 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.
+ ///
+ partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
+ {
+ if (value is null || EditingId is not null)
+ {
+ return;
+ }
+
+ editorVaultId = value.VaultId;
+ }
+
+ /// Refills the editor's vault picker, landing on the vault the editor will write to.
+ 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;
+ }
/// Whether the editor would create a snippet rather than replace one.
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));
}
///
+ ///
/// 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.
+ ///
+ ///
+ /// A hidden vault's snippets come off here rather than out of VaultViewModel.Snippets, 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.
+ ///
///
private bool Matches(SnippetRowViewModel row)
{
+ if (!vault.IsVaultShown(row.VaultId))
+ {
+ return false;
+ }
+
var needle = Filter.Trim();
if (needle.Length == 0)
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index c3e9405..a8a82b2 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -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.
///
-internal sealed class SnippetRowViewModel(VaultItem snippet)
+internal sealed class SnippetRowViewModel(VaultItem snippet, Guid vaultId, string vaultName)
{
internal Guid EntityId => snippet.EntityId;
+ /// Which vault this snippet lives in. See .
+ ///
+ /// 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.
+ ///
+ internal Guid VaultId => vaultId;
+
+ /// The vault's display name.
+ internal string VaultName => vaultName;
+
+ ///
+ /// The vault name to print on this row, or empty when there is only one vault to be in.
+ ///
+ ///
+ internal string VaultBadge { get; init; } = string.Empty;
+
+ /// Whether this row has a vault to name.
+ 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(
/// How many snippets would not decrypt.
///
+ ///
/// 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.
+ ///
+ ///
+ /// Every readable vault, not the active one, which is what makes a snippet shareable. 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 and
+ /// , 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.
+ ///
///
private async Task ReloadSnippetsAsync(CancellationToken cancellationToken)
{
- var listing = await session.Snippets
- .ListAsync(session.ActiveVaultId, cancellationToken)
- .ConfigureAwait(true);
+ var unreadable = 0;
+ var rows = new List();
+
+ 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;
}
/// Stores one snippet, encrypted, and queues it for the server.
+ /// The vault to write it into.
/// The snippet to replace, or null to create one.
/// What to store.
/// Cancellation.
/// Whether it was stored; means the reason is in .
///
+ ///
/// 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 what a snippet is and nothing
/// else.
+ ///
+ ///
+ /// The vault is a parameter rather than the active one, 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 editingHostVaultId carries for hosts.
+ ///
///
internal async Task 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(
}
/// Queues a tombstone for one snippet.
+ ///
+ /// 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.
+ ///
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);
}
+ ///
+ /// Re-seals one snippet under another vault's key and tombstones the original.
+ ///
+ /// The snippet to move.
+ /// The vault it should end up in.
+ /// Cancellation.
+ /// Its id in the destination, or null when nothing was moved.
+ ///
+ ///
+ /// The write half of sharing a snippet; the panel that asks which vault belongs to the screen, as the
+ /// snippet editor does. See SnippetsViewModel.Move.
+ ///
+ ///
+ /// A snippet crosses whole, which is the one way this is simpler than .
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ internal async Task 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;
+ }
+
+ /// Every vault this session can write to except one, for a screen that owns its own picker.
+ ///
+ /// The snippets screen's move panel lives on SnippetsViewModel — its editor does too — so it
+ /// needs the same list fills the host's panel from, in the same
+ /// order. Shared rather than written twice, for the reason gives.
+ ///
+ internal IReadOnlyList MoveTargetsBesides(Guid vaultId) =>
+ [.. WritableVaultsBesides(vaultId)];
+
/// How many groups would not decrypt.
///
///
diff --git a/src/DodoSSH.Client.Sync/SnippetRepository.cs b/src/DodoSSH.Client.Sync/SnippetRepository.cs
index 1f8a2c9..cf802ad 100644
--- a/src/DodoSSH.Client.Sync/SnippetRepository.cs
+++ b/src/DodoSSH.Client.Sync/SnippetRepository.cs
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
+ ///
+ ///
+ /// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
+ /// — so unlike a host it crosses whole: nothing about it points at an item
+ /// of the vault it is leaving, and there is consequently nothing to strip on the way across.
+ ///
+ public Task MoveAsync(
+ Guid fromVaultId,
+ Guid toVaultId,
+ Guid entityId,
+ SnippetSecret snippet,
+ CancellationToken cancellationToken) =>
+ snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
+
///
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 3d7569f..f5e30d7 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
+ ///
+ ///
+ /// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
+ /// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
+ /// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
+ ///
+ ///
+ /// The state is set here rather than through MoveCommand, which would refuse: this fixture's
+ /// account holds one vault, and the command declines rather than open a picker with nothing in it. The
+ /// flow that fills it is covered in DodoSSH.Client.App.Tests. The same arrangement, and the same
+ /// reason, as .
+ ///
+ ///
+ [Fact]
+ public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
+ {
+ await SeedSnippetsAsync();
+
+ var snippets = NewSnippetsScreen();
+ snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
+
+ snippets.MoveVaultChoices.Add(
+ new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
+
+ snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
+ snippets.IsMoving = true;
+
+ snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
+
+ await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
+ }
+
[Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private async Task SeedSnippetsAsync()
{
await vault.SaveSnippetAsync(
+ vault.TargetVaultId,
null,
new SnippetSecret
{
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token);
await vault.SaveSnippetAsync(
+ vault.TargetVaultId,
null,
new SnippetSecret
{
diff --git a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
index 43a53b1..96441f3 100644
--- a/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/VaultSharingTests.cs
@@ -1475,6 +1475,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
+ ///
+ ///
+ /// Sharing a snippet, which is a move like a host's and simpler in exactly one way: a snippet crosses
+ /// whole. It has no group, no tags and no key binding — nothing on it points at an item of the vault it
+ /// came from — so the assertion the host's move makes about what was left behind has no analogue, and
+ /// the one worth making instead is that nothing was lost, the flag that decides whether it
+ /// presses Enter for you least of all.
+ ///
+ ///
+ /// The new id is asserted for the reason the host's test gives: one entity id in two vaults would make
+ /// the destination's row and the source's tombstone the same row.
+ ///
+ ///
+ [Fact]
+ public async Task MovingASnippetToAnotherVault_ReSealsItThereAndCarriesItWhole()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: true);
+
+ var before = Snippet(snippets, "restart the api");
+
+ before.VaultId.ShouldNotBe(sharedVaultId);
+
+ snippets.Selected = before;
+ snippets.CanMove.ShouldBeTrue("there is a second vault this session can write to");
+
+ snippets.MoveCommand.Execute(null);
+
+ snippets.IsMoving.ShouldBeTrue(snippets.Status);
+ snippets.MoveVaultChoices.ShouldNotContain(choice => choice.VaultId == before.VaultId);
+
+ snippets.SelectedMoveVault =
+ snippets.MoveVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ // The pass that follows every write is made to fail, so the move's own sentence is still on the
+ // status line to be read. See the host's move test, which does this for the same reason.
+ server.SyncFailure = new IOException("The server is not answering.");
+
+ await snippets.ConfirmMoveCommand.ExecuteAsync(null);
+
+ var after = Snippet(snippets, "restart the api");
+
+ after.VaultId.ShouldBe(sharedVaultId, vault.Status);
+ after.EntityId.ShouldNotBe(before.EntityId, "an id belongs to one vault");
+ after.Snippet.Command.ShouldBe("sudo systemctl restart dodossh-api");
+ after.Snippet.RunsOnInsert.ShouldBeTrue("the flag that decides whether it presses Enter came too");
+
+ snippets.Selected?.EntityId.ShouldBe(after.EntityId, "the pane follows the snippet it moved");
+ snippets.Status.ShouldContain("Platform secrets");
+ }
+
+ ///
+ /// Refused by the command rather than by an empty picker, and the phone reads the same question to
+ /// decide whether to draw the button at all.
+ ///
+ [Fact]
+ public async Task MovingASnippetWithNowhereToMoveIt_SaysSoRatherThanOpeningAnEmptyPicker()
+ {
+ await UnlockedAsync();
+
+ var vault = shell.Vault!;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
+
+ snippets.Selected = Snippet(snippets, "uptime");
+
+ snippets.CanMove.ShouldBeFalse("the personal vault is the only one there is");
+
+ snippets.MoveCommand.Execute(null);
+
+ snippets.IsMoving.ShouldBeFalse();
+ snippets.MoveVaultChoices.ShouldBeEmpty();
+ snippets.Status.ShouldContain("only vault you can write to");
+ }
+
+ ///
+ /// The picker the snippet editor grew, and the thing it is for: choosing at the moment a snippet is
+ /// written, on the form it is being typed into. A command is worth sharing precisely when somebody else
+ /// would otherwise be retyping it, so filing it into the team's vault at that moment is the ordinary
+ /// case rather than an afterthought.
+ ///
+ [Fact]
+ public async Task TheSnippetEditorFilesANewSnippetIntoTheVaultChosenOnIt()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ snippets.NewCommand.Execute(null);
+
+ snippets.ShowsEditorVaultChoice.ShouldBeTrue("there are two vaults to choose between");
+
+ snippets.EditorSelectedVault =
+ snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+
+ snippets.EditorLabel = "rotate the certs";
+ snippets.EditorCommand = "sudo certbot renew";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ Snippet(snippets, "rotate the certs").VaultId.ShouldBe(sharedVaultId, snippets.Status);
+ }
+
+ ///
+ ///
+ /// The bug the per-editor latch exists to prevent, and the reason the screen could not simply keep
+ /// writing to the active vault once its list spanned several. An update sent to the active vault would
+ /// create a second snippet there and leave the team's original untouched: a fork that shows up only
+ /// when a colleague asks why the correction never arrived.
+ ///
+ ///
+ /// The count is the assertion. One snippet with that label, in the vault it started in.
+ ///
+ ///
+ [Fact]
+ public async Task EditingASharedSnippet_WritesBackToItsOwnVaultRatherThanForkingACopy()
+ {
+ await UnlockedAsync();
+
+ var vaults = shell.Vaults;
+
+ await CreateVaultAsync(vaults, "Platform secrets");
+
+ var vault = shell.Vault!;
+ var sharedVaultId = vaults.SelectedVault!.VaultId;
+
+ await vault.LoadAsync(Token);
+
+ var snippets = SnippetsOver(vault);
+
+ snippets.NewCommand.Execute(null);
+ snippets.EditorSelectedVault =
+ snippets.EditorVaultChoices.Single(choice => choice.VaultId == sharedVaultId);
+ snippets.EditorLabel = "drain the node";
+ snippets.EditorCommand = "kubectl drain node-1";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ snippets.Selected = Snippet(snippets, "drain the node");
+ snippets.EditCommand.Execute(null);
+
+ snippets.ShowsEditorVaultChoice.ShouldBeFalse("an existing snippet's vault is not a field of the form");
+
+ snippets.EditorCommand = "kubectl drain node-1 --ignore-daemonsets";
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+
+ var edited = Snippet(snippets, "drain the node");
+
+ edited.VaultId.ShouldBe(sharedVaultId, "the edit went back to the vault it came from");
+ edited.Snippet.Command.ShouldBe("kubectl drain node-1 --ignore-daemonsets");
+ }
+
+ /// The snippet with a given name, re-found because every row is replaced on every reload.
+ private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
+ snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
+
+ /// The snippets screen over a vault, with no terminal to insert into.
+ ///
+ /// Insert is not what this suite is about — see ShellFlowTests for that — so the target is empty
+ /// and the delivery is a stub that would report success if anything asked it to.
+ ///
+ private static SnippetsViewModel SnippetsOver(VaultViewModel vault) =>
+ new(vault, () => InsertTarget.None, (_, _, _, _) => Task.FromResult(true));
+
+ private static async Task AddSnippetAsync(
+ SnippetsViewModel snippets,
+ string label,
+ string command,
+ bool runs)
+ {
+ snippets.NewCommand.Execute(null);
+ snippets.EditorLabel = label;
+ snippets.EditorCommand = command;
+ snippets.EditorRunsOnInsert = runs;
+
+ await snippets.SaveCommand.ExecuteAsync(null);
+ }
+
///
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity
/// key that was really enrolled: the fake server publishes it into its key log during enrollment, and