7 Commits
Author SHA1 Message Date
jaap-jan 422d5ca10e Record v5b in the design-import log, and true up the manual checks
ci / build and test (push) Failing after 2m12s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m25s
2026-08-08 00:49:59 +02:00
jaap-jan d91729c0b8 Restyle the keychain, the snips and the logs to their v5b shapes 2026-08-08 00:49:59 +02:00
jaap-jan 43c939b697 Give the window its v5b chrome and each session surface its own shell 2026-08-08 00:49:59 +02:00
jaap-jan 1b76c51fbb Name v5b's deep chrome, its track and its magenta in the palette 2026-08-08 00:49:59 +02:00
jaap-jan f3c0b9ca1b Merge branch 'claude/friendly-elgamal-e085e5' into claude/v5-design-fidelity 2026-08-07 22:28:31 +02:00
jaap-jan 7ca74a1e35 Retint the scrims to the v5 canvas, quick connect at the spec's 60%
Five scrim hardcodes still dimmed through the old canvas #0E1220; they
now sit on #05050A. Each keeps its alpha except QuickConnect's backdrop,
which the v5 spec pins at rgba(5,5,10,0.6) — its essay drops from 80% to
60% of Canvas to match.
2026-08-07 21:29:57 +02:00
jaap-jan d7f0bea258 Repaint the launcher mark and the window icon in v5's own ink 2026-08-07 21:24:19 +02:00
45 changed files with 5733 additions and 2617 deletions
+66 -2
View File
@@ -69,7 +69,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> labelled sidebar and the chrome went from 72 tall to 86, so `880x560` became `1016x574` — leaving every
> screen the same `826x464` it was designed against. Four of the tables stop fitting at 690 wide, so
> widening the sidebar without widening the window would have broken them where the layout suite was not
> looking.
> looking. **v5b grows it again**, to `1081x583` — see that section, below.
>
> **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the
> `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other
@@ -102,6 +102,14 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
> a page test rather than a fourth `ShellSurface`.
>
> **v5b reverses the paragraph above, and says so rather than leaving it to be found out of date.** The tab
> strip this paragraph describes is retired — see the v5b section, below — and with it the rule that made the
> rail conditional at all: the switcher it drew moved onto the nav rail's own head as three segments, the
> rail stopped collapsing for any of them, and neither SFTP, S3 nor a terminal gets the window's full width
> any longer. `IsVaultsTab` outlived the strip essentially unchanged — it is still a page test rather than a
> read of a `ShellSurface`, now over which of the rail's own destinations is showing rather than which of
> three tabs was.
>
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
@@ -243,6 +251,62 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The QUICK ACCESS hint's claim that pins live in a sidebar | "Pinned folders appear above the terminal for this host." — no sidebar exists on this screen for the sentence to point at, so the shipped hint says where they actually draw. |
> | The mock's "Saving to **DodoTech ▾** vault" subtitle, with a picker's chevron inside a sentence | The pre-existing `DrawerSubtitle` wording — the vault's name alone, unchanged by this pass. A chevron inside running text implies the text itself is the control, which it is not: the vault picker is its own element, shown only while creating and only above one writable vault, as it always has been. |
> | The design's two deeper text steps, `#6D6F84` and `#5D5F74` | Not added as `Palette.axaml` keys. `TextGhost`, already repicked to `#7C7F98` for v3, is reused everywhere the design reaches for either — a resource nothing yet distinguishes from `TextGhost` is not free to carry, on the same reasoning the border ramp's own remarks give. |
>
> ## The desktop's v5b — chrome, session shell and SFTP restyled
>
> A sixth pass, and the first aimed at the chrome and the two screens somebody spends the most time inside
> rather than at any one screen on its own. **What shipped:** the titlebar and nav rail redrawn against
> `TitleBar.dc.html` and `NavRail.dc.html` — a segmented SSH/SFTP/S3 switcher at the rail's own head, a
> mode-dependent first row beneath it, and Vaults and Preferences moved off the rail's list and into a
> popover under the user chip at its foot. The terminal and the SFTP surface both gained a session shell — an
> in-screen tab row, a host header, a bottom status bar and a 300-pixel QUICK ACCESS/SNIPS sidebar —
> replacing the window-wide tab strip v3 built and the pin-chip strip that used to sit above the terminal
> alone. The SFTP screen's own two panes and its TRANSFERS strip were restyled against `SFTP.dc.html` — row
> grids, quiet Material glyphs, slim progress bars — and three screens that had never had a `.dc.html` of
> their own got one each: Keychain, Snippets (titled Snips on screen now, matching the rail) and Logs.
>
> **The nav rail stopped being conditional furniture, and that is the one structural change underneath the
> paint.** v3 drew it only under the Vaults tab, so SFTP, S3 and an open terminal each got the window's full
> width; v5b's own fidelity pass puts the rail up beside all three, because the design draws it that way and
> because the segmented switcher now living at its head is only worth having if it stays reachable from
> everywhere. `MainWindowViewModel.IsVaultsTab` still exists and still answers the question it always did —
> which of the rail's own six destinations is on screen, as opposed to SFTP or S3 — but nothing about it gates
> the rail's own visibility any more. The window's minimum grew again to hold the difference: `1016x574`
> became `1081x583`, nine pixels for the titlebar's 44-to-53 and sixty-five for the rail's 190-to-255, added
> straight onto the minimum rather than absorbed by shrinking a screen — see `MainWindow.axaml`'s own remark
> and `LayoutHarness.MinimumWidth`/`MinimumHeight`. `ScreenWidth` held at 826 because the rail is the only
> thing beside a page that grew; `ScreenHeight` grew by the 42 pixels the retired tab strip used to cost every
> screen, because nothing replaced that strip as chrome every screen pays for — the terminal and SFTP
> surfaces pay for their own tab row out of their own session-shell budget instead. See
> `LayoutHarness.ScreenHeight` and `SessionScreenHeight`.
>
> | v5b element | What ships instead |
> | --- | --- |
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. |
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | Omitted, and recorded rather than silently dropped. None of the three is reachable from this shell today: `SshNetConnection` holds the negotiated algorithms but nothing reads them off `ISshConnection`; `HostKeyPresentation` exists only for the trust questions a connection asks on the way in, not for a session already open; and a key's display name is never threaded past the connect call into anything the session keeps. What is real and bound is CONNECTED plus a dot, the session's own elapsed timer, and — terminal surface only — `UTF-8`, worded as a fact about this client's own renderer rather than a claim the remote agreed to. Candidates for future plumbing, not a defect closed here. |
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
> | The S3/Buckets screen | **Did not get the session shell this pass.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sits inside the new tab row/header/status bar/sidebar; the S3 usage is unchanged. |
> | No pins destination in the design at all | **Kept anyway.** The rail still carries Pins — `KnownHostsScreen` — because the mock has no screen for approved host keys and this application's has to stay reachable. |
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | Both land on the one screen this application actually has, `Preferences` — the design's own Settings area is future work, so this is two doors to one room rather than a door removed. |
> | `· Org` after the user chip's name, and a `Primary` tag on a vault row in the popover | Neither. There is no organisation concept behind a vault — only the vault itself — and no vault is distinguished as primary; the popover's vault rows are the existing shown-vaults toggles, restyled. |
> | The design's titlebar, which has nowhere for a sync indicator | `SYNCED` stays, on the titlebar's right side, ahead of the window's own minimise/maximise/close buttons — the one thing this titlebar keeps that the design's own does not draw at all. |
> | Per-tab SFTP sessions, implied by a tab row shared between the terminal and the SFTP surface | **Not built, and not what shipped instead.** A click on the SFTP tab row runs `MainWindowViewModel.SelectFilesHostAsync`, which opens (or reuses) a second, SFTP-specific connection through the same "Browse files" plumbing a pin click already used — an honest second login, not a channel multiplexed onto the terminal's. `SelectedTab` moves with the click, which is also what keeps the sidebar's QUICK ACCESS in step — that list is keyed to `SelectedTab` on both surfaces, so selecting a different terminal tab afterwards can leave QUICK ACCESS naming a host that is not the one the remote pane is actually browsing. |
> | A scroll-to-section affordance behind the sidebar's **+ Pin folder** row | Not built — this application has no way to open the host editor already scrolled to one card inside it. `PinFolderFromSidebarCommand` opens the same three-card editor the hosts screen's own EDIT reaches, at the top, which is the closest honest affordance rather than a new one. |
> | The TRANSFERS strip's aggregate throughput readout (`8.4 MB/s` beside an arrow) | Omitted. `TransferRowViewModel.Progress` computes a rate per transfer; nothing sums those into one number for the whole queue, and inventing one would be exactly the fabricated fact this document's honesty rule forbids. |
> | Freshness/"hot row" colouring and a selection ring on a finished transfer | Not drawn — there is no tracked notion of how recently a row finished, so nothing distinguishes a transfer that just completed from one that finished an hour ago. |
> | The design's Material-Symbols-only "draft" glyph for a file row | `insert_drive_file`, the closest classic Material Icons has — a plain document rather than a page with a folded corner. Recorded as a substitution rather than silently swapped; "draft" is simply not a glyph this font contains. |
> | `REMOTE` across the SFTP panes' arrow column | `HOST`/`BUCKET`, kept from before this pass rather than adopted from the design. The pair is the difference between a directory tree and a flat namespace with inferred folders, which is a distinction worth keeping even where the design collapses it. |
> | `THIS MACHINE` on the local pane | `LOCAL`, the design's own word, adopted — nothing in this file's own comments ever defended "THIS MACHINE" the way HOST/BUCKET is defended above, so there was no reason to keep it. |
> | The Keychain screen's `MODIFIED` column and `FINGERPRINT` box | Both dropped. `VaultItem` carries no timestamp of any kind, and `SshKeySecret` has no fingerprint field — computing one would mean parsing armour the type stores verbatim, an already-recorded gap this pass did not revisit. |
> | Five separate add buttons (GENERATE / + SSH KEY / + PASSWORD / + TAG / + BUCKET) | One `+ New key` accent button, opening a menu of the same five actions in the same order — the design draws one button because its own mock has one "add" concept; this application has five, and none of them was dropped to fit. |
> | `NEW ITEMS GO TO` on the vault-filing picker | `NEW ITEMS FILE TO`, the design's own wording, adopted — this screen's own comments already called the act "filing", so the label was this application's vocabulary already. |
> | The Snippets screen's own on-screen name | **Snips**, matching the rail, which already said so. |
> | A modified-date on a snippet card | Omitted — `SnippetSecret` carries no timestamp, the same absence every other item kind has. |
> | The design's shorter captions — "Type into terminal", the rationale-only sentence for "runs on insert" | The screen's own longer, more actionable wording kept instead: the insert button still names the destination tab (`TYPE INTO {tab}` / `NO TERMINAL OPEN`), and the "runs on insert" caption still states the operational consequence rather than only the reason the setting exists. Both of the design's sentences are true; the ones already here say more. |
> | A delete confirmation for a snippet | **Built, matching the mock.** `SnippetsViewModel.RequestDelete`/`ConfirmDelete`/`CancelDelete` say the same vault-wide-reach, tombstone, no-undo sentence `VaultViewModel.HowFarADeletionGoes` already says for every other item kind — additive beside the existing uncounted `DeleteCommand`, which the phone's own DELETE row still calls. |
> | The Logs screen's own footer sentence about encryption | Adopted verbatim — verified against this screen's own header remark and ADR 0001 before shipping it, both true, so it is drawn as literal text rather than reworded. |
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
@@ -404,7 +468,7 @@ caption buttons and window title drawn on top of the application's own — two s
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the session shell's own host header shows the account and endpoint actually dialled (v3v4: the tab strip did; v5b moved the fact to `SessionHeader`, off `MainWindowViewModel.SessionAddress`, when the strip was retired). |
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
+33 -30
View File
@@ -24,15 +24,17 @@ a phase had nothing left for a person to do, which is the good outcome rather th
---
## Phase 1 — the shell and the tab strip
## Phase 1 — the shell and the nav rail
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults,
Preferences — and both of the fixed tabs, SFTP and S3.
Open two terminals, then visit every nav rail entry in turn — Hosts, Keys, Pins, Snips, Logs — and both of
the switcher's other two segments, SFTP and S3, at the rail's own head.
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab.
**Pass:** each screen draws whole, its buttons all clickable, and the nav rail stays up the left edge for
every one of them. Since v5b's chrome pass the rail is permanent furniture — it no longer collapses for
SFTP, S3 or an open terminal the way it did while it belonged to a Vaults tab; see
`MainWindowViewModel.IsVaultsTab`'s own remark for what changed and what stayed the same under that name.
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
@@ -41,8 +43,8 @@ bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossibl
### 1.2 A tab clicked from another screen takes the keyboard
Go to FILES with a terminal open. Click the tab. **Start typing immediately, without clicking anything
else.**
Go to SFTP with a terminal open, then click the SSH segment at the rail's head to return to it. **Start
typing immediately, without clicking anything else.**
**Pass:** every character reaches the shell, including the first.
@@ -53,7 +55,7 @@ be typed immediately rather than after a pause.
### 1.3 Leaving a terminal gives the keyboard back
With a terminal focused, click FILES. Type into the filter box.
With a terminal focused, click SFTP at the rail's head. Type into the filter box.
**Pass:** the characters appear in the box.
@@ -64,10 +66,10 @@ strip rework and is now on the hot path. See `docs/platform-flags.md`.
### 1.4 The middle click closes tabs and only tabs
Middle-click a tab (closes it), the strip background to the right of the last tab (closes nothing), and the
`+` button (closes nothing, opens nothing).
On the terminal's own in-screen tab row: middle-click a tab (closes it), the row's background to the right
of the last tab (closes nothing), and the `+` button (closes nothing, opens nothing).
**Pass:** as described. Covered by `TerminalTabsTests` headlessly, so this is a confirmation that headless
**Pass:** as described. Covered by `SessionTabRowTests` headlessly, so this is a confirmation that headless
pointer input matches a real mouse rather than a first look.
### 1.5 Connecting from the palette while on another screen
@@ -82,28 +84,28 @@ Approving connects; CANCEL leaves you on FILES.
question that cannot be reached — or the window has jumped to HOSTS, which is what it used to do and what
cost the palette its whole point.
### 1.6 The vault menu draws above the terminal's rectangle · **the one with a precedent**
### 1.6 The user-chip popover draws above the terminal's rectangle · **the one with a precedent**
With a terminal open and showing, press the `⌄` beside the Vaults tab.
With a terminal open and showing, press the user chip at the foot of the nav rail.
**Pass:** the window leaves the terminal for the Vaults tab as the menu opens, and the menu is drawn whole
over the screen underneath it — no part of it clipped along the WebView's edge.
**Pass:** the popover opens without leaving the terminal, and is drawn whole over the screen underneath it —
no part of it clipped along the WebView's edge.
**Failure means:** the popup is being composited under the renderer's native child window, and the guard
this design relies on has stopped working. It is not supposed to be possible: `OnVaultMenuPressed` selects
the Vaults tab *before* opening the flyout, and a page surface is one where the renderer is not drawn — the
same move QuickConnect makes. `OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt` asserts
the ordering headlessly, which is as far as a headless test can go: it has no native window, so it cannot
see what is painted over what. This check is the other half.
**Failure means:** the popup is being composited under the renderer's native child window. Unlike the v3v5
tab strip's own vault menu, which sat directly above the terminal's own rectangle and had to select the
Vaults tab before opening for exactly that reason, the rail's popover should not need that guard at all: it
opens inside the rail's own 255-pixel column — see `NavRail.axaml`'s own remark — which the terminal's
native child window never occupies, so there should be no rectangle here for a popup to be composited
under. If this fails, that assumption is the thing to re-examine, not the ordering of two calls. No headless
test can see this either way — it has no native window, so it cannot see what is painted over what.
It is on this list rather than assumed because the note beside the `+` button in `TerminalTabs.axaml`
refuses a flyout on exactly this reasoning, and `docs/platform-flags.md` records what this project has
already paid for treating a rendering claim as settled without looking.
It is on this list because `docs/platform-flags.md` records what this project has already paid for treating
a rendering claim as settled without looking.
### 1.7 Switching a vault off does not switch it out
With a team vault holding at least one host: press `⌄` beside Vaults, switch the team vault off, and check
the hosts screen, the keychain and the pins.
With a team vault holding at least one host: press the user chip at the foot of the nav rail, switch the
team vault off in the popover, and check the hosts screen, the keychain and the pins.
**Pass:** that vault's hosts, keys and pins are gone from all three; the vault is still in the "file this
into" picker on a host editor; the sync indicator still settles rather than stalling; and a host in another
@@ -120,7 +122,8 @@ view models — this check is the version with a real connection on the end of i
### 2.1 The fingerprint column is readable end to end
Connect to two or three hosts, approving each fingerprint. Go to Pins and narrow the window to its minimum
(1016px since the v2 sidebar; it was 880 while the rail was 54 wide), then widen it to something ordinary.
(1081px since v5b's titlebar and rail fidelity pass; it was 1016 with the v2 sidebar, and 880 while the rail
was 54 wide), then widen it to something ordinary.
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
@@ -1727,9 +1730,9 @@ button that always refuses is the milder failure and is still worth removing.
Rename a shared vault from the VAULTS screen.
**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu —
and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key
still opens it.
screen's "new items file to" picker, in the host editor's vault picker, and in the nav rail's user-chip
popover — and on the second account after a refresh. Nothing in the vault needs re-encrypting and
everybody's key still opens it.
**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
fail in, because several screens read it separately from a cached vault row. A vault that stops opening
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
The launcher mark, and it is the same mark PhoneShell's header and the desktop titlebar draw:
>_ in the canvas colour on a solid accent tile. Filled rather than outlined since v2.
>_ in AccentInk on a solid accent tile. Filled rather than outlined since v2.
The tile is not in this file. It is the background layer — @color/dodo_accent, see ic_launcher.xml
— and that is the whole trick of the filled design on Android: the rounding a launcher applies is
@@ -10,10 +10,10 @@
rounded shape inside the first, visibly clipped at the corners on any device whose mask is not the
one it was drawn for.
#0E1220 is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. It equals Canvas today and is named
separately in the palette for a reason worth keeping in mind here: this is ink on the accent, not
the window behind it, and it follows AccentInk if the two ever part.
#FFFFFF is AccentInk from DodoSSH.Client.Shell's Theme/Palette.axaml, written out because an
Android resource cannot reference a XAML dictionary. Through v2 it equalled Canvas and this file
followed it when the two parted: v5's ink on the accent is white, the way every accent surface in
that palette carries white, while the window behind it went its own way to #05050A.
108x108 with the artwork inside the middle 72 is the adaptive-icon contract — the outer 18 on each
edge is what the launcher eats for masking and parallax. The glyph spans 36.06..71.94, which is
@@ -35,7 +35,7 @@
<path
android:pathData="M36.06,43.33 L49.91,53.57 L36.06,63.8"
android:fillColor="#00000000"
android:strokeColor="#0E1220"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
@@ -44,7 +44,7 @@
<path
android:pathData="M54,64.68 L71.94,64.68"
android:fillColor="#00000000"
android:strokeColor="#0E1220"
android:strokeColor="#FFFFFF"
android:strokeWidth="5.4"
android:strokeLineCap="round" />
@@ -11,7 +11,7 @@
Worth shipping rather than leaving out: a launcher with themed icons on and no monochrome
layer to use falls back to the full-colour icon, so the one app on the home screen still
drawn in blue is this one.
drawn in its own accent is this one.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
@@ -9,12 +9,12 @@
between an Android resource and a XAML resource dictionary, so the duplication is stated rather than
hidden.
-->
<color name="dodo_window">#0E1220</color>
<color name="dodo_window">#05050A</color>
<!--
AccentColor from the same palette, here because the launcher icon's background layer is a colour
and not a drawable. Same hand-kept duplication as above, and the same rule: if the palette moves,
this moves with it.
-->
<color name="dodo_accent">#5B8CFF</color>
<color name="dodo_accent">#5D42DE</color>
</resources>
+3 -3
View File
@@ -161,7 +161,7 @@
interface glitching rather than as a tap being received.
-->
<Style Selector="Button.scrim">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Padding" Value="0" />
@@ -169,10 +169,10 @@
<Setter Property="VerticalAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
</Style>
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
<Setter Property="Background" Value="#9E0E1220" />
<Setter Property="Background" Value="#9E05050A" />
</Style>
<!--
@@ -36,7 +36,7 @@
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 Background="#9E05050A" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
+315 -113
View File
@@ -305,6 +305,22 @@
<Setter Property="Background" Value="{StaticResource ChromeHover}" />
</Style>
<!--
The titlebar's search pill. A plain <c>Border</c> rather than a styled <c>Button</c> template, because
the pill sits inside a <c>Button.flat</c> that already owns the click — see <c>TitleBar.axaml</c> — and
what changes under the pointer is the pill's own border, not a fill behind it. Declared on the parent
button's <c>:pointerover</c> rather than the border's own, because a <c>Border</c> has no pointer state
of its own to key a selector on.
-->
<Style Selector="Border.searchpill">
<Setter Property="Background" Value="{StaticResource SearchPill}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.search:pointerover Border.searchpill">
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<!--
Button.grouphead was here: the padding on the fold-away group heading that used to sit between the host
cards. The headings went when the grid became cards and the group cards above it became the thing that
@@ -331,178 +347,301 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
The nav rail. The active destination is marked with an accent bar down its left edge and a wash
behind it, which is the design's whole idiom for "you are here" — the same two marks a selected list
row carries, so the window has one vocabulary for selection rather than one per control.
-->
<!--
A destination in the sidebar. v2 turns these from 54-pixel stacked labels into 32-pixel rows with a
glyph, a word and a count, and the active one becomes a filled rounded row rather than a label with an
accent bar down its left edge. The bar is gone because a filled row at this width already reads as
chosen, and the bar was carrying that on its own when there was no room for a fill.
The nav rail. v5b redraws it against TitleBar.dc.html's sibling NavRail.dc.html rather than against
the v2 mock this replaces — see the comment block at the top of NavRail.axaml for what moved and why.
The active destination is now a filled accent row rather than a wash with a bar down its edge; the
idiom is otherwise the same one the switcher below and a selected list row already use.
TextGhost rather than TextFaint for the resting label: the mock computes rgb(124,127,152) for an
inactive row, which is TextGhost's own #7C7F98 exactly, not TextFaint's paler #9C9EB4.
-->
<Style Selector="Button.nav">
<Setter Property="Height" Value="32" />
<Setter Property="Padding" Value="10,0" />
<Setter Property="Height" Value="35" />
<Setter Property="Padding" Value="11,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="LetterSpacing" Value="0.1" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Button.nav /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.nav:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Hover}" />
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Button.nav.active">
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="Button.nav.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<!--
The three parts of a sidebar row. Separate classes rather than inline setters because nine rows draw
them and the day one of the three moves is the day eight of them would not have.
The glyph and the label inherit the row's own foreground, so they light with it; the count does not,
because a number that lit with its row would compete with the word beside it for the same emphasis.
The two parts of a sidebar row: a 19-pixel Material glyph and its word, ten pixels apart. v2's third
column — a count read off the vault — left with the row it decorated; see the remark on
<c>Button.nav</c> above for where that number went and why it is not replaced here.
-->
<Style Selector="TextBlock.navicon">
<Setter Property="FontSize" Value="14" />
<Setter Property="Width" Value="20" />
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="19" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.navlabel">
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
</Style>
<Style Selector="TextBlock.navcount">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
A terminal tab, and since v2 a pill rather than a filing-cabinet tab: 30 tall inside a 42 strip, its
own rounded outline, and the active one filled instead of marked along an edge. The edge mark is gone
because a pill has no edge to share with its neighbour — the gap between two of them is the divider
the old top-and-right border was standing in for.
The segmented SSH / SFTP / S3 switcher at the rail's head. A track the width of the rail's own content
column, 2 pixels of padding holding three equal segments — so the three are Buttons with
HorizontalAlignment="Stretch" inside a Grid of three equal columns, the same "buttons carry no state"
reasoning <c>NavRail.axaml</c>'s own remarks give for every row beneath them.
-->
<Style Selector="Button.tab">
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
<Setter Property="Padding" Value="12,0,8,0" />
<Setter Property="Height" Value="30" />
<Setter Property="Margin" Value="0,0,6,0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Style Selector="Border.navtrack">
<Setter Property="Height" Value="31" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="Padding" Value="2" />
</Style>
<!--
◆ ORDER IS THE BEHAVIOUR HERE. The base pill comes first, then its hover, then the exceptions — and
that ordering is the fix for a bug this arrangement had when v2 first drew it.
A pill paints its own Background, which the flat tab it replaced did not. That one detail moves where
the hover has to live: `Button.flat:pointerover` is declared far above and used to supply it, and the
moment `.tab`'s template rule set a Background of its own, the later declaration won and every tab in
the strip lost its pointer feedback silently. The `+` lost more than that — it sits below as an
exception, so a `.tab` rule declared after it was overriding the very thing that made it an exception,
and it drew as a filled outlined pill contradicting the comment above it.
Avalonia has no specificity; the later declaration wins. The same trap is recorded further down this
file for Border.rowmark. Anything added below must be an exception to what is above it, never a
restatement of the base.
-->
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
<Setter Property="Background" Value="{StaticResource Panel}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
<Style Selector="Button.navseg">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Height" Value="27" />
<Setter Property="CornerRadius" Value="7" />
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="LetterSpacing" Value="0.1" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="Button.tab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Hover}" />
<Style Selector="Button.navseg /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="Button.navseg.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Accent}" />
</Style>
<!--
One of the strip's three fixed tabs — Vaults, SFTP, S3. A pill in every respect except that it has no
close box, so it takes its padding back on the right: the base rule is short there to leave room for
the cross a terminal tab carries inside itself, and a fixed tab with the same asymmetry sits visibly
off-centre beside one that has a reason for it.
Nothing else differs, deliberately. These are tabs and have to read as tabs — the whole point of the
strip is that "where the window is" is one row of one kind of control.
The rail's foot: the user chip that opens the popover. Flat until the pointer arrives, the same idiom
every other row in this rail follows, at its own height and radius because it is a chip rather than a
destination — see <c>NavRail.axaml</c> for what it opens and why a <c>Flyout</c> is safe here.
-->
<Style Selector="Button.tab.fixed">
<Setter Property="Padding" Value="12,0" />
<Style Selector="Button.navuser">
<Setter Property="Height" Value="34" />
<Setter Property="Padding" Value="13,7" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.navuser /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Button.navuser:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
draws them as one list and a row that looked different from its neighbours would read as a separator
that is not one.
-->
<Style Selector="Button.poprow">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="11,4" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="MinHeight" Value="20" />
</Style>
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The check square beside a shown vault in the popover — magenta rather than the accent, because the
accent already means "press this" everywhere else in the window and a vault switch is a fact, not an
action waiting to be taken. See Palette.axaml's remark on Magenta.
-->
<Style Selector="Border.vaultcheck">
<Setter Property="Width" Value="12" />
<Setter Property="Height" Value="12" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Magenta}" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<!--
── v5b: SESSION TAB ROW ─────────────────────────────────────────────────────────────────────────────
The tab strip's replacement, per <c>Terminal.dc.html</c>/<c>SFTP.dc.html</c>: rounded only at the top,
fused visually to the bordered container it sits above rather than a free-floating pill — see
<c>SessionTabRow.axaml</c>. It went from a 42-pixel strip spanning the window to a 38-pixel row inside
each of the two screens that carry one, which is why the layout budget in <c>LayoutHarness</c> no
longer subtracts a tab strip's height from every screen: only these two now pay it, out of their own
26-pixel padded column rather than out of the window's own chrome.
◆ ORDER IS THE BEHAVIOUR HERE, as it was for the pill this replaces. The base tab comes first, then its
hover, then <c>.active</c> — each later rule is an exception to what came before it, because Avalonia
has no specificity and settles two rules matching one element by declaration order alone.
-->
<Style Selector="Button.sesstab">
<Setter Property="Padding" Value="16,10,12,10" />
<Setter Property="Height" Value="38" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalAlignment" Value="Bottom" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9,9,0,0" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="13.5" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.sesstab /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9,9,0,0" />
</Style>
<Style Selector="Button.sesstab:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The tab whose pane the container below is showing: DeepChrome body — the same value the container's
own host header and status bar are painted in, which is what fuses the two into one shape rather than
a tab floating above a separate box — and a 2px top border in one of two colours. Which colour is set
by the row's own <c>Classes="sftp"</c>, from the usage site in <c>MainWindow.axaml</c>: unmarked is the
terminal row's <c>TerminalTabAccent</c>, and <c>.sftp</c> is the SFTP row's <c>Magenta</c> — see the
remark on both keys in <c>Palette.axaml</c>. Marked on <c>TerminalTabViewModel.IsSelected</c> rather
than <c>IsShowing</c>, unlike the strip this replaces: the SFTP row's own active tab is not "the pane
the terminal surface is showing" at all, so the narrower flag would leave it permanently dark. See
<c>MainWindowViewModel.SelectFilesHostCommand</c>.
-->
<Style Selector="Button.sesstab.active">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontWeight" Value="Bold" />
</Style>
<Style Selector="Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DeepChrome}" />
<Setter Property="BorderThickness" Value="0,2,0,0" />
<Setter Property="BorderBrush" Value="{StaticResource TerminalTabAccent}" />
</Style>
<Style Selector=".sftp Button.sesstab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{StaticResource Magenta}" />
</Style>
<!--
The button that opens a connection, reusing the strip's own shape rather than a new one: no active
state, because it is never the thing showing, and no outline, because it is not one of the things
being chosen between. Both of those are cleared rather than merely omitted — the base rule above sets
a fill and a border, so an exception has to say so.
being chosen between.
-->
<Style Selector="Button.tab.plus">
<Style Selector="Button.sesstab.plus">
<Setter Property="Padding" Value="0" />
<Setter Property="Width" Value="28" />
<Setter Property="Height" Value="28" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Style Selector="Button.sesstab.plus /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Style Selector="Button.sesstab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="Background" Value="{StaticResource Hover}" />
</Style>
<Style Selector="Button.tab.active /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Active}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The two halves of the Vaults tab: the tab itself, and the caret that opens its menu. Two buttons
because they do two things, drawn as one pill because they are one destination — so the pair meets in
the middle with no gap, no doubled border down the join, and the outer corners rounded as any tab's
are.
Both the Button and its ContentPresenter carry a CornerRadius above, so both have to be squared here:
setting only one leaves a rounded outline inside a square hit area, which shows as a hairline of the
strip's background cutting through the join.
After Button.tab.fixed rather than beside it, because .caret takes that rule's padding back to zero
and Avalonia has no specificity — the later declaration is the one that wins. See the ordering note
above.
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
-->
<Style Selector="Button.tab.split">
<Setter Property="Margin" Value="0" />
<Setter Property="CornerRadius" Value="8,0,0,8" />
<Style Selector="Button.headerghost">
<Setter Property="Height" Value="32" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="16,0" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="Button.tab.split /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8,0,0,8" />
<Setter Property="BorderThickness" Value="1,1,0,1" />
<Style Selector="Button.headerghost /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderHover}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.tab.caret">
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0,8,8,0" />
<Style Selector="Button.headerghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="Button.tab.caret /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="0,8,8,0" />
<!--
── v5b: THE SESSION SIDEBAR'S ROWS ──────────────────────────────────────────────────────────────────
QUICK ACCESS's pins and, on the terminal surface, SNIPS — both h33, radius 9, a glyph and a mono
label, flat until the pointer finds them. See <c>SessionSidebar.axaml</c>.
-->
<Style Selector="Button.sidebarrow">
<Setter Property="Height" Value="33" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebarrow /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebarrow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
-->
<Style Selector="Button.sidebaradd">
<Setter Property="Height" Value="30" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="CornerRadius" Value="9" />
<Setter Property="FontSize" Value="11.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.sidebaradd /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="9" />
</Style>
<Style Selector="Button.sidebaradd:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
@@ -661,6 +800,34 @@
<Setter Property="Background" Value="Transparent" />
</Style>
<!--
── v5b: THE SFTP PANES' OWN FILE ROWS ───────────────────────────────────────────────────────────────
A third list shape, on the same reasoning A LIST OF TILES gives above it: the design draws a file row
with 7-pixel rounded corners and a single fill — Track, not Hover or AccentWash — for both hover and
selected, and neither the accent wash nor a square-cornered highlight peeking out from behind a rounded
row is the mock's own choice. See TransfersScreen.axaml's local and remote listings.
No selected+unhovered accent strip either, unlike Border.rowmark's own rows: the design has nothing to
distinguish "selected" from "hovered" beyond which one is true at the moment, and :selected alone
already answers that once the pointer has moved on — a strip drawn on top would be a second, redundant
mark for the one state this list bothers to keep after the pointer leaves.
Declared after ListBox's own base rules, for the reason recorded three times over already in this file:
Avalonia has no specificity, and an exception declared above the rule it excepts does nothing at all.
-->
<Style Selector="ListBox.filerows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="7" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.filerows > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
The card itself. A fixed width and a free height, which is the pair that makes a WrapPanel of these
into a grid: equal columns, and a card that grew a third line of tags is taller than its neighbours
@@ -914,6 +1081,41 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
── v5b: THE SFTP PANE HEADERS' QUIET GLYPHS ─────────────────────────────────────────────────────────
Two variants of the icon-only button above, for the two places wave C's SFTP restyle draws one that is
not the drawer's own chrome: a pane's UP/REFRESH/DELETE affordances — see TransfersScreen.axaml, whose
remote pane keeps DELETE's own destructive colour rather than borrowing the plain one — and a pane's
drive picker, which is a chip rather than a square icon and so borrows Border.chip's own geometry
instead, for the reason Button.chiptoggle borrows it too.
-->
<Style Selector="Button.paneicon.danger">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<Style Selector="Button.paneicon.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource DangerWash}" />
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
<Style Selector="Button.panechip">
<Setter Property="Padding" Value="8,4" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="Medium" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Button.panechip /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
</Style>
<Style Selector="Button.panechip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
The status dot, in one place rather than as a converter in code.
@@ -22,8 +22,8 @@ Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5B8CFF') # dodo_accent / AccentColor
$ink = [System.Drawing.ColorTranslator]::FromHtml('#0E1220') # AccentInk
$accent = [System.Drawing.ColorTranslator]::FromHtml('#5D42DE') # dodo_accent / AccentColor
$ink = [System.Drawing.ColorTranslator]::FromHtml('#FFFFFF') # AccentInk
# Every size Windows asks for: 16 in a titlebar and a tree, 32 on the desktop, 48 in a large-icon
# view, 256 for the preview pane. Shipping fewer means Windows downsamples one of these to fill
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -45,7 +45,7 @@
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
variant of a surface; the pre-multiplied ones there are accent washes.
-->
<Border Background="#CC0E1220">
<Border Background="#CC05050A">
<Panel>
<!-- ============ UNKNOWN HOST KEY ============ -->
File diff suppressed because it is too large Load Diff
+219 -118
View File
@@ -18,136 +18,237 @@
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
connection closes, so a live session is deliberately not in the vault yet.
── v5b — Logs.dc.html ──────────────────────────────────────────────────────────────────────────────────
A fidelity pass, not a new shape. What moved:
◆ THE HEADER. "Logs" 33 bold plus the two-segment CONNECTIONS/KEYCHAIN control, restyled onto
Border.navtrack/Button.navseg — the same h31 track the rail's own SSH/SFTP/S3 switcher already uses,
reused rather than redrawn, since it is the identical shape at the identical size. Two segments and not
three: LOGS is the screen's own title, not a tab, so the design's own two — CONNECTIONS and KEYCHAIN —
are the whole of what this screen switches between, exactly matching LogSection's own two values. No
deviation to record here. On the right: a mono status sentence — LogsViewModel.HeaderStatusLine, which
shows a refresh error when there is one and otherwise the fact this screen's own header comment already
states about whichever log is showing — and REFRESH, unchanged.
◆ THE BODY. A bordered radius-12 container on Pane bg wraps each table now, in place of the plain
background either one drew before. Tracked column headers are unchanged — HOST/ADDRESS/LASTED/KIND/
STARTED/FROM for connections, ITEM/TYPE/WHAT/FIELDS/WHEN for keychain activity, both already matching the
design exactly. Rows keep their real two-state host dot (green only for a session genuinely open right
now, per HasVisible on ConnectionLogRowViewModel.IsLive — never a third colour) and their existing
"failed"/"host key refused" amber badges. WHAT, on the activity table, is now a small coloured chip rather
than plain text — green for created, purple for a plain change, red for deleted — reading the same
ActivityOperation this screen already resolved to a word; a chip rather than a fabricated new fact.
◆ THE FOOTER. Logs.dc.html's own lock-glyph sentence — "Both logs are ordinary synced keychain items —
end-to-end encrypted. The server learns only that rows exist and when they were written." — verified
against this file's own header remark above and ADR 0001 before shipping it: both are true, so the
sentence is drawn as literal text rather than reworded.
-->
<Grid RowDefinitions="Auto,*">
<UserControl.Styles>
<Style Selector="Border.logtable">
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="Background" Value="{StaticResource Pane}" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<Style Selector="ListBox.logrows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8" />
</Style>
<Style Selector="ListBox.logrows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<!--
WHAT, on the activity table: created/changed/deleted, coloured the way the item badges are. "changed"
is the base rule's own colour — it is the default ActivityLogRowViewModel.Operation falls through to —
and .created/.deleted are declared after it so they win; Avalonia has no specificity and settles two
matching rules by declaration order, the same trap this application's other screens record.
-->
<Style Selector="Border.opchip">
<Setter Property="Background" Value="{StaticResource AccentWash}" />
</Style>
<Style Selector="Border.opchip > TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentText}" />
</Style>
<Style Selector="Border.opchip.created">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.opchip.created > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.opchip.deleted">
<Setter Property="Background" Value="{StaticResource DangerWash}" />
</Style>
<Style Selector="Border.opchip.deleted > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
Margin="0,0,14,0" />
<Grid RowDefinitions="Auto,*" Margin="26">
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
Classes.active="{Binding ShowsConnections}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}" />
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
Classes.active="{Binding ShowsActivity}"
Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}" />
<!-- ============ THE HEADER ============ -->
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="0" Text="Logs" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Border Grid.Column="1" Classes="navtrack" Margin="14,0,0,0" Width="200" VerticalAlignment="Center">
<Grid ColumnDefinitions="*,*">
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding ShowsConnections}"
Content="CONNECTIONS" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Connections}" />
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding ShowsActivity}"
Content="KEYCHAIN" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:LogSection.Activity}" />
</Grid>
</Border>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding HeaderStatusLine}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,14,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Button Grid.Column="4" Classes="ghost" Height="40"
Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE5D5;"
Foreground="{StaticResource TextFaint}" />
<TextBlock Text="Refresh" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" />
</StackPanel>
</Button>
</Grid>
<!-- ============ Connections ============ -->
<Border Grid.Row="1" Classes="logtable" IsVisible="{Binding ShowsConnections}">
<Grid RowDefinitions="Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="20,14,20,10"
IsVisible="{Binding HasConnections}">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConnectionList" Classes="logrows" Focusable="True"
ItemsSource="{Binding Connections}" Margin="12,0,12,10">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Height="40" Margin="8,0,10,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="9" Margin="12,0,8,0"
VerticalAlignment="Center">
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12.5" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="11"
Foreground="{StaticResource TextGhost}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Duration}" FontSize="11"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10.5"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<!--
Only when there is something to say. A connection that opened and closed says nothing
here; one that was refused says so, and that is the row worth finding in a long list.
-->
<Border Classes="chip warn" Padding="7,0" Height="18" IsVisible="{Binding HasOutcome}">
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
</Border>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasConnections}" />
<!-- ◆ THE FOOTER. Verified against this file's own header remark and ADR 0001; see the file-level note. -->
<Border Grid.Row="2" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
Padding="20,12">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE897;"
Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="Both logs are ordinary synced keychain items — end-to-end encrypted. The server learns only that rows exist and when they were written." />
</StackPanel>
</Border>
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<!-- ============ Connections ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
IsVisible="{Binding HasConnections}">
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
ItemsSource="{Binding Connections}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="10.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="10.5"
Foreground="{StaticResource TextDim}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
<!--
Only when there is something to say. A connection that opened and closed says nothing
here; one that was refused says so, and that is the row worth finding in a long list.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
<TextBlock Text="{Binding Outcome}" FontSize="9.5" />
</Border>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasConnections}" />
</Grid>
<!-- ============ Keychain changes ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
<Border Grid.Row="1" Classes="logtable" IsVisible="{Binding ShowsActivity}">
<Grid RowDefinitions="Auto,*,Auto">
<Grid Grid.Row="0" ColumnDefinitions="1.4*,0.8*,0.8*,1.6*,0.8*" Margin="20,14,20,10"
IsVisible="{Binding HasActivity}">
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ActivityList" Classes="logrows" Focusable="True"
ItemsSource="{Binding Activity}" Margin="12,0,12,10">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<Grid ColumnDefinitions="1.4*,0.8*,0.8*,1.6*,0.8*" Height="40" Margin="8,0,10,0">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="4,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<Border Grid.Column="2" Classes="opchip" Classes.created="{Binding IsCreated}"
Classes.deleted="{Binding IsDeleted}" Height="19" CornerRadius="5" Padding="8,0"
HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Operation}" FontSize="10" FontWeight="SemiBold" />
</Border>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding HasChangedFields}" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasActivity}" />
<Border Grid.Row="2" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
Padding="20,12">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE897;"
Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
Text="Both logs are ordinary synced keychain items — end-to-end encrypted. The server learns only that rows exist and when they were written." />
</StackPanel>
</Border>
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
IsVisible="{Binding HasActivity}">
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The names of the fields that changed, and never what they changed to. A log that recorded
an old password would be a plaintext credential store with a vault drawn around it.
-->
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
IsVisible="{Binding HasChangedFields}" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="420"
IsVisible="{Binding !HasActivity}" />
</Grid>
</Border>
</Grid>
+232 -195
View File
@@ -9,15 +9,14 @@
Icon="/Assets/dodossh.ico"
Width="1180"
Height="760"
MinWidth="1016"
MinHeight="574"
MinWidth="1081"
MinHeight="583"
Background="{StaticResource Canvas}"
SystemDecorations="BorderOnly"
Focusable="True">
<!--
The shell window: a titlebar it draws itself, a sidebar, a tab strip, one surface at a time, and a
status bar.
The shell window: a titlebar it draws itself, a nav rail, one surface at a time, and a status bar.
THE MINIMUM GREW, and it grew by exactly what v2 added rather than by a round number somebody liked.
The sidebar went from 54 pixels to 190 and the chrome from 72 tall to 86, so 880x560 became 1016x574 —
@@ -26,6 +25,17 @@
fitting at 690 wide. Widening the sidebar without widening the window would have quietly broken them
somewhere nobody was looking.
v5b moves it again, by exactly the same reasoning: the titlebar's fidelity pass takes it from 44 to 53
and the rail's from 190 to 255, so 1016x574 becomes 1081x583 — nine pixels and sixty-five pixels, added
straight onto the minimum rather than absorbed by shrinking a screen. <c>LayoutHarness.ScreenWidth</c>
stays unchanged at 826, because the rail is the only thing beside a page that grew.
<c>ScreenHeight</c> did move, and in the other direction: v5b's own fidelity pass also retires the
window-wide tab strip this comment used to describe — see the paragraph below — and every full-bleed
page gets that 42 pixels back rather than the window losing height to compensate. A screen this suite
measures is 826 pixels wide and taller than it was, by exactly the strip's own height; see
<c>LayoutHarness</c>'s own remark on the budget for the arithmetic.
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
near-black application is the one part of the window that would look borrowed.
@@ -37,12 +47,19 @@
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
chrome worth having.
TWO SURFACES, ONE RECTANGLE.
TWO SURFACES, ONE RECTANGLE — AND EACH ONE OWNS ITS OWN TAB ROW NOW.
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
visible and reachable from every other one. What that costs is that the terminal and the pages now share
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
enum rather than two flags, so there is no way to write the state where both are showing.
v5b retires the tab strip this file used to draw above the whole window — Vaults, SFTP and S3 left it
for the rail's own switcher in an earlier pass, and this one moves the remaining pills, one per open
terminal, off the window's own chrome entirely. Each of the two screens that carries a tab row —
<c>SessionTabRow.axaml</c> — draws its own, 38 pixels, inside its own 26-pixel padded column, per the
design; see the terminal surface's own Grid below and the SFTP one inside the pages Panel. A tab still
survives navigating away from either screen — that is what makes the terminal reachable from anywhere —
it simply is not drawn as chrome above every screen while it does.
The terminal and the pages still share the one rectangle beside the rail, and exactly one of them may
occupy it at a time: that is the whole of ShellSurface, an enum rather than two flags, so there is no way
to write the state where both are showing.
THE OCCLUSION RULE, which every arrangement in this file obeys.
@@ -67,7 +84,9 @@
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
tree, so every unlock would pay a cold start. Hoisting the binding to an ancestor looks tidier and is
unverified: NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push
fires only for its own IsVisible.
fires only for its own IsVisible. v5b nests the WebView three levels deeper than it used to sit, inside
the terminal surface's own session shell — see that Grid's own remark below for why the rule still holds
with the control that much further from the Panel that used to be its only parent.
-->
<Grid RowDefinitions="Auto,*,Auto,Auto">
@@ -77,209 +96,227 @@
<Panel Grid.Row="1">
<!-- The unlocked application. -->
<Grid RowDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<!--
◆ THE STRIP IS ABOVE THE RAIL, and it used to be beside it.
It was the other way round for a reason that stopped being true: while every tab was a terminal,
the strip navigated only the area to the right of a full-height rail, and putting it over the rail
would have been a row of tabs above a column of destinations they had nothing to do with.
The three fixed tabs are what changed that. The rail is now one tab's contents rather than the
window's own furniture — Vaults owns it, SFTP and S3 do not have it, and a terminal does not
either — so a rail drawn beside the strip would outrank the thing that decides whether it is
showing at all. Above and full width is the arrangement that matches what selects what.
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
under a strip: v5b retired the window-wide tab strip entirely, and with it the row this Grid used
to give up its own first row to. See the remark below on where a session's tabs live now.
-->
<views:TerminalTabs Grid.Row="0" />
<views:NavRail Grid.Column="0" />
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Panel Grid.Column="1">
<!--
The Vaults tab's own navigation, and it collapses with that tab. Its width is 190 either way, so
SFTP, S3 and a terminal each get the full window rather than the 826 a page gets.
-->
<views:NavRail Grid.Column="0" IsVisible="{Binding IsVaultsTab}" />
<Grid Grid.Column="1" RowDefinitions="Auto,*">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
◆ THE PIN STRIP, ABOVE THE TERMINAL AND NOTHING ELSE.
A Grid row rather than a sibling in the Panel below it — the terminal, the pages and the
connecting card are all layered on top of one another there, which is right for three things
that occupy the same rectangle and wrong for a row that is supposed to sit above it. Auto height
and IsVisible="False" collapse to nothing when there is nothing to show, so a page screen or an
empty terminal loses no height to a row it never draws — see ShowsPinStrip, which is false on
every page and every tab whose host has no pins.
ActiveTabPinnedPaths is MainWindowViewModel's, not the vault's: which tab is selected and
whether its session is live are the shell's business, and RefreshConnectedHosts is where the
two lists — Tabs and Vault.Hosts — are already walked together to paint the status dots. This
rides along on the same walk rather than opening a subscription of its own.
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<Border Grid.Row="0" Padding="14,8" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
IsVisible="{Binding ShowsPinStrip, FallbackValue=False}">
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Orientation="Horizontal" Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] is enough to reach back to the shell: this
ItemsControl's own DataContext is MainWindowViewModel, the window's root, so one hop up
from the chip's string DataContext lands on it directly — no #Board-style named-element
indirection needed, because there is no second level of template nesting here.
-->
<Button Classes="ghost" Padding="8,4" ToolTip.Tip="{Binding}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="12" />
<TextBlock FontFamily="{StaticResource MonoFont}" Text="{Binding}" FontSize="11.5"
MaxWidth="220" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Panel Grid.Row="1">
<!-- ============ THE PAGES ============ -->
<Panel IsVisible="{Binding IsShowingPages}">
<!--
Wrapped, like every other screen here. It used to be bound directly — its data context was
the shell's, so IsHostsScreen resolved on the same element — and that was only so it could
hand the vault to the host sidebar from inside its own markup. The sidebar is gone and the
drawer that replaced it is a plain child, so the screen takes the vault like the rest and
its visibility goes on the wrapper.
-->
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ SFTP ============ -->
<!--
Inside this Panel although it is a tab rather than a rail screen, and that is not an
oversight. IsShowingPages means "the Avalonia page area, not the WebView", which is the
occlusion question and is true of all three fixed tabs; which of them is showing is the
separate question each child below answers. Keeping the two apart is what lets the terminal
stay collapsed under one rule rather than under four.
What differs from a rail screen is only the rail: NavRail collapses on IsVaultsTab above, so
this screen is laid out at the full window width.
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
visibility is the shell's business and its data context is the transfers view model, and
putting both on one element resolves IsVisible against that view model, where
IsTransfersScreen does not exist.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ S3 ============ -->
<!--
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the tab in the strip that calls it.
-->
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist.
-->
<Panel IsVisible="{Binding IsKeychainScreen}">
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ VAULTS ============ -->
<!--
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
business and the data context is the vaults view model, and both on one element would resolve
IsVaultsScreen against a type that does not have it.
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
is one vault's contents and is what the keychain and hosts screens above draw.
-->
<Panel IsVisible="{Binding IsVaultsScreen}">
<views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
<Panel IsVisible="{Binding IsHostsScreen}">
<views:HostsScreen x:Name="HostsPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ SFTP ============ -->
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not exist
yet, or never will. Exclusive with the WebView below by construction — a selected tab either
has a session or it does not — which is what makes drawing it here safe under the occlusion
rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
◆ v5b's session shell, wrapping this screen's existing content rather than replacing it — the
two-pane grid and the queue inside TransfersScreen.axaml are untouched; wave C restyles their
internals. What is new here is everything design-notes/v5b-fidelity-notes.md calls the session
shell: a 26px padded column, an in-screen tab row, a bordered rounded-bottom container holding
a host header and a status bar around the screen's own content, and a 300px sidebar.
Gated on IsTransfersScreen exactly as before — IsShowingPages means "the Avalonia page area,
not the WebView", which is the occlusion question, and which mode the switcher is on is this
wrapper's own separate question.
The tab row's own click does not select a terminal tab — there is no per-tab SFTP session in
this application, and building one is out of this wave's scope; see the notes' own open
question and MainWindowViewModel.SelectFilesHostCommand for how this resolves it: a click
reuses the same "Browse files" plumbing a pin click already does, honestly opening (or
reusing) a second, SFTP-specific connection to that tab's host rather than pretending a session
exists that does not.
-->
<Panel IsVisible="{Binding IsTransfersScreen}">
<Grid RowDefinitions="Auto,*" Margin="26">
<views:SessionTabRow Grid.Row="0" Classes="sftp"
TabCommand="{Binding SelectFilesHostCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open terminal"
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
EmptyText="{Binding Transfers.Status}" />
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
<views:SessionStatusBar Grid.Row="2" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
drawn: the SFTP surface reaches this Panel before a host is chosen, and a sidebar with
an empty QUICK ACCESS and no host name to print would be furniture with nothing to say.
Collapsing frees the "Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Panel>
<!-- ============ S3 ============ -->
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
process tree, so twenty tabs would cost twenty of them.
The same screen as SFTP above, over the same view model, because an object store and an
SFTP host are both an IRemoteFileStore and everything below the picker was written once.
What differs is which picker is offered, and that is decided by the destination rather than
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
calls it now; see NavRail.axaml.
A sibling of the page area rather than a child of any screen, which is the structural half of
the tab rework: the terminal belongs to the window now, not to the hosts screen.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
then falls back to its default of true, and the occlusion comes back silently. Not reachable
at runtime — the DataContext is set before the window is shown — but it is what the previewer
does.
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
to be the other end of a cross-surface button, and pins nothing the sidebar's QUICK ACCESS
could show. Wave B's own scope is the terminal and SFTP surfaces; S3 stays the plain screen it
already was, and this is the recorded reason rather than an oversight.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Panel>
<!-- ============ KEYCHAIN ============ -->
<!--
Wrapped rather than bound directly, for the reason the vault column always was: this
element's visibility is the shell's business and its data context is the vault, and put both
on one element and IsVisible resolves against the vault as well, where IsKeychainScreen does
not exist.
-->
<Panel IsVisible="{Binding IsKeychainScreen}">
<views:KeychainScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
</Panel>
<!-- ============ HOST KEYS ============ -->
<!--
Wrapped, like the two above and for the same reason: its data context is the screen's own
view model, where IsKnownHostsScreen does not exist.
-->
<Panel IsVisible="{Binding IsKnownHostsScreen}">
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
</Panel>
<!-- ============ SNIPPETS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsSnippetsScreen}">
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
</Panel>
<!-- ============ LOGS ============ -->
<!-- Wrapped, like the others whose data context is their own view model. -->
<Panel IsVisible="{Binding IsLogsScreen}">
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
</Panel>
<!-- ============ VAULTS ============ -->
<!--
Wrapped, for the reason the keychain and transfers screens are: the visibility is the shell's
business and the data context is the vaults view model, and both on one element would resolve
IsVaultsScreen against a type that does not have it.
Bound to Vaults, which is the vaults themselves and the people in them — not to Vault, which
is one vault's contents and is what the keychain and hosts screens above draw.
-->
<Panel IsVisible="{Binding IsVaultsScreen}">
<views:VaultsScreen DataContext="{Binding Vaults}" />
</Panel>
<!-- ============ PREFERENCES ============ -->
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
<!-- ============ IMPORT ============ -->
<!--
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
the others whose data context is their own view model.
-->
<Panel IsVisible="{Binding IsImportScreen}">
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
</Panel>
</Panel>
<!-- ============ TERMINAL ============ -->
<!--
◆ v5b's session shell for the terminal surface, built the same way SFTP's own wrapper above is —
around the real terminal control rather than around a page.
Gated on IsTerminalSurface rather than on IsShowingPages: the two are always exclusive, because
Surface is a single ShellSurface value, so this Grid and the pages Panel above it are never both
visible at once. That is what keeps the occlusion rule intact with the WebView now nested inside
a padded column, a bordered container and a header row rather than sitting directly beside the
page area the way it used to.
THE RULE ITSELF DID NOT MOVE. NativeWebView's own IsVisible binding, below, is unchanged and is
not hoisted to this Grid — hoisting it is the one alternative the pin-strip era of this file
called out as unverified, and nesting the control deeper without touching its own binding is not
that: IsTerminalShowing already depends on IsTerminalSurface, so this Grid's own visibility and
the WebView's own visibility flip together on every surface change, driven by the same property
change rather than one waiting on the other.
The tab row's own click selects a terminal tab exactly as the old window-wide strip's did; see
SelectTabCommand. "+" keeps its current meaning, quick connect, on both this row and SFTP's own.
-->
<Grid RowDefinitions="Auto,*" Margin="26" IsVisible="{Binding IsTerminalSurface}">
<views:SessionTabRow Grid.Row="0" TabCommand="{Binding SelectTabCommand}" />
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
CornerRadius="0,0,12,12" ClipToBounds="True">
<Grid ColumnDefinitions="*,Auto">
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<views:SessionHeader Grid.Row="0"
OpenLabel="Open SFTP"
OpenCommand="{Binding SelectFilesHostCommand}"
OpenCommandParameter="{Binding SelectedTab}"
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
<Panel Grid.Row="1" Background="{StaticResource Pane}">
<!--
The other thing that can be in the terminal's rectangle: a tab whose session does not
exist yet, or never will. Exclusive with the WebView below by construction — a
selected tab either has a session or it does not — which is what makes drawing it here
safe under the occlusion rule, the same way the page area is. See ConnectingCard.axaml.
-->
<views:ConnectingCard x:Name="ConnectingPane"
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
<!--
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
browser process tree, so twenty tabs would cost twenty of them.
FallbackValue, because a compiled binding with no DataContext yields UnsetValue,
IsVisible then falls back to its default of true, and the occlusion comes back
silently. Not reachable at runtime — the DataContext is set before the window is shown
— but it is what the previewer does.
-->
<NativeWebView x:Name="Terminal"
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
</Panel>
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
</Grid>
<!--
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
the terminal surface is reachable with no tab open at all (the rail's own SSH segment), and
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
"Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
</Grid>
</Grid>
</Panel>
</Grid>
<!--
+267 -131
View File
@@ -7,159 +7,295 @@
<!--
The window's destinations, down the left edge.
Still called NavRail although v2 makes it 190 pixels wide and gives every entry a word: the type is
named in MainWindow and in the layout suite, and "the list of places this window goes" is what it was
called when it was 54 pixels and is what it still is. The width is the thing that changed, not the job.
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
Redrawn against NavRail.dc.html, which changes more here than a fresh coat of paint. 190 pixels became
255. The counts v2 added beside every row are gone — the mock's own row is an icon and a word, nothing
else, and this pass follows it rather than keeping a feature the mock never had; nowhere else on screen
states the number instead, so it is simply not drawn any more. And two things this rail used to answer
to the tab strip now answer to the rail itself:
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Three things the extra 136 pixels buy, and they are why the design widened it rather than a matter of
taste. The five-character abbreviations are gone — PINS and SNIPS were a width constraint and are now
Pins and Snippets. Each entry carries a glyph, so the list can be scanned by shape as well as read. And
each carries a count, which is the one genuinely new fact: how many hosts, how many keys, how many pins
is a question you would otherwise have to open the screen to answer.
── THE SWITCHER, AT THE RAIL'S OWN HEAD. ─────────────────────────────────────────────────────────────
SSH, SFTP and S3 were the strip's three fixed tabs — Vaults, SFTP, S3 — until this pass moved the choice
here as a segmented control, which is where the mock always drew it. Vaults did not come with it: the
rail's own item list below is what that tab used to gate, so a fourth segment naming it would have been
a second way to reach exactly what six rows underneath already reach. SSH, SFTP and S3 are a true
three-way here rather than two live segments and one dimmed — the mock leaves S3 unstyled as future
work, and this application already has bucket browsing, so it is wired like its two neighbours. See
<c>MainWindowViewModel.IsSshShowing</c>, <c>IsTransfersShowing</c> and <c>IsBucketsShowing</c>.
A count is drawn only where one is real. Logs and Preferences have none — a log has no total until it is
read, and preferences are not counted — so those two show nothing rather than a zero. The design draws a
number on every row; a zero beside Logs would be a fact this application never computed.
── THE RAIL IS NO LONGER DRAWN ONLY UNDER ONE TAB. ───────────────────────────────────────────────────
It used to collapse whenever the strip was on SFTP, S3 or a terminal — see the version of this remark
the v3 file carried, and <c>MainWindowViewModel.IsVaultsTab</c>, which still exists and still answers
the question it always did. What changed is <c>MainWindow.axaml</c>: the rail is part of the window's
own furniture now, the same as the titlebar and the status bar, so it stays up beside SFTP and S3 and
beside an open terminal — which is exactly what makes the switcher above worth having here at all.
── SFTP AND S3 ARE NOT HERE, and that is the tab strip's doing. ──────────────────────────────────────
Both were rail entries until the strip grew fixed tabs for them. They are the two destinations that are
not about the keychain — they are a place you leave the keychain to work in, and you stay there while a
transfer runs — which is exactly what a tab is for and what a rail entry is not. The rail is drawn only
under the Vaults tab now, so an entry here for either of them would be a route out of the tab it lives
in. See MainWindowViewModel.IsVaultsTab.
── THE FIRST ROW IS MODE-DEPENDENT, exactly as the mock's own <c>mode</c> prop is. ───────────────────
One row rather than three shown and hidden by turn: its icon, its label and what it runs all come from
<c>MainWindowViewModel.FirstRailItemIcon</c>/<c>FirstRailItemLabel</c>/<c>ShowFirstRailItemCommand</c>,
which read the same three flags the switcher above lights — so the row and the segment can never name
two different modes between them.
What this costs is that the S3 count has nowhere to go: the strip's tabs are one word each, and the rail
was where "how many buckets" was printed. It is on the S3 screen itself, which is where somebody
counting buckets is going anyway.
── Vaults and Preferences left the rail's own list for the user chip's popover, at the foot. ─────────
Both are still one click away; see the popover below the user chip. The chip itself carries the signed-
in identity this application actually has — a display name and, where the server sent one, an email —
which is also new: the titlebar drew an account name and a vault chip before this pass and does not any
more. See TitleBar.axaml and design-notes/v5b-fidelity-notes.md for the deviations this rail keeps on
purpose: Pins, which the mock has no screen for at all, and the S3 segment above.
The last entry was TEAMS and is now VAULTS, which is a change of subject rather than of destination: the
screen behind it lists vaults and the people in each, where it used to list teams that owned vaults. See
VaultsViewModel. It shares its word with the tab strip's first tab; the two are different levels of the
window, and the button's own comment says which is which.
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
of those hold the selection themselves, so a click moves the highlight before the shell can decide
anything. Buttons carry no state and cannot disagree with the screen that is showing.
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
back — and an entry lit while a terminal filled the window would be pointing at a screen that is not
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
mark, in the strip, and two "you are here" marks is one too many.
The design pins a "Team vault" card to the foot of this list, beside a Team entry in the list itself.
Two routes to one screen, one of them carrying a seat count nothing here can produce, so what sits at
the foot is Preferences — which is where it already was, and which is the one entry that is about the
machine rather than about the keychain.
Buttons rather than a TabStrip or a ListBox, still, for the reason the v3 remark gave: all three hold
the selection themselves, so a click would move the highlight before the shell decided anything, and a
Button carries no state to disagree with the screen that is actually showing.
-->
<Border Width="190" Background="{StaticResource Sidebar}"
<Border Width="255" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False">
<!--
Border rather than Chip for the right-hand rule, although the mock's own value — rgb(26,26,40) — is
Chip's exact #1A1A28. Chip is the fill behind a tag, and reusing it here as a line would answer a
later "why does a rail border share a key with a bucket chip" with "it doesn't, they just happen to
match" — where Border, at #1E1E2C, is one shade off and reads identically at one pixel wide.
-->
<DockPanel LastChildFill="False" Margin="14">
<StackPanel DockPanel.Dock="Top" Margin="8,10,8,0" Spacing="2">
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="▤" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Hosts" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Hosts.Count}" />
</Grid>
</Button>
<StackPanel DockPanel.Dock="Top" Spacing="18">
<!--
TotalItemCount is keys plus passwords and deliberately excludes buckets, which used to disagree
with the list under it. It no longer does: buckets have their own entry above, so this number and
this screen now count the same things.
The segmented switcher. Three equal columns in a Grid rather than a StackPanel with Width="*" on
each child — Avalonia gives a StackPanel's children their desired size, not an even split, and the
mock's three segments are exactly a third each.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Keychain" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.TotalItemCount}" />
<Border Classes="navtrack">
<Grid ColumnDefinitions="*,*,*">
<Button Grid.Column="0" Classes="navseg" Classes.active="{Binding IsSshShowing}"
Command="{Binding ShowTerminalCommand}"
ToolTip.Tip="The terminal, and every shell you have open">
<TextBlock Text="SSH" />
</Button>
<Button Grid.Column="1" Classes="navseg" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<TextBlock Text="SFTP" />
</Button>
<Button Grid.Column="2" Classes="navseg" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<TextBlock Text="S3" />
</Button>
</Grid>
</Button>
</Border>
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◈" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Pins" />
<!--
The vault's pins, not the screen's VisiblePins — that one is the filtered list, so a sidebar
bound to it would count what the Pins screen's own filter box happens to match and would
change as somebody typed in it. Every other count here is a total; this one has to be too.
-->
<TextBlock Grid.Column="2" Classes="navcount"
Text="{Binding Vault.KnownHostPins.Count}" />
</Grid>
</Button>
<StackPanel Spacing="8">
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Snippets" />
<TextBlock Grid.Column="2" Classes="navcount" Text="{Binding Vault.Snippets.Count}" />
</Grid>
</Button>
<!--
The mode-dependent first row: Terminal, Files or Buckets, matching whichever segment above is
lit. Active follows !IsVaultsTab rather than a property of its own — that is already exactly
"the terminal surface, or the files screen, or the buckets screen", which is what this row is.
-->
<Button Classes="flat nav" Classes.active="{Binding !IsVaultsTab}"
Command="{Binding ShowFirstRailItemCommand}"
ToolTip.Tip="The terminal while SSH is selected, or whichever file picker SFTP or S3 chose">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="{Binding FirstRailItemIcon}" />
<TextBlock Classes="navlabel" Text="{Binding FirstRailItemLabel}" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Logs" />
</Grid>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
ToolTip.Tip="Your hosts, and what is known about the one you have selected">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE875;" />
<TextBlock Classes="navlabel" Text="Hosts" />
</StackPanel>
</Button>
<!--
◆ THIS ENTRY SAID Teams UNTIL THE SCREEN BEHIND IT STOPPED BEING ABOUT THEM. A team is still what
the server authorises against; it is no longer something anybody has to make, name or think about,
so the rail names the thing people came for. See VaultsViewModel.
<!--
Keys, not Keychain — the mock's own word for this screen, which still holds SSH keys and stored
passwords; see ShellScreen.Keychain for the name that did not move with the label.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKeychainShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Keychain}"
ToolTip.Tip="Your keychain: SSH keys and stored passwords">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE73C;" />
<TextBlock Classes="navlabel" Text="Keys" />
</StackPanel>
</Button>
It shares a word with the tab strip's first tab, which is a different level of the window: that
tab is "this application rather than SFTP or S3", and this is one of the nine screens under it.
-->
<Button Classes="flat nav" Classes.active="{Binding IsVaultsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Vaults}"
ToolTip.Tip="Your vaults, the people in each one, and who holds a key">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="◎" />
<!--
No count. The vault list is the session's and could be counted here — but who is in each one
is read from the server when the screen is opened, not on unlock, and a number naming only
half of what the screen is about would be the one figure on this list that has to be
explained.
-->
<TextBlock Grid.Column="1" Classes="navlabel" Text="Vaults" />
</Grid>
</Button>
<!--
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
than picked afresh so the one concept reads as one glyph everywhere it appears.
-->
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE946;" />
<TextBlock Classes="navlabel" Text="Pins" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSnippetsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
ToolTip.Tip="Commands you have saved, and how to put one into a terminal">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xEAD3;" />
<TextBlock Classes="navlabel" Text="Snips" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsLogsShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}"
ToolTip.Tip="What has been connected to, and what has been changed in this keychain">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE26C;" />
<TextBlock Classes="navlabel" Text="Logs" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<Button DockPanel.Dock="Bottom" Classes="flat nav" Margin="8,0,8,10"
Classes.active="{Binding IsPreferencesShowing}"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
ToolTip.Tip="Preferences, and this machine's device key">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="navicon" Text="⚙" />
<TextBlock Grid.Column="1" Classes="navlabel" Text="Preferences" />
<!--
The rail's foot: the signed-in identity, and everything the strip's old Vaults tab used to gate
behind a caret. A Flyout is safe here without any ordering games: it opens inside the rail's own
255-pixel column, which the terminal's native child window never occupies — there is no rectangle
here a popup could be composited underneath, unlike the window-wide tab strip this rail's own
switcher replaced, which sat directly above that rectangle and had to select a page before opening
one for exactly that reason.
-->
<Button x:Name="UserChip" DockPanel.Dock="Bottom" Classes="flat navuser" Click="OnUserChipPressed">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="20" Height="20" CornerRadius="60"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="7.5" LetterSpacing="0.2"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="10,0,0,0" Spacing="4">
<TextBlock FontWeight="Bold" FontSize="10.5" LetterSpacing="0.1"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
Text="{Binding AccountName}" TextTrimming="CharacterEllipsis" />
<TextBlock FontSize="8" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
Text="&#x25BC;" />
</StackPanel>
</Grid>
<FlyoutBase.AttachedFlyout>
<Flyout Placement="TopEdgeAlignedLeft">
<StackPanel Width="227" Spacing="8">
<!--
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
is no organisation concept behind a vault, only the vault itself, which the rows below name.
-->
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
Foreground="{StaticResource TextGhost}"
Text="{Binding Email}"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
One row per readable vault — the strip's old "SHOW ITEMS FROM" chips, restyled: a 14-pixel
initial square (Chip's own fill, since no per-vault colour exists to draw honestly) and a
magenta check where the vault's items are shown. Toggling one leaves the Flyout open, the
same as the chips it replaces did — this is a switch to flip, not a place to leave from.
-->
<ItemsControl ItemsSource="{Binding VaultToggles}" IsVisible="{Binding HasVaultSwitches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultToggleViewModel">
<Button Classes="poprow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
CommandParameter="{Binding}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="14" Height="14" CornerRadius="2"
Background="{StaticResource Chip}">
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="9"
Foreground="{StaticResource TextDim}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<TextBlock Grid.Column="1" Margin="10,0" FontSize="10"
Foreground="{StaticResource Text}" VerticalAlignment="Center"
Text="{Binding Display}" TextTrimming="CharacterEllipsis" />
<Border Grid.Column="2" Classes="vaultcheck" IsVisible="{Binding IsShown}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="10"
Foreground="White"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="poprow" Click="OnPopoverNewVaultPressed"
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Text="New vault" FontSize="10" Foreground="{StaticResource TextGhost}" />
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text="&#xE145;"
FontSize="12" Foreground="{StaticResource TextGhost}" />
</Grid>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<!--
Settings and Preferences both land on the one screen this application actually has — the
mock's own Settings area is a family of screens that do not exist yet; see the decided
deviation in design-notes/v5b-fidelity-notes.md. Two doors to one room rather than one door
removed, so neither word on the mock goes unanswered.
-->
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE84F;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
</StackPanel>
</Button>
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE429;" FontSize="12"
Foreground="{StaticResource TextGhost}" />
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
</StackPanel>
</Button>
<Border Height="1" Background="{StaticResource BorderMid}" />
<!--
The existing sign-out flow, with its own confirm card — see
MainWindowViewModel.SignOutFromPopover for why this goes through Preferences rather than
calling SignOutCommand directly from wherever the popover happened to be opened.
-->
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE9BA;" FontSize="12"
Foreground="{StaticResource Text}" />
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
</StackPanel>
</Button>
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</Button>
</DockPanel>
+77 -1
View File
@@ -1,9 +1,85 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The five destinations down the left edge of the unlocked window.</summary>
/// <summary>The window's destinations, down the left edge the switcher, the six rail rows and the user chip.</summary>
internal sealed partial class NavRail : UserControl
{
public NavRail() => InitializeComponent();
/// <summary>Opens the user popover.</summary>
/// <remarks>
/// A handler rather than relying on the click that opening a <c>Flyout</c> answers to on its own: a
/// named method is a thing a test can call directly, where an implicit open is not.
/// </remarks>
private void OnUserChipPressed(object? sender, RoutedEventArgs e)
{
if (sender is Control chip)
{
FlyoutBase.ShowAttachedFlyout(chip);
}
}
/// <summary>Hides the popover, whatever handler is about to navigate.</summary>
private void ClosePopover()
{
if (this.FindControl<Button>("UserChip") is { } chip)
{
FlyoutBase.GetAttachedFlyout(chip)?.Hide();
}
}
/// <summary>Leaves for the vaults screen with the new-vault form open, shutting the popover behind it.</summary>
/// <remarks>
/// The popover is closed first: the command navigates, and a popup left open would be hanging over a
/// screen it has nothing to do with. A <c>Flyout</c> does not close on its own when something inside it
/// is pressed — which is what the vault switches above it want, and not what this wants.
/// </remarks>
private void OnPopoverNewVaultPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowNewVaultCommand.Execute(null);
}
}
/// <summary>Settings and Preferences both land here — see the remark in the markup.</summary>
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
}
}
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
}
}
/// <summary>
/// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/>.
/// </summary>
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.SignOutFromPopoverCommand.Execute(null);
}
}
}
@@ -27,9 +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.
-->
<!-- 80% of Canvas. Written out because a scrim is a brush with an alpha, and the palette holds no alpha
<!-- 60% 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 x:Name="Backdrop" Background="#9905050A" PointerPressed="OnBackdropPressed">
<Border Width="640" VerticalAlignment="Top" Margin="0,160,0,0"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="14">
@@ -0,0 +1,49 @@
<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.App.Views.SessionHeader"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
forward" button — none of those are facts this application has.
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
surface-aware property, so this control asks no question about which screen it is on. What differs
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
-->
<Border Height="60" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
<!--
The address, only while a session/host context is active — see SessionAddress's own remark for what
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
form to fill in.
-->
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
<Button Grid.Column="1" Classes="headerghost"
Content="{Binding #Root.OpenLabel}"
Command="{Binding #Root.OpenCommand}"
CommandParameter="{Binding #Root.OpenCommandParameter}" />
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,58 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
/// See the remark at the top of SessionHeader.axaml.
/// </summary>
internal sealed partial class SessionHeader : UserControl
{
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
internal static readonly StyledProperty<string?> OpenLabelProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
/// <summary>What the cross-surface ghost button runs.</summary>
/// <remarks>
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
/// </remarks>
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
internal static readonly StyledProperty<string?> EmptyTextProperty =
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
public SessionHeader() => InitializeComponent();
internal string? OpenLabel
{
get => GetValue(OpenLabelProperty);
set => SetValue(OpenLabelProperty, value);
}
internal ICommand? OpenCommand
{
get => GetValue(OpenCommandProperty);
set => SetValue(OpenCommandProperty, value);
}
internal object? OpenCommandParameter
{
get => GetValue(OpenCommandParameterProperty);
set => SetValue(OpenCommandParameterProperty, value);
}
internal string? EmptyText
{
get => GetValue(EmptyTextProperty);
set => SetValue(EmptyTextProperty, value);
}
}
@@ -0,0 +1,108 @@
<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.App.Views.SessionSidebar"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session sidebar ────────────────────────────────────────────────────────────────────────────────
300 pixels, Sidebar background, a 1px left border — the design's own right-hand column on both the
terminal and the SFTP screen. It replaces the pin chip strip that used to sit above the terminal: see
MainWindowViewModel.ActiveTabPinnedPaths and OpenPinnedPathCommand, both reused here unchanged, and
MainWindow.axaml for where the old strip's Border used to live.
QUICK ACCESS is on both surfaces. SNIPS is the terminal's own — gated on <see cref="ShowsSnips"/>, which
the terminal usage in MainWindow.axaml sets true and the SFTP usage leaves false, rather than a second
copy of this file: the two sections share nothing surface-specific except which one is drawn at all.
Every row here is a command the shell already exposes for exactly this purpose — see
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
so this control carries no logic of its own beyond the list it draws and the click it forwards.
-->
<Border Width="300" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="6" Margin="16,20">
<!-- ============ QUICK ACCESS ============ -->
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
Foreground="{StaticResource TextGhost}"
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
</Grid>
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!--
A single level of $parent[ItemsControl] reaches the shell directly, the same way the old pin
strip's chips did: this ItemsControl's own DataContext is MainWindowViewModel, so one hop up
from the path's string DataContext lands on it.
-->
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).OpenPinnedPathCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE2C7;" FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding PinFolderFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, at QUICK ACCESS.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Pin folder" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
<TextBlock Classes="label" Text="SNIPS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SnippetsScreen.Visible}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Button Classes="sidebarrow"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).InsertSnippetCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{Binding Snippet.Command}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="{}{ }" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
FontSize="11" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource MonoFont}" FontSize="12.5" Text="{Binding Label}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddSnippetFromSidebarCommand}"
ToolTip.Tip="Opens the snippet editor.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add Snip" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session sidebar: QUICK ACCESS on both surfaces, SNIPS on the terminal's own. See the remark at
/// the top of SessionSidebar.axaml.
/// </summary>
internal sealed partial class SessionSidebar : UserControl
{
/// <summary>Whether the SNIPS section draws below QUICK ACCESS.</summary>
/// <remarks>
/// Set from the usage site rather than inferred from a surface flag on the shell, for the same reason
/// <see cref="SessionTabRow.TabCommand"/> is: which sections a particular instance of this control shows
/// is a fact about where it was placed in <c>MainWindow.axaml</c>, not one this control can read off its
/// own <c>DataContext</c>.
/// </remarks>
internal static readonly StyledProperty<bool> ShowsSnipsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
public SessionSidebar() => InitializeComponent();
internal bool ShowsSnips
{
get => GetValue(ShowsSnipsProperty);
set => SetValue(ShowsSnipsProperty, value);
}
}
@@ -0,0 +1,55 @@
<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.App.Views.SessionStatusBar"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's session shell status bar ───────────────────────────────────────────────────────────────────────
37px, DeepChrome, a 1px top border — the foot of the bordered container both the terminal and the SFTP
surface share. The design's own row also carries a negotiated cipher, a host-key algorithm and a
key/credential name; none of the three is reachable from this shell today — see the wave B report this
control's history carries, and design-notes/v5b-fidelity-notes.md's own decided deviation — so none of
them is drawn, omitted rather than faked, per this project's honesty rule.
What is real and bound: the CONNECTED word and dot, off IsSessionConnected, shown only while there is a
session/host context to report on at all — SessionAddress null means nothing here has anything to say,
the same state the header answers with its own empty-state sentence; the elapsed timer, off
SessionElapsedText, which is null and therefore absent whenever there is nothing timed; and, only on the
terminal surface — see <see cref="ShowsEncoding"/> — "UTF-8", which is a true fact about this client's own
renderer and write path (see TerminalWorkspace's terminal.js and SshShellSessionExtensions.WriteTextAsync)
rather than a negotiated session parameter, and is worded plainly rather than as a claim the remote agreed
to it.
-->
<Border Height="37" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
<Grid ColumnDefinitions="*,Auto" Margin="24,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="7" VerticalAlignment="Center"
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Ellipse Classes="dot" Width="7" Height="7" Classes.live="{Binding IsSessionConnected}"
VerticalAlignment="Center" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="CONNECTED" Foreground="{StaticResource Live}"
IsVisible="{Binding IsSessionConnected}" />
<TextBlock FontFamily="Montserrat" FontWeight="SemiBold" FontSize="9.5" LetterSpacing="1.2"
VerticalAlignment="Center" Text="NOT CONNECTED" Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !IsSessionConnected}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="UTF-8"
IsVisible="{Binding #Root.ShowsEncoding}" />
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,30 @@
using Avalonia;
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b session shell's status bar: CONNECTED and its dot, the elapsed timer, and — only where it is true
/// — UTF-8. See the remark at the top of SessionStatusBar.axaml.
/// </summary>
internal sealed partial class SessionStatusBar : UserControl
{
/// <summary>
/// Whether "UTF-8" is drawn on the right.
/// </summary>
/// <remarks>
/// Set true only by the terminal surface's own usage in <c>MainWindow.axaml</c>. It is a fact about this
/// client's renderer and its write path — see the remark on <c>SessionStatusBar.axaml</c> — and has
/// nothing to do with an SFTP session, which moves bytes rather than decoded text.
/// </remarks>
internal static readonly StyledProperty<bool> ShowsEncodingProperty =
AvaloniaProperty.Register<SessionStatusBar, bool>(nameof(ShowsEncoding));
public SessionStatusBar() => InitializeComponent();
internal bool ShowsEncoding
{
get => GetValue(ShowsEncodingProperty);
set => SetValue(ShowsEncodingProperty, value);
}
}
@@ -0,0 +1,102 @@
<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.App.Views.SessionTabRow"
x:Name="Root"
x:DataType="vm:MainWindowViewModel">
<!--
── v5b's in-screen tab row, the replacement for TerminalTabs ───────────────────────────────────────────
One pill per open terminal and the button that opens another — the same list <c>TerminalTabs</c> drew
above the whole window — now drawn inside each of the two screens the design gives a tab row: the
terminal surface and the SFTP surface. See <c>MainWindow.axaml</c> for where the strip itself went and
<c>Palette.axaml</c>'s v5b remark for why the row is 38 tall rather than the old strip's 42.
One control rather than two copies of the same markup, because the two rows share everything except two
things: which colour marks the active tab, and what a click on a tab actually does. Both are handed in
from the usage site rather than branched on a mode property here.
◆ THE COLOUR is <c>Classes="sftp"</c> on this control's own usage in <c>MainWindow.axaml</c> — unmarked
is the terminal row and reads <c>TerminalTabAccent</c>; <c>.sftp</c> reads <c>Magenta</c>. Both are
<c>App.axaml</c> selectors keyed off that class on this element, which is why the two rows need no
binding of their own for it: <c>Button.sesstab.active</c> and <c>.sftp Button.sesstab.active</c> are
the whole of it.
◆ THE CLICK is <see cref="TabCommand"/>, a plain <c>ICommand</c> this control exposes rather than reads
off the shell — the terminal row binds it to <c>SelectTabCommand</c> and the SFTP row to
<c>SelectFilesHostCommand</c>, and neither of those is a decision this control has any business making.
Every tab button's own <c>CommandParameter</c> is the tab itself, exactly as the strip's was.
The close box, the middle-click gesture and the "+" are not parameterised: closing a tab ends its shell
regardless of which screen it was clicked from, and "+" always opens the same palette. See
<c>MainWindowViewModel.CloseTabCommand</c> and <c>ToggleSearchCommand</c>.
-->
<Border Height="38">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Spacing="6">
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<!--
Marked on IsSelected rather than IsShowing, unlike the old strip: IsShowing is "the terminal
surface is showing this tab's pane", which the SFTP row's own active tab is never true of. A
selected tab survives navigating away from the terminal surface — that is the whole point of
a selection outliving a screen — so both rows agree on what "active" means without either
one needing a flag scoped to just one surface. See App.axaml's own remark on Button.sesstab.
-->
<Button Classes="sesstab"
Classes.active="{Binding IsSelected}"
Command="{Binding #Root.TabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
<!--
Two states, as the strip's own dots always were: green while the shell behind this tab is
running, grey while it is connecting and once it has ended. The design's third, amber,
state has no meaning here — nothing in this application checks whether a host is merely
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
-->
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="&#x2715;" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Opens the quick-connect palette, on both rows: "the existing new-connection/quick-connect meaning"
the notes ask this button to keep. See TerminalTabs' own remark, carried over unchanged, on why
this is a palette and never a flyout menu over the terminal's own rectangle.
-->
<Button Classes="sesstab plus"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -0,0 +1,63 @@
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The v5b in-screen tab row: one pill per open terminal, and the "+" that opens another — parameterised by
/// <see cref="TabCommand"/> so the terminal surface and the SFTP surface can each wire a click to a different
/// meaning over the same list. See the remark at the top of SessionTabRow.axaml.
/// </summary>
internal sealed partial class SessionTabRow : UserControl
{
/// <summary>What a left click on a tab runs, with the tab itself as the command parameter.</summary>
/// <remarks>
/// A plain <see cref="ICommand"/> rather than a bound property read off the shell, because which command
/// that is is the one thing this control cannot decide for itself — the terminal surface wants
/// <c>SelectTabCommand</c> and the SFTP surface wants <c>SelectFilesHostCommand</c>, and only the caller
/// in <c>MainWindow.axaml</c> knows which screen this instance is on.
/// </remarks>
internal static readonly StyledProperty<ICommand?> TabCommandProperty =
AvaloniaProperty.Register<SessionTabRow, ICommand?>(nameof(TabCommand));
public SessionTabRow() => InitializeComponent();
internal ICommand? TabCommand
{
get => GetValue(TabCommandProperty);
set => SetValue(TabCommandProperty, value);
}
/// <summary>
/// Closes a tab on a middle click. See the identical remark on the strip this control replaced,
/// <c>TerminalTabs.axaml.cs</c>, for why this is <c>PointerUpdateKind</c> rather than
/// <c>IsMiddleButtonPressed</c>, why it fires on press rather than release, and why it is wired on the
/// tab's own template root rather than on the row.
/// </summary>
/// <remarks>
/// Not parameterised like <see cref="TabCommand"/>: closing a tab ends its shell regardless of which
/// screen the middle click landed on, so both rows want the same answer — <c>CloseTabCommand</c>, read
/// directly off this control's own <see cref="StyledElement.DataContext"/>, which is the shell on both.
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
e.Handled = true;
shell.CloseTabCommand.Execute(tab);
}
}
+327 -215
View File
@@ -10,245 +10,357 @@
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
the editor and the insert over the top. See SnippetsViewModel.
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
that a decision taken once while writing it rather than a button beside every one of them.
The two buttons at the bottom right of the detail pane are the whole safety design, and their wording is
load-bearing. A terminal is one input stream with no notion of being at a prompt — the remote may be
inside vi, or at a sudo password prompt with echo off — so this application cannot say "run this
command", only "type this into whatever is there". RUN appears solely for a snippet whose own flag says
it runs, which makes that a decision taken once while writing it rather than a button beside every one
of them.
── v5b — Snippets.dc.html ──────────────────────────────────────────────────────────────────────────────
A fidelity pass, not a new shape. What moved:
◆ THE SCREEN'S OWN TITLE. The design renames this screen "Snips" — adopted here on screen, matching the
nav rail, which already says Snips (see NavRail.axaml's own remark on the mock's wording for this
destination).
◆ THE HEADER. "Snips" 33 bold, a count chip, the existing filter box restyled to 280px, and a
"+ New snip" accent button in place of the old ghost "+ NEW SNIPPET" — same NewCommand.
◆ THE LIST. Card rows rather than a flat list — radius 10, Track fill and an accent ring on the selected
one, Track on hover — with a { } glyph, the mono name, the "runs immediately" chip (real: it is
RunsOnInsert, already tracked per snippet), the vault chip, and the mono command preview beneath. No
modified date: SnippetSecret carries none, and neither does any other item kind — VaultItem is (id,
secret, version, three sync flags) and nothing else; see docs/design-import-gaps.md. The bottom action
row keeps EDIT / MOVE TO VAULT… / DELETE, restyled and DELETE now danger-coloured to match.
◆ THE DETAIL/EDITOR SIDEBAR, widened to 340px per the design. Two modes exist as they always have — the
editor while IsEditing, the selected snippet's own facts and its insert controls otherwise — because the
two are genuinely different forms doing different things, not a cosmetic split; merging them would let
somebody fixing a typo re-file a snippet by leaving a vault picker where they found it. VAULT draws a
lock glyph beside the vault name, per the design. "Runs on insert"/"runs immediately" keeps this screen's
own longer caption rather than the design's shorter one — it states the operational consequence
("typed at the prompt and waits for you") where the design's states only the rationale for having the
setting once; both are true, the existing one is more actionable. The insert button keeps its own dynamic
label — "TYPE INTO {tab}" or "NO TERMINAL OPEN" — over the design's static "Type into terminal", because
naming the destination tab is a fact the design's caption does not carry and this screen already had.
Its own caption similarly stays: it names vi and a sudo prompt with echo off as the two ways "at a
prompt" can be wrong, which is more than the design's own sentence says.
◆ THE DELETE CONFIRMATION. Snippets.dc.html's own modal — vault-wide reach, a tombstone, no undo — is
exactly what VaultViewModel.HowFarADeletionGoes already says for every other kind of item, so
SnippetsViewModel.RequestDelete/ConfirmDelete/CancelDelete say it in the same words. Additive next to the
existing uncounted DeleteCommand rather than a change to it — that command is what the phone's own DELETE
row still calls, with no confirmation card on that screen to answer one; see SnippetsViewModel's own
remarks on both commands.
-->
<Grid ColumnDefinitions="*,300">
<UserControl.Styles>
<!--
A card, not a full-bleed row: Track fill on hover, Track fill plus an accent ring on the selected one,
matching the design's own "selected = Track bg + accent ring, hover Track" — distinct from
ListBox.filerows, which draws no ring, because a snippet card is chosen for one editor at a time rather
than opened directly.
-->
<Style Selector="ListBox.snipcards > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="10" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="ListBox.snipcards > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.snipcards > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<Style Selector="ListBox.snipcards > ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
</UserControl.Styles>
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="Auto,*" Margin="26">
<Border Grid.Row="0" Padding="14,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!-- ============ THE HEADER ============ -->
<Grid Grid.Row="0" Margin="0,0,0,20" ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<!--
The command is searched as well as the name: half of what anybody remembers about a saved
command is a word that was inside it.
-->
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
</Grid>
<TextBlock Grid.Column="0" Text="Snips" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Border Grid.Column="1" Margin="12,0,0,0" MinWidth="34" Height="30" CornerRadius="9" Padding="8,0"
Background="{StaticResource Chip}" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Visible.Count}" FontSize="12.5"
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
ItemsSource="{Binding Visible}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="12" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<!--
The flag, where the decision is made. A snippet that presses Enter for you is not the
same kind of thing as one that does not, and the list is where somebody chooses between
them.
-->
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
<TextBlock Text="runs immediately" FontSize="9.5" />
</Border>
<Border Classes="chip warn" Padding="4,0"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
<!--
Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert; the row is where somebody
decides whether the thing they are about to edit is theirs alone.
-->
<Border Classes="chip" Padding="4,0" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
text reads as a single command, which is the thing being decided about on this row.
-->
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
The command is searched as well as the name: half of what anybody remembers about a saved command is
a word that was inside it.
-->
<TextBox Grid.Column="3" x:Name="SnippetFilter" Text="{Binding Filter}" Width="280" Height="40"
CornerRadius="10" Margin="0,0,10,0"
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
IsEnabled="{Binding HasSelection}" />
<!--
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one and
a tombstone in the other — nothing a SAVE could do — and a picker inside the form would let
somebody correcting a typo hand a command to a team by leaving it where they found it. The
picker itself opens beside the snippet, in the pane on the right.
-->
<Button Classes="ghost" Content="MOVE TO VAULT…" Command="{Binding MoveCommand}"
IsEnabled="{Binding CanMove}"
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
</StackPanel>
</Border>
<Button Grid.Column="4" Classes="accent" Height="40" FontSize="13.5" Content="+ New snip"
Command="{Binding NewCommand}" />
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="8">
<!-- ============ THE BORDERED BODY ============ -->
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
ClipToBounds="True">
<Grid ColumnDefinitions="*,340">
<!-- ============ The editor ============ -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<Grid Grid.Column="0" RowDefinitions="*,Auto" Background="{StaticResource Pane}">
<!--
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault is
not a field of this form, and changing it is MOVE — and not drawn at all where there is only
one vault to choose between, because a control offering one option is a question nobody asked.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
<ListBox Grid.Row="0" x:Name="SnippetList" Classes="snipcards" Focusable="True"
ItemsSource="{Binding Visible}"
SelectedItem="{Binding Selected}"
Margin="10,10,10,4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:SnippetRowViewModel">
<StackPanel Margin="6,7" Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="mono" Text="{}{ }" FontSize="11" FontWeight="Bold"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<!--
The flag, where the decision is made. A snippet that presses Enter for you is not the
same kind of thing as one that does not, and the list is where somebody chooses between
them.
-->
<Border Classes="chip warn" Padding="7,0" Height="18" VerticalAlignment="Center"
IsVisible="{Binding RunsOnInsert}">
<TextBlock Text="runs immediately" FontSize="9.5" />
</Border>
<Border Classes="chip warn" Padding="7,0" Height="18" VerticalAlignment="Center"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
<!--
Which vault this one is in, drawn only where there is more than one to be in. A shared
snippet is a command the rest of a team can read and insert; the row is where somebody
decides whether the thing they are about to edit is theirs alone.
-->
<Border Classes="chip" Padding="7,0" Height="18" VerticalAlignment="Center"
IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="9.5" />
</Border>
</StackPanel>
<!--
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
text reads as a single command, which is the thing being decided about on this row.
-->
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="11.5"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="0" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" />
<!--
Sharing a snippet, which is what moving one into a team's vault is. Beside EDIT rather than
inside it: the two vaults are encrypted under different keys, so this is a re-seal into one
and a tombstone in the other — nothing a SAVE could do — and a picker inside the form would
let somebody correcting a typo hand a command to a team by leaving it where they found it.
The picker itself opens beside the snippet, in the pane on the right.
-->
<Button Classes="ghost" Content="Move to vault…" Command="{Binding MoveCommand}"
IsEnabled="{Binding CanMove}"
ToolTip.Tip="Re-encrypts this snippet with another vault's key. Everybody who holds that key can then read and insert it." />
<Button Classes="danger" Content="Delete" Command="{Binding RequestDeleteCommand}"
IsEnabled="{Binding HasSelection}" />
</StackPanel>
</Border>
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
waiting for one that never comes.
-->
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
FontSize="12" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
Content="Press Enter after inserting this" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
</StackPanel>
</StackPanel>
</Grid>
<!-- ============ The selected snippet ============ -->
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="18,20" Spacing="10">
<TextBlock Classes="hint" FontSize="12"
Text="Choose a snippet to see it in full and put it into a terminal."
IsVisible="{Binding !HasSelection}" />
<!-- ============ The editor ============ -->
<StackPanel Spacing="8" IsVisible="{Binding IsEditing}">
<TextBlock Classes="label" Text="NAME" FontSize="10" />
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" FontFamily="{StaticResource MonoFont}" />
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<!--
Which vault a *new* snippet is filed into, asked on the form it is being typed into rather
than through a standing preference elsewhere. Not drawn for an existing snippet — its vault
is not a field of this form, and changing it is MOVE — and not drawn at all where there is
only one vault to choose between, because a control offering one option is a question
nobody asked.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" FontSize="10" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding EditorVaultChoices}"
SelectedItem="{Binding EditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A shared vault means everybody holding its key can read this command and insert it into their own terminals." />
</StackPanel>
<!--
Named here as well as on the row, because this pane is where somebody decides to put a
command into a production terminal, and who else holds a key to it is part of that decision.
The row's badge is off the screen by the time this is being read.
-->
<Border Classes="chip" Padding="4,0" HorizontalAlignment="Left"
IsVisible="{Binding HasSelectionVaultBadge}">
<TextBlock Text="{Binding SelectionVaultBadge}" FontSize="9.5" />
</Border>
<TextBlock Classes="label" Text="COMMAND" FontSize="10" />
<!--
Stored exactly as typed — no trimming, no newline normalisation. A here-document's
terminator has to arrive on a line of its own, and tidying the trailing newline off it
leaves the shell waiting for one that never comes.
-->
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
FontSize="12" />
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="48" TextWrapping="Wrap" />
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<StackPanel Grid.Column="0" Spacing="3">
<TextBlock Text="Runs on insert" FontSize="12.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
</StackPanel>
<CheckBox Grid.Column="1" IsChecked="{Binding EditorRunsOnInsert}"
VerticalAlignment="Top" Margin="10,0,0,0" />
</Grid>
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="11"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The button names the tab it will type into. This screen is not the terminal — the strip
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
about to receive a command, at the moment that is worst to be wrong about.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
HorizontalAlignment="Left"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,10,0,0"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
<!--
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker and
two buttons, not a question with a yes: what is being asked is which vault, and a move is
undone by moving it back rather than by being careful — so this is not drawn in the danger
colours the deletion question uses.
It takes the insert controls' place while it is up, for the reason the host pane hides
CONNECT: the button that opened this is still on screen otherwise, offering to open it again.
The sentence is the part worth keeping. Unlike a host, a snippet crosses whole — it has no
group and no tags to leave behind — so what there is to say is who can read it afterwards,
and for a command that may carry a hostname or a path that is the whole of the decision.
-->
<StackPanel Spacing="8" Margin="0,14,0,0" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveCommand}" />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,6,0,0">
<Button Classes="accent" Content="Save" Command="{Binding SaveCommand}" />
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- ============ The selected snippet ============ -->
<StackPanel Spacing="10" IsVisible="{Binding !IsEditing}">
<TextBlock Classes="hint" FontSize="12"
Text="Choose a snippet to see it in full and put it into a terminal."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="10" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="15"
FontWeight="Bold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="label" Text="COMMAND" FontSize="10" />
<Border CornerRadius="10" Background="{StaticResource Pane}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
FontSize="11.5" LineHeight="19"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" />
</Border>
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="11"
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
VAULT, with the design's own lock glyph. Named here as well as on the row, because this
pane is where somebody decides to put a command into a production terminal, and who else
holds a key to it is part of that decision.
-->
<StackPanel Spacing="4" IsVisible="{Binding HasSelectionVaultBadge}">
<TextBlock Classes="label" Text="VAULT" FontSize="10" />
<Border Height="38" CornerRadius="10" Background="{StaticResource Pane}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" Padding="12,0">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectionVaultBadge}"
FontSize="11.5" Foreground="{StaticResource TextDim}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" FontFamily="{StaticResource IconFont}" Text="&#xE897;"
FontSize="14" Foreground="{StaticResource TextFaint}"
VerticalAlignment="Center" />
</Grid>
</Border>
</StackPanel>
<!--
The button names the tab it will type into, over the design's static "Type into
terminal" — see the file-level remark above for why the dynamic label is the truer one.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsSelectionActions}">
<Button Classes="accent" Content="{Binding InsertLabel}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding RunCommand}"
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" TextAlignment="Center"
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
</StackPanel>
<!--
◆ SHARING THE SNIPPET, which is what moving it into a team's vault amounts to. A picker
and two buttons, not a question with a yes.
-->
<StackPanel Spacing="8" IsVisible="{Binding IsMoving}">
<TextBlock Classes="label" Text="MOVE TO VAULT" FontSize="10" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The snippet is re-encrypted with the other vault's key, so everybody who holds that key can read this command and insert it — and nobody else can. Nothing else about it changes." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="Move" Command="{Binding ConfirmMoveCommand}" />
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelMoveCommand}" />
</StackPanel>
</StackPanel>
<!--
◆ THE DELETE CONFIRMATION. See RequestDelete's own remarks for why this is additive
beside DeleteCommand rather than a change to it.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="10" Padding="12"
IsVisible="{Binding IsConfirmingDelete}">
<StackPanel Spacing="8">
<TextBlock Classes="heading" FontSize="14" TextWrapping="Wrap"
Text="{Binding DeleteQuestion}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding DeleteConsequence}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="Delete snippet"
Command="{Binding ConfirmDeleteCommand}" />
<Button Classes="ghost" Content="Cancel" Command="{Binding CancelDeleteCommand}" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Grid>
@@ -1,314 +0,0 @@
<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.App.Views.TerminalTabs"
x:DataType="vm:MainWindowViewModel">
<!--
The window's tab strip: three fixed tabs, then one per open terminal.
── IT IS NOT ONLY TERMINALS ANY MORE, and the type is still called TerminalTabs. ─────────────────────
Vaults, SFTP and S3 sit at the head of the strip and are always there. The name stays because the strip
is named in MainWindow, in the layout harness's height budget and in its own suite, and renaming a type
to track what it grew into is a rename across four files that leaves the product identical. What the
name now under-describes is written here instead.
── THE THREE FIXED TABS ─────────────────────────────────────────────────────────────────────────────
None of the three can be closed, and that is the difference between them and a terminal rather than a
styling choice. A terminal tab owns a shell and closing it ends that shell; these three own nothing —
they are three places this window goes, and a close box on one would be asking whether to destroy a
destination.
Vaults is first and is the only one with anything under it: the nav rail, and whichever of its screens
the rail points at. SFTP and S3 were rail entries until this strip existed, and they moved because they
are the two destinations you *stay in* while something runs. The rail is drawn only under Vaults; see
MainWindowViewModel.IsVaultsTab for why that is expressed as a page test rather than as a surface.
S3 carries no count although the rail entry it replaces did. There is room for one, and a number on two
of five tabs reads as a fact about those two rather than as the tab's own state — a terminal tab has
nothing to count, and the eye reads the strip left to right expecting the same shape.
── THE TERMINAL TABS ────────────────────────────────────────────────────────────────────────────────
Every one is one pane in the one WebView, so switching is a single frame telling the page which pane to
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
one is the only thing in this application that deliberately ends a session.
The strip spans the whole window rather than one screen, which is what it is for: a connection you
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
that state.
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
Tabs became pills: taller, rounded, each with its own outline, on the sidebar's surface rather than the
chrome's. The design puts a "Hosts" pill at the head of this strip and hides the sidebar while a session
is showing, so that pill is the only way back. The three fixed tabs are that idea taken at its word and
one step further: the rail is not hidden, but it belongs to the Vaults tab, and the head of the strip is
where you go to get back to it.
-->
<Border Height="42" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<!--
Everything in one scrolling row: the three fixed tabs, a rule, the terminals, the button that opens
another, then the sentence for when there are none. The strip stays rather than collapsing — a row of
chrome that appears and disappears would move every screen up and down by 42 pixels each time the last
tab closed.
-->
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" Margin="8,0,0,0">
<!--
The three that are always here. Buttons with no close box, marked active from the shell's own
state rather than holding a selection of their own — the same reason the terminal tabs below are
buttons and not a TabStrip.
Each is lit by a different property and the three are exclusive by construction: IsVaultsTab is
"a page, and not one of these two", and the other two are the existing IsTransfersShowing and
IsBucketsShowing that both heads already use. Nothing here can light two at once.
-->
<!--
Two buttons drawn as one pill: the tab, and a caret that opens the vault menu. Split rather than
one button with a menu, because the tab's job is to go somewhere and that must stay a single
click — a tab you cannot press without being asked a question is not a tab.
── WHY A FLYOUT IS SAFE HERE, WHEN THE + BUTTON BELOW STILL REFUSES ONE ────────────────────────
That refusal stands and its reasoning is unchanged: this strip sits directly above the WebView's
rectangle, and whether a popup dropping into it composites above a native child window is not
something this project treats as settled without a screenshot.
What makes the question not arise here is the order in the handler. OnVaultMenuPressed selects
the Vaults tab *first*, which sets the shell's surface to a page and collapses the renderer — so
by the time the flyout opens there is no native child window under it. Exactly the move
QuickConnect already makes. It is also the behaviour a user expects: the caret belongs to the
Vaults tab, so pressing it going to Vaults is not a surprise.
The handler is explicit rather than Button.Flyout's own open, so that ordering is a thing the
code states and the headless suite can assert, rather than a thing the framework happens to do.
-->
<StackPanel Orientation="Horizontal" Spacing="0">
<Button Classes="flat tab fixed split" Classes.active="{Binding IsVaultsTab}"
Command="{Binding ShowVaultsCommand}"
ToolTip.Tip="Your keychain: hosts, keys, pins, snippets and logs">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="▦" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="Vaults" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button x:Name="VaultMenu" Classes="flat tab fixed caret" Width="22"
Classes.active="{Binding IsVaultsTab}"
Click="OnVaultMenuPressed"
ToolTip.Tip="Choose which vaults this window shows, or make a new one">
<TextBlock Text="⌄" FontSize="11" HorizontalAlignment="Center" VerticalAlignment="Center" />
<FlyoutBase.AttachedFlyout>
<Flyout Placement="BottomEdgeAlignedLeft">
<StackPanel Width="230" Spacing="8">
<!--
Chips rather than checkable menu items. Nothing in this application uses a checkable
MenuItem, and binding one needs an ItemContainerTheme to reach ToggleType and IsChecked
plus a composed collection to put a fixed entry after a bound one — where the chip
toggle beside every host's tags already says on-and-off in this window's own language.
-->
<TextBlock Classes="label" Text="SHOW ITEMS FROM"
IsVisible="{Binding HasVaultSwitches}" />
<ItemsControl ItemsSource="{Binding VaultToggles}"
IsVisible="{Binding HasVaultSwitches}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultToggleViewModel">
<Button Classes="chiptoggle" Classes.worn="{Binding IsShown}"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="0,0,0,4"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ToggleVaultCommand}"
CommandParameter="{Binding}">
<TextBlock Text="{Binding Display}" FontSize="11"
TextTrimming="CharacterEllipsis" />
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
A hint rather than a disabled switch, because the personal vault's chip is drawn lit and
pressing it says the same thing in the status bar. One sentence under the list is where
somebody looks when a chip does not move.
-->
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding HasVaultSwitches}"
Text="Switching a vault off only stops it being listed here. It still syncs, and hosts that authenticate with its keys still connect." />
<Border Height="1" Background="{StaticResource BorderSubtle}"
IsVisible="{Binding HasVaultSwitches}" />
<!--
A handler rather than a Command binding, because this one navigates and the menu has to
shut on the way. A Flyout stays open when something inside it is pressed — which is
right for the chips above, where switching two vaults off is one visit — and wrong for
the one entry that leaves.
-->
<Button Classes="ghost" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Content="New vault…"
Click="OnNewVaultPressed"
ToolTip.Tip="Names a vault you can share, and opens it on the Vaults screen so you can add people to it and give them roles" />
</StackPanel>
</Flyout>
</FlyoutBase.AttachedFlyout>
</Button>
</StackPanel>
<Button Classes="flat tab fixed" Classes.active="{Binding IsTransfersShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Host}"
ToolTip.Tip="Move files to and from a host over SFTP">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="⇅" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="SFTP" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
The same screen as SFTP over the same view model — an object store and an SFTP host are both an
IRemoteFileStore — and a separate tab anyway, because which picker is offered is decided by the
destination rather than by a toggle inside the screen. See ShowFiles, which also explains why
pressing this while an SFTP session is open refuses instead of arriving.
-->
<Button Classes="flat tab fixed" Classes.active="{Binding IsBucketsShowing}"
Command="{Binding ShowFilesCommand}"
CommandParameter="{x:Static vm:RemoteKind.Bucket}"
ToolTip.Tip="Objects in an S3-compatible bucket from your keychain">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<TextBlock Text="◳" FontSize="13" VerticalAlignment="Center" />
<TextBlock Text="S3" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!--
What separates the fixed tabs from the terminals. Without it the strip is five pills of the same
shape and the user has to read all five to learn that three of them are places and two are
machines. It is a rule rather than a gap because a gap at this width reads as the strip having
been laid out carelessly.
-->
<Border Width="1" Height="18" Margin="6,0,10,0" VerticalAlignment="Center"
Background="{StaticResource Border}" />
<ItemsControl ItemsSource="{Binding Tabs}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TerminalTabViewModel">
<!--
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
for the tab to its right.
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
event handled — so a left press on the cross does not also select the tab. It deliberately
does not handle any other button, which is exactly what lets a middle press bubble out of
the cross and reach the handler below.
Marked active on IsShowing rather than on IsSelected, which are not the same question. The
selection survives navigating away — that is what makes the strip a way back to a terminal —
so a tab lit while preferences filled the window would be a second "you are here" mark
pointing at something nobody can see. See TerminalTabViewModel.IsShowing.
-->
<Button Classes="flat tab"
Classes.active="{Binding IsShowing}"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
CommandParameter="{Binding}"
PointerPressed="OnTabPointerPressed"
ToolTip.Tip="{Binding Address}">
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
<!--
Green while the shell behind this tab is running, grey while it is connecting and once
it has ended. The pane keeps its scrollback either way, which is usually why somebody is
still looking at a tab whose dot has gone out.
-->
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
<!--
What a tab with no pane has to say for itself: "connecting…" while the handshake runs,
and the refusal once one has failed. It is here rather than only on the card because the
whole point of not blocking the window is that the user is somewhere else — the strip is
the one piece of chrome that is on screen wherever that is.
-->
<TextBlock Text="{Binding Status}" VerticalAlignment="Center" FontSize="10.5"
MaxWidth="180" TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding !HasSession}" />
<!--
Always drawn, never on hover only. The strip has no other close affordance, and one
that appears when the pointer is already over the tab cannot be found by somebody
looking for it.
-->
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
honestly, and there is one way to start a connection rather than two that have to agree.
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
dropping into it composites above a native child window depends on whether Avalonia gives it its
own platform window. docs/platform-flags.md records what this project already paid for treating a
rendering claim as settled without a screenshot. The palette has no such question — opening it
collapses the terminal outright.
-->
<Button Classes="flat tab plus" Width="30"
Command="{Binding ToggleSearchCommand}"
ToolTip.Tip="Open a connection · Ctrl+K">
<TextBlock Text="+" FontSize="15" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<!--
Nothing open, and this is where that is said. It is also the only place near the terminal that can
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
in it is drawn underneath.
-->
<TextBlock Classes="mono" FontSize="10.5"
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding !HasTabs}" />
</StackPanel>
</ScrollViewer>
</Border>
</UserControl>
@@ -1,108 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>The tab strip, above every screen.</summary>
internal sealed partial class TerminalTabs : UserControl
{
public TerminalTabs() => InitializeComponent();
/// <summary>
/// Closes a tab on a middle click.
/// </summary>
/// <remarks>
/// <para>
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
/// </para>
/// <para>
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
/// and for every press during a middle drag. The question here is which button caused this press, and
/// that is the one thing only <c>PointerUpdateKind</c> answers.
/// </para>
/// <para>
/// On press rather than on release, which is what every browser and every terminal does. Matching a
/// release to its press would need capture tracking, to buy the ability to change your mind about a
/// middle click — a gesture nobody makes by accident and nobody aborts.
/// </para>
/// </remarks>
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|| DataContext is not MainWindowViewModel shell)
{
return;
}
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
is not PointerUpdateKind.MiddleButtonPressed)
{
return;
}
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
e.Handled = true;
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
// it. Its failures are the workspace's to report, not this strip's.
shell.CloseTabCommand.Execute(tab);
}
/// <summary>
/// Opens the vault menu, on the Vaults tab.
/// </summary>
/// <remarks>
/// <para>
/// <b>The tab is selected before the menu opens, and that order is the whole reason this is a handler
/// rather than <c>Button.Flyout</c>.</b> Selecting it puts the shell on a page, which collapses the
/// renderer — so the popup never has to drop over the WebView's native child window, and the question
/// this strip's comment refuses to answer without a screenshot does not come up. See the comment on the
/// caret in the markup, and <c>docs/platform-flags.md</c> for what treating such a question as settled
/// has already cost this project.
/// </para>
/// <para>
/// It is also what a user expects. The caret belongs to the Vaults tab, so pressing it arriving at
/// Vaults is the same gesture as pressing the tab, with a menu on the end.
/// </para>
/// </remarks>
private void OnVaultMenuPressed(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel shell || sender is not Control caret)
{
return;
}
shell.ShowVaultsCommand.Execute(null);
FlyoutBase.ShowAttachedFlyout(caret);
}
/// <summary>Leaves for the teams screen with the new-vault form open, shutting the menu behind it.</summary>
/// <remarks>
/// The menu is closed first, because the command navigates and a flyout left open would be hanging over
/// a screen it has nothing to do with. A <c>Flyout</c> does not close when something inside it is
/// pressed — which is what the switches above it want, and not what this wants.
/// </remarks>
private void OnNewVaultPressed(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel shell)
{
return;
}
if (this.FindControl<Button>("VaultMenu") is { } caret)
{
FlyoutBase.GetAttachedFlyout(caret)?.Hide();
}
shell.ShowNewVaultCommand.Execute(null);
}
}
+55 -50
View File
@@ -9,86 +9,83 @@
The window asks Windows for no chrome at all, so everything a titlebar does has to be here: dragging,
the double-click to maximise, and three buttons. That is a real cost, and the reason it is worth paying
is that a 38-pixel grey system bar above a near-black application is the one part of the window that
is that a 53-pixel grey system bar above a near-black application is the one part of the window that
would look borrowed.
It sits above the terminal rather than over it, which matters more than it looks: the terminal is a
native child window that composites above anything Avalonia draws in the same rectangle, so a titlebar
overlapping it would be painted underneath and its close button would not be clickable.
── v5b ──────────────────────────────────────────────────────────────────────────────────────────────
Redrawn against TitleBar.dc.html rather than the v3 mock this replaced, and three things left with the
redraw. The vault chip and the account name are gone from here — both now live on the rail's user chip,
which is where the design's own "who is signed in" lives too, one destination lower than the window's
own name. 44 pixels became 53, which is the design's own height and not a number this file chose; see
<c>LayoutHarness.TitleBarHeight</c> and the matching 9-pixel rise in <c>MainWindow.axaml</c>'s own
<c>MinHeight</c>, which is what keeps every screen the exact height it was designed against despite the
bar above it growing.
The design's kbd chip reads ⌘K; this one reads CTRL K, because a Windows build is not where the ⌘ key
lives — the same substitution the search box's own tooltip already made before this pass touched it.
SYNCED stays, on the right, past the window buttons' own left edge: a documented deviation from a design
whose titlebar has no home for it at all — see design-notes/v5b-fidelity-notes.md.
-->
<Border Height="44" Background="{StaticResource Chrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="14,0,10,0">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<!--
Filled rather than outlined since v2, and rounded. The same mark the phone's header draws and the
same one the launcher icon carries, so the three cannot drift.
24 pixels and radius 7, the design's own tile — up from v3's 20/6, which was this bar's own
approximation before there was a mock to measure against. Filled rather than outlined, and the
same mark the phone's header draws and the launcher icon carries, so the three cannot drift.
-->
<Border Width="20" Height="20" CornerRadius="6" Background="{StaticResource Accent}">
<Border Width="24" Height="24" CornerRadius="7" Background="{StaticResource Accent}">
<TextBlock Classes="mono" Text="&gt;_" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource AccentInk}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<!--
The product's name is the one string in this bar that is not machine-shaped, so v2 sets it in the
sans face while the address, the account and the fingerprint beside it stay monospaced.
19 bold at -0.2 tracking, the design's own numbers. Named, because a nightly says so here — see
the code-behind, where the release build is exactly what this markup says and nothing changes
for it.
-->
<!--
Named, because a nightly says so here. See the code-behind: the release build is what this
markup says and nothing changes for it.
-->
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="14" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<!--
The design puts an organisation here — "dodotech / platform". There are no organisations: the
server has team tables and no endpoint that reads them, so the only name this application can
truthfully print is the one on the vault it has open. The account is beside it because a machine
can be enrolled to one account at a time and knowing which is the point of the chip.
-->
<Border Classes="chip" IsVisible="{Binding IsUnlocked}">
<TextBlock Text="{Binding Vault.VaultName}" />
</Border>
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" MaxWidth="220" />
<TextBlock x:Name="ProductName" Text="DodoSSH" FontSize="19" FontWeight="Bold"
LetterSpacing="-0.2" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<!--
The quick-connect box. It searches hosts and nothing else — the design's box also promises "run
command", and there is no snippet or saved-command item type for it to run. Clicking it is the same
as Ctrl+K, which is what the window binds; the design says ⌘K, and this is a Windows build.
514 wide and centred, which is what the design states for this box specifically — MaxWidth rather
than Width, so the button is free to shrink at the window's minimum instead of arranging outside its
own parent when there is no room for all 514 of it.
-->
<!--
Stretch-to-a-maximum, not a fixed width. The design draws this box at exactly 380 and centred, and
stating that as a Width on the Border is what makes it wrong: the Button around it is free to
shrink when the account name or the vault chip beside it is long, and a Border that will not shrink
with it arranges outside its own parent — over the name on one side and over the window buttons on
the other. MaxWidth on the stretching button gives the same 380 whenever there is room and gives
way when there is not.
-->
<Button Grid.Column="1" Classes="flat" MaxWidth="380" Height="28" Margin="16,0"
<Button Grid.Column="1" Classes="flat search" MaxWidth="514" Height="35"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Command="{Binding ToggleSearchCommand}" IsEnabled="{Binding IsUnlocked}">
<Border Background="{StaticResource Field}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="8" Padding="10,0">
<Border Classes="searchpill" CornerRadius="10" Padding="16,0,8,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⌕" FontSize="13"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" Text="&#xE8B6;"
FontSize="15" Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!--
"Search or connect…", which is what it does: the palette connects on Enter. Not the design's
wider promise of running a command — a snippet is inserted from its own screen, and a box that
offered to run one would be offering something this palette does not do.
-->
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13" Margin="8,0"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<Border Grid.Column="2" BorderBrush="{StaticResource BorderMid}" BorderThickness="1"
CornerRadius="4" Padding="5,1" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10"
Foreground="{StaticResource TextFaint}" />
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
Background="{StaticResource KbdChip}"
HorizontalAlignment="Center" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</Grid>
</Border>
@@ -112,20 +109,28 @@
<Border Width="1" Height="16" Background="{StaticResource Border}"
IsVisible="{Binding IsUnlocked}" />
<!--
The window controls, restyled to the design's own quiet Material glyphs: remove, crop_square,
close, rather than the dashes and box this bar drew before there was a mock to measure them
against. What each does is unchanged — see the code-behind.
-->
<StackPanel Orientation="Horizontal" Spacing="2">
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
ToolTip.Tip="Minimise">
<TextBlock Text="" FontSize="13" Foreground="{StaticResource TextDim}"
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE15B;" FontSize="14"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat" Width="26" Height="24" Click="OnToggleMaximised"
ToolTip.Tip="Maximise">
<TextBlock Text="▢" FontSize="11" Foreground="{StaticResource TextDim}"
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C6;" FontSize="13"
Foreground="{StaticResource TextFaint}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
<Button Classes="flat close" Width="26" Height="24" Click="OnClose"
ToolTip.Tip="Close DodoSSH. This ends every shell it has open.">
<TextBlock Text="✕" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CD;" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
+341 -178
View File
@@ -35,40 +35,71 @@
<UserControl.Styles>
<!--
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
described as deliberately rare. This is the one place it is spent.
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
A directory used to be marked by colour alone — this application shipped no icon set when that rule
was written. It ships one now: v5b embeds Material Icons as IconFont, already spent on the nav rail,
the drawer and the session shell's own sidebar, and per SFTP.dc.html a file row draws a 14px glyph of
its own — folder for a directory, and the closest classic Material Icons has to the design's own
Material-Symbols-only "draft": insert_drive_file, a plain document rather than a page with a folded
corner. Recorded as a deviation rather than silently swapped, because "draft" is simply not a glyph
this font contains.
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
characters that actually say so. The two never compete for one TextBlock, which is what lets a
world-writable executable show both facts instead of one winning an argument.
So NAME's colour stopped being the directory's own mark — the glyph is now — and become what the
design says instead: a folder is white and medium weight, a file is TextDim and regular. Blue is not
spent here any more; Info keeps meaning "a distinct scope" everywhere else it already did, and this
screen no longer borrows it.
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
string is the compromise: the eye lands on the column, and the string itself is the detail.
A second colour still comes from the mode, and it still overrides the first rather than competing with
it: an executable is Live green in NAME — "live, yours, something that runs" — regardless of whether
the row beside it is asking for the folder colours or the file ones. PERMS keeps its own, separate
amber for a mode anyone may write to, on the same reasoning as before: NAME says what a row is, PERMS
says what is notable about how it is set, and the two never compete for one TextBlock.
-->
<Style Selector="TextBlock.entry">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Text}" />
<Setter Property="FontWeight" Value="Medium" />
</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.
Declared after .dir on purpose — Avalonia has no specificity, and an executable that is also somehow
the target row would need this rule to win. It never is in practice; see SftpEntry, which reads the
execute bit off a file's own mode and nothing else's.
-->
<Style Selector="TextBlock.entry.dir">
<Setter Property="Foreground" Value="{StaticResource Info}" />
</Style>
<Style Selector="TextBlock.entry.exec">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<!--
The glyph beside NAME. One quiet colour regardless of folder or file — the design's own renderVals
gives both the same default iconFg — because the glyph itself already says which one a row is; the
colour underneath it is furniture, on the same TextFaint-family step TextBlock.fieldglyph already uses
for a mark meant to be skipped rather than read.
-->
<Style Selector="TextBlock.entryicon">
<Setter Property="FontFamily" Value="{StaticResource IconFont}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
</Style>
<!--
SIZE and MODIFIED, both quieter than the design's own literal values ask for — rgb(93,95,116) and
rgb(110,112,137) sit a step below every text colour this palette already names. Per
design-notes/v5b-fidelity-notes.md's own instruction for values this close, the nearest existing steps
are reused rather than two new keys added for a difference nobody would see: TextGhost for SIZE, which
the design draws a shade darker than MODIFIED, and TextFaint — one step lighter — for MODIFIED, which
keeps the two in the same order the design puts them in even though neither is the design's exact hex.
-->
<Style Selector="TextBlock.entrysize">
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
</Style>
<Style Selector="TextBlock.entrydate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<!--
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
@@ -84,6 +115,40 @@
<Style Selector="TextBlock.perms.loose">
<Setter Property="Foreground" Value="{StaticResource Warn}" />
</Style>
<!--
── v5b: the TRANSFERS strip's own progress track ────────────────────────────────────────────────────
5px, 3px radius, Chip's own fill under an AccentGradient indicator — the design's own slim bar. A real
0% is what a queued transfer draws with this: the indicator's width is nothing, which reads exactly as
the design's own queued sample row does (a bar the same colour as its own track). No indeterminate
animation is drawn for it — a queued transfer really is at zero, so zero is the honest thing to show,
not a state this screen has to invent furniture for.
-->
<Style Selector="ProgressBar.transferbar">
<Setter Property="Height" Value="5" />
<Setter Property="MinHeight" Value="5" />
<Setter Property="CornerRadius" Value="3" />
<Setter Property="Background" Value="{StaticResource Chip}" />
<Setter Property="Foreground" Value="{StaticResource AccentGradient}" />
</Style>
<!--
The strip's own right-aligned status word. Quiet by default — a stopped transfer's "stopped" gets no
rule of its own and falls through to this — and three states get their own colour: in motion, kept, and
refused. See TransferRowViewModel.StatusWord for what word each state actually prints.
-->
<Style Selector="TextBlock.transferstatus">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.transferstatus.running">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.transferstatus.done">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.transferstatus.failed">
<Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style>
</UserControl.Styles>
<Grid RowDefinitions="*,Auto">
@@ -99,14 +164,34 @@
-->
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
Per SFTP.dc.html: a 10px tracked-out label beside the current path in mono 13.5, and the pane's
affordances restyled to quiet glyphs rather than dropped. "THIS MACHINE" becomes the design's own
"LOCAL" — nothing in this file's own comments ever argued for that particular wording over the
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
recorded where it is drawn.
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
thing (go to the parent directory), and the pair reading differently is the design's own choice
faithfully carried over rather than a functional difference invented to justify it.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="10" Text="LOCAL" VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding LocalPath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding LocalPath}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<!--
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
directory. Without this the pane is stuck on whichever drive the user profile is on.
directory. Without this the pane is stuck on whichever drive the user profile is on. Chips
rather than the pane's own quiet icon buttons, because a drive is named rather than a single
glyph — "C:" has no honest Material Icons equivalent.
-->
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
<ItemsControl.ItemsPanel>
@@ -116,14 +201,20 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:CrumbViewModel">
<Button Classes="ghost" Content="{Binding Name}"
<Button Classes="panechip" Content="{Binding Name}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
CommandParameter="{Binding Path}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
<Button Classes="paneicon" Command="{Binding LocalUpCommand}"
ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D8;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshLocalCommand}"
ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
</StackPanel>
</Grid>
</Border>
@@ -150,25 +241,37 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<!--
v5b: 20 for the glyph column rather than the design's bare icon with no gutter, because a Material
glyph centred with no reserved width drifts as the font's own advance width varies between folder
and insert_drive_file. 60/56 for SIZE/MODIFIED are narrower than the design's literal 80/78 — see
the row template below for why the whole column set was chosen against 204 pixels rather than the
design's own 1920-pixel canvas.
-->
<Grid Grid.Row="2" ColumnDefinitions="20,*,60,56" Margin="0,2,10,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
</Grid>
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
<!--
h38, radius 7, Track for hover and selected — see ListBox.filerows in App.axaml — and no rowmark:
the design has nothing to distinguish "selected" from "hovered" beyond which one is currently true,
and :selected alone already carries that once the pointer moves on.
-->
<ListBox Grid.Row="3" x:Name="LocalList" Classes="filerows" ItemsSource="{Binding LocalEntries}"
SelectedItem="{Binding SelectedLocalEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<Grid ColumnDefinitions="20,*,60,56" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -219,26 +322,41 @@
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"
DragDrop.AllowDrop="True">
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,0,0,1">
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────
HOST/BUCKET keep their own words rather than adopting the design's generic "REMOTE": the comment
this replaced already carried a documented honesty reason — the pair is the difference between a
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
the style moves to the design's 10px tracked label, not the wording.
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
-->
<Border Grid.Row="0" Padding="0,0,0,10" BorderThickness="0">
<Grid ColumnDefinitions="Auto,*,Auto">
<!--
Which kind of remote this pane is for, in the place the local pane names itself. It is the
only thing left saying so on the screen itself — the bar that used to print SFTP or S3 across
the top is gone — and the pair is worth keeping apart, because HOST and BUCKET is the
difference between a directory tree and a flat namespace with inferred folders in it.
-->
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Grid.Column="0" Classes="label" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" />
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock Classes="label" FontSize="10" Text="HOST" VerticalAlignment="Center"
IsVisible="{Binding ShowsHostPicker}" />
<TextBlock Classes="label" FontSize="10" Text="BUCKET" VerticalAlignment="Center"
IsVisible="{Binding ShowsBucketPicker}" />
<TextBlock Classes="mono" FontSize="13.5" FontWeight="Medium"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding RemotePath}" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding RemotePath}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5DB;" />
</Button>
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Command="{Binding DeleteRemoteCommand}"
IsEnabled="{Binding CanDeleteRemote}" ToolTip.Tip="Delete on the host">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
@@ -246,27 +364,22 @@
<!--
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
A row of its own rather than three more cells in the header above, and the reason is arithmetic
rather than taste: this pane is 381 pixels wide at the window's minimum, UP, REFRESH and DELETE
take most of that, and an account-at-host chip beside a DISCONNECT would have pushed one of them
off the edge. The layout suite would have caught it — which is the point of stating the number
here, so the next thing added to either row is measured against it rather than tried.
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
170-pixel chip this row used to carry would have taken most of that on its own.
Two things and a gap, and the gap is the point: the status line was tried here and does not fit.
What is left after a 170-pixel address and a DISCONNECT is about eighty pixels, which turns every
sentence into its first word and an ellipsis. It is at the foot of the screen instead — see the
queue's own strip, which has the width for one.
The status line still is not here, for the reason the comment this replaced gave: there is no room
for a sentence beside anything else this row holds. It is at the foot of the screen instead — see
the queue's own strip.
-->
<Border Grid.Row="1" Padding="12,6" Background="{StaticResource Raised}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
IsVisible="{Binding IsConnected}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="chip accent" MaxWidth="170">
<TextBlock Text="{Binding ConnectedTo}" TextTrimming="CharacterEllipsis" />
</Border>
<Button Grid.Column="2" Classes="ghost" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Grid>
<Button Classes="headerghost" HorizontalAlignment="Right" Content="DISCONNECT"
Command="{Binding DisconnectCommand}" />
</Border>
<!--
@@ -277,6 +390,11 @@
This is the strongest warning on any of these screens, and deliberately: everything else this
application deletes is a tombstone against a copy the server still has, and a file on somebody's
host is bytes with nothing behind them.
A WrapPanel for the two buttons rather than a horizontal StackPanel — v5b's own change, made once
the session shell's sidebar could take this pane down to 204 pixels: "DELETE ON THE HOST" and
CANCEL side by side want closer to 220, so at the narrow width they wrap onto their own line
instead of one of them going off the edge, which the WrapPanel achieves for free.
-->
<Border Grid.Row="2" Padding="12,10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
@@ -289,11 +407,12 @@
Text="{Binding PendingRemoteDeletion.FullPath}" />
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingRemoteDeletion.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="DELETE ON THE HOST"
<WrapPanel>
<Button Classes="danger" Content="DELETE ON THE HOST" Margin="0,0,8,4"
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
</StackPanel>
<Button Classes="ghost" Content="CANCEL" Margin="0,0,0,4"
Command="{Binding CancelDeleteRemoteCommand}" />
</WrapPanel>
</StackPanel>
</Border>
@@ -324,37 +443,48 @@
Making a directory sits here, beside the path it would be made in, rather than with the queue's
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
"that name is already taken" is a dead end.
v5b narrows the box from 140 to 90: at the session shell's own 204-pixel width once QUICK ACCESS
is showing, 140 plus MKDIR plus the spacing between them ran past the pane's own edge. Still wide
enough for a real directory name to be typed and read back, which is what this box is for; a
name that outgrows it scrolls inside the box the way every TextBox already does.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding IsConnected}">
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<TextBox Width="90" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
</StackPanel>
</Grid>
<Grid Grid.Row="4" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" />
<!--
v5b: PERMS keeps its own column — a real fact about a file on the host, dropped nowhere — but at
48 pixels rather than the drawer's own wider habit, and SIZE/MODIFIED shrink to match the local
pane's narrower numbers. See the local pane's own remark on why 204 rather than the design's 1920.
-->
<Grid Grid.Row="4" ColumnDefinitions="20,*,56,52,48" Margin="0,2,10,4">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" Margin="8,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="9.5" HorizontalAlignment="Right" />
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="9.5" HorizontalAlignment="Right" />
</Grid>
<ListBox Grid.Row="5" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
<ListBox Grid.Row="5" x:Name="RemoteList" Classes="filerows" ItemsSource="{Binding RemoteEntries}"
SelectedItem="{Binding SelectedRemoteEntry}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
<Border Grid.Column="0" Classes="rowmark" />
<Grid ColumnDefinitions="20,*,56,52,48" Height="38" Margin="14,0,10,0">
<TextBlock Grid.Column="0" Classes="entryicon" Text="{Binding IconGlyph}" />
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
Classes.exec="{Binding IsExecutable}"
Text="{Binding Name}" FontSize="12"
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
Text="{Binding Name}" FontSize="13.5"
Margin="10,0,8,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="2" Classes="mono entrysize" Text="{Binding Size}" FontSize="12"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono entrydate" Text="{Binding Modified}" FontSize="12"
Margin="8,0,0,0" HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
Text="{Binding Permissions}" FontSize="10.5" VerticalAlignment="Center" />
Text="{Binding Permissions}" FontSize="10" Margin="6,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
@@ -392,10 +522,11 @@
HorizontalAlignment="Center" VerticalAlignment="Center">
<!--
The mark the hosts screen puts on a group, at the size an empty state can carry one. This
application ships no icon set — see the note on colour at the top of this file — so a glyph in
a rounded square is what an icon is here, and is already the one that means "a place things
are kept".
The mark the hosts screen puts on a group, at the size an empty state can carry one. Still a
hand-picked glyph rather than one of the Material Icons codepoints the file rows now use — this
invitation predates the app's own IconFont and is outside wave C's restyle, which touched the
pane headers, the rows and the TRANSFERS strip and not this card — and ▤ already means "a place
things are kept" without borrowing from a font built for a different visual language.
-->
<Border Width="44" Height="44" CornerRadius="12" HorizontalAlignment="Center"
Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
@@ -545,94 +676,126 @@
</Grid>
<!-- ============ The queue ============ -->
<Border Grid.Row="1" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196">
<Grid RowDefinitions="Auto,*">
<!--
============ The TRANSFERS strip ============
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
DeepChrome and a top border, per SFTP.dc.html, in place of the plain Sidebar fill this used to carry —
the same surface the session shell's own header and status bar are painted in, which is what fuses the
strip into that frame rather than leaving it read as another pane. Two Borders share this row and
exactly one is ever visible: the design's own header — TRANSFERS plus a count chip — with the queue
underneath while there is one, and a single quiet line otherwise. That is the "collapse" wave C's own
brief asks for: not zero height, because the honest sentence that used to live inside the full strip
still has somewhere to be read, but far short of the full header-plus-rows shape.
<Border Grid.Row="0" Padding="12,7">
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" FontSize="10.5" Margin="10,0,0,0"
The design's own aggregate throughput readout — "8.4 MB/s" beside an upward arrow — is not drawn.
TransferRowViewModel.Progress computes a rate per transfer, off TransferSnapshot.BytesPerSecond, and
nothing anywhere in this queue sums those into one number for the whole strip; inventing one here would
be exactly the fabricated fact this project's honesty rule forbids. Recorded as a deviation rather than
silently dropped.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" MaxHeight="196" IsVisible="{Binding HasTransfers}">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="16,12">
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*">
<TextBlock Grid.Column="0" Classes="label" FontSize="10" Text="TRANSFERS" VerticalAlignment="Center" />
<!--
h18, radius 5, Chip fill with no border — the design's own count chip, distinct from Border.chip's
usual bordered-and-unfilled shape elsewhere in this application. ActiveTransfersLabel is
"N active", off TransfersViewModel.ActiveTransfers: queued counts as active there for the reason
its own remark gives, which matches what this chip is naming.
-->
<Border Grid.Column="1" Height="18" CornerRadius="5" Background="{StaticResource Chip}"
Padding="8,0" Margin="10,0,0,0" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="10.5" FontWeight="Medium"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="one at a time · nothing lands at its final name until it is complete" />
Text="{Binding ActiveTransfersLabel}" />
</Border>
</Grid>
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one.
<!--
◆ THE STATUS LINE, in the state where the remote pane has no room for one — unchanged in meaning
from the strip this replaced, only in paint. Right-aligned beside the queue's own "one at a time"
policy sentence, which stays for the reason it always did: the queue refuses to overwrite, and
nothing else on this screen says so.
-->
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="0,8,0,10">
<TextBlock Grid.Column="0" Classes="mono" FontSize="10.5" Foreground="{StaticResource TextGhost}"
Text="one at a time · nothing lands at its final name until it is complete" />
<TextBlock Grid.Column="1" Classes="hint" FontSize="11" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
It is here rather than beside DISCONNECT because this row spans the window and that one spans
half of it: what the screen has to say about a session is a sentence, and a sentence needs the
width. Only while something is open — the other half of the time it is inside the invitation
in the remote pane, next to the button that provoked it, which is where a refusal has to be.
<ScrollViewer Grid.Row="2">
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<!--
Per SFTP.dc.html: a 320-pixel ellipsized "source → destination" label, a slim progress track
and a right-aligned status word — see TransferRowViewModel.Label/StatusWord. STOP, RETRY/
RESUME and DISCARD are the app's own, kept per wave C's brief rather than dropped for having
no equivalent in the design's static mock: icon-only now, restyled to the pane headers' own
quiet-glyph idiom, with a tooltip carrying the word the button used to print.
-->
<Grid ColumnDefinitions="320,*,64,Auto" Margin="0,5" ColumnSpacing="14">
<TextBlock Grid.Column="0" Classes="mono" FontSize="12"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
Right-aligned in a free column, so it reads as this row's other end rather than as a third
clause of the sentence to its left.
-->
<TextBlock Grid.Column="2" Classes="hint" FontSize="11.5" Margin="16,0,0,0"
HorizontalAlignment="Right" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" TextWrapping="NoWrap"
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid>
</Border>
<ProgressBar Grid.Column="1" Classes="transferbar" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
ToolTip.Tip="{Binding Progress}" />
<ScrollViewer Grid.Row="1">
<StackPanel>
<TextBlock Classes="hint" FontSize="11.5" Margin="12,4,12,14"
IsVisible="{Binding !HasTransfers}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
<TextBlock Grid.Column="2" Classes="mono transferstatus"
Classes.running="{Binding IsInProgress}" Classes.done="{Binding IsDone}"
Classes.failed="{Binding HasFailed}" FontSize="12" FontWeight="Medium"
Text="{Binding StatusWord}" TextAlignment="Right" VerticalAlignment="Center" />
<ItemsControl ItemsSource="{Binding Transfers}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel">
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="12"
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
<StackPanel Grid.Column="1" Margin="0,0,8,0">
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="11.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</StackPanel>
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
Value="{Binding Percent}" VerticalAlignment="Center"
Foreground="{StaticResource Accent}"
Background="{StaticResource Raised}" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="10.5"
Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"
Foreground="{StaticResource TextDim}" />
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
<Border Classes="chip">
<TextBlock Text="{Binding StateLabel}" />
</Border>
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}" />
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
IsVisible="{Binding HasTransfers}" />
</StackPanel>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="4">
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding IsRunning}"
ToolTip.Tip="Stop this transfer"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5CD;" />
</Button>
<Button Classes="paneicon" Width="22" Height="22" IsVisible="{Binding CanRetry}"
ToolTip.Tip="{Binding RetryLabel}"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE5D5;" />
</Button>
<Button Classes="paneicon danger" Width="22" Height="22" IsVisible="{Binding IsFinished}"
ToolTip.Tip="Discard this row"
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
CommandParameter="{Binding}">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="13" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
</Grid>
</Border>
<!--
The collapsed shape: no header, no count chip, no rows — just the honest sentence the full strip
carried before anything was ever queued, kept per wave C's own brief rather than dropped along with the
rest of the strip's furniture.
-->
<Border Grid.Row="1" Background="{StaticResource DeepChrome}" BorderBrush="{StaticResource Border}"
BorderThickness="0,1,0,0" Padding="16,12" IsVisible="{Binding !HasTransfers}">
<TextBlock Classes="hint" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
</Border>
<!--
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
they arrive here on their own because this is a separate connection — a host trusted for a shell is
@@ -232,4 +232,69 @@
<FontFamily x:Key="MonoFont">avares://DodoSSH.Client.Shell/Assets/Fonts#JetBrains Mono,ui-monospace,Cascadia Mono,Consolas,monospace</FontFamily>
<FontFamily x:Key="IconFont">avares://DodoSSH.Client.Shell/Assets/Fonts#Material Icons</FontFamily>
<!--
── v5b ─────────────────────────────────────────────────────────────────────────────────────────────
A fidelity pass rather than a new design: the titlebar and the nav rail are redrawn closer to what
v3's own mock actually specifies, and the eight values below are what that redraw needed and did not
already have a name for. They arrived together because the source notes' table did, not because they
are one idea — read each on its own.
DeepChrome is one step darker than Chrome itself, #0B0B14 against #10111E, and it is what the titlebar
and the nav rail are painted in now instead of Chrome. The two used to share Chrome's value; the mock
draws the window's own furniture — the bar at the top, the rail down the side, the strip a session
sits in, a host's header row, the status bar at the foot — one shade below every screen it frames, so
the frame reads as the thing holding the glass rather than as another pane of it. Only the titlebar and
the rail are repainted in this pass; the session shell's header row and the status bar are wave B's.
Track is rgb(20,21,36), and it is not Hover: the two are close enough to be mistaken for a rounding
error and are not one. Hover, #14151E, is what a row goes to under the pointer everywhere this palette
already reached. Track is a half-step warmer and is what three specific things ask for instead — the
segmented switcher's own resting track, the rail row a pointer sits over, and (in wave B) a selected
file row — because the mock draws those three at a value Hover does not quite match, and "close enough"
is how a screenshot next to the mock finds the seam this file exists to prevent.
Pane is rgb(10,10,18), the file and terminal surface's replacement for TerminalSurface in this design:
darker and cooler rather than warmer, which is the same swap the surfaces above went through when v3
turned the whole palette near-black. Not renaming TerminalSurface itself, because nothing has repainted
a terminal pane yet — this key exists so wave B's SFTP panes have a name to reach for when they do.
SearchPill is the titlebar's own field, rgb(20,20,31) — close to Field's #101019 and a distinct value
the mock states for this one control, with BorderMid as its resting border and Accent replacing it
under the pointer. Kept separate from Field for the reason Raised and Chrome stay separate from each
other: the day the search pill needs to move again, a shared key would have to be pulled apart first.
KbdChip, rgb(35,35,58), is the fill behind the keyboard-shortcut hint inside that pill — CTRL K, not
the mock's ⌘K; see TitleBar.axaml. Distinct from Chip's #1A1A28 because the two are asked to sit near
each other in the one place both appear and a shared value would read as one shape rather than two.
Magenta, rgb(221,18,150), is SFTP's own colour: the top border of an active SFTP tab in wave B's session
shell, and the check square beside a shown vault in the rail's popover below. Named for what it looks
like rather than for a job, because it is about to do two unrelated ones and a name tied to either would
be wrong for the other — the same reasoning Chip's own name follows.
TerminalTabAccent, rgb(124,92,255), is a second read of the window's own Accent — close to #5D42DE and
not equal to it — reserved for exactly one thing: the top border of an active terminal tab in wave B's
session shell, so SSH's own tab reads as a different colour from SFTP's magenta one at a glance. Added
now, alongside the rest of this table, rather than when wave B first draws a tab; it is unused until
then, which is stated here so the next reader does not go looking for what has not been wired up yet.
AvatarGradient is the rail's own user chip: a 134.668-degree sweep through three violets, #4A37A9 into
#8D51E7 into #AB49EC, behind the two-letter initials the chip draws from whichever name is actually
signed in. A LinearGradientBrush rather than three stops guessed at a round angle, because Avalonia's
gradient axis is start-point-to-end-point rather than degrees, and the coordinates below are what the
mock's own angle resolves to.
-->
<SolidColorBrush x:Key="DeepChrome" Color="#0B0B14" />
<SolidColorBrush x:Key="Track" Color="#141524" />
<SolidColorBrush x:Key="Pane" Color="#0A0A12" />
<SolidColorBrush x:Key="SearchPill" Color="#14141F" />
<SolidColorBrush x:Key="KbdChip" Color="#23233A" />
<SolidColorBrush x:Key="Magenta" Color="#DD1296" />
<SolidColorBrush x:Key="TerminalTabAccent" Color="#7C5CFF" />
<LinearGradientBrush x:Key="AvatarGradient" StartPoint="14%,0%" EndPoint="86%,100%">
<GradientStop Color="#4A37A9" Offset="0" />
<GradientStop Color="#8D51E7" Offset="0.46" />
<GradientStop Color="#AB49EC" Offset="1" />
</LinearGradientBrush>
</ResourceDictionary>
@@ -114,6 +114,12 @@ internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry
_ => "changed",
};
/// <summary>── v5b ── Whether the WHAT chip should draw in its "created" colour.</summary>
internal bool IsCreated => entry.Secret.Operation is ActivityOperation.Created;
/// <summary>── v5b ── Whether the WHAT chip should draw in its "deleted" colour.</summary>
internal bool IsDeleted => entry.Secret.Operation is ActivityOperation.Deleted;
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
@@ -192,6 +198,24 @@ internal sealed partial class LogsViewModel : ObservableObject
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
+ "names of the fields that changed, never their contents.";
/// <summary>
/// ── v5b ── The mono sentence Logs.dc.html prints beside REFRESH: what one row of the showing log
/// actually records.
/// </summary>
/// <remarks>
/// Per section rather than one line for the screen, and drawn from the same fact this type's own header
/// remarks already state: an entry is written once, at close, for a connection; and once per write, per
/// device, for an activity row. Not <see cref="Status"/> itself — that is a refresh outcome, cleared to
/// empty on success — so <see cref="HeaderStatusLine"/> is the one the header actually binds, showing an
/// error over this fact when there is one to show.
/// </remarks>
internal string SectionFact => Section is LogSection.Connections
? "an entry is written once, when a connection closes"
: "one row per write, per device";
/// <summary>What the header's own status line shows: an error, if refreshing just produced one, else the fact.</summary>
internal string HeaderStatusLine => Status.Length > 0 ? Status : SectionFact;
/// <summary>Shows one of the two logs.</summary>
[RelayCommand]
private void ShowSection(LogSection section) => Section = section;
@@ -296,7 +320,11 @@ internal sealed partial class LogsViewModel : ObservableObject
OnPropertyChanged(nameof(ShowsConnections));
OnPropertyChanged(nameof(ShowsActivity));
OnPropertyChanged(nameof(EmptyMessage));
OnPropertyChanged(nameof(SectionFact));
OnPropertyChanged(nameof(HeaderStatusLine));
}
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(HeaderStatusLine));
}
/// <summary>A connection that is open right now.</summary>
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -17,11 +18,13 @@ using DodoSSH.Crypto;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One vault, as a switch in the tab strip's vault menu.</summary>
/// <summary>One vault, as a switch in the rail's user popover.</summary>
/// <remarks>
/// A record rebuilt per change rather than an observable row, which is the idiom the rest of these lists
/// use: the menu is short, it is rebuilt whenever anything about the vault list moves, and a row with a
/// settable property would be a second copy of a fact the cache already holds.
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
/// of a fact the cache already holds.
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
@@ -37,6 +40,14 @@ internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPe
/// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
/// <remarks>
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
/// letter is the honest half of the same badge.
/// </remarks>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
/// <summary>Whether this vault can be switched off.</summary>
/// <remarks>
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
@@ -328,6 +339,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
private CancellationTokenSource? lastConnectedTick;
/// <summary>
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
/// </summary>
/// <remarks>
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
/// case nothing else does: sitting still on a connected screen while a minute passes.
/// </remarks>
private readonly CancellationTokenSource sessionElapsedTick = new();
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
@@ -449,6 +474,26 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
_ = TellRendererTheFontSizeAsync();
StartSessionShellTracking();
}
/// <summary>
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
/// </summary>
/// <remarks>
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
/// </remarks>
private void StartSessionShellTracking()
{
transfers.PropertyChanged += OnTransfersPropertyChanged;
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>
@@ -602,6 +647,65 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? accountName;
/// <summary>
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
/// </summary>
/// <remarks>
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
/// never invented from the subject or the display name the way a naive fallback would.
/// </remarks>
[ObservableProperty]
private string? email;
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
/// <remarks>
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
/// mock's own two-letter shape.
/// </remarks>
internal string AvatarInitials
{
get
{
if (string.IsNullOrWhiteSpace(AccountName))
{
return string.Empty;
}
var words = AccountName.Split(
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
return words switch
{
[] => string.Empty,
[var only] => only[..1].ToUpperInvariant(),
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
};
}
}
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
/// <summary>
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
/// </summary>
/// <remarks>
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
/// did not.
/// </remarks>
private void AdoptIdentity(string? displayName, string? emailAddress, string subject)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
}
[ObservableProperty]
private VaultViewModel? vault;
@@ -1236,62 +1340,90 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.ManualStatus = string.Empty;
}
// ---- The desktop's fixed tabs ----
// ---- The rail's own page grouping ----
/// <summary>
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
/// </summary>
/// <remarks>
/// <para>
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
/// which is the screen you are looking at.
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
/// </para>
/// <para>
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
/// </para>
/// <para>
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the nine screens underneath this tab.</b> The
/// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated:
/// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal".
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
/// <summary>
/// Which page the Vaults tab returns to.
/// </summary>
/// <remarks>
/// <para>
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
/// screen.
/// </para>
/// <para>
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
/// worth stating because the two look alike. That one would have been a second copy of "which page",
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
/// remembering its page, the way any tab does — and nothing else reads it.
/// </para>
/// </remarks>
private ShellScreen vaultsScreen = ShellScreen.Hosts;
// ---- The rail's segmented switcher and its mode-dependent first entry ----
//
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
/// <remarks>
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
/// used to treat both as "not SFTP, not S3".
/// </remarks>
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
/// <remarks>
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
/// straight off the same three flags the switcher above lights, so the row and the segment can never
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
/// for the matching glyph and the command the row runs.
/// </remarks>
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
/// <summary>
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
/// </remarks>
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
/// <summary>Runs whichever of the three the row is currently naming.</summary>
/// <remarks>
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
/// answer to, so asking again here would be a second place those three facts could disagree.
/// </remarks>
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
private void ShowFirstRailItem()
{
if (IsBucketsShowing)
{
ShowFiles(RemoteKind.Bucket);
}
else if (IsTransfersShowing)
{
ShowFiles(RemoteKind.Host);
}
else
{
ShowTerminal();
}
}
// ---- Which vaults this window is showing ----
@@ -1299,15 +1431,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
/// </summary>
/// <remarks>
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it is in the
/// tab strip, which is this view model's, and the screens that read it are that one's. Rebuilt per
/// unlock: it is read out of the cache the session opened, so it cannot outlive the session any more
/// than the keyring can.
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it the
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
/// opened, so it cannot outlive the session any more than the keyring can.
/// </remarks>
private VaultVisibility? visibility;
/// <summary>
/// One switch per readable vault, for the menu on the Vaults tab.
/// One switch per readable vault, for the rail's user popover.
/// </summary>
/// <remarks>
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
@@ -1779,7 +1911,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
@@ -1840,7 +1972,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
@@ -2527,6 +2659,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
/// <summary>Starts a sign-out from the rail's user popover, from wherever the window is showing.</summary>
/// <remarks>
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
/// inline on the Preferences screen while the vault is unlocked — see <c>PreferencesScreen.axaml</c> —
/// and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
/// nothing on screen to show it — a card raised nobody can see. Going to Preferences first is what the
/// popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
ShowScreen(ShellScreen.Preferences);
SignOut();
}
/// <summary>
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
/// </summary>
@@ -2593,6 +2743,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
LiveSessionCount = workspace.LiveSessionCount;
AccountName = null;
Email = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
@@ -2652,12 +2803,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
// object would otherwise leak.
StopLastConnectedTick();
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
sessionElapsedTick.Dispose();
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
@@ -2984,13 +3141,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow() };
AdoptTab(adopted);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
tab.StartedAt = clock.GetUtcNow();
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
@@ -3214,7 +3376,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the pin strip's contents.
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
/// </summary>
/// <remarks>
/// <para>
@@ -3225,7 +3387,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para>
/// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
/// of its own — every place a dot can go stale is a place the strip can too, so folding the two into one
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
/// sites for the list of moments that counts as.
/// </para>
@@ -3236,7 +3398,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
ActiveTabPinnedPaths.Clear();
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
return;
}
@@ -3247,8 +3409,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
// The active tab's host, and only when it is actually connected: the strip is for a session that
// is open, not for whichever machine's tab happens to be selected while it is still dialling.
// The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
@@ -3268,39 +3430,58 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
OnPropertyChanged(nameof(ShowsPinStrip));
RaiseSessionState();
}
/// <summary>
/// The paths pinned on the connected host behind the selected tab, for the chip row above the terminal.
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
/// </summary>
/// <remarks>
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled.
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
/// only where they are drawn.
/// </remarks>
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
/// <summary>Whether the active tab's host has anything for the pin strip to draw.</summary>
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
/// <summary>
/// Whether the pin strip draws at all.
/// Whether the v5b session sidebar draws at all.
/// </summary>
/// <remarks>
/// Terminal surface and nothing else: <see cref="ActiveTabPinnedPaths"/> is keyed to the selected tab
/// rather than to which surface is showing, so without this the strip would sit over a page screen on
/// the rare frame between selecting a terminal tab and navigating away from it.
/// <para>
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface &amp;&amp; HasActiveTabPinnedPaths</c>
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
/// own "hides when no session is active".
/// </para>
/// <para>
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
/// </para>
/// </remarks>
internal bool ShowsPinStrip => IsTerminalSurface && HasActiveTabPinnedPaths;
internal bool ShowsQuickAccessSidebar =>
(IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected);
/// <summary>
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
/// </summary>
/// <remarks>
/// The pin strip's click handler. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the chip is there to click.
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
@@ -3316,6 +3497,271 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
}
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
/// <summary>
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
/// </summary>
/// <remarks>
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
/// underneath it agrees what it is editing.
/// </remarks>
[RelayCommand]
private void AddSnippetFromSidebar()
{
ShowScreen(ShellScreen.Snippets);
SnippetsScreen?.NewCommand.Execute(null);
}
/// <summary>
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
/// </summary>
/// <remarks>
/// <para>
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
/// writing to the renderer directly — that command already carries the whole safety story the snippets
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
/// that runs. A second insert path here would be a second place that story could go stale.
/// </para>
/// <para>
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
/// </para>
/// </remarks>
[RelayCommand]
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
{
if (SnippetsScreen is not { } screen || snip is null)
{
return;
}
screen.Selected = snip;
if (screen.CanInsert)
{
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
return;
}
ShowScreen(ShellScreen.Snippets);
}
/// <summary>
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
/// </summary>
/// <remarks>
/// The closest honest affordance rather than a new one: this application has no way to open the host
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
/// </remarks>
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
ShowScreen(ShellScreen.Hosts);
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>
/// <remarks>
/// <para>
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
/// it and land the remote pane there.
/// </para>
/// <para>
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
/// </para>
/// </remarks>
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
SelectedTab = tab;
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
}
/// <summary>
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
/// </summary>
/// <remarks>
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
/// </remarks>
[RelayCommand]
private async Task OpenTerminalForFilesHostAsync()
{
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
{
return;
}
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>
/// The account and endpoint the session shell's header and status bar are about right now, or null when
/// neither surface has one.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
/// </remarks>
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
_ => null,
};
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
/// <remarks>
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
/// that is still connecting has an address — it is what the connecting card names — but no live shell
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
/// </remarks>
internal bool IsSessionConnected => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IsLive is true,
_ when IsTransfersShowing => Transfers.IsConnected,
_ => false,
};
/// <summary>
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
/// </summary>
/// <remarks>
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
/// </remarks>
internal string? SessionElapsedText
{
get
{
var startedAt = Surface switch
{
ShellSurface.Terminal => SelectedTab?.StartedAt,
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
_ => null,
};
if (startedAt is not { } started)
{
return null;
}
var elapsed = clock.GetUtcNow() - started;
if (elapsed < TimeSpan.Zero)
{
// The clock this ran on and the clock the session opened on can disagree by a hair when both
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
// rather than a session that has not started yet.
elapsed = TimeSpan.Zero;
}
return string.Create(
CultureInfo.InvariantCulture,
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
}
}
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
/// <remarks>
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
/// </remarks>
private void RaiseSessionState()
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
}
/// <remarks>
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
/// fact the header, the status bar or the SFTP tab row's active mark reads.
/// </remarks>
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo))
{
RaiseSessionState();
}
}
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
OnPropertyChanged(nameof(SessionElapsedText));
}
}
catch (OperationCanceledException)
{
// The shell is closing; see DisposeAsync.
}
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
@@ -3355,12 +3801,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
RaiseSurfaceState();
// What the Vaults tab comes back to; see the field.
if (IsVaultsPage(value))
{
vaultsScreen = value;
}
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
@@ -3633,11 +4073,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private void RaiseSurfaceState()
{
// ShowsPinStrip reads IsTerminalSurface, and Surface moves through here rather than through
// RaiseTerminalState — see OnSurfaceChanged. Without this the strip's binding would go stale the
// moment somebody navigated off a terminal tab to a page screen, even though the property itself
// would answer correctly the next time anything else asked it.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
// off a terminal tab to a page screen, even though the property itself would answer correctly the
// next time anything else asked it.
RaiseSessionState();
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
@@ -3665,6 +4106,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
// The rail's switcher and its mode-dependent first row read the same three flags above, so
// whatever moved them has to repaint these too — see the remarks on each.
OnPropertyChanged(nameof(IsSshShowing));
OnPropertyChanged(nameof(FirstRailItemLabel));
OnPropertyChanged(nameof(FirstRailItemIcon));
RaiseTerminalState();
}
@@ -3684,9 +4131,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsConnectingShowing));
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
// ShowsPinStrip reads IsTerminalSurface too, so anything that moves the surface has to repaint it —
// otherwise the strip could stay drawn over a page reached by clicking away from a terminal tab.
OnPropertyChanged(nameof(ShowsPinStrip));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
// page reached by clicking away from a terminal tab.
RaiseSessionState();
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
@@ -187,11 +187,34 @@ internal sealed partial class SnippetsViewModel : ObservableObject
/// <summary>Whether the buttons under the selected snippet are showing.</summary>
/// <remarks>
/// Off while the move panel is up, which takes their place — the same rule the host pane's
/// <c>ShowsHostPaneActions</c> carries, and for the same reason: the button that opened the panel would
/// otherwise still be there offering to open it again.
/// Off while the move panel or the delete confirmation is up, either of which takes their place — the
/// same rule the host pane's <c>ShowsHostPaneActions</c> carries, and for the same reason: the button
/// that opened one would otherwise still be there offering to open it again.
/// </remarks>
internal bool ShowsSelectionActions => HasSelection && !IsMoving;
internal bool ShowsSelectionActions => HasSelection && !IsMoving && !IsConfirmingDelete;
/// <summary>
/// ── v5b ── Whether the desktop's own delete confirmation card is up.
/// </summary>
/// <remarks>See <see cref="RequestDelete"/> for why this exists beside <see cref="DeleteAsync"/> rather than in it.</remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsSelectionActions))]
private bool isConfirmingDelete;
/// <summary>Which snippet the open confirmation card is about.</summary>
private SnippetRowViewModel? pendingDelete;
/// <summary>The confirmation card's own question, naming the snippet.</summary>
internal string DeleteQuestion => pendingDelete is { } row ? $"Delete \"{row.Label}\"?" : string.Empty;
/// <summary>
/// The confirmation card's own consequence line — vault-wide reach, a tombstone, no undo — in the same
/// words <c>VaultViewModel.HowFarADeletionGoes</c> uses for every other kind of item.
/// </summary>
internal string DeleteConsequence => pendingDelete is { } row
? $"This snippet lives in the {row.VaultName} vault — deleting removes it for everyone who holds "
+ "that key. This writes a tombstone; there is no undo."
: string.Empty;
/// <summary>The vault the selected snippet lives in, named, or empty when there is only one.</summary>
/// <remarks>
@@ -334,6 +357,14 @@ internal sealed partial class SnippetsViewModel : ObservableObject
}
/// <summary>Deletes the selected snippet.</summary>
/// <remarks>
/// Unchanged and still reachable on its own — the phone's DELETE row still calls this directly, full
/// width and below EDIT and MOVE, with no question in front of it; see the phone's own
/// <c>SnippetsScreen.axaml</c> remarks for why a counted confirmation was never drawn there. The
/// desktop's own DELETE goes through <see cref="RequestDelete"/> instead — see that command's remarks —
/// so this one command now serves two different confirmations rather than the desktop growing a second
/// deletion path.
/// </remarks>
[RelayCommand]
private async Task DeleteAsync(CancellationToken cancellationToken)
{
@@ -347,6 +378,55 @@ internal sealed partial class SnippetsViewModel : ObservableObject
Status = vault.Status;
}
/// <summary>
/// ── v5b ── Asks before deleting, the way every other keychain item's DELETE already does.
/// </summary>
/// <remarks>
/// Additive rather than a change to <see cref="DeleteAsync"/> itself: that command is what the phone's
/// own DELETE row still calls, uncounted, and repurposing it here would leave a phone build with a
/// button that arms a card nothing on that screen ever draws — a delete that silently stopped working.
/// The desktop's own confirmation lives here instead, mirroring <c>VaultViewModel.PendingDeletion</c>'s
/// shape without touching that type: a snippet has no <see cref="DeletionTarget"/> of its own to add
/// there, and Snippets.dc.html's own modal copy — vault-wide reach, a tombstone, no undo — is exactly
/// what <c>HowFarADeletionGoes</c> already says for every other kind, said here in the same words.
/// </remarks>
[RelayCommand]
private void RequestDelete()
{
if (Selected is not { } row)
{
return;
}
pendingDelete = row;
IsConfirmingDelete = true;
}
/// <summary>Carries out the deletion the confirm card just agreed to.</summary>
[RelayCommand]
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
{
if (pendingDelete is not { } row)
{
return;
}
IsConfirmingDelete = false;
pendingDelete = null;
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
Status = vault.Status;
}
/// <summary>Thinks better of it.</summary>
[RelayCommand]
private void CancelDelete()
{
IsConfirmingDelete = false;
pendingDelete = null;
}
/// <summary>
/// Opens the panel that asks which vault the selected snippet should move to.
/// </summary>
@@ -515,6 +595,15 @@ internal sealed partial class SnippetsViewModel : ObservableObject
CloseMovePanel();
}
// The confirmation card folds away with the selection it was opened about, the same rule the move
// panel above follows and for the same reason: a filter or a reload that moved the selection
// elsewhere would otherwise leave a "delete this?" card pointed at a row nobody is looking at.
if (IsConfirmingDelete && value?.EntityId != pendingDelete?.EntityId)
{
IsConfirmingDelete = false;
pendingDelete = null;
}
OnPropertyChanged(nameof(HasSelection));
OnPropertyChanged(nameof(CanInsert));
OnPropertyChanged(nameof(SelectionRuns));
@@ -88,6 +88,20 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; }
/// <summary>
/// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is
/// none.
/// </summary>
/// <remarks>
/// Set by <c>MainWindowViewModel</c> from its own clock at the same moment it calls <see cref="Opened"/>
/// or constructs a tab that already carries a session id, rather than read here from
/// <see cref="TimeProvider.System"/> directly — a view model with its own notion of "now" is a second
/// clock the shell's tests would have no way to control. Left on a tab whose session has since ended:
/// the pane still holds the scrollback, and "how long that shell ran" stays a true fact about it after it
/// stops being live.
/// </remarks>
internal DateTimeOffset? StartedAt { get; set; }
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -40,6 +40,15 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
internal bool IsFile => entry.Kind is SftpEntryKind.File;
/// <summary>The row's own Material Icons glyph — folder for a directory, a document for a file.</summary>
/// <remarks>
/// The design's own sample data asks for "draft", which is a Material Symbols glyph this application's
/// embedded classic Material Icons font does not contain — see TransfersScreen.axaml's own remark on the
/// substitution. insert_drive_file is the closest classic equivalent: a plain document rather than one
/// with a folded corner, but a file rather than a folder is the fact this glyph exists to carry.
/// </remarks>
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
/// <remarks>
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
@@ -84,6 +93,9 @@ internal sealed class LocalEntryRowViewModel(LocalEntry entry)
internal bool IsFile => !entry.IsDirectory;
/// <inheritdoc cref="RemoteEntryRowViewModel.IconGlyph" />
internal string IconGlyph => IsNavigable ? "\uE2C7" : "\uE24D";
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
@@ -132,8 +144,64 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
? Transfer.RemotePath
: Transfer.LocalPath;
/// <summary>
/// "source → destination directory", the v5b TRANSFERS strip's own single-line label.
/// </summary>
/// <remarks>
/// Built from real paths this snapshot already carries rather than a shortened form invented for the
/// strip: the source is the file name at whichever end the transfer reads from, and the destination is
/// the whole directory it is written into — <see cref="SftpPath.Parent"/> for an upload, and
/// <see cref="System.IO.Path.GetDirectoryName(string)"/> for a download, because the two ends are a
/// remote path and a local one and this codebase does not run the BCL's path helpers on the former; see
/// <see cref="SftpPath"/>'s own remark on why. Neither end is trimmed to a last segment the way the
/// design's own sample data is — a directory two levels down would read as the same word as its parent —
/// so the strip's own 320-pixel column and <c>TextTrimming="CharacterEllipsis"</c> carry the length
/// instead.
/// </remarks>
internal string Label
{
get
{
var (sourcePath, destinationDirectory) = Transfer.Direction is TransferDirection.Upload
? (Transfer.LocalPath, SftpPath.Parent(Transfer.RemotePath))
: (Transfer.RemotePath, System.IO.Path.GetDirectoryName(Transfer.LocalPath) ?? string.Empty);
// Path.GetFileName reads either separator on Windows, so a remote path's forward slashes need no
// translation to name the file at the end of it.
var source = System.IO.Path.GetFileName(sourcePath);
return $"{source} → {destinationDirectory}";
}
}
internal double Percent => Transfer.Fraction * 100;
/// <summary>
/// The v5b TRANSFERS strip's own right-aligned status word: a state, or a live percentage while running.
/// </summary>
/// <remarks>
/// Not <see cref="Progress"/>, which stays for the row's own tooltip: that string carries the bytes and
/// the rate together, which is the sentence somebody reads on purpose, and a status column has room for a
/// word rather than a sentence. A running transfer's word is its own percentage rather than "running",
/// because the strip's slim progress bar already says "in motion" and the number beside it is the fact
/// the bar alone cannot state exactly.
/// </remarks>
internal string StatusWord => Transfer.State switch
{
TransferState.Queued => "queued",
TransferState.Running => string.Create(
CultureInfo.InvariantCulture, $"{(int)Percent}%"),
TransferState.Completed => "done",
TransferState.Cancelled => "stopped",
_ => "failed",
};
/// <summary>Whether the status word should read as the strip's own "in motion" colour.</summary>
internal bool IsInProgress => Transfer.State is TransferState.Running;
/// <summary>Whether the status word should read as the strip's own "finished, kept" colour.</summary>
internal bool IsDone => Transfer.State is TransferState.Completed;
/// <summary>
/// What the row says about where it has got to.
/// </summary>
@@ -192,6 +260,9 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
OnPropertyChanged(nameof(Progress));
OnPropertyChanged(nameof(Percent));
OnPropertyChanged(nameof(StateLabel));
OnPropertyChanged(nameof(StatusWord));
OnPropertyChanged(nameof(IsInProgress));
OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsFinished));
OnPropertyChanged(nameof(CanResume));
@@ -297,6 +368,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
/// <summary>
/// When the open connection was made, for the v5b session shell's status bar — or null while nothing is
/// connected.
/// </summary>
/// <remarks>
/// A read of <see cref="connected"/> rather than a field of its own: that tuple is already the one place
/// this screen keeps "what is open and when it opened", written at the same two call sites — a host and a
/// bucket — that set <see cref="ConnectedTo"/>. A second field would be a second fact to keep in step with
/// the first, for no reader that could not already reach it here.
/// </remarks>
internal DateTimeOffset? ConnectedStartedAt => connected?.StartedAt;
private bool disposed;
/// <param name="sftp">Opens SFTP sessions.</param>
@@ -342,7 +425,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// which is the life of the process.
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
Transfers.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(HasTransfers));
OnPropertyChanged(nameof(ActiveTransfersLabel));
};
// The fourth, and it follows a list this screen does not own: the buckets are refilled from the
// vault on every synchronisation pass, so the invitation these flags decide between has to change
@@ -619,6 +706,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// </remarks>
internal int ActiveTransfers => Transfers.Count(row => row.IsRunning);
/// <summary>
/// The v5b TRANSFERS strip's own count chip: "N active", off <see cref="ActiveTransfers"/>.
/// </summary>
/// <remarks>
/// A string of its own rather than a converter in the markup, for the reason every other label in this
/// class is one: the word belongs beside the number it is stated for, in one place, rather than composed
/// out of a format string a screen's own XAML would otherwise have to carry. It reads "0 active" rather
/// than hiding at zero — the chip only exists at all while <see cref="HasTransfers"/> is true, which a
/// queue holding nothing but finished or failed rows still is, and "0 active" is the honest word for
/// that shape rather than a chip claiming activity that has already finished.
/// </remarks>
internal string ActiveTransfersLabel => string.Create(
CultureInfo.InvariantCulture, $"{ActiveTransfers} active");
internal bool HasTransfers => Transfers.Count > 0;
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
@@ -1653,6 +1754,11 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
Transfers.Add(new TransferRowViewModel(e.Transfer));
}
// Not covered by the CollectionChanged subscription above: a transfer that finishes changes which
// rows count as active without the collection itself gaining or losing a row, so the strip's own
// count chip needs its own raise here.
OnPropertyChanged(nameof(ActiveTransfersLabel));
ActivityChanged?.Invoke(this, EventArgs.Empty);
});
@@ -749,6 +749,15 @@ internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vault
internal string Label => key.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
internal string Description => key.Secret switch
{
@@ -823,6 +832,15 @@ internal sealed class CredentialRowViewModel(
internal string Label => credential.Secret.Label;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>What the list shows under the name: the account, never the password.</summary>
/// <remarks>
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
@@ -1101,8 +1119,66 @@ internal sealed record VaultItemRowViewModel(
{
/// <summary>Whether this row has anything to say about its sync state.</summary>
internal bool HasBadge => Badge.Length > 0;
/// <summary>
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <remarks>
/// Additive: every other kind of row in this application already carries this fact (see
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
/// nothing does or the kind has no such fact.
/// </summary>
/// <remarks>
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
/// showing a region or an endpoint under a column that promises "who uses this".
/// </remarks>
internal string UsedBySummary { get; init; } = string.Empty;
/// <summary>Whether this row has anything to say about who uses it.</summary>
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
/// <summary>
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
/// </summary>
/// <remarks>
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
/// carries no algorithm field (see docs/design-import-gaps.md).
/// </remarks>
internal string IconGlyph => Kind switch
{
VaultItemKind.Key => "",
VaultItemKind.Credential => "",
VaultItemKind.Tag => "",
VaultItemKind.ObjectStore => "",
_ => string.Empty,
};
}
/// <summary>
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
/// </summary>
/// <param name="Label">The host's name.</param>
/// <param name="IsConnected">
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
/// </param>
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
internal enum DeletionTarget
{
@@ -2108,6 +2184,19 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
/// <summary>
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
/// </summary>
/// <remarks>
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
/// that does nothing, on the same "narrows the list and nothing else" reasoning
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
/// so what matches is never a surprise to whoever typed it.
/// </remarks>
[ObservableProperty]
private string itemFilter = string.Empty;
/// <summary>
/// The selected row of the vault table.
/// </summary>
@@ -2171,6 +2260,31 @@ internal sealed partial class VaultViewModel(
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
/// </summary>
/// <remarks>
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
/// no binding concept to scan at all.
/// </remarks>
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
{
0 => string.Empty,
1 => "in use · 1 host",
var n => $"in use · {n} hosts",
};
/// <summary>Whether the "in use" chip has anything to say.</summary>
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
/// <summary>Whether the selected row is one with an editor behind it.</summary>
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
@@ -5272,7 +5386,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<SshKeyRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.SshKeys
.ListAsync(vault.VaultId, cancellationToken)
@@ -5281,7 +5398,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Keys.Clear();
@@ -5312,7 +5433,10 @@ internal sealed partial class VaultViewModel(
var unreadable = 0;
var rows = new List<CredentialRowViewModel>();
foreach (var vault in session.ReadableVaults)
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.Credentials
.ListAsync(vault.VaultId, cancellationToken)
@@ -5321,7 +5445,11 @@ internal sealed partial class VaultViewModel(
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Credentials.Clear();
@@ -12017,7 +12145,12 @@ internal sealed partial class VaultViewModel(
"TAG",
tag.Description,
tag.Badge,
tag.HasUnsyncedChanges));
tag.HasUnsyncedChanges)
{
// A tag's own "used by" is the host count it already carries — the same fact
// Description prints, so the two never disagree.
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
});
}
}
@@ -12047,6 +12180,61 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
/// </summary>
/// <remarks>
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
/// </remarks>
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
{
var names = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind && binding.EntityId == entityId)
.Select(row => row.Label)
.ToList();
return names.Count switch
{
0 => string.Empty,
1 => names[0],
2 => $"{names[0]}, {names[1]}",
_ => $"{names[0]} +{names.Count - 1}",
};
}
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
/// the filter has no opinion about which kind a row is.
/// </remarks>
private void ApplyItemFilter()
{
var needle = ItemFilter.Trim();
if (needle.Length == 0)
{
return;
}
for (var i = VaultItems.Count - 1; i >= 0; i--)
{
var row = VaultItems[i];
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
{
VaultItems.RemoveAt(i);
}
}
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
}
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
@@ -12078,7 +12266,11 @@ internal sealed partial class VaultViewModel(
"SSH KEY",
key.Description,
key.Badge,
key.HasUnsyncedChanges));
key.HasUnsyncedChanges)
{
VaultBadge = key.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
});
}
}
@@ -12093,12 +12285,17 @@ internal sealed partial class VaultViewModel(
"PASSWORD",
credential.Description,
credential.Badge,
credential.HasUnsyncedChanges));
credential.HasUnsyncedChanges)
{
VaultBadge = credential.VaultBadge,
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
});
}
}
AddTagRows();
AddBucketRows();
ApplyItemFilter();
// The selection survives a reload, as every other list's does, and for the same reason: a background
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
@@ -12127,6 +12324,8 @@ internal sealed partial class VaultViewModel(
OnPropertyChanged(nameof(SelectedDetailHeading));
OnPropertyChanged(nameof(ShowsItemActions));
RebuildSelectedItemUsage(value);
// Every kind this table can delete, because one selection covers all of their lists.
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
@@ -12168,6 +12367,39 @@ internal sealed partial class VaultViewModel(
}
}
/// <summary>
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
/// </summary>
/// <remarks>
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
/// </remarks>
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
{
SelectedItemUsedByHosts.Clear();
ResolvedBindingKind? kind = value?.Kind switch
{
VaultItemKind.Key => ResolvedBindingKind.SshKey,
VaultItemKind.Credential => ResolvedBindingKind.Credential,
_ => null,
};
if (kind is { } resolvedKind && value is not null)
{
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
{
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
}
}
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
OnPropertyChanged(nameof(SelectedItemInUseSummary));
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
}
/// <remarks>
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
@@ -12177,6 +12409,9 @@ internal sealed partial class VaultViewModel(
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
partial void OnUnreadableItemsChanged(int value)
{
OnPropertyChanged(nameof(HasUnreadableItems));
@@ -30,11 +30,16 @@ internal static class LayoutHarness
/// Taken from <c>MainWindow.axaml</c>'s <c>MinWidth</c>/<c>MinHeight</c> by hand. A test asserts these
/// two constants still match the XAML, so the harness cannot quietly start measuring a window larger
/// than the one a user is allowed to drag to.
///
/// v5b: 1016x574 became 1081x583, exactly what the titlebar's and the rail's own fidelity passes added —
/// see <see cref="TitleBarHeight"/>, <see cref="NavRailWidth"/> and the matching remark in
/// <c>MainWindow.axaml</c>. <see cref="ScreenWidth"/> and <see cref="ScreenHeight"/> are both unchanged
/// by the move, because the minimum grew by exactly what the two grew by.
/// </remarks>
internal const double MinimumWidth = 1016;
internal const double MinimumWidth = 1081;
/// <inheritdoc cref="MinimumWidth" />
internal const double MinimumHeight = 574;
internal const double MinimumHeight = 583;
/// <summary>The hosts drawer's fixed width, from <c>HostDrawer.axaml</c>.</summary>
/// <remarks>
@@ -47,33 +52,95 @@ internal static class LayoutHarness
internal const double HostDrawerWidth = 320;
/// <summary>The nav rail's fixed width, from <c>NavRail.axaml</c>.</summary>
internal const double NavRailWidth = 190;
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
/// <summary>
/// What the titlebar, the tab strip and the status bar take off the window before any screen gets a
/// pixel.
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
/// <remarks>
/// All three are fixed heights declared in their own markup — 44, 42 and 24 — rather than shapes that
/// grow with their contents, which is what makes stating them here honest. Three tests hold the three
/// controls to those numbers, so the budget below cannot drift away from what the window actually
/// leaves.
/// Both are fixed heights declared in their own markup — 53 and 24 — rather than shapes that grow with
/// their contents, which is what makes stating them here honest. A test holds each control to its own
/// number, so the budget below cannot drift away from what the window actually leaves.
///
/// v5b: the titlebar's own 44 became 53, the design's own height; see <see cref="MinimumHeight"/> for
/// the matching rise that keeps every screen below it the same size it always measured.
///
/// A third constant, <c>TerminalTabsHeight</c>, stood beside these two through v5b's chrome wave: the
/// window-wide tab strip that used to sit above every screen, 42 pixels, whether or not there were any
/// tabs to draw. v5b's session-shell wave retires that strip — see <c>MainWindow.axaml</c>'s own remark
/// on where a session's tabs live now — and with it the constant: <see cref="ScreenHeight"/> no longer
/// subtracts anything for a row that no longer exists as chrome above every screen. The tab row itself
/// is now inside the two screens that carry one, at its own 38-pixel height; see
/// <see cref="SessionTabRowHeight"/>, which only those two screens' own budgets pay.
/// </remarks>
internal const double TitleBarHeight = 44;
internal const double TitleBarHeight = 53;
/// <inheritdoc cref="TitleBarHeight" />
internal const double StatusBarHeight = 24;
/// <summary>The v5b session shell's own tab row, from <c>App.axaml</c>'s <c>Button.sesstab</c> rule.</summary>
/// <remarks>
/// Not part of <see cref="ScreenHeight"/>'s budget, unlike the retired window-wide strip this replaced:
/// only the terminal and SFTP surfaces pay it, out of their own 26-pixel padded column — see
/// <see cref="SessionShellPadding"/> — rather than every screen paying it as chrome. Stated here so a
/// test can hold <c>SessionTabRow</c> to it the same way <c>TheChromeIsTheHeightTheBudgetAssumes</c>
/// holds the titlebar and the status bar to theirs.
/// </remarks>
internal const double SessionTabRowHeight = 38;
/// <summary>The v5b session shell's own padded column, from the design's <c>padding: 26px</c>.</summary>
internal const double SessionShellPadding = 26;
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
internal const double SessionSidebarWidth = 300;
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
internal const double SessionHeaderHeight = 60;
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
internal const double SessionStatusBarHeight = 37;
/// <summary>
/// <inheritdoc cref="TitleBarHeight" path="/summary" />
/// The bordered container both session-shell screens sit inside, from <c>MainWindow.axaml</c>'s
/// <c>BorderThickness="1"</c> around the header/pane/status-bar column and the sidebar beside it.
/// </summary>
internal const double SessionShellBorderThickness = 1;
/// <summary>
/// ◆ THE REAL BUDGET WAVE C CLOSES. What the terminal and SFTP surfaces' own screen — <c>TransfersScreen</c>
/// today, and whatever sits in the terminal's own pane — actually gets once the session shell built in
/// wave B has taken its padding, its tab row, its header and its status bar. Wave B left
/// <c>MeasureConnectingAsync</c> and <c>MeasureHostKeyAsync</c> measuring at the roomier
/// <see cref="ScreenWidth"/>/<see cref="ScreenHeight"/> instead, with a remark on each admitting the gap;
/// this is what closes it.
/// </summary>
/// <remarks>
/// It comes off every screen, not just the hosts screen, which is the layout consequence of the strip
/// spanning the window. The strip does not collapse when there are no tabs — a row of chrome that came
/// and went would move every screen up and down by 42 pixels each time the last tab closed — so this is
/// a fixed cost rather than a conditional one, and the budget can be a constant.
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
/// </remarks>
internal const double TerminalTabsHeight = 42;
internal static double SessionScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
- SessionHeaderHeight - SessionStatusBarHeight;
/// <summary>
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
/// column showing beside it.
/// </summary>
/// <remarks>
/// <see cref="ScreenWidth"/> less <see cref="SessionShellPadding"/> on both the left and the right of the
/// outer padded column, less <see cref="SessionShellBorderThickness"/> on both the left and the right of
/// the bordered container, less <see cref="SessionSidebarWidth"/> when the sidebar is showing beside the
/// pane rather than collapsed — see <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>, which for the
/// SFTP surface is exactly <c>Transfers.IsConnected</c>: the caller passes that fact in rather than this
/// harness guessing it, because it is a fact about a view model this file knows nothing about.
/// </remarks>
internal static double SessionScreenWidth(bool sidebarVisible) =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness)
- (sidebarVisible ? SessionSidebarWidth : 0);
/// <summary>The update banner's fixed height, from <c>UpdateBanner.axaml</c>.</summary>
/// <remarks>
@@ -111,12 +178,17 @@ internal static class LayoutHarness
/// <summary>Everything between the titlebar and the status bar, at the window's minimum.</summary>
internal static double ContentHeight => MinimumHeight - TitleBarHeight - StatusBarHeight;
/// <summary>The height a screen actually gets at the window's minimum.</summary>
/// <summary>
/// The height a full-bleed page screen actually gets at the window's minimum.
/// </summary>
/// <remarks>
/// Less than <see cref="ContentHeight"/> by the tab strip, which spans every screen and does not
/// collapse when there are no tabs.
/// Equal to <see cref="ContentHeight"/> since v5b's session-shell wave retired the window-wide tab strip
/// that used to be subtracted here — see <see cref="TitleBarHeight"/>'s own remark. The terminal and
/// SFTP surfaces pay for their own tab row, header and status bar out of their own budget now, which
/// this constant does not describe; a test measuring either of those two screens has to account for the
/// session shell's own geometry rather than reading it off this property.
/// </remarks>
internal static double ScreenHeight => ContentHeight - TerminalTabsHeight;
internal static double ScreenHeight => ContentHeight;
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
@@ -0,0 +1,338 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the rail's own switcher, mode-dependent first row and user popover answer a pointer.
/// </summary>
/// <remarks>
/// <para>
/// v5b moved three things onto this control that used to be tested elsewhere or not at all: the SSH/SFTP/S3
/// choice that used to be the tab strip's own fixed tabs (see <c>TerminalTabsTests</c>, which used to hold
/// the equivalent of the first two facts below), the mode-dependent first row the design calls its own
/// <c>mode</c> prop, and the popover that replaced the strip's vault menu. This suite is this control's
/// counterpart to that one — a minimal shell with two tabs and no vault, for the reason
/// <c>TerminalTabsTests</c> gives: nothing here reads <c>Vault</c> except the popover's vault switches,
/// which this suite therefore does not open — that is <c>VaultVisibilityTests</c>' business, over the
/// commands themselves, and this suite would only be re-testing the same command through a slower door.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the same reason the palette's and the strip's suites are one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>.
/// </para>
/// </remarks>
public sealed class NavRailTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"navrail-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <summary>
/// The switcher's three segments select the surface they name, and light up when it is the one showing.
/// </summary>
/// <remarks>
/// Driven through the segments rather than through the commands directly, for the reason
/// <c>TerminalTabsTests.TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage</c> gave for its own
/// three fixed tabs: what is being checked is that three buttons in the markup are wired to three
/// different things, which three commands called by hand would not catch if two of the three were
/// bound to the same one.
/// </remarks>
[Fact]
public async Task TheSwitcherSegmentsSelectTheirSurface_AndLightTheActiveOne()
{
await OnTheRailAsync((rail, window) =>
{
shell.IsSshShowing.ShouldBeTrue("nothing has navigated to SFTP or S3 yet");
Segment(rail, "SSH").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
Segment(rail, "SFTP").Classes.Contains("active").ShouldBeTrue();
Segment(rail, "SSH").Classes.Contains("active").ShouldBeFalse("exactly one segment lights at once");
Click(Segment(rail, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Segment(rail, "S3").Classes.Contains("active").ShouldBeTrue();
Click(Segment(rail, "SSH"), window);
shell.IsSshShowing.ShouldBeTrue();
shell.IsBucketsShowing.ShouldBeFalse();
});
}
/// <summary>
/// The mode-dependent first row follows the same three flags the switcher above lights.
/// </summary>
/// <remarks>
/// The label is read straight off the row's own bound text rather than off <see cref="MainWindowViewModel"/>
/// state directly, because what a fidelity pass could break is the binding between the two, not the
/// property computing the right string on its own — <c>MainWindowViewModelTests</c> would already catch
/// that half.
/// </remarks>
[Fact]
public async Task TheFirstRailItemsLabel_FollowsTheSwitchersMode()
{
await OnTheRailAsync((rail, window) =>
{
FirstRow(rail).ShouldBe("Terminal");
Click(Segment(rail, "SFTP"), window);
FirstRow(rail).ShouldBe("Files");
Click(Segment(rail, "S3"), window);
FirstRow(rail).ShouldBe("Buckets");
Click(Segment(rail, "SSH"), window);
FirstRow(rail).ShouldBe("Terminal");
});
}
/// <remarks>
/// The user popover is a <c>Flyout</c>, and this is the same assertion
/// <c>TerminalTabsTests.TheCaretBesideVaults_OpensTheVaultMenu</c> made of the strip's own — the pointer
/// opens the popup, and nothing about its position or its content occlude anything, since it never
/// crosses into the terminal's own rectangle. See the remark in NavRail.axaml.
/// </remarks>
[Fact]
public async Task ClickingTheUserChip_OpensThePopover()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(chip, window);
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Each vault switch in the popover draws the vault's own display name and shown state, and is wired
/// to the command that toggles it.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="MainWindowViewModel.VaultToggles"/> is fed directly rather than through a real sign-in
/// and a second vault created on a fake server. <c>VaultVisibilityTests</c>, in the Avalonia-free
/// <c>DodoSSH.Client.App.Tests</c> project, already proves <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself — that a hidden vault stays syncing, that the personal one refuses, and everything else the
/// command actually does once it runs. What is worth proving here, in the project that can lay markup
/// out at all, is only the wiring: that a row in this popover shows the right vault and calls that
/// command with that vault when pressed — the fact a fidelity pass to this file could actually break.
/// </para>
/// <para>
/// The command is read off the row rather than pressed, because <see cref="MainWindowViewModel.ToggleVaultCommand"/>
/// itself declines with no observable effect when <see cref="MainWindowViewModel.Vault"/> is null — which
/// it is here, for the reason above — so a press would prove nothing a reader could tell from a press
/// that reached the wrong command entirely.
/// </para>
/// </remarks>
[Fact]
public async Task PopoverVaultRows_NameTheirVaultAndAreWiredToToggleIt()
{
var shown = new VaultToggleViewModel(Guid.CreateVersion7(), "Personal", IsPersonal: true, IsShown: true);
var hidden = new VaultToggleViewModel(Guid.CreateVersion7(), "Platform secrets", IsPersonal: false, IsShown: false);
shell.VaultToggles.Add(shown);
shell.VaultToggles.Add(hidden);
await OnTheRailAsync((rail, window) =>
{
Click(UserChip(rail), window);
var shownRow = PopoverRow(window, shown);
var hiddenRow = PopoverRow(window, hidden);
shownRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
shownRow.CommandParameter.ShouldBeSameAs(shown);
HasVisibleCheck(shownRow).ShouldBeTrue("the personal vault is always shown");
hiddenRow.Command.ShouldBeSameAs(shell.ToggleVaultCommand);
hiddenRow.CommandParameter.ShouldBeSameAs(hidden);
HasVisibleCheck(hiddenRow).ShouldBeFalse("this one was switched off");
});
}
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
/// <remarks>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
[Fact]
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
[Fact]
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, label), window);
shell.Screen.ShouldBe(target);
shell.IsShowingPages.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse("a navigation row shuts the popover behind it");
});
/// <remarks>
/// Through Preferences rather than a direct <c>SignOutCommand</c> — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card the mock has no
/// room for at all is drawn inline on that one screen while the vault is unlocked, and arming it from
/// anywhere else would be a card raised nobody could see.
/// </remarks>
[Fact]
public async Task ThePopoversLogoutRow_GoesToPreferencesAndArmsTheSignOutConfirmation()
{
await OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
Click(chip, window);
Click(PopoverRow(window, "Logout"), window);
shell.Screen.ShouldBe(ShellScreen.Preferences);
shell.IsConfirmingSignOut.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
});
}
// ---- Helpers ----
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail { DataContext = shell };
var window = new Window { Content = rail };
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
try
{
body(rail, window);
}
finally
{
window.Close();
}
},
Token);
private static Button Segment(Visual rail, string label) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("navseg")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static Button UserChip(Visual rail) =>
rail.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("navuser"));
/// <summary>
/// A row inside the open popover, found by its own data context. Searched from the window rather than
/// from the rail: a <c>Flyout</c>'s content is a popup, hosted in the window's own overlay layer rather
/// than nested inside the control that owns it, so it is outside <c>rail.GetVisualDescendants()</c>.
/// </summary>
private static Button PopoverRow(Visual window, VaultToggleViewModel toggle) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow") && ReferenceEquals(button.DataContext, toggle));
/// <summary>A navigation row inside the open popover, found by the word on it.</summary>
/// <inheritdoc cref="PopoverRow(Visual, VaultToggleViewModel)" path="/summary" />
private static Button PopoverRow(Visual window, string label) =>
window.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("poprow")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
/// <summary>Whether a vault row's magenta check square is drawn, for whether it is currently shown.</summary>
private static bool HasVisibleCheck(Button row) =>
row.GetVisualDescendants().OfType<Border>().Any(
border => border.Classes.Contains("vaultcheck") && border.IsVisible);
/// <summary>The mode-dependent first row's own label, read off its bound <c>TextBlock</c>.</summary>
private static string FirstRow(Visual rail) =>
rail.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("nav") && !button.Classes.Contains("navseg"))
.GetVisualDescendants()
.OfType<TextBlock>()
.First(text => text.Classes.Contains("navlabel"))
.Text ?? string.Empty;
private static void Click(Visual control, Window window)
{
var at = control.TranslatePoint(
new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
}
@@ -825,7 +825,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
// The plain-screen budget, not the session shell's: MainWindow.axaml gives S3 the same TransfersScreen
// control with no tab row, no header and no sidebar around it — see its own remark on why the S3
// usage is "deliberately not given the session shell above."
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
/// <remarks>
@@ -1144,18 +1147,30 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
// ---- The transfers screen ----
//
// ◆ WAVE C's OWN BUDGET. This screen carries two real widths, not one, because the same TransfersScreen
// control sits in two different real containers — see LayoutHarness.SessionScreenWidth's own remark and
// MainWindow.axaml. On SFTP it is inside wave B's session shell: a 26px padded column, a 1px bordered
// container, and — once Transfers.IsConnected — a 300px QUICK ACCESS sidebar squeezed in beside it. On S3
// it is the plain screen it always was, at the roomier LayoutHarness.ScreenWidth/ScreenHeight budget; see
// TheS3ScreenFitsWithNoBucketsToOpen and MeasureBucketsAsync. Every test below this point measures the
// SFTP usage through MeasureTransfersAsync, which reads transfers.IsConnected itself to decide whether
// the sidebar is squeezing the pane — the same fact ShowsQuickAccessSidebar reads.
/// <remarks>
/// <para>
/// The widest thing in this window and the one with the least room to give: two file listings side by
/// side, each with four columns, and a queue underneath — all inside 826 pixels once the nav rail has
/// taken its column. The header row is the tight part, because it holds a host picker, a password box,
/// a button and a chip on one line.
/// The widest this screen gets and the one with the least room to give once connected: two file listings
/// side by side, a 64-pixel arrow column between them, and a queue underneath — inside 472 pixels once
/// the session shell's own padding, border and QUICK ACCESS sidebar have all been taken out. That leaves
/// 204 pixels a side, which is the width every restyled row template in <c>TransfersScreen.axaml</c> was
/// actually chosen against; see its own remark on the column widths.
/// </para>
/// <para>
/// Measured disconnected, which is the state the screen opens in and the one where the local pane is at
/// its fullest: it lists this machine's home directory, so the row template is exercised with real names
/// of real length rather than with fixtures chosen to fit.
/// Measured disconnected here, though, which is the state the screen opens in and the one where the
/// local pane is at its fullest it lists this machine's home directory, so the row template is
/// exercised with real names of real length rather than with fixtures chosen to fit. Disconnected also
/// means no sidebar yet, so this particular test is measured at the roomier 772-pixel shape;
/// <see cref="TheTransfersScreenFitsWithASessionOpen"/> below is the one that reaches the 472-pixel one.
/// </para>
/// </remarks>
[Fact]
@@ -1253,14 +1268,18 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The state the screen is in once something is open, and the reason it is worth a test of its own is
/// the strip the connect bar left behind: a chip naming the account and the endpoint, the status line,
/// and DISCONNECT — inside a pane that is under 400 pixels wide at the window's minimum, above a row
/// that already carries UP, REFRESH and DELETE.
/// The state the screen is in once something is open, and the tightest one wave C's restyle has to
/// survive: <c>Transfers.IsConnected</c> is exactly what pulls <c>SessionSidebar</c> into view beside
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
/// thing squeezing DISCONNECT off the edge at this width.
/// </para>
/// <para>
/// The address is a long one deliberately. It is the part of that row with no fixed width, and a chip
/// that grew to fit whatever it was given is how the button beside it goes off the edge.
/// The address is a long one deliberately, because <c>SessionAddress</c> still has to hold it without
/// trimming where the header prints it.
/// </para>
/// </remarks>
[Fact]
@@ -1303,6 +1322,214 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty());
}
// ---- ◆ wave C: the restyled panes and the TRANSFERS strip ----
/// <remarks>
/// The row template's own SIZE and MODIFIED columns, proved against real bindings rather than against
/// the fixture's own home directory — whose contents this suite does not control — by adding one row of
/// each pane's own shape directly and reading the rendered <c>TextBlock</c>s back. PERMS stays remote-only,
/// which is what <see cref="RemoteEntryRowViewModel"/> carrying it and <see cref="LocalEntryRowViewModel"/>
/// not proves alongside the two shared columns.
/// </remarks>
[Fact]
public async Task TheRowTemplateShowsSizeAndDateColumns()
{
var local = new LocalEntryRowViewModel(new LocalEntry(
"notes.txt",
Path.Combine(Path.GetTempPath(), "notes.txt"),
IsDirectory: false,
Length: 4_096,
new DateTimeOffset(2026, 7, 21, 9, 0, 0, TimeSpan.Zero)));
// Cleared first, not merely appended to: this fixture's real home directory can hold far more entries
// than the pane's own viewport, and a virtualizing ListBox only realises the rows that fit in it. A
// row added after all of those would never be built at all, which is a false pass rather than a
// proof — the assertion below would find nothing to have missed.
transfers.LocalEntries.Clear();
transfers.LocalEntries.Add(local);
var remote = new RemoteEntryRowViewModel(new SftpEntry(
"deploy.log",
"/srv/releases/deploy.log",
SftpEntryKind.File,
49_152,
new DateTimeOffset(2026, 7, 30, 14, 0, 0, TimeSpan.Zero),
"-rw-r--r--"));
transfers.RemoteEntries.Add(remote);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var texts = screen.GetVisualDescendants().OfType<TextBlock>().Select(text => text.Text).ToList();
texts.ShouldContain(local.Size, customMessage: "the local pane's own SIZE column");
texts.ShouldContain(local.Modified, customMessage: "the local pane's own MODIFIED column");
texts.ShouldContain(remote.Size, customMessage: "the remote pane's own SIZE column");
texts.ShouldContain(remote.Modified, customMessage: "the remote pane's own MODIFIED column");
texts.ShouldContain(remote.Permissions, customMessage: "PERMS, which stays remote-only");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// <see cref="TransferRowViewModel.StatusWord"/> is what v5b's TRANSFERS strip prints in place of the old
/// all-caps <c>StateLabel</c> chip — a live percentage while running, a plain word otherwise — and this
/// holds the mapping against every state <c>FileTransferQueue</c> actually reports, at the view model and
/// rendered onto the strip itself.
/// </remarks>
[Fact]
public async Task TheTransferStripsStatusWordsMapTheQueuesRealStates()
{
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
Enqueue(TransferDirection.Download, "running.bin", 1_000, 640, TransferState.Running, bytesPerSecond: 1_000);
Enqueue(TransferDirection.Upload, "done.txt", 1_000, 1_000, TransferState.Completed);
Enqueue(TransferDirection.Upload, "stopped.txt", 1_000, 200, TransferState.Cancelled);
Enqueue(TransferDirection.Upload, "failed.txt", 1_000, 0, TransferState.Failed, failure: "refused");
var expected = new[] { "queued", "64%", "done", "stopped", "failed" };
transfers.Transfers.Select(row => row.StatusWord).ShouldBe(expected);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var words = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Where(text => text.Classes.Contains("transferstatus"))
.Select(text => text.Text ?? string.Empty)
.ToList();
words.ShouldBe(expected);
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The TRANSFERS strip's own "collapse" — not to zero, but down to the honest sentence the full strip
/// carried when nothing had ever been queued. Both shapes are checked: the header and its count chip are
/// absent with nothing queued, and a queued transfer brings the full strip straight back.
/// </remarks>
[Fact]
public async Task TheTransfersStripCollapsesWhenTheQueueIsEmpty()
{
transfers.Transfers.ShouldBeEmpty("the fixture queues nothing before this test adds any");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeFalse("the full strip collapses with nothing queued");
var collapsed = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Text is { } spoken
&& spoken.StartsWith("Nothing queued.", StringComparison.Ordinal));
collapsed.IsEffectivelyVisible.ShouldBeTrue(
"the honest sentence stays reachable in the collapsed shape");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
Enqueue(TransferDirection.Upload, "queued.txt", 1_000, 0, TransferState.Queued);
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SessionScreenWidth(sidebarVisible: false), LayoutHarness.SessionScreenHeight);
try
{
var label = screen.GetVisualDescendants()
.OfType<TextBlock>()
.Single(text => text.Classes.Contains("label") && text.Text == "TRANSFERS");
label.IsEffectivelyVisible.ShouldBeTrue("a queued transfer brings the full strip back");
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// The narrowest real shape wave C's restyle has to survive at once: connected, so QUICK ACCESS's own
/// sidebar takes its 300 pixels — see <see cref="LayoutHarness.SessionScreenWidth"/> — a populated remote
/// listing carrying every colour state a row can show (a directory, an executable, a world-writable
/// file), and a full transfer queue underneath, all inside the 204-pixel-a-side budget that leaves either
/// pane.
/// </remarks>
[Fact]
public async Task TheRestyledPanesFitTheSessionShellsNarrowestBudget()
{
transfers.IsConnected = true;
transfers.ConnectedTo = "deployment-service@releases.eu-west.internal.example:2222";
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"docker-compose.yml", "/srv/releases/site/docker-compose.yml", SftpEntryKind.File,
3_400, TimeProvider.System.GetUtcNow(), "-rw-r--r--")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"deploy.sh", "/srv/releases/site/deploy.sh", SftpEntryKind.File,
912, TimeProvider.System.GetUtcNow(), "-rwxr-xr-x")));
transfers.RemoteEntries.Add(new RemoteEntryRowViewModel(new SftpEntry(
"shared", "/srv/releases/site/shared", SftpEntryKind.Directory,
0, TimeProvider.System.GetUtcNow(), string.Empty)));
Enqueue(TransferDirection.Download, "artefact.tar.gz", 402_653_184, 149_000_000,
TransferState.Running, bytesPerSecond: 6_500_000);
Enqueue(TransferDirection.Upload, "site-backup-2026-07-30.sql.gz", 8_100_000_000, 3_200_000_000,
TransferState.Cancelled);
await MeasureTransfersAsync(
faults => faults.ShouldBeEmpty("connected, with populated rows and a full queue at once"));
}
// ---- The chrome ----
/// <remarks>
@@ -1355,27 +1582,27 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <remarks>
/// <para>
/// The rail runs vertically, so what runs out here is height rather than width — at the window's minimum
/// the entries have to leave room for each other, which is the same failure the old four-button selector
/// was one label away from. It got tighter when the host keys left the keychain screen and became a
/// destination of their own, and tighter again with snippets and then the logs, which is why the count
/// is asserted rather than left to the fit check: an entry silently dropping off the bottom would still
/// pass every other assertion here.
/// v5b's redraw changes what this test has to hold. Three button shapes live in the rail now rather
/// than one: the switcher's three segments, each a third of the rail's own content width; the six item
/// rows below it and the user chip at the foot, both the rail's full content width. A single
/// across-the-board width assertion the way the v3 version of this test made one would either be wrong
/// for the segments or have to loosen until it caught nothing, so each shape gets its own count and its
/// own width now.
/// </para>
/// <para>
/// Seven now, and it went down rather than up for the first time: SFTP and S3 became fixed tabs in the
/// strip, which is where a destination you stay in belongs. The number is asserted in both directions
/// for the same reason — an entry that reappeared here would be a route out of the tab the rail lives
/// in. See <c>NavRail.axaml</c>.
/// The rail runs vertically, so what runs out at the window's minimum is still height — a switcher plus
/// six rows plus a user chip have to leave room for each other in the same space the v3 rail's seven
/// plain rows did. Both counts are asserted in both directions for the reason the old test's was: an
/// entry silently dropping off the bottom would still pass every other assertion here.
/// </para>
/// </remarks>
[Fact]
public async Task TheNavRailHoldsSevenDestinationsAtTheWindowsMinimum()
public async Task TheNavRailHoldsItsSwitcherSixDestinationsAndTheUserChipAtTheWindowsMinimum()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var rail = new NavRail();
var rail = new NavRail { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
rail, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
@@ -1383,18 +1610,29 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
{
var buttons = rail.GetVisualDescendants().OfType<Button>().ToList();
buttons.Count.ShouldBe(7, "one per screen the rail reaches, and SFTP and S3 are tabs");
var segments = buttons.Where(button => button.Classes.Contains("navseg")).ToList();
var rows = buttons.Where(button => button.Classes.Contains("nav")
&& !button.Classes.Contains("navseg")).ToList();
var chip = buttons.Single(button => button.Classes.Contains("navuser"));
foreach (var button in buttons)
segments.Count.ShouldBe(3, "SSH, SFTP and S3");
rows.Count.ShouldBe(
6, "the mode-dependent first row, then Hosts, Keys, Pins, Snips and Logs");
foreach (var segment in segments)
{
button.Bounds.Height.ShouldBeGreaterThan(20);
segment.Bounds.Height.ShouldBeGreaterThan(20);
}
// 190 wide, less the divider down the rail's right edge, less the 8 of inset on
// each side that v2 gives the rows so a filled one reads as a rounded row rather
// than as a full-width band. Stated exactly rather than as a lower bound: a button
// that stopped filling the row would leave a dead strip beside a destination, which
// is precisely the kind of near-miss a bound hides.
button.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 16);
foreach (var row in rows.Append(chip))
{
row.Bounds.Height.ShouldBeGreaterThan(20);
// 255 wide, less the 1-pixel border down the rail's right edge, less the 14 of
// inset v5b's own padding gives each side. Stated exactly rather than as a lower
// bound: a row that stopped filling the width would leave a dead strip beside a
// destination, which is precisely the kind of near-miss a bound hides.
row.Bounds.Width.ShouldBe(LayoutHarness.NavRailWidth - 1 - 28);
}
LayoutHarness.Unreachable(window).ShouldBeEmpty();
@@ -1579,8 +1817,21 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the connecting card out in the rectangle the terminal would have had.</summary>
/// <remarks>
/// The same width and height as a full screen: the card is a sibling of the page area rather than
/// something drawn inside one, so what it gets is everything under the tab strip and beside the rail.
/// <para>
/// ◆ WAVE C's OWN FIX. This used to measure a full screen's width and height, which is roomier than what
/// v5b's own session shell actually leaves the card once its padding, tab row, header and sidebar are
/// taken out; the gap is closed here rather than merely admitted. <c>ConnectingCard</c> sits in the same
/// <c>Grid.Column="0"</c> pane <c>ConnectingPane</c> occupies in <c>MainWindow.axaml</c>'s terminal usage
/// — the sidebar is <c>Grid.Column="1"</c>, a true sibling rather than an overlay on top of this one — so
/// its real rectangle is <see cref="LayoutHarness.SessionScreenWidth"/>/<see cref="LayoutHarness.SessionScreenHeight"/>,
/// not the plain screen budget.
/// </para>
/// <para>
/// The sidebar is showing whenever this card can be, which is what makes the width unconditional rather
/// than a parameter here: <c>ShowsQuickAccessSidebar</c> on the terminal surface is
/// <c>SelectedTab is not null</c>, and a connecting card has nothing to show without a selected tab
/// either — see the two callers below, both of which select one before calling this.
/// </para>
/// </remarks>
private Task MeasureConnectingAsync(Action<IReadOnlyList<string>> assert, TerminalTabViewModel tab) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1594,7 +1845,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
var card = new ConnectingCard { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
card, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
card, LayoutHarness.SessionScreenWidth(sidebarVisible: true), LayoutHarness.SessionScreenHeight);
try
{
@@ -1609,12 +1860,23 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the host-key decision out in the rectangle it is drawn over.</summary>
/// <remarks>
/// The vault is the data context, as it is in the window, and the size is a screen's rather than a card's:
/// this control carries its own scrim and its own <c>Border.card</c>, so what it is handed is the area the
/// overlay covers and the card centres itself inside it. That area is in fact everything under the
/// titlebar, which is <see cref="LayoutHarness.TerminalTabsHeight"/> taller than what is used here —
/// measuring it at the tighter budget is deliberate, since a card that fits the strip's row too cannot
/// stop fitting when the strip is what it is drawn over.
/// <para>
/// ◆ WAVE C's OWN FINDING, rather than wave C's own fix: this one turned out not to need the narrower
/// budget wave B's remark predicted. <c>HostKeyCard</c> is not nested inside either session shell at all
/// — <c>MainWindow.axaml</c> draws it as a sibling of the whole unlocked <c>Grid</c> (nav rail and content
/// both), the last child before the titlebar/status-bar row, so its own scrim genuinely spans the full
/// content area rather than the narrower column either session shell leaves. "Both cover the rectangle
/// the terminal would be in" — <c>MainWindowViewModel.IsHostKeyDecisionShowing</c>'s own remark — describes
/// the intent the two states share, not this control's actual bounds.
/// </para>
/// <para>
/// <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> — the nav rail already
/// taken out, the session shell's own padding and sidebar not — stays the right measurement rather than
/// the wrong one wave B's remark called it: it is narrower than what this control truly gets (the nav
/// rail's own 255 pixels back), so a card proven to fit here is proven to fit the real, wider overlay too.
/// <c>Border.card</c>'s own 520-pixel <c>MaxWidth</c> means neither number was ever the risk; what this
/// records is that no gap was left for a later wave to close.
/// </para>
/// </remarks>
private Task MeasureHostKeyAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -1852,7 +2114,42 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
static (_, _, _, _) => throw new InvalidOperationException("A layout test inserts nothing."));
/// <summary>Lays the transfers screen out at the width it gets beside the nav rail.</summary>
/// <summary>Lays the SFTP usage of <c>TransfersScreen</c> out at the real budget wave B's session shell leaves.</summary>
/// <remarks>
/// <c>transfers.IsConnected</c> decides the width the same way <c>ShowsQuickAccessSidebar</c> does at
/// runtime — see <see cref="LayoutHarness.SessionScreenWidth"/> — so a test that connects before calling
/// this measures the tighter, sidebar-narrowed shape without having to say so twice.
/// </remarks>
private Task MeasureTransfersAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen,
LayoutHarness.SessionScreenWidth(transfers.IsConnected),
LayoutHarness.SessionScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
/// <remarks>
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height: S3
/// is "deliberately not given the session shell" — see <c>MainWindow.axaml</c>'s own remark on why — so it
/// is measured at <see cref="LayoutHarness.ScreenWidth"/>/<see cref="LayoutHarness.ScreenHeight"/> instead,
/// the same budget every other full-bleed page gets.
/// </remarks>
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
@@ -1896,8 +2193,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
/// <summary>Lays the vaults screen out at the width it gets once the nav rail has taken its column.</summary>
/// <remarks>
/// <para>
/// Its right-hand column is the narrowest measured here: the window's minimum is 1016, the nav rail
/// takes 190 and the vault list 268, leaving 558 for everything above.
/// Its right-hand column is the narrowest measured here: the window's minimum is 1081, the nav rail
/// takes 255 and the vault list 268, leaving 558 for everything above — the same 558 as before v5b
/// widened the rail, because the minimum grew by exactly what the rail did.
/// </para>
/// <para>
/// Every list is seeded, and seeded with the long rows rather than the convenient ones — see
@@ -0,0 +1,391 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the v5b in-screen tab row answers a pointer, on both of the two ways it is used.
/// </summary>
/// <remarks>
/// The replacement for <c>TerminalTabsTests</c>, which measured the window-wide strip this control replaced
/// — see <c>SessionTabRow.axaml</c>'s own remark for why one control now serves both the terminal surface and
/// the SFTP surface. The gestures that do not vary by surface — middle-click closes, "+" opens the palette,
/// the tab pointer feedback — are carried over from that suite essentially unchanged; what is new here is
/// <see cref="TabRowTabClickRunsTheGivenCommand"/> and <see cref="SftpRowMarksTheColourItIsToldTo"/>, which
/// prove the one thing this control adds: the click and the accent colour are handed in rather than fixed.
/// </remarks>
public sealed class SessionTabRowTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"session-tabs-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
/// <remarks>
/// The terminal row's own click: <c>TabCommand</c> bound to <c>SelectTabCommand</c>, the same as the
/// window-wide strip's used to be.
/// </remarks>
[Fact]
public async Task ALeftClickOnATabRunsTheBoundTabCommand()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// The one thing this control adds over the strip it replaces: which command a tab click runs is handed
/// in, not fixed. Proved with a command that is not <c>SelectTabCommand</c> at all, so a row that quietly
/// ignored <c>TabCommand</c> and fell back to selecting the tab itself would fail this rather than pass
/// it by coincidence.
/// </remarks>
[Fact]
public async Task TabRowTabClickRunsTheGivenCommand()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
var seen = new List<TerminalTabViewModel>();
var strip = new SessionTabRow
{
DataContext = shell,
TabCommand = new RelayCommand<TerminalTabViewModel>(tab => seen.Add(tab!)),
};
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
var button = TabButton(strip, shell.Tabs[0]);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
seen.ShouldHaveSingleItem().ShouldBe(shell.Tabs[0]);
shell.SelectedTab.ShouldBeNull("the given command ran instead of SelectTabCommand");
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <remarks>
/// The strip's own height budget moved with it — see <see cref="LayoutHarness.SessionTabRowHeight"/> —
/// but the invariant this test protects is the same one <c>TerminalTabsTests</c> protected: a row of
/// tabs must not grow the chrome around it, however many are open.
/// </remarks>
[Fact]
public async Task TheRowIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
strip.DesiredSize.Height.ShouldBe(LayoutHarness.SessionTabRowHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
/// identically to the plus at rest by design. This proves the same two facts a different way: an inactive
/// tab still changes under the pointer, and the plus never gets the treatment an <em>active</em> tab does
/// — the coloured top border and the DeepChrome fill — which is the one visual claim "not a thing being
/// chosen between" actually has to hold.
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsAnActiveTab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
shell.SelectTabCommand.Execute(shell.Tabs[0]);
LayoutHarness.Settle(window, 900, 600);
var activeTab = TabButton(strip, shell.Tabs[0]);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(activeTab),
"the button that opens a connection never carries the active tab's DeepChrome fill");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
/// <remarks>
/// The SFTP row's own accent: <c>Classes="sftp"</c> on this control's own usage — see
/// <c>MainWindow.axaml</c> — is what selects <c>Magenta</c> over the terminal row's <c>TerminalTabAccent</c>
/// for an active tab's top border. Read as a colour rather than a class list, so this catches the App.axaml
/// selector actually resolving to a different brush rather than merely having the class applied.
/// </remarks>
[Fact]
public async Task SftpRowMarksTheColourItIsToldTo()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.SelectTabCommand.Execute(shell.Tabs[0]);
var sshRow = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var sftpRow = new SessionTabRow
{
DataContext = shell,
TabCommand = shell.SelectTabCommand,
Classes = { "sftp" },
};
var sshWindow = new Window { Content = sshRow };
var sftpWindow = new Window { Content = sftpRow };
LayoutHarness.Settle(sshWindow, 900, 600);
LayoutHarness.Settle(sftpWindow, 900, 600);
try
{
var sshBorder = Presenter(TabButton(sshRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
var sftpBorder = Presenter(TabButton(sftpRow, shell.Tabs[0])).BorderBrush as ISolidColorBrush;
sshBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#7C5CFF"), "TerminalTabAccent");
sftpBorder.ShouldNotBeNull().Color.ShouldBe(Color.Parse("#DD1296"), "Magenta");
}
finally
{
sshWindow.Close();
sftpWindow.Close();
}
},
Token);
}
// ---- Helpers ----
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
private Task OnTheStripAsync(Action<SessionTabRow, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("sesstab"));
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
}
@@ -1,507 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// How the tab strip answers a pointer.
/// </summary>
/// <remarks>
/// <para>
/// The strip spans every screen now, so it is chrome a user is in contact with all day rather than one
/// column of the hosts screen. What that earns it is the gestures every other tabbed application has — a
/// middle click that closes, a cross inside the tab rather than beside it, a button that opens another — and
/// what those need is a suite, because all three are pointer behaviour and none of it is expressible as a
/// binding.
/// </para>
/// <para>
/// A <c>UserControl</c> in a bare window, for the reason the palette's suite is one:
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/>. No vault and no session — the strip
/// binds only to the shell's tab list, and tabs are shell state that outlives the vault that opened them, so
/// they can be put there directly. Closing one asks the workspace to end a session it has never heard of,
/// which the workspace answers by returning: that is the same path a real close takes, minus a shell.
/// </para>
/// </remarks>
public sealed class TerminalTabsTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"tabs-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
// The only state the strip is ever interactive in. Assigned rather than reached through an
// enrollment, which would be an Argon2 pass for no extra coverage — nothing here reads the vault.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
/// <remarks>
/// The gesture this rework is for. Middle-clicking a tab is how every browser and every terminal closes
/// one, and the strip answered nothing but a left click before.
/// </remarks>
[Fact]
public async Task AMiddleClickOnATabClosesThatTab()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
window.MouseDown(Centre(TabButton(strip, doomed), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The other half of the rule, and the reason the handler is on the tab's own template root rather than
/// on the strip: a middle click on the chrome between the last tab and the edge of the window must not
/// close anything. Wiring it on the strip and testing what was underneath the pointer would have been
/// the same feature with a way to get it wrong.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheStripBackgroundClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
// Well right of two short tabs and the button after them, and inside the strip's own height.
window.MouseDown(new Point(700, 17), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
});
}
[Fact]
public async Task AMiddleClickOnTheButtonThatOpensAConnectionClosesNothing()
{
await OnTheStripAsync((strip, window) =>
{
window.MouseDown(Centre(PlusButton(strip), window), MouseButton.Middle);
shell.Tabs.Count.ShouldBe(2);
shell.IsSearching.ShouldBeFalse("a middle click is not how the palette opens either");
});
}
/// <remarks>
/// The cross is inside the tab, so a middle click on it bubbles out to the tab's handler as well. One
/// close, not two: the second would take the neighbour, which is the tab the user was aiming to keep.
/// </remarks>
[Fact]
public async Task AMiddleClickOnTheCrossClosesExactlyOneTab()
{
await OnTheStripAsync((strip, window) =>
{
var survivor = shell.Tabs[1];
window.MouseDown(Centre(CloseButton(strip, shell.Tabs[0]), window), MouseButton.Middle);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
});
}
/// <remarks>
/// The one assumption the nested-button template makes, stated as a test. Avalonia's
/// <c>Button.OnPointerPressed</c> takes the capture and marks a left press handled, so the cross does
/// not also reach the tab underneath it — which would select a tab on its way out and leave the
/// terminal switching to something that is about to disappear.
/// </remarks>
[Fact]
public async Task ALeftClickOnTheCrossClosesTheTabAndDoesNotSelectIt()
{
await OnTheStripAsync((strip, window) =>
{
var doomed = shell.Tabs[0];
var survivor = shell.Tabs[1];
shell.SelectTabCommand.Execute(survivor);
var cross = CloseButton(strip, doomed);
window.MouseDown(Centre(cross, window), MouseButton.Left);
window.MouseUp(Centre(cross, window), MouseButton.Left);
shell.Tabs.ShouldHaveSingleItem().ShouldBe(survivor);
shell.SelectedTab.ShouldBe(survivor);
});
}
[Fact]
public async Task ALeftClickOnATabSelectsItAndShowsTheTerminal()
{
await OnTheStripAsync((strip, window) =>
{
var wanted = shell.Tabs[1];
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.IsTerminalShowing.ShouldBeFalse();
var button = TabButton(strip, wanted);
window.MouseDown(Centre(button, window), MouseButton.Left);
window.MouseUp(Centre(button, window), MouseButton.Left);
shell.Tabs.Count.ShouldBe(2, "selecting is not closing");
shell.SelectedTab.ShouldBe(wanted);
shell.IsTerminalShowing.ShouldBeTrue();
});
}
/// <remarks>
/// It opens the palette rather than a menu, so that the strip and Ctrl+K are one way of doing one thing.
/// See the note in <c>TerminalTabs.axaml</c> for why a flyout over the terminal's rectangle is not a
/// claim this project is willing to make without a screenshot.
/// </remarks>
[Fact]
public async Task TheButtonThatOpensAConnectionOpensThePalette()
{
await OnTheStripAsync((strip, window) =>
{
var plus = PlusButton(strip);
window.MouseDown(Centre(plus, window), MouseButton.Left);
window.MouseUp(Centre(plus, window), MouseButton.Left);
shell.IsSearching.ShouldBeTrue();
});
}
/// <summary>
/// The three fixed tabs select what they name, and Vaults comes back to the page it was left on.
/// </summary>
/// <remarks>
/// <para>
/// The memory is the part worth a gesture rather than a property assertion. Vaults is the one tab with
/// sub-navigation, so it is the one that can come back to the wrong place — and the failure is silent:
/// a Vaults tab that always landed on Hosts looks like a working tab to anybody who was already on
/// Hosts, which is most of the time.
/// </para>
/// <para>
/// Driven through the strip rather than through the commands, because what is being checked is that
/// three buttons in the markup are wired to three different things. Three commands called directly
/// would pass on a strip whose SFTP tab was bound to the S3 one.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsSelectTheirSurface_AndVaultsRemembersItsPage()
{
await OnTheStripAsync((strip, window) =>
{
shell.ShowScreenCommand.Execute(ShellScreen.Snippets);
shell.IsVaultsTab.ShouldBeTrue("a rail screen is under the Vaults tab");
Click(FixedTab(strip, "SFTP"), window);
shell.IsTransfersShowing.ShouldBeTrue();
shell.IsVaultsTab.ShouldBeFalse("exactly one tab is lit at a time");
Click(FixedTab(strip, "S3"), window);
shell.IsBucketsShowing.ShouldBeTrue();
shell.IsTransfersShowing.ShouldBeFalse();
Click(FixedTab(strip, "Vaults"), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.Screen.ShouldBe(
ShellScreen.Snippets,
"the Vaults tab comes back to the page it was left on, not to Hosts");
});
}
/// <remarks>
/// The caret is the second half of the Vaults pill and the only control in this strip that opens a
/// popup. Asserted as behaviour rather than as markup, because what makes it correct is the order in
/// the handler rather than the flyout being attached — see the test below.
/// </remarks>
[Fact]
public async Task TheCaretBesideVaults_OpensTheVaultMenu()
{
await OnTheStripAsync((strip, window) =>
{
var caret = CaretButton(strip);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeFalse("nothing has been pressed yet");
Click(caret, window);
FlyoutBase.GetAttachedFlyout(caret)!.IsOpen.ShouldBeTrue();
});
}
/// <summary>
/// Opening the vault menu selects the Vaults tab first, so the renderer is collapsed under it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The occlusion guard, and the reason this strip may have a flyout at all.</b> The comment on the
/// <c>+</c> button refuses one because a popup dropping into the terminal's rectangle would have to
/// composite above a native child window, which this project does not claim without a screenshot. The
/// caret sidesteps the question rather than answering it: it goes to the Vaults tab before it opens,
/// and a page surface is one where the renderer is not drawn.
/// </para>
/// <para>
/// So the assertion is on <see cref="MainWindowViewModel.IsTerminalShowing"/> rather than on anything
/// about the popup. A change that opened the flyout without moving the surface first would still show a
/// menu in every screenshot anybody took on a machine where it happened to work.
/// </para>
/// </remarks>
[Fact]
public async Task OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt()
{
await OnTheStripAsync((strip, window) =>
{
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.IsTerminalShowing.ShouldBeTrue("this test is meaningless without one in the way");
Click(CaretButton(strip), window);
shell.IsVaultsTab.ShouldBeTrue();
shell.IsTerminalShowing.ShouldBeFalse(
"the flyout must never have to composite over the renderer's native child window");
});
}
/// <remarks>
/// None of the three owns a shell, so none of them may offer to end one. The cross is what tells a
/// destination from a machine in this strip, and a fixed tab that grew one would be offering to close
/// SFTP.
/// <para>
/// The Vaults caret is a sibling of its tab rather than a child, which is what keeps this assertion
/// meaning what it says: a button inside a fixed tab would still be a close box.
/// </para>
/// </remarks>
[Fact]
public async Task TheFixedTabsCarryNoCloseBox()
{
await OnTheStripAsync((strip, _) =>
{
foreach (var label in new[] { "Vaults", "SFTP", "S3" })
{
FixedTab(strip, label)
.GetVisualDescendants()
.OfType<Button>()
.ShouldBeEmpty($"{label} is a destination, not a session");
}
});
}
/// <remarks>
/// The strip is the one row of chrome every screen pays for, so its height is part of the layout budget
/// and this is what stops the budget drifting from the markup. See
/// <see cref="LayoutHarness.TerminalTabsHeight"/>.
/// </remarks>
[Fact]
public async Task TheStripIsTheHeightTheBudgetAssumes_AndDoesNotGrowWithTabs()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
for (var i = 0; i < 12; i++)
{
shell.Tabs.Add(new TerminalTabViewModel((uint)i, $"host-{i}", $"deploy@host-{i}:22"));
}
var strip = new TerminalTabs { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
// What it asks for, not what this host window gave it. Hosting it at 34 and then
// asserting it is 34 would pass on a strip that wanted 300 and got clipped, which is
// exactly the regression the budget needs catching.
strip.DesiredSize.Height.ShouldBe(LayoutHarness.TerminalTabsHeight);
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <summary>
/// A tab lights under the pointer, and the button that opens one is not drawn as a tab.
/// </summary>
/// <remarks>
/// <para>
/// The only test in this suite that reads a brush rather than a rectangle, and it is here because that
/// was the gap a real regression went through. Everything else measures heights and reachability, so a
/// strip whose tabs had silently stopped answering the pointer passed all of it.
/// </para>
/// <para>
/// What went wrong is worth stating, because the shape of it will recur. Avalonia has no specificity —
/// the later declaration wins — and when the tab became a pill that paints its own background, that
/// background was declared *after* the hover rule it relied on and after the exceptions the <c>+</c>
/// is made of. So every tab lost its pointer feedback and the <c>+</c> gained a fill and an outline it
/// is specifically not supposed to have. Both are one assertion each below.
/// </para>
/// </remarks>
[Fact]
public async Task ATabLightsUnderThePointer_AndThePlusIsNotDrawnAsATab()
{
await OnTheStripAsync(
(strip, window) =>
{
var tab = TabButton(strip, shell.Tabs[0]);
var resting = Fill(tab);
window.MouseMove(Centre(tab, window));
LayoutHarness.Settle(window, 900, 600);
tab.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
Fill(tab).ShouldNotBe(
resting,
"a tab that does not change under the pointer is one nobody can tell is clickable");
// Off the strip again, so the plus is measured at rest rather than under the pointer.
window.MouseMove(new Point(0, 0));
LayoutHarness.Settle(window, 900, 600);
var plus = PlusButton(strip);
Fill(plus).ShouldNotBe(
Fill(TabButton(strip, shell.Tabs[0])),
"the button that opens a connection is not one of the connections");
Presenter(plus).BorderThickness.ShouldBe(
default(Thickness),
"it carries no outline, because it is not a thing being chosen between");
});
}
// ---- Helpers ----
/// <summary>The presenter the Fluent theme actually paints, which is where every button style lands.</summary>
private static ContentPresenter Presenter(Visual button) =>
button.GetVisualDescendants()
.OfType<ContentPresenter>()
.First(presenter => presenter.Name is "PART_ContentPresenter");
/// <remarks>
/// The colour rather than the brush. Two <see cref="ISolidColorBrush"/> instances holding the same
/// colour are not equal, and it is the colour a user sees.
/// </remarks>
private static Color? Fill(Visual button) =>
Presenter(button).Background is ISolidColorBrush brush ? brush.Color : null;
/// <summary>Two open tabs, laid out in a window the width the application's is.</summary>
private Task OnTheStripAsync(Action<TerminalTabs, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "prod-db", "deploy@db.internal:22"));
shell.Tabs.Add(new TerminalTabViewModel(2, "web-01", "deploy@web-01.internal:22"));
var strip = new TerminalTabs { DataContext = shell };
var window = new Window { Content = strip };
LayoutHarness.Settle(window, 900, 600);
try
{
body(strip, window);
}
finally
{
window.Close();
}
},
Token);
/// <remarks>
/// Found by the class the style system already keys on, rather than by position in the visual tree: the
/// template puts the cross inside the tab, so both buttons carry the same data context and only the
/// classes tell them apart.
/// </remarks>
private static Button TabButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("tab"));
/// <inheritdoc cref="TabButton" />
private static Button CloseButton(Visual strip, TerminalTabViewModel tab) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => ReferenceEquals(button.DataContext, tab) && button.Classes.Contains("close"));
/// <summary>The half of the Vaults pill that opens the vault menu.</summary>
/// <inheritdoc cref="TabButton" path="/remarks" />
private static Button CaretButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("caret"));
/// <inheritdoc cref="TabButton" />
private static Button PlusButton(Visual strip) =>
strip.GetVisualDescendants().OfType<Button>().First(button => button.Classes.Contains("plus"));
/// <summary>One of the three tabs that are always there, found by the word on it.</summary>
/// <remarks>
/// By its label rather than by its position in the strip, so that adding a fourth or reordering the
/// three does not silently point these tests at the wrong one. The class narrows it to a fixed tab
/// first, because a terminal tab could be opened on a host called SFTP.
/// </remarks>
private static Button FixedTab(Visual strip, string label) =>
strip.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("fixed")
&& button.GetVisualDescendants()
.OfType<TextBlock>()
.Any(text => string.Equals(text.Text, label, StringComparison.Ordinal)));
private static void Click(Visual control, Window window)
{
var at = Centre(control, window);
window.MouseDown(at, MouseButton.Left);
window.MouseUp(at, MouseButton.Left);
}
private static Point Centre(Visual control, Visual window) =>
control.TranslatePoint(new Point(control.Bounds.Width / 2, control.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the control is not in this window's tree");
}
@@ -0,0 +1,104 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The titlebar's own search pill still opens the quick-connect palette after v5b's redraw.
/// </summary>
/// <remarks>
/// A narrow suite on purpose: <see cref="LayoutHarnessTests"/> already holds the bar to its declared
/// height and proves nothing inside it is unreachable, and the search pill's own accent-on-hover border is
/// a style rule with nothing to assert headlessly. What is worth a gesture is the one thing a fidelity pass
/// could quietly break without any layout test noticing — the pill no longer opening what Ctrl+K opens.
/// </remarks>
public sealed class TitleBarTests : IAsyncLifetime
{
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"titlebar-{Guid.CreateVersion7():N}");
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
shell = new MainWindowViewModel(
ClientPaths.Default,
caches,
workspace,
new VaultKnownHostStore(),
Substitute.For<IDeviceKeyStore>(),
(_, _) => throw new NotSupportedException("nothing here signs in"),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>())
{
// The search pill is disabled while locked — see TitleBar.axaml's IsEnabled binding — and this
// is the only state that gesture is reachable in.
State = ShellState.Unlocked,
};
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await workspace.DisposeAsync();
caches.Dispose();
}
[Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
shell.IsSearching.ShouldBeFalse("nothing has been pressed yet");
var pill = bar.GetVisualDescendants()
.OfType<Button>()
.First(button => button.Classes.Contains("search"));
var centre = pill.TranslatePoint(
new Point(pill.Bounds.Width / 2, pill.Bounds.Height / 2), window)
?? throw new InvalidOperationException("the pill is not in this window's tree");
window.MouseDown(centre, MouseButton.Left);
window.MouseUp(centre, MouseButton.Left);
shell.IsSearching.ShouldBeTrue(
"the pill is the click-through path Ctrl+K also opens — see ToggleSearchCommand");
}
finally
{
window.Close();
}
},
Token);
}
}
+362 -14
View File
@@ -2789,6 +2789,125 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Status.ShouldContain("host");
}
/// <remarks>
/// ── v5b ── The rail's own count chips, per category — Keychain.dc.html draws a right-aligned mono
/// count beside every category row, and this pins that each one is the same number the category's own
/// list already carries rather than a second, hand-kept tally that could drift from it.
/// </remarks>
[Fact]
public async Task TheCategoryRailCountsMatchTheUnderlyingLists()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddKeyAsync(vault, "backup");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.Keys.Count.ShouldBe(2);
vault.Credentials.Count.ShouldBe(1);
vault.Tags.Count.ShouldBe(1);
vault.ObjectStores.Count.ShouldBe(0);
vault.TotalItemCount.ShouldBe(4, "ALL's own chip counts every kind, buckets and tags included");
}
/// <remarks>
/// ── v5b ── The type glyph Keychain.dc.html draws beside every row's name. Pinned per kind, because a
/// glyph that silently fell back to the same one for two kinds would make ALL unreadable at a glance —
/// which is the whole reason the column exists.
/// </remarks>
[Fact]
public async Task EachVaultItemKindCarriesItsOwnGlyph()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
await AddTagAsync(vault, "production");
vault.ShowSectionCommand.Execute(VaultSection.All);
var byKind = vault.VaultItems.ToDictionary(row => row.Kind, row => row.IconGlyph);
byKind[VaultItemKind.Key].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Credential].ShouldNotBeNullOrEmpty();
byKind[VaultItemKind.Tag].ShouldNotBeNullOrEmpty();
new[] { byKind[VaultItemKind.Key], byKind[VaultItemKind.Credential], byKind[VaultItemKind.Tag] }
.Distinct(StringComparer.Ordinal).Count()
.ShouldBe(3, "three kinds on the same table read as three different glyphs");
}
/// <remarks>
/// ── v5b ── The filter box Keychain.dc.html adds to the table's own sub-toolbar — this table never had
/// one before. It has to narrow and nothing else: a row it hides is still in the underlying list, and
/// clearing it brings every row straight back.
/// </remarks>
[Fact]
public async Task TheItemFilterNarrowsTheMergedTableByNameOrType()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddKeyAsync(vault, "deploy-key");
await AddCredentialAsync(vault, "pg-primary", "s3cret");
vault.ShowSectionCommand.Execute(VaultSection.All);
vault.ItemFilter = "deploy";
vault.VaultItems.Select(row => row.Name).ShouldBe(["deploy-key"]);
vault.ItemFilter = "PASSWORD";
vault.VaultItems.Select(row => row.Name).ShouldBe(
["pg-primary"], "the type word matches too, case-insensitively");
vault.ItemFilter = string.Empty;
vault.VaultItems.Count.ShouldBe(2, "clearing the filter is not a second deletion");
}
/// <remarks>
/// ── v5b ── The detail pane's own USED BY list and "in use" chip, and the table's USED BY column — all
/// three read the same resolved-binding scan <c>HostsBoundTo</c> already used for the deletion warning,
/// so this pins that a host genuinely bound to a key shows up in all three rather than in only one of
/// them going stale relative to the others.
/// </remarks>
[Fact]
public async Task AKeyBoundToAHost_ShowsUpInTheUsedByFactsEverywhereTheyAreDrawn()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-api-01");
await AddKeyAsync(vault, "deploy");
var key = vault.Keys[0];
await BindKeyAsync(vault, Host(vault, "prod-api-01"), key.EntityId);
vault.ShowSectionCommand.Execute(VaultSection.All);
var row = vault.VaultItems.Single(item => item.Kind is VaultItemKind.Key);
row.UsedBySummary.ShouldBe("prod-api-01");
row.HasUsedBySummary.ShouldBeTrue();
vault.SelectedVaultItem = row;
vault.HasSelectedItemUsedByHosts.ShouldBeTrue();
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("prod-api-01");
vault.SelectedItemInUseSummary.ShouldBe("in use · 1 host");
// And a key nothing authenticates with says none of this — never a zero-count claim.
await AddKeyAsync(vault, "unused");
vault.SelectedVaultItem = vault.VaultItems.Single(
item => string.Equals(item.Name, "unused", StringComparison.Ordinal));
vault.HasSelectedItemUsedByHosts.ShouldBeFalse();
vault.SelectedItemInUseSummary.ShouldBe(string.Empty);
}
// ---- Keeping an open editor's pickers in step with the vault ----
/// <remarks>
@@ -5639,7 +5758,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
shell.Transfers.Status.ShouldContain("password");
}
// ---- The terminal pin strip ----
// ---- The v5b session sidebar's QUICK ACCESS ----
/// <summary>
/// Sets up a host with one bound key and one pin, and connects a terminal to it. Returns the vault, with
@@ -5666,16 +5785,16 @@ public sealed class ShellFlowTests : IAsyncLifetime
}
[Fact]
public async Task ThePinStrip_ShowsTheConnectedTabsHostsPins()
public async Task TheSidebar_ShowsTheConnectedTabsHostsPins()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ActiveTabPinnedPaths.ShouldBe(["/var/www/app"]);
}
[Fact]
public async Task ThePinStrip_StaysHiddenBeforeAnythingConnects()
public async Task TheSidebar_StaysHiddenBeforeAnythingConnects()
{
var vault = await ReadyToConnectAsync();
@@ -5684,14 +5803,20 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.AddEditorPinCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
// Pinned, but nothing has dialled it yet — the strip is keyed to a connected tab, not to the host
// that happens to be selected on the hosts screen.
shell.ShowsPinStrip.ShouldBeFalse();
// Pinned, but nothing has dialled it yet, and no tab exists for the sidebar to be about — it is keyed
// to a selected tab, not to the host that happens to be selected on the hosts screen.
shell.ShowsQuickAccessSidebar.ShouldBeFalse();
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
/// <remarks>
/// v5b widened the sidebar's own gate from "this host has pins" to "a session is in focus" — the sidebar
/// draws QUICK ACCESS's own "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on
/// a host that has pinned nothing yet. So a connected tab with no pins now shows the sidebar with an empty
/// QUICK ACCESS list rather than hiding it, which is the opposite of what the old pin strip did.
/// </remarks>
[Fact]
public async Task ThePinStrip_StaysHiddenForAHostWithNoPins()
public async Task TheSidebar_ShowsWithAnEmptyQuickAccessForAHostWithNoPins()
{
var vault = await ReadyToConnectAsync();
@@ -5703,28 +5828,45 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.ChooseHostCommand.Execute(Host(vault, "prod-db"));
await vault.ConnectToChosenHostCommand.ExecuteAsync(null);
shell.ShowsPinStrip.ShouldBeFalse("this host pins nothing");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("a session is open, even though this host pins nothing");
shell.ActiveTabPinnedPaths.ShouldBeEmpty();
}
[Fact]
public async Task ThePinStrip_HidesWhenTheSurfaceLeavesTheTerminal()
public async Task TheSidebar_HidesWhenTheSurfaceLeavesTheTerminalOrSftp()
{
await ConnectedHostWithAPinAsync();
shell.ShowsPinStrip.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue();
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.ShowsPinStrip.ShouldBeFalse("a page is showing, not the terminal the strip sits above");
shell.ShowsQuickAccessSidebar.ShouldBeFalse(
"a page is showing, not the terminal or SFTP the sidebar sits beside");
shell.SelectTabCommand.Execute(shell.Tabs[0]);
shell.ShowsPinStrip.ShouldBeTrue("back on the terminal surface, with the same tab selected");
shell.ShowsQuickAccessSidebar.ShouldBeTrue("back on the terminal surface, with the same tab selected");
}
/// <remarks>
/// The pin strip's click handler, exercised through the fake SFTP factory rather than mocked: the
/// The other surface the sidebar draws on since v5b: SFTP, gated on <c>Transfers.IsConnected</c> rather
/// than on a selected tab, since a session on that screen is its own connection — see
/// <c>MainWindowViewModel.ShowsQuickAccessSidebar</c>.
/// </remarks>
[Fact]
public async Task TheSidebar_ShowsOnTheSftpSurfaceOnceConnected()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.IsTransfersShowing.ShouldBeTrue();
shell.ShowsQuickAccessSidebar.ShouldBeTrue("the SFTP surface has a connected host of its own now");
}
/// <remarks>
/// The sidebar's QUICK ACCESS click handler, exercised through the fake SFTP factory rather than mocked: the
/// terminal connection and the SFTP one are both real <c>ISshConnectionFactory</c>/
/// <c>ISftpSessionFactory</c> calls against <c>FakeSshConnectionFactory</c>, so this is proof the two
/// really are the second authenticated connection the design docs say they are — SftpRequests gets an
@@ -5746,6 +5888,129 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.Hosts[0].IsConnected.ShouldBeTrue("the terminal session is untouched by opening a files pane");
}
// ---- The v5b session shell: tab rows, header/status-bar facts, cross-surface buttons ----
/// <remarks>
/// The SFTP tab row's click, resolved through the same "Browse files" plumbing a pin click already uses —
/// see the deviation recorded on <c>MainWindowViewModel.SelectFilesHostCommand</c>.
/// </remarks>
[Fact]
public async Task SelectingATabsFilesOpensSftpAtThatHostAndMarksTheTabSelected()
{
await ConnectedHostWithAPinAsync();
var tab = shell.Tabs[0];
shell.SelectedTab = null;
await shell.SelectFilesHostCommand.ExecuteAsync(tab);
shell.SelectedTab.ShouldBe(tab, "the tab row's own active mark reads IsSelected");
shell.IsTransfersShowing.ShouldBeTrue();
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.Transfers.IsConnected.ShouldBeTrue();
}
/// <remarks>
/// The header's "Open terminal" button on the SFTP surface — the other half of the two cross-surface
/// directions the v5b notes ask for, through <c>VaultViewModel.ConnectCommand</c> rather than through a
/// tab that does not exist.
/// </remarks>
[Fact]
public async Task OpeningATerminalFromSftpConnectsANewTerminalToTheBrowsedHost()
{
await ConnectedHostWithAPinAsync();
await shell.OpenPinnedPathCommand.ExecuteAsync("/var/www/app");
shell.Transfers.IsConnected.ShouldBeTrue();
var tabsBefore = shell.Tabs.Count;
await shell.OpenTerminalForFilesHostCommand.ExecuteAsync(null);
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "a new terminal connected to the browsed host");
shell.SelectedTab.ShouldNotBeNull().Label.ShouldBe("prod-db");
shell.IsTerminalShowing.ShouldBeTrue();
}
/// <remarks>
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
/// that same mechanism rather than reimplementing it.
/// </remarks>
[Fact]
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
{
await ConnectedHostWithAPinAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
var row = snippets.Visible.ShouldHaveSingleItem();
await shell.InsertSnippetCommand.ExecuteAsync(row);
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
}
[Fact]
public async Task AddingASnipFromTheSidebar_OpensTheSnippetsScreenWithTheEditorOpen()
{
await UnlockedAsync();
shell.AddSnippetFromSidebarCommand.Execute(null);
shell.IsSnippetsShowing.ShouldBeTrue();
shell.SnippetsScreen.ShouldNotBeNull().IsEditing.ShouldBeTrue();
}
/// <remarks>
/// The closest honest affordance the v5b notes ask for: this application cannot open a host editor
/// scrolled to one card, so "+ Pin folder" opens the whole editor on the active tab's host, the same as
/// the hosts screen's own EDIT does.
/// </remarks>
[Fact]
public async Task PinningAFolderFromTheSidebar_OpensTheActiveTabsHostEditor()
{
await ConnectedHostWithAPinAsync();
var vault = shell.Vault!;
shell.PinFolderFromSidebarCommand.Execute(null);
shell.IsHostsShowing.ShouldBeTrue();
vault.IsEditing.ShouldBeTrue();
vault.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
}
/// <remarks>
/// The status bar's facts, read off the selected terminal tab. <see cref="MainWindowViewModel.SessionElapsedText"/>
/// is real, not fabricated: <c>StartedAt</c> is set from the shell's own clock at the moment the session
/// opens, and this reads it back through the same clock.
/// </remarks>
[Fact]
public async Task SessionFacts_ReflectTheSelectedTerminalTab()
{
await ConnectedHostWithAPinAsync();
shell.IsSessionConnected.ShouldBeTrue();
shell.SessionAddress.ShouldBe(shell.Tabs[0].Address);
shell.SessionElapsedText.ShouldNotBeNull().ShouldStartWith("session ");
}
/// <remarks>
/// The honesty rule stated as a test: with nothing open, the status bar has no facts to show rather than
/// a blank or a placeholder standing in for them.
/// </remarks>
[Fact]
public async Task SessionFacts_AreAbsentWithNoSessionOpen()
{
await UnlockedAsync();
shell.IsSessionConnected.ShouldBeFalse();
shell.SessionAddress.ShouldBeNull();
shell.SessionElapsedText.ShouldBeNull();
}
[Fact]
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanAnotherOne()
{
@@ -6593,6 +6858,40 @@ public sealed class ShellFlowTests : IAsyncLifetime
snippets.Status.ShouldContain("no longer connected", Case.Sensitive);
}
/// <remarks>
/// ── v5b ── The desktop's own delete confirmation, additive beside the phone's uncounted DELETE — see
/// <c>SnippetsViewModel.RequestDelete</c>'s own remarks for why the two are separate commands. Pins the
/// two-step shape every other keychain item's deletion already has: arming changes nothing, and only
/// confirming actually removes it.
/// </remarks>
[Fact]
public async Task RequestingDeleteOnASnippet_AsksFirstAndChangesNothingUntilConfirmed()
{
await UnlockedAsync();
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
await AddSnippetAsync(snippets, "restart the api", "sudo systemctl restart dodossh-api", runs: false);
snippets.Selected = snippets.Visible.Single();
snippets.RequestDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeTrue();
snippets.DeleteQuestion.ShouldContain("restart the api");
snippets.ShowsSelectionActions.ShouldBeFalse("the confirm card takes the insert controls' place");
snippets.Visible.ShouldHaveSingleItem("arming the question deletes nothing by itself");
snippets.CancelDeleteCommand.Execute(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldHaveSingleItem("cancelling leaves the snippet exactly where it was");
snippets.RequestDeleteCommand.Execute(null);
await snippets.ConfirmDeleteCommand.ExecuteAsync(null);
snippets.IsConfirmingDelete.ShouldBeFalse();
snippets.Visible.ShouldBeEmpty();
}
private static SnippetsViewModel SnippetsOver(
VaultViewModel vault,
InsertTarget target,
@@ -6646,6 +6945,55 @@ public sealed class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null);
}
// ---- Logs ----
/// <remarks>
/// ── v5b ── Logs.dc.html's own two-segment CONNECTIONS/KEYCHAIN control, restyled onto the rail's own
/// track-and-segment idiom — see LogsScreen.axaml's own remark on why two segments and not three. Pins
/// that <c>LogSection</c> genuinely has only the two values the segments name, and that the two flags a
/// segment's own active state reads are mutually exclusive.
/// </remarks>
[Fact]
public async Task TheLogsScreenSwitchesBetweenExactlyTheTwoRealSections()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.Section.ShouldBe(LogSection.Connections, "the screen opens on connections");
logs.ShowsConnections.ShouldBeTrue();
logs.ShowsActivity.ShouldBeFalse();
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.ShowsActivity.ShouldBeTrue();
logs.ShowsConnections.ShouldBeFalse("the two flags are one fact read two ways and cannot both be true");
logs.ShowSectionCommand.Execute(LogSection.Connections);
logs.ShowsConnections.ShouldBeTrue();
Enum.GetValues<LogSection>().Length.ShouldBe(2, "the design draws two segments and the enum has two");
}
/// <remarks>
/// ── v5b ── The header's own status sentence — LogsViewModel.HeaderStatusLine — falls back to the per-
/// section fact this type's header comment already states truthfully, and steps aside for a refresh
/// error when refreshing just produced one.
/// </remarks>
[Fact]
public async Task TheHeaderStatusLineNamesTheSectionsOwnFactAndYieldsToARefreshError()
{
await UnlockedAsync();
var logs = shell.LogsScreen.ShouldNotBeNull();
logs.HeaderStatusLine.ShouldBe("an entry is written once, when a connection closes");
logs.ShowSectionCommand.Execute(LogSection.Activity);
logs.HeaderStatusLine.ShouldBe("one row per write, per device");
logs.Status = "network unreachable";
logs.HeaderStatusLine.ShouldBe("network unreachable", "a live refresh error outranks the section fact");
}
// ---- Helpers ----
private static CancellationToken Token => TestContext.Current.CancellationToken;
@@ -639,7 +639,13 @@ public sealed class VaultVisibilityTests : IAsyncLifetime
vault.IsEditing.ShouldBeFalse(vault.Status);
}
/// <summary>Switches a vault off through the menu, as the tab strip does.</summary>
/// <summary>
/// Switches a vault off through <see cref="MainWindowViewModel.ToggleVaultCommand"/> — the command
/// behind a vault row in the rail's own user popover now, not the tab strip's old vault menu, which is
/// gone; see <c>NavRail.axaml</c>. This project is deliberately Avalonia-free, so what this file proves
/// is the command's own effect; that the popover's row is wired to call it is
/// <c>DodoSSH.Client.App.Layout.Tests.NavRailTests</c>' business.
/// </summary>
private async Task HideAsync(Guid vaultId)
{
var toggle = shell.VaultToggles.Single(row => row.VaultId == vaultId);