Public Access
`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>
182 lines
9.8 KiB
XML
182 lines
9.8 KiB
XML
<UserControl xmlns="https://github.com/avaloniaui"
|
||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||
x:Class="DodoSSH.Client.Android.Views.KeychainScreen"
|
||
x:DataType="vm:VaultViewModel"
|
||
Background="{StaticResource Canvas}">
|
||
|
||
<!--
|
||
Design 04 — KEYCHAIN: everything in the vault that is not a host.
|
||
|
||
Two things from the design are structural rather than decorative and both are kept.
|
||
|
||
The category chips carry counts, because "SSH KEYS 8" answers a question the word alone does not.
|
||
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
|
||
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
|
||
hides which row it is talking about, which is exactly the information the confirmation exists to give.
|
||
|
||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||
Rows became cards with a glyph tile, which is the design's own shape. Three things drawn on those cards
|
||
are not here, and all three for the same reason: there is nothing behind them. An `ED25519` badge needs
|
||
an algorithm field, and `SshKeySecret` has none — the type stores the armour verbatim rather than
|
||
parsing it. A `SHA256:…` line needs a fingerprint, computed by the same parsing. And an `agent` chip
|
||
needs an agent, of which this application has none at all — the connect path decrypts the key and hands
|
||
the bytes to the SSH library. What the card carries instead is `Detail`, which is what is genuinely
|
||
known *about* an item and never the secret. See docs/design-import-gaps.md.
|
||
|
||
Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command
|
||
beside it has had the chance to refuse, which would leave the row lit for a section the list is not on.
|
||
-->
|
||
|
||
<Grid RowDefinitions="Auto,Auto,Auto,*">
|
||
|
||
<!-- ============ categories ============ -->
|
||
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||
Margin="0,12,0,4">
|
||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="16,0">
|
||
|
||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsAll, Mode=OneWay}"
|
||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.All}">
|
||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||
<TextBlock Text="ALL" />
|
||
<TextBlock Text="{Binding TotalItemCount}" />
|
||
</StackPanel>
|
||
</RadioButton>
|
||
|
||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsKeys, Mode=OneWay}"
|
||
Command="{Binding ShowSectionCommand}" CommandParameter="{x:Static vm:VaultSection.Keys}">
|
||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||
<TextBlock Text="SSH KEYS" />
|
||
<TextBlock Text="{Binding Keys.Count}" />
|
||
</StackPanel>
|
||
</RadioButton>
|
||
|
||
<RadioButton GroupName="section" Classes="chip" IsChecked="{Binding ShowsCredentials, Mode=OneWay}"
|
||
Command="{Binding ShowSectionCommand}"
|
||
CommandParameter="{x:Static vm:VaultSection.Credentials}">
|
||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||
<TextBlock Text="PASSWORDS" />
|
||
<TextBlock Text="{Binding Credentials.Count}" />
|
||
</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}">
|
||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||
<TextBlock Text="BUCKETS" />
|
||
<TextBlock Text="{Binding ObjectStores.Count}" />
|
||
</StackPanel>
|
||
</RadioButton>
|
||
|
||
</StackPanel>
|
||
</ScrollViewer>
|
||
|
||
<!--
|
||
The design's "4 keys · synced". SectionSummary counts the rows actually on screen and says how many of
|
||
them are still waiting to be pushed — which is the honest version of the design's unconditional
|
||
"synced".
|
||
-->
|
||
<TextBlock Grid.Row="1" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" />
|
||
|
||
<!-- ============ ◆ the counted confirmation ============ -->
|
||
<!--
|
||
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
|
||
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
|
||
difference between a sentence somebody reads and one they click past.
|
||
-->
|
||
<Border Grid.Row="2" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
|
||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||
BorderThickness="1" CornerRadius="12" Padding="14,12">
|
||
<StackPanel Spacing="5">
|
||
<TextBlock Classes="mono" FontSize="12" Text="{Binding PendingDeletion.Question}" TextWrapping="Wrap" />
|
||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||
Text="{Binding PendingDeletion.Consequence}" />
|
||
<!-- The line worth reading twice: what depends on the item, counted. Absent when nothing does. -->
|
||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||
IsVisible="{Binding PendingDeletion.HasUsage}"
|
||
Text="{Binding PendingDeletion.Usage}" />
|
||
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||
<Button Grid.Column="0" Classes="danger" Content="DELETE"
|
||
Command="{Binding ConfirmDeleteCommand}" />
|
||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP" FontSize="10.5"
|
||
Command="{Binding CancelDeleteCommand}" />
|
||
</Grid>
|
||
</StackPanel>
|
||
</Border>
|
||
|
||
<!-- ============ the items ============ -->
|
||
<Panel Grid.Row="3">
|
||
|
||
<!-- Written per section by the view model, and it says what the section is for. -->
|
||
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
|
||
VerticalAlignment="Top" Text="{Binding EmptySectionMessage}" />
|
||
|
||
<ListBox ItemsSource="{Binding VaultItems}" SelectedItem="{Binding SelectedVaultItem}"
|
||
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVaultItems}">
|
||
<ListBox.Styles>
|
||
<Style Selector="ListBoxItem">
|
||
<Setter Property="Padding" Value="0" />
|
||
<Setter Property="MinHeight" Value="0" />
|
||
</Style>
|
||
</ListBox.Styles>
|
||
<ListBox.ItemTemplate>
|
||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||
<Border Classes="card" Margin="12,4">
|
||
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64">
|
||
|
||
<!--
|
||
The glyph tile. One mark for all three kinds rather than three: a key, a password and a
|
||
bucket are already named by the badge beside them, and three near-identical monochrome
|
||
glyphs at 14px would be decoration that reads as a distinction.
|
||
-->
|
||
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10"
|
||
Background="{StaticResource Active}" VerticalAlignment="Center">
|
||
<TextBlock Text="⚿" Foreground="{StaticResource AccentText}" FontSize="14"
|
||
FontWeight="SemiBold" HorizontalAlignment="Center"
|
||
VerticalAlignment="Center" />
|
||
</Border>
|
||
|
||
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
|
||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
|
||
TextTrimming="CharacterEllipsis" />
|
||
<Border Classes="tag outline">
|
||
<TextBlock Text="{Binding Type}" />
|
||
</Border>
|
||
<Border Classes="tag outline" BorderBrush="{StaticResource WarnSoft}"
|
||
IsVisible="{Binding HasBadge}">
|
||
<TextBlock Foreground="{StaticResource Warn}" Text="{Binding Badge}" />
|
||
</Border>
|
||
</StackPanel>
|
||
<!-- What is known *about* an item, never the secret. -->
|
||
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
|
||
</StackPanel>
|
||
|
||
<TextBlock Grid.Column="2" Text="›" Foreground="{StaticResource TextGhost}" FontSize="15"
|
||
VerticalAlignment="Center" />
|
||
</Grid>
|
||
</Border>
|
||
</DataTemplate>
|
||
</ListBox.ItemTemplate>
|
||
</ListBox>
|
||
</Panel>
|
||
|
||
</Grid>
|
||
|
||
</UserControl>
|