Draw the tags that have been storable and invisible since the domain landed
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 52s

`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:
2026-08-03 12:42:43 +02:00
co-authored by Claude Opus 5
parent 4925dcf179
commit 16e0051e89
13 changed files with 1253 additions and 35 deletions
@@ -216,6 +216,37 @@
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<!--
A tag that is also a button: the picker in the host editor. It borrows Border.tag's geometry rather than
inheriting it, because a Button is not a Border and Avalonia's selectors are structural — the numbers
are repeated and the two have to move together, which is why they sit adjacent.
Worn is filled, unworn is outlined, so a row of these reads as the host's tags rather than as a form
about them. 36 tall rather than 44: it is a chip, its neighbours are chips, and a row of 44-pixel pills
would be a wall. The whole chip is the target and they are spaced 6 apart, which is what keeps a miss
landing on nothing rather than on the wrong tag.
-->
<Style Selector="Button.chiptoggle">
<Setter Property="MinHeight" Value="36" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="11,0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.chiptoggle.worn">
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.chiptoggle.worn /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Button.chiptoggle:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<!-- An outlined tag, for a fact about an item rather than a name: a type, an algorithm, a count. -->
<Style Selector="Border.tag.outline">
<Setter Property="Background" Value="Transparent" />
@@ -170,6 +170,25 @@
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
<!--
The design's tag chips, and the row is where they were always meant to be — what a machine
*is*, under what it is called and where it answers. Wrapped rather than trimmed: a host
wearing four tags on a 360dp row takes a second line, which is the honest shape.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}"
Margin="0,2,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="tag" Margin="0,0,4,3">
<TextBlock Text="{Binding}" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!--
@@ -252,6 +271,40 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!--
◆ The tags this host wears. Chips that toggle, matching the chips on the row behind this card —
a list of names to tick would make the user match an entry to a chip they can see.
The box under them creates one and puts it on straight away, which is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. It writes to the keychain
immediately, unlike every other field here, because a host can only name a tag that has an id —
so cancelling this editor leaves the tag behind. Renaming and deleting are on the keychain
screen, with every other item kind.
-->
<TextBlock Classes="label" Text="TAGS" Margin="0,4,0,0" />
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,6,6"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Classes="mono" FontSize="11.5" Text="{Binding Label}" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,8,Auto">
<TextBox Grid.Column="0" Classes="field" Text="{Binding EditorNewTag}"
PlaceholderText="new tag" />
<Button Grid.Column="2" Classes="secondary" Height="44" Width="72" Content="ADD"
Command="{Binding AddEditorTagCommand}" />
</Grid>
<!--
◆ The one control here that publishes something. Turning it on copies this host's address and
port into plaintext columns the server can read, which is the single deliberate concession in
@@ -60,6 +60,20 @@
</StackPanel>
</RadioButton>
<!--
Tags. The one category holding nothing secret — a tag is a name — and here because renaming one is
one write instead of twenty, and a rename needs somewhere to happen. Making one usually happens in
a host's editor instead; this is where they are renamed and deleted.
-->
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsTags, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="TAGS" />
<TextBlock Text="{Binding Tags.Count}" />
</StackPanel>
</RadioButton>
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsBuckets, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}">
+30
View File
@@ -108,6 +108,36 @@
<Style Selector="Border.chip.accent > TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<!--
A chip that is also a button: the tag picker in the host editor. It borrows Border.chip's geometry
rather than inheriting it, because a Button is not a Border and Avalonia's selectors are structural —
the numbers are repeated here and the two have to move together, which is why they sit adjacent.
Worn is filled, unworn is outlined, and the difference is the accent rather than a tick: a row of
these has to read as "the tags this host has" at a glance, and a checkbox column reads as a form.
-->
<Style Selector="Button.chiptoggle">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="6,2" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontWeight" Value="Medium" />
</Style>
<Style Selector="Button.chiptoggle.worn">
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Button.chiptoggle.worn /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
</Style>
<Style Selector="Button.chiptoggle:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Border.chip.warn">
<Setter Property="BorderBrush" Value="{StaticResource WarnSoft}" />
</Style>
+83 -1
View File
@@ -163,6 +163,28 @@
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
<!--
The tags this host wears. A third line rather than more on the second, because a host can
wear several and the second line's three facts are fixed-width where these are not — one
long tag would push the vault badge off the end of a 268-pixel column.
Names, not ids: a tag the vault cannot resolve is left out rather than drawn. See
HostRowViewModel.TagLabels.
-->
<ItemsControl ItemsSource="{Binding TagLabels}" IsVisible="{Binding HasTags}"
Margin="0,1,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<Border Classes="chip" Padding="5,0" Margin="0,0,4,0">
<TextBlock Text="{Binding}" FontSize="8.5" />
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Grid>
</DataTemplate>
@@ -170,10 +192,27 @@
</ListBox.DataTemplates>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<!--
The editor doubles as the "add" form; there is no separate dialog.
◆ It scrolls, and it did not until the tag picker arrived. The reason is that a picker's height is not
a property of the design: it 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 cost is real and worth naming. The layout harness skips any control with a ScrollViewer in its
ancestry — see LayoutHarness.IsScrollable — so from here on it certifies that this *pane* fits the
column rather than that every field inside it does. That is the true claim about a pane that scrolls.
The MaxHeight is stated rather than left to the row for the same reason: it is what keeps the pane
itself inside the window, which is the part the harness still watches. 300 is what the minimum window
affords once the filter, the heading and the button strip have taken theirs, with enough left that the
host list above does not vanish — the point of a column this narrow is that the list stays visible
while a host in it is edited.
-->
<Border Grid.Row="3" Padding="10" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsEditing}">
<ScrollViewer MaxHeight="300" HorizontalScrollBarVisibility="Disabled">
<StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
@@ -230,6 +269,48 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
◆ The tags this host wears. Chips that toggle rather than a multi-select list, because a chip is
what a tag looks like on the row two inches above — a list of names to tick would make the user
match an entry to a chip they can already see.
The box under them creates one and puts it on straight away. That is where a tag is usually
wanted: while tagging a host and finding it does not exist yet. Unlike every other field here it
writes to the keychain immediately, because a host can only name a tag that has an id — so
cancelling this editor leaves the tag behind, which is honest rather than hidden. Renaming and
deleting are on the keychain screen, where every other item kind is managed.
-->
<ItemsControl ItemsSource="{Binding EditorTagChoices}" IsVisible="{Binding HasTagChoices}"
Margin="0,4,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagChoice">
<!--
Worn is filled, unworn is outlined. One control per tag with two states rather than a
checkbox beside a label: the state and the name occupy the same object, so a row of them
reads as the host's tags rather than as a form about them.
-->
<Button Classes="chiptoggle" Classes.worn="{Binding IsWorn}" Margin="0,0,4,4"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).ToggleEditorTagCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Label}" FontSize="9.5" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="*,6,Auto">
<TextBox Grid.Column="0" Text="{Binding EditorNewTag}" PlaceholderText="new tag"
FontSize="11" MinHeight="26" Padding="8,3">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="ghost" Content="ADD" Command="{Binding AddEditorTagCommand}" />
</Grid>
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
Content="Connect through the server relay" />
<!--
@@ -255,6 +336,7 @@
IsVisible="{Binding CanForgetHostKey}"
ToolTip.Tip="Removes the pinned key for this host's address, so the next connection asks you to check its fingerprint again." />
</StackPanel>
</ScrollViewer>
</Border>
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
+44 -1
View File
@@ -76,6 +76,23 @@
is something somebody creates, edits and keeps a secret for, which is what the other two
categories are. A pin is a decision recorded at connect time and is not.
-->
<!--
Tags. The odd category: it is the only one holding nothing secret — a tag is a name. It is here
because the reason a tag is an item at all is that renaming it should be one write instead of
twenty, and a rename needs somewhere to happen; so does deleting, or the host editor's picker
fills with names nobody uses and never empties.
-->
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Tags}"
Classes.active="{Binding ShowsTags}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="TAGS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding Tags.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Buckets}"
Classes.active="{Binding ShowsBuckets}">
@@ -152,12 +169,18 @@
Always offered. Every category left on this screen is one things can be added to — the one
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
either. See KnownHostsScreen.
GENERATE lost the word KEY when a fifth button arrived: five of these overflow an 1016-wide
window's header by a few pixels, and the layout suite catches it. Its tooltip carries what the
word did, and the two key buttons are adjacent, so which one generates is not in doubt.
-->
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
<Button Classes="ghost" Content="GENERATE" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
ToolTip.Tip="Pastes in a key you already have." />
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
<Button Classes="ghost" Content="+ TAG" Command="{Binding NewTagCommand}"
ToolTip.Tip="A name to put on hosts. Usually made from a host's editor instead; this is for setting a scheme up before there is anything to put it on." />
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
</StackPanel>
@@ -384,6 +407,26 @@
</StackPanel>
</StackPanel>
<!--
The tag editor, and the whole of it is one box. What it does not have is the point: renaming a
tag touches no host, because every host wearing it names its id. That is the entire reason a tag
is an item rather than a string repeated inside twenty payloads.
-->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingTag}">
<TextBlock Classes="label" Text="TAG" Margin="0,0,0,4" />
<TextBox Text="{Binding TagEditorLabel}" PlaceholderText="name">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SaveTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="hint" FontSize="9.5"
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveTagCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelTagEditCommand}" />
</StackPanel>
</StackPanel>
<!-- The bucket editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
@@ -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();