Files
DodoSSH/src/DodoSSH.Client.App/Views/HostsScreen.axaml
T
jaap-janandClaude Opus 5 b7335743d9 Make a host take what its group lends it, everywhere it is read
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host
against its groups; nothing asked it to. This is the wiring, and it is mostly
one change repeated: read the resolved host, not the stored one.

TryBuildAuthentication and TryBuildConnectionRequest now take the resolved
value beside the stored one, which is where group context was being lost. It
is the only authentication resolution in the product — both heads and both
transports come through it — so a host inheriting its binding would otherwise
have been offered a password prompt on every screen at once. The
credential-username fallback becomes three levels, and Complete still refuses
an empty username, but now only after the chain has been walked; refusing
before it would refuse exactly the hosts inheritance exists to serve.

HostRowViewModel carries its ResolvedHost, resolved once when the list is
built. Address, Authentication and Dialled read it, so a row cannot disagree
with itself about what it dials — and MainWindowViewModel.Rank searches
Address, so a host inheriting 2222 that displayed 22 would have been
unfindable by the port it actually answers on.

HostsBoundTo counts over the resolved binding, which is the difference between
a warning and a silence: a key bound once on a group and inherited by twenty
hosts named nobody, would have been deleted, and would then have refused all
twenty at connect time.

HostFields.From is answered by a refusal rather than by threading a group list
through the sync engine. A relay host may not inherit its port. The reason is
stronger than the convenience: a plaintext column is a derived duplicate the
client supplies when it pushes *this* host, so an inherited port would make it
depend on another item — editing a group would change what the relay dials for
every host beneath it, except that nothing re-pushes those hosts, so the server
would keep dialling the old port until each was next touched for some unrelated
reason. A stale wire on the relay path connects the user to the wrong service.

The editor distinguishes unset from explicit in both directions. An empty port
box means "take the group's" and shows what that will be as a placeholder,
following the group picker as it moves — a pre-filled 2222 would have been
indistinguishable from one the user typed, and saving would have pinned it. The
authentication picker gains a fourth entry, offered only to a host in a group,
because for an ungrouped host it would behave exactly like the first.

Which found a real defect while the tests were being written. Filing an
ungrouped host into a group silently pinned it to a typed password: the picker
had no "Inherit" entry when it opened, so it sat on "Password (ask each time)",
and saving wrote that as a decision — the host would have been pinned to a
prompt nobody asked for and the group's key would never have reached it. Two
guards now: the picker is rebuilt when the group changes, and BuildHost writes
AsksForPassword only for a host that had the alternative on offer.

The group editor is here too, and the plan never assigned it a step. Without it
no group can carry a default, so every line above would have been unreachable.
It grows a parent picker that leaves out the group itself and everything
beneath it — a courtesy rather than the guarantee, since a cycle assembled from
two offline re-parents was never offered that list — and three defaults beside
the name, each of which may be left empty because "lend nothing" is an answer.

Tags are stored and not editable. TagIds merges, encodes and resolves; no
screen can set one yet, and the editor carries the set through a save untouched
so a client that can set them does not lose them to somebody editing a port.

Eight new tests, and they dial. That is the point of them: a resolved value
that never reaches SshConnectionRequest is a label, and every one of these
failures would be silent — a host connecting to the wrong port, or being asked
for a password it does not need, with nothing on screen admitting it.

Verified by the whole suite: 1390 tests over nineteen projects, none failing.
Both heads build. Nothing on the phone has changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 10:41:28 +02:00

298 lines
17 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.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>
<!--
A group is no longer only a name, so this is no longer only a box. The four fields under the
name are what the hosts inside inherit when they say nothing themselves; every one of them may
be left empty, and empty means "lend nothing" rather than "unset". The parent picker leaves out
this group and everything beneath it, so a cycle cannot be made here — which is a courtesy
rather than the guarantee, because one assembled offline on two machines was never offered this
list. See HostInheritance.
-->
<StackPanel Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorParentChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedParent}"
MinWidth="150" FontSize="11" MinHeight="26">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<NumericUpDown Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1" Maximum="65535"
FormatString="0" ShowButtonSpinner="False" PlaceholderText="default port"
Width="120" FontSize="11" MinHeight="26" />
<TextBox Text="{Binding Vault.GroupEditorDefaultUsername}"
PlaceholderText="default username" Width="150"
FontSize="11" MinHeight="26" Padding="8,3" />
<ComboBox ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}"
MinWidth="180" FontSize="11" MinHeight="26">
<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>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Content="EDIT SELECTED" Command="{Binding Vault.EditGroupCommand}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
</StackPanel>
</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>