Merge branch 'claude/snippets-vault-sharing-470476'

This commit is contained in:
2026-08-06 07:39:25 +02:00
7 changed files with 876 additions and 36 deletions
@@ -64,6 +64,26 @@
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" /> <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" <TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap" Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" /> VerticalContentAlignment="Top" Padding="14,10" />
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" /> <TextBlock Text="{Binding Badge}" />
</Border> </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> </StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. --> <!-- The command, on the surface every block of monospace in this design is drawn on. -->
@@ -151,6 +180,7 @@
IsEnabled="{Binding !IsEditing}"> IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9"> <StackPanel Spacing="9">
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}" <Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" /> IsEnabled="{Binding CanInsert}" />
@@ -162,9 +192,54 @@
<Grid ColumnDefinitions="*,8,*"> <Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT" <Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" /> 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> </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> </StackPanel>
</Border> </Border>
@@ -63,6 +63,14 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" /> <TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border> </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> </StackPanel>
<!-- <!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
@@ -89,6 +97,16 @@
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}" <Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" /> 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> </StackPanel>
</Border> </Border>
@@ -102,6 +120,27 @@
<!-- ============ The editor ============ --> <!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}"> <StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" /> <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 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 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" <TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" /> 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" /> <TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}" <Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8"> 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 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. 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" <Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left" HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}" Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
@@ -163,6 +213,38 @@
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0" <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." /> 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> </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>
</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 /// 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"/>. /// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para> /// </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> /// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject internal sealed partial class SnippetsViewModel : ObservableObject
{ {
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
[ObservableProperty] [ObservableProperty]
private string status = string.Empty; 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; internal bool HasVisible => Visible.Count > 0;
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
+ "typing it again."; + "typing it again.";
/// <summary>Starts a new snippet.</summary> /// <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] [RelayCommand]
private void New() private void New()
{ {
CloseMovePanel();
EditingId = null; EditingId = null;
EditorLabel = string.Empty; EditorLabel = string.Empty;
EditorCommand = string.Empty; EditorCommand = string.Empty;
EditorNotes = string.Empty; EditorNotes = string.Empty;
EditorRunsOnInsert = false; EditorRunsOnInsert = false;
BuildEditorVaultChoices(vault.TargetVaultId);
IsEditing = true; IsEditing = true;
Status = "Adding a snippet."; Status = "Adding a snippet.";
} }
/// <summary>Opens the selected snippet for editing.</summary> /// <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] [RelayCommand]
private void Edit() private void Edit()
{ {
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
return; return;
} }
CloseMovePanel();
EditingId = row.EntityId; EditingId = row.EntityId;
EditorLabel = row.Snippet.Label; EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command; EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty; EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert; EditorRunsOnInsert = row.Snippet.RunsOnInsert;
BuildEditorVaultChoices(row.VaultId);
IsEditing = true; IsEditing = true;
Status = $"Editing {row.Label}."; Status = $"Editing {row.Label}.";
} }
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
RunsOnInsert = EditorRunsOnInsert, 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) if (!saved)
{ {
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status; 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> /// <summary>
/// Types the selected snippet into the selected terminal, without pressing Enter. /// Types the selected snippet into the selected terminal, without pressing Enter.
/// </summary> /// </summary>
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
partial void OnFilterChanged(string value) => Rebuild(); 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) partial void OnSelectedChanged(SnippetRowViewModel? value)
{ {
if (IsMoving && value?.EntityId != moving?.EntityId)
{
CloseMovePanel();
}
OnPropertyChanged(nameof(HasSelection)); OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert)); OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns)); 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> /// <summary>Whether the editor would create a snippet rather than replace one.</summary>
internal bool IsCreating => EditingId is null; internal bool IsCreating => EditingId is null;
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
OnPropertyChanged(nameof(HasSnippets)); OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible)); OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage)); OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(CanMove));
} }
/// <remarks> /// <remarks>
/// <para>
/// The command is searched as well as the name and the notes, because half of what somebody remembers /// 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. /// 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> /// </remarks>
private bool Matches(SnippetRowViewModel row) private bool Matches(SnippetRowViewModel row)
{ {
if (!vault.IsVaultShown(row.VaultId))
{
return false;
}
var needle = Filter.Trim(); var needle = Filter.Trim();
if (needle.Length == 0) 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 /// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
/// per click. /// per click.
/// </remarks> /// </remarks>
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet) internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet, Guid vaultId, string vaultName)
{ {
internal Guid EntityId => snippet.EntityId; 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 SnippetSecret Snippet => snippet.Secret;
internal string Label => snippet.Secret.Label; internal string Label => snippet.Secret.Label;
@@ -3285,37 +3306,79 @@ internal sealed partial class VaultViewModel(
/// <returns>How many snippets would not decrypt.</returns> /// <returns>How many snippets would not decrypt.</returns>
/// <remarks> /// <remarks>
/// <para>
/// No selection to preserve: what a snippet screen selects is its own, and it restores it around this /// 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. /// 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> /// </remarks>
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken) 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 var listing = await session.Snippets
.ListAsync(session.ActiveVaultId, cancellationToken) .ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true); .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(); Snippets.Clear();
foreach (var snippet in listing.Items foreach (var snippet in rows
.OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture)) .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> /// <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="entityId">The snippet to replace, or null to create one.</param>
/// <param name="snippet">What to store.</param> /// <param name="snippet">What to store.</param>
/// <param name="cancellationToken">Cancellation.</param> /// <param name="cancellationToken">Cancellation.</param>
/// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns> /// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
/// <remarks> /// <remarks>
/// <para>
/// Here rather than on the screen, so the write goes through the same repository, the same outbox and the /// 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 /// same immediate push as every other save. The screen decides <em>what</em> a snippet is and nothing
/// else. /// 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> /// </remarks>
internal async Task<bool> SaveSnippetAsync( internal async Task<bool> SaveSnippetAsync(
Guid vaultId,
Guid? entityId, Guid? entityId,
SnippetSecret snippet, SnippetSecret snippet,
CancellationToken cancellationToken) CancellationToken cancellationToken)
@@ -3335,13 +3398,13 @@ internal sealed partial class VaultViewModel(
if (entityId is { } existing) if (entityId is { } existing)
{ {
await session.Snippets await session.Snippets
.UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken) .UpdateAsync(vaultId, existing, snippet, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
else else
{ {
await session.Snippets await session.Snippets
.CreateAsync(session.ActiveVaultId, snippet, cancellationToken) .CreateAsync(vaultId, snippet, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
} }
@@ -3358,6 +3421,10 @@ internal sealed partial class VaultViewModel(
} }
/// <summary>Queues a tombstone for one snippet.</summary> /// <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) internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
{ {
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row) if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
@@ -3371,7 +3438,7 @@ internal sealed partial class VaultViewModel(
async () => async () =>
{ {
await session.Snippets await session.Snippets
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken) .DeleteAsync(row.VaultId, entityId, cancellationToken)
.ConfigureAwait(true); .ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
@@ -3381,6 +3448,76 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true); 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> /// <returns>How many groups would not decrypt.</returns>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, 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" /> /// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, 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); 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] [Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing() public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{ {
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private async Task SeedSnippetsAsync() private async Task SeedSnippetsAsync()
{ {
await vault.SaveSnippetAsync( await vault.SaveSnippetAsync(
vault.TargetVaultId,
null, null,
new SnippetSecret new SnippetSecret
{ {
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token); Token);
await vault.SaveSnippetAsync( await vault.SaveSnippetAsync(
vault.TargetVaultId,
null, null,
new SnippetSecret new SnippetSecret
{ {
@@ -1475,6 +1475,210 @@ public sealed class VaultSharingTests : IAsyncLifetime
vaults.SelectedVault!.IsShared.ShouldBeTrue(vaults.Status); 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> /// <remarks>
/// The whole path rather than a shortcut into the unlocked state, because sharing needs an identity /// 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 /// key that was really enrolled: the fake server publishes it into its key log during enrollment, and