Public Access
Merge branch 'claude/snippets-vault-sharing-470476'
This commit is contained in:
@@ -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,20 +180,66 @@
|
|||||||
IsEnabled="{Binding !IsEditing}">
|
IsEnabled="{Binding !IsEditing}">
|
||||||
<StackPanel Spacing="9">
|
<StackPanel Spacing="9">
|
||||||
|
|
||||||
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
|
||||||
IsEnabled="{Binding CanInsert}" />
|
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
|
||||||
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
|
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
|
||||||
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
|
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
|
||||||
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
|
||||||
IsEnabled="{Binding CanInsert}" />
|
IsEnabled="{Binding CanInsert}" />
|
||||||
|
|
||||||
<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
|
||||||
</Grid>
|
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>
|
</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,18 +199,51 @@
|
|||||||
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.
|
||||||
-->
|
-->
|
||||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
|
||||||
HorizontalAlignment="Left"
|
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
HorizontalAlignment="Left"
|
||||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||||
|
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||||
|
|
||||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
||||||
Command="{Binding RunCommand}"
|
Command="{Binding RunCommand}"
|
||||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||||
|
|
||||||
<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>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 listing = await session.Snippets
|
var unreadable = 0;
|
||||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
var rows = new List<SnippetRowViewModel>();
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
var readable = session.ReadableVaults.ToList();
|
||||||
|
var several = readable.Count > 1;
|
||||||
|
|
||||||
|
foreach (var vault in readable)
|
||||||
|
{
|
||||||
|
var listing = await session.Snippets
|
||||||
|
.ListAsync(vault.VaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
unreadable += listing.Unreadable;
|
||||||
|
|
||||||
|
rows.AddRange(listing.Items.Select(
|
||||||
|
item => new SnippetRowViewModel(item, vault.VaultId, vault.Name)
|
||||||
|
{
|
||||||
|
// Only when there is something to tell apart, as the host grid's badge is.
|
||||||
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
Snippets.Clear();
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user