From 16e0051e89bad96169d309977ec6e0a2aacbaf39 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 3 Aug 2026 12:42:43 +0200 Subject: [PATCH] Draw the tags that have been storable and invisible since the domain landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Tag` has been a full item kind for three commits — a table, a migration, a codec, a merge, a cipher — and `HostSecret.TagIds` has merged per tag so two people tagging one host both keep theirs. Nothing drew a chip. The tags a client could store were ones nothing here could see. Chips on host rows, both heads, from names resolved through the tag list rather than ids: a tag that does not resolve is left out rather than drawn, because it means the tag was deleted elsewhere or belongs to a vault this session cannot read, and a host with one chip fewer is the honest answer where a host wearing a GUID is not. The id stays on the host, so the chip comes back if the tag does. The picker is chips that toggle, matching the chips on the row behind it. A list of names to tick would make the user match an entry to a chip they can see two inches away. The box under it creates a tag and puts it on straight away, because that is when a tag is usually wanted — while tagging a host and finding it does not exist yet. Unlike every other field in that editor it writes to the keychain immediately, since a host can only name an id that exists; cancelling therefore leaves the tag behind, which is honest rather than hidden. A name that already exists is used rather than repeated: two tags called "staging" are storable and must stay storable, because two people creating one offline is how it happens, but typing it into a box beside a chip of the same name is a slip. Renaming and deleting needed a home, or the picker fills with names nobody uses and never empties. That home is a TAGS category on the keychain screen, where every other item kind is managed — and renaming is the whole reason a tag is an item rather than a string repeated inside twenty payloads: it is one write, and no host is touched. The delete confirmation counts the hosts wearing it, which is the difference between a tidy-up and losing a filter somebody relies on. The desktop host editor now scrolls, and that is not a tidy-up. A picker's height is a chip per tag in the keychain, wrapped, so somebody with fifteen tags has an editor half again as tall as somebody with three; no fixed height holds that, and trimming other fields to buy room only moves the failure to whoever has sixteen. The layout suite caught it the moment its seeder grew tags — which is why the seeder now creates ten rather than three, enough to drive the pane onto its cap so the capped shape is what gets measured rather than one no real keychain produces. The cost is named where it is paid: the harness skips anything inside a ScrollViewer, so from here it certifies that pane fits the column rather than that every field in it does. Two smaller things fell out. Five buttons overflowed the keychain header by a few pixels, so GENERATE lost the word KEY — its tooltip carries what the word did. And TotalItemCount had been counting keys and credentials while ALL showed four kinds; it counts all five now, because a number under a chip that disagrees with the rows it opens is worse than no number. An adversarial review of this change found two defects it had introduced, both green against the full suite. NewTag filed into the "new items go to" picker while the tag list only ever holds the active vault's — so with a team vault selected a tag would be created, queued for push, reported as added, and then invisible, with no row, no count, no picker entry and nothing able to rename or delete it, because there is no active-vault switcher to go and find it with. The comment on the host editor's own create path states that exact rule; this was the one place that broke it, and NewObjectStore, whose list is likewise active-vault-only, already ignored the picker. And the tag editor was the only one of five that did not disarm a pending deletion when it opened, so arming a key's deletion and then pressing + TAG left a live DELETE for an item the user was no longer looking at, directly above the boxes they were typing into. Both are fixed, both have a test, and the first was checked against the broken version before being kept. The same review caught a doc comment that had been inserted between SnippetRowViewModel's summary and its declaration, silently taking it over. Verified by the whole suite on a clean build: 1413 tests over nineteen projects, none failing. Both heads build. The rectangles the layout suite cannot reach are phase 9 of docs/manual-checks.md. Co-Authored-By: Claude Opus 5 --- docs/adding-hosts-on-the-phone.md | 15 +- docs/design-import-gaps.md | 8 +- docs/manual-checks.md | 45 ++ src/DodoSSH.Client.Android/Theme/Phone.axaml | 31 + .../Views/HostsScreen.axaml | 53 ++ .../Views/KeychainScreen.axaml | 14 + src/DodoSSH.Client.App/App.axaml | 30 + .../Views/HostSidebar.axaml | 84 ++- .../Views/VaultScreen.axaml | 45 +- .../ViewModels/VaultViewModel.cs | 645 +++++++++++++++++- .../ScreenLayoutTests.cs | 29 +- .../ShellFlowTests.cs | 254 +++++++ .../TeamSharingTests.cs | 35 + 13 files changed, 1253 insertions(+), 35 deletions(-) diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md index 42e8a89..ce1d417 100644 --- a/docs/adding-hosts-on-the-phone.md +++ b/docs/adding-hosts-on-the-phone.md @@ -16,13 +16,14 @@ ordered work, and the traps that are already known. > | 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 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. +> **Tags shipped after the six steps, in a seventh pass.** They were storable and unreachable when step 6 +> closed — a full item kind that no screen drew. Both heads now show a chip per tag on a host row and toggle +> them in the host editor, which also creates one inline, because wanting a tag and tagging a host are the +> same moment. Renaming and deleting live in a TAGS category on the keychain screen: a tag is an item so +> that renaming it is one write instead of twenty, and that rename needed somewhere to happen. +> +> That pass also made the desktop host editor scroll. A picker's height is a chip per tag in the keychain, +> so no fixed height holds it — which the layout suite caught the moment its seeder grew tags. > > **`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 diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 19be8da..336c364 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -53,13 +53,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > > **Buckets became a destination** rather than a toggle inside the files screen, matching the phone: the > `HOST` / `BUCKET` pair is gone and `ShellScreen.Buckets` draws the same `TransfersScreen` with the other -> picker. That also settles an old disagreement — `TotalItemCount` excludes buckets and used to disagree -> with the list under it, and now counts exactly what the keychain screen shows. +> picker. That also settled an old disagreement — `TotalItemCount` excluded buckets and disagreed with the +> list under it. It counts every kind now, tags included. > > | 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 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 host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. | > | 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. | @@ -205,7 +205,7 @@ 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 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. | +| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **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. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. | | 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 | 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. | diff --git a/docs/manual-checks.md b/docs/manual-checks.md index 30dc766..53bcd9e 100644 --- a/docs/manual-checks.md +++ b/docs/manual-checks.md @@ -789,3 +789,48 @@ Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and t **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. + +## Phase 9 — Tag chips and the picker + +The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one +host so the chip row that wraps is what gets laid out. Two things it still cannot answer, and the phone's +side of it remains unmeasurable for the reasons in phase 8. + +### 9.1 The desktop host editor scrolls rather than losing its buttons + +Open a host's editor with a keychain holding a dozen tags. + +**Pass:** the editor pane scrolls, and FORGET HOST KEY is reachable at the bottom of it. + +**Failure means:** the pane's MaxHeight is gone or the ScrollViewer is. The layout harness skips anything +inside a ScrollViewer, so from that commit on it certifies the pane fits rather than the fields — it will +tell you the pane is fine while the last button sits below the window. + +### 9.2 A chip toggles and reads as toggled + +In the picker, tap a tag on and off. + +**Pass:** worn is filled and accent-coloured, unworn is outlined. The difference is legible at a glance from +normal reading distance, not only side by side. + +### 9.3 A tag renamed on one machine renames every chip on the other + +Tag two hosts, sync, rename the tag on the other machine, sync back. + +**Pass:** both rows show the new name, and neither host shows as having unsynced changes — renaming a tag +rewrites no host. + +**Failure means:** the rename copied a name into the hosts, which is the thing making a tag an item was for. + +### 9.4 A deleted tag drops the chip and nothing else + +Delete a tag two hosts wear. + +**Pass:** the confirmation says how many hosts wear it. After confirming, both rows lose the chip and +nothing else about them changes. Neither host is queued for push. + +### 9.5 The phone's picker is usable with a thumb + +On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss +lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either +being squeezed to nothing. diff --git a/src/DodoSSH.Client.Android/Theme/Phone.axaml b/src/DodoSSH.Client.Android/Theme/Phone.axaml index e6ae763..d27fcd7 100644 --- a/src/DodoSSH.Client.Android/Theme/Phone.axaml +++ b/src/DodoSSH.Client.Android/Theme/Phone.axaml @@ -216,6 +216,37 @@ + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/HostSidebar.axaml b/src/DodoSSH.Client.App/Views/HostSidebar.axaml index cbce23f..dc17825 100644 --- a/src/DodoSSH.Client.App/Views/HostSidebar.axaml +++ b/src/DodoSSH.Client.App/Views/HostSidebar.axaml @@ -163,6 +163,28 @@ Foreground="{StaticResource TextFaint}" IsVisible="{Binding HasVaultBadge}" /> + + + + + + + + + + + + + + @@ -170,10 +192,27 @@ - + + @@ -230,6 +269,48 @@ + + + + + + + + + + + + + + + + + + + + +