Public Access
Draw the tags that have been storable and invisible since the domain landed
`Tag` has been a full item kind for three commits — a table, a migration, a codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two people tagging one host both keep theirs. Nothing drew a chip. The tags a client could store were ones nothing here could see. Chips on host rows, both heads, from names resolved through the tag list rather than ids: a tag that does not resolve is left out rather than drawn, because it means the tag was deleted elsewhere or belongs to a vault this session cannot read, and a host with one chip fewer is the honest answer where a host wearing a GUID is not. The id stays on the host, so the chip comes back if the tag does. The picker is chips that toggle, matching the chips on the row behind it. A list of names to tick would make the user match an entry to a chip they can see two inches away. The box under it creates a tag and puts it on straight away, because that is when a tag is usually wanted — while tagging a host and finding it does not exist yet. Unlike every other field in that editor it writes to the keychain immediately, since a host can only name an id that exists; cancelling therefore leaves the tag behind, which is honest rather than hidden. A name that already exists is used rather than repeated: two tags called "staging" are storable and must stay storable, because two people creating one offline is how it happens, but typing it into a box beside a chip of the same name is a slip. Renaming and deleting needed a home, or the picker fills with names nobody uses and never empties. That home is a TAGS category on the keychain screen, where every other item kind is managed — and renaming is the whole reason a tag is an item rather than a string repeated inside twenty payloads: it is one write, and no host is touched. The delete confirmation counts the hosts wearing it, which is the difference between a tidy-up and losing a filter somebody relies on. The desktop host editor now scrolls, and that is not a tidy-up. A picker's height is a chip per tag in the keychain, wrapped, so somebody with fifteen tags has an editor half again as tall as somebody with three; no fixed height holds that, and trimming other fields to buy room only moves the failure to whoever has sixteen. The layout suite caught it the moment its seeder grew tags — which is why the seeder now creates ten rather than three, enough to drive the pane onto its cap so the capped shape is what gets measured rather than one no real keychain produces. The cost is named where it is paid: the harness skips anything inside a ScrollViewer, so from here it certifies that pane fits the column rather than that every field in it does. Two smaller things fell out. Five buttons overflowed the keychain header by a few pixels, so GENERATE lost the word KEY — its tooltip carries what the word did. And TotalItemCount had been counting keys and credentials while ALL showed four kinds; it counts all five now, because a number under a chip that disagrees with the rows it opens is worse than no number. An adversarial review of this change found two defects it had introduced, both green against the full suite. NewTag filed into the "new items go to" picker while the tag list only ever holds the active vault's — so with a team vault selected a tag would be created, queued for push, reported as added, and then invisible, with no row, no count, no picker entry and nothing able to rename or delete it, because there is no active-vault switcher to go and find it with. The comment on the host editor's own create path states that exact rule; this was the one place that broke it, and NewObjectStore, whose list is likewise active-vault-only, already ignored the picker. And the tag editor was the only one of five that did not disarm a pending deletion when it opened, so arming a key's deletion and then pressing + TAG left a live DELETE for an item the user was no longer looking at, directly above the boxes they were typing into. Both are fixed, both have a test, and the first was checked against the broken version before being kept. The same review caught a doc comment that had been inserted between SnippetRowViewModel's summary and its declaration, silently taking it over. Verified by the whole suite on a clean build: 1413 tests over nineteen projects, none failing. Both heads build. The rectangles the layout suite cannot reach are phase 9 of docs/manual-checks.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -128,6 +128,58 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
/// <summary>One tag, as a row in the keychain table.</summary>
|
||||
/// <remarks>
|
||||
/// The narrowest row on that screen — a tag is a name — and it is there rather than only inside the host
|
||||
/// editor for the reason the type exists at all: a tag is worth being an item because renaming it is one
|
||||
/// write instead of twenty, and a rename needs somewhere to happen. Deleting needs somewhere too, or the
|
||||
/// picker fills with names nobody uses and never empties.
|
||||
/// </remarks>
|
||||
internal sealed class TagRowViewModel(VaultItem<TagSecret> tag, int hostCount)
|
||||
{
|
||||
internal Guid EntityId => tag.EntityId;
|
||||
|
||||
internal TagSecret Tag => tag.Secret;
|
||||
|
||||
internal string Label => tag.Secret.Label;
|
||||
|
||||
internal int HostCount => hostCount;
|
||||
|
||||
internal bool IsReadOnly => tag.IsReadOnly;
|
||||
|
||||
internal bool HasUnsyncedChanges => tag.HasUnsyncedChanges;
|
||||
|
||||
internal string Badge => ItemBadge.For(tag.IsBlocked, tag.IsReadOnly, tag.HasUnsyncedChanges);
|
||||
|
||||
/// <summary>What the row says under the name.</summary>
|
||||
/// <remarks>
|
||||
/// The count, because it is the only fact a tag has beyond its name and it is the one that decides
|
||||
/// whether deleting it matters. A tag on nothing is a tidy-up; a tag on twenty machines is a filter
|
||||
/// somebody relies on.
|
||||
/// </remarks>
|
||||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One tag in the host editor's picker, and whether this host wears it.
|
||||
/// </summary>
|
||||
/// <param name="EntityId">The tag item.</param>
|
||||
/// <param name="Label">What to show on the chip.</param>
|
||||
/// <param name="IsWorn">Whether the host being edited currently carries it.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A chip that toggles rather than a multi-select list, because that is what a tag looks like everywhere
|
||||
/// else on both heads — the row already draws worn tags as chips, and a picker drawn as anything else
|
||||
/// would make the user match a list entry to a chip they can see two inches away.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents.
|
||||
/// A mutable <c>IsWorn</c> would need change notification on every chip in the vault to make one toggle
|
||||
/// redraw.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn);
|
||||
|
||||
/// <summary>One host, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
|
||||
@@ -137,6 +189,7 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||||
internal sealed partial class HostRowViewModel(
|
||||
VaultItem<HostSecret> host,
|
||||
ResolvedHost resolved,
|
||||
IReadOnlyList<string> tagLabels,
|
||||
Guid vaultId,
|
||||
string vaultName) : ObservableObject, ISidebarRow
|
||||
{
|
||||
@@ -182,6 +235,28 @@ internal sealed partial class HostRowViewModel(
|
||||
/// </remarks>
|
||||
internal ResolvedHost Resolved => resolved;
|
||||
|
||||
/// <summary>
|
||||
/// The names of the tags this host wears, in the order the chips are drawn.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Names rather than ids, resolved once when the list is built, because a chip shows a name and the
|
||||
/// host stores an id — and resolving per chip per redraw would be a dictionary lookup per tag per row
|
||||
/// per frame for a string that cannot change without the list being rebuilt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A tag that does not resolve is left out rather than drawn as its id.</b> It means the tag was
|
||||
/// deleted on another machine, or belongs to a vault this session cannot read; either way the honest
|
||||
/// answer is a host with one chip fewer, not one wearing a GUID. That the host still carries the id is
|
||||
/// the point — nothing rewrites twenty payloads to clear one deleted tag, so the chip comes back if the
|
||||
/// tag does. See <see cref="HostSecret.TagIds"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal IReadOnlyList<string> TagLabels => tagLabels;
|
||||
|
||||
/// <summary>Whether this host has any chips to draw.</summary>
|
||||
internal bool HasTags => tagLabels.Count > 0;
|
||||
|
||||
internal string Label => host.Secret.Label;
|
||||
|
||||
/// <summary>
|
||||
@@ -630,6 +705,15 @@ internal enum VaultSection
|
||||
/// </remarks>
|
||||
Credentials,
|
||||
|
||||
/// <summary>The tags hosts wear.</summary>
|
||||
/// <remarks>
|
||||
/// The odd one out, and here anyway. A tag holds no secret at all — it is a name, and the reason it is
|
||||
/// an item rather than a string on a host is that renaming it should be one write instead of twenty.
|
||||
/// That rename needs somewhere to happen, and deleting does too, or the host editor's picker fills with
|
||||
/// names nobody uses and never empties. This is where every other item kind already lives.
|
||||
/// </remarks>
|
||||
Tags,
|
||||
|
||||
/// <summary>S3-compatible buckets, and the keys that reach them.</summary>
|
||||
/// <remarks>
|
||||
/// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something
|
||||
@@ -654,6 +738,9 @@ internal enum VaultItemKind
|
||||
|
||||
/// <summary>An S3-compatible bucket.</summary>
|
||||
ObjectStore,
|
||||
|
||||
/// <summary>A tag a host can wear.</summary>
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -731,6 +818,9 @@ internal enum DeletionTarget
|
||||
|
||||
/// <summary>A bucket, from the vault screen.</summary>
|
||||
ObjectStore,
|
||||
|
||||
/// <summary>A tag, from the vault screen.</summary>
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -882,6 +972,23 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private Dictionary<Guid, HostGroupSecret> groupsById = [];
|
||||
|
||||
/// <summary>
|
||||
/// The tags as they came out of the vault, before the host counts are attached.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="groupItems" path="/remarks" />
|
||||
private IReadOnlyList<VaultItem<TagSecret>> tagItems = [];
|
||||
|
||||
/// <summary>
|
||||
/// The same tags by id, which is what a host's <see cref="HostSecret.TagIds"/> resolves through.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every readable vault's, like <see cref="groupsById"/> and for a weaker version of the same reason.
|
||||
/// A tag id that does not resolve draws no chip, so a host in a team's vault would silently look
|
||||
/// untagged — which costs a label rather than a connection, but is just as confusing to look at and
|
||||
/// costs nothing to avoid.
|
||||
/// </remarks>
|
||||
private Dictionary<Guid, TagSecret> tagsById = [];
|
||||
|
||||
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
|
||||
private readonly HashSet<Guid> collapsedGroups = [];
|
||||
|
||||
@@ -974,6 +1081,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// <summary>The buckets to show, unpushed local state included.</summary>
|
||||
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
||||
|
||||
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
||||
/// <remarks>
|
||||
/// Counted like a group's rows are, and read after the hosts for the same reason: the count is a
|
||||
/// property of the host list rather than of the tag.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<TagRowViewModel> Tags { get; } = [];
|
||||
|
||||
/// <summary>The host keys this user has approved.</summary>
|
||||
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
|
||||
|
||||
@@ -1093,6 +1207,27 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private ObjectStoreRowViewModel? selectedObjectStore;
|
||||
|
||||
[ObservableProperty]
|
||||
private TagRowViewModel? selectedTag;
|
||||
|
||||
// ---- The tag editor ----
|
||||
// A fifth set, and the smallest by a long way: a tag is a name. It is still its own pair rather than
|
||||
// sharing the group editor's box, for the reason the other four are separate — a half-typed tag
|
||||
// appearing inside a group's name field is the kind of thing that only shows up in a bug report.
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditingTag;
|
||||
|
||||
[ObservableProperty]
|
||||
private string tagEditorLabel = string.Empty;
|
||||
|
||||
/// <summary>The tag being edited, or null when creating.</summary>
|
||||
private Guid? editingTagId;
|
||||
|
||||
/// <summary>Which vault the tag editor will write to.</summary>
|
||||
/// <inheritdoc cref="editingHostVaultId" path="/remarks" />
|
||||
private Guid editingTagVaultId;
|
||||
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selectedKnownHost;
|
||||
|
||||
@@ -1169,6 +1304,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// <inheritdoc cref="ShowsAll" />
|
||||
internal bool ShowsBuckets => Section is VaultSection.Buckets;
|
||||
|
||||
/// <inheritdoc cref="ShowsAll" />
|
||||
internal bool ShowsTags => Section is VaultSection.Tags;
|
||||
|
||||
/// <summary>
|
||||
/// The rows the vault table is showing, for whichever category is selected.
|
||||
/// </summary>
|
||||
@@ -1195,6 +1333,7 @@ internal sealed partial class VaultViewModel(
|
||||
VaultSection.Keys => "SSH KEYS",
|
||||
VaultSection.Credentials => "PASSWORDS",
|
||||
VaultSection.Buckets => "BUCKETS",
|
||||
VaultSection.Tags => "TAGS",
|
||||
_ => "ALL ITEMS",
|
||||
};
|
||||
|
||||
@@ -1227,7 +1366,15 @@ internal sealed partial class VaultViewModel(
|
||||
/// Both of those have screens of their own now. Counting a pin here would put a number on the ALL
|
||||
/// category that the ALL category does not list.
|
||||
/// </remarks>
|
||||
internal int TotalItemCount => Keys.Count + Credentials.Count;
|
||||
/// <summary>What the ALL chip counts, which is everything ALL actually shows.</summary>
|
||||
/// <remarks>
|
||||
/// Buckets were already missing from this before tags were, so the number under a chip labelled ALL has
|
||||
/// been smaller than the list it opens for as long as there have been four kinds. Fixed here rather
|
||||
/// than left consistent: a count that disagrees with the rows beneath it is worse than no count, and
|
||||
/// adding a fifth kind to the same expression would have widened the gap rather than caused it.
|
||||
/// </remarks>
|
||||
internal int TotalItemCount =>
|
||||
Keys.Count + Credentials.Count + ObjectStores.Count + Tags.Count;
|
||||
|
||||
internal bool HasVaultItems => VaultItems.Count > 0;
|
||||
|
||||
@@ -1235,7 +1382,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
||||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore;
|
||||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
||||
|
||||
/// <summary>Whether the selected row is an SSH key, which is the only kind with a public half to copy.</summary>
|
||||
internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;
|
||||
@@ -1266,6 +1413,9 @@ internal sealed partial class VaultViewModel(
|
||||
"No stored passwords yet. Add one to stop typing the same password into every connection.",
|
||||
VaultSection.Buckets =>
|
||||
"No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.",
|
||||
VaultSection.Tags =>
|
||||
"No tags yet. Add one here, or from a host's editor, and it becomes a chip you can put on "
|
||||
+ "twenty machines and rename once.",
|
||||
_ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
|
||||
};
|
||||
|
||||
@@ -1454,16 +1604,155 @@ internal sealed partial class VaultViewModel(
|
||||
.FirstOrDefault(value => value is not null);
|
||||
|
||||
/// <summary>
|
||||
/// The tags the host being edited already wears, carried through a save untouched.
|
||||
/// The tags the host being edited wears, as the picker leaves them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a box on this head — neither head can put a tag on a host yet — and held anyway, because
|
||||
/// <see cref="BuildHost"/> rebuilds the whole record from the editor's state. Left out, editing a port
|
||||
/// would strip every tag a teammate had added from a client that can set them, which is the same class
|
||||
/// of silent loss that <c>HostSecretDocument.IsReadOnly</c> exists to prevent.
|
||||
/// The authority while an editor is open, not <see cref="EditorTagChoices"/>: the chips are a
|
||||
/// projection of this and are rebuilt from it, so a tag the vault no longer offers — deleted on another
|
||||
/// machine between the editor opening and Save — is still carried through rather than dropped by an
|
||||
/// edit that was about something else.
|
||||
/// </remarks>
|
||||
private TagSet editorTagIds = TagSet.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Every tag in the vault, as a chip, with whether the host being edited wears it.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||||
internal ObservableCollection<TagChoice> EditorTagChoices { get; } = [];
|
||||
|
||||
/// <summary>Whether there is any tag to offer, which is what draws the picker at all.</summary>
|
||||
/// <remarks>
|
||||
/// A vault with no tags shows the new-tag box and nothing else. An empty row of chips with a heading
|
||||
/// over it would be a control that looks broken rather than one that has nothing to say.
|
||||
/// </remarks>
|
||||
internal bool HasTagChoices => EditorTagChoices.Count > 0;
|
||||
|
||||
/// <summary>The name in the editor's "new tag" box.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorNewTag = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Puts a tag on the host being edited, or takes it off.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Applies to the editor's own set rather than saving anything. The host is written by
|
||||
/// <see cref="BuildHost"/> like every other field, so cancelling an edit drops the tagging with the
|
||||
/// rest of it — which is what a user who pressed CANCEL asked for.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ToggleEditorTag(TagChoice? choice)
|
||||
{
|
||||
if (choice is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editorTagIds = choice.IsWorn
|
||||
? TagSet.Create(editorTagIds.Where(id => id != choice.EntityId))
|
||||
: TagSet.Create([.. editorTagIds, choice.EntityId]);
|
||||
|
||||
BuildTagChoices();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a tag from the editor's box and puts it on the host being edited.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The moment a tag is wanted is the moment somebody is tagging a host and finds it does not exist yet,
|
||||
/// so this is where creating one belongs. The keychain screen has the list for renaming and deleting;
|
||||
/// making a user go there first, come back, and find their half-typed host gone would be the wrong way
|
||||
/// round.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It writes to the vault immediately, unlike everything else in this editor.</b> A tag is a shared
|
||||
/// item with an id, and a host can only name an id that exists — so there is nothing to defer. The
|
||||
/// consequence is honest rather than hidden: cancelling the host edit leaves the tag behind, because
|
||||
/// the tag was never part of the host.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable
|
||||
/// — see <see cref="TagSecret.TryValidate"/> for why refusing the second would be worse — but creating
|
||||
/// one by hand, from a box, beside a chip of the same name is a slip rather than an intention.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddEditorTagAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var label = EditorNewTag.Trim();
|
||||
|
||||
if (label.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ordinal-ignore-case rather than the current culture's. A tag name is a filter token people type
|
||||
// and re-type — "PCI" and "pci" are one tag by anybody's reading — and culture-aware casing would
|
||||
// make whether they are the same depend on the phone's locale, which is not a property of the
|
||||
// keychain the two machines share.
|
||||
if (Tags.FirstOrDefault(
|
||||
row => string.Equals(row.Label, label, StringComparison.OrdinalIgnoreCase)) is { } existing)
|
||||
{
|
||||
editorTagIds = TagSet.Create([.. editorTagIds, existing.EntityId]);
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
Status = $"'{existing.Label}' is already in this keychain, so it was used rather than repeated.";
|
||||
return;
|
||||
}
|
||||
|
||||
var tag = new TagSecret { Label = label };
|
||||
|
||||
if (!tag.TryValidate(out var reason))
|
||||
{
|
||||
Status = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Saving…",
|
||||
async () =>
|
||||
{
|
||||
// The active vault, not the host's. The picker lists the active vault's tags — exactly as
|
||||
// the group picker lists the active vault's groups — so creating one anywhere else would
|
||||
// put a chip on the host that the editor beside it could not show. A host in a team's vault
|
||||
// can therefore end up naming a personal tag, which is the same cross-vault reference a
|
||||
// group already allows and is recorded with it in docs/design-import-gaps.md.
|
||||
var entityId = await session.Tags
|
||||
.CreateAsync(session.ActiveVaultId, tag, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
editorTagIds = TagSet.Create([.. editorTagIds, entityId]);
|
||||
EditorNewTag = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
BuildTagChoices();
|
||||
|
||||
Status = $"Added the tag '{tag.Label}'.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Refills the editor's chips from the vault's tags and the set being edited.</summary>
|
||||
/// <remarks>
|
||||
/// Rebuilt wholesale rather than mutated, because <see cref="TagChoice"/> is a record and its
|
||||
/// <c>IsWorn</c> is part of its value. One toggle therefore replaces the collection, which for a list of
|
||||
/// chips is cheaper than raising change notification on each of them.
|
||||
/// </remarks>
|
||||
private void BuildTagChoices()
|
||||
{
|
||||
EditorTagChoices.Clear();
|
||||
|
||||
foreach (var tag in Tags)
|
||||
{
|
||||
EditorTagChoices.Add(
|
||||
new TagChoice(tag.EntityId, tag.Label, editorTagIds.Contains(tag.EntityId)));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasTagChoices));
|
||||
}
|
||||
|
||||
/// <summary>The item being edited, or null when creating.</summary>
|
||||
private Guid? editingEntityId;
|
||||
|
||||
@@ -1859,11 +2148,18 @@ internal sealed partial class VaultViewModel(
|
||||
RebuildTargetVaults();
|
||||
|
||||
// Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
|
||||
// gets counted under them — so the host reload is the pass that can put both together.
|
||||
// gets counted under them — so the host reload is the pass that can put both together. Tags are read
|
||||
// in the same place and for the same reason: a host row draws a chip per tag, and it can only draw
|
||||
// the name if the tag was read first.
|
||||
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
unreadable += await ReloadTagsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the hosts, because a tag row says how many wear it.
|
||||
RebuildTags();
|
||||
|
||||
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
||||
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
||||
@@ -1929,10 +2225,12 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
// Resolved here, which is why ReloadGroupsAsync runs before this: a host resolved against a
|
||||
// stale group list would show one port and dial another.
|
||||
// Resolved here, which is why ReloadGroupsAsync and ReloadTagsAsync both run before this: a host
|
||||
// resolved against a stale group list would show one port and dial another, and one resolved
|
||||
// against a stale tag list would draw a chip that has been renamed.
|
||||
rows.AddRange(listing.Items.Select(
|
||||
item => new HostRowViewModel(item, Resolve(item.Secret), vault.VaultId, vault.Name)
|
||||
item => new HostRowViewModel(
|
||||
item, Resolve(item.Secret), LabelsFor(item.Secret.TagIds), vault.VaultId, vault.Name)
|
||||
{
|
||||
// Only when there is something to tell apart. A badge on every row of a
|
||||
// single-vault list is noise that says the same thing on all of them.
|
||||
@@ -2158,6 +2456,66 @@ internal sealed partial class VaultViewModel(
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <returns>How many tags would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// The same two reads as <see cref="ReloadGroupsAsync"/>, and the same split: the editable list is the
|
||||
/// active vault's, the resolution map is every readable vault's. A tag id that does not resolve simply
|
||||
/// draws no chip, so the cost of a narrow map here is a host that looks untagged rather than one that
|
||||
/// dials the wrong port — cheaper than the group case, and no cheaper to get right.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadTagsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var unreadable = 0;
|
||||
var resolvable = new Dictionary<Guid, TagSecret>();
|
||||
|
||||
tagItems = [];
|
||||
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.Tags
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
foreach (var tag in listing.Items)
|
||||
{
|
||||
resolvable[tag.EntityId] = tag.Secret;
|
||||
}
|
||||
|
||||
if (vault.VaultId == session.ActiveVaultId)
|
||||
{
|
||||
tagItems = [.. listing.Items.OrderBy(tag => tag.Secret.Label, StringComparer.CurrentCulture)];
|
||||
}
|
||||
}
|
||||
|
||||
tagsById = resolvable;
|
||||
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>Refills <see cref="Tags"/>, counting the hosts wearing each.</summary>
|
||||
/// <remarks>
|
||||
/// Counted over <see cref="Hosts"/>, which spans every readable vault, while the rows themselves are
|
||||
/// the active vault's. That asymmetry is deliberate and is the same one the delete warning needs: a tag
|
||||
/// worn by a teammate's host is still worn, and a count that ignored those would tell somebody a tag
|
||||
/// was unused just before they deleted it out from under twenty machines.
|
||||
/// </remarks>
|
||||
private void RebuildTags()
|
||||
{
|
||||
var selectedId = SelectedTag?.EntityId;
|
||||
|
||||
Tags.Clear();
|
||||
|
||||
foreach (var tag in tagItems)
|
||||
{
|
||||
Tags.Add(new TagRowViewModel(
|
||||
tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
|
||||
}
|
||||
|
||||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
|
||||
/// authenticate.
|
||||
@@ -2177,6 +2535,22 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
|
||||
|
||||
/// <summary>
|
||||
/// The names behind a host's tag ids, sorted for display, skipping any that do not resolve.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sorted by name rather than kept in the set's own order. <see cref="TagSet"/> sorts by id, which is a
|
||||
/// UUIDv7 and therefore by when the tag was created — an order that means nothing on screen and changes
|
||||
/// what a row looks like depending on which machine made which tag first.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<string> LabelsFor(TagSet tagIds) =>
|
||||
[
|
||||
.. tagIds
|
||||
.Where(tagsById.ContainsKey)
|
||||
.Select(id => tagsById[id].Label)
|
||||
.OrderBy(label => label, StringComparer.CurrentCulture),
|
||||
];
|
||||
|
||||
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
||||
private void RebuildGroups()
|
||||
{
|
||||
@@ -2888,6 +3262,8 @@ internal sealed partial class VaultViewModel(
|
||||
EditorNotes = string.Empty;
|
||||
EditorRelayEnabled = false;
|
||||
editorTagIds = TagSet.Empty;
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
|
||||
// A new host opens in whichever group is selected beside the list, if one is, because adding three
|
||||
// machines to the group somebody has just made is the ordinary case. Before the picker, because
|
||||
@@ -2933,6 +3309,8 @@ internal sealed partial class VaultViewModel(
|
||||
EditorNotes = row.Host.Notes ?? string.Empty;
|
||||
EditorRelayEnabled = row.Host.RelayEnabled;
|
||||
editorTagIds = row.Host.TagIds;
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
|
||||
BuildGroupChoices(row.Host.GroupId);
|
||||
|
||||
@@ -2971,6 +3349,10 @@ internal sealed partial class VaultViewModel(
|
||||
EditObjectStoreCommand.Execute(null);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Tag:
|
||||
EditTagCommand.Execute(null);
|
||||
break;
|
||||
|
||||
default:
|
||||
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
|
||||
break;
|
||||
@@ -3000,6 +3382,10 @@ internal sealed partial class VaultViewModel(
|
||||
DeleteObjectStoreCommand.Execute(null);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Tag:
|
||||
DeleteTagCommand.Execute(null);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -3966,6 +4352,174 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Starts a new tag from the keychain screen.</summary>
|
||||
/// <remarks>
|
||||
/// The second way to make one. The first — the host editor's box — is where it usually happens, because
|
||||
/// wanting a tag and tagging a host are the same moment. This is for the times they are not: setting up
|
||||
/// a scheme before there is anything to put it on.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewTag()
|
||||
{
|
||||
if (AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editingTagId = null;
|
||||
|
||||
// The active vault, not the "new items go to" picker, and this is the one place the difference
|
||||
// bites. Keys and credentials are read across every readable vault, so filing one into a team's is
|
||||
// safe — it comes back in the list. Tags are read like groups and buckets are: the editable list is
|
||||
// the active vault's alone. A tag filed anywhere else would be created, queued for push, reported
|
||||
// as added, and then invisible — no row, no count, no entry in any host editor's picker, and
|
||||
// nothing on this screen able to rename or delete it, because there is no active-vault switcher to
|
||||
// go and find it with. NewObjectStore ignores the picker for exactly this reason.
|
||||
editingTagVaultId = session.ActiveVaultId;
|
||||
TagEditorLabel = string.Empty;
|
||||
IsEditingTag = true;
|
||||
Status = "Adding a tag.";
|
||||
}
|
||||
|
||||
/// <summary>Loads the selected tag's name into the box, so saving renames it.</summary>
|
||||
[RelayCommand]
|
||||
private void EditTag()
|
||||
{
|
||||
if (SelectedTag is not { } row || AVaultEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This tag was written by a newer version of DodoSSH. Update before editing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
editingTagId = row.EntityId;
|
||||
editingTagVaultId = session.ActiveVaultId;
|
||||
TagEditorLabel = row.Label;
|
||||
IsEditingTag = true;
|
||||
Status = $"Renaming {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the tag editor.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelTagEdit()
|
||||
{
|
||||
IsEditingTag = false;
|
||||
editingTagId = null;
|
||||
TagEditorLabel = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the tag in the box, encrypted, and queues it for the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>A rename is one write, and that is the whole reason this type exists.</b> Every host wearing the
|
||||
/// tag names its id, so none of them is touched — which is what makes renaming safe to do casually and
|
||||
/// what a string repeated inside twenty payloads could never have offered. See <see cref="TagSecret"/>.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task SaveTagAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var tag = new TagSecret { Label = TagEditorLabel.Trim() };
|
||||
|
||||
if (!tag.TryValidate(out var reason))
|
||||
{
|
||||
Status = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
var renaming = editingTagId;
|
||||
|
||||
await RunAsync(
|
||||
"Saving…",
|
||||
async () =>
|
||||
{
|
||||
if (renaming is { } entityId)
|
||||
{
|
||||
await session.Tags
|
||||
.UpdateAsync(editingTagVaultId, entityId, tag, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
await session.Tags
|
||||
.CreateAsync(editingTagVaultId, tag, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
CancelTagEdit();
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = renaming is null
|
||||
? $"Added the tag '{tag.Label}'."
|
||||
: $"Renamed to '{tag.Label}'.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Asks whether the selected tag should go.</summary>
|
||||
/// <remarks>
|
||||
/// The consequence is worth counting rather than describing. Deleting a tag does not touch the hosts
|
||||
/// wearing it — rewriting N payloads inside one delete is what keeping membership on the host exists to
|
||||
/// avoid — so what actually happens is that N rows lose a chip, and the number is the difference
|
||||
/// between a tidy-up and losing a filter somebody relies on.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void DeleteTag()
|
||||
{
|
||||
if (SelectedTag is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Tag,
|
||||
row.EntityId,
|
||||
$"Delete the tag '{row.Label}'?",
|
||||
HowFarADeletionGoes("The tag"),
|
||||
row.HostCount == 0
|
||||
? string.Empty
|
||||
: row.HostCount == 1
|
||||
? "1 host wears it and will simply stop showing the chip. Nothing else about it changes."
|
||||
: $"{row.HostCount} hosts wear it and will simply stop showing the chip. Nothing else "
|
||||
+ "about them changes.");
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the tag that was agreed to.</summary>
|
||||
private async Task DeleteTagNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Tags.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
{
|
||||
Status = "That tag is no longer here, so nothing was deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
{
|
||||
await session.Tags
|
||||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (editingTagId == entityId)
|
||||
{
|
||||
CancelTagEdit();
|
||||
}
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted the tag '{row.Label}'.";
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Asks whether the selected bucket should go.</summary>
|
||||
/// <remarks>
|
||||
/// The bucket itself is untouched, and the question says so. Removing the entry here removes this
|
||||
@@ -4181,6 +4735,10 @@ internal sealed partial class VaultViewModel(
|
||||
await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
case DeletionTarget.Tag:
|
||||
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -4976,9 +5534,10 @@ internal sealed partial class VaultViewModel(
|
||||
? true
|
||||
: null,
|
||||
|
||||
// Carried through rather than edited here. Nothing on this head can put a tag on a host yet, and
|
||||
// rebuilding the host from the editor's boxes alone would strip the tags a teammate had added on
|
||||
// a machine that can. See docs/adding-hosts-on-the-phone.md.
|
||||
// The picker's set, and it is the picker's rather than the chips' — see editorTagIds. An id the
|
||||
// vault cannot currently offer, because the tag was deleted on another machine between this
|
||||
// editor opening and Save, is carried through rather than dropped by an edit that was about
|
||||
// something else.
|
||||
TagIds = editorTagIds,
|
||||
|
||||
// Both read off the one picker, including the id of something that has gone missing. Reading them
|
||||
@@ -5200,16 +5759,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private bool AVaultEditorIsInTheWay()
|
||||
{
|
||||
Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore) switch
|
||||
Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore, IsEditingTag)
|
||||
switch
|
||||
{
|
||||
(true, _, _, _) => "Finish or cancel the SSH key you are editing first.",
|
||||
(_, true, _, _) => "Finish or cancel the credential you are editing first.",
|
||||
(_, _, true, _) => "Finish or cancel the key you are generating first.",
|
||||
(_, _, _, true) => "Finish or cancel the bucket you are editing first.",
|
||||
(true, _, _, _, _) => "Finish or cancel the SSH key you are editing first.",
|
||||
(_, true, _, _, _) => "Finish or cancel the credential you are editing first.",
|
||||
(_, _, true, _, _) => "Finish or cancel the key you are generating first.",
|
||||
(_, _, _, true, _) => "Finish or cancel the bucket you are editing first.",
|
||||
(_, _, _, _, true) => "Finish or cancel the tag you are editing first.",
|
||||
_ => Status,
|
||||
};
|
||||
|
||||
return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore;
|
||||
return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore
|
||||
|| IsEditingTag;
|
||||
}
|
||||
|
||||
private void ClearKeyEditor()
|
||||
@@ -5483,6 +6045,32 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
|
||||
|
||||
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method purely for length: <see cref="RebuildVaultItems"/> is one <c>if</c> per kind and a
|
||||
/// fifth took it past what this project lets a method be. Split at the newest arm rather than the
|
||||
/// prettiest place, so the diff that added it is the diff that moved it.
|
||||
/// </remarks>
|
||||
private void AddTagRows()
|
||||
{
|
||||
if (Section is not (VaultSection.All or VaultSection.Tags))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var tag in Tags)
|
||||
{
|
||||
VaultItems.Add(new VaultItemRowViewModel(
|
||||
VaultItemKind.Tag,
|
||||
tag.EntityId,
|
||||
tag.Label,
|
||||
"TAG",
|
||||
tag.Description,
|
||||
tag.Badge,
|
||||
tag.HasUnsyncedChanges));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refills the vault table from the typed lists.
|
||||
/// </summary>
|
||||
@@ -5527,6 +6115,8 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
AddTagRows();
|
||||
|
||||
if (Section is VaultSection.All or VaultSection.Buckets)
|
||||
{
|
||||
foreach (var store in ObjectStores)
|
||||
@@ -5564,10 +6154,11 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
|
||||
// Both kinds this table can delete, because one selection covers both lists.
|
||||
// Every kind this table can delete, because one selection covers all of their lists.
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Tag, value?.EntityId);
|
||||
|
||||
switch (value?.Kind)
|
||||
{
|
||||
@@ -5583,11 +6174,22 @@ internal sealed partial class VaultViewModel(
|
||||
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Tag:
|
||||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
||||
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
||||
/// the DELETE the user could see belonged to an item they were no longer looking at.
|
||||
/// </remarks>
|
||||
partial void OnIsEditingTagChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||
|
||||
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
||||
|
||||
partial void OnUnreadableItemsChanged(int value)
|
||||
@@ -5607,6 +6209,7 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(ShowsKeys));
|
||||
OnPropertyChanged(nameof(ShowsCredentials));
|
||||
OnPropertyChanged(nameof(ShowsBuckets));
|
||||
OnPropertyChanged(nameof(ShowsTags));
|
||||
OnPropertyChanged(nameof(SectionTitle));
|
||||
|
||||
RebuildVaultItems();
|
||||
|
||||
Reference in New Issue
Block a user