Files
DodoSSH/src/DodoSSH.Client.Android/Views/PhoneShell.axaml
T
jaap-jan dbfe3a5a37
ci / build and test (push) Successful in 1m36s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 27s
Merge branch 'claude/host-connection-top-bar-25d04e'
2026-08-03 14:56:12 +02:00

311 lines
18 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.Android.Views"
x:Class="DodoSSH.Client.Android.Views.PhoneShell"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
The phone's single view, and the counterpart of the desktop head's MainWindow — except that this one has
no window, no nav rail, no titlebar and no status bar.
It switches on ShellState, which is the same six states the desktop has because both heads share one
state machine. What differs is only what each one draws.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and the rest live one tap deeper
behind MORE: snippets, SFTP, S3, logs, preferences — and teams, which v2 did not draw and which is
argued for on the screen itself. That is the v2 design's own arrangement, and it replaces the first
design's four, which had nothing behind them at all. Five characters was a desktop constraint and the
phone uses words.
The order is the design's rather than the rail's. Terminal sits second, beside Hosts, because those two
are the pair a session moves between; on the desktop the terminal is not a rail entry at all.
── a terminal gets the screen ─────────────────────────────────────────────────────────────────────────
Three of the four rows below stand down while a shell is showing: the header, the shells strip and the
bottom bar itself. All three are bound on IsShowingPages, which is the same question asked once — the
surface is either a page or a terminal, and these are the chrome a page has.
The arithmetic is why. Header 56, strip 46, bar 64, and the terminal's own two rows on top of that: at
360dp the shell was framed by about a third of the display, all of it about somewhere the user was not.
What takes their place is one 35-pixel bar drawn by the surface itself, carrying back on the left and
the sessions and a + across from it. See TerminalScreen.axaml.
-->
<!--
Named, and the name is load-bearing: everything the phone draws is inside this one element, so its
bottom margin is the single place the software keyboard can be kept off the box being typed into,
whichever of the eleven screens is showing. See PhoneShell.axaml.cs.
-->
<Panel x:Name="Body">
<!-- ============ getting in ============ -->
<views:PendingScreen IsVisible="{Binding IsStarting}"
Heading="OPENING THE KEYCHAIN"
Detail="Reading this phone's local cache to find out whether it is enrolled." />
<views:ServerScreen IsVisible="{Binding IsNeedingServer}" />
<views:EnrollScreen IsVisible="{Binding IsNeedingEnrollment}" />
<views:RecoveryCodeScreen IsVisible="{Binding IsShowingRecoveryCode}" />
<views:LockedScreen IsVisible="{Binding IsLocked}" />
<!-- ============ unlocked ============ -->
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto">
<!--
The header: which vault, and whether it is synced.
Hidden behind MORE, and that is the design's arrangement rather than a saving. v2 gives every screen
one header carrying that screen's own name and its own actions — a back arrow, an add, a refresh —
so the hub's screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the two destinations that are the product's top level, where the
vault's name and the sync light are the most useful thing a header could say.
Wrapped rather than given a second condition, because Avalonia's bindings have no "and": the wrapper
collapses it over a terminal, where the surface draws its own bar and the vault's name is not what
the user is looking at. That is one of three rows this Grid stands down while a shell is showing —
see the strip and the bottom bar below.
-->
<Panel Grid.Row="0" IsVisible="{Binding IsShowingPages}">
<Border Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="14,0" Height="56"
IsVisible="{Binding !IsMoreSurface}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!--
Filled rather than outlined since v2. The mark is the one thing on this header that is not a
fact about the vault, and the design gives it the accent as a solid tile — which is also what
the launcher icon draws, so the two agree.
-->
<Border Grid.Column="0" Width="26" Height="26" CornerRadius="8"
Background="{StaticResource Accent}" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource AccentInk}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="Bold"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Grid.Column="1" Classes="heading" Margin="10,0,8,0" FontSize="16"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<!--
The sync light, and it is green only when it has earned it — see SyncLabel. The design draws
a permanently green "Synced" here, which is the one claim on that mock-up this application
will not make.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="9" Text="{Binding SyncLabel}" />
</StackPanel>
<Button Grid.Column="3" Classes="icon" Margin="4,0,0,0" Command="{Binding LockCommand}"
ToolTip.Tip="Lock the keychain">
<TextBlock Text="LOCK" Classes="label" FontSize="8.5"
Foreground="{StaticResource TextDim}" />
</Button>
</Grid>
</Border>
</Panel>
<!-- The screens. Only one draws; which one is the shell's business. -->
<Panel Grid.Row="1">
<!--
Wrapped rather than bound directly, and the desktop head's MainWindow carries the same note for
the same reason: this element's visibility is the shell's business and its data context is the
vault, and putting both on one element resolves IsVisible against the vault — where
IsHostsShowing does not exist. It is a compile error here rather than a silent blank screen only
because compiled bindings are on.
-->
<Panel IsVisible="{Binding IsHostsShowing}">
<views:HostsScreen DataContext="{Binding Vault}" />
</Panel>
<Panel IsVisible="{Binding IsVaultShowing}">
<views:KeychainScreen DataContext="{Binding Vault}" />
</Panel>
<!--
IsTerminalSurface rather than IsTerminalShowing: a tab exists from the moment connecting starts and
its session does not, so this screen has to be on show while there is nothing to render — it is
what says which machine is being connected to. Which of the two things inside it draws is decided
in the screen itself. See TerminalTabViewModel.
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<!--
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the destinations behind it each take the view model they are about, so each one is
wrapped. SnippetsScreen and LogsScreen are nullable on the shell — they are rebuilt on every
unlock and nulled on lock — and it is the collapsed wrapper that keeps a template from binding
against nothing.
-->
<views:MoreScreen IsVisible="{Binding IsMoreShowing}" />
<Panel IsVisible="{Binding IsSnippetsShowing}">
<views:SnippetsScreen DataContext="{Binding SnippetsScreen}" />
</Panel>
<Panel IsVisible="{Binding IsLogsShowing}">
<views:LogsScreen DataContext="{Binding LogsScreen}" />
</Panel>
<!--
The sixth destination behind MORE, and the one v2 never drew — see the comment on the screen
itself. Wrapped like its neighbours even though Teams is not nullable: the reason for the wrapper
is the data context, not the null. IsTeamShowing is the shell's and Teams is not the shell.
-->
<Panel IsVisible="{Binding IsTeamShowing}">
<views:TeamsScreen DataContext="{Binding Teams}" />
</Panel>
<!--
One screen for both file destinations. SFTP and S3 differ in which picker they offer and in
nothing else below it — the panes, the queue and the transfers are the same IRemoteFileStore
either way — so drawing them twice would be two copies of a screen that has to stay identical.
Which picker shows is TransfersViewModel.Remote, set on arrival by the shell.
-->
<Panel IsVisible="{Binding IsTransfersShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<Panel IsVisible="{Binding IsBucketsShowing}">
<views:FilesScreen DataContext="{Binding Transfers}" />
</Panel>
<!--
Preferences keeps PendingScreen for its body — it is still a screen that states what is absent
rather than one with settings on it — and gains the header its siblings under MORE have, here
rather than inside PendingScreen: that control is shared with three states of getting in, none of
which has anywhere to go back to.
-->
<Grid IsVisible="{Binding IsPreferencesShowing}" RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="Auto,*" Height="56" Margin="8,0">
<Button Grid.Column="0" Classes="icon" Content="←" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
<TextBlock Grid.Column="1" Classes="heading" Text="Preferences" Margin="4,0" />
</Grid>
<views:PendingScreen Grid.Row="1"
Heading="PREFERENCES"
Detail="Registering this phone's device key, and signing out, are on the unlock screen for now. Importing an ~/.ssh/config has no meaning here — scoped storage means there is no such file to read." />
</Grid>
</Panel>
<!--
◆ The two host-key decisions, over everything. Outside the screen Panel deliberately: file transfer
makes its own trust decision on the desktop, and the phone now has that screen — so a sheet nested
inside the hosts screen would be one the files screen could not raise.
-->
<Panel Grid.Row="1">
<views:HostKeySheet DataContext="{Binding Vault}" />
</Panel>
<!--
The shells strip. The design puts it above the nav on every screen, and that is the single most
desktop-shaped idea in the product — it is what makes a terminal a surface you switch to rather than
a screen you navigate away from. Kept, because losing it would make an open shell invisible from the
host list, which is where you are when you open the next one.
v2 draws the sessions as pills rather than as a labelled row, and drops the word SHELLS: with a
rounded chip carrying a live dot and a name, the label was spending nine characters of a 360dp row
saying what the row already looks like.
On every screen except the one it names. The terminal draws these same sessions in its own bar, and
two rows of the same pills — one of them 46 pixels of it — is the arrangement this surface exists to
stop. Wrapped rather than given a second condition, because the strip's own visibility is about
whether there are any tabs and this one is about which surface is up.
-->
<Panel Grid.Row="2" IsVisible="{Binding IsShowingPages}">
<Border IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Tabs}" Margin="12,0" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="34" Padding="13,0" CornerRadius="9"
Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green only while there is a shell behind the tab. It used to be lit unconditionally,
which was true when a tab could not exist without a session; one can now —
connecting opens the tab first — and a dot that was green before anything had
answered would be the one thing on this strip claiming something untrue.
-->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Panel>
<!-- ============ navigation ============ -->
<!--
Gone while a terminal is showing, which is the whole of that surface's arrangement: the bar's four
destinations are replaced by a back arrow and a + that leads to three of them, both in the terminal's
own bar. See TerminalScreen.axaml.
This one is bound directly rather than wrapped — its visibility is a single question and it has no
second condition of its own to keep separate.
-->
<Border Grid.Row="3" IsVisible="{Binding IsShowingPages}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*">
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<!--
The terminal is a surface rather than a page — see ShellSurface — so this one does not go
through ShowScreen. Its own command is on the shell.
The only entry here that never lights, and deliberately no longer tries: this bar is collapsed
while the terminal is showing, so IsCurrent could only ever be read as false. Binding it anyway
would be a rule about a state this control cannot be in. What marks the terminal as current is
the surface filling the screen.
-->
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗"
Command="{Binding ShowTerminalCommand}" />
<!--
"Keychain", although the design's tab says Vault and the enum member is Vault. The split is a
standing rule rather than an inconsistency: every sentence a user reads calls the encrypted
store a keychain, and everything under the surface calls it a vault, because renaming the wire
route, the tables and the CLR types would put an AAD resource type inside the blast radius of a
find-and-replace. See docs/design-import-gaps.md. A tab reading Vault would be the one place the
product surface leaked the implementation's word.
-->
<views:NavButton Grid.Column="2" Label="Keychain" Glyph="⚿" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for the hub and everything behind it,
and a bar that went dark the moment you opened one of them would only ever light three of its
four entries.
-->
<views:NavButton Grid.Column="3" Label="More" Glyph="≣" IsCurrent="{Binding IsMoreSurface}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.More}" />
</Grid>
</Border>
</Grid>
</Panel>
</UserControl>