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
+86 -50
View File
@@ -5,17 +5,20 @@
x:DataType="vm:MainWindowViewModel">
<!--
The tab strip above the terminal.
The tab strip, above every screen.
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session.
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see
docs/design-import-gaps.md.
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
@@ -24,10 +27,15 @@
<Border Height="34" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<!--
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
disappears would move every screen up and down by 34 pixels each time the last tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -36,57 +44,85 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Grid ColumnDefinitions="*,Auto">
<Button Grid.Column="0" Classes="flat tab"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
Classes.active="{Binding IsSelected}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
keeps its scrollback either way, which is usually why somebody is still looking at a
tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
for the tab to its right.
<Button Grid.Column="1" Classes="flat close" Width="20"
VerticalAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell.">
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
event handled — so a left press on the cross does not also select the tab. It deliberately
does not handle any other button, which is exactly what lets a middle press bubble out of
the cross and reach the handler below.
-->
<Button Classes="flat tab"
Classes.active="{Binding IsSelected}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey once it has ended. The pane
keeps its scrollback either way, which is usually why somebody is still looking at a
tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
looking for it.
-->
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<!--
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
-->
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
<!--
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
honestly, and there is one way to start a connection rather than two that have to agree.
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis" MaxWidth="280"
IsVisible="{Binding HasTabs}" />
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
dropping into it composites above a native child window depends on whether Avalonia gives it its
own platform window. docs/platform-flags.md records what this project already paid for treating a
rendering claim as settled without a screenshot. The palette has no such question — opening it
collapses the terminal outright.
-->
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</Grid>
<!--
Nothing open, and this is where that is said. It is also the only place near the terminal that can
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="9.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>