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:
2026-08-06 07:39:03 +02:00
parent 174ef7c420
commit 3d9ed03b09
7 changed files with 876 additions and 36 deletions
@@ -64,6 +64,26 @@
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
vault to choose between, which is where most people stay.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" />
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" />
</Border>
<!--
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert into their own terminals,
and this card is the only place that fact appears before somebody taps EDIT.
-->
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
@@ -151,6 +180,7 @@
IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9">
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" />
@@ -162,9 +192,54 @@
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteCommand}" />
<!--
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
move to; see SnippetsViewModel.CanMove.
-->
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
</Grid>
<!--
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
one is where a thumb lands.
-->
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
</StackPanel>
<!--
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
by moving it back.
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
the status line is one line at the bottom of a screen somebody has already navigated away from,
before the tap is the only place that reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveCommand}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
@@ -63,6 +63,14 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
<!--
Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert; the row is where somebody
decides whether the thing they are about to edit is theirs alone.
-->
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
@@ -89,6 +97,16 @@
IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" />
<!--
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do — and a picker inside the form would let
somebody correcting a typo hand a command to a team by leaving it where they found it. The
picker itself opens beside the snippet, in the pane on the right.
-->
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
IsEnabled="{Binding CanMove}"
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
</StackPanel>
</Border>
@@ -102,6 +120,27 @@
<!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
one vault to choose between, because a control offering one option is a question nobody asked.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
@@ -133,6 +172,16 @@
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<!--
Named here as well as on the row, because this pane is where somebody decides to put a
command into a production terminal, and who else holds a key to it is part of that decision.
The row's badge is off the screen by the time this is being read.
-->
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
IsVisible="{Binding HasSelectionVaultBadge}">
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
</Border>
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
@@ -150,6 +199,7 @@
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.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
@@ -163,6 +213,38 @@
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
<!--
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
two buttons, not a question with a yes: what is being asked is which vault, and a move is
undone by moving it back rather than by being careful — so this is not drawn in the danger
colours the deletion question uses.
It takes the insert controls' place while it is up, for the reason the host pane hides
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
group and no tags to leave behind — so what there is to say is who can read it afterwards,
and for a command that may carry a hostname or a path that is the whole of the decision.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
@@ -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 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(session.ActiveVaultId, cancellationToken)
.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>
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
/// <see cref="SnippetSecret"/> — 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.
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SnippetSecret snippet,
CancellationToken cancellationToken) =>
snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveCommand</c>, 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 <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
/// </para>
/// </remarks>
[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
{
@@ -1475,6 +1475,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status);
}
/// <remarks>
/// <para>
/// 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 <em>nothing</em> was lost, the flag that decides whether it
/// presses Enter for you least of all.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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");
}
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The count is the assertion. One snippet with that label, in the vault it started in.
/// </para>
/// </remarks>
[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");
}
/// <summary>The snippet with a given name, re-found because every row is replaced on every reload.</summary>
private static SnippetRowViewModel Snippet(SnippetsViewModel snippets, string label) =>
snippets.Visible.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>The snippets screen over a vault, with no terminal to insert into.</summary>
/// <remarks>
/// Insert is not what this suite is about — see <c>ShellFlowTests</c> for that — so the target is empty
/// and the delivery is a stub that would report success if anything asked it to.
/// </remarks>
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);
}
/// <remarks>
/// 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