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.
This commit is contained in:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -0,0 +1,252 @@
<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.HostsScreen"
x:DataType="vm:MainWindowViewModel">
<!--
The hosts screen: the list of machines, and what this application has to say about the one that is
selected.
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
promised: an overview.
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
has to reproduce by hand.
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
two cannot be put on one element.
-->
<Grid ColumnDefinitions="268,*">
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
<!--
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
one of them is fine.
-->
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
PasswordChar="•" Width="200" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
FontSize="11" VerticalAlignment="Center"
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
IsEnabled="{Binding !Vault.IsBusy}" />
</StackPanel>
</Border>
<StackPanel Grid.Row="1">
<!--
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
through the one warning that matters.
-->
<Border Padding="12,10" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasPendingHostKey}">
<StackPanel Spacing="6">
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding Vault.TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL"
Command="{Binding Vault.RejectHostKeyCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasHostKeyMismatch}">
<StackPanel Spacing="6">
<TextBlock Text="The host key changed and the connection was refused."
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose &quot;Forget host key&quot; first. There is deliberately no way to continue from here."
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
</StackPanel>
</Border>
<!--
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
and shown; without this panel it would be last-writer-wins with a longer explanation.
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
for as long as it did because no test could lay this markup out; that is the other half of why this
file exists.
-->
<Border Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding Vault.HasConflicts}">
<StackPanel Spacing="6">
<TextBlock Text="Some changes could not be merged automatically."
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
CornerRadius="4">
<StackPanel Spacing="4">
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
Foreground="{StaticResource TextDim}"
IsVisible="{Binding HasDetail}" />
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
</StackPanel>
</Border>
</StackPanel>
<!--
The overview proper: what is known about the host the list has selected.
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
terminal until this screen stopped hosting one, and filling it with something that needed new state
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
list of forty.
-->
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
<Panel Margin="24">
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
VerticalAlignment="Center" />
<Border Classes="chip" VerticalAlignment="Center"
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
<TextBlock Text="CONNECTED" />
</Border>
</StackPanel>
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
</StackPanel>
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
MaxWidth="440" TextWrapping="Wrap"
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
</Panel>
</ScrollViewer>
<!--
Groups: making them, renaming them, and taking them away.
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
in the host's own editor, on the left, for the same reason its key and its password are.
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
happen.
-->
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
</StackPanel>
<!--
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
third of this column to say what a row of chips says in one line.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
IsVisible="{Binding Vault.HasGroups}">
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
Background="Transparent" MaxHeight="72">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<StackPanel Margin="2,4" Spacing="1">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
FontSize="11.5" />
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
</Border>
</StackPanel>
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
<!--
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
two panels share one pending deletion, and the sidebar checks the same way.
-->
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
</StackPanel>
</Border>
</Grid>
</Grid>
</UserControl>