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:
2026-08-02 18:23:53 +02:00
co-authored by Claude Opus 5
parent c00e5dbc5c
commit 5593f337b6
33 changed files with 1959 additions and 257 deletions
+15 -4
View File
@@ -268,10 +268,21 @@ the reasoning behind it. Sign in, unlock, browse hosts, open a shell, and read t
host-key decisions and the counted delete confirmations are there too, and none of them were softened to
fit 360dp.
What it does **not** have is file transfer — deliberately, since scoped storage means there is no local
pane to put beside the remote one — and the four list screens the desktop grew last (pins, snippets, logs,
teams), whose view models are already shared and which are additive rather than structural. Importing an
`~/.ssh/config` has no meaning on a phone at all.
Its interface is the **v2 design**: four destinations in a bottom bar — Hosts, Terminal, Vault and More —
with snippets, SFTP, S3 buckets, logs and preferences one tap deeper behind the last. That is also where
the product's palette now comes from, and both heads share it, so the desktop is blue too.
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain
item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no
phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
every control on that screen would have had no effect. See
[`docs/design-import-gaps.md`](docs/design-import-gaps.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
+30 -3
View File
@@ -439,6 +439,15 @@ go at 360dp:
`setInvalidatedByBiometricEnrollment` is on, which is what stops somebody who can add a fingerprint to an
unlocked phone from inheriting the vault — the cost being that re-enrolment permanently destroys the key,
which the load path treats as ordinary rather than exceptional.
**The prompt is raised on arrival at the lock screen** rather than waiting for the button, which is still
there and still says what it does. Two rules shape it, both in `PhoneShell.TryOfferDeviceUnlock`. It
happens **only at launch**: a lock the user asked for is not answered with a request to unlock, because
that makes LOCK look inert and trains the reflex of authenticating at a prompt nobody asked for. And it
happens **once**: a declined gesture leaves the passphrase box exactly where it was, which is the whole
fallback, and a prompt that came back after being dismissed would be a modal you cannot get out of to
type into it. It watches three properties rather than one because startup sets the state to `Locked`
before it has asked the keystore whether there is a key to offer, and does both inside the busy wrapper.
5. ~~**Android sign-in.**~~ **Done**, and the seam it needed turned out to be worth more than the
implementation. `IAuthorizationCallback` now sits between `OidcClient` and the loopback listener, so the
two heads differ in *where the response arrives* and in nothing else — PKCE, the state check, discovery,
@@ -477,6 +486,14 @@ go at 360dp:
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
import has no meaning under scoped storage, and the other four are list screens whose view models already
exist — they are additive rather than structural.
**Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
view models that were already shared, behind a MORE hub that holds the five destinations the bottom bar
has no room for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved
together, because it is shared. What v2 asked for and did not get is in
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
named as absent rather than drawn, because nothing in the SSH layer forwards anything.
8. ~~**The terminal.**~~ **Done** — one `NativeWebView` for every session, as on the desktop and for the
same reason, with the tab strip scrolling horizontally rather than wrapping so that opening a tab never
reflows a terminal while output is arriving.
@@ -487,9 +504,19 @@ go at 360dp:
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
believing they typed an `l`.
All eight are done for the decided scope. What is left is not on this list: the four destinations the
phone does not draw, file transfer (deliberately deferred, and see the decision above for the shape it
takes), the App Link upgrade, and — the one that is not optional — running any of it on a device.
All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
What is left, in the order it matters:
- **Running any of it on a device.** Still the one that is not optional, and still true: nothing here has
ever been launched on hardware or an emulator. Everything below is reasoning from the code.
- **The document picker.** The files screen browses a remote, downloads from it and runs the queue; putting
a file *on* a host from the phone needs `ACTION_OPEN_DOCUMENT` behind a third `IRemoteFileStore`-shaped
source, which is the decision recorded above and the obvious next piece of work.
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
- **Pins, teams and import**, which v2 does not draw either.
- **The App Link upgrade**, unchanged from step 5.
---
+35
View File
@@ -3,6 +3,41 @@
The Avalonia client's interface was rebuilt from a Claude Design project — a five-screen spec covering
the chrome, hosts and terminals, file transfer, the vault, teams, and preferences.
> ## The second import — v2, phone
>
> A later design in the same project, **DodoSSH Android v2**, is what the phone head now draws: eight
> screens, a bottom bar of four with the rest behind MORE, and a new palette. It has been imported, and
> this section is the same accounting for it. The desktop's own v2 has **not** been imported — only its
> palette, because the palette is shared and the two heads may not disagree about a colour.
>
> **The palette went from green to blue, and one colour changed meaning rather than value.** Green used to
> be the accent, so `Ellipse.dot.live` filled with `Accent` and "the thing to press" and "a shell is open
> here" were the same colour by construction. v2 makes the accent `#5B8CFF` and keeps a green, `#3DDC97`,
> for status alone. That is now `Live`, and nothing merely interactive may use it. The accent is also two
> colours — `Accent` fills, `AccentText` writes — which is what stops a row of chips reading as a row of
> primary actions.
>
> **Five destinations moved under MORE and four of them were built**: snippets, SFTP, S3 and logs, each
> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
>
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
> | Logs: a blinking **FOLLOW** pill over a live `tail` | A REFRESH button. These are the keychain's own synced audit records, written once at close and read when the screen opens — there is nothing to tail. |
> | Logs: `ALL 8 / INFO 5 / WARN 2 / ERROR 1` and per-host chips | The real two-way choice: connections, or keychain activity. A connection record is not a syslog line and carries no level, and neither log filters by host. |
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against 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 report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
> | A `+` floating button on **HOSTS** | Omitted. It would open a host editor, and this head has none — hosts are created on the desktop and sync down. |
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
project each piece would have to land in, and **what the shipped interface does instead**. That last
column is the important one. This codebase states its limits rather than implying them — the README does
+7 -2
View File
@@ -76,8 +76,13 @@ public sealed partial class DodoSshApp : Avalonia.Application
// Difference 2: the foreground service, which is what makes TerminalWorkspace's promise — that a
// shell outlives a vault lock — true on a platform that stops backgrounded processes.
// Zero transfers for now: file transfer is out of this head's first scope by decision, and this is
// the seam it arrives through rather than a dependency taken before there is anything behind it.
//
// Still zero transfers, and the reason moved rather than went away. v2 built the files screen, so
// this head can now browse a remote — but it cannot start a transfer, because both directions need
// the system document picker that scoped storage forces and that is not built (see FilesScreen).
// So the count is zero because the queue provably cannot have anything in it, not because nothing
// was wired. This is still the seam it arrives through: when the picker lands, this reads the
// queue and Refresh() gets called as transfers start and finish.
// A local rather than a field, matching the desktop head: an Avalonia Application has no disposal
// hook, so a field holding a disposable would have nowhere honest to release it. It stays alive
// because it is subscribed to the workspace, which lives as long as the process.
@@ -5,7 +5,7 @@
bitmap so there is one geometry to change and no set of five PNG densities to forget one
of.
#3CE88F is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
#5B8CFF is AccentColor from DodoSSH.Client.Shell's Theme/Palette.axaml, written out
because an Android resource cannot reference a XAML dictionary. The same duplication
colors.xml already carries for the window background, and the same rule applies: if the
palette moves, this moves with it.
@@ -31,14 +31,14 @@
<path
android:pathData="M33,33 L75,33 L75,75 L33,75 Z"
android:fillColor="#00000000"
android:strokeColor="#3CE88F"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.2" />
<!-- The chevron of >_ -->
<path
android:pathData="M44.8,48.75 L51.9,54 L44.8,59.25"
android:fillColor="#00000000"
android:strokeColor="#3CE88F"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
@@ -47,7 +47,7 @@
<path
android:pathData="M54,59.7 L63.2,59.7"
android:fillColor="#00000000"
android:strokeColor="#3CE88F"
android:strokeColor="#5B8CFF"
android:strokeWidth="2.8"
android:strokeLineCap="round" />
@@ -9,5 +9,5 @@
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
hidden.
-->
<color name="dodo_window">#0A0C0B</color>
<color name="dodo_window">#0E1220</color>
</resources>
@@ -12,7 +12,7 @@
the sync dot, which a system action bar has nowhere to put.
windowLightStatusBar is false so the clock and the battery icon are drawn light. The design's status
strip is #7E8A84 text on near-black, and leaving this at its default renders dark-on-dark — legible
strip is #8B93B0 text on near-black, and leaving this at its default renders dark-on-dark — legible
on the mock-up and invisible on a device.
-->
<style name="DodoTheme" parent="Theme.AppCompat.NoActionBar">
+166 -16
View File
@@ -10,6 +10,12 @@
Classes rather than implicit styles for the buttons, because the shell needs three that differ in
meaning rather than in degree: the accent one is what the screen wants you to do, the outlined one is
the alternative, and the red one is the one that costs something.
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
The second design rounds everything. The corner radii below are the design's own — 4 for a tag, 9 for a
button or a pill, 10 for a list row, 11 for the search well, 12 for a card, 14 for a block of
monospaced output — and they are a ladder rather than a set of preferences: the radius says how big the
thing is, so a 12 on a chip or a 4 on a card reads as the wrong size before it reads as the wrong shape.
-->
<Style Selector="Button.primary">
@@ -18,8 +24,8 @@
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -35,7 +41,7 @@
reads the screen as broken rather than as waiting for them.
-->
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
<Setter Property="Background" Value="#1A1F1D" />
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextFaint}" />
</Style>
@@ -48,7 +54,7 @@
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -63,7 +69,7 @@
<Setter Property="BorderBrush" Value="{StaticResource DangerSoft}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource Danger}" />
<Setter Property="CornerRadius" Value="5" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -76,7 +82,7 @@
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="Padding" Value="16,6" />
</Style>
<Style Selector="Button.row:pressed /template/ ContentPresenter">
@@ -84,20 +90,108 @@
</Style>
<!--
A category chip. A RadioButton rather than a ToggleButton because the four are exclusive and a screen
The 44-pixel square in a header — a back arrow, an add, an upload. Square rather than stretched because
it sits beside a title that must keep the width it has, and 44 rather than the glyph's own size because
a 17-pixel arrow is a 17-pixel target.
-->
<Style Selector="Button.icon">
<Setter Property="Width" Value="44" />
<Setter Property="Height" Value="44" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="17" />
</Style>
<Style Selector="Button.icon.accent">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="Button.icon:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<!--
No floating action button. The design puts one on HOSTS and one on S3, and both would open an editor
this head has not got — hosts and keychain items are created on the desktop and sync down. The style is
not here waiting for them either: an unused style is a claim that the control exists somewhere.
-->
<!--
A card: one item, one rule, one thing you can act on. The design uses it for a key, a snippet, a
transfer and the panel at the foot of the keychain, and they are all the same shape deliberately —
the screens differ in what they hold rather than in how they are built.
-->
<Style Selector="Border.card">
<Setter Property="Background" Value="{StaticResource Sidebar}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Padding" Value="14,13" />
</Style>
<!--
A block of monospaced output — a snippet's command, a log, the terminal. One surface for all three, and
it is not one of the palette's chrome greys: see TerminalSurface in Palette.axaml.
-->
<Style Selector="Border.output">
<Setter Property="Background" Value="{StaticResource TerminalSurface}" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="Padding" Value="12" />
</Style>
<!--
A small monospaced tag: a vault's name, a sync badge, what is open on the files screen. Not a control —
it is never pressed — so a Border rather than a Button, and the class exists so that the four screens
drawing one cannot disagree about its padding.
-->
<Style Selector="Border.tag">
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Padding" Value="7,2" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="Border.tag > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="9" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<!-- An outlined tag, for a fact about an item rather than a name: a type, an algorithm, a count. -->
<Style Selector="Border.tag.outline">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Padding" Value="5,1" />
</Style>
<Style Selector="Border.tag.outline > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="8.5" />
</Style>
<!--
A category chip. A RadioButton rather than a ToggleButton because the options are exclusive and a screen
reader should say so; the bullet its default template draws is removed, since the chip's own fill is
what carries the state.
Checked is a filled surface with accent *text*, not an accent fill. v2 makes that distinction
everywhere — see the remark on AccentText in Palette.axaml — and a chip is where it matters most: a row
of four solid blue lozenges is a row of four things that all look like the primary action.
-->
<Style Selector="RadioButton.chip">
<Setter Property="MinHeight" Value="34" />
<Setter Property="Padding" Value="12,6" />
<Setter Property="CornerRadius" Value="17" />
<Setter Property="Padding" Value="13,6" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Template">
<ControlTemplate>
@@ -114,19 +208,27 @@
</Setter>
</Style>
<Style Selector="RadioButton.chip:checked">
<Setter Property="Background" Value="{StaticResource Accent}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Canvas}" />
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
No segmented control either. The design draws one over the file listing for LOCAL / REMOTE, and there is
no local side on a phone — see FilesScreen. The two-way choices that are real, the log's two logs and
the keychain's categories, are chip rows, which is the same control at the same size without a well
around it.
-->
<Style Selector="TextBox.field">
<Setter Property="Height" Value="48" />
<Setter Property="Height" Value="44" />
<Setter Property="Padding" Value="14,0" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{StaticResource Field}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="CornerRadius" Value="11" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="12" />
@@ -136,6 +238,9 @@
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
nothing pings anything, and a dot meaning "up" would be a claim the application never checks.
Live rather than Accent since v2. The accent is blue now and this is not an accent: it is a fact about
a host. See the remark on Live in Palette.axaml.
-->
<Style Selector="Ellipse.dot">
<Setter Property="Width" Value="7" />
@@ -143,7 +248,7 @@
<Setter Property="Fill" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Ellipse.dot.live">
<Setter Property="Fill" Value="{StaticResource Accent}" />
<Setter Property="Fill" Value="{StaticResource Live}" />
</Style>
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
@@ -177,11 +282,49 @@
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<!--
A screen's own name, in the header. The one place this design is not monospaced: the design sets every
heading in its sans face and keeps the mono for what is machine-shaped — an address, a fingerprint, a
count, a command. That split is worth honouring even though both resolve to substituted fonts here.
-->
<Style Selector="TextBlock.heading">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="17" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
A group heading over a list: PRODUCTION, STAGING. Letter-spaced, and quieter than the rows under it —
but TextDim rather than the design's own #565E7E. In a flat list these headings are the only thing
separating one block of hosts from the next, and at 10px with a letter-space the fainter step is under
3:1 on this background. Quieter than a host row, still readable, which is what "quieter" has to mean.
-->
<Style Selector="TextBlock.section">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="FontSize" Value="10" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1" />
</Style>
<!--
A fingerprint, and it is a style rather than four repeated setters because the rule it encodes is a
product rule: never truncated, and always breakable across lines rather than clipped. Four screens show
one, and the day one of them clips is the day somebody approves the wrong key.
-->
<!--
◆ Declared twice, and this is the bug the rule above exists to prevent rather than a tidiness problem.
Avalonia's type selectors are exact: `TextBlock.fingerprint` does not match a SelectableTextBlock, which
derives from it. Every fingerprint on this head is selectable — you want to be able to copy one and
compare it — so with only the first selector the style applied to none of them, and a SHA256 fingerprint
rendered in the proportional font at the inherited size with TextWrapping left at NoWrap. At 360dp that
runs off the edge and clips, which is exactly the thing the comment above says must never happen.
The desktop head already carries the same pair for `mono` (App.axaml), for the same reason. Avalonia has
an `:is(TextBlock)` form that would match both in one selector; two explicit lines are used here because
the duplication is the thing worth seeing when somebody adds a third fingerprint site.
-->
<Style Selector="TextBlock.fingerprint">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
@@ -189,5 +332,12 @@
<Setter Property="LineHeight" Value="18" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="SelectableTextBlock.fingerprint">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="LineHeight" Value="18" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</Styles>
@@ -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>
@@ -0,0 +1,43 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.Android.Views;
/// <summary>Designs 06 and 08 — one remote pane, over SFTP or over a bucket.</summary>
internal sealed partial class FilesScreen : UserControl
{
public FilesScreen() => AvaloniaXamlLoader.Load(this);
/// <summary>
/// Opens a directory on a single tap.
/// </summary>
/// <remarks>
/// <para>
/// The desktop pane opens on a double tap and on Enter. A phone has neither, so the gesture has to be
/// the one gesture there is — and that is why this is here rather than in the shared view model: making
/// selection navigate would give the desktop a single click that walks into a directory.
/// </para>
/// <para>
/// <see cref="InputElement.Tapped"/> rather than a command on the row, because the row must stay a
/// plain <see cref="Border"/>: a button as the item template swallows the press before the list sees it
/// — it marks the pointer event handled, and the list's own handler is not registered for handled
/// events — leaving nothing selected and every command that reads the selection doing nothing. Tapped
/// fires after the list has moved its selection, which is what lets this read it.
/// </para>
/// <para>
/// A file is left selected rather than opened. There is nothing this head could do with it — see the
/// note about the document picker at the top of the screen — and the actions below act on the selection.
/// </para>
/// </remarks>
private void OnRemoteEntryTapped(object? sender, TappedEventArgs e)
{
if (DataContext is TransfersViewModel { SelectedRemoteEntry.IsNavigable: true } transfers)
{
transfers.OpenRemoteCommand.Execute(null);
}
}
}
@@ -24,8 +24,12 @@
<!-- ============ 05 UNKNOWN HOST KEY ============ -->
<Panel IsVisible="{Binding HasPendingHostKey}">
<!-- Scrim. Non-interactive by itself: tapping outside must not answer the question. -->
<Border Background="#9E040505" />
<!--
Scrim. Non-interactive by itself: tapping outside must not answer the question. 62% of Canvas,
written out because the palette holds no alpha variant of a surface — see QuickConnect on the
desktop, which carries the same note.
-->
<Border Background="#9E0E1220" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
@@ -6,7 +6,7 @@
Background="{StaticResource Canvas}">
<!--
Design 02 — HOSTS, the phone's home.
Design 01 — HOSTS, the phone's home.
The desktop's sidebar and its right-hand connect column cannot both be on screen at 360dp, so they are
the same column here: the list fills the screen, and choosing a host raises the connect bar over the
@@ -15,29 +15,35 @@
Groups are rows in the same list rather than a nesting container, which is what SidebarRows already
hands both heads. The desktop shows group headings only when groups exist and so does this, for free.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The rows became cards: rounded, inset from the edge, and filled when chosen rather than marked with a
strip. What did not come across is the design's floating + button. It would open a host editor, and this
head has none — hosts are created on the desktop and sync down. A button that opened nothing would be
the one control on this screen that lied about what the phone can do; see docs/design-import-gaps.md.
-->
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- ============ filter ============ -->
<!--
The design's "quick connect · search hosts" bar. On the desktop this is Ctrl+K over a palette; on a
phone the list is already the palette, so it filters in place rather than opening a second surface.
The design's "Search or connect…" well. On the desktop this is Ctrl+K over a palette; on a phone the
list is already the palette, so it filters in place rather than opening a second surface.
-->
<Border Grid.Row="0" Margin="16,12,16,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="6">
<Border Grid.Row="0" Margin="14,10,14,4" Background="{StaticResource Field}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="11" Height="44">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="&gt;" Classes="detail" Margin="12,0,0,0"
VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="search hosts"
Height="44" Padding="9,0" VerticalContentAlignment="Center"
<TextBlock Grid.Column="0" Text="⌕" Foreground="{StaticResource TextFaint}" FontSize="13"
Margin="13,0,0,0" VerticalAlignment="Center" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts"
Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="11.5" />
Foreground="{StaticResource Text}" FontFamily="{StaticResource MonoFont}" FontSize="12" />
</Grid>
</Border>
<!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="6">
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
@@ -46,11 +52,11 @@
banner the user has to dismiss rather than a screen they might never open.
-->
<Border IsVisible="{Binding HasConflicts}" Background="{StaticResource WarnWash}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
<StackPanel Spacing="8">
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<ItemsControl ItemsSource="{Binding Conflicts}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DataTemplate x:DataType="vm:ConflictRowViewModel">
<TextBlock Classes="detail" Foreground="{StaticResource WarnText}" TextWrapping="Wrap"
Text="{Binding Summary}" Margin="0,2" />
</DataTemplate>
@@ -62,22 +68,33 @@
</Border>
<Border IsVisible="{Binding HasUnreadableItems}" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="6" Padding="12,10">
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<TextBlock Classes="detail" Foreground="{StaticResource DangerText}" TextWrapping="Wrap"
Text="{Binding UnreadableSummary}" />
</Border>
</StackPanel>
<!-- ============ the list ============ -->
<ListBox Grid.Row="2" Margin="0,8,0,0"
<ListBox Grid.Row="2" Margin="0,6,0,0"
ItemsSource="{Binding SidebarRows}"
SelectedItem="{Binding SelectedSidebarRow}"
Background="Transparent" BorderThickness="0">
<ListBox.Styles>
<!--
The chosen row is a filled card. The corner radius is on the item rather than on a Border inside
it so that the fill the theme draws for selection is the rounded one — a radius set inside would
leave a square highlight behind a rounded row.
-->
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Margin" Value="10,1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="CornerRadius" Value="12" />
</Style>
</ListBox.Styles>
@@ -89,45 +106,52 @@
the filter, which is what a heading over a filtered list has to say.
-->
<DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="16,12,16,6">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="8,12,8,5">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}" Margin="7,0,0,0" />
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" />
</Grid>
</DataTemplate>
<!-- A host. -->
<DataTemplate DataType="vm:HostRowViewModel">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="54" Margin="16,6">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="56" Margin="14,12">
<!-- Green means a terminal is open on this host right now. See the .dot style. -->
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}"
<Ellipse Grid.Column="0" Classes="dot" Classes.live="{Binding IsConnected}" Width="8" Height="8"
VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="11,0,0,0" Spacing="2" VerticalAlignment="Center">
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="14" FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<Border IsVisible="{Binding HasVaultBadge}" BorderBrush="{StaticResource BorderFaint}"
BorderThickness="1" CornerRadius="3" Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8.5" Text="{Binding VaultBadge}" />
<Border Classes="tag outline" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<Border IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
BorderBrush="{StaticResource WarnSoft}" BorderThickness="1" CornerRadius="3"
Padding="4,0" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
<Border Classes="tag outline" BorderBrush="{StaticResource WarnSoft}"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Foreground="{StaticResource Warn}" Text="{Binding Badge}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="detail" Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" Text="·" />
<TextBlock Classes="detail" Text="{Binding Authentication}" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Address}"
TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" Text="·" />
<TextBlock Classes="detail" FontSize="10.5" Text="{Binding Authentication}" />
</StackPanel>
</StackPanel>
<!--
The design's "live" tag, on exactly the hosts the dot is green for — and in the same green,
overriding the tag style's accent text. A status word in the accent colour beside a green dot
meaning the same thing would be one fact drawn in two colours.
-->
<Border Grid.Column="2" Classes="tag" IsVisible="{Binding IsConnected}"
Background="{StaticResource LiveWash}">
<TextBlock Text="live" Foreground="{StaticResource Live}" />
</Border>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
VerticalAlignment="Center" IsVisible="{Binding !IsConnected}" />
</Grid>
</DataTemplate>
@@ -141,7 +165,7 @@
-->
<Border Grid.Row="3" IsVisible="{Binding SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12">
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -153,7 +177,7 @@
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
-->
<TextBox Classes="field" Height="44" IsVisible="{Binding SelectedHostAsksForAPassword}"
<TextBox Classes="field" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PasswordChar="•" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
@@ -14,9 +14,21 @@
And ◆ the delete confirmation replaces the row in place rather than stacking a dialogue over it — the
design is explicit about that, and on a phone it matters more than on the desktop: a modal over a list
hides which row it is talking about, which is exactly the information the confirmation exists to give.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Rows became cards with a glyph tile, which is the design's own shape. Three things drawn on those cards
are not here, and all three for the same reason: there is nothing behind them. An `ED25519` badge needs
an algorithm field, and `SshKeySecret` has none — the type stores the armour verbatim rather than
parsing it. A `SHA256:…` line needs a fingerprint, computed by the same parsing. And an `agent` chip
needs an agent, of which this application has none at all — the connect path decrypts the key and hands
the bytes to the SSH library. What the card carries instead is `Detail`, which is what is genuinely
known *about* an item and never the secret. See docs/design-import-gaps.md.
Mode=OneWay on the chips is load-bearing: a two-way selection binding moves the chip before the command
beside it has had the chance to refuse, which would leave the row lit for a section the list is not on.
-->
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- ============ categories ============ -->
<ScrollViewer Grid.Row="0" HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
@@ -60,15 +72,22 @@
</StackPanel>
</ScrollViewer>
<!--
The design's "4 keys · synced". SectionSummary counts the rows actually on screen and says how many of
them are still waiting to be pushed — which is the honest version of the design's unconditional
"synced".
-->
<TextBlock Grid.Row="1" Classes="detail" Margin="18,6,18,2" Text="{Binding SectionSummary}" />
<!-- ============ ◆ the counted confirmation ============ -->
<!--
In place, above the list it refers to. The text comes from the view model, which counts what breaks —
"three hosts authenticate with this key and will refuse to connect" — and the count is the whole
difference between a sentence somebody reads and one they click past.
-->
<Border Grid.Row="1" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
<Border Grid.Row="2" IsVisible="{Binding IsConfirmingDeletion}" Margin="12,4"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="6" Padding="14,12">
BorderThickness="1" CornerRadius="12" Padding="14,12">
<StackPanel Spacing="5">
<TextBlock Classes="mono" FontSize="12" Text="{Binding PendingDeletion.Question}" TextWrapping="Wrap" />
<TextBlock Classes="body" Foreground="{StaticResource DangerText}"
@@ -87,7 +106,7 @@
</Border>
<!-- ============ the items ============ -->
<Panel Grid.Row="2">
<Panel Grid.Row="3">
<!-- Written per section by the view model, and it says what the section is for. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVaultItems}" Margin="24,20"
@@ -103,27 +122,41 @@
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="*,Auto" MinHeight="56" Margin="16,6">
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13" FontWeight="Medium" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<Border BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="3"
Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Text="{Binding Type}" />
</Border>
<Border IsVisible="{Binding HasBadge}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" CornerRadius="3" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="detail" FontSize="8" Foreground="{StaticResource Warn}"
Text="{Binding Badge}" />
</Border>
<Border Classes="card" Margin="12,4">
<Grid ColumnDefinitions="Auto,*,Auto" MinHeight="64">
<!--
The glyph tile. One mark for all three kinds rather than three: a key, a password and a
bucket are already named by the badge beside them, and three near-identical monochrome
glyphs at 14px would be decoration that reads as a distinction.
-->
<Border Grid.Column="0" Width="36" Height="36" CornerRadius="10"
Background="{StaticResource Active}" VerticalAlignment="Center">
<TextBlock Text="" Foreground="{StaticResource AccentText}" FontSize="14"
FontWeight="SemiBold" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Margin="12,0" Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Name}"
TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline">
<TextBlock Text="{Binding Type}" />
</Border>
<Border Classes="tag outline" BorderBrush="{StaticResource WarnSoft}"
IsVisible="{Binding HasBadge}">
<TextBlock Foreground="{StaticResource Warn}" Text="{Binding Badge}" />
</Border>
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- What is known *about* an item, never the secret. -->
<TextBlock Classes="detail" Text="{Binding Detail}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
@@ -62,7 +62,7 @@
<Button Content="UNLOCK" Command="{Binding UnlockCommand}" IsEnabled="{Binding !IsBusy}"
Height="48" Margin="0,10,0,0" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Background="{StaticResource Accent}" Foreground="{StaticResource Canvas}"
Background="{StaticResource Accent}" Foreground="{StaticResource AccentInk}"
CornerRadius="6" FontFamily="{StaticResource MonoFont}" FontSize="12" FontWeight="SemiBold" />
<!--
@@ -99,7 +99,9 @@
BorderThickness="1" CornerRadius="6" Padding="14,12">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<!-- Live, not Accent: this marks shells that are still running, which is a fact and not a
thing to press. It read as green until v2 turned the accent blue. -->
<Ellipse Width="6" Height="6" Fill="{StaticResource Live}" VerticalAlignment="Center" />
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Warn}"
FontFamily="{StaticResource MonoFont}" FontSize="10" FontWeight="SemiBold"
TextWrapping="Wrap" />
@@ -0,0 +1,173 @@
<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.LogsScreen"
x:DataType="vm:LogsViewModel"
Background="{StaticResource Canvas}">
<!--
Design 07 — LOGS, under MORE.
Three things on the mock-up are not drawn, and each is the same kind of omission.
◆ **FOLLOW.** The design's header carries a blinking green "FOLLOW" pill and the screen is described as
a live tail over ssh. Nothing here tails anything: these are the keychain's own synced audit records,
written once at close, and they are read when the screen is opened. A pill that blinked would be
animation standing in for a feature. It is a REFRESH button, which is what actually happens.
**The severity filter** — ALL / INFO / WARN / ERROR with counts — has nothing to count. A connection
record is not a syslog line and carries no level. What this screen genuinely has two of is *logs*, so
the design's chip row is spent on the real choice: what was connected to, and what was changed.
**The host chips** are gone for the same reason: nothing filters either log by host.
What is kept is the design's row shape — a quiet timestamp, a coloured word, a source, and the fact
underneath — because that shape is why a log is readable at 10pt on a phone.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="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="Logs" Margin="4,0" />
<Button Grid.Column="2" Classes="icon" Content="↻" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
<!-- ============ which log ============ -->
<!--
Mode=OneWay is load-bearing on both of these, not a tidiness: a two-way selection binding moves the
chip before the command it is paired with has had a chance to refuse, so the screen would show a
section it is not on. The keychain's categories carry the same note.
-->
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="6" Margin="14,0,14,4">
<RadioButton GroupName="log" Classes="chip" IsChecked="{Binding ShowsConnections, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}"
Content="CONNECTIONS" />
<RadioButton GroupName="log" Classes="chip" IsChecked="{Binding ShowsActivity, Mode=OneWay}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}"
Content="KEYCHAIN" />
</StackPanel>
<TextBlock Grid.Row="2" Classes="detail" Margin="18,4,18,6" TextWrapping="Wrap"
Text="{Binding Status}" />
<!-- ============ the log ============ -->
<Border Grid.Row="3" Classes="output" Margin="12,0,12,12">
<Panel>
<!-- ============ what was connected to ============ -->
<!--
One wrapper per section, each holding its own empty state, because "this section is empty" is a
pair of facts — which section, and whether it has rows — and a binding cannot say `ShowsConnections
&& !HasConnections` without a converter. Two Panels are cheaper than a converter and they keep
EmptyMessage, which is already written per section, pointing at the section it describes.
-->
<Panel IsVisible="{Binding ShowsConnections}">
<TextBlock Classes="body" VerticalAlignment="Top" Text="{Binding EmptyMessage}"
IsVisible="{Binding !HasConnections}" />
<ScrollViewer IsVisible="{Binding HasConnections}">
<ItemsControl ItemsSource="{Binding Connections}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding Started}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<!--
The kind sits where the design puts a severity, because it is the word that tells you
what the row is: a shell or a file transfer. Green while the session is still open —
the same green the host list uses, and the one thing on this screen that is about now
rather than about the past.
-->
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold" Text="{Binding Kind}"
Foreground="{StaticResource Info}" VerticalAlignment="Center"
IsVisible="{Binding !IsLive}" />
<StackPanel Orientation="Horizontal" Spacing="5" IsVisible="{Binding IsLive}"
VerticalAlignment="Center">
<Ellipse Classes="dot live" Width="6" Height="6" VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
Text="{Binding Kind}" Foreground="{StaticResource Live}" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10" Text="{Binding HostLabel}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding Address}" TextTrimming="CharacterEllipsis" />
<!--
TextDim rather than the `detail` class's own faint step. This block is drawn on
TerminalSurface, which is lighter than the Canvas that step was chosen against, so the
same colour that reads as quiet on a screen background reads as barely there here.
-->
<StackPanel Orientation="Horizontal" Spacing="7">
<!-- "still open" rather than a dash, which the plan calls out by name. -->
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="{Binding Duration}" />
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="·" />
<TextBlock Classes="detail" FontSize="9.5" Foreground="{StaticResource TextDim}"
Text="{Binding DeviceName}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<!-- A refused host key is the one outcome worth a colour of its own. -->
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasOutcome}"
Foreground="{StaticResource DangerText}" Text="{Binding Outcome}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Panel>
<!-- ============ what was changed ============ -->
<Panel IsVisible="{Binding ShowsActivity}">
<TextBlock Classes="body" VerticalAlignment="Top" Text="{Binding EmptyMessage}"
IsVisible="{Binding !HasActivity}" />
<ScrollViewer IsVisible="{Binding HasActivity}">
<ItemsControl ItemsSource="{Binding Activity}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<StackPanel Spacing="1" Margin="0,5">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="detail" FontSize="10" Text="{Binding At}"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" FontWeight="SemiBold"
Text="{Binding Operation}" Foreground="{StaticResource Info}"
VerticalAlignment="Center" />
<TextBlock Classes="detail" FontSize="10" Text="{Binding ItemKind}"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="detail" FontSize="10.5" Foreground="{StaticResource Text}"
Text="{Binding ItemLabel}" TextTrimming="CharacterEllipsis" />
<!-- The names of the fields that changed, never their values. -->
<TextBlock Classes="detail" FontSize="9.5" IsVisible="{Binding HasChangedFields}"
Foreground="{StaticResource TextDim}"
Text="{Binding ChangedFields}" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Panel>
</Panel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 07 — the two logs: what has been connected to, and what has been changed.</summary>
internal sealed partial class LogsScreen : UserControl
{
public LogsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -0,0 +1,160 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
x:Class="DodoSSH.Client.Android.Views.MoreScreen"
x:DataType="vm:MainWindowViewModel"
Background="{StaticResource Canvas}">
<!--
Design v2 — MORE: the hub for everything the bottom bar has no room for.
Four slots and nine destinations is the arithmetic the design solves by putting five of them one tap
deeper. This screen is that tap. It takes the shell as its data context rather than the vault, because
every row on it is a navigation command and nothing here reads an item.
The rows are the design's list rows rather than cards: a card is one thing you act on, and a destination
is not a thing — it is a place. Each carries a sentence saying what is behind it, because a hub whose
entries are one word each is a menu you have to open to read.
-->
<Grid RowDefinitions="Auto,*">
<Border Grid.Row="0" Height="56" Padding="16,0">
<TextBlock Classes="heading" Text="More" />
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel Margin="10,0,10,20" Spacing="2">
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Snippets" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="Saved commands, typed into an open shell." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
These two go through ShowFiles rather than ShowScreen: they are one screen over one view model,
and which kind of remote it offers is the thing being chosen. See ShowFilesCommand for why that
is set by the button rather than on arrival.
-->
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⇅" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="SFTP" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Browse a host's files." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="◳" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="S3" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Objects in an S3-compatible bucket from the keychain." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="≡" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Logs" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What has been connected to, and what has changed." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<Button Classes="row" Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚙" Foreground="{StaticResource AccentText}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Preferences" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}" Text="What this phone can and cannot do, and why." />
</StackPanel>
<TextBlock Grid.Column="2" Text="" Foreground="{StaticResource TextGhost}" FontSize="15"
VerticalAlignment="Center" />
</Grid>
</Button>
<!--
Locking, here as well as in the header.
The header carries it on the three top-level destinations and stands down behind MORE, so without
this row the five screens under here would each be two taps from locking the keychain. That is the
wrong direction for the one action whose whole value is being quick, and it is why this is a row
on the hub rather than something you go back for.
-->
<Button Classes="row" Margin="0,10,0,0" Command="{Binding LockCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="⚿" Foreground="{StaticResource TextDim}" FontSize="14"
Width="22" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="Lock the keychain" />
<TextBlock Classes="detail" Foreground="{StaticResource TextDim}"
Text="Shells you have open keep running." />
</StackPanel>
</Grid>
</Button>
<!--
◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no
effect and every counter a number with no source.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
docs/design-import-gaps.md.
-->
<Border Classes="card" Margin="6,16,6,0">
<StackPanel Spacing="7">
<StackPanel Orientation="Horizontal" Spacing="9">
<TextBlock Text="⇄" Foreground="{StaticResource TextFaint}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" />
</Border>
</StackPanel>
<TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design v2 — MORE, the hub for the five destinations the bottom bar has no room for.</summary>
internal sealed partial class MoreScreen : UserControl
{
public MoreScreen() => AvaloniaXamlLoader.Load(this);
}
+77 -15
View File
@@ -17,13 +17,21 @@ namespace DodoSSH.Client.Android.Views;
/// </summary>
/// <remarks>
/// <para>
/// Written in C# rather than as a XAML control because it is a button with a label and a rule about the
/// accent bar, and a templated control for that would be more machinery than the thing it draws.
/// Written in C# rather than as a XAML control because it is a button with a glyph, a label and a rule about
/// the accent bar, and a templated control for that would be more machinery than the thing it draws.
/// </para>
/// <para>
/// <b>The accent bar is on top, not underneath.</b> The design puts it there in the nav rail and it is
/// worth keeping on a phone for a reason the desktop does not have: the bottom of a phone screen is under
/// the system's own gesture bar, and an indicator drawn there is one the user cannot see.
/// <b>The accent bar is on top, not underneath.</b> The v2 design distinguishes the current destination with
/// colour and weight alone and draws no bar at all; the bar is kept, and kept at the top, for a reason the
/// design does not have to think about. The bottom of a phone screen is under the system's own gesture bar,
/// so an indicator drawn there is one the user cannot see — and colour alone at 9pt on a dark background is
/// the single signal a user with a colour deficiency would be left with. Weight, colour and the bar are
/// three, which is the right number for the control that says where you are.
/// </para>
/// <para>
/// Every colour here is resolved from <c>Theme/Palette.axaml</c> rather than written out. A nav bar that
/// named its own blues is exactly how the fourth place a colour appears ends up one shade off — which is the
/// argument the desktop head's <c>Ellipse.dot</c> style makes for being a style rather than a converter.
/// </para>
/// </remarks>
internal sealed class NavButton : Button
@@ -31,12 +39,26 @@ internal sealed class NavButton : Button
public static readonly StyledProperty<string> LabelProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Label), string.Empty);
/// <summary>The glyph drawn above the label.</summary>
/// <remarks>
/// A character rather than an icon asset. The four the design uses — ▤ ⌗ ⚿ ≣ — are all in the fonts
/// Android already has, so shipping four vectors to draw them would buy nothing but four more files to
/// keep in step with the palette.
/// </remarks>
public static readonly StyledProperty<string> GlyphProperty =
AvaloniaProperty.Register<NavButton, string>(nameof(Glyph), string.Empty);
public static readonly StyledProperty<bool> IsCurrentProperty =
AvaloniaProperty.Register<NavButton, bool>(nameof(IsCurrent));
private readonly Border indicator;
private readonly TextBlock glyph;
private readonly TextBlock text;
// Resolved from the palette once the control is in the tree; see Repaint.
private IBrush? currentBrush;
private IBrush? idleBrush;
public NavButton()
{
Background = Brushes.Transparent;
@@ -55,16 +77,28 @@ internal sealed class NavButton : Button
Background = Brushes.Transparent,
};
glyph = new TextBlock
{
FontSize = 15,
HorizontalAlignment = HorizontalAlignment.Center,
};
text = new TextBlock
{
FontSize = 9,
FontWeight = FontWeight.SemiBold,
FontWeight = FontWeight.Medium,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Foreground = new SolidColorBrush(Color.Parse("#566059")),
};
Content = new Panel { Children = { indicator, text } };
var stack = new StackPanel
{
Spacing = 3,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
Children = { glyph, text },
};
Content = new Panel { Children = { indicator, stack } };
}
public string Label
@@ -73,6 +107,13 @@ internal sealed class NavButton : Button
set => SetValue(LabelProperty, value);
}
/// <inheritdoc cref="GlyphProperty" />
public string Glyph
{
get => GetValue(GlyphProperty);
set => SetValue(GlyphProperty, value);
}
/// <summary>Whether this is the destination currently showing.</summary>
public bool IsCurrent
{
@@ -89,14 +130,13 @@ internal sealed class NavButton : Button
{
text.Text = Label;
}
else if (change.Property == GlyphProperty)
{
glyph.Text = Glyph;
}
else if (change.Property == IsCurrentProperty)
{
// Both the label and the bar, not just the bar. Colour alone at 9pt on a dark background is a
// weak signal, and it is the only one a user with a colour deficiency would have.
text.Foreground = new SolidColorBrush(Color.Parse(IsCurrent ? "#3CE88F" : "#566059"));
indicator.Background = IsCurrent
? new SolidColorBrush(Color.Parse("#3CE88F"))
: Brushes.Transparent;
Repaint();
}
else if (change.Property == FontFamilyProperty)
{
@@ -115,5 +155,27 @@ internal sealed class NavButton : Button
{
text.FontFamily = mono;
}
currentBrush = Application.Current?.FindResource("AccentText") as IBrush;
idleBrush = Application.Current?.FindResource("TextFaint") as IBrush;
// IsCurrent is normally bound and so has usually already been set by the time the template applies,
// which is why this runs again here rather than only on the property change.
Repaint();
}
private void Repaint()
{
// Both the label and the bar, not just the bar — and the weight as well. See the remark on the type.
var paint = IsCurrent ? currentBrush : idleBrush;
if (paint is not null)
{
glyph.Foreground = paint;
text.Foreground = paint;
}
text.FontWeight = IsCurrent ? FontWeight.SemiBold : FontWeight.Medium;
indicator.Background = IsCurrent && currentBrush is not null ? currentBrush : Brushes.Transparent;
}
}
+144 -63
View File
@@ -13,10 +13,14 @@
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.
The nav rail's eight destinations become four in a bottom bar. That is not a reflow: the plan says five
characters was a desktop constraint and the phone can use words, and it also says a phone cannot carry
eight. What is here is what the decided scope needs — the keychain and a terminal — and the destinations
behind the other four are reachable from those two rather than being hidden.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
The desktop's eight rail destinations become four in a bottom bar, and five more live one tap deeper
behind MORE: snippets, SFTP, S3, logs and preferences. 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.
-->
<Panel>
@@ -34,29 +38,47 @@
<!-- ============ unlocked ============ -->
<Grid IsVisible="{Binding IsUnlocked}" RowDefinitions="Auto,*,Auto,Auto">
<!-- The header: which vault, and whether it is synced. -->
<!--
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 five hub screens draw their own and this one stands down rather than stacking a second row of
chrome above theirs. It stays on the three 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.
-->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" Padding="16,0" Height="52">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="20" Height="20" BorderBrush="{StaticResource Accent}"
BorderThickness="1" VerticalAlignment="Center">
<TextBlock Text="&gt;_" Foreground="{StaticResource Accent}"
FontFamily="{StaticResource MonoFont}" FontSize="9" FontWeight="SemiBold"
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>
<StackPanel Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" Spacing="1">
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Vault.VaultName}" TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" Spacing="5">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="label" FontSize="8" Text="{Binding SyncLabel}" />
</StackPanel>
<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="2" Classes="row" Width="44" MinHeight="44" Padding="0"
HorizontalContentAlignment="Center" Command="{Binding LockCommand}">
<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>
@@ -85,15 +107,60 @@
-->
<views:TerminalScreen IsVisible="{Binding IsTerminalSurface}" />
<views:PendingScreen IsVisible="{Binding IsPreferencesShowing}"
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." />
<!--
============ under MORE ============
The hub itself takes the shell as its data context, because every row on it is a navigation
command; the five 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>
<!--
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 when the phone grows that screen this must already
be somewhere both can reach rather than nested inside one of them.
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}" />
@@ -104,64 +171,78 @@
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.
-->
<Border Grid.Row="2" IsVisible="{Binding HasTabs}" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Height="46">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Spacing="8" Margin="12,0" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="8" Text="SHELLS" VerticalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Button Classes="row" MinHeight="32" Padding="10,6" CornerRadius="5"
Background="{StaticResource Field}"
Command="{Binding $parent[views:PhoneShell].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
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="5" Height="5"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="10" Text="{Binding Label}" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<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>
<!-- ============ navigation ============ -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Height="58">
BorderThickness="0,1,0,0" Height="64">
<Grid ColumnDefinitions="*,*,*,*">
<views:NavButton Grid.Column="0" Label="HOSTS" IsCurrent="{Binding IsHostsShowing}"
<views:NavButton Grid.Column="0" Label="Hosts" Glyph="▤" IsCurrent="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}" />
<views:NavButton Grid.Column="1" Label="KEYCHAIN" IsCurrent="{Binding IsVaultShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
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.
-->
<views:NavButton Grid.Column="2" Label="TERMINAL" IsCurrent="{Binding IsTerminalSurface}"
<views:NavButton Grid.Column="1" Label="Terminal" Glyph="⌗" IsCurrent="{Binding IsTerminalSurface}"
Command="{Binding ShowTerminalCommand}" />
<views:NavButton Grid.Column="3" Label="MORE" IsCurrent="{Binding IsPreferencesShowing}"
<!--
"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.Preferences}" />
CommandParameter="{x:Static vm:ShellScreen.Vault}" />
<!--
IsMoreSurface rather than IsMoreShowing: this tab stands for six screens, 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>
@@ -1,6 +1,8 @@
using global::Android.Views;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
using DodoSSH.Client.Android.Platform;
@@ -13,6 +15,19 @@ internal sealed partial class PhoneShell : UserControl
{
private MainWindowViewModel? shell;
/// <summary>
/// Whether the lock screen currently showing is the one the application launched into.
/// </summary>
/// <remarks>
/// Set once, when the shell arrives, because this control is built once for the process. Cleared the
/// moment the state leaves <see cref="ShellState.Locked"/>, and never set again — which is what stops a
/// deliberate lock from being answered with an immediate request to unlock. See
/// <see cref="TryOfferDeviceUnlock"/>.
/// </remarks>
private bool thisLockIsTheLaunch;
private bool offeredDeviceUnlock;
public PhoneShell()
{
AvaloniaXamlLoader.Load(this);
@@ -30,15 +45,167 @@ internal sealed partial class PhoneShell : UserControl
{
shell.PropertyChanged += OnShellChanged;
ApplyScreenshotPolicy(shell.State);
thisLockIsTheLaunch = true;
TryOfferDeviceUnlock();
}
};
}
private void OnShellChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (shell is not null && e.PropertyName is nameof(MainWindowViewModel.State))
if (shell is null)
{
return;
}
if (e.PropertyName is nameof(MainWindowViewModel.State))
{
ApplyScreenshotPolicy(shell.State);
if (shell.State is not ShellState.Locked)
{
thisLockIsTheLaunch = false;
offeredDeviceUnlock = false;
}
}
// Three properties rather than one, because the condition is assembled out of order. Startup sets
// State to Locked and only then awaits the keystore to decide CanUnlockWithDevice, and it does all
// of it inside the busy wrapper — which refuses a second command outright. So whichever of the
// three settles last is the one that has to ask again.
if (e.PropertyName is nameof(MainWindowViewModel.State)
or nameof(MainWindowViewModel.CanUnlockWithDevice)
or nameof(MainWindowViewModel.IsBusy))
{
TryOfferDeviceUnlock();
}
}
/// <summary>
/// Raises the fingerprint prompt on arriving at the lock screen, rather than waiting to be asked.
/// </summary>
/// <remarks>
/// <para>
/// The button is still there and still says what it does; this only spends the tap for you. On a phone
/// that is the difference between opening the application in one gesture and in two, and the second of
/// the two was a button whose entire content was "yes, do the thing you already know I want".
/// </para>
/// <para>
/// <b>Only at launch.</b> A lock the user asked for is not answered with a request to unlock — that
/// turns LOCK into a control that appears to do nothing, and worse, trains the reflex of authenticating
/// at a prompt that appeared without being asked for. So the offer belongs to the locked screen the
/// process started on and to no other.
/// </para>
/// <para>
/// <b>Once.</b> A declined gesture leaves the passphrase box exactly where it was, which is the whole
/// fallback — and a prompt that reappeared after being dismissed would be a modal the user cannot get
/// out of to type into it.
/// </para>
/// <para>
/// Nothing here needs a failure path. <c>UnlockWithDeviceAsync</c> turns every refusal into a status
/// line, and a phone with no enrolled fingerprint never gets here at all, because
/// <c>CanUnlockWithDevice</c> already asked the keystore.
/// </para>
/// </remarks>
private void TryOfferDeviceUnlock()
{
if (!thisLockIsTheLaunch || offeredDeviceUnlock)
{
return;
}
if (shell is not { State: ShellState.Locked, CanUnlockWithDevice: true, IsBusy: false } current)
{
return;
}
offeredDeviceUnlock = true;
current.UnlockWithDeviceCommand.Execute(null);
}
/// <inheritdoc />
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested += OnBackRequested;
}
}
/// <inheritdoc />
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (TopLevel.GetTopLevel(this) is { } top)
{
top.BackRequested -= OnBackRequested;
}
base.OnDetachedFromVisualTree(e);
}
/// <summary>
/// Takes the system back gesture up the hierarchy rather than out of the application.
/// </summary>
/// <remarks>
/// <para>
/// v2 is the first arrangement here with a second level: five destinations sit behind MORE, each with
/// its own back arrow. Android's back is the same gesture as that arrow and users reach for it first,
/// and left unhandled it does not go up — it finishes the activity. Ending the application from a log
/// screen is not a plausible reading of "back".
/// </para>
/// <para>
/// Handled in the order the interface is stacked, not by screen alone: the terminal is a surface over a
/// page, so it is dismissed before the page under it is considered. Setting <c>Handled</c> is what stops
/// the event being re-dispatched to the activity's own default.
/// </para>
/// <para>
/// <b>Two things it deliberately does not do.</b> It does not answer a host-key prompt — those are
/// decisions with two named buttons, and a gesture that dismissed one would be the swipe-to-dismiss this
/// head refused when it made the changed-key refusal a full-screen panel rather than a sheet. And from
/// the host list it does nothing at all, so back still leaves the application from the screen the
/// application opens on, which is what every other Android app does.
/// </para>
/// </remarks>
private void OnBackRequested(object? sender, RoutedEventArgs e)
{
if (shell is not { State: ShellState.Unlocked } current)
{
return;
}
// A decision is on screen. Leave it alone — see the remark.
if (current.Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true }
|| current.Transfers is { HasPendingHostKey: true } or { HasHostKeyMismatch: true })
{
return;
}
if (!current.IsShowingPages)
{
current.ShowScreenCommand.Execute(current.Screen);
e.Handled = true;
return;
}
switch (current.Screen)
{
case ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers
or ShellScreen.Buckets or ShellScreen.Preferences:
current.ShowScreenCommand.Execute(ShellScreen.More);
e.Handled = true;
break;
case ShellScreen.More or ShellScreen.Vault:
current.ShowScreenCommand.Execute(ShellScreen.Hosts);
e.Handled = true;
break;
default:
// Hosts, and anything the phone does not draw. Left unhandled, so back leaves the app.
break;
}
}
@@ -0,0 +1,167 @@
<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.SnippetsScreen"
x:DataType="vm:SnippetsViewModel"
Background="{StaticResource Canvas}">
<!--
Design 05 — SNIPPETS, under MORE.
Two departures from the mock-up, both because of what a snippet actually is here.
The design puts a ▶ Run button on every card. There are two operations rather than one — type the
command at the prompt, and type it *and* press Enter — and which of them a snippet allows is a property
of the snippet, set by a checkbox in its editor. ◆ That checkbox is the whole safety property: a
snippet that does not run on insert leaves the command sitting at the prompt for a human to read before
committing to it. Collapsing the two into one button called Run would throw that away, so the actions
are a bar over the bottom of the list, they name the terminal they will type into, and RUN only appears
for a snippet that is marked as running.
And the design's per-card footer — the hosts it ran on, the time, `exit 0` — is not drawn. Nothing here
records where a snippet was run, when, or what it returned; a snippet is a saved string, and the shell
it is typed into never reports back. See docs/design-import-gaps.md.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<!-- ============ header ============ -->
<Grid Grid.Row="0" ColumnDefinitions="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="Snippets" Margin="4,0" />
<Button Grid.Column="2" Classes="icon accent" Content="+" Command="{Binding NewCommand}" />
</Grid>
<!-- ============ filter ============ -->
<!-- Matches the command text as well as the name: the workflow is "the one that vacuums", not its label. -->
<TextBox Grid.Row="1" Classes="field" Margin="14,2,14,8" Text="{Binding Filter}"
PlaceholderText="search snippets" IsVisible="{Binding HasSnippets}" />
<TextBlock Grid.Row="2" Classes="detail" Margin="18,0,18,6" TextWrapping="Wrap"
Text="{Binding Status}" />
<!-- ============ the editor ============ -->
<!--
In place, above the list, rather than as a screen of its own — the same decision the keychain's
delete confirmation makes and for the same reason: a form stacked over the list hides what it is
about.
-->
<Border Grid.Row="3" Classes="card" Margin="12,0" IsVisible="{Binding IsEditing}"
VerticalAlignment="Top">
<ScrollViewer>
<StackPanel Spacing="10">
<TextBlock Classes="label" Text="NEW SNIPPET" IsVisible="{Binding IsCreating}" />
<TextBlock Classes="label" Text="EDIT SNIPPET" IsVisible="{Binding !IsCreating}" />
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
<TextBox Classes="field" Text="{Binding EditorCommand}" PlaceholderText="command"
Height="120" AcceptsReturn="True" TextWrapping="Wrap"
VerticalContentAlignment="Top" Padding="14,10" />
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
<!--
◆ The checkbox the whole screen is arranged around, with the paragraph that says why leaving it
off is the safe default. It is not a preference — it is the difference between a command a human
reads before committing to it and one that has already run.
-->
<CheckBox IsChecked="{Binding EditorRunsOnInsert}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5" Text="Press Enter after inserting this"
TextWrapping="Wrap" />
</CheckBox>
<TextBlock Classes="body"
Text="Left off, inserting types the command at the prompt and stops, so it can be read and edited before it runs. Nothing here can tell whether the terminal is even at a prompt, which is why that is the default." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
Command="{Binding SaveCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelCommand}" />
</Grid>
</StackPanel>
</ScrollViewer>
</Border>
<!-- ============ the list ============ -->
<Panel Grid.Row="3" IsVisible="{Binding !IsEditing}">
<!-- Written by the view model, and it says what a snippet is for rather than that there are none. -->
<TextBlock Classes="body" IsVisible="{Binding !HasVisible}" Margin="24,20" VerticalAlignment="Top"
Text="{Binding EmptyMessage}" />
<ListBox ItemsSource="{Binding Visible}" SelectedItem="{Binding Selected}"
Background="Transparent" BorderThickness="0" IsVisible="{Binding HasVisible}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0" />
<Setter Property="MinHeight" Value="0" />
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Border Classes="card" Margin="12,5">
<StackPanel Spacing="9">
<StackPanel Orientation="Horizontal" Spacing="7">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!-- ◆ The chip that says this one does not wait to be read. -->
<Border Classes="tag" IsVisible="{Binding RunsOnInsert}"
Background="{StaticResource WarnWash}">
<TextBlock Text="RUNS" Foreground="{StaticResource Warn}" />
</Border>
<Border Classes="tag outline"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" />
</Border>
</StackPanel>
<!-- The command, on the surface every block of monospace in this design is drawn on. -->
<Border Classes="output" CornerRadius="9" Padding="11,9">
<TextBlock Classes="mono" FontSize="11" Text="{Binding Preview}"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</Border>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Panel>
<!-- ============ what to do with the chosen one ============ -->
<!--
Raised over the list, as the hosts screen raises its connect bar, and for the same reason: there is no
second column to put it in. Both buttons name the terminal — "TYPE INTO pg-primary" — because on a
phone the tab strip may be scrolled away and "whatever is in the terminal receives this" is not a
sentence anybody should have to guess the subject of.
-->
<Border Grid.Row="4" IsVisible="{Binding HasSelection}" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0" Padding="14,12"
IsEnabled="{Binding !IsEditing}">
<StackPanel Spacing="9">
<Button Classes="primary" Content="{Binding InsertLabel}" Command="{Binding InsertCommand}"
IsEnabled="{Binding CanInsert}" />
<!-- Only for a snippet marked as running. For every other one there is nothing this could mean. -->
<Button Classes="secondary" Height="44" Content="{Binding RunLabel}"
Command="{Binding RunCommand}" IsVisible="{Binding SelectionRuns}"
IsEnabled="{Binding CanInsert}" />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditCommand}" />
<Button Grid.Column="2" Classes="danger" Height="44" Content="DELETE"
Command="{Binding DeleteCommand}" />
</Grid>
</StackPanel>
</Border>
</Grid>
</UserControl>
@@ -0,0 +1,10 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace DodoSSH.Client.Android.Views;
/// <summary>Design 05 — the saved commands, and the two ways of sending one to a shell.</summary>
internal sealed partial class SnippetsScreen : UserControl
{
public SnippetsScreen() => AvaloniaXamlLoader.Load(this);
}
@@ -21,43 +21,61 @@
<Grid RowDefinitions="Auto,Auto,*,Auto">
<!-- ============ tabs ============ -->
<Border Grid.Row="0" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<!--
v2 draws these as pills rather than as a segmented strip, so the row is transparent and each session
carries its own outline. The close cross moved inside the pill with the name, which is what makes it
read as one object you can dismiss rather than as two adjacent targets.
-->
<Border Grid.Row="0" Height="52">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Height="44">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<Grid ColumnDefinitions="Auto,Auto">
<Button Grid.Column="0" Classes="row" MinHeight="44" Padding="12,0"
<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">
<!--
44 tall, where the session pills on the shell strip are 34. The difference is the close
cross: a pill you only select can be chip-sized, and a pill containing the control that ends
a shell cannot. This head's rule is 44 and this is the one control on the phone that is both
destructive and has no confirmation and no undo — see CloseTabAsync, which ends the session
the moment it is pressed.
-->
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="11" Height="44">
<StackPanel Orientation="Horizontal">
<Button Classes="row" MinHeight="42" Padding="13,0" CornerRadius="11"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="5" Height="5"
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
Text="{Binding Label}" />
</StackPanel>
</Button>
<!--
The close cross is inside the tab, which the plan calls out: a strip-level close would
act on whichever tab happened to be selected, and on a phone that is a mis-tap away from
killing the wrong shell.
The hairline down its left edge is not decoration. The two targets are flush inside one
pill, so without a visible seam there is nothing telling a thumb where "switch to this
shell" stops and "end it" starts.
-->
<Button Grid.Column="1" Classes="row" MinHeight="44" Width="34" Padding="0"
<Button Classes="row" MinHeight="42" Width="44" Padding="0" CornerRadius="0,11,11,0"
HorizontalContentAlignment="Center"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1,0,0,0"
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}">
<TextBlock Text="×" Foreground="{StaticResource TextFaint}" FontSize="14" />
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
@@ -70,11 +88,16 @@
A phone cannot press Ctrl+plus. The desktop head has that chord and needs no buttons; this head has
no keyboard to press it with, which is why the two heads differ here and nowhere else in this screen.
-->
<Border Grid.Row="1" Background="{StaticResource Panel}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1" Padding="14,5"
<!--
The design's line here also carries a round-trip time and a forwarded port. Neither is drawn: the SSH
library offers no RTT measurement, and nothing in this application forwards anything. What is left is
the one fact that is real and is the one that matters — the account and endpoint actually dialled.
-->
<Border Grid.Row="1" Padding="16,5" Height="36"
IsVisible="{Binding SelectedTab, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="detail" FontSize="9.5" TextTrimming="CharacterEllipsis"
<TextBlock Grid.Column="0" Classes="detail" FontSize="10.5" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" Text="{Binding SelectedTab.Address}" />
<!--
@@ -137,6 +160,12 @@
IsTerminalShowing rather than HasTabs, which are no longer the same question: a tab that is still
connecting has no pane, and showing the renderer for it would show the previous session's output
under the name of a machine nothing has connected to yet.
v2 insets this behind a 14-pixel radius. Not done, and not an oversight: this is a native child view
composited above everything Avalonia draws, so a rounded Border behind it clips nothing — the
corners would stay square over a frame pretending otherwise. What the design was really after is
that the terminal reads as the lit surface, and that comes from its own background, which now is the
design's #171A26. See WebAssets/terminal.css.
-->
<NativeWebView x:Name="Renderer" IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
@@ -149,11 +178,10 @@
then release, because holding a modifier while typing is not possible one-thumbed.
-->
<!-- Only with a pane to type into: the keys send bytes at a session, and a connecting tab has none. -->
<Border Grid.Row="3" Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" IsVisible="{Binding IsTerminalShowing}">
<Border Grid.Row="3" IsVisible="{Binding IsTerminalShowing}" Height="50">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Height="44" Spacing="4"
Margin="6,5" />
<StackPanel x:Name="AccessoryKeys" Orientation="Horizontal" Spacing="5" Margin="12,0"
VerticalAlignment="Center" />
</ScrollViewer>
</Border>
@@ -129,13 +129,13 @@ internal sealed partial class TerminalScreen : UserControl
// 44 wide as well as tall. The design draws them flexed across the width, which at 360dp
// with ten keys is 32 pixels each — under every thumb-target guideline there is.
MinWidth = 44,
Height = 34,
Height = 38,
Padding = new Thickness(10, 0),
CornerRadius = new CornerRadius(5),
Background = new SolidColorBrush(Color.Parse("#161B19")),
BorderBrush = new SolidColorBrush(Color.Parse("#232927")),
CornerRadius = new CornerRadius(9),
Background = Palette("Panel"),
BorderBrush = Palette("BorderMid"),
BorderThickness = new Thickness(1),
Foreground = new SolidColorBrush(Color.Parse("#B7C0BB")),
Foreground = Palette("TextDim"),
HorizontalContentAlignment = HorizontalAlignment.Center,
};
@@ -164,13 +164,18 @@ internal sealed partial class TerminalScreen : UserControl
// Latched state has to be visible. A modifier that is on and does not look on is how somebody sends
// ^L to a database prompt believing they typed an l.
controlKey.Background = new SolidColorBrush(
Color.Parse(controlLatched ? "#243A2F" : "#161B19"));
controlKey.Foreground = new SolidColorBrush(
Color.Parse(controlLatched ? "#3CE88F" : "#B7C0BB"));
controlKey.Background = Palette(controlLatched ? "Active" : "Panel");
controlKey.Foreground = Palette(controlLatched ? "AccentText" : "TextDim");
}
/// <summary>One brush from <c>Theme/Palette.axaml</c>, by key.</summary>
/// <remarks>
/// The accessory row is built in code because its keys come from a table, so its colours cannot be set in
/// XAML with the rest of the screen's. Resolving them by name is the next best thing: a row that named
/// its own blues is how the palette ends up with a fifth surface nobody meant to add.
/// </remarks>
private static IBrush? Palette(string key) => Application.Current?.FindResource(key) as IBrush;
/// <remarks>
/// The control translation is the ASCII one and nothing cleverer: Ctrl-A through Ctrl-Z are 0x01 to
/// 0x1A, which is letter &amp; 0x1F. Applied only to letters, because Ctrl with an arrow key is a
+6 -2
View File
@@ -428,8 +428,12 @@
decoration that reads as information.
A style rather than a value converter so that the two colours are the palette's, once. A converter
would have had to name #3CE88F in C#, which is how a green ends up one shade off in the fourth place
would have had to name #3DDC97 in C#, which is how a green ends up one shade off in the fourth place
it appears.
Live rather than Accent, and that is a v2 change with meaning rather than a recolour. The accent is
blue now, and the green survives as the colour of a fact — see the remark on Live in Palette.axaml.
Filling this with Accent would say a connected host is a thing to press.
-->
<Style Selector="Ellipse.dot">
<Setter Property="Width" Value="6" />
@@ -437,7 +441,7 @@
<Setter Property="Fill" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Ellipse.dot.live">
<Setter Property="Fill" Value="{StaticResource Accent}" />
<Setter Property="Fill" Value="{StaticResource Live}" />
</Style>
<!--
@@ -27,7 +27,9 @@
from "inside": a press anywhere on the card below reports that control as its source and bubbles through
here on its way to the window.
-->
<Border x:Name="Backdrop" Background="#CC0A0C0B" PointerPressed="OnBackdropPressed">
<!-- 80% of Canvas. Written out because a scrim is a brush with an alpha, and the palette holds no alpha
variant of a surface — see Palette.axaml, where the only pre-multiplied brushes are the accent washes. -->
<Border x:Name="Backdrop" Background="#CC0E1220" PointerPressed="OnBackdropPressed">
<Border Width="520" VerticalAlignment="Top" Margin="0,90,0,0"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="6">
@@ -37,11 +37,21 @@
<Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
Two different hues, and after v2 that takes saying. These marks encode two independent facts in one
column, so they have to be told apart at a glance — and they used to be, for free, because the accent
was green and Info was blue. v2 made the accent blue too, which put #5B8CFF beside #7FB0FF: the same
hue two steps apart, which is a shade rather than a distinction.
So an executable is Live green now. It is the one use of that colour that is not about a session, and
it earns it on the same grounds — it is a fact about the file rather than something to press, and it
is the colour this marker already was before the accent moved out from under it.
-->
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Info}" />
</Style>
<Style Selector="TextBlock.entry.exec">
<Setter Property="Foreground" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<!--
+113 -37
View File
@@ -3,7 +3,7 @@
<!--
The palette, named for what a colour is for rather than for what it looks like. Every one of these is
from the design; the names are this codebase's, because "#0C0F0E" appearing in nine files is how a
from the design; the names are this codebase's, because "#0E1220" appearing in nine files is how a
surface ends up two shades off in the tenth.
It lives here, in the shared project, because there are now two heads drawing the same product. Two
@@ -13,62 +13,138 @@
Five near-black surfaces rather than one, and the difference between them is real work: the window is
the darkest so the terminal reads as the lit thing, chrome sits one step up so the header and status
bar frame it, and the lists sit between the two so a list does not look like part of either.
── v2 ──────────────────────────────────────────────────────────────────────────────────────────────
This is the second design's palette: blue-black surfaces and a blue accent, replacing the green-black
ones the first import shipped. Both heads move together, which is the whole reason the file is here —
the Android v2 design and the desktop v2 design specify the same seventeen tokens, so a phone that went
blue while the desktop stayed green would be the drift this file exists to prevent.
Where the design names a colour, the value below is that colour exactly. The design has seventeen
tokens and this palette has rather more, so the rest are interpolated along the same ramp and are
marked. That is deliberate rather than lazy: the finer distinctions here — three border weights, four
text steps — are ones the design draws without naming, and collapsing them to match the token count
would lose the rule each one encodes.
-->
<Color x:Key="CanvasColor">#0A0C0B</Color>
<!--
The surfaces, darkest first. Two things changed shape rather than merely colour.
A field is now *inset* — darker than the canvas it sits on — where it used to be raised. That follows
the design, which draws the search box as a well rather than as a tile, and it is the one place where
reading the old palette's ordering across to the new one gives the wrong answer.
And Chrome and Panel are now the same value, because the design uses one surface for both the header
and a card. They are kept as two names anyway: they answer different questions, and the day one of the
two moves is the day having merged them would have to be undone across every file that used the wrong
one.
-->
<Color x:Key="CanvasColor">#0E1220</Color>
<SolidColorBrush x:Key="Canvas" Color="{StaticResource CanvasColor}" />
<SolidColorBrush x:Key="Chrome" Color="#0D100F" />
<SolidColorBrush x:Key="Sidebar" Color="#0C0F0E" />
<SolidColorBrush x:Key="Panel" Color="#0F1211" />
<SolidColorBrush x:Key="Raised" Color="#111514" />
<SolidColorBrush x:Key="Field" Color="#121615" />
<SolidColorBrush x:Key="Chrome" Color="#111629" />
<SolidColorBrush x:Key="Sidebar" Color="#0F1426" />
<SolidColorBrush x:Key="Panel" Color="#111629" />
<SolidColorBrush x:Key="Raised" Color="#151B31" />
<SolidColorBrush x:Key="Field" Color="#0B0F1D" />
<!--
The surface a terminal, a log and a snippet's command are drawn on, and it is not one of the five
above. The design gives every block of monospaced output this one value, warmer than the chrome around
it, and that is what makes the lit thing read as lit. Named here rather than repeated in the renderer's
stylesheet and in two screens, which is how the three of them would drift.
-->
<SolidColorBrush x:Key="TerminalSurface" Color="#171A26" />
<!-- Row hover, and the heavier one the chrome's own buttons use. On the phone these are press states. -->
<SolidColorBrush x:Key="Hover" Color="#141817" />
<SolidColorBrush x:Key="ChromeHover" Color="#1A1F1D" />
<SolidColorBrush x:Key="Hover" Color="#151B31" />
<SolidColorBrush x:Key="ChromeHover" Color="#1D2749" />
<!--
Selection, and it is a surface rather than a highlight: the design fills a chosen host row, the current
segment of a toggle and the current chip with this and adds no border. Distinct from Hover, which is
what the finger is on rather than what is chosen.
-->
<SolidColorBrush x:Key="Active" Color="#1D2749" />
<!-- The fill behind a small monospaced tag — a group name, a storage class, a bucket. -->
<SolidColorBrush x:Key="Chip" Color="#16204A" />
<!--
Three border weights, and they are not interchangeable. Strong separates one region from another,
subtle separates rows inside one region, and mid is what a control draws around itself.
-->
<SolidColorBrush x:Key="Border" Color="#1E2422" />
<SolidColorBrush x:Key="BorderSubtle" Color="#171C1A" />
<SolidColorBrush x:Key="BorderMid" Color="#2A312E" />
<SolidColorBrush x:Key="BorderHover" Color="#3A423E" />
<SolidColorBrush x:Key="BorderFaint" Color="#232927" />
<SolidColorBrush x:Key="Border" Color="#1A2138" />
<SolidColorBrush x:Key="BorderSubtle" Color="#151B31" />
<SolidColorBrush x:Key="BorderMid" Color="#232B45" />
<SolidColorBrush x:Key="BorderHover" Color="#2E3859" />
<SolidColorBrush x:Key="BorderFaint" Color="#1F2740" />
<!--
The text ramp. Three steps, used consistently: what you read, what you glance at, and what is there
only so its absence would be noticed. A fourth step would be one nobody could tell from its neighbours.
-->
<SolidColorBrush x:Key="Text" Color="#DCE3DF" />
<SolidColorBrush x:Key="TextDim" Color="#7E8A84" />
<SolidColorBrush x:Key="TextFaint" Color="#566059" />
<SolidColorBrush x:Key="TextGhost" Color="#404743" />
<SolidColorBrush x:Key="Text" Color="#E3E7F4" />
<SolidColorBrush x:Key="TextDim" Color="#8B93B0" />
<SolidColorBrush x:Key="TextFaint" Color="#565E7E" />
<SolidColorBrush x:Key="TextGhost" Color="#414A6B" />
<!--
The accent, and the three colours that are allowed to disagree with it. Green means live, connected or
yours; amber means a caveat worth reading; red means refused or destructive; blue is for the one thing
that is neither — a directory, a distinct scope — and is deliberately rare.
The accent, and it is two colours rather than one. The design fills with #5B8CFF — the button, the
floating action button, a progress bar, a toggle that is on — and writes with #8FA7FF: the current
destination in the bottom bar, a link, the text inside a chip. They are not interchangeable in either
direction. The fill is too saturated to read as small text on a dark surface, and the text colour is
too pale to carry a button.
AccentInk is what goes *on* the fill. It equals Canvas today and is named separately anyway, because
"the darkest surface" and "the colour of text on a blue button" are two facts that happen to coincide,
and a light theme would separate them immediately.
-->
<Color x:Key="AccentColor">#3CE88F</Color>
<Color x:Key="AccentColor">#5B8CFF</Color>
<SolidColorBrush x:Key="Accent" Color="{StaticResource AccentColor}" />
<SolidColorBrush x:Key="AccentSoft" Color="#3CE88F" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#3CE88F" Opacity="0.06" />
<SolidColorBrush x:Key="Warn" Color="#E8B44C" />
<SolidColorBrush x:Key="WarnSoft" Color="#E8B44C" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#E8B44C" Opacity="0.06" />
<SolidColorBrush x:Key="WarnText" Color="#B9A26B" />
<SolidColorBrush x:Key="Danger" Color="#E85D5D" />
<SolidColorBrush x:Key="DangerSoft" Color="#E85D5D" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E85D5D" Opacity="0.08" />
<SolidColorBrush x:Key="DangerText" Color="#D98A8A" />
<SolidColorBrush x:Key="Info" Color="#5DA9E8" />
<SolidColorBrush x:Key="AccentText" Color="#8FA7FF" />
<SolidColorBrush x:Key="AccentInk" Color="#0E1220" />
<SolidColorBrush x:Key="AccentSoft" Color="#5B8CFF" Opacity="0.35" />
<SolidColorBrush x:Key="AccentWash" Color="#5B8CFF" Opacity="0.06" />
<!--
The design asks for IBM Plex Mono and IBM Plex Sans. Neither ships with this application, and neither
is on a stock Windows install or a stock Android one, so requesting them by name would render as
whatever the font fallback chose that day — which is worse than choosing deliberately. Inter is
embedded by both heads and is what they already draw with.
◆ Green is no longer the accent, and that is the one semantic change in this file rather than a
recolour.
It used to be both: Ellipse.dot.live filled with Accent, so "a shell is open on this host" and "this is
the thing the screen wants you to press" were the same colour by construction. The v2 design keeps a
green — #3DDC97, for the live dot, the sync light and the follow indicator — and makes the accent blue,
which finally separates the two. Live is that green, and nothing that is merely interactive may use it.
Read the pair as: blue is what you can do, green is what is true.
-->
<SolidColorBrush x:Key="Live" Color="#3DDC97" />
<SolidColorBrush x:Key="LiveWash" Color="#3DDC97" Opacity="0.12" />
<!--
The three colours that are allowed to disagree with the accent. Amber means a caveat worth reading, red
means refused or destructive, and blue-light is for the one thing that is neither — an informational log
line, a directory, a distinct scope.
WarnText and DangerText are the prose weights: a wash is a very dark tint, and the signal colour that
works as a 6-pixel dot or a one-word label is not the one that reads as a sentence on top of it. The
design supplies both reds — #E86A6A labels the refusal and #FF8F8F writes it out — and one amber, so
the amber prose weight is interpolated.
-->
<SolidColorBrush x:Key="Warn" Color="#FFB454" />
<SolidColorBrush x:Key="WarnSoft" Color="#FFB454" Opacity="0.35" />
<SolidColorBrush x:Key="WarnWash" Color="#FFB454" Opacity="0.12" />
<SolidColorBrush x:Key="WarnText" Color="#E4A868" />
<SolidColorBrush x:Key="Danger" Color="#E86A6A" />
<SolidColorBrush x:Key="DangerSoft" Color="#E86A6A" Opacity="0.3" />
<SolidColorBrush x:Key="DangerWash" Color="#E86A6A" Opacity="0.12" />
<SolidColorBrush x:Key="DangerText" Color="#FF8F8F" />
<SolidColorBrush x:Key="Info" Color="#7FB0FF" />
<!--
The design asks for Instrument Sans and Fira Code, as the one before it asked for IBM Plex. Neither
ships with this application, and neither is on a stock Windows install or a stock Android one, so
requesting them by name would render as whatever the font fallback chose that day — which is worse than
choosing deliberately. Inter is embedded by both heads and is what they already draw with.
The stack ends in the generic `monospace` rather than a Windows face, which is what makes it work on
both: Android has no Cascadia Mono or Consolas and resolves the generic name to its own mono face.
@@ -102,6 +102,34 @@ internal enum ShellScreen
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
/// <summary>
/// The phone's hub for everything the bottom bar has no room for.
/// </summary>
/// <remarks>
/// <para>
/// Drawn by the Android head alone. The desktop has a nav rail with room for every destination, so it has
/// nothing to put behind a hub and never sets this; the v2 phone design has four slots and nine places to
/// go, so five of them live one tap deeper. It is a member of the shared enum rather than a phone-local
/// bool because it is a value of <see cref="Screen"/> like any other — the back arrow on each of those
/// five screens comes here, and a second notion of "where am I" is how the two would disagree.
/// </para>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
/// </remarks>
More = 9,
/// <summary>
/// Objects in an S3-compatible bucket.
/// </summary>
/// <remarks>
/// The same screen as <see cref="Transfers"/> over the same <see cref="TransfersViewModel"/>, and a
/// separate destination anyway. What differs is only which picker is offered, so the two do not each
/// need a screen — but they do each need a name, because the v2 design puts SFTP and S3 side by side in
/// the hub, and a single "files" entry that silently remembered which kind you last opened would be a
/// destination whose meaning depended on history. Entering either sets
/// <see cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
/// </remarks>
Buckets = 10,
}
/// <summary>
@@ -701,6 +729,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsMoreScreen => Screen is ShellScreen.More;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsBucketsScreen => Screen is ShellScreen.Buckets;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
@@ -734,6 +768,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsMoreShowing => IsShowingPages && IsMoreScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
/// <summary>
/// Whether the phone's MORE tab should light.
/// </summary>
/// <remarks>
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
/// its destinations would be a bar that only ever lights three of its four entries. This is the one
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
/// tabs are each exactly one screen, and this one is six.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this.
/// </remarks>
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
@@ -2435,6 +2491,43 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
_ = teams.LoadAsync(CancellationToken.None);
}
}
/// <summary>
/// Goes to the file screen with one of the two kinds of remote offered.
/// </summary>
/// <remarks>
/// <para>
/// The phone splits SFTP and S3 into two destinations over this one view model; the desktop has a
/// single FILES screen with the toggle on it. So the kind is set here, by the thing that navigates,
/// rather than in <see cref="OnScreenChanged"/> — which would have made every arrival at
/// <see cref="ShellScreen.Transfers"/> force the picker back to hosts, including the desktop's own nav
/// rail arriving at a screen with a bucket already open.
/// </para>
/// <para>
/// <b>It will not change the kind while something is open.</b> There is one session behind both
/// destinations, so switching the picker under a live one would leave a screen titled S3 listing an
/// SFTP host's files. Refusing and saying so is the honest half of sharing a view model between two
/// destinations; the screen keeps showing what is actually open.
/// </para>
/// </remarks>
[RelayCommand]
private void ShowFiles(RemoteKind kind)
{
if (Transfers.IsConnected && Transfers.Remote != kind)
{
Transfers.Status = kind is RemoteKind.Bucket
? "An SFTP session is open. Close it before opening a bucket."
: "A bucket is open. Close it before connecting to a host.";
}
else
{
Transfers.Remote = kind;
}
Screen = kind is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
Surface = ShellSurface.Page;
}
/// <inheritdoc cref="OnScreenChanged" />
@@ -2456,6 +2549,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsMoreScreen));
OnPropertyChanged(nameof(IsBucketsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsHostsShowing));
@@ -2466,6 +2561,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsMoreShowing));
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
RaiseTerminalState();
}
@@ -46,7 +46,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
/// </remarks>
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
/// <remarks>
/// Blank when there is no timestamp, for the reason the size above is blank for a directory. A bucket
/// has no folders — a prefix is inferred from the keys under it — so the object store has nothing to
/// report for one and leaves the timestamp at its default. Formatting that prints
/// <c>0001-01-01 00:00</c>, which is not a quiet "unknown": it is a date, and a listing that states one
/// for every folder in a bucket is stating something untrue.
/// </remarks>
internal string Modified =>
entry.LastWriteTimeUtc == default ? string.Empty : Timestamps.Format(entry.LastWriteTimeUtc);
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
internal string Permissions => entry.Permissions;
@@ -4,10 +4,23 @@
layout systems disagreeing about the same pixels.
*/
/*
The three colours the page decides for itself, and they are the v2 design's rather than a second opinion
about it. The background, #171a26, is `TerminalSurface` in Palette.axaml the same surface a snippet's
command block and the log view are drawn on. The muted grey is `TextDim`.
The foreground is the one that is not a palette token: the design sets terminal text to #d8dcea, a step
below the #E3E7F4 it uses for interface text, and that difference is deliberate on its part a wall of
monospace at full contrast is harder to read than the labels around it, not easier.
The page cannot read an Avalonia resource in any case it is a WebView served over a loopback socket,
not part of the visual tree so these are repeated here and in the theme object in terminal.js, and
both sites say so.
*/
:root {
--dodo-background: #10131a;
--dodo-foreground: #d5d8de;
--dodo-muted: #7b8394;
--dodo-background: #171a26;
--dodo-foreground: #d8dcea;
--dodo-muted: #8b93b0;
}
* {
@@ -204,8 +204,9 @@ function createSession(sessionId) {
cursorBlink: true,
fontSize,
scrollback: SCROLLBACK_LINES,
// Matches terminal.css, so the canvas and the page agree on the background.
theme: { background: '#10131a', foreground: '#d5d8de' },
// Matches terminal.css, so the canvas and the page agree on the background. Both are the v2
// design's terminal surface — `TerminalSurface` in Palette.axaml. See the note in terminal.css.
theme: { background: '#171a26', foreground: '#d8dcea' },
});
const fit = new FitAddon.FitAddon();