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
@@ -65,15 +65,32 @@
<!-- ============ The host, and the connection ============ -->
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
<Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,12,0" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
<!--
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
the reason is that the two are not interchangeable: a host brings a password box, a host key
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
would mean half this bar appearing and disappearing with the selection.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
IsVisible="{Binding !IsConnected}">
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}" />
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
Command="{Binding ShowRemoteCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
</StackPanel>
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
SelectedItem="{Binding SelectedHost}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsHostPicker}"
PlaceholderText="choose a host">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostRowViewModel">
@@ -87,26 +104,43 @@
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
SelectedItem="{Binding SelectedBucket}"
IsEnabled="{Binding !IsConnected}"
IsVisible="{Binding ShowsBucketPicker}"
PlaceholderText="choose a bucket">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
<StackPanel>
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
Foreground="{StaticResource TextFaint}" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
holding a different value. This connection authenticates separately, so a password typed to open a
terminal was never offered here.
-->
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
<TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
Text="{Binding TypedPassword}" PlaceholderText="password"
IsVisible="{Binding SelectedHostAsksForAPassword}"
IsEnabled="{Binding !IsConnected}" />
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
Command="{Binding ConnectCommand}"
IsVisible="{Binding !IsConnected}"
IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
<Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
Command="{Binding DisconnectCommand}"
IsVisible="{Binding IsConnected}" />
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
IsVisible="{Binding IsConnected}">
<TextBlock Text="{Binding ConnectedTo}" />
</Border>
@@ -122,7 +156,12 @@
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
<!-- ==== This machine ==== -->
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
<!--
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
This side takes remote rows only; see TransfersScreen.axaml.cs.
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
@@ -204,6 +243,17 @@
Text="Nothing in this folder. Use the trail above to go somewhere else."
IsVisible="{Binding !HasLocalEntries}" />
<!--
The drop highlight, over the whole pane and last so it is on top.
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
clears, and the drop lands nowhere.
-->
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
</Grid>
<!-- ==== The two directions ==== -->
@@ -226,7 +276,8 @@
</Border>
<!-- ==== The host ==== -->
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
@@ -345,6 +396,24 @@
IsVisible="{Binding IsConnected}" />
</StackPanel>
<!--
Two highlights rather than one, because refusing is worth showing. Something dragged over a
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
window that has stopped answering, and the answer arriving after the drop is the answer arriving
too late. See the local pane for why neither may hit-test.
-->
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
Foreground="{StaticResource Danger}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Grid>