Merge branch 'main'
ci / build and test (push) Successful in 2m3s
ci / android head (push) Successful in 3m21s
ci / desktop nightly (push) Successful in 45s
ci / api image (push) Successful in 33s

Two of main's changes land in files this branch rewrote, and both needed carrying
across by hand rather than by the merge.

The phone's nav staying up on Connections with nothing running is a fourth input
to RefreshChrome, which this branch had already given two more — whether hosts are
ticked and whether the host editor is filling the screen. They compose: the rail
and the bottom bar now ask (pages || connectPage) && !editing, so a page-shaped
terminal surface keeps its way off the screen and the editor still takes the whole
display.

The key question under the host's move panel is the harder one, because this
branch deleted the panel it was added to. The connect card is gone and the phone's
only route to a move is the action bar, so leaving the merge to take this side
would have removed a capability main had just shipped — silently, since nothing
would fail to build. It is asked in the action bar's own picker instead, in two
shapes fewer than the desktop's: one host, because which key to carry is a fact
about one machine and a selection of six has six answers, and a move rather than a
copy, because taking the key out from under an original that is staying put would
leave that original unable to connect. BindingOfTheMovingHost splits into
MovableBindingOf so both heads answer it the same way from different panels.

Main also fixed a real trap in the same commit — a host that only inherited its
key from its group arrived in the destination naming nothing at all, because the
group stays behind — and the batch move had the same bug for the same reason. It
goes through Detached now, which is where that fix lives.

The carried host is written as the carry left it rather than being detached again,
which is the one thing worth measuring: the key takes a new id over there, so a
run that rebuilt the payload from the row would send the machine across naming a
tombstone. Both directions are pinned, along with the rule about which shapes the
question is asked in at all.
This commit is contained in:
2026-08-06 09:30:00 +02:00
20 changed files with 2530 additions and 113 deletions
@@ -158,6 +158,28 @@
<TextBlock Classes="body" Text="{Binding ChosenHostVaultPanelNote}" />
<!--
◆ THE KEY. A binding resolves across vaults, so the host goes on working here whichever way this
is answered — but the vault it has just joined holds one key, and a host whose own key stayed
behind is one its new colleagues cannot connect with.
Drawn only for a move of exactly one host, which is the only shape the question has an answer in:
which key to carry is a fact about one machine, and a copy that took the key away would leave the
original unable to connect. See VaultViewModel.BringsTheChosenBindingAlong.
Unticked, because moving a key into a team's vault hands it to everybody who holds that vault's
key: a disclosure is chosen, never defaulted into. The line under it is the count of what else
uses that key, which is the difference between an obvious yes and an obvious no.
-->
<CheckBox IsChecked="{Binding BringsTheChosenBindingAlong}" MinHeight="44"
IsVisible="{Binding HasAChosenBindingToBring}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding ChosenBindingToBringQuestion}" />
</CheckBox>
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasAChosenBindingToBring}"
Text="{Binding ChosenBindingToBringNote}" />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44"
Content="{Binding ChosenHostVaultPanelVerb}"
@@ -149,8 +149,19 @@
</StackPanel>
</StackPanel>
<Button Grid.Column="2" Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" />
<!--
MOVE beside it, and only where there is somewhere to move to — the rule the host's MOVE follows on
this head, for the reason a phone has: there is no room to draw a button that answers with a
refusal. It is the ghost of the pair rather than the danger one, because a move is undone by
moving it back.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="secondary" Height="44" Width="86" Content="MOVE"
IsVisible="{Binding CanMoveSelectedItem}"
Command="{Binding MoveSelectedItemCommand}" />
<Button Classes="danger" Height="44" Width="104" Content="DELETE"
Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
</Grid>
</Border>
@@ -180,6 +191,47 @@
</StackPanel>
</Border>
<!-- ============ ◆ moving it to another vault ============ -->
<!--
The desktop's panel, in the place the deletion question uses and never at the same time as it: MOVE
disarms a pending deletion on the way in, and the buttons that ask either question are hidden while
one is up.
Both sentences are here rather than only in the status line afterwards, which on a phone is one line at
the bottom of a screen somebody has already navigated away from. The second one is the count of what
points at this key — every one of them is re-aimed at the vault it moves to, and that is the part
nobody can see from a keychain row.
-->
<Border Grid.Row="3" IsVisible="{Binding IsMovingItem}" Margin="12,4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
Text="{Binding MovingItemLabel}" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body" TextWrapping="Wrap"
Text="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="body" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveItemCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveItemCommand}" />
</Grid>
</StackPanel>
</Border>
<!-- ============ the items ============ -->
<Panel Grid.Row="4">
@@ -134,15 +134,17 @@
<!--
The terminal is a surface rather than a page, so it goes through its own command — the same
reason the bottom bar's middle entry does. It is never lit, and deliberately: this rail is
collapsed while a shell is showing, so IsCurrent could only ever read false. What marks that
destination as current is the surface filling the screen.
reason the bottom bar's middle entry does. It lights on IsTerminalSurface, which on this control
can only mean one thing: the rail stands down for a shell, so the one moment it is drawn beside
this surface is Connections with nothing running. That screen is a page in everything but which
enum it is in, and a rail sitting beside it with no entry lit would be pointing nowhere.
No count, unlike the two above it. The shells strip sits above the bottom of every page on this
surface and is that count, drawn as the sessions themselves — a number beside this word would be
the same fact said twice, three inches apart.
-->
<Button Classes="railentry" Command="{Binding ShowTerminalCommand}">
<Button Classes="railentry" Classes.active="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="railglyph" Text="⌗" />
<TextBlock Grid.Column="1" Classes="raillabel" Text="Connections" />
@@ -25,8 +25,9 @@
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
surface is either a page or a terminal, and these are the chrome a page has.
bottom bar itself. The strip asks IsShowingPages directly; the other two go through flags the control
computes, and the bar's is the one that differs — it stays up on Connections with nothing running, which
is the terminal surface drawing a page rather than a shell. See PhoneShell.RefreshChrome.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
@@ -316,10 +317,15 @@
<!-- ============ navigation ============ -->
<!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's three
Gone while a shell is showing, which is the whole of that surface's arrangement: the bar's three
destinations are replaced by a back arrow and a + that leads to all of them, both in the terminal's
own bar. See TerminalScreen.axaml.
◆ A shell rather than the surface, which are not the same question — Connections with nothing running
is that surface drawing a page, and the bar stays under it. The screen it stands down for is the one
that pays for the room; a box asking which machine to connect to does not, and taking the nav away
from somebody who has just closed their last tab left them with only the back gesture.
◆ It is no longer a single question — a wide surface takes the rail instead — so it reads one flag
the control computes rather than a condition here. See PhoneShell.ShowsBottomBar.
@@ -348,12 +354,15 @@
rather than the door. The enum member stays ShellSurface.Terminal, for the reason the keychain's
tab is not called Vault: the surface is a terminal, and the word a user reads is the product's.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
would be a rule about a state this control cannot be in. What marks it as current is the surface
filling the screen.
◆ It lights again, and IsTerminalSurface is the right question rather than a near miss. This bar
is drawn in exactly two situations — a page, and Connections with nothing running — and that flag
separates them: false for every page, true for the one screen where this is where you are. With a
shell up the bar is gone and nothing here is read at all. It was left unbound while the bar was
collapsed for the whole of the terminal surface, which made a lit state unreachable; a screen the
bar can now sit under and not point at is the entry looking broken instead.
-->
<views:NavButton Grid.Column="1" Label="Connections" Glyph="⌗"
IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<!--
@@ -146,10 +146,19 @@ internal sealed partial class PhoneShell : UserControl
AvaloniaProperty.Register<PhoneShell, bool>(nameof(IsWide));
/// <summary>Whether the rail down the left edge is drawn.</summary>
/// <remarks>
/// The wide surface's answer to <see cref="ShowsBottomBarProperty"/> and asks the same question about
/// which screen is up, so the two move together — including over Connections with nothing running. See
/// <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsRailProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsRail));
/// <summary>Whether the three-entry bar across the bottom is drawn.</summary>
/// <remarks>
/// Not simply the pages: it also stays up on Connections with nothing running, which is the terminal
/// surface drawing a page. See <see cref="RefreshChrome"/>.
/// </remarks>
public static readonly StyledProperty<bool> ShowsBottomBarProperty =
AvaloniaProperty.Register<PhoneShell, bool>(nameof(ShowsBottomBar));
@@ -212,12 +221,21 @@ internal sealed partial class PhoneShell : UserControl
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Three flags computed here rather than three conditions in the markup, because Avalonia's
/// ◆ <b>Five flags computed here rather than five conditions in the markup, because Avalonia's
/// bindings have no "and" and none of these is a single question any more.</b> Everywhere else on this
/// head that costs a wrapper element; here it would cost two nested ones per row and the header's would
/// have to be an "or", which a wrapper cannot express at all.
/// </para>
/// <para>
/// ◆ <b>The nav stands down for a shell rather than for the terminal surface, and those parted company
/// when that surface gained a page.</b> Connections with nothing running is a box, a CONNECT button and
/// the machines connected to before — see TerminalScreen.axaml — and none of that is worth the screen a
/// shell is worth it for. It is also the one screen somebody can arrive at by closing their last tab,
/// which made the collapsed bar a way to end up with no route to Hosts or Settings but the system back
/// gesture. The header is deliberately not part of this: the surface draws its own bar with the back
/// arrow and the +, and the vault header above that is the second row this head exists to avoid.
/// </para>
/// <para>
/// <b>The header is the one worth reading twice.</b> Narrow, it stands down behind SETTINGS, because the
/// screens under that hub draw their own header with a back arrow and two rows of chrome is what this
/// surface exists to avoid. Wide, there is no hub to be behind and no back arrow to duplicate — the rail
@@ -245,6 +263,14 @@ internal sealed partial class PhoneShell : UserControl
var wide = body.Bounds.Width >= WideAt;
var pages = shell?.IsShowingPages == true;
// ◆ Connections with nothing running, which is the terminal surface drawing a page: a box, a CONNECT
// button and the machines connected to before. The nav stands down for a shell — the whole of the
// arrangement below — and there is no shell here to stand down for, so it stays. Taking it away on
// this one screen was worst where it was least affordable: somebody who has just closed their last
// tab, or who pressed Connections to see what was open and found nothing, was left on a screen whose
// only way to Hosts or Settings was the system back gesture.
var connectPage = shell is { IsTerminalSurface: true, HasTabs: false };
// The editor is a page of its own now, so nothing else is drawn around it — not the vault header,
// not the shells strip, and not the way off the screen. Its own header carries the back arrow, which
// is the one control it needs and the one the system gesture already maps to.
@@ -266,8 +292,8 @@ internal sealed partial class PhoneShell : UserControl
}
IsWide = wide;
ShowsRail = wide && pages && !editing;
ShowsBottomBar = !wide && pages && !editing;
ShowsRail = wide && (pages || connectPage) && !editing;
ShowsBottomBar = !wide && (pages || connectPage) && !editing;
ShowsShellStrip = pages && !editing;
ShowsHostSelectionBar = pages && choosing && !editing;
ShowsVaultHeader = pages && !editing && !choosing && (wide || shell?.IsMoreSurface != true);
@@ -64,6 +64,26 @@
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ Which vault a *new* snippet is filed into. Hidden for an existing one — its vault is not a
field of this form, and changing it is MOVE below — and hidden entirely where there is only one
vault to choose between, which is where most people stay.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" />
@@ -125,6 +145,15 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" />
</Border>
<!--
◆ Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert into their own terminals,
and this card is the only place that fact appears before somebody taps EDIT.
-->
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
@@ -151,20 +180,66 @@
IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" />
<StackPanel Spacing="9" IsVisible="{Binding ShowsSelectionActions}">
<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. -->
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
IsEnabled="{Binding CanInsert}" />
<!-- 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}"
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
IsEnabled="{Binding CanInsert}" />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteCommand}" />
</Grid>
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<!--
◆ Sharing the snippet. Beside EDIT rather than inside it, which is the line the hosts screen
draws too: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do. It shows only where there is somewhere to
move to; see SnippetsViewModel.CanMove.
-->
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMove}" Command="{Binding MoveCommand}" />
</Grid>
<!--
◆ A row of its own, under the pair rather than beside them, as the hosts screen puts it. A phone
has no hover and no tooltip, so the only thing separating a destructive button from an ordinary
one is where a thumb lands.
-->
<Button Classes="danger" Height="44" Content="DELETE" Command="{Binding DeleteCommand}" />
</StackPanel>
<!--
◆ MOVING THE SNIPPET TO ANOTHER VAULT, in the place the insert controls were. A picker and two
buttons rather than a question with a yes: what is being asked is which vault, and a move is undone
by moving it back.
The sentence is not decoration. Unlike a host, a snippet crosses whole — there is no group and no
tag to leave behind — so what there is to say is who can read it afterwards; and on a phone, where
the status line is one line at the bottom of a screen somebody has already navigated away from,
before the tap is the only place that reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveCommand}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>
@@ -19,9 +19,13 @@
── the screen a shell gets ───────────────────────────────────────────────────────────────────────────
A connected phone shows one bar and then the terminal. The vault header, the shells strip and the
four-entry bottom bar are all collapsed by PhoneShell while this surface is up, and what replaces them
three-entry bottom bar are all collapsed by PhoneShell while a shell is showing, and what replaces them
is the row below: back, the sessions, and the way to open another one.
With nothing running it is only the header and the strip. This surface is then the connect page below —
a box and the machines connected to before — which is not what the screen was being cleared for, and is
the one screen reachable by closing your last tab, so the nav stays. See PhoneShell.RefreshChrome.
That is a trade, and the thing bought is the only one a terminal really wants. At 360dp the chrome this
screen used to sit inside came to 254 pixels of a roughly 780-pixel display — a third of it — and every
one of those rows was about somewhere the user was not. What is given up is the bottom bar's one-tap
@@ -389,9 +393,10 @@
of them.
It lives here rather than in PhoneShell for the reason the add sheet lives in its own screen: nothing
but this surface raises it. The scrim reaching only the screen area is not a compromise here the way
it was there — the bottom bar is collapsed while a terminal is showing, so the screen area is the
display.
but this surface raises it. The scrim reaching only the screen area is the full display while a shell
is showing, because the bottom bar is collapsed for it. Raised from the connect page it is not, and
that lands on the add sheet's own answer: the three destinations under the scrim stay tappable, and for
a menu of places to connect from, navigating away is a perfectly good way to decide not to.
Every row navigates away from the terminal. That is not a side effect of the menu, it is the menu:
each of the three destinations is a picker, and the shell they open lands back on this surface as a
@@ -689,6 +689,26 @@
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<!--
◆ THE KEY, WHICH IS THE HALF THE SENTENCE ABOVE CANNOT PROMISE. A binding resolves across
vaults, so the host goes on working here either way — but the colleagues it has just joined
hold one vault's key, and a host whose key stayed behind is one they cannot connect with.
Unticked, and it has to be: moving a key into a team's vault hands it to everybody who holds
that key. The note under it is the count, because a key twenty machines authenticate with is a
different decision from one nothing else uses, and neither is visible from here otherwise.
-->
<CheckBox IsChecked="{Binding BringsTheBindingAlong}"
IsVisible="{Binding HasABindingToBring}"
ToolTip.Tip="Moves the key or password itself into the same vault, and re-aims every host and group that used it at where it has gone.">
<TextBlock Text="{Binding BindingToBringQuestion}" Classes="hint" FontSize="12"
TextWrapping="Wrap" />
</CheckBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasABindingToBring}"
Text="{Binding BindingToBringNote}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" />
@@ -297,9 +297,50 @@
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<!--
Only where there is somewhere to move to, unlike EDIT beside it, which is the same rule the
host's MOVE follows on the phone: a button that answers with "this is the only vault you can
write to" is a button that should not have been drawn. Keys and passwords only — a tag and a
bucket are read from the active vault alone, so "another vault" is not a question they have.
-->
<Button Classes="ghost" Content="MOVE" Command="{Binding MoveSelectedItemCommand}"
IsVisible="{Binding CanMoveSelectedItem}"
ToolTip.Tip="Re-encrypts this under another vault's key, and re-aims every host and group that used it at where it has gone." />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
◆ MOVING THE ITEM TO ANOTHER VAULT, in the place those buttons were. The host's panel, over
here — see HostDrawer.axaml — and what it is for is the thing a shared vault could not do until
now: a key typed into a personal vault before the team existed was stuck there, and the only
way across was to paste the private half into a second item and delete the first.
The two sentences under the picker are the whole of the decision. The first says what a move
is; the second says what points at this key, because everything that does is re-aimed at it in
its new vault and somebody moving a key twenty machines use should see the twenty first.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMovingItem}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveItemVaultChoices}"
SelectedItem="{Binding SelectedMoveItemVault}">
<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="It is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody in the vault it leaves can." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasMovingItemUsage}"
Text="{Binding MovingItemUsage}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveItemCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveItemCommand}" />
</StackPanel>
</StackPanel>
<!--
The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in
@@ -63,6 +63,14 @@
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
<!--
Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert; the row is where somebody
decides whether the thing they are about to edit is theirs alone.
-->
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
@@ -89,6 +97,16 @@
IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" />
<!--
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do — and a picker inside the form would let
somebody correcting a typo hand a command to a team by leaving it where they found it. The
picker itself opens beside the snippet, in the pane on the right.
-->
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
IsEnabled="{Binding CanMove}"
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
</StackPanel>
</Border>
@@ -102,6 +120,27 @@
<!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
one vault to choose between, because a control offering one option is a question nobody asked.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
@@ -133,6 +172,16 @@
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<!--
Named here as well as on the row, because this pane is where somebody decides to put a
command into a production terminal, and who else holds a key to it is part of that decision.
The row's badge is off the screen by the time this is being read.
-->
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
IsVisible="{Binding HasSelectionVaultBadge}">
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
</Border>
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
@@ -150,18 +199,51 @@
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
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"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
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"
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." />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
<!--
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
two buttons, not a question with a yes: what is being asked is which vault, and a move is
undone by moving it back rather than by being careful — so this is not drawn in the danger
colours the deletion question uses.
It takes the insert controls' place while it is up, for the reason the host pane hides
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
group and no tags to leave behind — so what there is to say is who can read it afterwards,
and for a command that may carry a hostname or a path that is the whole of the decision.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
@@ -35,6 +35,15 @@ internal sealed record InsertTarget(uint? SessionId, string Label)
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
/// </para>
/// <para>
/// <b>A snippet can be shared, which is why this screen has two vault controls rather than none.</b> The
/// editor asks which vault a <em>new</em> snippet goes into, and the move panel re-seals an existing one into
/// another — two controls because they are two different acts. A save writes a payload; a move re-encrypts it
/// under a second key and tombstones the first, so putting that in the editor would let somebody fixing a
/// typo hand a command to a team by leaving a picker where they found it. Both live here rather than on
/// <see cref="VaultViewModel"/> because this screen owns its editor, unlike the host pane; the writing they
/// ask for is still the vault's.
/// </para>
/// </remarks>
internal sealed partial class SnippetsViewModel : ObservableObject
{
@@ -103,7 +112,100 @@ internal sealed partial class SnippetsViewModel : ObservableObject
[ObservableProperty]
private string status = string.Empty;
internal bool HasSnippets => vault.Snippets.Count > 0;
/// <summary>
/// The vault the open editor will write to.
/// </summary>
/// <remarks>
/// Latched when the editor opens — the chosen vault for a new snippet, the row's own vault for an
/// existing one — rather than read back off the selection at save time. The list this screen shows spans
/// every readable vault now, so a save that reached for the active vault instead would fork a colleague's
/// snippet into a private copy; and a selection that moved under a half-typed form would send the text to
/// whichever row happened to be highlighted. The same reason <c>VaultViewModel.editingHostVaultId</c>
/// exists.
/// </remarks>
private Guid editorVaultId;
/// <summary>Which vault the open move panel would send the snippet to.</summary>
private SnippetRowViewModel? moving;
/// <summary>
/// The vaults a new snippet may be filed into.
/// </summary>
/// <remarks>
/// Filled from <c>VaultViewModel.TargetVaults</c>, which is already the readable-and-writable set: a
/// vault this session cannot read has no key to encrypt with, and one it can read but not write is a
/// team vault this account is a viewer of. The options are the shared objects rather than copies, so
/// this picker and the keychain screen's show the same names without either being able to move the
/// other — what they do not share is the selection.
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? editorSelectedVault;
/// <summary>
/// Whether the editor should be asking which vault this snippet goes into.
/// </summary>
/// <remarks>
/// Only while creating, and only where there is more than one vault to choose between. An existing
/// snippet's vault is not a field of this form — moving it is a re-seal and a tombstone rather than a
/// save, offered by <see cref="Move"/> — and a control offering one option is a question nobody was
/// asked.
/// </remarks>
internal bool ShowsEditorVaultChoice => IsCreating && EditorVaultChoices.Count > 1;
/// <summary>Whether the panel asking which vault to move the selected snippet to is up.</summary>
/// <remarks>
/// The armed-state idiom this application uses instead of a modal, carrying a choice rather than a yes:
/// the question is not "are you sure" but "which vault".
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
private bool isMoving;
/// <summary>Where the selected snippet could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveVault;
/// <summary>
/// Whether the selected snippet has anywhere to move to.
/// </summary>
/// <remarks>
/// Asked so the phone can leave the button out rather than draw one that answers with a refusal, as the
/// hosts screen does. It counts vaults rather than merely asking whether there are two, because the
/// answer is per snippet: one already in the only other writable vault has nowhere to go.
/// </remarks>
internal bool CanMove =>
Selected is { IsReadOnly: false } row && vault.MoveTargetsBesides(row.VaultId).Count > 0;
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
/// <remarks>
/// Off while the move panel is up, which takes their place — the same rule the host pane's
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
/// otherwise still be there offering to open it again.
/// </remarks>
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
/// <remarks>
/// For the detail pane, which is where somebody decides whether to insert a command into a production
/// terminal. Who else can read it is part of that, and the badge on the row is gone by the time the pane
/// is being read.
/// </remarks>
internal string SelectionVaultBadge => Selected?.VaultBadge ?? string.Empty;
/// <summary>Whether there is a vault to name beside the selected snippet.</summary>
internal bool HasSelectionVaultBadge => SelectionVaultBadge.Length > 0;
/// <summary>Whether this keychain holds any snippet the screen would draw.</summary>
/// <remarks>
/// Counts what a hidden vault leaves behind rather than the whole list, so that switching a team's vault
/// off and emptying the screen produces the "nothing saved yet" copy rather than a filter box over
/// nothing.
/// </remarks>
internal bool HasSnippets => vault.Snippets.Any(row => vault.IsVaultShown(row.VaultId));
internal bool HasVisible => Visible.Count > 0;
@@ -136,19 +238,31 @@ internal sealed partial class SnippetsViewModel : ObservableObject
+ "typing it again.";
/// <summary>Starts a new snippet.</summary>
/// <remarks>
/// The vault picker lands on wherever the keychain screen is filing new items — the personal vault
/// unless that has been changed — because a snippet put in a team's vault is a command everybody in
/// that team can read, and that has to be chosen rather than defaulted into.
/// </remarks>
[RelayCommand]
private void New()
{
CloseMovePanel();
EditingId = null;
EditorLabel = string.Empty;
EditorCommand = string.Empty;
EditorNotes = string.Empty;
EditorRunsOnInsert = false;
BuildEditorVaultChoices(vault.TargetVaultId);
IsEditing = true;
Status = "Adding a snippet.";
}
/// <summary>Opens the selected snippet for editing.</summary>
/// <remarks>
/// The editor writes back to the vault this row came out of, which is what the latch is for. The picker
/// is not drawn for an existing snippet: its vault is not a field of this form.
/// </remarks>
[RelayCommand]
private void Edit()
{
@@ -163,11 +277,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
return;
}
CloseMovePanel();
EditingId = row.EntityId;
EditorLabel = row.Snippet.Label;
EditorCommand = row.Snippet.Command;
EditorNotes = row.Snippet.Notes ?? string.Empty;
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
BuildEditorVaultChoices(row.VaultId);
IsEditing = true;
Status = $"Editing {row.Label}.";
}
@@ -197,7 +314,8 @@ internal sealed partial class SnippetsViewModel : ObservableObject
RunsOnInsert = EditorRunsOnInsert,
};
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
var saved = await vault.SaveSnippetAsync(editorVaultId, EditingId, snippet, cancellationToken)
.ConfigureAwait(true);
if (!saved)
{
@@ -224,6 +342,105 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status;
}
/// <summary>
/// Opens the panel that asks which vault the selected snippet should move to.
/// </summary>
/// <remarks>
/// <para>
/// How a snippet gets shared: a command one person keeps becomes one the team holds a key to. A panel
/// rather than a picker in the editor, because a move is a re-seal under the destination's key and a
/// tombstone in the source — see <c>VaultItemRepository.MoveAsync</c> — and that must not happen as a
/// side effect of saving a corrected typo.
/// </para>
/// <para>
/// Refused for a snippet a newer client wrote, as editing one is, and refused with the editor open: two
/// forms about the same snippet, one of which moves it, is not something anybody should have to read
/// carefully.
/// </para>
/// </remarks>
[RelayCommand]
private void Move()
{
if (Selected is not { } row || IsEditing)
{
return;
}
if (row.IsReadOnly)
{
Status = "This snippet was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
var choices = vault.MoveTargetsBesides(row.VaultId);
if (choices.Count == 0)
{
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
// somebody in a team whose only other vault is read-only sees.
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
MoveVaultChoices.Clear();
foreach (var choice in choices)
{
MoveVaultChoices.Add(choice);
}
SelectedMoveVault = MoveVaultChoices[0];
moving = row;
IsMoving = true;
Status = string.Empty;
}
/// <summary>Abandons the move panel.</summary>
[RelayCommand]
private void CancelMove()
{
CloseMovePanel();
Status = string.Empty;
}
/// <summary>
/// Moves the selected snippet into the chosen vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>The snippet crosses whole.</b> Nothing on it points at an item of the vault it is leaving — a
/// snippet is a label, a command and a note — so unlike a host there is no group and no tag to strip,
/// and nothing to report as left behind.
/// </para>
/// <para>
/// The row is re-selected by its new id afterwards. A move carries the item into the destination under a
/// fresh id, so a screen that went on looking for the old one would leave the pane empty and read as the
/// snippet having been deleted.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveAsync(CancellationToken cancellationToken)
{
if (moving is not { } row || SelectedMoveVault is not { } target)
{
return;
}
CloseMovePanel();
var moved = await vault.MoveSnippetAsync(row, target, cancellationToken).ConfigureAwait(true);
// The reload inside the move refilled the list, which rebuilt this one and dropped a selection
// keyed on an id that no longer exists.
if (moved is { } entityId)
{
Selected = Visible.FirstOrDefault(candidate => candidate.EntityId == entityId);
}
Status = vault.Status;
}
/// <summary>
/// Types the selected snippet into the selected terminal, without pressing Enter.
/// </summary>
@@ -282,14 +499,78 @@ internal sealed partial class SnippetsViewModel : ObservableObject
partial void OnFilterChanged(string value) => Rebuild();
/// <remarks>
/// The move panel folds away with the selection it was opened about. Without that, a filter that stopped
/// matching the snippet would leave a picker on screen aimed at a row nobody can see.
/// </remarks>
partial void OnSelectedChanged(SnippetRowViewModel? value)
{
if (IsMoving && value?.EntityId != moving?.EntityId)
{
CloseMovePanel();
}
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
OnPropertyChanged(nameof(CanMove));
OnPropertyChanged(nameof(ShowsSelectionActions));
OnPropertyChanged(nameof(SelectionVaultBadge));
OnPropertyChanged(nameof(HasSelectionVaultBadge));
}
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
partial void OnEditingIdChanged(Guid? value)
{
OnPropertyChanged(nameof(IsCreating));
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
}
/// <summary>
/// Moves a half-typed snippet into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
/// the control. An existing snippet <em>can</em> change vaults — see <see cref="Move"/> — but not this
/// way and not as part of a save: reassigning it here on an edit would write the snippet into a second
/// vault and leave the original behind, which is a fork rather than a move.
/// </remarks>
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
if (value is null || EditingId is not null)
{
return;
}
editorVaultId = value.VaultId;
}
/// <summary>Refills the editor's vault picker, landing on the vault the editor will write to.</summary>
private void BuildEditorVaultChoices(Guid vaultId)
{
editorVaultId = vaultId;
EditorVaultChoices.Clear();
foreach (var choice in vault.TargetVaults)
{
EditorVaultChoices.Add(choice);
}
// Null where the snippet's vault is one this session cannot write — a team vault this account is a
// viewer of. The picker is hidden for an existing snippet anyway, and an empty box is a better
// answer than an option that would move the snippet if it were touched.
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
}
private void CloseMovePanel()
{
IsMoving = false;
moving = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
}
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
internal bool IsCreating => EditingId is null;
@@ -315,14 +596,27 @@ internal sealed partial class SnippetsViewModel : ObservableObject
OnPropertyChanged(nameof(HasSnippets));
OnPropertyChanged(nameof(HasVisible));
OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(CanMove));
}
/// <remarks>
/// <para>
/// The command is searched as well as the name and the notes, because half of what somebody remembers
/// about a saved command is a word that was in it.
/// </para>
/// <para>
/// A hidden vault's snippets come off here rather than out of <c>VaultViewModel.Snippets</c>, which is
/// the rule that list follows for keys and passwords too: the projection is filtered and the list stays
/// whole. Hiding a vault is a preference about what is drawn, not about what the keychain contains.
/// </para>
/// </remarks>
private bool Matches(SnippetRowViewModel row)
{
if (!vault.IsVaultShown(row.VaultId))
{
return false;
}
var needle = Filter.Trim();
if (needle.Length == 0)
File diff suppressed because it is too large Load Diff
@@ -48,6 +48,16 @@ public sealed class CredentialRepository(
CancellationToken cancellationToken) =>
credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <inheritdoc cref="SshKeyRepository.MoveAsync" path="/remarks" />
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
CredentialSecret credential,
CancellationToken cancellationToken) =>
credentials.MoveAsync(fromVaultId, toVaultId, entityId, credential, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
credentials.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -41,6 +41,20 @@ public sealed class SnippetRepository(
CancellationToken cancellationToken) =>
snippets.UpdateAsync(vaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// What sharing a snippet is, underneath. A snippet has no group, no tags and no key binding — see
/// <see cref="SnippetSecret"/> — so unlike a host it crosses whole: nothing about it points at an item
/// of the vault it is leaving, and there is consequently nothing to strip on the way across.
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SnippetSecret snippet,
CancellationToken cancellationToken) =>
snippets.MoveAsync(fromVaultId, toVaultId, entityId, snippet, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
snippets.DeleteAsync(vaultId, entityId, cancellationToken);
@@ -47,6 +47,29 @@ public sealed class SshKeyRepository(
CancellationToken cancellationToken) =>
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.MoveAsync" />
/// <remarks>
/// <para>
/// The same two writes a host's move is, and the reason a key needs one at all is what a vault is for:
/// a key created in a personal vault before a team existed is the key the team's hosts authenticate
/// with, and until this existed the only way to get it across was to paste the private half into a
/// second item and delete the first — which is a private key on a clipboard, and two items nobody can
/// tell apart afterwards.
/// </para>
/// <para>
/// <b>It lands with a new id</b>, as everything moved does, so every host and group default naming the
/// old one is left pointing at a tombstone. Re-aiming them is the caller's, because only the caller
/// knows which of them it is allowed to rewrite — see <c>VaultViewModel.ReAimAtAsync</c>.
/// </para>
/// </remarks>
public Task<Guid> MoveAsync(
Guid fromVaultId,
Guid toVaultId,
Guid entityId,
SshKeySecret key,
CancellationToken cancellationToken) =>
keys.MoveAsync(fromVaultId, toVaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
keys.DeleteAsync(vaultId, entityId, cancellationToken);