3 Commits
Author SHA1 Message Date
jaap-jan c59b517fdf Record v5c in the design-import log, and true up the manual checks
ci / build and test (push) Failing after 2m14s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m27s
2026-08-08 14:17:20 +02:00
jaap-jan bb2f973687 Redraw the host keys screen with its pins' own facts beside it 2026-08-08 14:17:19 +02:00
jaap-jan c8507b44fe Give the application a settings area built from what really exists 2026-08-08 14:17:19 +02:00
45 changed files with 4184 additions and 1242 deletions
+59 -2
View File
@@ -287,9 +287,9 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | 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. |
> | The S3/Buckets screen | **Did not get the session shell in v5b.** `TransfersScreen` serves both SFTP and S3 today and only the SFTP usage in `MainWindow.axaml` sat inside the new tab row/header/status bar/sidebar; the S3 usage was unchanged at the time. **v5c gives it the shell's own look without the machinery** — a 26-pixel padded, bordered, radius-12 container and nothing past that, since a bucket has no tab to close, no host to head a card with and no pin for a sidebar to show; see the v5c section, below. |
> | 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. |
> | The popover's Settings and Preferences rows, and the design's own Settings-* family of screens | **Landed in v5c.** What was two doors to one room in v5b — Settings and Preferences both opening the same bare `Preferences` screen — is now two of three doors onto their own settings pages: Settings opens General, Preferences opens Preferences, and a third row, Vaults, opens Vaults. All three are real, distinct pages inside one settings mode; see the v5c section, below. |
> | `· 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. |
@@ -307,6 +307,63 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | 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. |
>
> ## The desktop's v5c — a settings area, at last
>
> An eighth pass, and the first to give the design's own Settings area a home of its own rather than folding
> its two real screens into the rail's popover. Settings is a full-window **mode** now, swapped in wholesale
> rather than laid over anything: its own 53px titlebar reading "Back to application", a 340px `SettingsNav`
> rail — SETTINGS (General, Vaults, Account) above CUSTOMIZE (Security, Preferences, Groups, Tags), Logout
> pinned below both — and a content column capped at the design's own 1100 pixels, which is 741 beside the
> rail at the window's own minimum rather than the full 1100 (`LayoutHarness.SettingsContentWidth`). Seven
> pages, not the design's eight: the ORGANISATION section and its one page are refused outright, below. Two
> of the seven are wholly new — Account and Security did not exist as screens before this pass — and the
> other five are the old `PreferencesScreen.axaml` and `VaultsScreen.axaml` split apart and restyled into the
> design's own card idiom; both files are deleted, and every command either one offered is reachable here
> exactly once. The popover's own Settings, Vaults and Preferences rows, and everywhere else in the codebase
> that used to navigate to the bare `Preferences` or `Vaults` screen, now land in this mode instead —
> `MainWindowViewModel.ShowScreen` redirects at that one point rather than at every caller that used to reach
> either screen directly.
>
> The importer moved inside this same chrome rather than staying a screen of its own: it draws over the
> Preferences page as a boolean overlay — `SettingsNav` stays lit on Preferences the whole time it is open,
> and the titlebar's own back button reads "Back to preferences" instead of "Back to application" — restyled
> to the design's own table (ALIAS/HOSTNAME/USER/PORT/WHAT THIS MEANS, one header tick-all button in place of
> the old TICK ALL/TICK NONE pair). Known Hosts was restyled the same pass but kept its own place: it stays a
> main-chrome screen, reached from the rail's Keys entry and now also from Security's own "Approved host
> keys" row, because the design draws no page for it at all. And the S3/Buckets surface — which got none of
> v5b's session shell — picked up that shell's *look* this time without its machinery: a 26-pixel padded,
> bordered, radius-12 container and nothing else, since a bucket has no tab row, no host to head a card with,
> no status-bar fact to print and no pin for a sidebar to show.
>
> | v5c element | What ships instead |
> | --- | --- |
> | The Settings-Organisation page, and the rail's own ORGANISATION section | Refused outright. No organisation entity exists anywhere in this product — a team is the membership list behind a shared vault, and there is exactly one tenant per deployment — so the rail simply has no third section; see `SettingsNav.axaml`'s own remark. |
> | General: the update-channel switcher, Launch at login, Reopen tabs, the theme control and the language picker | Refused, and carried on the page's own NOT BUILT YET card rather than left silently missing: which channel a copy follows is fixed when it is built, nothing registers this application with Windows' own startup list or remembers a tab list across a launch, only the one dark theme exists, and there is no i18n anywhere in this client. |
> | Vaults: the VAULT DEFAULTS card (auto-lock, require-password-on-unlock, relay) and the RECOVERY card (kit, export) | Both refused outright — none of the three settings exists, sync is always on, and there is no recovery kit and no export. |
> | Vaults: "Manage devices", and the "3 devices" count on the sync line | Refused. There is no list-devices endpoint anywhere in this client. |
> | Vaults: per-vault UNLOCKED/LOCKED chips, and the "Unlock ⟨vault⟩" modal | Refused. This application locks the keychain as a whole, not one vault at a time, so there is no per-vault state for a chip or a modal to act on; the keychain-level fact is the sync card's own dot instead. |
> | Vaults: the magenta "Default" badge | Dropped rather than faked. The app does track a "new items go to" vault, but that lives on a different view model than the row being drawn here, and cross-referencing the two per card would be more machinery than the badge is worth. |
> | Vaults: "Sorted by name ▾" | Decorative in the mock, and not drawn — `VaultsViewModel.Vaults` is already ordered personal-first-then-name, and there is no second order to switch to. |
> | Vaults: member avatar stacks on every card | Refused on an unselected card — `VaultRowViewModel` carries a member *count*, not the members themselves, and only the selected vault's own member list is actually loaded. A count stands in on the card; avatars are real in the members panel, where the data is. |
> | Account: Edit profile, Change passphrase, the DEVICES list, Sign out everywhere | All four refused — no endpoint exists for any of them, and the passphrase is a one-time enrollment choice with no change flow. What is new and real on this page instead: the SIGN-IN card's issuer sentence, `MainWindowViewModel.Issuer` off `MeResponse.Issuer`, cached the same turn the name and email are and surfaced here for the first time — "Signing in proves who you are — it never decrypts a vault." |
> | Security: the strict-host-key toggle, the allowed-algorithms chips, the auto-lock link row | Refused — the app always asks on a changed key, there is no algorithm allow-list anywhere in the SSH stack, and there is no auto-lock setting for the link to point at. |
> | Security: RECENT SECURITY EVENTS as a list of rows | Shipped as the link alone, "See all activity in Logs." A real list would mean a filtered read over `LogsViewModel`'s two logs, and which entries count as "security" is a judgement call the design does not resolve; the honest link is complete on its own. |
> | Preferences: the device-name editor, Font/Cursor/Scrollback/Copy on select/Terminal bell, the clipboard-clear delay, confirm-run-on-insert | All refused, on the page's own carried-over NOT-BUILT idiom — none of the seven has anything behind it. Windows Hello's register/"Stop unlocking here" pair moved off this page to Security instead, one home rather than two. |
> | Groups: drag-to-reorder, and the mock's "the order here is the order there" sentence | Refused. `HostGroupRowViewModel` orders by the vault new items go into, then by vault name, then by label — there is no manual order to drag into. The page prints the true sentence instead and draws no drag handles. |
> | Tags: the LAST APPLIED column | Refused — no timestamp of when a tag was last put on a host exists anywhere in this client. |
> | Tags: a per-tag vault chip | Refused — `TagRowViewModel` carries a label and a host count and nothing else; there is no vault id on the row for a chip to read. |
> | Import: the "saving to ⟨vault⟩ ▾" picker | Refused as a control. Every import writes through the same `session.ActiveVaultId` every other bulk write does, so there is no per-import target to choose — the footer prints the vault's name as a fact, `ImportViewModel.SelectionSummary`, rather than as a `▾`. |
> | Import: the old ADDRESS/AUTHENTICATION/STATE columns | Folded into the design's own column set — ALIAS/HOSTNAME/USER/PORT — with the authentication text moved onto the alias cell's own tooltip rather than kept as a column of its own. |
> | Import: TICK ALL / TICK NONE | Replaced by the design's own header tick-all button, one press that ticks or unticks every row through `ImportRowViewModel.ToggleAllCommand`. |
> | Import as a nav-rail destination | Refused. It is a boolean overlay over the Preferences settings page (`MainWindowViewModel.IsImportOpen`), not a page of its own — `SettingsNav` stays lit on Preferences throughout, exactly as `Import.dc.html` draws it. |
> | The design's fixed 1100px content column | `MaxWidth="1100"` rather than a fixed width — the column is 741 pixels beside the 340px rail at the window's own minimum, where a fixed 1100 would not fit. |
> | The status bar and the update banner, while in settings mode | Both hidden — `MainWindow.axaml`'s bottom two rows read `!IsSettingsMode`. The design's own settings titlebar has no room for either, and there is nowhere honest to draw them instead. |
> | The design's sentence-case button copy | Not adopted. Every button on every settings page keeps this application's own ALL-CAPS mono convention — `CHECK NOW`, `SIGN OUT`, `OPEN IMPORTER` — over the mock's own "Check now". |
> | The design's assumption that Settings is the only thing on screen | Not followed. The quick-connect palette and an unapproved host key's decision card both still draw over settings mode exactly as they interrupt every other screen — a connection question does not stop mattering because the window happens to be showing Settings; see `MainWindow.axaml`'s own remark on the ordering of its Panel. |
> | The importer's own sentence, "This is the only control in DodoSSH that opens key material from a directory you did not point at file by file — nothing is read until Import is pressed." | Adopted verbatim — verified against `ImportViewModel.ScanAsync` and `ImportAsync`: scanning never calls `SshConfigLocator.ReadIdentity`, and `ImportAsync` is the only path that ever does. |
> | The tags page's own sentence, "Renaming here is one write and every host follows." | Adopted verbatim — verified against `SaveTagAsync`: a host names a tag by id, never by label, so a rename touches nothing but the tag item itself. |
> | The known-hosts screen's own intro, "Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." | Adopted verbatim, matching the stance the rest of this application already takes on a pin. |
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
+126 -50
View File
@@ -115,6 +115,61 @@ vault that authenticates with a key filed in the switched-off one still connects
`VaultViewModel.IsVaultShown` for the list, and `VaultVisibilityTests` for the same assertions made against
view models — this check is the version with a real connection on the end of it.
### 1.8 Entering and leaving settings mode
From HOSTS, press the user chip at the foot of the nav rail and choose **Settings**. Then repeat from a
fresh open, choosing **Vaults**, and again choosing **Preferences**.
**Pass:** each of the three swaps the whole window's chrome — the titlebar becomes the 53px "Back to
application" bar, the ordinary nav rail is replaced by the 340px SettingsNav, and the status bar and the
update banner (if either was showing) are both gone. **Settings** lands on General; **Vaults** and
**Preferences** land on their own pages, each lit in SettingsNav's own list — General/Vaults/Account under
SETTINGS, Security/Preferences/Groups/Tags under CUSTOMIZE, Logout pinned below both. Click every one of the
seven rows in turn: each swaps the content column without leaving settings mode or touching the titlebar.
Now open a terminal, leave it showing, and open Settings from the user chip again. Press **Back to
application** — or, without touching anything else, press Esc.
**Pass:** both return to exactly the screen that was showing before Settings was opened — the terminal,
still running, rather than HOSTS or anywhere else. Switching between settings pages first (General → Vaults
→ Preferences) does not change what "back" goes back to; only the screen open the moment Settings was
**first** entered does.
**Failure means:** a return to HOSTS regardless of where Settings was opened from is
`MainWindowViewModel.settingsReturnScreen` being recaptured on every `EnterSettings` call rather than only on
the way in from outside settings mode — see that field's own remark. A status bar or update banner still
visible in settings mode is `MainWindow.axaml`'s two bottom rows no longer reading `!IsSettingsMode`. Esc
doing nothing is `MainWindow.axaml.cs`'s `OnKeyDown` no longer checking `IsSettingsMode` after the palette's
own branch.
### 1.9 The importer's own home, and what still reaches through settings mode
From Settings → Preferences, press **OPEN IMPORTER**.
**Pass:** the titlebar's back button relabels itself "Back to preferences" — not "Back to application" — and
SettingsNav stays lit on **Preferences** the whole time; the importer is drawn over the Preferences page
rather than being a destination of its own. Press SCAN AGAIN and let it read your `~/.ssh/config`.
Press **Back to preferences** (or Esc). **Pass:** the Preferences page is back, still inside settings mode,
and the titlebar's label reverts. Press Esc a second time (or **Back to application**): now settings mode
itself closes, back to whatever screen was open before Settings was entered — the same two-deep
"closest thing first" order the titlebar's own two buttons and `MainWindowViewModel.OpenImport`/`CloseImport`
follow.
**Then, with the importer open, press Ctrl+K** and connect to a host from the palette.
**Pass:** it works exactly as it does anywhere else in the application — the palette opens over the
importer, and connecting leaves settings mode outright for the new terminal, the same way choosing any
ordinary nav rail destination does through `MainWindowViewModel.ShowScreen`. An unapproved host's key card
comes up over settings mode the same way it would over any other screen.
**Failure means:** a back button that never changes label is `SettingsTitleBar.axaml`'s two buttons both
bound to the same side of `IsImportOpen`. SettingsNav lighting a different row while the importer is up is
`MainWindowViewModel.IsSettingsPreferencesPage` reading `IsImportOpen` when it must not —
`Import.dc.html` draws the rail unmoved on purpose; see the remark on `IsImportOpen`. Ctrl+K doing nothing
over the importer would be a guard added to `OnKeyDown` for `IsSettingsMode` that the design never asked for
and this application's own quick-connect card was built to reach past.
---
## Phase 2 — Known Hosts as its own page
@@ -200,7 +255,7 @@ The parser has 22 cases over the shapes a real file contains, and the end-to-end
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
Preferences → IMPORT HOSTS → SCAN. Do not press import yet.
Settings → Preferences → OPEN IMPORTER, then SCAN AGAIN. Do not press Import N hosts yet.
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
above the table account for anything missing.
@@ -364,61 +419,81 @@ card, nothing saying the hosts are unfiled.
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
### 3.2 Filing hosts, and the grid being one level at a time
### 3.2 Filing hosts, and the board drawing every group at once
Make two groups and file some hosts into each through the host editor, leaving at least one host unfiled.
Make two groups — the Group ▾ flyout's "New group…" — and file some hosts into each, once through a host's
own editor and its GROUP picker, and once by ticking several hosts and choosing **Change group…** from the
right-click menu. Leave at least one host unfiled.
**Pass:** the filed hosts **leave the grid** as they are filed — a group is a place, not a label, and the
grid holds one level of it the way a directory pane holds one directory. What is left is the unfiled hosts.
**There is no heading and no fold on the desktop** — the headings, their chevrons and UNGROUPED are the
phone's, whose list has no room for a row of group cards and draws the whole tree flat instead. With every
host filed, the grid says so in a sentence rather than sitting empty.
**Pass:** every group is a heading on the board **at once** — in label order, "No group" first where it is
occupied — with its own hosts underneath. There is no double-click, no card to open a group into and no
breadcrumb trail: v3's one-level-at-a-time grid is gone outright, and every section is on screen from the
moment the board is. A host filed under `production` is drawn once, under that heading, and nowhere else on
the board.
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the
level it was, and no buttons appear beside the GROUPS heading: editing and deleting a group are on the card's
own right-click menu, which is 7.9. **Then double-press it.** The group opens: its hosts
are the grid, the trail above the cards reads `ALL HOSTS <name> `, each card carrying the group's name as
an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain.
Pressing ALL HOSTS goes back to the outermost level.
**Then press the chevron beside one heading.** It folds — the heading stays, with its live count, and its
cards go — and pressing it again brings the cards back. **Then press "Collapse all"**, drawn on the first
heading only. It folds every section at once and its own label swaps to "Expand all"; opening any one heading
by hand swaps the label back to "Collapse all".
**Then type a filed host's name into the find box at the top.** It is found from the outermost level,
wherever it was filed and however deep, with the chip on its card saying which group it came out of. Clearing
the box puts it away again. Inside a group the same box reaches that group and everything under it, and the
empty-grid sentence there offers ALL HOSTS as the way to widen it.
**Then type a filed host's name into the find box at the top, with its heading folded.** **Pass:** the
heading's own count narrows to match — the count is read off the set the find box has already narrowed,
before the fold is looked at — but the cards themselves stay hidden until that heading is reopened; open it
and the narrowed set is there. Clear the box and every count returns.
**Failure means:** if one press still narrows the grid, the card `ListBox` is bound to the wrong property —
`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. Filed hosts still on the
outermost level is `Matches` no longer comparing the host's group to the open one. A search that cannot find
a filed host is worse than either: it is the box answering "no host matches that" about a machine the
keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through
`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
**Failure means:** a host drawn under a heading that is not its own, or under two at once, is
`VaultViewModel.AddFlatSection`'s membership test reading the wrong field for that section —
`host.Host.GroupId == group.EntityId` for a named heading, everything else for "No group". A chevron that
folds every section rather than the one it sits beside is `ToggleGroupCommand` being handed the board rather
than the single `SidebarGroupHeader` it was pressed on. A count that does not move with the find box is
`MatchesHostBoardFilters` not running before `HostSections` is rebuilt — see
`VaultViewModel.RebuildHostSections` and `OnHostFilterChanged`.
### 3.2a A group inside a group, and the way back out
### 3.2a A group nested under another, and what a host filed under it inherits
Make two groups and file one under the other with the parent picker in the group editor.
Make an outer group with a default port and no default username, then make a second group whose editor's
PARENT picker points at the outer one, and give this inner group no default port of its own. File a host
under the inner group with nothing set on the host itself.
**Pass:** only the outer group has a card to start with. Double-press it and the inner one is the only card
shown, with the trail reading `ALL HOSTS <outer> `. Double-press that, and the cards disappear entirely —
it has nothing inside it — while the trail stays. Pressing the **middle** crumb goes back one level rather
than all the way out, which is also how a group with nothing inside it is renamed: back out to the level
where it has a card, and right-click that.
**Pass:** both groups are their own headings on the board, side by side — nesting is never drawn on the board
itself, only carried in the group editor's own PARENT field. The inner heading's count is only the hosts
filed directly under it, and the outer's is only the hosts filed directly under the outer one; the host filed
under the inner group is not counted on the outer heading. Connect the host anyway: it dials the **outer**
group's port. `HostInheritance.Chain` walks past the inner group's own silence on that field rather than
stopping at the nearest group regardless of whether it answered. Give the inner group its own default port
and reconnect: the host now dials that one instead, because the nearer group's answer wins once there is one.
**Failure means:** cards for groups that are not at this level is `VisibleGroups` having been bound past —
the flat `Groups` is the phone's and the lookups'. A group that cannot be reached at all is worse and is the
case `EffectiveParents` promotes: see 3.4a.
**Then open the inner group's own editor and look at its PARENT picker.** The outer group is offered. **Then
open the outer group's own editor and look at its PARENT picker.** The inner group is **not** offered — a
group found by walking down from the outer one is refused as its own parent, which is what keeps this one
machine, acting alone, from building a cycle. (Two machines can still build one offline between them; that is
3.4a, below.)
### 3.2b Making something while standing inside a group
**Failure means:** a host that dials 22 with the outer group's port set is the chain stopping at the first
group above the host rather than reading each field independently from the nearest group that states it — see
`HostInheritance.Resolve`'s own remarks. A parent picker offering a group's own descendant is
`VaultViewModel.BuildGroupParentChoices` walking the wrong direction: each candidate has to be walked
*upward*, through `HostInheritance.Chain`, to see whether it passes through the group being edited — a
downward index of children is what the alternative would need, and this view model keeps none.
Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
### 3.2b Making a host or a group inherits nothing from where the board is scrolled
**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
group as its parent, so the new group is a card inside the one that is open.
Scroll the board so one group's section fills the window, or fold every other section away, then press
**+ New host**. Afterwards, open the Group ▾ flyout and press **New group…**.
**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
difference between them: the host editor also takes a merely *selected* card as its group, the group editor
takes only the group that is open.
**Pass:** the host editor opens with its GROUP picker on **"No group"**, and the group editor opens with its
PARENT on **"No group"** too — neither reads anything from which heading happens to be on screen or scrolled
to. This is deliberate: the level-at-a-time grid these two commands used to inherit an "open group" from is
gone, and nothing on the flat board replaced that context. Pick a group by hand in either picker and it
stays picked — the host or the new group lands there once saved.
**Failure means:** an editor that opens already filed under whichever section happened to be on screen is
`VaultViewModel.GroupTarget` — the fallback both `NewHost` and `NewGroup` still read — having been wired to a
live selection again. Read that property's own remarks before treating this as a regression: it is an alias
for the single-select `GroupFilter`, which nothing in the current toolbar ever assigns any more — the Group ▾
flyout ticks a set, `checkedGroupFilterIds`, a different field entirely — so today the fallback is dead code
rather than a path either `+` button takes. Restoring it as live board context would make both commands
context-sensitive in a way nothing on this board signals before the fact.
### 3.3 Deleting a group with hosts in it
@@ -1618,7 +1693,7 @@ newcomer **must not have signed in to this deployment before** — 12.1 is about
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
1. Sign in as `alice`, go to Settings → Vaults, make a vault there, and select it.
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
@@ -1716,7 +1791,7 @@ vault is now flagged for rekey, the transfer is removing the outgoing owner rath
Look for a way to remove a vault, on both heads.
**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a
**Pass:** there is none, and Settings → Vaults says why in a sentence: nothing in this product removes a
vault, and the server refuses to archive the membership list behind one while it exists. Archiving that
list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what
is being checked here is that no button offers it.
@@ -1727,7 +1802,7 @@ button that always refuses is the milder failure and is still worth removing.
### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts**
Rename a shared vault from the VAULTS screen.
Rename a shared vault from Settings → Vaults.
**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 file to" picker, in the host editor's vault picker, and in the nav rail's user-chip
@@ -2149,10 +2224,11 @@ fifteen seconds comes from.
### 16.5 The version on screen is the version that was built
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
Right-click `DodoSSH.exe` → Properties → Details, and open Settings → General.
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
preferences screen prints the same number.
General page's UPDATES card prints the same number — the block moved there from Preferences in v5c, and
Preferences itself carries no version line any more.
**Failure means:** `0.0.0.0` is MinVer never seeing a tag — a shallow clone, or `fetch-depth` having been
dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's inert `assemblyIdentity`
@@ -2174,7 +2250,7 @@ script warns rather than failing when that is legitimate, which is the first rel
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
v0.1.1 (`-Upload`). Then press CHECK NOW on PREFERENCES rather than waiting six hours.
v0.1.1 (`-Upload`). Then press CHECK NOW on Settings → General rather than waiting six hours.
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
@@ -2228,7 +2304,7 @@ there is one install directory, the pack ids collide and the nightly has replace
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
four separations has been undone.
**And the direction that matters most:** on the release build, PREFERENCES → UPDATES → CHECK NOW must not
**And the direction that matters most:** on the release build, Settings → General → CHECK NOW must not
offer a nightly, ever, however many have been published since. It reads a different index and refuses
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
rather than as a bug in the nightly.
+51
View File
@@ -1261,6 +1261,57 @@
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<!--
── v5c ──────────────────────────────────────────────────────────────────────────────────────────────
Settings mode's own furniture: a page title, a tracked section label, a card group and the rows inside
it. Named apart from Border.section and TextBlock.sectionlabel above rather than reusing them — those
are the hosts drawer's 320-pixel column, at that column's own padding and heading size, and the
settings pages are a 1100-pixel one at the numbers Settings-General.dc.html and its three siblings all
state identically: 12-radius cards on Raised with a 1px Border inset, 20/24 row padding, a 1px
BorderSubtle divider between rows and none under the last one.
-->
<Style Selector="TextBlock.settingstitle">
<Setter Property="FontSize" Value="33" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="LetterSpacing" Value="-0.5" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="TextBlock.settingssection">
<Setter Property="FontSize" Value="10.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="LetterSpacing" Value="1.2" />
<Setter Property="Foreground" Value="{StaticResource TextGhost}" />
<Setter Property="Margin" Value="0,32,0,12" />
</Style>
<Style Selector="Border.settingscard">
<Setter Property="Background" Value="{StaticResource Raised}" />
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Border.settingsrow">
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="Padding" Value="24,20" />
</Style>
<Style Selector="Border.settingsrow.last">
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="TextBlock.settingsrowtitle">
<Setter Property="FontSize" Value="14.5" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="LetterSpacing" Value="-0.2" />
<Setter Property="Foreground" Value="{StaticResource Text}" />
</Style>
<Style Selector="TextBlock.settingsrowcaption">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="LineHeight" Value="19.4" />
<Setter Property="MaxWidth" Value="640" />
<Setter Property="HorizontalAlignment" Value="Left" />
</Style>
</Application.Styles>
</Application>
+188 -119
View File
@@ -12,9 +12,10 @@
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
says what each entry means; importing is a separate press on a set somebody has looked at.
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
something almost nobody is looking at.
v5c-3: restyled into Import.dc.html's own table over SettingsView's content column — SettingsNav stays
lit on Preferences while this is up, and the titlebar says "Back to preferences"; see
MainWindowViewModel.IsImportOpen. No longer reachable from the nav rail, exactly as before: it is a task
done once or once a year, reached from the Preferences page's own "OPEN IMPORTER" row.
── ◆ THE ONE TICK THAT READS PRIVATE KEYS ─────────────────────────────────────────────────────────────
Below the list, off, and drawn only where the scan actually found an IdentityFile. It is the only control
@@ -26,144 +27,212 @@
What comes back afterwards is the report under the list: one line per key file, saying which were stored,
which are protected by a passphrase this cannot know, and which were not there at all. That is reported
rather than previewed for the same reason — previewing would mean reading them.
── ◆ WHAT THIS MEANS ──────────────────────────────────────────────────────────────────────────────────
One chip per row rather than the old separate AUTHENTICATION/STATE columns, mapped off the two facts a
row actually carries: ImportRowViewModel.AlreadyPresent and HasWarnings. A skipped Host pattern (a
wildcard block) never becomes a row at all — see SshConfigImport.SkippedPatterns — so there is no third,
"skipped" state to draw here; a warned row is the amber case instead, and it wins over "already here"
because the warning is the more actionable of the two facts. See ImportRowViewModel.Meaning.
-->
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
<UserControl.Styles>
<!-- The row's own hover fill, matching the design's per-row style-hover — Track, the same as every other table this application draws. -->
<Style Selector="Border.importrow:pointerover">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="Border.meaningchip.new">
<Setter Property="Background" Value="{StaticResource LiveWash}" />
</Style>
<Style Selector="Border.meaningchip.new > TextBlock">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="Border.meaningchip.exists">
<Setter Property="Background" Value="{StaticResource Chip}" />
</Style>
<Style Selector="Border.meaningchip.exists > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="Border.meaningchip.warn">
<Setter Property="Background" Value="{StaticResource WarnWash}" />
</Style>
<Style Selector="Border.meaningchip.warn > TextBlock">
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
</Style>
</UserControl.Styles>
<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="IMPORT SSH CONFIG" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
</Grid>
</Border>
<Grid MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch" RowDefinitions="Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="14,12,14,0"
<!-- ============ HEADER ============ -->
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="10">
<TextBlock Classes="settingstitle" Text="Import SSH config" />
<TextBlock Classes="mono" Text="{Binding HeaderStatus}" FontSize="12"
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="40" VerticalAlignment="Top" Content="SCAN AGAIN"
Command="{Binding ScanCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Reads the file again and shows what it found. Nothing is stored." />
</Grid>
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="12" Margin="0,10,0,0"
TextWrapping="Wrap" />
<!--
What could not be honoured, above the list rather than beside it. Every one of these is a way the
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
comparing the two needs to be told before they conclude the parser lost something.
What could not be honoured, at document level. Every one of these is a way the import is quieter than
the file — an ignored Match block, a dropped ProxyCommand — and a person comparing the two needs to be
told before they conclude the parser lost something.
-->
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
<Border Grid.Row="2" Margin="0,14,0,0" Padding="14,10" CornerRadius="10"
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" IsVisible="{Binding HasWarnings}">
<ItemsControl ItemsSource="{Binding Warnings}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11"
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="11.5"
TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="3" Margin="0,18,0,0" CornerRadius="12" Background="{StaticResource Pane}"
BorderBrush="{StaticResource Border}" BorderThickness="1" ClipToBounds="True"
IsVisible="{Binding HasRows}">
<Grid RowDefinitions="Auto,*">
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="9.5" LetterSpacing="1" />
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*">
<!--
The header tick-all box. A plain Button rather than a CheckBox — ImportRowViewModel's own
ToggleAllCommand is "tick everything, or untick everything" in one press, which is a command
rather than a two-way bound bool, and a CheckBox has no Command of its own to hang that on.
-->
<Button Grid.Column="0" Classes="flat" Width="18" Height="18" Padding="0"
VerticalAlignment="Center" Command="{Binding ToggleAllCommand}"
ToolTip.Tip="Tick or untick every row">
<Panel Width="18" Height="18">
<Border CornerRadius="5" Background="{StaticResource Accent}" IsVisible="{Binding AllTicked}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CA;" FontSize="13"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<Border CornerRadius="5" BorderBrush="{StaticResource BorderMid}" BorderThickness="1.5"
IsVisible="{Binding !AllTicked}" />
</Panel>
</Button>
<TextBlock Grid.Column="1" Classes="label" Text="ALIAS" FontSize="9.5" LetterSpacing="1"
Margin="14,0,0,0" />
<TextBlock Grid.Column="2" Classes="label" Text="HOSTNAME" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="USER" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="WHAT THIS MEANS" FontSize="9.5" LetterSpacing="1" />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<Border Classes="importrow" CornerRadius="8">
<Grid ColumnDefinitions="24,1*,1.4*,0.7*,0.5*,1.3*" Margin="12" MinHeight="20">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="14,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
ToolTip.Tip="{Binding Authentication}" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Hostname}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding User}" FontSize="11.5"
Foreground="{StaticResource TextDim}" Margin="0,0,10,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Port}" FontSize="11.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<Border Grid.Column="5" Classes="meaningchip" CornerRadius="5" Padding="8,3"
HorizontalAlignment="Left" VerticalAlignment="Center"
Classes.new="{Binding IsMeaningNew}" Classes.exists="{Binding IsMeaningExisting}"
Classes.warn="{Binding IsMeaningWarned}" ToolTip.Tip="{Binding Meaning}">
<TextBlock Text="{Binding Meaning}" FontSize="10" FontWeight="SemiBold"
MaxWidth="230" TextTrimming="CharacterEllipsis" />
</Border>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Border>
<!-- ============ FOOTER ============ -->
<StackPanel Grid.Row="4" Margin="0,16,0,0" Spacing="16" IsVisible="{Binding HasRows}">
<!--
Said before the button, not after. Whether the key material comes with the host is the difference
between a bookmark that connects and one that asks for a password, and somebody who is not told
will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap" IsVisible="{Binding !ImportsKeys}"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
<!--
◆ THE OPT-IN CARD. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read
~/.ssh on a screen where it would read nothing is a control that teaches people to ignore it.
-->
<Border CornerRadius="12" Background="{StaticResource Raised}" BorderBrush="{StaticResource WarnSoft}"
BorderThickness="1" Padding="18,16" IsVisible="{Binding HasKeyFiles}">
<StackPanel Orientation="Horizontal" Spacing="14">
<CheckBox VerticalAlignment="Top" Margin="0,3,0,0" IsChecked="{Binding ImportsKeys}" />
<StackPanel Spacing="6">
<TextBlock Text="Also store the private keys these entries point at" FontSize="13.5"
FontWeight="SemiBold" Foreground="{StaticResource Text}" />
<TextBlock Classes="hint" FontSize="11.5" LineHeight="17.5" TextWrapping="Wrap"
Text="{Binding KeyMaterialIntro}" />
<!--
The warning sentence appears only when the tick is on, and it is the one place this application
says out loud that it is about to open private keys.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" IsVisible="{Binding ImportsKeys}"
Foreground="{StaticResource WarnText}"
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
</StackPanel>
</StackPanel>
</Border>
<!--
◆ What became of each key file, after the fact. Capped with a scroll viewer because a config with
thirty keyed hosts would otherwise push IMPORT off the window — the one control this screen must
never lose.
-->
<Border IsVisible="{Binding HasKeyReport}" Padding="12,10" CornerRadius="10"
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}" BorderThickness="1">
<ScrollViewer MaxHeight="120">
<ItemsControl ItemsSource="{Binding KeyReport}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
<!-- "N of M entries selected · saving to {vault}", Cancel, Import N hosts. -->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="hint" FontSize="12" VerticalAlignment="Center"
Text="{Binding SelectionSummary}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button Classes="ghost" Height="44" Content="CANCEL" Command="{Binding CancelCommand}"
ToolTip.Tip="Back to Preferences. Nothing is stored." />
<Button Classes="accent" Height="44" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
</Grid>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Rows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel">
<StackPanel Margin="14,0">
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="10.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border>
</Grid>
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="10.5" Margin="34,0,0,8"
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
IsVisible="{Binding HasWarnings}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding HasRows}">
<StackPanel Spacing="8">
<!--
Said before the button, not after. Whether the key material comes with the host is the difference
between a bookmark that connects and one that asks for a password, and somebody who is not told
will conclude the import was broken.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding !ImportsKeys}"
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain." />
<!--
◆ THE TICK. Hidden entirely where the scan found no IdentityFile anywhere — an offer to read ~/.ssh
on a screen where it would read nothing is a control that teaches people to ignore it.
The warning sentence appears only when it is on, and it is the one place this application says out
loud that it is about to open private keys. It names the directory rather than saying "your keys",
because what somebody is agreeing to is a read of that directory.
-->
<StackPanel Spacing="6" IsVisible="{Binding HasKeyFiles}">
<CheckBox IsChecked="{Binding ImportsKeys}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Also import the private keys these hosts point at" />
</CheckBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding ImportsKeys}" Foreground="{StaticResource WarnText}"
Text="Pressing IMPORT will read each host's first IdentityFile out of ~/.ssh, store it in this vault encrypted, and bind the host to it. One key is stored per file however many hosts name it, and a file already in your keychain is bound to rather than stored twice. A key protected by a passphrase comes in without one — nothing on disk says what it is — and the report below will name it." />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
</StackPanel>
<!--
◆ What became of each key file, after the fact. Below the button because it is the answer rather
than the offer, and capped with a scroll viewer because a config with thirty keyed hosts would
otherwise push IMPORT off the window — the one control this screen must never lose.
-->
<Border IsVisible="{Binding HasKeyReport}" Padding="10,8" CornerRadius="4"
Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1">
<ScrollViewer MaxHeight="120">
<ItemsControl ItemsSource="{Binding KeyReport}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock Text="{Binding}" Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Margin="0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</StackPanel>
</Border>
</StackPanel>
</Grid>
@@ -1,6 +1,5 @@
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using DodoSSH.Client.Shell.ViewModels;
@@ -10,7 +9,11 @@ namespace DodoSSH.Client.App.Views;
/// Importing hosts from <c>~/.ssh/config</c>.
/// </summary>
/// <remarks>
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
/// A task rather than a destination, which is why it is reached from Preferences rather than from the nav
/// rail. v5c-3 moved it inside settings mode as an overlay over the Preferences page — it no longer has a
/// keyboard target of its own to hand back: <c>MainWindow.axaml.cs</c>'s <c>KeyboardHome</c> falls back to
/// the window for settings mode as a whole, the same way it already does for the account and logs screens,
/// so a <c>KeyboardTarget</c> property here would be dead code nothing reads.
/// </remarks>
internal sealed partial class ImportScreen : UserControl
{
@@ -24,9 +27,6 @@ internal sealed partial class ImportScreen : UserControl
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
}
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
internal IInputElement KeyboardTarget => this;
private void OnTickChanged(object? sender, RoutedEventArgs e)
{
if (DataContext is ImportViewModel import)
@@ -15,128 +15,210 @@
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
v5c-3: restyled against KnownHosts.dc.html — still a main-chrome screen (the rail's own Keys item stays
the way in), a 40px back arrow to Keychain rather than the old link inside it, a bordered radius-12
container in place of the plain list-and-sidebar split, and a Copy fingerprint button beside Withdraw
pin. Everything the screen could do before still can: Filter, Selected, ForgetSelectedCommand and the
provenance notes below are unchanged, just repainted.
-->
<Grid ColumnDefinitions="*,244">
<UserControl.Styles>
<!--
A row, not a card: 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" — the same idiom SnippetsScreen's
own ListBox.snipcards uses, at this design's own 8px radius rather than that one's 10.
-->
<Style Selector="ListBox.pinrows > ListBoxItem /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="ListBox.pinrows > ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
</Style>
<Style Selector="ListBox.pinrows > ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{StaticResource Track}" />
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
</Style>
<Style Selector="ListBox.pinrows > 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,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="HOST KEYS" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Matches fingerprints as well as host names, which is the point of it. What somebody does with
this screen is check whether a published SHA256:… is the one they approved, and searching only
by name would answer a different question.
-->
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
</Grid>
<!-- ============ HEADER ============ -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,Auto,*,Auto">
<Button Grid.Column="0" Classes="ghost" Width="40" Height="40" Padding="0"
Command="{Binding BackCommand}" ToolTip.Tip="Back to Keychain">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5C4;" FontSize="17"
Foreground="{StaticResource TextFaint}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
<TextBlock Grid.Column="1" Text="Host keys" FontSize="33" FontWeight="Bold" LetterSpacing="-0.5"
Foreground="{StaticResource Text}" VerticalAlignment="Center" Margin="14,0,0,0" />
<Border Grid.Column="2" MinWidth="30" Height="26" CornerRadius="8" Padding="8,0" Margin="12,0,0,0"
Background="{StaticResource Chip}" VerticalAlignment="Center" IsVisible="{Binding HasPins}">
<TextBlock Classes="mono" Text="{Binding Count}" FontSize="12.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
IsVisible="{Binding HasVisiblePins}">
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="9.5" LetterSpacing="1" />
</Grid>
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
ItemsSource="{Binding VisiblePins}"
SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="10.5"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Never trimmed, and this column is why the table is laid out the way it is. The only thing
anybody does with a fingerprint is compare it character by character against one an operator
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
mechanism exists to replace.
-->
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="340"
IsVisible="{Binding !HasVisiblePins}" />
<!--
Matches fingerprints as well as host names, which is the point of it. What somebody does with
this screen is check whether a published SHA256:… is the one they approved, and searching only
by name would answer a different question.
-->
<TextBox Grid.Column="4" x:Name="PinFilter" Text="{Binding Filter}" Width="320" Height="40"
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
</Grid>
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="14,16" Spacing="6">
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="0,10,0,0" TextWrapping="Wrap"
Text="Every pin is a decision recorded at the moment of connecting. Fingerprints are never trimmed — compare them character by character against what the operator published." />
<TextBlock Classes="hint" FontSize="12"
Text="Choose a pinned key to see it in full, and to withdraw it."
IsVisible="{Binding !HasSelection}" />
<!-- ============ THE TABLE ============ -->
<Border Grid.Row="2" Margin="0,18,0,0" CornerRadius="12" BorderBrush="{StaticResource Border}"
BorderThickness="1" ClipToBounds="True">
<Grid ColumnDefinitions="*,320">
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<Grid Grid.Column="0" Background="{StaticResource Pane}" RowDefinitions="Auto,*">
<Border Classes="chip warn" HorizontalAlignment="Left"
IsVisible="{Binding !Selected.IsDialledByAHost}">
<TextBlock Text="no host uses this" />
</Border>
<Border Grid.Row="0" Padding="24,14,24,10" BorderBrush="{StaticResource Border}"
BorderThickness="0,0,0,1" IsVisible="{Binding HasVisiblePins}">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96">
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="9.5" LetterSpacing="1" />
</Grid>
</Border>
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="4" Padding="8">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" />
</Border>
<ListBox Grid.Row="1" x:Name="PinList" Classes="pinrows" Focusable="True" Margin="12,8"
ItemsSource="{Binding VisiblePins}" SelectedItem="{Binding Selected}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="12">
<Border Grid.Column="0" Classes="rowmark" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="12.5"
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="11"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="10.5"
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<!--
Never trimmed, and this column is why the table is laid out the way it is. The only thing
anybody does with a fingerprint is compare it character by character against one an
operator published; an ellipsis in the middle turns that into a glance, which is the habit
the whole mechanism exists to replace.
-->
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
VerticalAlignment="Center" />
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11"
Foreground="{StaticResource TextDim}" />
<!--
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
the item's own version 7 id — which records when the pin was created and knows nothing about
it being re-approved since. Presenting that as "last used" would be inventing a fact.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="12"
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
TextAlignment="Center" MaxWidth="340" IsVisible="{Binding !HasVisiblePins}" />
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" Margin="0,12,0,0"
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
</Grid>
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
HorizontalAlignment="Left"
Command="{Binding ForgetSelectedCommand}"
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
</StackPanel>
<!-- ============ DETAIL SIDEBAR ============ -->
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
<ScrollViewer>
<StackPanel Margin="20,22" Spacing="14">
</StackPanel>
</ScrollViewer>
<TextBlock Classes="hint" FontSize="12"
Text="Choose a pinned key to see it in full, and to withdraw it."
IsVisible="{Binding !HasSelection}" />
<StackPanel Spacing="14" IsVisible="{Binding HasSelection}">
<StackPanel Orientation="Horizontal" Spacing="12">
<Border Width="36" Height="36" CornerRadius="10" Background="{StaticResource AccentSoft}">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE90D;" FontSize="17"
Foreground="{StaticResource AccentText}" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="mono" Text="{Binding Selected.Host}" FontSize="13.5" FontWeight="Bold"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="10.5"
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
<Border Classes="chip warn" Background="{StaticResource WarnWash}" HorizontalAlignment="Left"
IsVisible="{Binding !Selected.IsDialledByAHost}">
<TextBlock Text="no host uses this" />
</Border>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="FINGERPRINT" />
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="10" Padding="12,10">
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
FontSize="10.5" Foreground="{StaticResource TextDim}"
TextWrapping="Wrap" LineHeight="16" />
</Border>
</StackPanel>
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="APPROVED" />
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="11.5"
Foreground="{StaticResource TextDim}" TextWrapping="Wrap" />
<!--
Said rather than implied. No vault item carries a timestamp, so this date is read back out
of the item's own version 7 id — which records when the pin was created and knows nothing
about it being re-approved since. Presenting that as "last used" would be inventing a fact.
-->
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
</StackPanel>
<!--
◆ VAULT. Real, not decorative — KnownHostRowViewModel.VaultName comes off the same
VaultId/VaultName pair HostRowViewModel carries, so this chip never names a vault the pin
is not actually stored in.
-->
<StackPanel Spacing="6">
<TextBlock Classes="label" Text="VAULT" />
<Border Classes="chip" HorizontalAlignment="Left">
<TextBlock Text="{Binding Selected.VaultName}" />
</Border>
</StackPanel>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" Margin="0,4,0,0"
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
<StackPanel Spacing="8" Margin="0,10,0,0">
<Button Classes="ghost" Height="38" HorizontalAlignment="Stretch"
Content="COPY FINGERPRINT" Command="{Binding CopyFingerprintCommand}"
ToolTip.Tip="Fingerprints are public. Puts it on the clipboard in full." />
<Button Classes="danger" Height="38" HorizontalAlignment="Stretch"
Content="WITHDRAW PIN" Command="{Binding ForgetSelectedCommand}"
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap" TextAlignment="Center"
Text="The next connection will ask you to approve this host's key again." />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Grid>
+52 -31
View File
@@ -91,12 +91,13 @@
<Grid RowDefinitions="Auto,*,Auto,Auto">
<views:TitleBar Grid.Row="0" />
<views:TitleBar Grid.Row="0" IsVisible="{Binding !IsSettingsMode}" />
<Panel Grid.Row="1">
<!-- The unlocked application. -->
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}"
IsEnabled="{Binding !IsSettingsMode}">
<!--
The window's own furniture, at 255 pixels — see NavRail.axaml's own v5b remark. It no longer sits
@@ -176,13 +177,19 @@
by a toggle inside the screen — see ShowFiles, and the rail's own switcher segment that
calls it now; see NavRail.axaml.
Deliberately not given the session shell above: a bucket is not a host, has no terminal tab
Deliberately not given the full 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.
could show — so no tab row, no host header, no status bar and no sidebar. Wave B's own scope
was the terminal and SFTP surfaces and left this plain; v5c-3 gives it the session shell's own
LOOK without its machinery — a 26px padded column and the same bordered, radius-12 container —
since design-notes/v5c-fidelity-notes.md calls for the fidelity pass here too, and there is
nothing this screen does that needs the parts still withheld.
-->
<Panel IsVisible="{Binding IsBucketsScreen}">
<views:TransfersScreen DataContext="{Binding Transfers}" />
<Border Margin="26" BorderBrush="{StaticResource Border}" BorderThickness="1" CornerRadius="12"
ClipToBounds="True">
<views:TransfersScreen DataContext="{Binding Transfers}" />
</Border>
</Panel>
<!-- ============ KEYCHAIN ============ -->
@@ -217,30 +224,21 @@
<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.
v5c-2: the VAULTS panel that used to live here is gone. ShellScreen.Vaults now only ever shows
through settings mode's own SettingsVaultsPage — see MainWindowViewModel.ShowScreen, which
redirects it into EnterSettings before Screen is ever set — so IsVaultsScreen being true also
means IsSettingsMode is true, and this whole page-area Panel (bound to !IsSettingsMode, above)
is already hidden behind SettingsView by then. A Panel here would never have been drawn.
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.
v5c-3: IMPORT left this Panel the same way, and for the same reason. Import.dc.html draws the
importer inside the settings chrome — SettingsNav lit on Preferences, a "Back to preferences"
titlebar rather than the ordinary rail and titlebar this Panel belongs to — so ImportScreen now
lives in SettingsView.axaml behind MainWindowViewModel.IsImportOpen, beside the seven settings
pages rather than among the screens here. See ShowScreen's own translation of
ShellScreen.Import for what still reaches it: the Preferences page's own "OPEN IMPORTER" row,
and anything else that names the same destination.
-->
<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>
@@ -319,6 +317,22 @@
</Panel>
</Grid>
<!--
v5c: the settings mode, which replaces the titlebar above and everything in this Panel below it with
its own — see SettingsView.axaml and TitleBar's own IsVisible next to it in this file. Later in the
Panel than the unlocked Grid, on the same reasoning the setup Border below gives for its own
position: Avalonia z-order draws a later sibling over an earlier one, and SettingsView's own
Background is opaque, so it covers the rail and the page area completely rather than needing them
hidden out from under it. The unlocked Grid keeps IsEnabled bound to the negation above so a Tab
press cannot walk keyboard focus into a control this mode has covered.
Earlier than the palette and the host-key decision, both further down this same Panel, so either can
still interrupt settings mode exactly as it already interrupts every other screen — a host key that
changes while the sync loop is running does not stop being worth answering because the window
happens to be showing Settings.
-->
<views:SettingsView IsVisible="{Binding IsSettingsMode}" />
<!--
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
@@ -476,11 +490,18 @@
no DataContext yields UnsetValue, IsVisible falls back to true, and the previewer would show a banner
announcing an update that does not exist.
-->
<views:UpdateBanner Grid.Row="2"
DataContext="{Binding Updates}"
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
<!--
v5c: wrapped rather than given a third condition of its own — UpdateBanner sets its own DataContext to
UpdateViewModel on this same element, so a binding against IsSettingsMode has to live on an ancestor
whose context is still the shell. The design's settings titlebar has no room for this strip; hiding it
here rather than teaching the banner about a mode it otherwise has no reason to know about.
-->
<Panel Grid.Row="2" IsVisible="{Binding !IsSettingsMode}">
<views:UpdateBanner DataContext="{Binding Updates}"
IsVisible="{Binding IsBannerShowing, FallbackValue=False}" />
</Panel>
<views:StatusBar Grid.Row="3" />
<views:StatusBar Grid.Row="3" IsVisible="{Binding !IsSettingsMode}" />
</Grid>
@@ -111,11 +111,13 @@ internal sealed partial class MainWindow : Window
/// </remarks>
private IInputElement KeyboardHome => shell switch
{
// v5c: settings mode has no keyboard-focused control of its own yet — its pages are read-only prose
// and buttons, the same shape the account and logs screens already fall back to the window for.
{ IsSettingsMode: true } => this,
{ IsTerminalShowing: true } => Terminal,
{ Screen: ShellScreen.Keychain } => VaultPane.KeyboardTarget,
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
_ => this,
@@ -213,6 +215,25 @@ internal sealed partial class MainWindow : Window
{
Palette.HandleKey(e);
}
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
// already follows one branch up. Checked after the palette rather than before it: the two states
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
// settings does not open the palette — but an Escape while both were somehow true should close the
// thing drawn on top, which is the palette.
//
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
// "Back to application" while IsImportOpen is true.
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
{
viewModel.CloseImportCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
{
viewModel.LeaveSettingsCommand.Execute(null);
e.Handled = true;
}
base.OnKeyDown(e);
}
+10 -7
View File
@@ -249,12 +249,15 @@
<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.
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
see MainWindowViewModel.EnterSettings and SettingsView.axaml. Settings opens on General, the
mode's own default landing page; Vaults and Preferences open directly on the page they name,
which is also what anything that used to navigate to ShellScreen.Vaults or
ShellScreen.Preferences now does — see ShowScreen. Three handlers rather than the two rows
sharing one before this wave: the mock's Settings area was a family of screens that did not
exist yet, and now that it does, "Settings" and "Preferences" are no longer the same click.
-->
<Button Classes="poprow" Click="OnPopoverPreferencesPressed">
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE8B8;" FontSize="12"
Foreground="{StaticResource Text}" />
@@ -282,8 +285,8 @@
<!--
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.
MainWindowViewModel.SignOutFromPopover for why this goes through the Account settings page
rather than calling SignOutCommand directly from wherever the popover happened to be opened.
-->
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
<StackPanel Orientation="Horizontal" Spacing="10">
+17 -5
View File
@@ -48,30 +48,42 @@ internal sealed partial class NavRail : UserControl
}
}
/// <summary>Settings and Preferences both land here — see the remark in the markup.</summary>
/// <summary>Opens settings mode on its default landing page — see the remark in the markup.</summary>
private void OnPopoverSettingsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.EnterSettingsCommand.Execute(SettingsPage.General);
}
}
/// <summary>Opens settings mode on its Preferences page.</summary>
private void OnPopoverPreferencesPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
}
}
/// <summary>Opens settings mode on its Vaults page.</summary>
private void OnPopoverVaultsPressed(object? sender, RoutedEventArgs e)
{
ClosePopover();
if (DataContext is MainWindowViewModel shell)
{
shell.ShowScreenCommand.Execute(ShellScreen.Vaults);
shell.EnterSettingsCommand.Execute(SettingsPage.Vaults);
}
}
/// <summary>
/// Starts a sign-out, through Preferences so the confirmation card has somewhere to be seen — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/>.
/// Starts a sign-out, through the Account settings page so the confirmation card has somewhere to be
/// seen — see <see cref="MainWindowViewModel.SignOutFromPopover"/>.
/// </summary>
private void OnPopoverLogoutPressed(object? sender, RoutedEventArgs e)
{
@@ -1,275 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
x:DataType="vm:MainWindowViewModel">
<!--
Preferences.
The design's rail has six sections and its TERMINAL panel has six settings. One of those six is now
real: text size. It needed all three of the things this comment used to record as missing — somewhere
to keep a preference (settings.json beside the cache, outside it deliberately, so it can be read on a
launch that never unlocks anything), a frame carrying a terminal option (TerminalServerOpcode.FontSize),
and a way for the page's own chords to reach the host that owns the value
(TerminalClientOpcode.FontSizeStep). The rule that kept it off this screen until then still stands and
is why the storage came first: a stepper that reset on every launch is worse than no stepper.
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
The two commands behind it were already in the shell; they were merely homeless, wedged into the old
account bar because there was nowhere else to put them.
Everything else is listed as absent rather than omitted, because a preferences screen that is silent
about the settings it has not got reads as a product with six preferences.
-->
<ScrollViewer>
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<!--
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one: a
machine with no TPM and a machine that is already registered are both "cannot register", and only
the second has anything to take back.
-->
<Button Grid.Column="1" Classes="danger" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanForgetDevice}"
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
</Grid>
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
client can replace itself, and the moment that is true the question of where a replacement comes
from stops being theoretical. The answer is the security content of this section rather than a
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
-->
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
</StackPanel>
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
</StackPanel>
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
IsEnabled="{Binding Updates.IsSupported}" />
</Grid>
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
Value="{Binding Updates.DownloadPercent}"
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
IsVisible="{Binding Updates.IsDownloading}" />
<!--
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
this is where the warning can be as long as it needs to be — and it needs to be, because this
application has spent a lot of words teaching that locking keeps shells running.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
Command="{Binding Updates.RestartNowCommand}" />
</Grid>
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
why rather than shown three controls that cannot do anything.
-->
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This copy of DodoSSH cannot replace itself, so none of the above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
IsVisible="{Binding Updates.IsUnsupported}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Here as well as on the chord, and not because the chord is in doubt. Ctrl+plus can only be heard
while a terminal has focus, since that is where the keyboard is being read — so somebody who has
not opened one yet, or who has just made the text too small to find anything in, has nowhere else
to look. This is that place, and it names the chord so the screen teaches it rather than replacing
it.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
<Button Classes="ghost" Content="A" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}"
ToolTip.Tip="Smaller · Ctrl+minus" />
<TextBlock Classes="mono" FontSize="13" MinWidth="26" VerticalAlignment="Center"
TextAlignment="Center" Foreground="{StaticResource Text}"
Text="{Binding TerminalFontSize}" />
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}"
ToolTip.Tip="Larger · Ctrl+plus" />
<Button Classes="ghost" Content="RESET" Command="{Binding ResetTerminalFontCommand}"
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
</StackPanel>
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}"
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Import}" />
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel>
<!--
Hidden rather than disabled while the confirmation is up, because the card below carries the
button that actually does it and two sign-out buttons on one screen is one too many.
-->
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
Command="{Binding SignOutCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding !IsConfirmingSignOut}" />
</Grid>
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" FontSize="12" Margin="0,8,0,0"
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
<ItemsControl Margin="0,12,0,0">
<ItemsControl.Styles>
<Style Selector="TextBlock.gap">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="0,0,0,7" />
</Style>
</ItemsControl.Styles>
<TextBlock Classes="gap"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. The text size above is the one that is not, and it is the path the others would follow if they were worth a row here." />
<TextBlock Classes="gap"
Text="Changing channel from in here — there is a nightly as well as a release, but which one a copy follows is fixed when it is built, so moving between them means installing the other one." />
<TextBlock Classes="gap"
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
<TextBlock Classes="gap"
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
<TextBlock Classes="gap"
Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
<TextBlock Classes="gap"
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
</ItemsControl>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Preferences: what this build can actually change, and a list of what it cannot.</summary>
internal sealed partial class PreferencesScreen : UserControl
{
public PreferencesScreen() => InitializeComponent();
}
@@ -0,0 +1,94 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsAccountPage"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: Account, against Settings-Account.dc.html.
The profile card is read-only, unlike the design's own — there is no "Edit profile" row here because
there is no endpoint to send an edit to. Name, email and avatar all come from MeResponse and nothing in
this client can change any of the three.
The SIGN-IN card keeps one row rather than the design's two. The issuer sentence is real:
MainWindowViewModel.Issuer is MeResponse.Issuer, cached the same turn AccountName and Email are and
surfaced here for the first time — see the remark on that property. "Master passphrase" and its "Change
passphrase" button are refused outright: there is no change-passphrase flow, only the one enrollment-time
choice, and a button that opened nothing would be worse than no button.
DEVICES and "Sign out everywhere" are both refused. There is no list-devices endpoint and no
sign-out-everywhere endpoint; the one honest device fact this client has — this machine's own Windows
Hello registration — is drawn on the Security page, not duplicated here as a one-row list.
SignOutCard is the same control the old PreferencesScreen used, restyled into this page's card idiom
rather than rewritten — see that control's own remark on why it is a bare StackPanel and not a card of
its own, and why one control serves both this screen and the unlock screen's copy of it.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<TextBlock Classes="settingstitle" Text="Account" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="24,22">
<StackPanel Orientation="Horizontal" Spacing="18">
<Border Width="64" Height="64" CornerRadius="60" Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding AvatarInitials}" FontWeight="Bold" FontSize="20" LetterSpacing="0.5"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="7" VerticalAlignment="Center">
<TextBlock Text="{Binding AccountName}" FontSize="20" FontWeight="Bold" LetterSpacing="-0.25"
Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextFaint}"
Text="{Binding Email}"
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="SIGN-IN" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<StackPanel Spacing="6" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Single sign-on" />
<TextBlock Classes="settingsrowcaption"
Text="Signing in proves who you are — it never decrypts a vault." />
<TextBlock Classes="mono" FontSize="12" Margin="0,4,0,0" Foreground="{StaticResource Live}"
Text="{Binding Issuer, StringFormat='You sign in through {0}.'}"
IsVisible="{Binding Issuer, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="THIS MACHINE" />
<Border Classes="settingscard" Margin="0,12,0,40">
<Panel>
<Border Classes="settingsrow last" IsVisible="{Binding !IsConfirmingSignOut}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Sign out of this machine" />
<TextBlock Classes="settingsrowcaption"
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel>
<Button Grid.Column="1" Classes="danger" Height="38" Content="SIGN OUT"
Command="{Binding SignOutCommand}"
IsEnabled="{Binding !IsBusy}" />
</Grid>
</Border>
<Border Padding="24,20" IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
</Panel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's Account page: the read-only profile, the sign-in fact, and signing out.</summary>
internal sealed partial class SettingsAccountPage : UserControl
{
public SettingsAccountPage() => InitializeComponent();
}
@@ -0,0 +1,114 @@
<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.SettingsGeneralPage"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: General, against Settings-General.dc.html.
The design's page has three cards — STARTUP, APPEARANCE, UPDATES — and this one has one. STARTUP
(launch at login, reopen tabs) and APPEARANCE (theme, language) are refused outright: nothing tracks
whether Windows starts this application, nothing remembers a tab list across a launch, only the one dark
theme exists, and there is no i18n anywhere in the client. Building either card would be two rows of
controls that toggle a field nothing reads. See design-notes/v5c-fidelity-notes.md.
UPDATES is the whole page's real content, moved here from the old PreferencesScreen.axaml verbatim —
same UpdateViewModel, same states, same warnings, restyled into this page's row idiom. Its own
update-channel switcher is refused the same way STARTUP and APPEARANCE are: which channel a copy follows
is fixed when it is built, so this screen cannot offer to switch it.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<TextBlock Classes="settingstitle" Text="General" />
<TextBlock Classes="settingssection" Text="UPDATES" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Version" />
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" Text="{Binding Updates.CurrentVersion}" />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
</Grid>
</Border>
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Check on its own" />
<TextBlock Classes="settingsrowcaption"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
</StackPanel>
<CheckBox Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
IsEnabled="{Binding Updates.IsSupported}" />
</Grid>
</Border>
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsDownloading}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="Downloading…" />
<ProgressBar Height="4" Minimum="0" Maximum="100"
Value="{Binding Updates.DownloadPercent}"
Foreground="{StaticResource Accent}" Background="{StaticResource Chip}" />
</StackPanel>
</Border>
<Border Classes="settingsrow" IsVisible="{Binding Updates.IsReady}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="{Binding Updates.ReadyHeadline}" TextWrapping="Wrap" />
<TextBlock Classes="settingsrowcaption" Text="{Binding Updates.RestartWarning}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="RESTART NOW"
Command="{Binding Updates.RestartNowCommand}" />
</Grid>
</Border>
<Border Classes="settingsrow last">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowcaption"
Text="Builds come from the project's own release page, and never from the server you sign in to. Whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
<TextBlock Classes="settingsrowcaption" Foreground="{StaticResource TextFaint}"
Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<TextBlock Classes="settingsrowcaption"
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
IsVisible="{Binding Updates.IsUnsupported}" />
</StackPanel>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
<Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption"
Text="These are on the design and have nothing behind them here. They are listed rather than left out, so what this page does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
<TextBlock Classes="settingsrowcaption"
Text="Update channel — there is a nightly as well as a release, but which one a copy follows is fixed when it is built, so moving between them means installing the other one." />
<TextBlock Classes="settingsrowcaption"
Text="Launch at login — nothing registers this application with Windows' own startup list." />
<TextBlock Classes="settingsrowcaption"
Text="Reopen tabs from last session — nothing remembers which tabs were open across a launch, and every connection would need to authenticate again regardless." />
<TextBlock Classes="settingsrowcaption"
Text="Theme and language — only the one dark theme exists, and there is no translation anywhere in this client." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's General page: the Updates card, and the refused items as an essay.</summary>
internal sealed partial class SettingsGeneralPage : UserControl
{
public SettingsGeneralPage() => InitializeComponent();
}
@@ -0,0 +1,194 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsGroupsPage"
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c-2: Groups, against Settings-Groups.dc.html — a real management page over VaultViewModel's own group
commands (Groups, NewGroup, EditGroup, DeleteGroup), given its own row here rather than only living inside
the hosts board's sidebar. DataContext.Vault (VaultViewModel) is nullable — locked, or the settings mode
reached before an unlock — so every binding below is {Binding Vault.*}, and the empty-vault caption covers
the null case the same way the vaults page's own does.
── THE INTRO SENTENCE IS NOT THE MOCK'S ────────────────────────────────────────────────────────────────
The design says "the order here is the order there" and draws drag handles to back it up. Neither is true:
HostGroupRowViewModel — see VaultViewModel.RebuildGroups — orders groups by the vault new items go into
first, then by vault name, then by label; there is no manual order to drag into and nothing this page
could do would make one real. The sentence below says what is actually true instead, and there are no
drag handles.
── EDIT AND DELETE ──────────────────────────────────────────────────────────────────────────────────────
EditGroupCommand and DeleteGroupCommand already take the row as their argument — see
VaultViewModel.EditGroup and VaultViewModel.EditGroupFromHeading's own remark on why — so this card's
buttons bind straight to them with CommandParameter="{Binding}"; no wrapper commands were needed the way
the tags page needs one. Editing opens the same drawer form the hosts board's own GROUPS section does
(HostDrawer.axaml's IsEditingGroup panel); it is not reproduced here as a second form, on the reasoning
the vaults page gives for reusing VaultsViewModel's commands rather than rebuilding them: one set of
fields, one place they can drift from what SaveGroupCommand actually writes.
── THE "NO GROUP" FOOTER ────────────────────────────────────────────────────────────────────────────────
Real, all three facts: VaultViewModel.UngroupedHostCount counts hosts whose group has gone or was never
set, "always listed first" is true of both FlattenIntoSections call sites (RebuildSidebarRows passes
ungroupedFirst: false — it is drawn last there — but the desktop's own board, RebuildHostSections, passes
true), and it genuinely cannot be renamed or deleted: there is no HostGroupSecret behind it for either
command to act on.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBlock Classes="settingstitle" Text="Groups" />
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Vault.Groups.Count}" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextFaint}" Margin="8,0"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW GROUP"
Command="{Binding Vault.NewGroupCommand}"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
</Grid>
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
Text="Groups order by which vault new items go into, then by vault, then by label — there is no manual order to set." />
<!-- The group editor, in place: HostDrawer's own IsEditingGroup form, restyled into this page's card
idiom rather than duplicated. Opened by + NEW GROUP above or an EDIT icon below. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vault.IsEditingGroup}">
<StackPanel Spacing="10">
<TextBlock Classes="settingsrowtitle" Text="{Binding Vault.DrawerTitle}" />
<TextBlock Classes="settingsrowcaption"
Text="A heading for the hosts board, and the defaults every host under it inherits when it says nothing itself." />
<TextBox PlaceholderText="group name" Text="{Binding Vault.GroupEditorLabel}" />
<StackPanel Spacing="4" IsVisible="{Binding Vault.ShowsGroupEditorVaultChoice}">
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorVaultChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorParentChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedParent}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:GroupChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<Grid ColumnDefinitions="*,8,*">
<NumericUpDown Grid.Column="0" Value="{Binding Vault.GroupEditorDefaultPort}" Minimum="1"
Maximum="65535" FormatString="0" ShowButtonSpinner="False"
PlaceholderText="default port" />
<TextBox Grid.Column="2" Text="{Binding Vault.GroupEditorDefaultUsername}"
PlaceholderText="default username" />
</Grid>
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding Vault.GroupEditorAuthenticationChoices}"
SelectedItem="{Binding Vault.GroupEditorSelectedAuthentication}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="{Binding Vault.GroupSaveLabel}"
Command="{Binding Vault.SaveGroupCommand}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vault.CancelGroupEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- The delete confirmation, in place. The same control the hosts board's GROUPS section and the
vaults page both reuse. -->
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1"
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<StackPanel Margin="0,24,0,40" Spacing="10">
<ItemsControl ItemsSource="{Binding Vault.Groups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<Border Classes="settingscard" Padding="20,16">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Border Grid.Column="0" Width="38" Height="38" CornerRadius="10"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="14"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="5" Margin="14,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding Description}" />
</StackPanel>
<Border Grid.Column="2" Classes="chip" Margin="0,0,14,0" VerticalAlignment="Center"
IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditGroupCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this group or change what its hosts inherit.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteGroupCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Delete this group. The hosts filed under it are asked about separately.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- The "No group" footer row. Always drawn, even with zero groups, because it is true whether or
not any group exists — it is just as often the only row on this page. -->
<Border Classes="settingscard" Padding="20,16" Opacity="0.7"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel Orientation="Horizontal" Spacing="14">
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="17" Text="&#xE14B;"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
<StackPanel Spacing="5">
<TextBlock Text="No group" FontSize="14" FontWeight="SemiBold" LetterSpacing="-0.2"
Foreground="{StaticResource TextFaint}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
Text="{Binding Vault.UngroupedHostCount, StringFormat='{}{0} hosts · always listed first · cannot be renamed or deleted'}" />
</StackPanel>
</StackPanel>
</Border>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
Text="Unlock your keychain to see and manage your groups." />
</StackPanel>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Groups: every group, and the hosts filed under each — see the remark at the top of the markup.</summary>
internal sealed partial class SettingsGroupsPage : UserControl
{
public SettingsGroupsPage() => InitializeComponent();
}
@@ -0,0 +1,116 @@
<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.SettingsNav"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: settings mode's own 340px rail — SettingsNav.dc.html. Two sections rather than the design's three:
the design's ORGANISATION section and its one row (Organisation settings) are refused outright — no
organisation entity exists anywhere in this product; a Team is the membership list behind a shared vault,
and there is exactly one tenant per deployment. See design-notes/v5c-fidelity-notes.md.
v5c-2: Groups and Tags joined CUSTOMIZE, after Preferences — the design's own order (Security,
Preferences, Groups, Tags). Each opens a real management page over the existing group and tag commands;
see SettingsGroupsPage.axaml and SettingsTagsPage.axaml.
Button.nav and its two TextBlock helpers are the exact rows NavRail.axaml already draws at 255px — height
35, radius 8, a 19px glyph and a 10.5 semibold label, accent fill with white text when active. Reused
rather than restyled: this design states the same three numbers for this rail's own rows, and a second
copy of one style is how the two rails drift apart the day only one of them is touched again.
-->
<Border Width="340" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<DockPanel LastChildFill="False" Margin="18,24">
<StackPanel DockPanel.Dock="Top" Spacing="8">
<TextBlock Text="SETTINGS" Margin="11,0,0,6" FontSize="10" FontWeight="SemiBold"
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGeneralPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.General}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE8B8;" />
<TextBlock Classes="navlabel" Text="General" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSettingsVaultsPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Vaults}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE84F;" />
<TextBlock Classes="navlabel" Text="Vaults" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSettingsAccountPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Account}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE7FD;" />
<TextBlock Classes="navlabel" Text="Account" />
</StackPanel>
</Button>
<TextBlock Text="CUSTOMIZE" Margin="11,14,0,6" FontSize="10" FontWeight="SemiBold"
LetterSpacing="1.5" Foreground="{StaticResource TextGhost}" />
<Button Classes="flat nav" Classes.active="{Binding IsSettingsSecurityPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Security}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE9E0;" />
<TextBlock Classes="navlabel" Text="Security" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSettingsPreferencesPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Preferences}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE429;" />
<TextBlock Classes="navlabel" Text="Preferences" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSettingsGroupsPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Groups}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE7EF;" />
<TextBlock Classes="navlabel" Text="Groups" />
</StackPanel>
</Button>
<Button Classes="flat nav" Classes.active="{Binding IsSettingsTagsPage}"
Command="{Binding EnterSettingsCommand}"
CommandParameter="{x:Static vm:SettingsPage.Tags}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" Text="&#xE54E;" />
<TextBlock Classes="navlabel" Text="Tags" />
</StackPanel>
</Button>
</StackPanel>
<!--
Wired to the same command the rail's own user popover Logout row calls — see
MainWindowViewModel.SignOutFromPopover, which now lands on the Account page rather than on the old
bare Preferences screen, so there is exactly one place the confirmation card is drawn.
-->
<Button DockPanel.Dock="Bottom" Classes="flat nav"
Command="{Binding SignOutFromPopoverCommand}">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Classes="navicon" FontSize="17" Text="&#xE9BA;" />
<TextBlock Classes="navlabel" Text="Logout" />
</StackPanel>
</Button>
</DockPanel>
</Border>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's own 340px rail — General/Vaults/Account, Security/Preferences, and Logout.</summary>
internal sealed partial class SettingsNav : UserControl
{
public SettingsNav() => InitializeComponent();
}
@@ -0,0 +1,134 @@
<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.SettingsPreferencesPage"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: Preferences, against Settings-Preferences.dc.html — the old PreferencesScreen.axaml's real content,
restyled into the settings page idiom and split across two pages rather than one.
THIS MACHINE moved to the Security page whole: Windows Hello's register/"stop unlocking here" pair and
the no-TPM explanation are Security's UNLOCKING card now, one home rather than two — see the cross-
reference row at the foot of this page. Device name is not one of the rows that moved; it never existed
here at all, because there is no device-name setting to move. Logs record the machine name on their own.
TERMINAL keeps its one real setting, text size, and drops the design's other five — font, cursor,
scrollback, copy on select, terminal bell — which the renderer hard-codes. KEYCHAIN keeps Lock now,
Sync now/Sign in, and the importer row exactly as the old screen had them; the design's own SECURITY
card (clipboard-clear delay, confirm-run-on-insert) has nothing behind either row and is not drawn here.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<TextBlock Classes="settingstitle" Text="Preferences" />
<TextBlock Classes="settingssection" Text="TERMINAL" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Text size" />
<TextBlock Classes="settingsrowcaption"
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Top">
<Button Width="36" Height="36" Classes="ghost" Padding="0"
Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}"
ToolTip.Tip="Smaller · Ctrl+minus">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE15B;" FontSize="15" />
</Button>
<TextBlock Classes="mono" FontSize="13.5" MinWidth="46" VerticalAlignment="Center"
TextAlignment="Center" Foreground="{StaticResource Text}"
Text="{Binding TerminalFontSize, StringFormat={}{0} px}" />
<Button Width="36" Height="36" Classes="ghost" Padding="0"
Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}"
ToolTip.Tip="Larger · Ctrl+plus">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE145;" FontSize="15" />
</Button>
<Button Classes="ghost" Height="36" Content="RESET"
Command="{Binding ResetTerminalFontCommand}"
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
</StackPanel>
</Grid>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="KEYCHAIN" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Lock the keychain" />
<TextBlock Classes="settingsrowcaption"
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="LOCK NOW"
Command="{Binding LockCommand}" />
</Grid>
</Border>
<Border Classes="settingsrow">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Synchronise" />
<TextBlock Classes="settingsrowcaption"
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Height="38" Content="SIGN IN" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}"
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume." />
<Button Classes="ghost" Height="38" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
</StackPanel>
</Grid>
</Border>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0">
<TextBlock Classes="settingsrowtitle" Text="Import SSH config" />
<TextBlock Classes="settingsrowcaption"
Text="Reads this machine's ~/.ssh/config, shows what it found, and imports only what you approve. Nothing is stored during the scan, and no private key is read — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN IMPORTER"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Import}" />
</Grid>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="NOT BUILT YET" />
<Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption"
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
<TextBlock Classes="settingsrowcaption"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
<TextBlock Classes="settingsrowcaption"
Text="Copy on select and the terminal bell — neither is wired to anything the renderer does." />
<TextBlock Classes="settingsrowcaption"
Text="Clearing the clipboard after copying a secret, and confirming a snippet that runs on insert — neither exists; a copied secret stays on the clipboard until something else replaces it." />
<TextBlock Classes="settingsrowcaption"
Text="A device name — there is no such setting. Logs record this machine's own name on their own." />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's Preferences page: the Terminal and Keychain cards.</summary>
internal sealed partial class SettingsPreferencesPage : UserControl
{
public SettingsPreferencesPage() => InitializeComponent();
}
@@ -0,0 +1,126 @@
<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.SettingsSecurityPage"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: Security, against Settings-Security.dc.html.
The end-to-end card's sentence is ADR 0001's own claim in the design's words: "The server stores
ciphertext and never holds a key" and "the relay operator ... see ciphertext only" — see docs/adr/0001-e2ee-trust-model.md.
UNLOCKING is Windows Hello's register/"Stop unlocking here" pair, moved here whole from the old
PreferencesScreen.axaml — same RegisterDeviceCommand, ForgetDeviceCommand and the no-TPM explanation
(HasNoDeviceKeyOption). One home for this fact rather than two; see the cross-reference on the
Preferences page. The design's own auto-lock link row is refused: there is nothing to link to, since
auto-lock and per-vault unlock rules do not exist anywhere in this client.
CONNECTING keeps exactly one row, Approved host keys. Strict host-key checking and the allowed-algorithm
chips are both refused — the app always asks on a changed key, and there is no algorithm allow-list
anywhere in the SSH stack. The count and the "N that no host dials" clause both come straight off
KnownHostsViewModel.Summary, the same sentence the known-hosts screen's own header prints, so this row
can never say a different number than the screen it sends you to. "Open host keys" leaves settings mode
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
its own: Logs already holds the whole story, and this row says so rather than half-repeating it.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<TextBlock Classes="settingstitle" Text="Security" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
<StackPanel Orientation="Horizontal" Spacing="16">
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20"
Foreground="{StaticResource Live}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="6" VerticalAlignment="Center">
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption"
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
</StackPanel>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="UNLOCKING" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
<TextBlock Classes="settingsrowcaption"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<Button Grid.Column="1" Classes="danger" Height="38" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanForgetDevice}"
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
</Grid>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="CONNECTING" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Approved host keys" />
<TextBlock Classes="settingsrowcaption" Text="{Binding KnownHostsScreen.Summary}" />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Height="38" Content="OPEN HOST KEYS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}" />
</Grid>
</Border>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
<Border Classes="settingscard" Margin="0,12,0,40">
<StackPanel>
<Border Classes="settingsrow last">
<Button Classes="flat" Padding="0" HorizontalAlignment="Left"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Logs}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="See all activity in Logs" FontSize="12" FontWeight="SemiBold"
Foreground="{StaticResource AccentText}" />
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5C8;" FontSize="14"
Foreground="{StaticResource AccentText}" />
</StackPanel>
</Button>
</Border>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary>
internal sealed partial class SettingsSecurityPage : UserControl
{
public SettingsSecurityPage() => InitializeComponent();
}
@@ -0,0 +1,150 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsTagsPage"
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c-2: Tags, against Settings-Tags.dc.html — a real management page over VaultViewModel's own tag
commands (Tags, NewTag, EditTagRow, DeleteTagRow), which used to be reachable only from the keychain
screen's TAGS category. Both doors stay open — the keychain screen's own panel is untouched — because
they lead to the same commands and the same rows; nothing here is a second implementation.
── THE INTRO SENTENCE IS TRUE, AND IT IS THE DESIGN'S OWN ─────────────────────────────────────────────
"Renaming here is one write and every host follows" — verified against SaveTagAsync and TagRowViewModel's
own remark: a host names a tag's id in its TagSet, never its label, so renaming touches nothing but the
tag item itself. Kept rather than rewritten.
── EDIT AND DELETE ARE WRAPPER COMMANDS, UNLIKE THE GROUPS PAGE'S ──────────────────────────────────────
EditTagCommand and DeleteTagCommand read VaultViewModel.SelectedTag rather than taking a row argument —
they were built for the keychain screen's own ListBox selection, which this page has no equivalent of.
VaultViewModel.EditTagRow/DeleteTagRow (v5c-2, additive) select the row and then call the real command, so
every guard and every sentence either one already has is still the one that runs.
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
The LAST APPLIED column: no timestamp of when a tag was last put on a host exists anywhere in this
client, and a column of "just now" / "2 min ago" would be inventing one.
-->
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBlock Classes="settingstitle" Text="Tags" />
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBlock Text="{Binding Vault.Tags.Count}" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextFaint}" Margin="8,0"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<Button Grid.Column="1" Classes="accent" Height="40" Content="+ NEW TAG"
Command="{Binding Vault.NewTagCommand}"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}" />
</Grid>
<TextBlock Classes="settingsrowcaption" Margin="0,12,0,0" TextWrapping="Wrap"
Text="A tag is a name shared by every host that carries it. Renaming here is one write — every host, filter and snippet rule follows." />
<!-- The tag editor, in place — the same one-box form the keychain screen's own panel draws, restyled
into this page's card idiom. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vault.IsEditingTag}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="Tag" />
<TextBox PlaceholderText="name" Text="{Binding Vault.TagEditorLabel}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveTagCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBlock Classes="settingsrowcaption"
Text="Renaming a tag changes it everywhere at once. No host is rewritten — each one names this tag rather than repeating its name." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="SAVE" Command="{Binding Vault.SaveTagCommand}" />
<Button Classes="ghost" Height="36" Content="CANCEL" Command="{Binding Vault.CancelTagEditCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- The delete confirmation, in place — the same shared control the groups and vaults pages reuse. -->
<Border Margin="0,20,0,0" Padding="20" CornerRadius="12"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1"
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<Border Classes="settingscard" Margin="0,24,0,40" Padding="0"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel>
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
<TextBlock Grid.Column="3" Classes="label" Text=" " />
</Grid>
<ItemsControl ItemsSource="{Binding Vault.Tags}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel">
<Border Classes="settingsrow last" Padding="20,10">
<Grid ColumnDefinitions="1.4*,*,*,Auto">
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" />
</Border>
<TextBlock Grid.Column="1" Classes="mono" FontSize="11.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" />
<!--
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
so there is no second vault this column could honestly name.
-->
<TextBlock Grid.Column="2" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon danger" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Delete this tag. Hosts wearing it simply stop showing the chip.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Classes="settingsrowcaption" Margin="20,14,20,18" TextWrapping="Wrap"
IsVisible="{Binding !Vault.HasTagItems}"
Text="No tags yet. Add one from here, or from a host's own editor." />
</StackPanel>
</Border>
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
Text="Unlock your keychain to see and manage your tags." />
</StackPanel>
</ScrollViewer>
</UserControl>
@@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Tags: every tag, and how many hosts wear each — see the remark at the top of the markup.</summary>
internal sealed partial class SettingsTagsPage : UserControl
{
public SettingsTagsPage() => InitializeComponent();
}
@@ -0,0 +1,79 @@
<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.SettingsTitleBar"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: settings mode's own titlebar — SettingsNav.dc.html and the four Settings-*.dc.html sources all draw
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
the same three window-control glyphs on the right TitleBar.axaml already draws.
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
bars needs its own window behaviour a shared base would have to be pulled apart first.
v5c-3: two back buttons rather than one whose text and command a converter swaps, toggled by
MainWindowViewModel.IsImportOpen — Import.dc.html draws the same 53px bar with "Back to preferences" in
place of "Back to application" while the importer is up, and CloseImportCommand closes it without leaving
settings mode, unlike LeaveSettingsCommand.
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding !IsImportOpen}"
Command="{Binding LeaveSettingsCommand}"
ToolTip.Tip="Back to the screen you were on before opening Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5C4;" FontSize="17"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Text="Back to application" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<Button Grid.Column="0" Classes="flat" HorizontalAlignment="Left"
IsVisible="{Binding IsImportOpen}"
Command="{Binding CloseImportCommand}"
ToolTip.Tip="Back to preferences, without leaving Settings">
<StackPanel Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5C4;" FontSize="17"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
<TextBlock Text="Back to preferences" FontSize="13" FontWeight="SemiBold"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
</Button>
<!-- The window controls, identical to TitleBar's own — see the remark above on why they are repeated. -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2">
<Button Classes="flat" Width="26" Height="24" Click="OnMinimise"
ToolTip.Tip="Minimise">
<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 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 FontFamily="{StaticResource IconFont}" Text="&#xE5CD;" FontSize="14"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Button>
</StackPanel>
</Grid>
</Border>
</UserControl>
@@ -0,0 +1,47 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's own titlebar — see the remark in the markup for why it is not TitleBar itself.</summary>
internal sealed partial class SettingsTitleBar : UserControl
{
public SettingsTitleBar() => InitializeComponent();
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
private void OnDrag(object? sender, PointerPressedEventArgs e)
{
if (e.Handled || !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
Host?.BeginMoveDrag(e);
}
private void OnMinimise(object? sender, RoutedEventArgs e)
{
if (Host is { } window)
{
window.WindowState = WindowState.Minimized;
}
}
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
{
return;
}
window.WindowState = window.WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
}
private void OnClose(object? sender, RoutedEventArgs e) => Host?.Close();
}
@@ -0,0 +1,407 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.SettingsVaultsPage"
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c-2: Vaults, against Settings-Vaults.dc.html. Replaces the old full-bleed VaultsScreen, which this
settings page now covers completely — every command it had is reachable here exactly once, and
VaultsScreen.axaml is gone. Its data is DataContext.Vaults (VaultsViewModel), reached as {Binding Vaults.*}
throughout because this page's own DataContext is the shell, the same choice every other settings page
makes.
── WHAT THE DESIGN DREW AND THIS PAGE DOES NOT ─────────────────────────────────────────────────────────
"Manage devices" and "3 devices" in the sync line — no list-devices endpoint exists. The whole VAULT
DEFAULTS card (auto-lock, require-password-on-unlock, relay toggle) and the whole RECOVERY card (kit,
export) — none of the three exists; sync is always on. The magenta "Default" badge — the app does track a
"new items go to" vault (VaultViewModel.TargetVaultId), but that lives on a different view model than the
row being drawn here and cross-referencing the two per card would be more machinery than the badge is
worth; dropped rather than faked. Per-vault UNLOCKED/LOCKED chips — this app locks the keychain as a
whole, not one vault at a time, so there is no per-vault state for a chip to draw; the keychain-level fact
lives in the sync card's dot instead. The "Unlock Rocateq" modal — same reason, there is nothing per-vault
to unlock. Member avatar stacks on the card list — VaultRowViewModel carries a member *count*, not the
members themselves; only the selected vault's Members collection is actually loaded, which is why avatars
are drawn in the members panel below and nowhere else. The "Sorted by name ▾" control — decorative in the
mock; Vaults.Vaults is already ordered personal-first-then-name and there is no second order to switch to.
── THE MEMBERS PANEL ────────────────────────────────────────────────────────────────────────────────────
The design draws an 860px modal. This app's only overlay idiom below a whole-window mode is the scrim a
Border with a translucent Background and a PointerPressed handler draws — see QuickConnect.axaml, the
other place a click-away-to-close panel exists — so that is what this is, capped at MaxWidth 860 rather
than fixed to it: the settings content column is narrower than 860 at the window's minimum, and a modal
that insisted on the full width would arrange its own rows past the edge of what this page is given.
Inside it is the old screen's own right-hand column, restyled rather than reinvented: a selectable list of
members, the SET ROLE buttons, the ADD row, REMOVE, HAND OVER, SHARE KEY/WITHDRAW KEY and the KEY HOLDERS
list, on the same VaultsViewModel.SelectedMember selection the old screen used. Deleting a vault stays a
card-level action (the danger icon below), not a members-panel one, so PendingAction is drawn in only one
of the two places at a time — see the two IsVisible guards below, which key off IsMembersPanelOpen because
HAND OVER can only ever be armed with the panel open (it needs a selected member) and DELETE only with it
closed (the card's own icon is what arms it).
The sentence that must survive from the old screen: a vault cannot be deleted through the membership list
behind it — there is no such call anywhere in the server, and archiving a team while its vault still
exists is refused. That is not the same fact as "a vault cannot be deleted" — the DELETE icon below does
delete the vault itself — and both sentences are kept, each where it is true.
-->
<Panel>
<ScrollViewer>
<StackPanel MaxWidth="1100" Margin="40" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<TextBlock Classes="settingstitle" Text="Vaults" />
<Border Background="{StaticResource Chip}" CornerRadius="9" MinWidth="34" Height="30">
<TextBlock Text="{Binding Vaults.Vaults.Count}" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextFaint}" Margin="8,0"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<Button Classes="ghost" Height="40" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
<Button Classes="accent" Height="40" Content="+ NEW VAULT"
Command="{Binding Vaults.NewVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" />
</StackPanel>
</Grid>
<!-- The name-a-vault and rename-a-vault forms, in place — this window has no modal idiom for a bare
text field, and the members panel below is a different kind of overlay: over one vault's people,
not over a form. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vaults.IsCreatingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="New vault" />
<TextBox PlaceholderText="Name" Text="{Binding Vaults.NewVaultName}" />
<TextBlock Classes="settingsrowcaption"
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="CREATE"
Command="{Binding Vaults.CreateVaultCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelNewVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
IsVisible="{Binding Vaults.IsRenamingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="Rename vault" />
<TextBox PlaceholderText="Name" Text="{Binding Vaults.EditVaultName}" />
<TextBlock Classes="settingsrowcaption"
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes — the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="accent" Height="36" Content="SAVE"
Command="{Binding Vaults.SaveVaultNameCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelRenameVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- A vault's own destructive confirmation. Only while the members panel is shut — see the remark
above for why the two never compete for this one PendingAction. Both conditions on the same
Border, not one nested inside the other: a Border visible with its content hidden would still
draw an empty danger-tinted card while the members panel's own copy of this confirmation is
showing. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}">
<Border.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Vaults.IsConfirming" />
<Binding Path="!Vaults.IsMembersPanelOpen" />
</MultiBinding>
</Border.IsVisible>
<StackPanel Spacing="8">
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="14.5" FontWeight="Bold"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="12.5"
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Height="36" Content="CONFIRM"
Command="{Binding Vaults.ConfirmActionCommand}" IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Height="36" Content="CANCEL"
Command="{Binding Vaults.CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!-- Sync status: only what is real — no device count, no "ran N minutes ago" (this session tracks no
such timestamp). The dot and the word are MainWindowViewModel.IsFullySynced/SyncLabel, the exact
fact the titlebar's own dot already tells the truth with. -->
<Border Classes="settingscard" Margin="0,20,0,0" Padding="20,18">
<StackPanel Orientation="Horizontal" Spacing="14">
<Ellipse Classes="dot" Classes.live="{Binding IsFullySynced}" Width="9" Height="9"
Margin="0,7,0,0" VerticalAlignment="Top" />
<StackPanel Spacing="6">
<TextBlock Classes="mono" Text="{Binding SyncLabel}" FontSize="13" FontWeight="Bold"
LetterSpacing="0.5" Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption" Text="End-to-end encrypted." />
</StackPanel>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="YOUR VAULTS" Margin="0,32,0,12" />
<StackPanel Spacing="10" Margin="0,0,0,40" IsVisible="{Binding Vaults.HasVaults}">
<ItemsControl ItemsSource="{Binding Vaults.Vaults}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Spacing="10" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:VaultRowViewModel">
<Border Classes="settingscard" Padding="22,18">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Width="52" Height="52" CornerRadius="12"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding Initial}" FontWeight="Bold" FontSize="20"
Foreground="White" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="7" Margin="16,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Name}" FontSize="17" FontWeight="Bold"
LetterSpacing="-0.25" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" />
<Border Classes="chip">
<TextBlock Text="{Binding RoleLabel}" />
</Border>
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding Detail}" TextWrapping="Wrap" />
<TextBlock Classes="mono" FontSize="10.5" Foreground="{StaticResource WarnText}"
Text="{Binding State}" IsVisible="{Binding HasState}" TextWrapping="Wrap" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.OpenMembersPanelCommand}"
CommandParameter="{Binding}" IsVisible="{Binding IsShared}"
ToolTip.Tip="See and manage who is in this vault.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE7EF;" />
</Button>
<Button Classes="paneicon" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.RenameVaultRowCommand}"
CommandParameter="{Binding}" IsVisible="{Binding CanAdminister}"
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE3C9;" />
</Button>
<Button Classes="paneicon danger" Width="34" Height="34" FontSize="15"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vaults.DeleteVaultRowCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding !IsPersonal}"
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</StackPanel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<TextBlock Classes="settingsrowcaption" Margin="0,20,0,40" TextWrapping="Wrap"
IsVisible="{Binding !Vaults.HasVaults}"
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
<TextBlock Margin="0,0,0,40" FontSize="11.5" TextWrapping="Wrap"
Foreground="{StaticResource TextFaint}"
IsVisible="{Binding Vaults.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Text="{Binding Vaults.Status}" />
</StackPanel>
</ScrollViewer>
<!-- ============ THE MEMBERS PANEL ============ -->
<Border x:Name="MembersBackdrop" Background="#9905050A"
IsVisible="{Binding Vaults.IsMembersPanelOpen}" PointerPressed="OnBackdropPressed">
<Border MaxWidth="860" MaxHeight="620" Margin="30"
HorizontalAlignment="Center" VerticalAlignment="Center"
Background="{StaticResource Chrome}" BorderBrush="{StaticResource BorderMid}"
BorderThickness="1" CornerRadius="14">
<Grid RowDefinitions="Auto,*" Margin="26,22">
<Grid Grid.Row="0" ColumnDefinitions="*,Auto" Margin="0,0,0,14">
<TextBlock Grid.Column="0" Text="{Binding Vaults.SelectedVault.Name, StringFormat='{}{0} · Members'}"
FontSize="18" FontWeight="Bold" LetterSpacing="-0.25"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1" Classes="paneicon" Width="26" Height="26"
Command="{Binding Vaults.CloseMembersPanelCommand}" ToolTip.Tip="Close">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE5CD;" FontSize="15" />
</Button>
</Grid>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="16">
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vaults.HasSharedMembershipWarning}"
Text="{Binding Vaults.SharedMembershipWarning}" />
<StackPanel Spacing="8" IsVisible="{Binding !Vaults.SelectedIsPersonal}">
<TextBlock Classes="settingssection" Text="MEMBERS" Margin="0" />
<ListBox ItemsSource="{Binding Vaults.Members}" SelectedItem="{Binding Vaults.SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="220">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="Auto,*,150,Auto" Margin="0,4">
<Border Grid.Column="0" Width="30" Height="30" CornerRadius="15"
Background="{StaticResource AvatarGradient}">
<TextBlock Text="{Binding Initials}" FontWeight="Bold" FontSize="10"
Foreground="White" HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1" Spacing="2" Margin="10,0" VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding Email}" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="settingsrowcaption" FontSize="10.5" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="settingsrowcaption" FontSize="10" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Role}" FontSize="10.5"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- The hand-over confirmation, only while this panel is the one that armed it. -->
<Border Padding="14" CornerRadius="10" Background="{StaticResource DangerWash}"
BorderBrush="{StaticResource DangerSoft}" BorderThickness="1"
IsVisible="{Binding Vaults.IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding Vaults.PendingAction.Question}" FontSize="13.5" FontWeight="Bold"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Text="{Binding Vaults.PendingAction.Consequence}" FontSize="11.5"
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="CONFIRM" Command="{Binding Vaults.ConfirmActionCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding Vaults.CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !Vaults.IsBusy}" />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding Vaults.ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !Vaults.IsBusy}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="HAND OVER" Command="{Binding Vaults.HandOverCommand}"
IsEnabled="{Binding !Vaults.IsBusy}" IsVisible="{Binding Vaults.OwnsSelected}"
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
<Button Classes="danger" Content="REMOVE" Command="{Binding Vaults.RemoveMemberCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only." />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.CanAdministerSelected}">
<TextBlock Classes="settingssection" Text="INVITE" Margin="0" />
<Grid ColumnDefinitions="*,140,44">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
Text="{Binding Vaults.NewMemberEmail}" Margin="0,0,8,0" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4">
<Button Classes="flat choice" Content="VIEWER"
Classes.active="{Binding Vaults.AddsAsViewer}"
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER"
Classes.active="{Binding Vaults.AddsAsMember}"
Command="{Binding Vaults.ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
</StackPanel>
<Button Grid.Column="2" Classes="accent" Width="44" Content="+"
Command="{Binding Vaults.AddMemberCommand}" IsEnabled="{Binding !Vaults.IsBusy}"
Margin="8,0,0,0"
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so." />
</Grid>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Adding somebody lets the server serve them this vault. It does not let them read it — that needs a vault key, which SHARE KEY below wraps for them." />
</StackPanel>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding Vaults.SelectedIsPersonal}">
<TextBlock Classes="settingssection" Text="YOURS ALONE" Margin="0" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright. Make a vault for the things you want to share, and put them in it." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding Vaults.SelectedIsShared}">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding Vaults.ShareVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding Vaults.RevokeVaultCommand}"
IsEnabled="{Binding !Vaults.IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
</StackPanel>
<TextBlock Classes="settingssection" Text="KEY HOLDERS" Margin="0" />
<ListBox ItemsSource="{Binding Vaults.Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="120">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="settingsrowcaption" FontSize="11" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Load-bearing, from the old screen: there is no DELETE here for a reason, and the reason is
not "not built yet". The membership list behind a shared vault cannot be archived while the
vault exists — that is a different fact from the vault itself, which the card's own DELETE
icon does remove.
-->
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="A vault's membership list cannot be deleted on its own — the server refuses to archive it while the vault it carries still exists. Deleting the vault itself is the card's own icon, outside this panel." />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</Border>
</Panel>
</UserControl>
@@ -0,0 +1,29 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>Vaults, restyled into the settings page idiom — see the remark at the top of the markup.</summary>
internal sealed partial class SettingsVaultsPage : UserControl
{
public SettingsVaultsPage() => InitializeComponent();
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
/// <remarks>
/// Only a press on the wash itself, the same test <c>QuickConnect.OnBackdropPressed</c> makes: a press
/// inside the card bubbles through here too, with its source the control that was actually hit rather
/// than the backdrop, so closing on those would make the panel impossible to click into.
/// </remarks>
private void OnBackdropPressed(object? sender, PointerPressedEventArgs e)
{
if (!ReferenceEquals(e.Source, MembersBackdrop) || Shell is not { } shell)
{
return;
}
shell.Vaults.CloseMembersPanelCommand.Execute(null);
e.Handled = true;
}
}
@@ -0,0 +1,68 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsView"
x:DataType="vm:MainWindowViewModel">
<!--
v5c: the settings MODE — the full-window chrome swap the design calls Settings, built as one control so
MainWindow.axaml can show or hide the whole thing behind MainWindowViewModel.IsSettingsMode with a single
IsVisible, the same way it already does for the unlocked application and the setup half of the window.
Titlebar, then a row of the 340px SettingsNav beside whichever page ActiveSettingsPage names. Seven pages
now, all of the design's — v5c-2 added Groups and Tags and retired the old VaultsScreen; see
SettingsNav.axaml and design-notes/v5c-fidelity-notes.md.
Every page including Vaults is wrapped in the 1100px centred column now. SettingsVaultsPage replaces the
old VaultsScreen's own 268px-list-beside-a-full-bleed-pane layout with the design's card idiom — see that
page's own remark for what changed and why.
v5c-3: ImportScreen joined the Panel, drawn over SettingsPreferencesPage rather than beside it — see
MainWindowViewModel.IsImportOpen. SettingsNav stays lit on Preferences the whole time, per Import.dc.html,
because ActiveSettingsPage never actually leaves SettingsPage.Preferences; only IsImportOpen and the pair
of IsVisible bindings below move. Its data context is ImportScreen rather than this control's own, the
same split MainWindow.axaml drew before the importer moved in here.
-->
<Grid RowDefinitions="Auto,*" Background="{StaticResource Canvas}">
<views:SettingsTitleBar Grid.Row="0" />
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<views:SettingsNav Grid.Column="0" />
<Panel Grid.Column="1">
<views:SettingsGeneralPage IsVisible="{Binding IsSettingsGeneralPage}" />
<views:SettingsVaultsPage IsVisible="{Binding IsSettingsVaultsPage}" />
<views:SettingsAccountPage IsVisible="{Binding IsSettingsAccountPage}" />
<views:SettingsSecurityPage IsVisible="{Binding IsSettingsSecurityPage}" />
<views:SettingsPreferencesPage IsVisible="{Binding IsSettingsPreferencesContentShowing}" />
<views:SettingsGroupsPage IsVisible="{Binding IsSettingsGroupsPage}" />
<views:SettingsTagsPage IsVisible="{Binding IsSettingsTagsPage}" />
<!--
Wrapped, like MainWindow.axaml wraps every screen whose data context is its own rather than this
control's: IsVisible has to resolve against the ambient MainWindowViewModel, and putting it on the
same element as the DataContext override below would have it resolve against ImportViewModel
instead, where IsImportOpen does not exist.
-->
<Panel IsVisible="{Binding IsImportOpen}">
<views:ImportScreen DataContext="{Binding ImportScreen}" />
</Panel>
</Panel>
</Grid>
</Grid>
</UserControl>
@@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The settings mode's whole chrome: its own titlebar, its own 340px rail, and whichever of its five pages
/// <see cref="DodoSSH.Client.Shell.ViewModels.MainWindowViewModel.ActiveSettingsPage"/> names.
/// </summary>
internal sealed partial class SettingsView : UserControl
{
public SettingsView() => InitializeComponent();
}
@@ -1,362 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:contracts="using:DodoSSH.Contracts"
x:Class="DodoSSH.Client.App.Views.VaultsScreen"
x:DataType="vm:VaultsViewModel">
<!--
Vaults, and the people in each of them.
── THIS WAS THE TEAMS SCREEN, AND THE TEAM IS NOW BEHIND THE VAULT. ─────────────────────────────────
The left column used to list teams; a team owned vaults, and sharing meant creating a team, then a
vault in it, then wrapping a key. Two of those three steps were about a concept nobody came here for.
So the rows are vaults now: naming one makes the membership list that carries it, and everything on
the right — members, key holders — is that vault's. The server still authorises against
a team, because that is what VaultAccessService resolves; what went is the requirement that a person
know it exists. The one case where it is still visible is a membership list carrying several vaults,
which this screen cannot make and will not hide: see SharedMembershipWarning.
── THE ONE FACT THE WHOLE SCREEN IS BUILT AROUND ────────────────────────────────────────────────────
Adding somebody to a vault and giving them its key are two different acts, and only the first is
something a server can do. The second needs a machine that holds the key, because this server never
does. So the members list and the key-holders list are both here and are not the same list, an
addition says out loud that it granted nothing readable yet, and SHARE KEY is its own button rather
than a checkbox on the member row.
What the design asked for and is still not here: two-factor state (no such concept exists anywhere in
this product) and avatars (no picture is stored anywhere). There is no INVITED list either, and that
one is a decision rather than a gap — an address is not a way into a vault, so only an account that
already exists can be added and there is nothing pending to draw. See the ADD box below, which says
what to do about somebody who has not signed in here yet. Last-active is recorded at most once per
account per hour, so it is drawn coarsely. Nor is there a way to delete a vault: the server has no
such call, and the screen says so rather than offering a button that refuses.
-->
<Grid ColumnDefinitions="268,*">
<!-- ============ The vault list ============ -->
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
<Grid RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="VAULTS" FontSize="12" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="ghost" Content="NEW"
Command="{Binding NewVaultCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Makes a vault you can share. Its key is generated on this machine, and nobody else has it until you hand it out." />
</Grid>
</Border>
<ScrollViewer Grid.Row="1">
<StackPanel>
<!--
Read from this machine's own vault list rather than from the server, so the column is right
with no connection. What is missing offline is who is in each one, which is why a row can
say its membership is unknown rather than saying nothing at all.
-->
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
Background="Transparent" BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultRowViewModel">
<StackPanel Spacing="2" Margin="0,3">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding RoleLabel}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="8,0,0,0" />
</Grid>
<TextBlock Classes="hint" FontSize="11" Text="{Binding Detail}" />
<!--
Only when there is something to say. A vault waiting for a key and one owing a rekey
are both temporary and both need somebody to act; a permanent "fine" beside them
would teach people to stop reading the line.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding State}"
TextWrapping="Wrap" IsVisible="{Binding HasState}" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="11" Margin="14,12" TextWrapping="Wrap"
IsVisible="{Binding !HasVaults}"
Text="No vaults yet. Unlock your keychain to see the personal one, or make a vault to share hosts and credentials with colleagues." />
</StackPanel>
</ScrollViewer>
<!--
The name-a-vault form, in place rather than in a modal: this window has no idiom for one. It is
in this column rather than beside the pane on the right for one reason — that pane is bound to
HasSelection, so with no vaults at all it is not on screen, and "no vaults at all" is exactly
the state somebody arrives in from the tab strip's New vault entry.
One field. The membership list behind it is made with it and named after it, and its slug is
derived — see VaultsViewModel.CreateVaultAsync. Asking for a URL handle would be asking for one
from somebody who has not been told they are making anything but a vault.
-->
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding IsCreatingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="NEW VAULT" />
<TextBox PlaceholderText="Name" Text="{Binding NewVaultName}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Its key is made on this machine and nobody else has it. Add people to it once it exists, then press SHARE KEY." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="CREATE" Command="{Binding CreateVaultCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
</Grid>
</Border>
<!-- ============ The selected vault: who is in it, and who can open it ============ -->
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding SelectedVault.Name}" FontSize="12"
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
VerticalAlignment="Center" />
<!--
The vault's own operations. RENAME is an admin's; handing it on is the owner's alone, and
that is the line the server draws as well — an admin the owner promoted must not be able to
take the vault from them.
DELETE is an admin's too, and it is last and red because it is the only one of the three that
cannot be undone. It is absent on the personal vault rather than disabled: there is no version
of this window in which that vault can go, so a greyed button would be an offer that never
becomes real. Its question is the card below, as every destructive answer on this screen is.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
IsVisible="{Binding ShowsVaultActions}">
<Button Classes="ghost" Content="RENAME" Command="{Binding RenameVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}"
ToolTip.Tip="Changes what this vault is called. The name is plaintext on the server, as it always was; nothing inside is re-encrypted." />
<Button Classes="ghost" Content="HAND OVER" Command="{Binding HandOverCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding OwnsSelected}"
ToolTip.Tip="Hands this vault to the selected member. They become its owner and you become an admin; only the new owner can hand it on again." />
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteVaultCommand}"
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanDeleteSelected}"
ToolTip.Tip="Deletes this vault and withdraws everybody's key to it. It cannot reach a machine that has already synced it." />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
<StackPanel Margin="14,14" Spacing="18">
<!-- The rename form, in place, exactly as the create form on the left is. -->
<Border Padding="12" CornerRadius="4" BorderThickness="1"
BorderBrush="{StaticResource Border}" IsVisible="{Binding IsRenamingVault}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="RENAME VAULT" />
<TextBox PlaceholderText="Name" Text="{Binding EditVaultName}" />
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="Everybody who shares this vault sees the new name. Nothing is re-encrypted and no key changes; the name has always been stored in plain text, because a person has to be able to pick a vault before anything is decrypted." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveVaultNameCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelRenameVaultCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
The armed confirmation, drawn where the buttons that armed it were. The keychain screen's
idiom, and for the same reason: there is no modal anywhere in this window.
-->
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1" CornerRadius="4" Padding="12"
IsVisible="{Binding IsConfirming}">
<StackPanel Spacing="8">
<TextBlock Text="{Binding PendingAction.Question}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="{Binding PendingAction.Consequence}" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="CONFIRM" Command="{Binding ConfirmActionCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelActionCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
The personal vault, which is the one vault sharing cannot reach. Said here rather than by
drawing the members section empty: an empty MEMBERS heading over a vault that can never have
any reads as a feature that has not loaded.
-->
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsPersonal}">
<TextBlock Classes="label" Text="YOURS ALONE" />
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="Nobody can be added to your personal vault, and the server refuses a key grant on one outright — a key wrapped to somebody it will go on refusing to serve would look like sharing and would not be. Make a vault above for the things you want to share, and put them in it." />
</StackPanel>
<!-- Members -->
<StackPanel Spacing="8" IsVisible="{Binding SelectedIsShared}">
<TextBlock Classes="label" Text="MEMBERS" />
<!--
Only ever non-empty for a membership list this screen did not make. Adding somebody to one
vault and silently adding them to three others is precisely the fact a vault-shaped screen
is in a position to hide, so it says it instead.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding HasSharedMembershipWarning}"
Text="{Binding SharedMembershipWarning}" />
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
Background="Transparent" BorderThickness="0" MaxHeight="240">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultMemberRowViewModel">
<Grid ColumnDefinitions="*,168,Auto" Margin="0,3">
<StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Email}" />
</StackPanel>
<StackPanel Grid.Column="1" Spacing="2" VerticalAlignment="Center">
<TextBlock Classes="hint" FontSize="11" Text="{Binding KeyState}"
TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding LastActive}" />
</StackPanel>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="10"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Margin="10,0,0,0" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!--
Buttons and a command rather than a selector bound to the role, which is the choice the
key editor and the category rail already make and for the reason they record: a selector
moves its own highlight before anything can refuse, so it can end up showing a role
nobody was given. OWNER is absent because it is not a role that can be assigned —
handing the vault over is its own act, with its own confirmation.
-->
<StackPanel Spacing="6" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="SET THE SELECTED MEMBER'S ROLE" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May pull this vault and may not push. It does not withdraw a key they already hold." />
<Button Classes="flat choice" Content="MEMBER" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May read and change what is in this vault." />
<Button Classes="flat choice" Content="ADMIN" Command="{Binding ChangeRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="May also add and remove people, rename the vault, and share its key." />
</StackPanel>
</StackPanel>
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com"
Text="{Binding NewMemberEmail}" Margin="0,0,6,0" />
<Button Grid.Column="1" Classes="accent" Content="ADD"
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Adds the account that signs in with this address. An address with no account here is refused and says so — ask them to sign in to this server once, which is what creates the account, and then add them." />
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Removes the selected member and withdraws every key they hold to this vault. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
</Grid>
<StackPanel Spacing="4" IsVisible="{Binding CanAdministerSelected}">
<TextBlock Classes="label" Text="THEY ARRIVE AS" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="flat choice" Content="VIEWER" Classes.active="{Binding AddsAsViewer}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Viewer}" />
<Button Classes="flat choice" Content="MEMBER" Classes.active="{Binding AddsAsMember}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Member}" />
<Button Classes="flat choice" Content="ADMIN" Classes.active="{Binding AddsAsAdmin}"
Command="{Binding ChooseNewMemberRoleCommand}"
CommandParameter="{x:Static contracts:TeamMemberRole.Admin}" />
</StackPanel>
</StackPanel>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding CanAdministerSelected}"
Text="Adding somebody lets the server serve them this vault. It does not let them read it: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
</StackPanel>
<Border Height="1" Background="{StaticResource BorderSubtle}" />
<!-- Who holds the key -->
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding SelectedIsShared}">
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Wraps this vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
IsEnabled="{Binding !IsBusy}"
ToolTip.Tip="Withdraws the selected member's key to this vault. Blocks future reads only." />
</StackPanel>
<!--
Who can open this vault — the design's "shared with" avatars, as names and a state.
Withdrawn and stale grants stay listed and say which they are, because a list that quietly
dropped them would show a departed colleague as merely absent rather than as somebody whose
key was taken away. The dot is Live and means exactly what it says: this person can open
this vault right now.
-->
<TextBlock Classes="label" Text="KEY HOLDERS" />
<ListBox ItemsSource="{Binding Grants}" Background="Transparent" BorderThickness="0"
MaxHeight="150">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultGrantRowViewModel">
<Grid ColumnDefinitions="10,*" Margin="0,3">
<Ellipse Grid.Column="0" Width="6" Height="6" VerticalAlignment="Center"
IsVisible="{Binding IsLive}" Fill="{StaticResource Live}" />
<StackPanel Grid.Column="1" Spacing="2" Margin="6,0,0,0">
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="Medium"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding State}" />
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
IsVisible="{Binding SelectedIsShared}"
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
<!--
Said once, where somebody would otherwise go looking for a DELETE button. There is no call
for it anywhere in the server, and archiving the membership list behind a vault is refused
while the vault exists — so a button here would be one that always refuses.
-->
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="A vault cannot be deleted. Nothing in this product removes one, and the server refuses to archive the membership list behind it while it still exists." />
</StackPanel>
</StackPanel>
</ScrollViewer>
<TextBlock Grid.Row="1" Classes="hint" FontSize="12" Margin="20" TextWrapping="Wrap"
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
Text="Make a vault on the left, or wait for somebody to add you to one. A vault holds hosts and credentials that a group of people share; its key is what makes those readable, and that key is handed out by people rather than by the server." />
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Classes="hint" FontSize="11.5" Text="{Binding Status}" TextWrapping="Wrap" />
</Border>
</Grid>
</Grid>
</UserControl>
@@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Vaults: which there are, who is in each, and who holds a key to it.</summary>
internal sealed partial class VaultsScreen : UserControl
{
public VaultsScreen() => InitializeComponent();
}
@@ -1,4 +1,5 @@
using System.Collections.ObjectModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
@@ -54,6 +55,16 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal string Address => host.Address;
/// <summary>What <c>HostName</c> said, on its own — the v5c table's own column, beside <see cref="User"/>
/// and <see cref="Port"/> rather than folded into <see cref="Address"/>.</summary>
internal string Hostname => host.Hostname;
/// <summary>What <c>User</c> said, or an em dash where the entry named none.</summary>
internal string User => host.Username is { Length: > 0 } user ? user : "—";
/// <summary>What <c>Port</c> said, defaulting to 22 the same way <see cref="ImportedHost"/> does.</summary>
internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
/// <summary>Whether a host with this address is already in the keychain.</summary>
internal bool AlreadyPresent { get; }
@@ -61,6 +72,23 @@ internal sealed partial class ImportRowViewModel : ObservableObject
internal bool HasBadge => AlreadyPresent;
/// <summary>
/// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually
/// carries — nothing this screen cannot back up. Skipped patterns (a wildcard <c>Host</c> block) never
/// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host
/// warnings, from <see cref="HasWarnings"/>, are the amber case instead — a flattened <c>ProxyJump</c> or
/// a dropped directive is exactly the kind of thing "quieter than the file" that <c>ImportScreen.axaml</c>'s
/// own remark says has to be told before it looks like data loss.
/// </summary>
internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
/// <summary>The warned case wins over "already here" — a warning is the more actionable of the two facts.</summary>
internal bool IsMeaningWarned => HasWarnings;
internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
/// <remarks>
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
@@ -138,7 +166,10 @@ internal sealed partial class ImportRowViewModel : ObservableObject
/// <see cref="KeyReport"/>.
/// </para>
/// </remarks>
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
internal sealed partial class ImportViewModel(
VaultViewModel vault,
SshConfigLocator locator,
Action? onCancel = null) : ObservableObject
{
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
@@ -202,6 +233,66 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
/// <summary>Whether every row is ticked — what the v5c table's header tick-all box shows.</summary>
internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
/// <summary>
/// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
/// </summary>
/// <remarks>
/// Two real facts and nothing invented — <see cref="ConfigPath"/> and <see cref="HasScanned"/>. The
/// fuller narrative belongs to <see cref="Status"/>, which this does not replace: what happened on a scan
/// or an import is a sentence, not a fact this header line has room to state honestly in a handful of
/// words.
/// </remarks>
internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
/// <summary>
/// The key-material card's own always-visible sentence, ahead of the tick.
/// </summary>
/// <remarks>
/// The count is hosts naming a key file, not raw <c>IdentityFile</c> lines — a fact <see cref="Rows"/>
/// actually carries, where a literal line count would not survive a host that names more than one and is
/// only ever bound to the first. The rest of the sentence is <c>ImportViewModel</c>'s own long-standing
/// claim, restated in the design's words after checking it against <c>SshConfigLocator</c>: this type is
/// the only place in the application that reads a private key out of a directory nobody pointed at file
/// by file, and <see cref="ImportAsync"/> is the only place that ever calls
/// <see cref="SshConfigLocator.ReadIdentity"/> — never <see cref="ScanAsync"/> — so nothing is read until
/// IMPORT is pressed.
/// </remarks>
internal string KeyMaterialIntro
{
get
{
var count = Rows.Count(row => row.HasKeyFile);
var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath;
var noun = count == 1 ? "host names" : "hosts name";
return $"The scan found {count} {noun} a key file in {directory}. This is the only control in "
+ "DodoSSH that opens key material from a directory you did not point at file by file — "
+ "nothing is read until Import is pressed.";
}
}
/// <summary>The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.</summary>
/// <remarks>
/// Fixed rather than offered as a picker: the import goes through the same
/// <c>session.ActiveVaultId</c> every other bulk write does, and there is no per-import target choice to
/// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown.
/// </remarks>
internal string VaultName => vault.VaultName;
/// <summary>The v5c footer's own sentence: how many are ticked, out of how many, and where they land.</summary>
internal string SelectionSummary
{
get
{
var noun = Rows.Count == 1 ? "entry" : "entries";
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
}
}
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
[RelayCommand]
private async Task ScanAsync(CancellationToken cancellationToken)
@@ -389,6 +480,16 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
internal void NoteSelectionChanged() => RaiseListState();
/// <summary>The footer's own Cancel button: back to the Preferences page, nothing stored.</summary>
/// <remarks>
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the same reasoning
/// <c>VaultViewModel</c>'s own <c>copyToClipboard</c> is one: this type has no business knowing settings
/// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes
/// the button a no-op rather than a crash.
/// </remarks>
[RelayCommand]
private void Cancel() => onCancel?.Invoke();
/// <remarks>
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
@@ -441,5 +542,11 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
OnPropertyChanged(nameof(HasKeyReport));
OnPropertyChanged(nameof(SelectedCount));
OnPropertyChanged(nameof(ImportLabel));
OnPropertyChanged(nameof(AllTicked));
OnPropertyChanged(nameof(KeyMaterialIntro));
OnPropertyChanged(nameof(SelectionSummary));
}
/// <remarks>The header's own status line is a function of <see cref="HasScanned"/> alone.</remarks>
partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
}
@@ -106,10 +106,19 @@ internal sealed class KnownHostRowViewModel(
internal sealed partial class KnownHostsViewModel : ObservableObject
{
private readonly VaultViewModel vault;
private readonly Action? onBack;
internal KnownHostsViewModel(VaultViewModel vault)
/// <param name="vault">Where the pins, the reload and the withdrawal all actually live.</param>
/// <param name="onBack">
/// What the v5c header's own back arrow does — a delegate rather than a reference up to
/// <c>MainWindowViewModel</c>, on the same reasoning <c>ImportViewModel</c>'s own <c>onCancel</c> is one:
/// this type has no business knowing <c>ShellScreen</c> exists. Null in a layout test that builds this
/// directly makes the button a no-op rather than a crash.
/// </param>
internal KnownHostsViewModel(VaultViewModel vault, Action? onBack = null)
{
this.vault = vault;
this.onBack = onBack;
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
// copy of a trust decision is the one kind of staleness that matters here.
@@ -155,6 +164,14 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
internal bool HasPins => Shown.Any();
/// <summary>
/// How many approved host keys this machine can see, before the filter box narrows the table — the v5c
/// header's own count chip. Unfiltered, on the same reasoning <see cref="Summary"/> reads off
/// <see cref="Shown"/> rather than <see cref="VisiblePins"/>: it is a fact about the list, not about
/// whatever somebody last typed into the filter.
/// </summary>
internal int Count => Shown.Count();
internal bool HasVisiblePins => VisiblePins.Count > 0;
internal bool HasSelection => Selected is not null;
@@ -218,6 +235,22 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>Puts the selected pin's fingerprint on the clipboard. Forwarded, like <see cref="ForgetSelectedAsync"/>.</summary>
[RelayCommand]
private async Task CopyFingerprintAsync()
{
if (Selected is null)
{
return;
}
await vault.CopyPinFingerprintCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>The header's own back arrow: to the Keychain screen this list was pulled out of.</summary>
[RelayCommand]
private void Back() => onBack?.Invoke();
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
partial void OnFilterChanged(string value) => Rebuild();
@@ -247,6 +280,7 @@ internal sealed partial class KnownHostsViewModel : ObservableObject
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
OnPropertyChanged(nameof(HasPins));
OnPropertyChanged(nameof(Count));
OnPropertyChanged(nameof(HasVisiblePins));
OnPropertyChanged(nameof(Summary));
OnPropertyChanged(nameof(EmptyMessage));
@@ -206,6 +206,54 @@ internal enum ShellSurface
Terminal = 1,
}
/// <summary>
/// Which page the settings mode is showing, while <see cref="MainWindowViewModel.ActiveSettingsPage"/> is
/// not null.
/// </summary>
/// <remarks>
/// <para>
/// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page
/// area with its own — see <c>SettingsView.axaml</c> and the settings-mode remark on
/// <see cref="MainWindowViewModel.ActiveSettingsPage"/>. This is a second, orthogonal notion of "where am I"
/// from <see cref="ShellScreen"/>, not a replacement for it: <see cref="Preferences"/> and <see cref="Vaults"/>
/// still set <see cref="MainWindowViewModel.Screen"/> to the matching <see cref="ShellScreen"/> member, so
/// every existing binding and test that asks "is the screen Preferences" keeps its answer. <see cref="General"/>,
/// <see cref="Account"/> and <see cref="Security"/> have no <see cref="ShellScreen"/> counterpart — nothing
/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
/// </para>
/// <para>
/// <b>v5c-2: Groups and Tags joined.</b> The design's rail lists them beside Security and Preferences; v5c-1
/// omitted both from <c>SettingsView.axaml</c> rather than building a placeholder for either, and this wave
/// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a
/// <see cref="ShellScreen"/> counterpart: managing groups and tags has never been its own screen before this,
/// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding
/// for either to keep in step with.
/// </para>
/// </remarks>
internal enum SettingsPage
{
/// <summary>Updates, and the refused items from the design's General page, as an essay.</summary>
General = 0,
/// <summary>The vaults themselves and the people in them — the existing <see cref="ShellScreen.Vaults"/> screen.</summary>
Vaults = 1,
/// <summary>The signed-in profile, the sign-in fact, and signing out of this machine.</summary>
Account = 2,
/// <summary>The end-to-end explainer, Windows Hello, and approved host keys.</summary>
Security = 3,
/// <summary>This machine's terminal and keychain settings — the existing <see cref="ShellScreen.Preferences"/> screen.</summary>
Preferences = 4,
/// <summary>Every group, and the hosts filed under each — the existing group commands, given their own page.</summary>
Groups = 5,
/// <summary>Every tag, and how many hosts wear each — the existing tag commands, given their own page.</summary>
Tags = 6,
}
/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
@@ -368,6 +416,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
private bool disposed;
/// <summary>
/// Where <see cref="LeaveSettings"/> goes back to — captured once, on the turn settings mode is
/// entered, and not touched again until it is left.
/// </summary>
/// <remarks>
/// Not re-captured on every <see cref="EnterSettings"/> call, which is what makes switching pages inside
/// settings mode (Preferences, then Security, then Account) still come back to the one screen the user
/// was actually on beforehand rather than to whichever settings page they last visited.
/// </remarks>
private ShellScreen settingsReturnScreen;
/// <inheritdoc cref="settingsReturnScreen" />
private ShellSurface settingsReturnSurface;
/// <summary>
/// Establishes a connection to a server.
/// </summary>
@@ -660,6 +722,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private string? email;
/// <summary>
/// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
/// settings page's SIGN-IN row.
/// </summary>
/// <remarks>
/// v5c: <c>MeResponse.Issuer</c> was already being cached into <c>StoredUnlockMaterial.Issuer</c> by
/// <see cref="AccountProvisioner"/>, for no reader — nothing before this wave surfaced it. Set from the
/// same two places <see cref="AccountName"/> and <see cref="Email"/> are, in <see cref="AdoptIdentity"/>,
/// so the three can never drift out of step with which account is actually signed in.
/// </remarks>
[ObservableProperty]
private string? issuer;
/// <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
@@ -700,10 +775,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// 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)
private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
Issuer = issuer;
}
[ObservableProperty]
@@ -980,9 +1056,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
@@ -1186,10 +1259,175 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void ShowScreen(ShellScreen target)
{
// v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either
// of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant
// to land in settings mode rather than on the bare screen the design retired. Redirecting here,
// rather than at every caller, is what makes that true without hunting down every existing call.
if (target is ShellScreen.Preferences)
{
EnterSettings(SettingsPage.Preferences);
return;
}
if (target is ShellScreen.Vaults)
{
EnterSettings(SettingsPage.Vaults);
return;
}
// v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on
// Preferences, and what changes underneath it is the content column and the titlebar's own back
// label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport.
if (target is ShellScreen.Import)
{
OpenImport();
return;
}
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
// the way in — the caller named a destination, and that destination wins over "go back".
ActiveSettingsPage = null;
Screen = target;
Surface = ShellSurface.Page;
}
/// <summary>
/// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
/// see <c>SettingsView.axaml</c>. Not null exactly while that chrome, rather than the ordinary titlebar
/// and nav rail, is what <c>MainWindow.axaml</c> draws.
/// </summary>
/// <remarks>
/// A second notion of "where am I" from <see cref="Screen"/> rather than a replacement for it — see the
/// remark on <see cref="SettingsPage"/>. Two of its five members, <see cref="SettingsPage.Preferences"/>
/// and <see cref="SettingsPage.Vaults"/>, keep <see cref="Screen"/> in step with the matching
/// <see cref="ShellScreen"/> member so every binding and test written against that screen before this
/// mode existed keeps working; the other three have nothing to keep in step with.
/// </remarks>
[ObservableProperty]
private SettingsPage? activeSettingsPage;
/// <summary>Whether the settings chrome, rather than the ordinary one, is what the window is drawing.</summary>
internal bool IsSettingsMode => ActiveSettingsPage is not null;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;
/// <summary>
/// Whether the importer is showing over the Preferences page, inside settings mode.
/// </summary>
/// <remarks>
/// A flag layered on top of <see cref="ActiveSettingsPage"/> rather than a <see cref="SettingsPage"/>
/// member of its own — Import.dc.html draws <c>SettingsNav</c> lit on Preferences the whole time the
/// importer is up, which this makes true for free: <see cref="ActiveSettingsPage"/> never leaves
/// <see cref="SettingsPage.Preferences"/>, so <see cref="IsSettingsPreferencesPage"/> and the nav row it
/// drives stay exactly as they were. What moves is only the content column, via
/// <see cref="IsSettingsPreferencesContentShowing"/>, and the titlebar's own back label — see
/// <c>SettingsTitleBar.axaml</c>.
/// </remarks>
[ObservableProperty]
private bool isImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
/// content column shows.
/// </summary>
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
partial void OnActiveSettingsPageChanged(SettingsPage? value)
{
OnPropertyChanged(nameof(IsSettingsMode));
OnPropertyChanged(nameof(IsSettingsGeneralPage));
OnPropertyChanged(nameof(IsSettingsVaultsPage));
OnPropertyChanged(nameof(IsSettingsAccountPage));
OnPropertyChanged(nameof(IsSettingsSecurityPage));
OnPropertyChanged(nameof(IsSettingsPreferencesPage));
OnPropertyChanged(nameof(IsSettingsGroupsPage));
OnPropertyChanged(nameof(IsSettingsTagsPage));
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
partial void OnIsImportOpenChanged(bool value) =>
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
/// <remarks>
/// The return screen is captured only on the way in from outside settings mode — see
/// <see cref="settingsReturnScreen"/> — so switching between settings pages, which calls this
/// repeatedly, cannot overwrite it with another settings page.
/// <para>
/// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is
/// a request for that page, not for whatever was drawn over it the last time settings mode was up.
/// </para>
/// </remarks>
[RelayCommand]
private void EnterSettings(SettingsPage page)
{
if (ActiveSettingsPage is null)
{
settingsReturnScreen = Screen;
settingsReturnSurface = Surface;
}
ActiveSettingsPage = page;
IsImportOpen = false;
Screen = page switch
{
SettingsPage.Preferences => ShellScreen.Preferences,
SettingsPage.Vaults => ShellScreen.Vaults,
_ => Screen,
};
Surface = ShellSurface.Page;
}
/// <summary>
/// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.Import"/> for every other caller.
/// </summary>
private void OpenImport()
{
EnterSettings(SettingsPage.Preferences);
IsImportOpen = true;
}
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
[RelayCommand]
private void CloseImport() => IsImportOpen = false;
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
[RelayCommand]
private void LeaveSettings()
{
if (ActiveSettingsPage is null)
{
return;
}
ActiveSettingsPage = null;
IsImportOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
/// <summary>Switches to the terminal surface.</summary>
/// <remarks>
/// <para>
@@ -1911,7 +2149,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject);
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
@@ -1972,7 +2210,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject);
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
@@ -2659,21 +2897,30 @@ 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>
/// <summary>
/// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, 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
/// inline on the Account settings page while the vault is unlocked — see <c>SettingsAccountPage.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
/// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is
/// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// <para>
/// v5c: went to <c>ShellScreen.Preferences</c> before this wave, because that bare screen was the only
/// place the confirmation card could be seen. It moved to the Account settings page with the card — see
/// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row
/// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home.
/// </para>
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
ShowScreen(ShellScreen.Preferences);
EnterSettings(SettingsPage.Account);
SignOut();
}
@@ -2744,6 +2991,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
AccountName = null;
Email = null;
Issuer = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
@@ -3017,8 +3265,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
// v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below
// — KnownHostsViewModel has no business knowing ShellScreen exists.
KnownHostsScreen = newValue is null
? null
: new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain));
// v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page
// beneath it without ImportViewModel knowing anything about settings mode — the same reasoning
// VaultViewModel's copyToClipboard delegate is built on.
ImportScreen = newValue is null
? null
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
@@ -4086,7 +4345,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsVaultsScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsMoreScreen));
@@ -123,6 +123,9 @@ internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
/// <summary>What the row says under the name.</summary>
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
/// <summary>The single letter the settings page's card draws on this group's tile.</summary>
internal string Initial => Label.Length > 0 ? Label[..1].ToUpperInvariant() : "?";
}
/// <summary>An entry in the host editor's group picker.</summary>
@@ -4667,8 +4670,20 @@ internal sealed partial class VaultViewModel(
}
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasTagItems));
}
/// <summary>
/// Whether there is at least one tag to manage.
/// </summary>
/// <remarks>
/// v5c-2: the settings Tags page's empty state. Named apart from <c>HostRowViewModel.HasTags</c>, which
/// answers a different question — whether one host wears any — rather than reusing that name at this
/// level and relying on which <c>x:DataType</c> a binding happens to be inside to tell the two apart.
/// </remarks>
internal bool HasTagItems => Tags.Count > 0;
/// <summary>
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
/// authenticate.
@@ -4763,6 +4778,28 @@ internal sealed partial class VaultViewModel(
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
OnPropertyChanged(nameof(HasGroups));
OnPropertyChanged(nameof(UngroupedHostCount));
}
/// <summary>
/// How many hosts, across every shown vault, carry no group that still exists.
/// </summary>
/// <remarks>
/// v5c: the settings Groups page's "No group" footer row. The same dangling-reference reading
/// <see cref="FlattenIntoSections"/> gives the sidebar's own UNGROUPED heading — a host naming a group
/// this vault no longer has counts as ungrouped rather than vanishing — but counted over every shown
/// vault's hosts rather than over a find-box-filtered subset, because a settings page has no find box
/// and a count that shrank while somebody typed in one would be answering the wrong question.
/// </remarks>
internal int UngroupedHostCount
{
get
{
var known = Groups.Select(group => group.EntityId).ToHashSet();
return Hosts.Count(row =>
IsVaultShown(row.VaultId) && (row.Host.GroupId is not { } id || !known.Contains(id)));
}
}
/// <summary>
@@ -9881,6 +9918,35 @@ internal sealed partial class VaultViewModel(
Status = $"Renaming {row.Label}.";
}
/// <summary>
/// Opens the tag editor for a specific row, from the settings page's per-row edit icon.
/// </summary>
/// <remarks>
/// A thin wrapper around <see cref="EditTag"/> rather than a second implementation of what it does. The
/// keychain screen's own table drives <see cref="EditTag"/> off a <c>ListBox</c> selection; the settings
/// page draws one card per tag with no such selection to lean on, so this puts the row on
/// <see cref="SelectedTag"/> first and then asks the command that already knows how to open it — the
/// same trick <see cref="EditGroupFromHeading"/> plays for a group, except that command took the row as
/// an argument from the start and this one did not, because nothing needed it to until now.
/// </remarks>
/// <param name="row">The tag to edit.</param>
[RelayCommand]
private void EditTagRow(TagRowViewModel? row)
{
SelectedTag = row;
EditTagCommand.Execute(null);
}
/// <summary>Arms the delete confirmation for a specific row, from the settings page's per-row delete icon.</summary>
/// <inheritdoc cref="EditTagRow" path="/remarks" />
/// <param name="row">The tag to ask about deleting.</param>
[RelayCommand]
private void DeleteTagRow(TagRowViewModel? row)
{
SelectedTag = row;
DeleteTagCommand.Execute(null);
}
/// <summary>Abandons the tag editor.</summary>
[RelayCommand]
private void CancelTagEdit()
@@ -10341,6 +10407,35 @@ internal sealed partial class VaultViewModel(
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Puts the selected pin's fingerprint on the clipboard.
/// </summary>
/// <remarks>
/// The twin of <see cref="CopyPublicKeyAsync"/> and the opposite call about secrecy: a host key
/// fingerprint is not one. Operators publish theirs on purpose, and the whole workflow this screen exists
/// for is comparing a pinned one against what was published — which is a copy-and-paste somebody should
/// not have to retype by hand out of a box that refuses to trim it.
/// </remarks>
[RelayCommand]
private async Task CopyPinFingerprintAsync()
{
if (SelectedKnownHost is not { } row)
{
Status = "Choose a pinned key first.";
return;
}
if (copyToClipboard is null)
{
Status = "This machine has no clipboard.";
return;
}
await copyToClipboard(row.Fingerprint).ConfigureAwait(true);
Status = $"Copied the fingerprint for {row.Host}.";
}
/// <summary>
/// Opens a terminal on the selected host.
/// </summary>
@@ -107,6 +107,9 @@ internal sealed record VaultRowViewModel(
};
internal bool HasState => State.Length > 0;
/// <summary>The single letter the settings page's card draws on this vault's tile.</summary>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
}
/// <summary>One member of a vault, as a row in the members table.</summary>
@@ -153,6 +156,32 @@ internal sealed record VaultMemberRowViewModel(TeamMemberSummary Member, bool Is
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
/// <summary>
/// The two letters the members panel draws on this row's avatar.
/// </summary>
/// <remarks>
/// The same rule <c>HostRowViewModel.Monogram</c> draws a card's monogram by — the first letters of the
/// first two words in the name, or the first two characters where it is one word — except uppercase,
/// which is how the design draws a person's initials rather than a host's.
/// </remarks>
internal string Initials
{
get
{
var words = Name.Split(
InitialsWordSeparators,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var letters = words.Length >= 2
? string.Concat(words[0][0], words[1][0])
: Name.Length >= 2 ? Name[..2] : Name;
return letters.ToUpperInvariant();
}
}
private static readonly char[] InitialsWordSeparators = [' ', '-', '_', '.'];
/// <summary>Whether this member's role can be changed at all.</summary>
/// <remarks>
/// The owner's cannot, and not for want of an endpoint: ownership is sole, so demoting them is
@@ -312,6 +341,33 @@ internal sealed partial class VaultsViewModel(
[ObservableProperty]
private bool isBusy;
/// <summary>
/// Whether the members panel — v5c's settings-page overlay over the selected vault's people — is open.
/// </summary>
/// <remarks>
/// Desktop-only presentation state, not a fact about any vault: it says which rectangle is on screen and
/// nothing else. Selecting a vault is what actually reads its members, in
/// <see cref="OnSelectedVaultChanged"/>; opening this panel over one already selected re-reads nothing.
/// </remarks>
[ObservableProperty]
private bool isMembersPanelOpen;
/// <summary>Opens the members panel, selecting the vault it is about first if it is not selected already.</summary>
[RelayCommand]
private void OpenMembersPanel(VaultRowViewModel? vault)
{
if (vault is not null && !ReferenceEquals(vault, SelectedVault))
{
SelectedVault = vault;
}
IsMembersPanelOpen = true;
}
/// <summary>Closes the members panel.</summary>
[RelayCommand]
private void CloseMembersPanel() => IsMembersPanelOpen = false;
// ---- Creating a vault ----
[ObservableProperty]
@@ -824,6 +880,38 @@ internal sealed partial class VaultsViewModel(
Status = string.Empty;
}
/// <summary>Opens the rename form for a specific vault, from the settings page's per-card edit icon.</summary>
/// <remarks>
/// A thin wrapper around <see cref="RenameVault"/> rather than a second implementation: the settings
/// page draws one card per vault with no list selection to lean on the way this screen used to have, so
/// this selects the row first and then asks the command that already knows how to open the form.
/// </remarks>
/// <param name="vault">The vault to rename.</param>
[RelayCommand]
private void RenameVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
RenameVaultCommand.Execute(null);
}
/// <summary>Arms the delete confirmation for a specific vault, from the settings page's per-card delete icon.</summary>
/// <inheritdoc cref="RenameVaultRow" path="/remarks" />
/// <param name="vault">The vault to ask about deleting.</param>
[RelayCommand]
private void DeleteVaultRow(VaultRowViewModel? vault)
{
if (vault is not null)
{
SelectedVault = vault;
}
DeleteVaultCommand.Execute(null);
}
/// <summary>
/// Saves the renamed vault.
/// </summary>
@@ -1538,6 +1626,13 @@ internal sealed partial class VaultsViewModel(
PendingAction = null;
IsRenamingVault = false;
// The members panel is drawn over one vault's people; a selection that clears altogether — the
// vault it was showing got deleted, or the list emptied — leaves nothing for it to be about.
if (value is null)
{
IsMembersPanelOpen = false;
}
if (isReselecting)
{
return;
@@ -55,6 +55,33 @@ internal static class LayoutHarness
/// <remarks>v5b: 190 became 255, the design's own number rather than this bar's old approximation.</remarks>
internal const double NavRailWidth = 255;
/// <summary>Settings mode's own rail, from <c>SettingsNav.axaml</c> — wider than <see cref="NavRailWidth"/>.</summary>
internal const double SettingsNavWidth = 340;
/// <summary>
/// The width settings mode's own content column asks for, from the design's <c>width:1100px</c>.
/// </summary>
/// <remarks>
/// A <c>MaxWidth</c> on the page, not a <c>Width</c> — see the same trade <c>TitleBar.axaml</c>'s own
/// search box makes with its own <c>MaxWidth="514"</c>, and for the identical reason:
/// <see cref="SettingsContentWidth"/> below is smaller than this at the window's minimum, and a page
/// that insisted on the full 1100 would arrange its own rows past the edge of the rectangle settings
/// mode actually gives them.
/// </remarks>
internal const double SettingsDesignContentWidth = 1100;
/// <summary>The width a settings page's content column actually gets at the window's minimum.</summary>
internal static double SettingsContentWidth => MinimumWidth - SettingsNavWidth;
/// <summary>What settings mode leaves a page between its own titlebar and the window's bottom edge.</summary>
/// <remarks>
/// Settings mode has no status bar and no update banner of its own — see <c>MainWindow.axaml</c>'s own
/// remark on why both are hidden while <c>IsSettingsMode</c> is true — so this is
/// <see cref="MinimumHeight"/> less only <see cref="TitleBarHeight"/>, not <see cref="ContentHeight"/>'s
/// own subtraction of <see cref="StatusBarHeight"/> too.
/// </remarks>
internal static double SettingsContentHeight => MinimumHeight - TitleBarHeight;
/// <summary>
/// What the titlebar and the status bar take off the window before any screen gets a pixel.
/// </summary>
@@ -193,6 +220,18 @@ internal static class LayoutHarness
/// <summary>The width a full-width screen gets, once the nav rail has taken its column.</summary>
internal static double ScreenWidth => MinimumWidth - NavRailWidth;
/// <summary>
/// v5c-3: what the S3 usage of <c>TransfersScreen</c> gets, now that <c>MainWindow.axaml</c> gives it the
/// session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — no tab row, header,
/// status bar or sidebar to take further space off it.
/// </summary>
internal static double BucketsScreenWidth =>
ScreenWidth - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
/// <inheritdoc cref="BucketsScreenWidth" />
internal static double BucketsScreenHeight =>
ScreenHeight - (2 * SessionShellPadding) - (2 * SessionShellBorderThickness);
private static readonly HeadlessUnitTestSession Session =
HeadlessUnitTestSession.GetOrStartForAssembly(typeof(LayoutHarness).Assembly);
@@ -207,24 +207,35 @@ public sealed class NavRailTests : IAsyncLifetime
});
}
/// <summary>Settings, Vaults and Preferences each land on the screen they promise, and shut the popover.</summary>
/// <summary>
/// Settings, Vaults and Preferences each enter settings mode on the page they promise, and shut the
/// popover behind them.
/// </summary>
/// <remarks>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
/// <para>
/// v5c: these three used to land on a bare <c>ShellScreen</c> — Settings and Preferences on the very
/// same one, since the mock's own Settings area did not exist yet. Now that it does, each opens the
/// settings mode on its own page — see <see cref="MainWindowViewModel.EnterSettings"/> — and "Settings"
/// and "Preferences" are no longer the same click.
/// </para>
/// <para>
/// Three <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>SettingsPage</c>
/// is <c>internal</c>, and a public theory method may not carry an internal type in its signature.
/// </para>
/// </remarks>
[Fact]
public Task ThePopoversSettingsRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", ShellScreen.Preferences);
public Task ThePopoversSettingsRow_EntersSettingsOnGeneralAndClosesThePopover() =>
APopoverRowLandsOnAsync("Settings", SettingsPage.General);
[Fact]
public Task ThePopoversVaultsRow_LandsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", ShellScreen.Vaults);
public Task ThePopoversVaultsRow_EntersSettingsOnVaultsAndClosesThePopover() =>
APopoverRowLandsOnAsync("Vaults", SettingsPage.Vaults);
[Fact]
public Task ThePopoversPreferencesRow_LandsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", ShellScreen.Preferences);
public Task ThePopoversPreferencesRow_EntersSettingsOnPreferencesAndClosesThePopover() =>
APopoverRowLandsOnAsync("Preferences", SettingsPage.Preferences);
private Task APopoverRowLandsOnAsync(string label, ShellScreen target) =>
private Task APopoverRowLandsOnAsync(string label, SettingsPage target) =>
OnTheRailAsync((rail, window) =>
{
var chip = UserChip(rail);
@@ -232,19 +243,20 @@ public sealed class NavRailTests : IAsyncLifetime
Click(PopoverRow(window, label), window);
shell.Screen.ShouldBe(target);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.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.
/// Through the Account settings page rather than a direct <c>SignOutCommand</c> — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> for why: the confirmation card is drawn inline on
/// that one page 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()
public async Task ThePopoversLogoutRow_EntersSettingsOnAccountAndArmsTheSignOutConfirmation()
{
await OnTheRailAsync((rail, window) =>
{
@@ -253,7 +265,8 @@ public sealed class NavRailTests : IAsyncLifetime
Click(PopoverRow(window, "Logout"), window);
shell.Screen.ShouldBe(ShellScreen.Preferences);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
shell.IsConfirmingSignOut.ShouldBeTrue();
FlyoutBase.GetAttachedFlyout(chip)!.IsOpen.ShouldBeFalse();
});
@@ -6,7 +6,6 @@ using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Import;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
@@ -825,9 +824,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
transfers.ShowsNoBuckets.ShouldBeTrue("this vault has no buckets in it");
// 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."
// The buckets budget, not the full session shell's: MainWindow.axaml gives S3 the same TransfersScreen
// control inside a padded, bordered container but with no tab row, no header, no status bar and no
// sidebar around it — see its own remark on why the S3 usage is "deliberately not given the full
// session shell above."
await MeasureBucketsAsync(faults => faults.ShouldBeEmpty("with nothing to open yet"));
}
@@ -847,27 +847,9 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
await MeasureTransfersAsync(faults => faults.ShouldBeEmpty("with a drop in progress"));
}
// ---- The import screen ----
[Fact]
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
{
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
}
/// <remarks>
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
/// understands and one they think is broken.
/// </remarks>
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// v5c-3: the import screen's own layout coverage moved to SettingsPagesLayoutTests — it is a settings
// page now, drawn inside settings mode over the Preferences page rather than beside the ordinary nav
// rail; see MainWindowViewModel.IsImportOpen and design-notes/v5c-fidelity-notes.md.
// ---- The host keys screen ----
@@ -1898,87 +1880,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the import screen out at the size it gets beside the nav rail.</summary>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
ImportViewModel? import = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new ImportScreen
{
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// An import view model that has scanned a real file, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through a temporary directory rather than by populating the rows directly, because the shape being
/// measured is what the parser produces — an entry with two warnings under it is taller than one
/// without, and inventing the rows would measure a layout nothing generates.
/// </remarks>
private async Task<ImportViewModel> ScannedImportAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-import-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(directory);
try
{
await File.WriteAllTextAsync(
Path.Combine(directory, "config"),
"""
Host *
ServerAliveInterval 30
Host prod-db
HostName database.production.internal
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host bastion-eu-west-1
HostName bastion.eu-west-1.example.com
User ops
ProxyCommand nc %h %p
Compression yes
compression no
Match host anything
User root
""");
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
// table — which is the other test.
await import.ScanCommand.ExecuteAsync(null);
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
return import;
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
/// <summary>Lays the host keys screen out at the size it gets beside the nav rail.</summary>
private Task MeasurePinsAsync(
Action<IReadOnlyList<string>> assert,
@@ -2142,12 +2043,14 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the plain-screen budget it actually gets.</summary>
/// <summary>Lays the S3 usage of <c>TransfersScreen</c> out at the 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.
/// The same control as <see cref="MeasureTransfersAsync"/> measures, at a different width and height. v5c-3
/// gives S3 the session shell's own 26px-padded, 1px-bordered LOOK with none of its machinery — see
/// <c>MainWindow.axaml</c>'s own remark on why — so it is measured at
/// <see cref="LayoutHarness.BucketsScreenWidth"/>/<see cref="LayoutHarness.BucketsScreenHeight"/>, which
/// take that padding and border off the full-bleed budget every other page gets and stop there: no tab
/// row, header, status bar or sidebar to subtract, unlike <see cref="LayoutHarness.SessionScreenWidth"/>.
/// </remarks>
private Task MeasureBucketsAsync(Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
@@ -2156,7 +2059,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
var screen = new TransfersScreen { DataContext = transfers };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
screen, LayoutHarness.BucketsScreenWidth, LayoutHarness.BucketsScreenHeight);
try
{
@@ -2190,123 +2093,12 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
bytesPerSecond,
failure)));
/// <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 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
/// <see cref="StubTeamServer"/>. The two states that hide half the screen, the rename form and the
/// confirmation, are measured in their own tests below rather than here, because a control that is
/// collapsed when the window is laid out is a control this suite has not checked.
/// </para>
/// v5c-2: the old VaultsScreen this suite used to measure here is gone — Vaults is a settings page now,
/// and its own layout coverage (populated lists, the rename and new-vault forms, the hand-over
/// confirmation, the members panel) lives in <c>SettingsPagesLayoutTests</c> beside every other settings
/// page's, measured against the settings content budget rather than this suite's full-chrome one.
/// </remarks>
[Fact]
public Task TheVaultsScreen_FitsWithEveryListPopulated() =>
OnTheVaultsScreenAsync(
vaults => { },
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the vaults screen with members and key holders"));
/// <remarks>
/// The rename form is drawn in place, above the members list, and pushes everything below it down.
/// </remarks>
[Fact]
public Task TheVaultsScreen_FitsWhileRenamingAVault() =>
OnTheVaultsScreenAsync(
vaults => vaults.RenameVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the vaults screen with the rename form open"));
/// <remarks>
/// The name-a-vault form is in the left column under the vault list. Worth its own case because the
/// column is 268 wide and the sentence under the field wraps.
/// </remarks>
[Fact]
public Task TheVaultsScreen_FitsWithTheNewVaultFormOpen() =>
OnTheVaultsScreenAsync(
vaults => vaults.NewVaultCommand.Execute(null),
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the vaults screen with the new-vault form open"));
/// <remarks>
/// The armed confirmation carries two sentences of prose and replaces the header's buttons. It is the
/// tallest thing that can appear above the members list, so it is the case most likely to push the
/// key-holders list off the bottom.
/// </remarks>
[Fact]
public Task TheVaultsScreen_FitsWhileConfirmingAHandOver() =>
OnTheVaultsScreenAsync(
vaults =>
{
vaults.SelectedMember = vaults.Members.First(member => !member.IsSelf);
vaults.HandOverCommand.Execute(null);
},
window => LayoutHarness.Unreachable(window)
.ShouldBeEmpty("the vaults screen with the hand-over confirmation armed"));
/// <remarks>
/// <para>
/// A real <c>VaultsViewModel</c> over this suite's own unlocked session and a stub server. Both halves
/// are needed and they answer different questions: the vault list is the session's, and who is in each
/// vault is the server's.
/// </para>
/// <para>
/// A shared vault is created into the session first, because a session that has only ever been unlocked
/// offline holds one personal vault — and the personal vault draws none of what this screen is for. It
/// is created through the real <c>CreateTeamVaultAsync</c> rather than poked into the cache, so the row
/// being measured is one the application could actually produce.
/// </para>
/// <para>
/// Selected before the second load rather than after it, so the members read is the awaited one: a
/// selection assignment starts a read nothing can wait for, and measuring a window while it was still
/// in flight would certify a screen with empty lists.
/// </para>
/// </remarks>
private async Task OnTheVaultsScreenAsync(
Action<VaultsViewModel> arrange,
Action<Window> assert)
{
using var teamServer = new StubTeamServer();
await session.CreateTeamVaultAsync(
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
var vaults = new VaultsViewModel(() => teamServer, () => session);
await vaults.LoadAsync(Token);
vaults.SelectedVault = vaults.Vaults.First(row => row.IsShared);
await vaults.LoadAsync(Token);
vaults.Members.ShouldNotBeEmpty("there is nothing to measure otherwise");
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
arrange(vaults);
var screen = new VaultsScreen { DataContext = vaults };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
assert(window);
}
finally
{
window.Close();
}
},
Token);
}
private Task MeasureVaultAsync(Action<IReadOnlyList<string>> assert) =>
OnTheVaultAsync((_, window) => assert(LayoutHarness.Unreachable(window)));
@@ -0,0 +1,541 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Import;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// v5c: whether each of settings mode's four pages fits the rectangle it is actually given, and whether the
/// rows a fidelity pass could quietly unwire — Updates on General, Windows Hello on Security — are still
/// wired to the real commands.
/// </summary>
/// <remarks>
/// <para>
/// The budget is <see cref="LayoutHarness.SettingsContentWidth"/> by <see cref="LayoutHarness.SettingsContentHeight"/>
/// — the space beside <c>SettingsNav</c>'s own 340 pixels, under settings mode's own titlebar and with no
/// status bar or update banner beneath it, at the window's minimum. Not the design's own 1100-wide column,
/// which is wider than that budget: see the <c>MaxWidth</c> remark on <see cref="LayoutHarness.SettingsDesignContentWidth"/>
/// for why the pages ask for 1100 at most rather than exactly.
/// </para>
/// <para>
/// A real unlocked vault, on the same reasoning <see cref="ScreenLayoutTests"/> gives: the Security page
/// binds <c>KnownHostsScreen.Summary</c>, which is null until a vault is open, and a stand-in vault would
/// still have to be the real <see cref="VaultViewModel"/> type for the compiled bindings to resolve at all.
/// </para>
/// </remarks>
public sealed class SettingsPagesLayoutTests : IAsyncLifetime
{
private const string Passphrase = "a sufficiently long passphrase";
private const string ServerUrl = "https://dodossh.example";
private static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
private readonly FakeAccountServer server = new();
private readonly StubKeyBinding keyBinding = new();
private readonly VaultKnownHostStore knownHosts = new();
private ClientCacheFactory caches = null!;
private TerminalWorkspace workspace = null!;
private VaultSession session = null!;
private VaultViewModel vault = null!;
private MainWindowViewModel shell = null!;
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <inheritdoc />
public async ValueTask InitializeAsync()
{
caches = ClientCacheFactory.ForMemory($"settings-layout-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
session = outcome.Session!;
workspace = new TerminalWorkspace(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
await knownHosts.OpenAsync(session, Token);
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
shell = new MainWindowViewModel(
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-settings-layout-{Guid.CreateVersion7():N}")),
caches,
workspace,
knownHosts,
new UnavailableDeviceKeyStore(),
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>(),
CheapProfile)
{
State = ShellState.Unlocked,
Vault = vault,
AccountName = "Ripley Vega",
Email = "ripley@example.test",
Issuer = "https://sso.example.test/realms/dodotech",
};
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await shell.DisposeAsync();
await vault.DisposeAsync();
knownHosts.Close();
await workspace.DisposeAsync();
await session.DisposeAsync();
caches.Dispose();
}
[Fact]
public Task TheGeneralPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsGeneralPage(), faults => faults.ShouldBeEmpty());
[Fact]
public Task ThePreferencesPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsPreferencesPage(), faults => faults.ShouldBeEmpty());
[Fact]
public Task TheAccountPageFitsAtTheWindowsMinimum() =>
MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("the ordinary shape"));
/// <remarks>The other shape the Account page takes — the sign-out confirmation card in place of the row.</remarks>
[Fact]
public async Task TheAccountPageFitsWithTheSignOutConfirmationUp()
{
shell.SignOutCommand.Execute(null);
shell.IsConfirmingSignOut.ShouldBeTrue();
await MeasureAsync(() => new SettingsAccountPage(), faults => faults.ShouldBeEmpty("with the confirm card up"));
}
[Fact]
public Task TheSecurityPageFitsWhileThisMachineCanRegisterADeviceKey()
{
shell.CanRegisterDevice = true;
shell.CanForgetDevice = false;
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to register"));
}
[Fact]
public Task TheSecurityPageFitsWhileThisMachineIsAlreadyRegistered()
{
shell.CanRegisterDevice = false;
shell.CanForgetDevice = true;
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("offering to withdraw"));
}
[Fact]
public Task TheSecurityPageFitsOnAMachineWithNowhereToKeepADeviceKey()
{
shell.CanRegisterDevice = false;
shell.CanForgetDevice = false;
shell.HasNoDeviceKeyOption.ShouldBeTrue();
return MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("the no-TPM explanation"));
}
/// <summary>
/// Checking for updates on the General page is still the real <c>UpdateViewModel</c> command, not a
/// row a fidelity pass silently detached while restyling it into the card idiom.
/// </summary>
[Fact]
public Task TheGeneralPagesCheckNowButton_IsWiredToTheRealUpdatesCommand() =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsGeneralPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "CHECK NOW").Command.ShouldBeSameAs(shell.Updates.CheckNowCommand);
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// Windows Hello's two commands — moved here whole from the old Preferences screen — are reachable from
/// the Security page in both of the states they can be in.
/// </summary>
[Fact]
public async Task TheSecurityPagesHelloButtons_AreWiredToTheRealDeviceCommands()
{
shell.CanRegisterDevice = true;
shell.CanForgetDevice = false;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "REGISTER").Command.ShouldBeSameAs(shell.RegisterDeviceCommand);
}
finally
{
window.Close();
}
},
Token);
shell.CanRegisterDevice = false;
shell.CanForgetDevice = true;
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "STOP UNLOCKING HERE").Command.ShouldBeSameAs(shell.ForgetDeviceCommand);
}
finally
{
window.Close();
}
},
Token);
}
// ---- v5c-2: Vaults, Groups, Tags ----
//
// Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
// LayoutHarness.ScreenWidth/ScreenHeight (the space beside the ordinary nav rail); it is a settings page
// now, so its own layout coverage belongs here, against the narrower settings budget, beside every other
// settings page's. Groups and Tags are new pages with nothing to migrate.
/// <remarks>
/// A shared vault added straight into the fixture's own session — the same technique the retired
/// VaultsScreen layout tests used — so the card list draws both the personal vault and a shared one
/// without a live server: <c>VaultRowViewModel.IsShared</c> only asks whether the vault carries a team
/// id, which this sets without needing <see cref="MainWindowViewModel"/>'s own (offline) connection.
/// </remarks>
private async Task AddSharedVaultAsync()
{
using var teamServer = new StubTeamServer();
await session.CreateTeamVaultAsync(
teamServer.Teams, StubTeamServer.SharedTeamId, "Platform secrets", Token);
}
[Fact]
public async Task TheVaultsPageFitsWithTheVaultListPopulated()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("a personal vault and a shared one"));
}
[Fact]
public async Task TheVaultsPageFitsWithTheNewVaultFormOpen()
{
await shell.Vaults.LoadAsync(Token);
shell.Vaults.NewVaultCommand.Execute(null);
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the new-vault form open"));
}
[Fact]
public async Task TheVaultsPageFitsWithTheRenameFormOpen()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
shell.Vaults.RenameVaultRowCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the rename form open"));
}
/// <remarks>
/// The panel's own geometry — the ListBoxes, the SHARE KEY/WITHDRAW KEY row, the KEY HOLDERS list —
/// with nothing in Members or Grants, since the fixture's connection is offline and both are read from
/// the server on selection. An empty ListBox is zero pixels tall and exempt from this harness's own
/// "no size" rule, so this is still a real check of everything around it.
/// </remarks>
[Fact]
public async Task TheVaultsPageFitsWithTheMembersPanelOpen()
{
await AddSharedVaultAsync();
await shell.Vaults.LoadAsync(Token);
shell.Vaults.OpenMembersPanelCommand.Execute(shell.Vaults.Vaults.First(row => row.IsShared));
await MeasureAsync(
() => new SettingsVaultsPage(),
faults => faults.ShouldBeEmpty("the members panel open"));
}
[Fact]
public async Task TheGroupsPageFitsWithGroupsPopulated()
{
await AddGroupAsync("production");
await AddGroupAsync("staging");
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("two groups and the No group footer"));
}
[Fact]
public async Task TheGroupsPageFitsWithTheEditorOpen()
{
vault.NewGroupCommand.Execute(null);
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("the group editor open"));
}
[Fact]
public async Task TheGroupsPageFitsWithTheDeleteConfirmationArmed()
{
await AddGroupAsync("production");
vault.DeleteGroupCommand.Execute(vault.Groups.Single());
await MeasureAsync(
() => new SettingsGroupsPage(),
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
}
[Fact]
public async Task TheTagsPageFitsWithTagsPopulated()
{
await AddTagAsync("production");
await AddTagAsync("staging");
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("two tags"));
}
[Fact]
public async Task TheTagsPageFitsWithTheEditorOpen()
{
vault.NewTagCommand.Execute(null);
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("the tag editor open"));
}
[Fact]
public async Task TheTagsPageFitsWithTheDeleteConfirmationArmed()
{
await AddTagAsync("production");
vault.DeleteTagRowCommand.Execute(vault.Tags.Single());
await MeasureAsync(
() => new SettingsTagsPage(),
faults => faults.ShouldBeEmpty("the delete confirmation armed"));
}
private async Task AddGroupAsync(string label)
{
vault.NewGroupCommand.Execute(null);
vault.GroupEditorLabel = label;
await vault.SaveGroupCommand.ExecuteAsync(null);
}
private async Task AddTagAsync(string label)
{
vault.NewTagCommand.Execute(null);
vault.TagEditorLabel = label;
await vault.SaveTagCommand.ExecuteAsync(null);
}
// ---- v5c-3: Import ----
//
// The importer moved into settings mode's own chrome — see MainWindowViewModel.IsImportOpen and
// design-notes/v5c-fidelity-notes.md — so its layout coverage moved here from ScreenLayoutTests, against
// the same SettingsContentWidth/SettingsContentHeight budget every other settings page is measured
// against, rather than the plain full-bleed one it used to get beside the ordinary nav rail.
[Fact]
public async Task TheImportScreenFitsBeforeAnythingHasBeenScanned()
{
await MeasureImportAsync(faults => faults.ShouldBeEmpty("the state it opens in"));
}
/// <remarks>
/// The shape with something to decide about: a table of candidate hosts with tickboxes, a warning
/// block above it, and a footer carrying the sentence that says key files are not read. That sentence
/// is the one that must not be pushed off the bottom — it is the difference between an import somebody
/// understands and one they think is broken.
/// </remarks>
[Fact]
public async Task TheImportScreenFitsWithHostsToChooseFromAndWarnings()
{
await MeasureImportAsync(
faults => faults.ShouldBeEmpty("with a scanned list"),
await ScannedImportAsync());
}
// ---- Helpers ----
/// <remarks>
/// The page is built by the factory rather than handed in already constructed: an Avalonia control is
/// owned by whichever thread creates it, and every caller of this helper must build its page on the
/// dispatcher thread <see cref="LayoutHarness.OnTheUiThreadAsync"/> switches onto, not on the test
/// runner's own thread the factory is captured from.
/// </remarks>
private Task MeasureAsync(Func<UserControl> page, Action<IReadOnlyList<string>> assert) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var control = page();
control.DataContext = shell;
var window = LayoutHarness.HostAtMinimumSize(
control, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
private static Button ButtonNamed(Visual root, string label) =>
root.GetVisualDescendants()
.OfType<Button>()
.First(button => string.Equals(button.Content as string, label, StringComparison.Ordinal));
/// <summary>
/// Lays the importer out at the budget settings mode's own content column actually gets.
/// </summary>
/// <remarks>
/// Not built through <see cref="MeasureAsync"/>: every other settings page is typed to
/// <see cref="MainWindowViewModel"/> and takes <see cref="shell"/> as its data context, where
/// <c>ImportScreen</c> is typed to <c>ImportViewModel</c> — the same split <c>SettingsView.axaml</c>
/// draws by handing it <c>{Binding ImportScreen}</c> rather than the shell itself.
/// </remarks>
private Task MeasureImportAsync(
Action<IReadOnlyList<string>> assert,
ImportViewModel? import = null) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new ImportScreen
{
DataContext = import ?? new ImportViewModel(vault, new SshConfigLocator()),
};
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
assert(LayoutHarness.Unreachable(window));
}
finally
{
window.Close();
}
},
Token);
/// <summary>
/// An import view model that has scanned a real file, so the table has rows in it.
/// </summary>
/// <remarks>
/// Through a temporary directory rather than by populating the rows directly, because the shape being
/// measured is what the parser produces — an entry with two warnings under it is taller than one
/// without, and inventing the rows would measure a layout nothing generates.
/// </remarks>
private async Task<ImportViewModel> ScannedImportAsync()
{
var directory = Path.Combine(Path.GetTempPath(), $"dodossh-settings-import-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(directory);
try
{
await File.WriteAllTextAsync(
Path.Combine(directory, "config"),
"""
Host *
ServerAliveInterval 30
Host prod-db
HostName database.production.internal
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host bastion-eu-west-1
HostName bastion.eu-west-1.example.com
User ops
ProxyCommand nc %h %p
Compression yes
compression no
Match host anything
User root
""");
var import = new ImportViewModel(vault, new SshConfigLocator(directory));
// Awaited, not fired. ScanCommand reads a file, so executing without awaiting measures an empty
// table — which is the other test.
await import.ScanCommand.ExecuteAsync(null);
import.HasRows.ShouldBeTrue("the fixture has hosts in it");
import.HasWarnings.ShouldBeTrue("the fixture has a Match block and a wildcard block");
return import;
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
}
@@ -3815,6 +3815,66 @@ public sealed class ShellFlowTests : IAsyncLifetime
vault.KnownHostPins.ShouldHaveSingleItem();
}
/// <remarks>
/// v5c-3: fingerprints are public — operators publish theirs on purpose — so this is the one clipboard
/// copy on this screen that needs no confirmation and no refusal, unlike a private key's own
/// <c>CopyPublicKeyCommand</c>. In full, because a shortened fingerprint cannot be compared against what
/// was published.
/// </remarks>
[Fact]
public async Task CopyingAPinsFingerprint_PutsTheFullFingerprintOnTheClipboard()
{
var vault = await ReadyToConnectAsync();
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await vault.LoadAsync(Token);
vault.SelectedKnownHost = vault.KnownHostPins.ShouldHaveSingleItem();
await vault.CopyPinFingerprintCommand.ExecuteAsync(null);
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
}
/// <remarks>The v5c screen's own restyle over <see cref="KnownHostsViewModel"/> forwards the same command.</remarks>
[Fact]
public async Task CopyingAPinsFingerprintThroughTheKnownHostsScreen_ReachesTheVault()
{
await UnlockedAsync();
var vault = shell.Vault!;
await knownHosts.TrustAsync(
new HostKeyPresentation("db.internal", 22, "ssh-ed25519", "SHA256:the-key"), Token);
await vault.LoadAsync(Token);
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
pins.Selected = pins.VisiblePins.ShouldHaveSingleItem();
await pins.CopyFingerprintCommand.ExecuteAsync(null);
clipboard.ShouldHaveSingleItem().ShouldBe("SHA256:the-key");
}
/// <remarks>
/// The v5c header's own back arrow, reached through the same onBack delegate ImportViewModel's Cancel
/// button uses — see MainWindowViewModel.OnVaultChanged. Its destination is the Keychain screen this list
/// was pulled out of.
/// </remarks>
[Fact]
public async Task TheKnownHostsScreensBackArrow_ReturnsToKeychain()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.KnownHosts);
shell.IsKnownHostsScreen.ShouldBeTrue();
var pins = shell.KnownHostsScreen.ShouldNotBeNull();
pins.BackCommand.Execute(null);
shell.IsKeychainScreen.ShouldBeTrue();
}
/// <remarks>
/// Pins used to be a category on the keychain screen. They are a destination of their own now, and this
/// is the seam that could silently come apart: the screen's view model is built from the vault in
@@ -4332,6 +4392,150 @@ public sealed class ShellFlowTests : IAsyncLifetime
import.Status.ShouldContain("no", Case.Insensitive);
}
// ---- v5c-3: the WHAT THIS MEANS chip, tick-all, and the footer's own facts ----
/// <remarks>
/// The three real states a row can be in, and nothing else: a skipped <c>Host</c> pattern never becomes a
/// row at all (see <c>SshConfigImport.SkippedPatterns</c>), so there is no fourth, invented "skipped" chip
/// to test for. A warned row wins over "already here" — see <c>ImportRowViewModel.Meaning</c>.
/// </remarks>
[Fact]
public async Task TheImportersMeaningChipsMapTheRealRowStatesHonestly()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var sshDirectory = Path.Combine(directory, $"ssh-meaning-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(sshDirectory);
await File.WriteAllTextAsync(
Path.Combine(sshDirectory, "config"),
"""
Host already-here
HostName db.internal
User deploy
Host bastion
HostName bastion.internal
User ops
ProxyCommand nc %h %p
Host fresh
HostName fresh.internal
User deploy
""",
Token);
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.Rows.Count.ShouldBe(3);
var known = import.Rows.Single(row => string.Equals(row.Alias, "already-here", StringComparison.Ordinal));
known.IsMeaningExisting.ShouldBeTrue();
known.IsMeaningNew.ShouldBeFalse();
known.IsMeaningWarned.ShouldBeFalse();
known.Meaning.ShouldBe("already here");
var warned = import.Rows.Single(row => string.Equals(row.Alias, "bastion", StringComparison.Ordinal));
warned.IsMeaningWarned.ShouldBeTrue();
warned.IsMeaningNew.ShouldBeFalse();
warned.IsMeaningExisting.ShouldBeFalse();
// The warned chip carries the row's own real reason.
warned.Meaning.ShouldContain("ProxyCommand");
var fresh = import.Rows.Single(row => string.Equals(row.Alias, "fresh", StringComparison.Ordinal));
fresh.IsMeaningNew.ShouldBeTrue();
fresh.IsMeaningExisting.ShouldBeFalse();
fresh.IsMeaningWarned.ShouldBeFalse();
fresh.Meaning.ShouldBe("new host");
}
/// <remarks>The header's own tick-all box, over <see cref="ImportViewModel.ToggleAllCommand"/>.</remarks>
[Fact]
public async Task TickingAllTogglesEveryRowAndTheHeaderTickReflectsIt()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = Path.Combine(directory, $"ssh-tickall-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(sshDirectory);
await File.WriteAllTextAsync(
Path.Combine(sshDirectory, "config"),
"""
Host a
HostName a.internal
Host b
HostName b.internal
""",
Token);
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.AllTicked.ShouldBeTrue("both are new hosts, which start ticked");
import.Rows[0].IsSelected = false;
import.NoteSelectionChanged();
import.AllTicked.ShouldBeFalse();
import.ToggleAllCommand.Execute(null);
import.AllTicked.ShouldBeTrue("fewer than all ticked toggles everything on");
import.Rows.ShouldAllBe(row => row.IsSelected);
import.ToggleAllCommand.Execute(null);
import.AllTicked.ShouldBeFalse();
import.Rows.ShouldAllBe(row => !row.IsSelected);
}
/// <remarks>
/// The key-material opt-in card's own always-visible sentence: a real count of hosts naming a key file,
/// the real directory, and the same "nothing is read until Import is pressed" claim verified against
/// <see cref="SshConfigLocator.ReadIdentity"/> only ever being called from <c>ImportAsync</c>.
/// </remarks>
[Fact]
public async Task TheKeyMaterialCardsIntroSentence_NamesTheRealCountAndDirectory()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = KeyedConfigDirectory();
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.KeyMaterialIntro.ShouldContain("1 host names");
import.KeyMaterialIntro.ShouldContain(sshDirectory);
import.KeyMaterialIntro.ShouldContain(
"nothing is read until Import is pressed", Case.Insensitive);
}
[Fact]
public async Task TheFooterSummary_NamesTheRealSelectionCountAndVault()
{
await UnlockedAsync();
var vault = shell.Vault!;
var sshDirectory = Path.Combine(directory, $"ssh-summary-{Guid.CreateVersion7():N}");
Directory.CreateDirectory(sshDirectory);
await File.WriteAllTextAsync(
Path.Combine(sshDirectory, "config"), "Host a\n HostName a.internal\n", Token);
var import = new ImportViewModel(vault, new SshConfigLocator(sshDirectory));
await import.ScanCommand.ExecuteAsync(null);
import.SelectionSummary.ShouldBe($"1 of 1 entry selected · saving to {vault.VaultName}");
}
// ---- Filtering the host sidebar ----
/// <remarks>
@@ -4435,6 +4639,38 @@ public sealed class ShellFlowTests : IAsyncLifetime
rows[3].ShouldBeOfType<HostRowViewModel>().Label.ShouldBe("stage-web");
}
/// <remarks>
/// v5c-2: the settings Groups page's "No group" footer row. Counts a host whose group has never been set
/// and one whose group id dangles (deleted from under it) the same way — both are "ungrouped" to a person
/// looking at the list, per the reading <c>FlattenIntoSections</c> already gives the sidebar's own
/// heading, and <c>UngroupedHostCount</c> has to agree with it rather than invent a second definition.
/// </remarks>
[Fact]
public async Task UngroupedHostCount_CountsHostsWithNoGroupAndHostsWhoseGroupHasGone()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddHostAsync(vault, "stage-web");
await AddHostAsync(vault, "bastion");
await AddGroupAsync(vault, "production");
vault.UngroupedHostCount.ShouldBe(3, "no host has been filed under the new group yet");
await FileAsync(vault, "prod-db", "production");
vault.UngroupedHostCount.ShouldBe(2, "one host now belongs to a real group");
var group = vault.Groups.Single();
vault.DeleteGroupCommand.Execute(group);
vault.PendingDeletion.ShouldNotBeNull();
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.UngroupedHostCount.ShouldBe(
3, "a host whose group was deleted falls back to ungrouped rather than vanishing from the count");
}
/// <remarks>
/// An empty group keeps its heading; a group emptied by the filter does not. The first is a folder
/// somebody made and can put things in, the second is an absence of search results — and a heading with
@@ -6282,6 +6518,46 @@ public sealed class ShellFlowTests : IAsyncLifetime
Host(vault, "prod-db").Host.TagIds.ShouldBe(wornBefore);
}
/// <remarks>
/// v5c-2: the settings Tags page has no list selection to lean on the way the keychain screen's own
/// table does, so <c>EditTagRow</c>/<c>DeleteTagRow</c> select the row and then hand off to the real
/// commands above — this proves the hand-off reaches the same place, with the same guard sentences.
/// </remarks>
[Fact]
public async Task EditTagRow_SelectsTheRowThenOpensTheSameEditorEditTagDoes()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddTagAsync(vault, "pci");
var row = vault.Tags.Single();
vault.EditTagRowCommand.Execute(row);
vault.SelectedTag.ShouldBe(row);
vault.IsEditingTag.ShouldBeTrue();
vault.TagEditorLabel.ShouldBe("pci");
}
[Fact]
public async Task DeleteTagRow_SelectsTheRowThenArmsTheSameConfirmationDeleteTagDoes()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
await AddTagAsync(vault, "pci");
await TagAsync(vault, "prod-db", "pci");
var row = vault.Tags.Single();
vault.DeleteTagRowCommand.Execute(row);
vault.SelectedTag.ShouldBe(row);
vault.PendingDeletion.ShouldNotBeNull().Usage
.ShouldContain("1 host", Case.Insensitive, "the same guard sentence DeleteTag would have armed");
}
[Fact]
public async Task ATagCreatedFromTheHostEditor_IsPutOnTheHostBeingEdited()
{
@@ -7915,6 +8191,249 @@ public sealed class ShellFlowTests : IAsyncLifetime
ssh.Requests.ShouldNotBeEmpty("the password is only kept once a handshake has succeeded");
}
// ---- v5c: settings mode ----
//
// The window-level mode that swaps the titlebar, the rail and the page area for settings mode's own —
// see MainWindowViewModel.EnterSettings and design-notes/v5c-fidelity-notes.md. What is worth proving at
// this level, with no Avalonia involved, is the state machine itself: entering and leaving preserves
// wherever the user actually was, switching between settings pages does not forget it, and the two
// pages that mirror an existing ShellScreen keep every binding written against that screen before this
// mode existed.
/// <remarks>
/// The core promise of "Back to application": whatever screen a user was on survives a trip through
/// settings mode untouched, however many pages they visit while they are there.
/// </remarks>
[Fact]
public async Task EnteringAndLeavingSettingsMode_PreservesTheScreenItWasEnteredFrom()
{
await ReadyToConnectAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.EnterSettingsCommand.Execute(SettingsPage.General);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.General);
// Switching pages inside settings mode must not overwrite the remembered return screen with a
// settings page of its own — see the remark on MainWindowViewModel.settingsReturnScreen.
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
shell.LeaveSettingsCommand.Execute(null);
shell.IsSettingsMode.ShouldBeFalse();
shell.ActiveSettingsPage.ShouldBeNull();
shell.Screen.ShouldBe(ShellScreen.Keychain);
}
/// <remarks>
/// Settings mode collapses the terminal the same way any other page does — <see cref="ShellSurface.Page"/>
/// and <see cref="ShellSurface.Terminal"/> are exclusive by construction — and "Back to application" has
/// to bring it back rather than leaving the user on a page they never asked for.
/// </remarks>
[Fact]
public async Task EnteringSettingsModeFromATerminal_CollapsesItAndLeavingRestoresIt()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
shell.IsTerminalSurface.ShouldBeTrue();
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
shell.IsTerminalSurface.ShouldBeFalse("settings mode occupies the same rectangle a page does");
shell.IsSettingsMode.ShouldBeTrue();
shell.LeaveSettingsCommand.Execute(null);
shell.IsTerminalSurface.ShouldBeTrue();
shell.IsSettingsMode.ShouldBeFalse();
}
/// <remarks>
/// v5c: <see cref="ShellScreen.Preferences"/> and <see cref="ShellScreen.Vaults"/> are settings pages
/// now, so anything that still navigates to either — a test written before this wave, the phone's own
/// hub — is redirected into settings mode on the matching page rather than landing on a screen the
/// design retired. <see cref="MainWindowViewModel.Screen"/> is kept in step with the two so every
/// existing binding written against either screen keeps its answer.
/// <para>
/// Two <see cref="Fact"/>s over one private body rather than a <see cref="Theory"/>: <c>ShellScreen</c>
/// and <c>SettingsPage</c> are both <c>internal</c>, and a public theory method may not carry an
/// internal type in its signature.
/// </para>
/// </remarks>
[Fact]
public void ShowingPreferences_EntersSettingsModeOnThePreferencesPage() =>
ShowingAScreenEntersSettingsModeOn(ShellScreen.Preferences, SettingsPage.Preferences);
[Fact]
public void ShowingVaults_EntersSettingsModeOnTheVaultsPage() =>
ShowingAScreenEntersSettingsModeOn(ShellScreen.Vaults, SettingsPage.Vaults);
/// <remarks>
/// v5c-2: Groups and Tags joined settings mode with no <see cref="ShellScreen"/> counterpart — managing
/// either has never been its own screen before this wave — so there is no redirect to prove, only that
/// <see cref="MainWindowViewModel.EnterSettingsCommand"/> reaches each directly.
/// </remarks>
[Fact]
public void EnteringSettingsOnGroups_ShowsTheGroupsPage()
{
shell.EnterSettingsCommand.Execute(SettingsPage.Groups);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Groups);
shell.IsSettingsGroupsPage.ShouldBeTrue();
}
[Fact]
public void EnteringSettingsOnTags_ShowsTheTagsPage()
{
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Tags);
shell.IsSettingsTagsPage.ShouldBeTrue();
}
private void ShowingAScreenEntersSettingsModeOn(ShellScreen screen, SettingsPage page)
{
shell.ShowScreenCommand.Execute(screen);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(page);
shell.Screen.ShouldBe(screen);
shell.IsShowingPages.ShouldBeTrue();
}
/// <remarks>
/// A caller that names an ordinary screen while settings mode is up is not asking to go back to
/// wherever settings was entered from — it is asking for that screen, which wins over "Back to
/// application" restoring anything.
/// </remarks>
[Fact]
public void NavigatingToAnOrdinaryScreenWhileInSettingsMode_LeavesSettingsModeOutright()
{
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.EnterSettingsCommand.Execute(SettingsPage.Security);
shell.ShowScreenCommand.Execute(ShellScreen.Hosts);
shell.IsSettingsMode.ShouldBeFalse();
shell.Screen.ShouldBe(ShellScreen.Hosts);
}
/// <remarks>
/// The confirmation card moved from the old bare Preferences screen to the Account settings page — see
/// <see cref="MainWindowViewModel.SignOutFromPopover"/> — and this is the one command both the rail's
/// popover Logout row and settings mode's own bottom Logout row call, so there is exactly one place the
/// card is armed from.
/// </remarks>
[Fact]
public async Task SignOutFromPopover_EntersSettingsOnAccountAndArmsTheConfirmation()
{
await ReadyToConnectAsync();
shell.SignOutFromPopoverCommand.Execute(null);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Account);
shell.IsConfirmingSignOut.ShouldBeTrue();
}
// ---- v5c-3: the importer, inside settings mode ----
//
// Import.dc.html draws the importer over the Preferences page, with SettingsNav still lit on
// Preferences — so ActiveSettingsPage never actually leaves SettingsPage.Preferences; only
// MainWindowViewModel.IsImportOpen and IsSettingsPreferencesContentShowing move. See ShowScreen's own
// translation of ShellScreen.Import, which is the Preferences page's "OPEN IMPORTER" row and every other
// caller that used to land on the old bare screen.
[Fact]
public void ShowingImport_OpensTheImporterOverThePreferencesPage()
{
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences, "SettingsNav stays lit on Preferences");
shell.IsSettingsPreferencesPage.ShouldBeTrue();
shell.IsImportOpen.ShouldBeTrue();
shell.IsSettingsPreferencesContentShowing.ShouldBeFalse("the importer is drawn over it, not beside it");
}
/// <remarks>The titlebar's own "Back to preferences": closes the importer without leaving settings mode.</remarks>
[Fact]
public void CloseImport_ReturnsToPreferencesWithoutLeavingSettingsMode()
{
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.CloseImportCommand.Execute(null);
shell.IsSettingsMode.ShouldBeTrue();
shell.ActiveSettingsPage.ShouldBe(SettingsPage.Preferences);
shell.IsImportOpen.ShouldBeFalse();
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
}
/// <remarks>The importer's own footer Cancel button, wired through ImportViewModel's onCancel delegate.</remarks>
[Fact]
public async Task TheImporterScreensCancelButton_ClosesItTheSameWayTheTitlebarDoes()
{
await UnlockedAsync();
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsImportOpen.ShouldBeTrue();
shell.ImportScreen!.CancelCommand.Execute(null);
shell.IsSettingsMode.ShouldBeTrue("Cancel backs out to Preferences, not out of Settings altogether");
shell.IsImportOpen.ShouldBeFalse();
}
/// <remarks>
/// Naming a settings page — including Preferences again — while the importer is up is a request for that
/// page, not for whatever was drawn over it last time. Covers the nav rail's own Preferences row as well
/// as every other page.
/// </remarks>
[Fact]
public void EnteringAnySettingsPageWhileImportIsOpen_ClosesTheImporter()
{
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.IsImportOpen.ShouldBeTrue();
shell.EnterSettingsCommand.Execute(SettingsPage.Preferences);
shell.IsImportOpen.ShouldBeFalse();
shell.IsSettingsPreferencesContentShowing.ShouldBeTrue();
}
[Fact]
public void LeavingSettingsModeWhileImportIsOpen_ClosesTheImporterToo()
{
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
shell.ShowScreenCommand.Execute(ShellScreen.Import);
shell.LeaveSettingsCommand.Execute(null);
shell.IsSettingsMode.ShouldBeFalse();
shell.IsImportOpen.ShouldBeFalse("a stale flag here would reopen the importer the next time Settings is entered");
}
/// <remarks>
/// <see cref="MainWindowViewModel.Issuer"/> is new in v5c, for the Account settings page's SIGN-IN row —
/// see the property's own remark. <c>MeResponse.Issuer</c> was already being cached into
/// <c>StoredUnlockMaterial</c> for no reader before this wave; this is the first assertion that it also
/// reaches the shell.
/// </remarks>
[Fact]
public async Task UnlockingCarriesTheIssuerOntoTheShell_ForTheAccountPagesSignInRow()
{
await UnlockedAsync();
shell.Issuer.ShouldBe("https://idp.example/realms/dodossh");
}
/// <summary>An unlocked vault with one selected host and a renderer attached.</summary>
private async Task<VaultViewModel> ReadyToConnectAsync()
{
@@ -1910,6 +1910,85 @@ public sealed class VaultSharingTests : IAsyncLifetime
.ShouldBe("Platform");
}
/// <remarks>
/// v5c-2: the settings Vaults page draws one card per vault with no list selection to lean on, so
/// <c>RenameVaultRow</c>/<c>DeleteVaultRow</c> select the row first and then hand off to the commands
/// above — this proves the hand-off selects the right vault and reaches the same form.
/// </remarks>
[Fact]
public async Task RenameVaultRow_SelectsTheCardThenOpensTheSameFormRenameVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
// A different vault selected first, so the row argument is what actually decides which one the
// form is about rather than whatever was already selected.
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.RenameVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsRenamingVault.ShouldBeTrue();
vaults.EditVaultName.ShouldBe("Platform secrets");
}
[Fact]
public async Task DeleteVaultRow_SelectsTheCardThenArmsTheSameConfirmationDeleteVaultDoes()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.DeleteVaultRowCommand.Execute(shared);
vaults.SelectedVault.ShouldBe(shared);
vaults.IsConfirming.ShouldBeTrue();
vaults.PendingAction!.Question.ShouldContain("Platform secrets");
}
/// <remarks>
/// The settings page's members panel: pressing the card's members icon on a vault that is not already
/// selected has to select it first, or the panel would open over whichever vault the list last landed
/// on rather than the one that was actually clicked.
/// </remarks>
[Fact]
public async Task OpenMembersPanel_SelectsTheVaultItWasOpenedForAndReadsItsMembers()
{
await UnlockedAsync();
var vaults = shell.Vaults;
await CreateVaultAsync(vaults, "Platform secrets");
var shared = vaults.SelectedVault!;
vaults.SelectedVault = vaults.Vaults.First(row => row.IsPersonal);
vaults.IsMembersPanelOpen.ShouldBeFalse();
vaults.OpenMembersPanelCommand.Execute(shared);
vaults.IsMembersPanelOpen.ShouldBeTrue();
vaults.SelectedVault.ShouldBe(shared);
// OpenMembersPanel's own selection assignment starts a read nothing here can await — see
// OnSelectedVaultChanged — so this reads it again through LoadAsync, which is awaited, rather than
// racing the fire-and-forget one.
await vaults.LoadAsync(Token);
vaults.Members.ShouldContain(member => member.IsSelf);
vaults.CloseMembersPanelCommand.Execute(null);
vaults.IsMembersPanelOpen.ShouldBeFalse();
}
/// <remarks>
/// <para>
/// <b>An address with no account is a refusal, and the sentence has to say what to do about it.</b>