Public Access
Give the phone the second design, and both heads the palette it arrives with
The Android v2 design is what this head draws now: four destinations in a bottom bar — Hosts, Terminal, Keychain, More — with snippets, SFTP, S3, logs and preferences one tap deeper behind the last. The first design's four had nothing behind them, which is what made a hub worth building. The palette moved from green-black to blue-black, and it moved in the shared project because that is where it lives and the desktop v2 specifies the same seventeen tokens. One colour changed meaning rather than value, and it is the only semantic change in the file. Green used to *be* the accent, so Ellipse.dot.live filled with Accent and "the thing to press" and "a shell is open on this host" were the same colour by construction. v2 makes the accent blue and keeps a green for status alone, which finally separates them: Live is that green and nothing merely interactive may use it. The accent is also two colours now — Accent fills, AccentText writes — because a row of chips in the fill colour is a row of things that all look like the primary action. A palette is not one file, which is the part worth knowing before the next one. Nine hex literals lived outside it: the nav bar's own label colours, the accessory keys and their Ctrl-latched state, two scrims, the window background Android paints before Avalonia has a frame, and the launcher vector. The two C# sites now resolve from the dictionary by name rather than restating it. The renderer's page cannot — it is served to a WebView over a loopback socket — so terminal.css and terminal.js keep hand-copied values and say so at both sites. ShellScreen gained More and Buckets, appended rather than slotted in. SFTP and S3 are one screen over one TransfersViewModel differing only in which picker they offer, and the kind is set by the button that navigates rather than on arrival — doing it in OnScreenChanged made every arrival at Transfers force the picker back to hosts, including the desktop's own rail arriving at a screen with a bucket already open. It refuses to change kind while a session is live, because there is one session behind both destinations and switching under it would title a screen S3 while it listed an SFTP host. What the design draws and this does not, on the usual grounds. The FORWARDING screen: nothing here forwards anything, so every toggle would be a control with no effect — it is a paragraph on the hub naming the absence, for the reason the desktop keeps TEAMS in its rail. The terminal's `23 ms · fwd 5432`. An ED25519 badge and a SHA256 line on keychain cards, which need an algorithm field and a fingerprint the item type does not have. An `agent` chip, for an agent that does not exist. Snippet run history and exit codes. The Logs FOLLOW pill, which claims a live tail over records that are written once at close and read when the screen opens, and the severity filter, which has nothing to count — that chip row is spent on the real choice, which of the two logs. S3 bucket totals and lifecycle. And the + on HOSTS, which would open a host editor this head has not got. SFTP is browse, open and delete. Both transfer commands work, and what they work against is the local pane: QueueDownloads writes to Path.Combine(LocalPath, name), and LocalPath starts at SpecialFolder.UserProfile, which on Android is the application's own private directory. A download would have reported success and left the file where the person who asked for it cannot open it, which is worse than not offering it — a refusal is visible and a file in /data/user/0/ is not. The queue is not drawn either, since nothing here can put anything in it. Both return with the document picker. The foreground service still counts zero transfers, and the reason moved rather than went away. Four defects worth naming, because three of them are the kind that compile. A Button as a ListBox ItemTemplate swallows the pointer press before the list sees it, so the files listing selected nothing and every command reading the selection did nothing — the row is a Border now and the phone-only single-tap-to-open is a Tapped handler, which also keeps a desktop single click from walking into directories. Avalonia type selectors are exact, so TextBlock.fingerprint never matched SelectableTextBlock and every fingerprint on this head rendered proportional and unwrapped: that was breaking the never-truncated rule on the host-key sheet already. The new two-level hierarchy had no handler for the system back gesture, so back left the application from a log screen. And the tab's close cross had shrunk to a 30x32 target flush against the select target, which is the one control here that ends a shell with no confirmation and no undo. Fingerprint unlock is raised on arriving at the lock screen rather than waiting for its button, which is still there. Only at launch: a lock the user asked for is not answered with an immediate request to unlock, which makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And once, because a declined gesture leaves the passphrase box exactly where it was and a prompt that came back after being dismissed would be a modal you cannot get out of to type into it. Two fixes fall on the desktop. Its file listing coloured directories with Info and executables with Accent, which was blue against green and is now two steps of one blue; an executable is Live now. And a bucket's folders were drawn with a 0001-01-01 timestamp, because a prefix has no modification time — blank now, for the reason a directory's size is blank. Verified by the whole suite: 1309 tests over nineteen projects, none failing, including the layout suite that stands up real Avalonia and parses every desktop screen. Both heads build. Not verified on a device — nothing in this head ever has been; see docs/android-port.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZE3u99BNt6LzgTC5jhbz2
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
<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.FilesScreen"
|
||||
x:DataType="vm:TransfersViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
|
||||
<!--
|
||||
Designs 06 and 08 — SFTP and S3, which are one screen.
|
||||
|
||||
Below the picker the two are the same thing: a bucket and an SFTP host are both an IRemoteFileStore, and
|
||||
the pane, the queue and every transfer were written once. What differs is which picker is offered and
|
||||
what the button says — OPEN for a bucket, because S3 is request-per-operation and nothing stays
|
||||
connected. Drawing them twice would be two copies of a screen obliged to stay identical.
|
||||
|
||||
**One pane, not two.** The design's LOCAL / REMOTE toggle is not drawn, and this is the decision
|
||||
docs/android-port.md took before any of this was built: Android has scoped storage, so there is no
|
||||
browsable local filesystem to put in the other half. TransfersViewModel's local pane — LocalPath,
|
||||
LocalRoots, LocalEntries — is desktop-only and is left alone here rather than shown empty.
|
||||
|
||||
◆ **And that is why neither DOWNLOAD nor UPLOAD is on this screen.** Both commands exist and both work;
|
||||
what they work *against* is the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`,
|
||||
and `LocalPath` starts at `LocalDirectory.Home` — `SpecialFolder.UserProfile`, which on Android is the
|
||||
application's own private directory. A download would report success and put the file somewhere the
|
||||
person who asked for it cannot open it, which is worse than not offering it: a refusal is visible and a
|
||||
file in `/data/user/0/…` is not. The way in and out is the system document picker, which is the shape
|
||||
docs/android-port.md decided on and is the next piece of work.
|
||||
|
||||
So what ships is browsing a remote, and the two remote-side operations that need nothing local —
|
||||
opening a directory and deleting. The queue is drawn because a transfer can still be running when this
|
||||
screen is opened; it is simply not something this head can start yet.
|
||||
|
||||
◆ **The host key prompts are here too.** File transfer is a second, separate authenticated connection
|
||||
and it makes its own trust decision — the host records a second login. So this screen carries its own
|
||||
unknown-key prompt and its own changed-key refusal, over TransfersViewModel's own PendingHostKey, and
|
||||
the refusal has no continue button here either.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ header ============ -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto" Height="56" Margin="8,0">
|
||||
<Button Grid.Column="0" Classes="icon" Content="←"
|
||||
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.More}" />
|
||||
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="SFTP" Margin="4,0"
|
||||
IsVisible="{Binding ShowsHostPicker}" />
|
||||
<TextBlock Grid.Column="1" Classes="heading" Text="S3" Margin="4,0"
|
||||
IsVisible="{Binding ShowsBucketPicker}" />
|
||||
|
||||
<!-- What is actually open, named. On a bucket this reads s3://name. -->
|
||||
<Border Grid.Column="2" Classes="tag" Margin="8,0" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
|
||||
</Border>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon" Content="↻" Command="{Binding RefreshRemoteCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ opening one ============ -->
|
||||
<Border Grid.Row="1" Classes="card" Margin="12,0,12,8" IsVisible="{Binding !IsConnected}">
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsHostPicker}"
|
||||
ItemsSource="{Binding Hosts}" SelectedItem="{Binding SelectedHost}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<ComboBox HorizontalAlignment="Stretch" MinHeight="44" IsVisible="{Binding ShowsBucketPicker}"
|
||||
ItemsSource="{Binding Buckets}" SelectedItem="{Binding SelectedBucket}"
|
||||
PlaceholderText="choose a bucket">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
||||
<StackPanel Spacing="1">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
<TextBlock Classes="detail" Text="{Binding Description}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Its own box, deliberately not the hosts screen's. This is a separate authentication, so a password
|
||||
typed to open a terminal has not been offered here — and quietly reusing it would make a one-time
|
||||
password appear to work twice.
|
||||
-->
|
||||
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
Text="{Binding TypedPassword}" PasswordChar="•" PlaceholderText="password" />
|
||||
|
||||
<Button Classes="primary" Content="{Binding ConnectLabel}" Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ ◆ the host key decisions ============ -->
|
||||
<!--
|
||||
The unknown key: the fingerprint in full, wrapping rather than clipping, and two ways out.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasPendingHostKey}"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="14">
|
||||
<StackPanel Spacing="9">
|
||||
<TextBlock Classes="label" Foreground="{StaticResource Warn}" Text="UNKNOWN HOST KEY" />
|
||||
<TextBlock Classes="body">
|
||||
<Run Text="First contact with" />
|
||||
<Run Text="{Binding PendingHostKey.Host}" Foreground="{StaticResource Text}" />
|
||||
<Run Text="·" />
|
||||
<Run Text="{Binding PendingHostKey.Port}" />
|
||||
<Run Text=". Nothing in this keychain has approved this key." />
|
||||
</TextBlock>
|
||||
<TextBlock Classes="label" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding PendingHostKey.Algorithm}" />
|
||||
<!-- ◆ In full, and wrapping rather than clipping. See the .fingerprint style. -->
|
||||
<SelectableTextBlock Classes="fingerprint" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="TRUST AND OPEN"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
◆ The changed key, and it has no continue button. Presenting this as dismissible is the one design
|
||||
mistake that matters here — the same rule the hosts screen's sheet follows.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="12,0,12,8" IsVisible="{Binding HasHostKeyMismatch}"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="14">
|
||||
<StackPanel Spacing="9">
|
||||
<TextBlock Classes="label" Foreground="{StaticResource Danger}" Text="HOST KEY CHANGED — REFUSED" />
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}" Text="{Binding HostKeyMismatch}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ where we are ============ -->
|
||||
<ScrollViewer Grid.Row="3" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding IsConnected}" Margin="0,0,0,4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="14,0" VerticalAlignment="Center">
|
||||
<Button Classes="row" MinHeight="36" Padding="9,0" CornerRadius="9"
|
||||
Command="{Binding RemoteUpCommand}">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="↑" Foreground="{StaticResource AccentText}" />
|
||||
</Button>
|
||||
<ItemsControl ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="2" /></ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="row" MinHeight="36" Padding="7,0" CornerRadius="9"
|
||||
Command="{Binding $parent[views:FilesScreen].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="detail" FontSize="11" Text="{Binding Name}" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- ============ the listing ============ -->
|
||||
<Panel Grid.Row="4">
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConnected}" Margin="24,12"
|
||||
VerticalAlignment="Top" Text="{Binding Status}" />
|
||||
|
||||
<!--
|
||||
Tapped on the list rather than a Command on the row, and that is not a preference.
|
||||
|
||||
A Button as the ItemTemplate root swallows the press: the ListBoxItem never sees it, SelectedItem
|
||||
stays null, and every command that reads SelectedRemoteEntry — opening a directory, deleting —
|
||||
quietly does nothing. Measured, not assumed. So the row is a Border, selection works as it does on
|
||||
every other list in this head, and the one phone-specific behaviour is here: a single tap on a
|
||||
directory opens it, because a phone has no double-tap-to-open and no Enter.
|
||||
|
||||
Not driven from OnSelectedRemoteEntryChanged, which would have been the tidier-looking place: that
|
||||
setter is in the shared view model, and the desktop pane deliberately opens on DoubleTapped — making
|
||||
selection navigate would send a single desktop click walking into a directory.
|
||||
-->
|
||||
<ListBox ItemsSource="{Binding RemoteEntries}" SelectedItem="{Binding SelectedRemoteEntry}"
|
||||
Background="Transparent" BorderThickness="0" IsVisible="{Binding IsConnected}"
|
||||
Tapped="OnRemoteEntryTapped">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
<Setter Property="Margin" Value="10,1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Active}" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Border MinHeight="52" Padding="10,7">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="▸"
|
||||
Foreground="{StaticResource AccentText}" IsVisible="{Binding IsNavigable}" />
|
||||
<TextBlock Grid.Column="0" Width="16" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" FontSize="12" Text="·"
|
||||
Foreground="{StaticResource TextFaint}" IsVisible="{Binding IsFile}" />
|
||||
|
||||
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="12.5" Text="{Binding Name}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<TextBlock Classes="detail" Text="{Binding Size}"
|
||||
IsVisible="{Binding Size, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<!-- Real POSIX bits from the server, not a plausible-looking mode. -->
|
||||
<TextBlock Classes="detail" Text="{Binding Permissions}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Modified}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Panel>
|
||||
|
||||
<!-- ============ what to do with the chosen entry ============ -->
|
||||
<Border Grid.Row="5" IsVisible="{Binding IsConnected}" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="12,10">
|
||||
<StackPanel Spacing="9">
|
||||
|
||||
<!-- ◆ Counted, and it replaces the actions in place rather than stacking over the row it names. -->
|
||||
<Border IsVisible="{Binding IsConfirmingRemoteDeletion}" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12"
|
||||
Padding="13,11">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<Grid ColumnDefinitions="*,8,*" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="danger" Height="44" Content="DELETE"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="KEEP"
|
||||
Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsConfirmingRemoteDeletion}">
|
||||
<Button Grid.Column="0" Classes="secondary" Height="44" Content="DELETE"
|
||||
Command="{Binding DeleteRemoteCommand}" IsEnabled="{Binding CanDeleteRemote}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CLOSE"
|
||||
Command="{Binding DisconnectCommand}" />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="body" IsVisible="{Binding !IsConfirmingRemoteDeletion}"
|
||||
Text="Copying files to and from this phone needs the system document picker, which is not built yet — see the note at the top of this screen. Browsing, opening and deleting work." />
|
||||
|
||||
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" TextWrapping="Wrap"
|
||||
Text="{Binding Status}" />
|
||||
|
||||
<!--
|
||||
There is no queue on this screen, and that follows from the note at the top rather than being a
|
||||
separate decision: nothing here can enqueue a transfer, so a queue would be a region that is
|
||||
empty for every possible state of the application. It comes back with the document picker, along
|
||||
with the two buttons that would fill it.
|
||||
-->
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
Reference in New Issue
Block a user