Files
DodoSSH/src/DodoSSH.Client.App/Views/HostSidebar.axaml
T
jaap-janandClaude Opus 5 16e0051e89
ci / build and test (push) Successful in 1m22s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 52s
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>
2026-08-03 12:42:43 +02:00

367 lines
21 KiB
XML

<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostSidebar"
x:DataType="vm:VaultViewModel">
<!--
The host list, and the editor for whichever host is open.
This was the top half of VaultColumn. The design gives hosts their own column beside the terminal and
puts everything else in the vault screen, which is the split this control and VaultScreen are — and it
is a better split than the one it replaces: the hosts list is the thing you look at while you work, and
the keys and passwords behind it are the thing you go and manage.
Its data context is the VaultViewModel, so every binding here is a property of the vault. The shell
hands it over; see MainWindow.
The editor stays in this column rather than moving into the terminal's half of the window, and that is
an occlusion constraint rather than a preference: the terminal's WebView is a native child window that
composites above anything Avalonia draws in the same rectangle, so a form laid over there would render
underneath it with its buttons unclickable. It is also why this control is measurable at all — no part
of it is the WebView, so the layout harness can lay it out headlessly.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto,Auto" Background="{StaticResource Sidebar}">
<!--
The filter. It narrows this list and nothing else — the connect path, the selection and the pinned
host key list all read the unfiltered collection — so a filter left in the box can hide a host but
can never break one.
-->
<Border Grid.Row="0" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" PlaceholderText="filter hosts" FontSize="11"
FontFamily="{StaticResource MonoFont}" MinHeight="26" Padding="8,3" />
</Border>
<!--
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
the collection's own, so it follows the filter without a second number to keep in step.
-->
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Column="0" Text="▾" Foreground="{StaticResource TextFaint}" FontSize="8"
VerticalAlignment="Center" Margin="0,0,6,0"
IsVisible="{Binding AreHostsExpanded}" />
<TextBlock Grid.Column="0" Text="▸" Foreground="{StaticResource TextFaint}" FontSize="8"
VerticalAlignment="Center" Margin="0,0,6,0"
IsVisible="{Binding !AreHostsExpanded}" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding HostsHeading}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding VisibleHosts.Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
<!--
Named because it is where keyboard focus lands when the user leaves the terminal.
Focusable, which a ListBox is not by default — Avalonia leaves focus to the items and an empty list has
none. Without it the release-the-keyboard path is a measured no-op: it takes Win32 focus off the
terminal's child window and then calls Focus() on something that refuses it, leaving the window with
nothing focused and the keystrokes going nowhere.
-->
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
DragDrop.AllowDrop="True"
IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<!--
The three things you can do to a host, on the host itself.
On the list rather than in the item template, and that is what makes it one menu rather than one per
row: a ContextMenu inside a DataTemplate would have the row for its data context, and the commands
here are the vault's — the same three the buttons at the foot of this column run. The code-behind
selects whatever was right-clicked before the menu opens, so the selection-based commands act on the
row under the pointer, and cancels the menu outright over a group heading, which has no host to act
on. See HostSidebar.axaml.cs.
The same commands as the buttons, deliberately: a second path to deleting a host would be a second
place for the confirmation to be forgotten.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Connect" Command="{Binding ConnectCommand}" />
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<!--
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
The heading is a row rather than a container, which means the control will happily select it. That is
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
-->
<ListBox.DataTemplates>
<DataTemplate DataType="vm:SidebarGroupHeader">
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</Button>
</DataTemplate>
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
<Border Grid.Column="0" Classes="rowmark" />
<!--
Connected, and nothing more. Green means a terminal is open on this host right now; grey means
there is not one. It is deliberately not reachability — nothing here pings anything, and a dot
that meant "up" would be a claim this application never checks.
-->
<Ellipse Grid.Column="1" Classes="dot" Classes.live="{Binding IsConnected}"
Margin="8,5,8,0" VerticalAlignment="Top" />
<StackPanel Grid.Column="2" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" FontWeight="Medium"
TextTrimming="CharacterEllipsis" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
<!--
Which of the three ways this host authenticates. In the list because only one of them wants
the password box filled in, and an empty box on a key-authenticated host is otherwise
indistinguishable from one somebody forgot to fill in.
-->
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
<!--
Which vault this host is in, and only when there is more than one to be in. It decides
who else can see the host and where an edit goes back to, so on a list that spans
several vaults it is not decoration.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
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>
</ListBox.DataTemplates>
</ListBox>
<!--
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" />
<!--
Both boxes are allowed to be empty, and empty means "take the group's" rather than "unset". The
watermark is what the host will actually use if it is left that way, which is why it is bound
rather than literal: it changes when the group picker below moves.
-->
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False"
PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
</Grid>
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<!--
How this host authenticates: a typed password, one of the vault's keys, or one of its credentials.
Part of the host rather than of the connection, so it follows the host to every machine; a host
bound to something since deleted keeps a placeholder entry here, so that editing the port cannot
quietly turn it back into a typed-password host.
One control for all three, which is what makes "a key or a credential, never both" impossible to
express rather than merely invalid. The qualifier beside each label is not decoration: a key called
"deploy" and the deploy account's password are the ordinary case, and bare labels would offer two
identical-looking entries that authenticate completely differently.
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="10"
VerticalAlignment="Center"
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Which group this host is filed under. Inside the encrypted payload like everything else here, so
the server learns nothing about how the estate is organised — and a group the vault no longer has
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
-->
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
SelectedItem="{Binding EditorSelectedGroup}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</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" />
<!--
Stated at the moment the decision is made, which is the only place it means anything. With
relay off the server stores no address at all; with it on the server must be able to resolve
the target, or it becomes an authenticated open proxy into the operator's network.
-->
<TextBlock Classes="hint" FontSize="10"
Text="The relay stores this host's address on the server in plain text. Everything else stays encrypted." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
</StackPanel>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host key
is refused outright with no way to continue past it — so a legitimately rebuilt server needs
somewhere deliberate to be re-approved from, and that somewhere must not be the warning
itself. It takes effect when clicked rather than on Save, and the status line says so; it is
not a field of the host.
-->
<Button Classes="danger" Content="FORGET HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetHostKeyCommand}"
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"
IsVisible="{Binding ShowsHostActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteHostCommand}" />
</StackPanel>
</Border>
<!--
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
block below the first would push its own buttons off the window — the same reasoning that swaps the
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
-->
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsConfirmingHostDeletion}">
<views:ConfirmDeleteCard />
</Border>
</Grid>
</UserControl>