Public Access
Give the phone the + it was promised, and the editors behind it
Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The phone can now put things in a keychain rather than only read one. The floating + arrives three designs after it was first asked for. It was refused twice on the honest grounds that it would open an editor this head had not got — and that stopped being true when steps 1 to 4 landed. It raises a sheet rather than a form, because "add" on this screen has been two operations since groups existed: a host, or a group to file hosts under. Both editors are cards in the list's own row, swapped for the list rather than stacked over it, following SnippetsScreen and for the reason written there: a form on top of the list hides what it is about. There is still no dialog and no editor screen anywhere on this head. The port and username boxes may be left empty, and the greyed text inside each is what the host will actually use — following the group picker as it moves, so the form says what leaving a box blank will do rather than making it a guess. The back gesture gains a guard above the switch rather than a case inside it. An editor is not a screen and has no entry there, and it is strictly nearer: the sheet sits over the editor's own screen, so back lowers whatever is topmost before it considers moving between screens. A group editor needs a way in, and the phone draws no groups panel — so the heading in the host list gains a pencil. A + that adds groups with no way to correct one is the same strange thing to ship as one that adds hosts with no way to correct one, and worse: a group's defaults are what every host beneath it falls back to. Scouting step 6 turned up a bug worth more than the prose it was looking for. ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation while a group carried only a name — a teammate's host showed under UNGROUPED and nothing else was lost. Since a group began lending a port, a username and a binding, the same omission silently dropped all three: that host would dial 22 as nobody while the machine is on 2222 as deploy, with nothing on screen saying why. The resolution map now spans every readable vault. The editable list stays narrow, because a row shown across vaults needs a vault id for rename and delete and two vaults may hold groups with one name — but the map needs none of that, since it is only ever asked what an id says. An adversarial review of this change found something I had written a comment denying. The + was a child of the outer Panel rather than of the list's row, so it anchored to the bottom of the screen and sat squarely over the new EDIT button and the lower edge of CONNECT — and because a Panel hit-tests its last-declared child first, tapping the right end of EDIT would have raised the add sheet. The comment beside it claimed it was in the list's row precisely so that could not happen, and manual-checks 8.1 names that exact failure. It is in Grid.Row 2 now. The same pass caught a connect bar that was disabled rather than hidden while its own comment said hidden, a scrim that would have flashed its default pressed chrome across the screen, a fab comment claiming an accent fill no other button had when Button.primary has it, and a back-gesture remark naming two view-model properties that do not exist anywhere in the repo. That last one came from this plan, which invented IsVaultsSurface and IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has never existed. Both are corrected in the plan rather than quietly worked around, along with a fourth "groups are flat" site in README.md that step 1 missed. The phone's rectangles are not measurable and structurally never will be: the layout suite is net10.0, the Android head is net10.0-android, and Avalonia's application is a one-shot process global — so a second head cannot share the process even if the reference were possible. Everything the sheet and both editors bind to is shared and is tested headlessly here; the pixels go to docs/manual-checks.md phase 8, nine checks, which is where this project already sends what it cannot assert. Tags remain the one thing asked for that did not ship. Tag is a full item kind and TagIds merges, encodes and resolves, but no screen draws a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors carry the set through a save untouched so a client that can set them does not lose them. Recorded as half-shipped in docs/design-import-gaps.md rather than quietly left out. Verified by the whole suite: 1402 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display — phase 8 is what that costs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -441,8 +441,18 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
|
||||
keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
|
||||
Group membership is a field on the *host* rather than a member list on the group, so filing two machines at
|
||||
once on two laptops is two independent writes instead of one contested one; and groups are flat, because a
|
||||
parent pointer merged field by field lets two offline clients build a cycle that nothing can repair.
|
||||
once on two laptops is two independent writes instead of one contested one; and a group carries the port,
|
||||
username and key the hosts inside it fall back to, read at connect time rather than copied in, so changing
|
||||
one changes every host that never overrode it.
|
||||
|
||||
Groups nest, and that reverses an earlier decision worth recording. They were flat because a parent pointer
|
||||
merged field by field lets two offline clients build a cycle nothing upstream can see — the pointer is
|
||||
inside the payload, so the server cannot read it, and the merge resolves one item against one item and
|
||||
never sees the pair. That is still true. What changed is that inheritance made the chain something the
|
||||
*connect* path walks, so the answer had to be a walk that terminates whatever it is handed: every walk
|
||||
carries a visited set and stops at a repeat, which degrades a cycle to a group reading as a root rather
|
||||
than to a shell that never opens. Given a walk that had to be cycle-safe anyway, refusing to nest bought
|
||||
nothing.
|
||||
|
||||
Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
|
||||
default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
|
||||
|
||||
@@ -4,9 +4,8 @@ The phone can read a keychain and connect through it. It cannot put anything in
|
||||
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
|
||||
ordered work, and the traps that are already known.
|
||||
|
||||
> **Status: steps 1–4 built, 5–6 not started.** The domain and the desktop are done and the phone has not
|
||||
> been touched. Each built step compiles with the whole suite green, which is the rule the ordering below
|
||||
> sets.
|
||||
> **Status: built.** All six steps. Each one compiled with the whole suite green before the next began,
|
||||
> which is the rule the ordering below sets.
|
||||
>
|
||||
> | Step | State | Notes |
|
||||
> | --- | --- | --- |
|
||||
@@ -14,12 +13,16 @@ ordered work, and the traps that are already known.
|
||||
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
|
||||
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
|
||||
> | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
|
||||
> | 5. The phone | Not started | Nothing in `Views/HostsScreen.axaml`, `Theme/Phone.axaml` or `PhoneShell` has changed. |
|
||||
> | 6. Tests and false prose | Partly done as it went | The guards each step tripped are fixed and the connect path is covered. `docs/design-import-gaps.md` and the three phone files still say the `+` is deliberately absent. |
|
||||
> | 5. The phone | **Done** | The `+`, the sheet, both editor cards, the back guard and EDIT on the connect bar. |
|
||||
> | 6. Tests and false prose | **Done** | Plus a fourth "groups are flat" site in `README.md` this plan did not list, and a cross-vault resolution bug the scouting for it turned up. |
|
||||
>
|
||||
> **Tags are stored but not editable.** `TagIds` merges, encodes and resolves, and no screen can put one on a
|
||||
> host yet — the desktop editor carries the set through a save untouched so that a client which can set them
|
||||
> does not lose them. The chips belong with the phone's editors in step 5.
|
||||
> **Tags are stored and still not editable, and that is the one thing asked for that did not ship.** `Tag` is
|
||||
> a full item kind and `HostSecret.TagIds` merges, encodes and resolves — but no screen on either head draws
|
||||
> a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors
|
||||
> carry the set through a save untouched, so a client that can set them does not lose them to somebody
|
||||
> editing a port. What is missing is a picker and a chip row, on both heads: the plan's step 5 list does not
|
||||
> mention them, and building the sheet, both editors and the back guard was already the larger half of it.
|
||||
> Recorded as half-shipped in `docs/design-import-gaps.md` rather than quietly left out.
|
||||
>
|
||||
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
|
||||
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
|
||||
@@ -50,7 +53,7 @@ Three of those fields do not exist at any layer, and one of them is refused on t
|
||||
|
||||
| Asked for | What exists today |
|
||||
| --- | --- |
|
||||
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml` and `ConnectionsScreen.axaml` both tell the user, in prose on screen, that hosts are created on the desktop and sync down. |
|
||||
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
|
||||
| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's `VaultScreen.axaml` already draws it. |
|
||||
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
|
||||
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
|
||||
@@ -272,8 +275,10 @@ repo-wide, so any XAML warning fails CI.
|
||||
`HostKeySheet`, which is exactly why that control lives there.
|
||||
- **The back gesture needs a new guard**, above the `switch` in `PhoneShell.axaml.cs`, in the shape of the
|
||||
host-key guard: back should close the sheet or the editor rather than leave the screen. The comment on that
|
||||
method states an invariant — the two switch cases are the membership tests of `IsVaultsSurface` and
|
||||
`IsConnectionsSurface` and must stay in step — so add a guard, do not extend the switch.
|
||||
method states an invariant about the switch cases staying in step with the surface tests — so add a guard,
|
||||
do not extend the switch. *(This named `IsVaultsSurface` and `IsConnectionsSurface`. Neither exists
|
||||
anywhere in the repo; the real properties are `IsMoreSurface` and `IsTerminalSurface`, and only the first
|
||||
is what the switch mirrors.)*
|
||||
- The editors follow `SnippetsScreen.axaml`: a `Border Classes="card"` in the same grid row as the list,
|
||||
toggled on `IsEditing`, with the list toggled on `!IsEditing`. There is no dialog, no modal and no
|
||||
editor-screen anywhere on this head, and the reason is written down — a form stacked over the list hides what
|
||||
@@ -324,7 +329,9 @@ deliberately absent, and each states a reason that will no longer hold.
|
||||
|
||||
- `Theme/Phone.axaml` — the "No floating action button" block.
|
||||
- `Views/HostsScreen.axaml` — the v2 and v3 notes on the missing `+`.
|
||||
- `Views/ConnectionsScreen.axaml` — "hosts are created on the desktop and sync down", rendered on screen.
|
||||
- ~~`Views/ConnectionsScreen.axaml`~~ — **this file does not exist and never did.** The real inventory is
|
||||
the two above plus `README.md`, `docs/android-port.md` and `docs/design-import-gaps.md`. Neither phone
|
||||
site was "rendered on screen": both are XAML comments.
|
||||
- `HostGroupSecret.cs` and `HostGroupSecretTests.cs` — "No parent. Groups are flat."
|
||||
- `docs/design-import-gaps.md` — the **Add host** row, the two floating-button rows, the "Tag / HostTag still
|
||||
reserved and unused" paragraph, and the tag-chips and groups-on-a-team's-hosts rows in the Hosts table.
|
||||
|
||||
@@ -331,7 +331,9 @@ that appear rarely and matter most. Those are marked **◆**.
|
||||
- **◆ Conflict log** — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge
|
||||
is only allowed to pick a winner because this exists.
|
||||
- *Groups panel:* the groups as chips with host counts, a name box that both adds and renames, delete with
|
||||
its own confirmation counting the affected hosts.
|
||||
its own confirmation counting the affected hosts. Since groups gained a parent and the four defaults their
|
||||
hosts inherit, the panel is a small form rather than a box: a parent picker that excludes the group's own
|
||||
descendants, and a default port, username and binding beside the name.
|
||||
|
||||
**2. Files** — two panes and a queue.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
|
||||
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
|
||||
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
|
||||
> | A `+` floating button on **HOSTS** | Omitted. It would open a host editor, and this head has none — hosts are created on the desktop and sync down. |
|
||||
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
|
||||
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
|
||||
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
|
||||
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
|
||||
@@ -59,7 +59,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. |
|
||||
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn. There is no fingerprint on a stored key, no tag item type, and no last-used timestamp at any layer. The pane keeps the host editor the design has no equivalent of, which is the thing that is actually used. |
|
||||
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn. There is no fingerprint on a stored key and no last-used timestamp at any layer. A tag item type now exists and a host names them, but nothing on either head draws or edits a chip, so the card would have one field of the three and it would be empty. The pane keeps the host editor the design has no equivalent of, which is the thing that is actually used. |
|
||||
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
|
||||
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
|
||||
> | Snippets: **RUNS ON** host tags and a last-run line | The command and the two buttons that name the terminal they type into. |
|
||||
@@ -136,8 +136,15 @@ protocol rather than a protocol change.
|
||||
|
||||
Two of those slots are now taken. `HostGroup` and `Snippet` shipped as full item kinds — a table, an EF
|
||||
configuration, a server kind that refuses every plaintext field, a codec, a merge, a cipher and a repository
|
||||
— and neither needed a contract change, which is what the reservation bought. `Tag`, `HostTag`,
|
||||
`HostCredential` and `PortForward` are still reserved and still unused.
|
||||
— and neither needed a contract change, which is what the reservation bought. `Tag = 5` has since shipped the
|
||||
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
|
||||
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
|
||||
|
||||
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now
|
||||
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
|
||||
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
|
||||
without either losing, and the client's per-key three-way merge already gives it. A second item type would
|
||||
be a second table and a second pass through every rule in this file, for nothing.
|
||||
|
||||
**A third was added for something the design never mentioned**: `ObjectStore = 13`, an S3-compatible bucket
|
||||
and the keys that reach it. It is a keychain item like any other — the endpoint and the secret access key are
|
||||
@@ -198,10 +205,10 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. |
|
||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Flat, not nested. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
|
||||
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Half shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. No chip is drawn or edited on either head yet, so the tags a client can store are ones nothing here can see. The filter box searches name, address and notes. |
|
||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Nested in the data and drawn flat — a group carries a `ParentId` and the defaults its hosts inherit, and the sidebar still emits one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
|
||||
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
|
||||
| Groups on a **team's** hosts | client-domain | Reading groups across every readable vault, a vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | Not yet. Groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED — the same way a host whose group was deleted does. Nothing is lost or misfiled; the grouping is simply not shown. |
|
||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a host a teammate filed still shows under UNGROUPED and cannot be renamed or unfiled from here. |
|
||||
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
||||
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
||||
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
||||
|
||||
@@ -691,3 +691,101 @@ add a bucket — then come back.
|
||||
|
||||
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
|
||||
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
|
||||
|
||||
## Phase 8 — Adding hosts on the phone
|
||||
|
||||
Nine checks, and the reason there are nine rather than none is worth stating: **the layout suite cannot see
|
||||
any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||||
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
|
||||
Every property and command below is covered headlessly in `ShellFlowTests` — the flow is shared, and only
|
||||
the rectangles are not. So what follows is the rectangles.
|
||||
|
||||
Run on a 360dp-wide device or emulator, unlocked, on HOSTS.
|
||||
|
||||
### 8.1 The + is reachable and is not covering anything
|
||||
|
||||
Look at the bottom right of the host list with no host selected.
|
||||
|
||||
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then select a
|
||||
host so the connect bar rises.
|
||||
|
||||
**Pass:** the + has moved up with the bar and is not over the CONNECT button.
|
||||
|
||||
**Failure means:** the + was anchored to the screen rather than to the list's row. Nothing in the layout
|
||||
harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
|
||||
between two controls, so a button sitting squarely on top of CONNECT reports clean.
|
||||
|
||||
### 8.2 The sheet says what each choice does
|
||||
|
||||
Tap the +.
|
||||
|
||||
**Pass:** a sheet rises from the bottom with a grab handle, two rows — **New host** and **New group** — each
|
||||
with a sentence under it, and CANCEL. The scrim darkens the list.
|
||||
|
||||
**Note:** the four nav destinations underneath stay lit and tappable. That is deliberate — this is a menu,
|
||||
not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which
|
||||
must offer no way out but BACK.
|
||||
|
||||
### 8.3 The sheet is dismissible three ways
|
||||
|
||||
Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
|
||||
|
||||
**Pass:** all three lower it, and none of them opens an editor.
|
||||
|
||||
**Failure means:** back is being handled by the screen switch rather than by the guard above it, and the
|
||||
gesture has left HOSTS instead of closing the sheet.
|
||||
|
||||
### 8.4 An empty port box says what it will dial
|
||||
|
||||
Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker.
|
||||
|
||||
**Pass:** the port box is **empty**, and the greyed text inside it changes to `2222` the moment the group is
|
||||
chosen. The username box behaves the same way.
|
||||
|
||||
**Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it —
|
||||
saving would then pin 2222 onto the host, and changing the group later would leave that host behind.
|
||||
|
||||
### 8.5 A host created that way inherits rather than copies
|
||||
|
||||
Save that host, then edit the group's default port to 2200 and look at the host's row.
|
||||
|
||||
**Pass:** the address now reads `…:2200`. Connect: the session goes to 2200.
|
||||
|
||||
**Failure means:** the port was copied at creation and the group is a one-shot template.
|
||||
|
||||
### 8.6 Filing a host into a group does not silently pin its password
|
||||
|
||||
Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask
|
||||
each time)**. Save. Now edit it and choose the group.
|
||||
|
||||
**Pass:** the picker gains an **Inherit from group** entry and moves to it. Save, and the host row reads
|
||||
`key`, and the connect bar shows a note naming the group rather than a password box.
|
||||
|
||||
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
|
||||
absence of one, and this host will never use the fleet's key.
|
||||
|
||||
### 8.7 A host can still refuse its group's key
|
||||
|
||||
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
|
||||
|
||||
**Pass:** the connect bar shows a password box again, and it survives a lock and unlock.
|
||||
|
||||
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
|
||||
|
||||
### 8.8 Back closes the topmost thing, one at a time
|
||||
|
||||
Tap + → New host, then use the back gesture.
|
||||
|
||||
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
|
||||
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
|
||||
|
||||
### 8.9 Every target is a thumb's size
|
||||
|
||||
Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and the four buttons.
|
||||
|
||||
**Pass:** nothing is under 44 effective pixels tall.
|
||||
|
||||
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
|
||||
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
|
||||
|
||||
@@ -115,10 +115,65 @@
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
No floating action button. The design puts one on HOSTS and one on S3, and both would open an editor
|
||||
this head has not got — hosts and keychain items are created on the desktop and sync down. The style is
|
||||
not here waiting for them either: an unused style is a claim that the control exists somewhere.
|
||||
The floating action button the design has asked for since v2, and which this theme refused twice — on
|
||||
the grounds that it would open an editor this head had not got, and that an unused style is a claim the
|
||||
control exists somewhere. Both were true. The second is why the style arrives in the same commit as the
|
||||
button rather than ahead of it.
|
||||
|
||||
56 rather than the 44 the header icons use. It is the only control on the screen that floats over
|
||||
content rather than sitting in a bar, so it has no neighbouring chrome to borrow an edge from, and a
|
||||
thumb reaching the bottom-right corner of a 360dp screen is doing it without looking.
|
||||
|
||||
Accent-filled, which it shares with Button.primary and with nothing else — and it means the same thing
|
||||
in both places: the one action on the surface that is not a choice between peers. Circular by radius
|
||||
rather than by a Path, so the pressed state the template draws is the same shape as the button.
|
||||
|
||||
Still only on HOSTS. The design puts a second one on S3 and that editor genuinely does not exist yet, so
|
||||
the style being here is not permission to draw one there.
|
||||
-->
|
||||
<Style Selector="Button.fab">
|
||||
<Setter Property="Width" Value="56" />
|
||||
<Setter Property="Height" Value="56" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentInk}" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="24" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<Style Selector="Button.fab:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Opacity" Value="0.82" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The dimmed area behind a sheet, and a Button rather than a Border because tapping it has to mean
|
||||
something — see the add sheet in HostsScreen, which is dismissible for the reason the host-key sheet is
|
||||
not.
|
||||
|
||||
62% of Canvas, written out because the palette holds no alpha variant of a surface. The pressed rule is
|
||||
not decoration: a Button's default template lights its ContentPresenter when touched, so without it the
|
||||
scrim would flash a pale rectangle across the whole screen on the way to closing — which reads as the
|
||||
interface glitching rather than as a tap being received.
|
||||
-->
|
||||
<Style Selector="Button.scrim">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
</Style>
|
||||
<Style Selector="Button.scrim:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="#9E0E1220" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A card: one item, one rule, one thing you can act on. The design uses it for a key, a snippet, a
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||
x:Class="DodoSSH.Client.Android.Views.HostsScreen"
|
||||
x:DataType="vm:VaultViewModel"
|
||||
Background="{StaticResource Canvas}">
|
||||
@@ -18,11 +19,27 @@
|
||||
|
||||
── v2 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The rows became cards: rounded, inset from the edge, and filled when chosen rather than marked with a
|
||||
strip. What did not come across is the design's floating + button. It would open a host editor, and this
|
||||
head has none — hosts are created on the desktop and sync down. A button that opened nothing would be
|
||||
the one control on this screen that lied about what the phone can do; see docs/design-import-gaps.md.
|
||||
strip.
|
||||
|
||||
── v3 ────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
The design's floating + arrives, three versions after it was first asked for. It was refused twice on
|
||||
the grounds that it would open an editor this head had not got — hosts were created on the desktop and
|
||||
synced down — and that was true until the editors below existed. It opens a sheet rather than a form,
|
||||
because "add" on this screen has been two operations since groups existed: a host, or a group to file
|
||||
hosts under.
|
||||
|
||||
Both editors are cards in the list's own row, swapped for the list rather than stacked over it, which is
|
||||
what SnippetsScreen does and for the reason written there: a form on top of the list hides what it is
|
||||
about. There is no dialog and no editor screen anywhere on this head.
|
||||
-->
|
||||
|
||||
<!--
|
||||
The Panel is what lets the add sheet cover the screen. The + does not need it — it is layered inside the
|
||||
list's own Grid row, which is where it has to be so that the connect bar pushes it up rather than being
|
||||
covered by it. See the button itself.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!-- ============ filter ============ -->
|
||||
@@ -75,7 +92,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ the list ============ -->
|
||||
<ListBox Grid.Row="2" Margin="0,6,0,0"
|
||||
<ListBox Grid.Row="2" Margin="0,6,0,0" IsVisible="{Binding !AnEditorIsOpen}"
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
@@ -106,10 +123,24 @@
|
||||
the filter, which is what a heading over a filtered list has to say.
|
||||
-->
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="8,12,8,5">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
|
||||
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9" />
|
||||
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The phone's only way into a group's editor. The desktop reaches it from a groups panel this
|
||||
head does not draw, and a heading's own selection deliberately bounces back to the host — so
|
||||
without this a group could be created here and never corrected, which matters more for a group
|
||||
than for a host: its defaults are what every host beneath it falls back to.
|
||||
|
||||
Hidden on the ungrouped heading, which has no group behind it to edit.
|
||||
-->
|
||||
<Button Grid.Column="3" Classes="icon" FontSize="13" Content="✎" Margin="4,0,0,0"
|
||||
IsVisible="{Binding GroupId, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
Command="{Binding $parent[views:HostsScreen].((vm:VaultViewModel)DataContext).EditGroupFromHeadingCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -158,12 +189,170 @@
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- ============ the host editor ============ -->
|
||||
<!--
|
||||
In the list's row and swapped for it, following SnippetsScreen. Every field is the shared view model's
|
||||
— the same properties the desktop's sidebar binds — so the two heads cannot drift about what a host
|
||||
is or what saving one does.
|
||||
|
||||
◆ The port and username boxes may be left empty, and empty is not "unset": it means the host takes its
|
||||
group's. The watermark on each is what it will actually get, and it follows the group picker below as
|
||||
that moves, so the form says what leaving a box blank will do rather than making it a guess. See
|
||||
HostInheritance.
|
||||
-->
|
||||
<Border Grid.Row="2" Classes="card" Margin="12,0" IsVisible="{Binding IsEditing}"
|
||||
VerticalAlignment="Top">
|
||||
<ScrollViewer MaxHeight="560">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="label" Text="HOST" />
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Classes="field" Text="{Binding EditorHostname}" PlaceholderText="hostname or address" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,2*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535"
|
||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||
|
||||
<!--
|
||||
One picker for all of them, as on the desktop, which is what makes "a key or a credential, never
|
||||
both" impossible to express rather than merely invalid. The qualifier beside each label matters
|
||||
for the same reason it does there: a key called deploy and the deploy account's password are the
|
||||
ordinary case, and bare labels would offer two identical-looking entries.
|
||||
-->
|
||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
<TextBlock Classes="detail" FontSize="10" VerticalAlignment="Center"
|
||||
Text="{Binding Qualifier}"
|
||||
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
◆ The one control here that publishes something. Turning it on copies this host's address and
|
||||
port into plaintext columns the server can read, which is the single deliberate concession in
|
||||
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5" Text="Reach this host through the server relay"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock Classes="body"
|
||||
Text="The relay dials on your behalf, so this host's address and port are stored on the server in the clear. Everything else about it stays encrypted. A relayed host also needs a port of its own rather than its group's." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveHostCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- ============ the group editor ============ -->
|
||||
<!--
|
||||
The same card in the same row, and a group is now enough of a thing to need one: a name, where it sits
|
||||
in the tree, and the three things hosts inside it fall back to. Every one of those may be left empty,
|
||||
and empty means the group lends nothing rather than that it has not been filled in.
|
||||
|
||||
The parent picker leaves out this group and everything beneath it, so a cycle cannot be made here. That
|
||||
is a courtesy rather than the guarantee — one assembled from two offline re-parents was never offered
|
||||
this list, which is why the walk itself stops at a repeat.
|
||||
-->
|
||||
<Border Grid.Row="2" Classes="card" Margin="12,0" IsVisible="{Binding IsEditingGroup}"
|
||||
VerticalAlignment="Top">
|
||||
<ScrollViewer MaxHeight="560">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Classes="label" Text="GROUP" />
|
||||
|
||||
<TextBox Classes="field" Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
|
||||
|
||||
<TextBlock Classes="label" Text="INSIDE" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
|
||||
SelectedItem="{Binding GroupEditorSelectedParent}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="label" Text="HOSTS INSIDE FALL BACK TO" Margin="0,6,0,0" />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,2*">
|
||||
<NumericUpDown Grid.Column="0" Value="{Binding GroupEditorDefaultPort}" Minimum="1"
|
||||
Maximum="65535" FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||
PlaceholderText="no default" />
|
||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding GroupEditorDefaultUsername}"
|
||||
PlaceholderText="no default user" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox ItemsSource="{Binding GroupEditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding GroupEditorSelectedAuthentication}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
<TextBlock Classes="detail" FontSize="10" VerticalAlignment="Center"
|
||||
Text="{Binding Qualifier}"
|
||||
IsVisible="{Binding Qualifier, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<TextBlock Classes="body"
|
||||
Text="A host inside this group uses these wherever it says nothing itself. They are read at connect time rather than copied in, so changing one here changes every host that never overrode it." />
|
||||
|
||||
<Grid ColumnDefinitions="*,8,*">
|
||||
<Button Grid.Column="0" Classes="primary" Height="44" Content="SAVE"
|
||||
Command="{Binding SaveGroupCommand}" />
|
||||
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
|
||||
Command="{Binding CancelGroupEditCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
<!-- ============ connect ============ -->
|
||||
<!--
|
||||
Raised over the list when a host is chosen, because the desktop's right-hand column has nowhere to go
|
||||
at this width. It names the host: the selection that is obvious on a wide window is not obvious here.
|
||||
|
||||
Gone entirely while an editor is up, rather than merely greyed. The editor replaces the list above it,
|
||||
so a bar left in place would be a set of buttons about a host that is no longer on screen — and the
|
||||
one underneath the host editor would be about the very record being typed into. Disabling it was the
|
||||
first attempt and it is worse: a dimmed CONNECT under an open form reads as a screen that has broken
|
||||
rather than as one that is busy.
|
||||
-->
|
||||
<Border Grid.Row="3" IsVisible="{Binding SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
<Border Grid.Row="3" IsVisible="{Binding ShowsConnectBar}"
|
||||
Background="{StaticResource Chrome}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" Padding="14,12">
|
||||
<StackPanel Spacing="10">
|
||||
@@ -189,9 +378,100 @@
|
||||
|
||||
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<!--
|
||||
Not asked for by the design, and here because a + that adds hosts with no way to correct one is a
|
||||
strange thing to ship. It costs nothing: the editor above serves both, so this is the same panel
|
||||
opened on an existing row.
|
||||
-->
|
||||
<Button Classes="secondary" Height="44" Content="EDIT"
|
||||
Command="{Binding EditSelectedHostCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ============ add ============ -->
|
||||
<!--
|
||||
◆ The design's floating +, and the reason every version of this file until now said it could not be
|
||||
drawn.
|
||||
|
||||
<b>Grid.Row 2, inside the Grid — not the Panel around it — and the difference is the whole control.</b>
|
||||
The plan asked for the outer Panel so the button would not move. A Panel child aligned to the bottom is
|
||||
anchored to the bottom of the screen, and the connect bar is a row of that same Grid: the 56px circle
|
||||
would then sit squarely over EDIT and the lower edge of CONNECT, and because a Panel hit-tests its
|
||||
last-declared child first, tapping the right end of EDIT would raise the add sheet instead. Covering
|
||||
CONNECT is the one thing on this screen that must not happen.
|
||||
|
||||
In the list's row it rides up as the connect bar appears, which is what a floating button does on every
|
||||
other Android application and what a thumb reaches for. It moves; that is the cost, and it is the
|
||||
smaller one.
|
||||
|
||||
Hidden rather than disabled while anything is open over the list. A + that opens an editor on top of an
|
||||
open editor is a control with nothing to do, and disabling it would leave a large accented circle on
|
||||
screen saying so.
|
||||
-->
|
||||
<Button Grid.Row="2" Classes="fab" Content="+" Margin="0,0,18,18"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Bottom"
|
||||
IsVisible="{Binding !AnEditorIsOpen}"
|
||||
Command="{Binding OpenAddSheetCommand}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ the add sheet ============ -->
|
||||
<!--
|
||||
Follows HostKeySheet, this head's one existing sheet: a scrim, a bottom-aligned panel with the top two
|
||||
corners rounded, and a grab handle that is decoration. Two things it does differently, both because it
|
||||
is a menu rather than a decision.
|
||||
|
||||
It is dismissible. Tapping the scrim closes it, which HostKeySheet deliberately refuses — answering
|
||||
"do you trust this key" by tapping beside it is the swipe-to-dismiss that head was built to avoid.
|
||||
"Which kind of thing are you adding" has no wrong answer and no answer at all is one of them.
|
||||
|
||||
And it lives here rather than in PhoneShell. HostKeySheet sits up there because the files screen raises
|
||||
it too; nothing but this screen raises this. The cost is that the scrim covers the screen and not the
|
||||
64px nav bar underneath, so the four destinations stay lit and tappable — which for a menu is right:
|
||||
navigating away is a perfectly good way to decide not to add anything.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsAddSheetOpen}">
|
||||
|
||||
<!--
|
||||
Declared before the sheet so the sheet draws over it: a Panel stacks its children in declaration
|
||||
order. See the .scrim style for why a tap on it must not light anything up.
|
||||
-->
|
||||
<Button Classes="scrim" Command="{Binding CloseAddSheetCommand}" />
|
||||
|
||||
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||
|
||||
<TextBlock Classes="title" Text="ADD" FontSize="13" />
|
||||
|
||||
<Button Classes="row" Margin="0,10,0,0" Command="{Binding NewHostCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New host" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A machine to connect to, filed under a group if you want one." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="row" Command="{Binding NewGroupCommand}">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" Text="New group" />
|
||||
<TextBlock Classes="detail" FontSize="10.5"
|
||||
Text="A heading for hosts, and the port, user and key they fall back to." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="secondary" Content="CANCEL" Margin="0,12,0,0"
|
||||
Command="{Binding CloseAddSheetCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -168,6 +168,15 @@ internal sealed partial class PhoneShell : UserControl
|
||||
/// the host list it does nothing at all, so back still leaves the application from the screen the
|
||||
/// application opens on, which is what every other Android app does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>v3 adds one guard above the switch rather than another case inside it.</b> The switch's first case
|
||||
/// is the membership test of <see cref="MainWindowViewModel.IsMoreSurface"/> minus <c>More</c> itself,
|
||||
/// and has to stay in step with it — a screen added to the hub and not to that case would trap the user
|
||||
/// on it. An editor is not a screen and has no entry there. It is also strictly nearer: the add sheet
|
||||
/// sits over the host editor's own screen, so back has to lower whatever is topmost before it considers
|
||||
/// moving between screens at all. Closing an editor is not the same refusal as leaving a host-key
|
||||
/// decision alone — an editor is abandonable by design, and the CANCEL button beside it says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnBackRequested(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -183,6 +192,12 @@ internal sealed partial class PhoneShell : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryCloseAnOpenEditor(current))
|
||||
{
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!current.IsShowingPages)
|
||||
{
|
||||
current.ShowScreenCommand.Execute(current.Screen);
|
||||
@@ -209,6 +224,51 @@ internal sealed partial class PhoneShell : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
||||
/// </summary>
|
||||
/// <returns>Whether anything was closed, and so whether back has been spent.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Order is the whole of it. The add sheet sits over the list and the two editors sit in place of it, so
|
||||
/// the sheet has to go first — closing an editor while a sheet was open would leave the sheet floating
|
||||
/// over a list nobody asked to see, and the second back would then close the sheet rather than the
|
||||
/// editor the user was looking at.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The editors are cancelled rather than merely hidden. Cancelling is what clears the boxes, and the
|
||||
/// host editor's boxes are the ones worth clearing: leaving a half-typed hostname behind would have the
|
||||
/// next NEW HOST open on somebody else's abandoned draft.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryCloseAnOpenEditor(MainWindowViewModel current)
|
||||
{
|
||||
if (current.Vault is not { } vault)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (vault.IsAddSheetOpen)
|
||||
{
|
||||
vault.CloseAddSheetCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsEditing)
|
||||
{
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vault.IsEditingGroup)
|
||||
{
|
||||
vault.CancelGroupEditCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blocks screenshots and screen recording while the recovery code is on screen.
|
||||
/// </summary>
|
||||
|
||||
@@ -1272,8 +1272,65 @@ internal sealed partial class VaultViewModel(
|
||||
// ---- The editor ----
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isEditing;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the group editor is open as a surface of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
|
||||
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
|
||||
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
|
||||
/// different question from "which group is being edited", because adding one has no id.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
||||
/// whether an editor is open. The back gesture and the floating button both read it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the add sheet is showing over the host list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The phone's answer to a <c>+</c> that has two things to offer. It is a separate flag from the two
|
||||
/// editors because it sits <em>before</em> either of them: the sheet asks which kind, and choosing
|
||||
/// closes the sheet and opens that kind's editor. See <see cref="OpenAddSheet"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||||
private bool isAddSheetOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether anything the host screen can put over its list is showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One property rather than three tests at each call site, and it exists because two controls need
|
||||
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
|
||||
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
|
||||
/// does nothing — and the back gesture closes them before it considers leaving the screen.
|
||||
/// </remarks>
|
||||
internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's connect bar has anything to be about.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one
|
||||
/// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing
|
||||
/// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host
|
||||
/// editor, for the very record being typed into.
|
||||
/// </remarks>
|
||||
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
@@ -1624,7 +1681,12 @@ internal sealed partial class VaultViewModel(
|
||||
internal bool HasGroups => Groups.Count > 0;
|
||||
|
||||
/// <summary>What the group panel's save button says.</summary>
|
||||
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "RENAME";
|
||||
/// <remarks>
|
||||
/// "SAVE" rather than the "RENAME" it said while a group was only a name. A button that offers to
|
||||
/// rename, pressed after somebody has changed the default port beside it, describes one of the four
|
||||
/// things it is about to do.
|
||||
/// </remarks>
|
||||
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE";
|
||||
|
||||
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||
/// <inheritdoc cref="ShowsHostActions" />
|
||||
@@ -2035,26 +2097,65 @@ internal sealed partial class VaultViewModel(
|
||||
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The active vault only, unlike every other list on this screen.</b> Hosts, keys, credentials and
|
||||
/// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
|
||||
/// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
|
||||
/// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
|
||||
/// across vaults means a group row has to carry the vault it lives in — rename and delete both need it —
|
||||
/// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell
|
||||
/// apart. Both are worth doing and neither is a merge's business. Recorded in
|
||||
/// <c>docs/design-import-gaps.md</c>.
|
||||
/// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
|
||||
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
|
||||
/// resolution map is every readable vault's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
|
||||
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
|
||||
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
|
||||
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
|
||||
/// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
|
||||
/// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
|
||||
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
|
||||
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
|
||||
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
|
||||
/// place.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
|
||||
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
|
||||
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.HostGroups
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var unreadable = 0;
|
||||
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
||||
|
||||
groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||||
groupsById = groupItems.ToDictionary(group => group.EntityId, group => group.Secret);
|
||||
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
|
||||
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
|
||||
// ever writes on a match would leave the last readable vault's groups on screen as though they were
|
||||
// still this one's.
|
||||
groupItems = [];
|
||||
|
||||
return listing.Unreadable;
|
||||
foreach (var vault in session.ReadableVaults)
|
||||
{
|
||||
var listing = await session.HostGroups
|
||||
.ListAsync(vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
unreadable += listing.Unreadable;
|
||||
|
||||
foreach (var group in listing.Items)
|
||||
{
|
||||
resolvable[group.EntityId] = group.Secret;
|
||||
}
|
||||
|
||||
if (vault.VaultId == session.ActiveVaultId)
|
||||
{
|
||||
groupItems =
|
||||
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||||
}
|
||||
}
|
||||
|
||||
groupsById = resolvable;
|
||||
|
||||
return unreadable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2733,10 +2834,43 @@ internal sealed partial class VaultViewModel(
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises the sheet that asks whether the thing being added is a host or a group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things behind one <c>+</c>, which is the design's arrangement and is also the honest one: a
|
||||
/// phone has room for one floating button, and "add" on this screen has genuinely been two operations
|
||||
/// since groups existed. The desktop asks the same question by having two buttons in two panels, which
|
||||
/// is what a 1280-pixel window can afford.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Refuses while an editor is open rather than stacking on top of it. The button that raises this is
|
||||
/// hidden in that state — see <see cref="AnEditorIsOpen"/> — so this is the guard for the path the
|
||||
/// button does not control, which is a command invoked from anywhere else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void OpenAddSheet()
|
||||
{
|
||||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsAddSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the add sheet without choosing anything.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseAddSheet() => IsAddSheetOpen = false;
|
||||
|
||||
/// <summary>Starts a new host.</summary>
|
||||
[RelayCommand]
|
||||
private void NewHost()
|
||||
{
|
||||
IsAddSheetOpen = false;
|
||||
|
||||
if (AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
@@ -2962,9 +3096,90 @@ internal sealed partial class VaultViewModel(
|
||||
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
||||
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
||||
|
||||
IsEditingGroup = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a group's editor from its heading in the host list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The phone's only route to <see cref="EditGroup"/>, and it exists because there is no other. The
|
||||
/// desktop reaches the group editor through the groups panel, which selects a
|
||||
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
|
||||
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
|
||||
/// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
|
||||
/// the header rather than the selection.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
|
||||
/// <c>+</c> that adds hosts with no way to correct one — and worse here, because a group's defaults are
|
||||
/// inherited: getting one wrong is wrong for every host beneath it at once.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
|
||||
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
|
||||
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
|
||||
/// opening an editor on nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
||||
{
|
||||
if (header?.GroupId is not { } groupId
|
||||
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectedGroup = row;
|
||||
EditGroupCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>Starts a new group.</summary>
|
||||
/// <remarks>
|
||||
/// The desktop never needed this command: its group editor is a bar that is always on screen, so
|
||||
/// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
|
||||
/// editor is a card that has to be raised — and raising it from a stale state would offer the last
|
||||
/// group's default key to the new one without anybody choosing it, which is what
|
||||
/// <see cref="ClearGroupEditor"/> prevents.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void NewGroup()
|
||||
{
|
||||
IsAddSheetOpen = false;
|
||||
|
||||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearGroupEditor();
|
||||
|
||||
IsEditingGroup = true;
|
||||
Status = "Adding a group.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the group editor has to be dealt with before another editor opens.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
|
||||
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
|
||||
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
|
||||
/// is false there and nothing is refused.
|
||||
/// </remarks>
|
||||
private bool AGroupEditorIsInTheWay()
|
||||
{
|
||||
if (IsEditingGroup)
|
||||
{
|
||||
Status = "Finish or cancel the group you are editing first.";
|
||||
}
|
||||
|
||||
return IsEditingGroup;
|
||||
}
|
||||
|
||||
/// <summary>The group picker's selection, if it names something of this kind.</summary>
|
||||
private Guid? GroupBound(AuthenticationKind kind) =>
|
||||
GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
||||
@@ -3074,6 +3289,7 @@ internal sealed partial class VaultViewModel(
|
||||
private void ClearGroupEditor()
|
||||
{
|
||||
EditingGroupId = null;
|
||||
IsEditingGroup = false;
|
||||
GroupEditorLabel = string.Empty;
|
||||
GroupEditorDefaultPort = null;
|
||||
GroupEditorDefaultUsername = string.Empty;
|
||||
@@ -5184,6 +5400,7 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||||
|
||||
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
||||
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
||||
|
||||
@@ -620,8 +620,11 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has eleven of them.
|
||||
/// Measured with an edit that touched several, because one field name fits anywhere.
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
|
||||
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
|
||||
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
|
||||
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
|
||||
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheActivityLogFitsWithAnEditThatTouchedSeveralFields()
|
||||
@@ -1307,7 +1310,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
ItemId = Guid.CreateVersion7(),
|
||||
ItemLabel = "customer-a-production-database",
|
||||
Operation = ActivityOperation.Updated,
|
||||
ChangedFields = "Hostname, Port, Username, Options, Group",
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
|
||||
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
|
||||
@@ -3526,6 +3526,267 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
||||
.ShouldBe(["No group"], "estate cannot be its own parent, and production already sits under it");
|
||||
}
|
||||
|
||||
// ---- What the phone's + drives ----
|
||||
//
|
||||
// The phone's own pixels are not measurable here and cannot be: the layout suite is net10.0 and
|
||||
// DodoSSH.Client.Android is net10.0-android, so its views are unreachable by construction, and Avalonia's
|
||||
// application is a process global so a second head cannot share this process either. What IS shared is
|
||||
// every property and command the sheet and its two editors bind to, which is all of the behaviour — the
|
||||
// markup only decides where it is drawn. So the flow is tested here and the rectangles go to
|
||||
// docs/manual-checks.md, which is where this project already sends what it cannot assert.
|
||||
|
||||
[Fact]
|
||||
public async Task TheAddSheet_OffersTwoThingsAndOpensNeitherUntilOneIsChosen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeTrue();
|
||||
vault.AnEditorIsOpen.ShouldBeTrue("the + hides while anything is over the list");
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeFalse("choosing lowers the sheet rather than stacking on it");
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheAddSheet_ClosesWithoutOpeningAnything()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.CloseAddSheetCommand.Execute(null);
|
||||
|
||||
vault.AnEditorIsOpen.ShouldBeFalse();
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewGroupFromTheSheet_StartsEmptyRatherThanOnTheLastOneEdited()
|
||||
{
|
||||
// The failure this rules out is quiet: a group editor left holding the previous group's default key
|
||||
// would lend it to the next group somebody created without anybody choosing it.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddKeyAsync(vault, "deploy");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy", key: "deploy");
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.IsAddSheetOpen.ShouldBeFalse();
|
||||
vault.EditingGroupId.ShouldBeNull("this is an add, not a rename");
|
||||
vault.GroupEditorLabel.ShouldBeEmpty();
|
||||
vault.GroupEditorDefaultPort.ShouldBeNull();
|
||||
vault.GroupEditorDefaultUsername.ShouldBeEmpty();
|
||||
vault.GroupEditorSelectedAuthentication.ShouldBe(AuthenticationChoice.NoDefault);
|
||||
vault.GroupEditorSelectedParent.ShouldBe(GroupChoice.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupAddedFromTheSheet_CarriesTheDefaultsTypedIntoIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewGroupCommand.Execute(null);
|
||||
vault.GroupEditorLabel = "production";
|
||||
vault.GroupEditorDefaultPort = 2222;
|
||||
vault.GroupEditorDefaultUsername = "deploy";
|
||||
|
||||
await vault.SaveGroupCommand.ExecuteAsync(null);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeFalse("saving lowers the card");
|
||||
|
||||
var group = vault.Groups.Single();
|
||||
group.Group.Label.ShouldBe("production");
|
||||
group.Group.DefaultPort.ShouldBe(2222);
|
||||
group.Group.DefaultUsername.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheSheet_RefusesToOpenOverAnEditorRatherThanStackingOnIt()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
|
||||
vault.IsAddSheetOpen.ShouldBeFalse();
|
||||
vault.Status.ShouldContain("Finish or cancel", Case.Insensitive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANewHostFromTheSheet_LeavesItsPortToWhicheverGroupItIsFiledUnder()
|
||||
{
|
||||
// The phone's add flow end to end, and the reason the port box opens empty: a host created under a
|
||||
// group that says 2222 wants 2222 without anybody typing it, and stays wanting whatever the group
|
||||
// says afterwards.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222, username: "deploy");
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorPort.ShouldBeNull("an empty box is what leaves the port to the group");
|
||||
|
||||
vault.EditorLabel = "prod-db";
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorSelectedGroup = vault.EditorGroupChoices.Single(
|
||||
choice => string.Equals(choice.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditorPortPlaceholder.ShouldBe("2222", "the form says what leaving it blank will get you");
|
||||
vault.EditorUsernamePlaceholder.ShouldBe("deploy");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = Host(vault, "prod-db");
|
||||
host.Host.Port.ShouldBeNull("nothing was typed, so nothing was pinned");
|
||||
host.Resolved.Port.Value.ShouldBe(2222);
|
||||
host.Resolved.Username.Value.ShouldBe("deploy");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsParentPicker_LetsAGroupBeFiledUnderAnother()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
|
||||
vault.Groups
|
||||
.Single(row => string.Equals(row.Label, "production", StringComparison.Ordinal))
|
||||
.Group.ParentId
|
||||
.ShouldBe(vault.Groups.Single(row => string.Equals(row.Label, "estate", StringComparison.Ordinal))
|
||||
.EntityId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AHostUnderANestedGroup_TakesTheNearestAnswerAndKeepsWalkingForTheRest()
|
||||
{
|
||||
// Two levels, and each field resolved on its own: a group that answers one question does not stop
|
||||
// the walk for the others.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await SetGroupDefaultsAsync(vault, "estate", username: "root");
|
||||
await SetGroupDefaultsAsync(vault, "production", port: 2222);
|
||||
await SetGroupParentAsync(vault, "production", "estate");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorUsername = string.Empty;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = Host(vault, "prod-db");
|
||||
host.Resolved.Port.Value.ShouldBe(2222, "the nearer group answers the port");
|
||||
host.Resolved.Username.Value.ShouldBe("root", "and the walk carries on for the one it did not");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheConnectBar_GoesAwayWhileAnEditorIsUpRatherThanGreyingOut()
|
||||
{
|
||||
// The editors replace the list rather than floating over it, so a bar left in place would carry
|
||||
// CONNECT and EDIT for a host that is no longer on screen — and under the host editor, for the very
|
||||
// record being typed into. This was disabled rather than hidden first, which reads as a screen that
|
||||
// has broken rather than one that is busy.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = Host(vault, "prod-db");
|
||||
|
||||
vault.ShowsConnectBar.ShouldBeTrue();
|
||||
|
||||
vault.OpenAddSheetCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeFalse("the sheet is over the list");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeFalse("and the editor is in place of it");
|
||||
|
||||
vault.CancelEditCommand.Execute(null);
|
||||
vault.ShowsConnectBar.ShouldBeTrue("and it comes back with the list");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheConnectBar_StaysAwayWithNoHostChosen()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
vault.SelectedHost = null;
|
||||
|
||||
vault.ShowsConnectBar.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGroupsHeading_OpensThatGroupsEditorRatherThanTheSelectedOne()
|
||||
{
|
||||
// The phone's only route into a group editor: it draws no groups panel, and a heading's own
|
||||
// selection bounces back to the host on purpose. The command has to work off the heading it was
|
||||
// pressed on rather than off SelectedGroup, or pressing one heading would edit another.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "estate");
|
||||
await AddGroupAsync(vault, "production");
|
||||
await FileAsync(vault, "prod-db", "production");
|
||||
|
||||
vault.SelectedGroup = vault.Groups.Single(
|
||||
row => string.Equals(row.Label, "estate", StringComparison.Ordinal));
|
||||
|
||||
var heading = vault.SidebarRows.OfType<SidebarGroupHeader>().Single(
|
||||
row => string.Equals(row.Label, "production", StringComparison.Ordinal));
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(heading);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeTrue();
|
||||
vault.GroupEditorLabel.ShouldBe("production", "the heading pressed, not the group selected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheUngroupedHeading_OpensNothing()
|
||||
{
|
||||
// It has no group behind it. The button is hidden there, so this is the guard for the path the
|
||||
// markup does not control.
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
await AddGroupAsync(vault, "production");
|
||||
|
||||
var ungrouped = vault.SidebarRows.OfType<SidebarGroupHeader>()
|
||||
.FirstOrDefault(row => row.GroupId is null);
|
||||
|
||||
ungrouped.ShouldNotBeNull("a vault with a group and an unfiled host draws an ungrouped heading");
|
||||
|
||||
vault.EditGroupFromHeadingCommand.Execute(ungrouped);
|
||||
|
||||
vault.IsEditingGroup.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static async Task SetGroupDefaultsAsync(
|
||||
VaultViewModel vault,
|
||||
string group,
|
||||
|
||||
Reference in New Issue
Block a user