Files
DodoSSH/src/DodoSSH.Client.App/Views/HostSidebar.axaml
T
jaap-jan 2caedd93ff Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs,
import, teams — plus the ObjectStore and Import projects behind two of them, and moved
WindowsDeviceKeyStore into the desktop head's Platform folder.

Five of those view models landed in a directory this branch had already moved, so they
join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the
namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android
head then gets transitively and will use neither of at first — scoped storage means there
is no ~/.ssh/config to import, and file transfer is out of its first scope.

Desktop suites green at 155 and 64.
2026-07-31 21:03:22 +02:00

255 lines
15 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"
IsVisible="{Binding AreHostsExpanded}"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}">
<!--
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>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
<Border Grid.Row="3" Padding="10" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsEditing}">
<StackPanel Spacing="6">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" />
<TextBox Grid.Column="2" Text="{Binding EditorUsername}" PlaceholderText="username" />
</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>
<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>
</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>