Give hosts and terminals their own screen, and the rest of the vault another
ci / build and test (ubuntu) (pull_request) Canceled after 0s
ci / build (windows) (pull_request) Canceled after 0s

Rebuilds the client's shell from an imported design: a titlebar and nav rail
it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host
search, and a vault screen that merges keys, passwords and pinned host keys
into one table. Hosts left the vault column for their own screen beside the
terminal, which is what the design asks for and turned out to be the better
split anyway.

Two screens the design shows have nothing behind them yet — file transfer
and teams — and say so plainly rather than rendering invented data; every
other gap between the design and this build is recorded in
docs/design-import-gaps.md.
This commit is contained in:
2026-07-31 08:39:37 +02:00
parent d162271a45
commit 9a76eced14
37 changed files with 4672 additions and 1347 deletions
@@ -0,0 +1,92 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
x:Class="DodoSSH.Client.App.Views.TerminalTabs"
x:DataType="vm:MainWindowViewModel">
<!--
The tab strip above the terminal.
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.
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
that state.
-->
<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">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</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>
<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>
</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}" />
<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}" />
</Grid>
</Border>
</UserControl>