Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
@@ -0,0 +1,303 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel">
<!--
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
has approved.
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
shape for every kind, which is what makes the ALL category possible and is why the row projection
exists; see VaultItemRowViewModel.
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
access service, so a rail with three entries would be showing two vaults nothing can open.
-->
<Grid ColumnDefinitions="176,*,244">
<!-- Categories and scopes -->
<Border Grid.Column="0" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<ScrollViewer>
<StackPanel Margin="0,12">
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.All}"
Classes.active="{Binding ShowsAll}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="ALL" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding TotalItemCount}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Keys}"
Classes.active="{Binding ShowsKeys}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="SSH KEYS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding Keys.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.Credentials}"
Classes.active="{Binding ShowsCredentials}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="PASSWORDS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding Credentials.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
Classes.active="{Binding ShowsKnownHosts}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="14,10" />
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
<!--
One entry per vault this session opened. Not a selector: every list on this screen reads the
active vault, and a rail that let you click a vault you cannot switch to would be a control that
does nothing. It is here because knowing which vault you are looking at is worth a line, and
because this is where a second one appears when shared vaults arrive.
-->
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
<!--
Items that would not decrypt. Shown here rather than only in the status line because this is the
screen the number is about, and because a non-zero count after a rekey is the signal that new
grants are needed rather than a transient.
-->
<Border Classes="chip warn" Margin="14,12,14,0" HorizontalAlignment="Left"
IsVisible="{Binding HasUnreadableItems}">
<TextBlock Text="{Binding UnreadableSummary}" />
</Border>
</StackPanel>
</ScrollViewer>
</Border>
<!-- The table -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SectionTitle}" FontSize="11"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
IsVisible="{Binding CanAddToSection}" />
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
IsVisible="{Binding CanAddToSection}" />
</StackPanel>
</Grid>
</Border>
<!--
The design's columns are NAME / TYPE / FINGERPRINT / SCOPE / ACCESS / LAST. Three of those six have
nothing behind them: there is one scope, no roles, and no item carries a last-used or modified time —
VaultItem is (id, secret, version, three sync flags) and nothing else. What replaces them is the one
thing this build does know and the design had no column for: whether a change is still sitting in
this machine's outbox.
-->
<Grid Grid.Row="1" ColumnDefinitions="2,1.3*,74,*,88" Margin="0,6,14,6"
IsVisible="{Binding HasVaultItems}">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="DETAIL" FontSize="8.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="ItemList" Focusable="True"
ItemsSource="{Binding VaultItems}"
SelectedItem="{Binding SelectedVaultItem}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,1.3*,74,*,88" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="11"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="9"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Detail}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip warn" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
The empty state says which category is empty and what to do about it, rather than showing an empty
grid that reads as a list still loading.
-->
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptySectionMessage}" FontSize="11"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="320"
IsVisible="{Binding !HasVaultItems}" />
</Grid>
<!-- The detail pane, and the editors -->
<Border Grid.Column="2" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16">
<!-- Nothing selected. -->
<TextBlock Classes="hint" FontSize="11"
Text="Choose something on the left to see what is known about it."
IsVisible="{Binding !HasSelectedVaultItem}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelectedVaultItem}">
<TextBlock Classes="mono" Text="{Binding SelectedVaultItem.Name}" FontSize="12"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
<TextBlock Text="{Binding SelectedVaultItem.Type}" />
</Border>
<Border Classes="chip accent">
<TextBlock Text="{Binding HostsHeading}" />
</Border>
</StackPanel>
<TextBlock Classes="label" Text="{Binding SelectedDetailHeading}" Margin="0,12,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding SelectedVaultItem.Detail}"
FontSize="9.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<!--
What the design puts here — who added it, when, who it is shared with, and a TEST CONNECT
button — has nothing behind it. Items carry no author, no timestamps and no sharing, and
nothing can exercise a credential without a host to exercise it against. Rather than five
empty rows, this says what is missing in one line.
-->
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding SelectedItemIsEditable}">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
the one place it can be checked against what the operator published. What it does have is a
way out, because a changed host key is refused outright and a rebuilt server would otherwise
be unreachable for ever.
-->
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
<TextBlock Classes="hint" FontSize="9.5"
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetPinCommand}"
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
</StackPanel>
</StackPanel>
<!-- The key editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingKey}">
<TextBlock Classes="label" Text="SSH KEY" Margin="0,0,0,4" />
<TextBox Text="{Binding KeyEditorLabel}" PlaceholderText="name" />
<!--
Not a password box. The armour has to be visible to be pasted and checked — a masked
multi-line box makes "did the whole key arrive?" unanswerable — and the mistake this actually
prevents is pasting the .pub file, which SshKeySecret.TryValidate rejects by name.
-->
<TextBox Text="{Binding KeyEditorPrivateKey}"
PlaceholderText="-----BEGIN OPENSSH PRIVATE KEY-----"
AcceptsReturn="True" Height="96" TextWrapping="NoWrap"
FontFamily="{StaticResource MonoFont}" FontSize="10.5" />
<TextBox Text="{Binding KeyEditorPassphrase}"
PlaceholderText="passphrase, if the key has one" PasswordChar="•" />
<TextBox Text="{Binding KeyEditorPublicKey}" PlaceholderText="public half (optional)" />
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
</StackPanel>
</StackPanel>
<!-- The password editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingCredential}">
<TextBlock Classes="label" Text="PASSWORD" Margin="0,0,0,4" />
<TextBox Text="{Binding CredentialEditorLabel}" PlaceholderText="name" />
<!--
Optional, and the reason a credential is worth being its own item rather than two more fields on
a host: one account on twenty machines is described once and rotated once. Left blank, each host
supplies its own username and only the password is shared.
-->
<TextBox Text="{Binding CredentialEditorUsername}"
PlaceholderText="username (blank: use each host's own)" />
<!--
Masked, unlike the private key box, and the difference is not inconsistency. A key's armour has
to be visible to be checked for truncation after a paste; a password is short, usually typed,
and shoulder-surfing is the likelier problem.
-->
<TextBox Text="{Binding CredentialEditorPassword}" PlaceholderText="password" PasswordChar="•" />
<TextBox Text="{Binding CredentialEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="Encrypted here and never sent to the server in a form it can read. Bind a host to this in the host's own editor, under how it authenticates." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCredentialCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCredentialEditCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</UserControl>