Public Access
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:
@@ -151,10 +151,31 @@ internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
|
||||
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
|
||||
/// per click.
|
||||
/// </remarks>
|
||||
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||||
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet, Guid vaultId, string vaultName)
|
||||
{
|
||||
internal Guid EntityId => snippet.EntityId;
|
||||
|
||||
/// <summary>Which vault this snippet lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
/// <remarks>
|
||||
/// What makes a snippet shareable rather than private. The list spans every readable vault now, so an
|
||||
/// edit and a deletion both have to return to the vault the snippet came out of — saving a team's
|
||||
/// snippet into the active vault instead would leave the original untouched and put a second copy
|
||||
/// somewhere only the person editing it can see.
|
||||
/// </remarks>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
/// <summary>
|
||||
/// The vault name to print on this row, or empty when there is only one vault to be in.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Whether this row has a vault to name.</summary>
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
internal SnippetSecret Snippet => snippet.Secret;
|
||||
|
||||
internal string Label => snippet.Secret.Label;
|
||||
@@ -3285,37 +3306,79 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <returns>How many snippets would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
|
||||
/// list changing the way every other screen does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every readable vault, not the active one, which is what makes a snippet shareable.</b> The read is
|
||||
/// the half that has to come first: a snippet moved into a team's vault by the machine that owns it would
|
||||
/// otherwise vanish from the list that moved it, and one a colleague wrote there would never appear at
|
||||
/// all — sharing would look like losing. The same shape as <see cref="ReloadHostsAsync"/> and
|
||||
/// <see cref="ReloadKeysAsync"/>, down to the ordering: the vault new items go into first, then by vault
|
||||
/// name, then by label, because two vaults may hold a snippet called the same thing and which vault it
|
||||
/// is in is the only thing that tells them apart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Snippets
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var unreadable = 0;
|
||||
var rows = new List<SnippetRowViewModel>();
|
||||
|
||||
var readable = session.ReadableVaults.ToList();
|
||||
var several = readable.Count > 1;
|
||||
|
||||
foreach (var vault in readable)
|
||||
{
|
||||
var listing = await session.Snippets
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new SnippetRowViewModel(item, vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart, as the host grid's badge is.
|
||||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||
}));
|
||||
}
|
||||
|
||||
Snippets.Clear();
|
||||
|
||||
foreach (var snippet in listing.Items
|
||||
.OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
|
||||
foreach (var snippet in rows
|
||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||||
{
|
||||
Snippets.Add(new SnippetRowViewModel(snippet));
|
||||
Snippets.Add(snippet);
|
||||
}
|
||||
|
||||
return listing.Unreadable;
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
|
||||
/// <param name="vaultId">The vault to write it into.</param>
|
||||
/// <param name="entityId">The snippet to replace, or null to create one.</param>
|
||||
/// <param name="snippet">What to store.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
|
||||
/// same immediate push as every other save. The screen decides <em>what</em> a snippet is and nothing
|
||||
/// else.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The vault is a parameter rather than the active one</b>, and that is not tidiness: the screen
|
||||
/// latches it when the editor opens — the chosen vault for a new snippet, the row's own for an existing
|
||||
/// one — because an update sent to the active vault would write a second copy there and leave the team's
|
||||
/// original untouched, which is a fork nobody would see until a colleague asked why the change never
|
||||
/// arrived. The same rule <c>editingHostVaultId</c> carries for hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<bool> SaveSnippetAsync(
|
||||
Guid vaultId,
|
||||
Guid? entityId,
|
||||
SnippetSecret snippet,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -3335,13 +3398,13 @@ internal sealed partial class VaultViewModel(
|
||||
if (entityId is { } existing)
|
||||
{
|
||||
await session.Snippets
|
||||
.UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
|
||||
.UpdateAsync(vaultId, existing, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await session.Snippets
|
||||
.CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
|
||||
.CreateAsync(vaultId, snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -3358,6 +3421,10 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for one snippet.</summary>
|
||||
/// <remarks>
|
||||
/// The tombstone goes to the vault the row came out of, which the row carries. Deleting out of the
|
||||
/// active vault instead would tombstone nothing and leave the snippet on screen.
|
||||
/// </remarks>
|
||||
internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
@@ -3371,7 +3438,7 @@ internal sealed partial class VaultViewModel(
|
||||
async () =>
|
||||
{
|
||||
await session.Snippets
|
||||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||||
.DeleteAsync(row.VaultId, entityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -3381,6 +3448,76 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-seals one snippet under another vault's key and tombstones the original.
|
||||
/// </summary>
|
||||
/// <param name="row">The snippet to move.</param>
|
||||
/// <param name="target">The vault it should end up in.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Its id in the destination, or null when nothing was moved.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The write half of sharing a snippet; the panel that asks which vault belongs to the screen, as the
|
||||
/// snippet editor does. See <c>SnippetsViewModel.Move</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A snippet crosses whole</b>, which is the one way this is simpler than <see cref="MoveHost"/>.
|
||||
/// A host leaves its group and its tags behind because both are items of the vault it came from; a
|
||||
/// snippet is a label, a command and a note, and none of them points at anything — so there is nothing
|
||||
/// to strip and nothing to warn about. What the caller still has to say is that the command is now
|
||||
/// readable by everybody holding the destination's key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refused for a snippet a newer client wrote, exactly as editing one is: the move re-encodes the
|
||||
/// payload here, so a field this build cannot represent would be dropped on the way across.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<Guid?> MoveSnippetAsync(
|
||||
SnippetRowViewModel row,
|
||||
VaultChoiceViewModel target,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet was written by a newer version of DodoSSH. Moving it would re-encode it "
|
||||
+ "here and lose what this build cannot read. Update first.";
|
||||
return null;
|
||||
}
|
||||
|
||||
Guid? moved = null;
|
||||
|
||||
await RunAsync(
|
||||
"Moving…",
|
||||
async () =>
|
||||
{
|
||||
moved = await session.Snippets
|
||||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, row.Snippet, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Moved '{row.Label}' to {target.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||||
// them and not the other shows the snippet twice or not at all until the next pass.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <summary>Every vault this session can write to except one, for a screen that owns its own picker.</summary>
|
||||
/// <remarks>
|
||||
/// The snippets screen's move panel lives on <c>SnippetsViewModel</c> — its editor does too — so it
|
||||
/// needs the same list <see cref="BuildMoveVaultChoices"/> fills the host's panel from, in the same
|
||||
/// order. Shared rather than written twice, for the reason <see cref="WritableVaultsBesides"/> gives.
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<VaultChoiceViewModel> MoveTargetsBesides(Guid vaultId) =>
|
||||
[.. WritableVaultsBesides(vaultId)];
|
||||
|
||||
/// <returns>How many groups would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
||||
Reference in New Issue
Block a user