Let a snippet be shared to a vault, the way a host already can

A snippet was a first-class vault item everywhere except where it mattered: the
crypto, the sync, the server table and every registry already treated it exactly
as they treat a host, and the screen read it out of the active vault alone. So
the one command a team most obviously wants to hold in common — the incantation
somebody worked out once and everybody else retypes — was the only item kind that
could not leave the machine that wrote it.

The read is the half that had to come first, and it is why this is not simply a
MoveAsync. ReloadSnippetsAsync now lists every readable vault rather than the
active one, in the shape ReloadHostsAsync and ReloadKeysAsync already use: the
vault new items go into first, then by vault name, then by label, with a badge on
the row only where there is more than one vault to tell apart. Without that, a
snippet moved into a team vault would have disappeared from the very screen that
moved it, and one a colleague wrote there would never have arrived at all —
sharing would have looked like losing.

Three writes were pinned to the active vault and each one broke differently once
the list spanned several. The delete tombstoned in the wrong vault, which
tombstones nothing and leaves the snippet on screen. The save is the bad one: an
update sent to the active vault creates a second snippet there and leaves the
team original untouched, so the person editing sees their fix and nobody else
ever does. That is a fork with no symptom, which is why the vault is now a
parameter and the screen latches it when the editor opens — the chosen vault for
a new snippet, the row own vault for an existing one — rather than reading it
back off a selection that can move under a half-typed form. VaultViewModel has
carried editingHostVaultId for the same reason since hosts crossed vaults.

Two controls rather than one, and that is the same line the host pane draws. The
editor asks which vault a new snippet is filed into; MOVE re-seals an existing one
under another key and tombstones the first. Putting the second inside the first
would let somebody correcting a typo hand a command to a team by leaving a picker
where they found it, so the picker is not drawn for an existing snippet at all.
Both live on SnippetsViewModel rather than VaultViewModel because this screen owns
its editor, unlike the host drawer; the writing they ask for is still the vault.

A snippet crosses whole, which is the one way this is simpler than the host it
copies. A host leaves its group and its tags behind because both are items of the
vault it came from and would dangle for everybody in the destination. A snippet is
a label, a command and a note, and none of them points at anything — so there is
nothing to strip, nothing to report as left behind, and what the copy says instead
is the thing that is actually at stake: who can read the command afterwards. For a
command that may carry a hostname or a path, that is the whole decision.

Two judgement calls worth finding later. A hidden vault now hides its snippets,
filtered in the screen projection rather than in VaultViewModel.Snippets, which is
the rule keys and passwords already follow: the list stays whole so nothing that
resolves against it breaks, and the projection is what a preference about reading
gets to change. And the nav rail count is left spanning vaults unfiltered, because
Vault.Hosts.Count beside it is unfiltered too — filtering one of the four would
make the rail disagree with itself.

Four flow tests in VaultSharingTests, beside the host ones they mirror: the move
re-seals with a new id and carries the runs-on-insert flag across, the move with
nowhere to go refuses rather than opening an empty picker, the editor files into
the vault chosen on it, and the edit of a shared snippet goes back to its own
vault instead of forking. That last one is the regression the latch exists for and
the only one whose absence has no visible symptom. Plus a layout test with the
move panel open, since that paragraph wraps in a 300-pixel column and the desktop
pane it lands in is measured.

The whole suite passes: 1660 tests, none failing.
This commit is contained in:
2026-08-06 07:39:03 +02:00
parent 174ef7c420
commit 3d9ed03b09
7 changed files with 876 additions and 36 deletions
@@ -883,6 +883,38 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the editor open"), snippets);
}
/// <remarks>
/// <para>
/// The move panel, which is how a snippet gets shared and takes the insert controls' place while it is
/// up: a heading, a combo box, a wrapping paragraph and two buttons, in the same 300-pixel column the
/// detail pane has. The paragraph is the risk — it is what says who can read the command afterwards.
/// </para>
/// <para>
/// The state is set here rather than through <c>MoveCommand</c>, which would refuse: this fixture's
/// account holds one vault, and the command declines rather than open a picker with nothing in it. The
/// flow that fills it is covered in <c>DodoSSH.Client.App.Tests</c>. The same arrangement, and the same
/// reason, as <see cref="TheHostDrawerFitsWithTheMovePanelOpen"/>.
/// </para>
/// </remarks>
[Fact]
public async Task TheSnippetsScreenFitsWithTheMovePanelOpen()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
snippets.Selected = snippets.Visible.Single(row => row.RunsOnInsert);
snippets.MoveVaultChoices.Add(
new VaultChoiceViewModel(Guid.CreateVersion7(), "Platform Engineering secrets", false));
snippets.SelectedMoveVault = snippets.MoveVaultChoices[0];
snippets.IsMoving = true;
snippets.ShowsSelectionActions.ShouldBeFalse("the panel takes the pane rather than sharing it");
await MeasureSnippetsAsync(faults => faults.ShouldBeEmpty("with the move panel open"), snippets);
}
[Fact]
public async Task TheSnippetsScreenFitsWhenTheFilterMatchesNothing()
{
@@ -1919,6 +1951,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
private async Task SeedSnippetsAsync()
{
await vault.SaveSnippetAsync(
vault.TargetVaultId,
null,
new SnippetSecret
{
@@ -1929,6 +1962,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
Token);
await vault.SaveSnippetAsync(
vault.TargetVaultId,
null,
new SnippetSecret
{