Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it

This commit is contained in:
2026-08-07 15:26:00 +02:00
parent bea0279937
commit 06f9dcfc27
5 changed files with 1095 additions and 554 deletions
+168
View File
@@ -0,0 +1,168 @@
# Handoff: Hosts v5 design import — execution in progress
Written 2026-08-07, mid-execution, because the session hit its usage limit. A future session should
read this file plus `hosts-v5-design-spec.md` (same directory) and resume at "Where things stand".
## What this is
Implementing the Claude Design project screen `Hosts.dc.html`
(https://claude.ai/design/p/b20d580c-eae5-4cdd-bfae-88f669b5f8a3?file=Hosts.dc.html) in the desktop
Avalonia client. Worktree: `.claude/worktrees/connection-host-default-text-9e501d`, branch
`claude/hosts-design-implementation-22f161`. Nothing is committed yet — all progress is uncommitted
working-tree changes.
`hosts-v5-design-spec.md` holds the palette mapping table, the design's measurements, the monogram
hue pairs, and — critically — the **agreed deviations** from the mock (relay copy stays honest, dot
stays two-state, card click selects rather than connects, quick-connect keeps the auth word, find box
stays, "New group…" moves into the Group ▼ flyout, quick-access hint drops the "sidebar" claim).
## User-approved decisions (asked and answered, do not re-ask)
1. **Full re-theme** — shared palette swapped to purple/near-black; Montserrat + JetBrains Mono +
Material Icons embedded in the shared Shell project; Android recolors via the shared palette but
keeps its own default sans.
2. **Flat sections** — desktop hosts screen becomes one collapsible section per group (nesting
flattened phone-style, label order), "No group" first, Collapse All; group cards + breadcrumbs go.
Collapse state in-memory only.
3. **Pinned folders: full build**`HostSecret.PinnedPaths`, drawer QUICK ACCESS editor, pin-count
card badge, chip strip above the terminal; **clicking a pin opens the SFTP tab with the remote pane
at that path**.
4. **Last connected from the connection log**`max(StartedAt)` per `HostId` on hosts-screen
activation and on `SessionEnded`; relative time, restrung on a 1-minute tick; live sessions show
the green dot instead.
Execution method the user chose: **Sonnet subagents**, one wave at a time (waves 2+ serialize because
they all touch `src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs`, ~11.8k lines, shared with the
Android head).
## Where things stand
### Wave 1 — DONE and verified (full `dotnet build DodoSSH.slnx`: 0 errors)
**Domain (complete, 264/264 tests pass in `tests/DodoSSH.Client.Domain.Tests`):**
- New `src/DodoSSH.Client.Domain/PinnedPathList.cs` — immutable, insertion-ordered, ordinal-deduped
(keeps first occurrence), `Empty`, `Create(...)`, `ToPathMap()` for the merge.
- `HostSecret.PinnedPaths` (default `Empty`), constants `MaxPinnedPathLength = 1024`,
`MaxPinnedPaths = 32`, validation wired into `TryValidate` (blank/control-char/oversize refused;
relative paths allowed).
- `HostSecretMerge.MergePinnedPaths` — per-element three-way merge; result order = base, then local
additions, then remote additions.
- `HostSecretCodec`: `PinnedPathsSchemaVersion = 7` (now current), field written last and null when
empty; old payloads decode as pinning nothing.
- UI usage: add = `host with { PinnedPaths = PinnedPathList.Create([.. host.PinnedPaths, p]) }`;
remove via `Create(host.PinnedPaths.Where(...))`.
**Theme (complete):**
- `src/DodoSSH.Client.Shell/Theme/Palette.axaml` fully re-colored per the spec table. New resources:
`AccentGradient` (#8D51E7#5D42DE), `AccentGlow` (BoxShadows `0 0 22 0 #598D51E7`), `IconFont`
(Material Icons, reference glyphs BY CODEPOINT, e.g. push_pin ``). `AccentInk` is now
white. `Live` #34D399. Essay comments updated where the recolor falsified them.
- Fonts embedded at `src/DodoSSH.Client.Shell/Assets/Fonts/` (Montserrat 4 statics, JetBrains Mono 3,
MaterialIcons-Regular; licenses alongside; `<AvaloniaResource Include="Assets/Fonts/**"/>` added to
the Shell csproj). `Program.cs` default sans → `avares://DodoSSH.Client.Shell/Assets/Fonts#Montserrat`
with Inter fallback; `MonoFont` → JetBrains Mono first. `tests/DodoSSH.Client.App.Layout.Tests/HeadlessApp.cs`
updated to keep its documented font parity with `Program.BuildAvaloniaApp`.
- `App.axaml`: tile/section radius 12, buttons/inputs radius 10, chips 6; `Button.accent` uses
gradient + glow (glow zeroed on :disabled). Deliberately skipped: raising global control MinHeight
to 40 (too risky unverified — style the hosts screen explicitly instead).
- Known stale doc: `docs/design-import-gaps.md:304` still says Inter/system-mono — wave 5 fixes docs.
### Wave 2 — WAS RUNNING when the session ended: state UNKNOWN, verify before building on it
A Sonnet agent was mid-flight restructuring the hosts screen. **Its changes may be complete, partial,
or absent in the working tree.** Before anything else: `git status` + `git diff --stat`, look at
`src/DodoSSH.Client.App/Views/HostsScreen.axaml` and `VaultViewModel.cs`, build, and run
`tests/DodoSSH.Client.App.Layout.Tests` + `tests/DodoSSH.Client.App.Tests`. If its work is partial or
broken, either finish it by hand or reset those files and re-dispatch.
Wave 2's full brief (re-usable as a subagent prompt):
- Flat sections per approved decision 2. Reuse/generalize the phone's flattening
(`SidebarRows` / `SidebarGroupHeader` / `ToggleGroup` / in-memory `collapsedGroups` ~line 1251).
No-groups keychain renders headerless (documented invariant). Section heading:
`{label} · {count}`, vault name beside it when >1 vault, per-section collapse, "Collapse All" on
the first heading row. Group Edit/Move/Delete on a heading menu (that order; no "Open"). Group
cards, `GroupTrail`, double-click-open, drag-host-onto-group-card all go (desktop only — phone
must not regress; it shares the VM).
- PRESERVE: multi-select (marquee `Band` + chosen-hosts ops + the three action panels), keyboard
nav, context menus, conflict panel, `NoVisibleHostsMessage`, find box (only search that crosses
group boundaries).
- Toolbar: "Hosts" title + count chip; Group ▼ / Tag ▼ checkable filter flyouts ("New group…" at the
Group flyout's foot); + New host accent button. Host passes tag filter if it wears ANY checked tag;
empty sections hide; filters ∩ find ∩ collapse compose.
- New card: 32×32 monogram avatar (two letters, stable-hash into the spec's four hue pairs, JetBrains
Mono bold), name 17 bold + two-state dot, mono `user@hostname` line (full address stays on
tooltip), pin badge (`IconFont` &#xE946; + `HostRowViewModel.PinCount` reading
`Host.PinnedPaths.Count`) only when >0, tag chips bottom-left, `LastConnectedText`
`[ObservableProperty]` slot bottom-right (EMPTY — wave 4 fills it), keep warn Badge/VaultBadge/✓/
pencil→`OpenHostPaneCommand`. Click selects, double-click connects. Responsive card widths.
- Update layout + behavioral tests; add coverage for flattening order, no-groups invariant, collapse,
filter composition, monogram stability.
### Wave 3 — NOT STARTED: drawer + pinned folders UI
- Restyle `src/DodoSSH.Client.App/Views/HostDrawer.axaml` to the mock: width 304→320, section labels
ADDRESS / GENERAL / CONNECTION / QUICK ACCESS (10px semibold, letterspacing 1.2), "Saving to
{vault} vault" inline picker (same gating: creating only + >1 writable vault), address field first
(mono, accent inset border), Name/group/tags/notes, username + 78px port on one row, auth
ComboBox, relay checkbox card — **KEEP the honest copy including "(not wired up yet)"
(HostDrawer.axaml:516-535); do not adopt the mock's relay text**. Footer: Save host
(accent/gradient, flex) + Cancel (92px ghost).
- QUICK ACCESS editor: rows (folder glyph &#xE2C7; via IconFont, mono path, ✕ remove), mono add
field + add button, hint sentence exactly: "Pinned folders appear above the terminal for this
host." Editor state on `VaultViewModel` following the `editorTagIds` pattern; enforce
`HostSecret.MaxPinnedPaths`/`MaxPinnedPathLength` before `TryValidate`.
- Detail pane (non-editing) should also show pinned paths read-only (design only shows the editor;
match the pane's existing fieldrow idiom).
- Terminal pin strip: chip row above the terminal for the connected host's pins, visible only when
the active tab's host has pins. Clicking opens the SFTP tab with the remote pane navigated to that
path for THAT host — needs an "open SFTP at path for host X" entry point on the transfers side
(extend what the context menu's "Browse files" already does; see `TransfersViewModel`). SFTP is a
second authenticated connection (documented, fine). Tab→host matching today is by label
(`MainWindowViewModel.RefreshConnectedHosts` ~3179) — reuse that convention and its documented
tradeoff.
- Android: the field syncs; phone editor UI deferred (document in wave 5).
### Wave 4 — NOT STARTED: last connected + quick connect
- Last connected per approved decision 4. Read via `VaultSession.ConnectionLog`
(`ConnectionLogSecret`: `HostId?`, `StartedAt`; entries written once at close). Compute on hosts
screen activation + on `TerminalWorkspace.SessionEnded`; fill `HostRowViewModel.LastConnectedText`
("just now", "N min ago", "N hr ago", "N days ago"); 1-minute restring tick while the screen is
visible; NO background log re-read (the log is deliberately not on the sync loop —
`LogsViewModel.cs:138-143`). Precedent: `MainWindowViewModel.RecentConnections` ~1113.
- Quick connect restyle (`src/DodoSSH.Client.App/Views/QuickConnect.axaml` + `MainWindowViewModel`
~1576-1730): card 520→~640 wide, radius 14, mono prompt `>_` styling, row = 8px live dot
(`IsConnected` exists on the row VMs' hosts) + mono name + mono address + auth word right
(credential/key/password — NOT the mock's SSH/SFTP constant). Selected row fills Accent with white
text. Footer hint kept.
### Wave 5 — NOT STARTED: docs + full verification
- New **v5 section** in `docs/design-import-gaps.md`, same format as v2/v3/v4: what shipped, then a
table of mock elements refused/deviated (amber dot; relay copy; card-as-link; SSH/SFTP kind column;
collapse persistence — in-memory, settings.json stays two scalars by decision; phone pinned-folder
editor deferred; Collapse All placement; "sidebar" claim dropped from the quick-access hint). Fix
the stale font row at ~line 304. Note the v3→v5 reversal (sections replace breadcrumbs) and the
v4 reversal (chips + address back on the card) explicitly, with the user's approval as the reason.
- Full `dotnet build DodoSSH.slnx` + ALL test projects. LF check over every touched file.
## Conventions that bit or will bite
- **LF everywhere** — one CRLF file fails the build via IDE0055; merges/agents reintroduce it.
Verify by raw bytes, not shell grep (two agents got false readings from shell quoting).
- Single test: `dotnet test <proj> -- --filter-method '<name>'` — plain `--filter` is silently ignored.
- Essay-style comments are load-bearing; never leave one asserting something a change made false.
- `PublicAPI.Unshipped.txt` analyzer exists only for `DodoSSH.Contracts` (not touched so far).
- Don't run two `dotnet build`s concurrently (shared artifacts dir) — serialize agent builds.
- The repo's honesty rule: nothing in the UI claims what the code doesn't do. This killed the mock's
relay copy, amber dot, and SSH/SFTP kind column — keep applying it.
## Suggested resume sequence
1. `git status` / `git diff --stat`; assess wave 2's actual state (complete/partial/absent).
2. Finish or re-dispatch wave 2; build + layout & app tests green before proceeding.
3. Waves 3 → 4 sequentially (both touch `VaultViewModel`), each verified by build + tests.
4. Wave 5 (docs + full sweep). Then review the whole diff, and commit in reviewable slices
(suggested: domain / theme+fonts / hosts screen / drawer+pins / ago+palette / docs).
5. Delete `HANDOFF-hosts-v5.md` and `hosts-v5-design-spec.md` before the branch is merged — they are
working notes, not repo docs.
+130
View File
@@ -0,0 +1,130 @@
# Hosts v5 design import — spec and decisions
Source: Claude Design project `DodoSSH`, file `Hosts.dc.html` (1920x1080 desktop mock).
This file holds (a) the agreed decisions, (b) the palette mapping, (c) the raw design HTML at the bottom.
## Agreed decisions (user-approved)
1. **Full re-theme**: swap the shared palette (`src/DodoSSH.Client.Shell/Theme/Palette.axaml`) to the new
purple/near-black scheme — both heads recolor together. Embed Montserrat + JetBrains Mono +
Material Icons font files in the desktop head. Android keeps its default sans (fonts are per-head;
only colors are shared by decision); `MonoFont` gains JetBrains Mono at the front of its fallback list.
2. **Flat sections**: the desktop hosts screen becomes one collapsible section per group (nesting
flattened into one heading per group in label order, exactly like the phone's `SidebarRows`),
ungrouped hosts under "No group" first. Per-section collapse + Collapse All. Group cards and the
breadcrumb trail leave this screen. Group edit/move/delete move to a menu on the section heading.
Collapse state is in-memory only (settings.json stays two scalars by decision).
3. **Pinned folders (QUICK ACCESS)**: full feature. `HostSecret.PinnedPaths` (ordered strings,
per-path three-way merge like `TagIds`), drawer editor section, pin-count badge on cards, and a chip
strip above the terminal for the connected host; clicking a pin opens the SFTP tab with the remote
pane at that path (a second authenticated connection, as SFTP always is here).
4. **Last connected**: computed from the synced connection log (`max(StartedAt)` per `HostId`) on
hosts-screen activation and on `SessionEnded`; relative time ("just now" / "N min ago" / hours/days),
restrung on a 1-minute tick while visible, no background re-read.
## Deliberate deviations from the mock (do NOT implement the mock literally on these)
- **Relay checkbox copy keeps the honest wording** including "(not wired up yet)" — the design copy
implies a working relay; the client dial path does not exist yet. Keep the current sentence structure
in HostDrawer.axaml:516-535.
- **Status dot stays two states** (green = terminal open, grey = not). The design's amber has no
truthful meaning; nothing probes hosts.
- **Card click selects** (multi-select depends on it), double-click connects, pencil opens the detail
pane. The mock's card-as-link-to-terminal is not adopted.
- **Quick connect keeps the auth word** (credential/key/password) instead of the mock's SSH/SFTP kind
column — every palette connect is SSH, and a constant printed as a reading is refused in this repo.
- **Find box stays** (restyled) — it is the only search that crosses group boundaries.
- "New group…" moves to the foot of the Group ▼ filter flyout (the mock has no group-creation control).
- The QUICK ACCESS hint sentence ships as what is true: "Pinned folders appear above the terminal for
this host." (no "sidebar" claim).
## Palette mapping (design rgb → Palette.axaml key)
| Key | Old | New |
| --- | --- | --- |
| CanvasColor | #0E1220 | #05050A (design body rgb(5,5,10)) |
| Chrome | #111629 | #10111E (window rgb(16,17,30)) |
| Sidebar | #0F1426 | #0D0D16 (drawer rgb(13,13,22)) |
| Panel | #111629 | #10111E |
| Raised | #151B31 | #101019 (card rgb(16,16,25)) |
| Field | #0B0F1D | #101019 (inputs rgb(16,16,25)) |
| Chip | #16204A | #1A1A28 (rgb(26,26,40)) |
| Hover/Active/ChromeHover | — | derive slightly lighter than their surfaces, e.g. Hover #14151E, Active #1E1E2C, ChromeHover #1E1E2C |
| Border | #1A2138 | #1E1E2C (rgb(30,30,44)) |
| BorderMid | #232B45 | #24243A (rgb(36,36,58)) |
| BorderHover | #2E3859 | #2C2C3E (rgb(44,44,62)) |
| BorderSubtle/BorderFaint | — | keep one step darker than Border, e.g. #17172", use judgment |
| Text | #FFFFFF | #FFFFFF |
| TextDim | #B4BCD4 | #EDEDF5 (rgb(237,237,245)) |
| TextFaint | #7B849F | #9C9EB4 (rgb(156,158,180)) |
| TextGhost | #5A6280 | #7C7F98 (rgb(124,127,152)); the design also uses #6D6F84 and #5D5F74 for deeper steps — add keys only if needed |
| AccentColor | #5B8CFF | #5D42DE (rgb(93,66,222)) |
| AccentText | #8FA7FF | #A78BFA |
| AccentInk | #0E1220 | #FFFFFF (text on accent is white in this design) |
| AccentSoft/AccentWash | 0.35/0.06 | same opacities over #5D42DE |
| NEW: AccentGradient | — | LinearGradientBrush 180deg #8D51E7#5D42DE |
| NEW: AccentGlow | — | for BoxShadow use: 0 0 22 0 #8D51E7 at ~35% alpha |
| Live | #3DDC97 | #34D399 (rgb(52,211,153) — the design's own green) |
| Warn | #FFB454 | #F59E0B (rgb(245,158,11)); WarnText #F5B942 |
| Danger | #E86A6A | keep |
Radii: cards 12, controls 10, small chips 5-6. Field/control height 40. Primary buttons use
AccentGradient + glow.
Monogram avatar hue pairs (bg/fg), assigned by stable hash of host label:
violet #241C4F/#A78BFA · green #103A2F/#34D399 · amber #423211/#F5B942 · gray #1E1E2C/#9C9EB4.
## Raw design HTML (Hosts.dc.html)
The `<sc-for>`/`{{ }}` bits are the design tool's templating; `style-hover` is a hover style.
Sample data lives in the script block at the bottom.
```html
<div data-screen-label="Hosts" style="width:1920px;height:1080px;...background:rgb(16,17,30);...">
<dc-import name="TitleBar" on-search="{{ openQuickConnect }}" hint-size="100%,53px"></dc-import>
<div style="display:flex;flex-direction:row;flex-grow:1;min-height:0;">
<dc-import name="NavRail" active="hosts" mode="ssh" hint-size="255px,100%"></dc-import>
<div style="flex-grow:1;...padding:34px 36px;...">
<!-- Header row: title + count chip | Group ▼, Tag ▼, + New host -->
<span style="font-weight:700;font-size:33px;letter-spacing:-0.5px;color:#fff;">Hosts</span>
<div style="width:34px;height:30px;border-radius:9px;background:rgb(26,26,40);">10</div>
<!-- Group / Tag: h40, radius 10, inset 1px rgb(44,44,62) border, hover border accent -->
<!-- + New host: h40, radius 10, AccentGradient, glow, white 13.5 semibold -->
<!-- Section heading row (margin 32 0 16): "No group · 6" 12.5 medium rgb(124,127,152)
right side: collapse icon + "Collapse All" / "Collapse" 12 medium rgb(93,95,116) -->
<!-- Host grid: 3 columns, gap 14 -->
<!-- Host card: h120, radius 12, bg rgb(16,16,25), inset 1px rgb(30,30,44), hover border accent
- avatar 32x32 radius 9 at 21,20 with 2-letter mono bold 12.5 monogram
- name 17 bold white + 9px status dot, at 68,18
- addr mono 12.5 rgb(109,111,132) + pin badge (h19 radius 5 bg rgb(26,26,40),
push_pin icon 11 + count 10.5) at 68,39
- tag chips bottom-left (h21 radius 5 bg rgb(30,30,44), 11 medium rgb(156,158,180))
- ago text bottom-right 12 rgb(93,95,116) -->
</div>
<!-- Drawer (sc-if drawerOpen): w320, bg rgb(13,13,22), border-left rgb(26,26,40), padding 24 22
- "New host" 20 bold; subtitle "Saving to **DodoTech ▾** vault" 12.5
- section labels: 10px semibold letter-spacing 1.2 rgb(93,95,116): ADDRESS / GENERAL / CONNECTION / QUICK ACCESS
- ADDRESS: h42 radius 10 field with accent 1.5px inset border, ">_" prefix, mono value, blinking caret
- GENERAL: Name field h40; group dropdown ("No group" + expand_more); Add tag field + 40x40 add button; Notes h58
- CONNECTION: Username field + w78 port field ("22" mono) on one row; auth dropdown
("Password (ask each time)" + expand_more); relay checkbox card (radius 12, 18x18 checkbox,
title 13 semibold + body 11.5 rgb(124,127,152))
- QUICK ACCESS: per pin: h33 radius 9 row, folder icon + mono path 12.5 + close ✕;
add row: mono field "/path/to/folder" + 36x36 add button;
hint 11.5 rgb(93,95,116)
- footer: Save host (flex-grow, h44, AccentGradient + glow) + Cancel (w92 h44 ghost) -->
<!-- Quick connect (sc-if quickConnectOpen): full-window scrim rgba(5,5,10,0.6) + blur(3px),
card w640 radius 14 bg rgb(16,17,30) top-offset 160
- header row: ">_" accent mono + mono input "deploy@" + caret + "↵ connect · esc close"
- rows h44 radius 9: status dot 8px, name mono 13, addr mono 11.5, kind right
(selected row bg rgb(93,66,222), texts white) -->
</div>
</div>
```
Sample data (for tone, not to be shipped): hosts like `prod-api-01 / deploy@203.0.113.14 / pins 4 /
tags production,api / 2 min ago`; pinned = `/var/www/app`, `/etc/nginx`, `/var/log/pm2`.
+134 -503
View File
@@ -9,113 +9,75 @@ using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// The grid of groups and hosts, and everything on it that is a gesture rather than a binding.
/// The flat board of host cards, and everything on it that is a gesture rather than a binding.
/// </summary>
/// <remarks>
/// <para>
/// All of this was <c>HostSidebar</c>'s until the host list became a grid of cards. It moved with the list
/// rather than staying with the editor: every handler here is about the thing that was clicked, dragged or
/// right-clicked, and the drawer beside the grid has none of those. See <see cref="HostDrawer"/>.
/// ◆ <b>v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG.</b> The desktop
/// used to hold one level of the group tree at a time — a wrap of group cards above the hosts, opened by a
/// double-click, with a trail above it saying where you were — the way a directory pane holds one directory.
/// That model is retired outright rather than folded into the new board: every group is now a heading, in
/// label order, all of them at once, exactly the flattening the phone's <c>SidebarRows</c> has always drawn.
/// A host is filed under a group through the group's own picker in its editor, or through the "Change
/// group…" entry the chosen-hosts menu already carried — dragging a card onto another card had exactly one
/// destination, a group card, and that card is what left. See <c>VaultViewModel.HostSections</c>.
/// </para>
/// <para>
/// ◆ <b>There are two selections on this screen and they are not the same thing.</b> The <c>ListBox</c> holds
/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a <em>set</em>,
/// which is what Ctrl, Shift and a band dragged across the grid build. The set is the phone's, built here by
/// a pointer instead of by a long press; see <c>VaultViewModel.ChooseHosts</c> and Android's
/// <c>HostsScreen</c>. Everything below that reads a modifier is about keeping the two from being confused
/// for one another: a plain press drops the set, so no command ever runs while both are saying something
/// different.
/// ◆ <b>ONE SECTION, ONE <c>ListBox</c>.</b> The two shapes worth weighing were a single mixed list of
/// header rows and host rows in one <c>WrapPanel</c>, or an <c>ItemsControl</c> of sections each holding its
/// own card list. The first is what the phone's single flat list already is, and reusing it here would have
/// meant a header row pretending to be as wide as the row it sits on so the wrap panel breaks a line before
/// and after it — a real technique, and a fragile one to get right at every width this window can be resized
/// to. The second costs a real thing: there is no longer one <c>ListBox</c> owning one <c>SelectedItem</c>,
/// so multi-select and the marquee have to be taught to reach across however many section lists are on
/// screen. That is what most of this file now does. It was chosen anyway, because a <c>WrapPanel</c> that
/// only ever holds cards of one kind is the ordinary, well-trodden case, and "reach across N lists instead
/// of one" is a bounded, mechanical problem — enumerate every list, not the fixed one named in the markup —
/// where "make a wrap panel break a line for one specific child" is a harder one to be sure of without a
/// custom panel this application does not otherwise need.
/// </para>
/// <para>
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is, so every binding in the
/// markup is a property of the vault. The window hands it over; see <see cref="MainWindow"/>. The drawer
/// beside the grid inherits the same one.
/// ◆ <b>SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT.</b> Each section's
/// <c>ListBox</c> binds <c>SelectedItem</c> from <c>VaultViewModel.SelectedSidebarRow</c> so the right card
/// lights up wherever it lives, but not back — a two-way binding shared by several independently-rebuilt
/// lists would have each list's own <c>Reset</c> (one per filter keystroke, one per background sync) racing
/// to null the one shared property, which is the same hazard <c>VaultViewModel.RebuildVisibleHosts</c>
/// already documents for a single list and multiplies it by the section count. So a card being pressed is
/// what actually moves the selection, here in code, exactly as a Ctrl or Shift modifier already had to be —
/// see <see cref="OnPointerPressed"/>.
/// </para>
/// <para>
/// ◆ <b>THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.</b>
/// <c>SelectedHost</c> is the card the drawer, CONNECT and the ordinary menu are about; the chosen set is
/// what Ctrl, Shift and the band build on top of it, as <c>IsChosen</c> on the row. Every handler below that
/// reads a modifier is about keeping the two from being confused for one another, on the same terms this
/// screen has always used — see <c>VaultViewModel.ChooseHosts</c> and Android's <c>HostsScreen</c>.
/// </para>
/// <para>
/// Its data context is the <c>VaultViewModel</c>, as <see cref="KeychainScreen"/>'s is. The window hands it
/// over; see <see cref="MainWindow"/>.
/// </para>
/// </remarks>
internal sealed partial class HostsScreen : UserControl
{
/// <summary>
/// How hosts travel from the cards they were picked up on to the group card they are dropped on.
/// </summary>
/// <summary>How far the pointer has to travel before a press on the empty space becomes a band.</summary>
/// <remarks>
/// <para>
/// An in-process format carrying the rows themselves, rather than text carrying ids. The drag never
/// leaves this window — there is nothing outside it that could accept a host — and the rows are what the
/// drop needs: they know which vault each edit has to return to, which an id on its own does not.
/// </para>
/// <para>
/// A list rather than one row, because a drag that starts on a ticked card carries every ticked card.
/// Moving whichever one the pointer happened to be holding and leaving the other five where they are is
/// a gesture that quietly does a fraction of what it looks like it does.
/// </para>
/// </remarks>
private static readonly DataFormat<IReadOnlyList<HostRowViewModel>> HostFormat =
DataFormat.CreateInProcessFormat<IReadOnlyList<HostRowViewModel>>("dodossh-host-rows");
/// <summary>How far the pointer has to travel before a press becomes a drag.</summary>
/// <remarks>
/// A threshold, because a press on this grid is nearly always a click: selecting a host, or the first
/// half of the double-click that connects. Starting a drag on the press itself would turn every one of
/// those into a drag gesture the user never asked for. The band on the empty space between the cards
/// uses it for the same reason — a click there means "nothing is chosen any more", and a rectangle that
/// flashed up on every one of those would be a gesture reported where none happened.
/// A threshold, because a press on the space around the cards is nearly always a click that drops the
/// chosen set — see <see cref="OnBandPressed"/>. Starting a band on the press itself would turn every one
/// of those into a rectangle nobody asked for.
/// </remarks>
private const double DragThreshold = 5;
/// <summary>How close to the top or bottom of the grid a drag has to be held to scroll it.</summary>
/// <remarks>
/// Deeper than a card's own margin, because the band has to be reachable while the pointer is still
/// carrying something the user is looking at — a band the width of a hairline would only be found by
/// accident, and only by somebody who did not need it.
/// </remarks>
private const double EdgeBand = 48;
/// <summary>How far one drag event inside that band moves the grid.</summary>
/// <remarks>
/// Roughly a third of a card, so a pointer moving inside the band travels the grid at about the speed it
/// is moving. A step of a whole card would jump the target out from under the pointer between two events.
/// </remarks>
private const double EdgeStep = 24;
/// <summary>The press a drag would start from, or null once it has become one or been let go of.</summary>
/// <remarks>
/// Held because <see cref="DragDrop.DoDragDropAsync"/> takes the press rather than the movement: the
/// gesture belongs to the pointer that went down, and the platform needs that event to hand the drag
/// over to the operating system.
/// </remarks>
private PointerPressedEventArgs? press;
/// <summary>What that press would carry: the ticked hosts, or the one card it landed on.</summary>
private IReadOnlyList<HostRowViewModel> pickedUp = [];
private Point origin;
/// <summary>The group card the pointer is currently over, while a drag is in flight.</summary>
private ListBoxItem? marked;
/// <summary>
/// Where a Shift-click measures its run from: the last card pressed without one.
/// </summary>
/// <summary>Where a Shift-click measures its run from: the last card pressed without one.</summary>
/// <remarks>
/// Kept here rather than in the vault because it is a fact about the gesture rather than about the
/// keychain — it is what the pointer last touched, and it means nothing to the phone or to any command.
/// Null until something has been pressed, which is what a Shift-click into an untouched grid falls back
/// Null until something has been pressed, which is what a Shift-click into an untouched board falls back
/// to the selected card for.
/// </remarks>
private HostRowViewModel? anchor;
/// <summary>
/// The ticked card a plain press landed on, which the release collapses the set onto.
/// </summary>
/// <remarks>
/// ◆ <b>A plain press on a card that is already ticked cannot drop the set, because it may be the start
/// of a drag of all of it.</b> So the decision is deferred to the release: if no drag began, the press
/// was an ordinary click and means what a click always means here — this one card, and nothing else. If
/// one did, <see cref="Forget"/> clears this on the way out and the set survives the journey.
/// </remarks>
private HostRowViewModel? collapse;
/// <summary>Whether a band is being dragged out over the grid right now.</summary>
/// <summary>Whether a band is being dragged out over the board right now.</summary>
private bool banding;
/// <summary>The corner it was started from, in the scroller's own coordinates.</summary>
@@ -133,53 +95,17 @@ internal sealed partial class HostsScreen : UserControl
InitializeComponent();
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
// every other client of this kind does, and CONNECT stays: it is the one in the drawer with the
// password box above it, and a host that asks for a password still needs it typed first.
HostGrid.DoubleTapped += OnHostActivated;
// the transfers screen opens a directory too. Attached to the board rather than to any one section's
// list, since double-tapping a card in any of them means the same thing and DoubleTapped bubbles.
Board.DoubleTapped += OnHostActivated;
// And a group card opens the group, on the same gesture, for the same reason: going inside
// something by double-clicking it is what the transfers screen's directories do and what every file
// manager does. One click used to open a group, which made the card that names a group and the
// control that narrows the grid to it the same press — so there was no way to select a group in
// order to rename it without also losing sight of every host outside it.
GroupGrid.DoubleTapped += OnGroupActivated;
// Tunnelled, so the card under the pointer is read before the ListBox has answered the press itself.
// Bubbling would work for the drag but not for the menu: by then the control has already decided
// what is selected, and the menu is about to open against it. It is also what lets a modifier click
// stop the press dead — see OnPointerPressed, where a Ctrl-click must tick a card without the list
// moving its own selection onto it.
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
// And the group cards answer a right click the same way, for the same reason: their menu's commands
// read the vault's group selection, and without this they would act on whichever card was selected
// before — or, with none, on the group the trail ends with, which is not on screen at all.
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
HostGrid.PointerMoved += OnPointerMoved;
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
// Tunnelled, so the card under the pointer is read before whichever section's ListBox has answered
// the press itself. Bubbling would work for a plain click but not for the menu or for Ctrl/Shift: by
// the time either reaches this control the list has already decided what it thinks is selected.
Board.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
WireTheBand();
// The host grid is where a drag starts and the group cards are where it lands. They used to be the
// same control: the target was a heading among the cards, and with the headings gone the group cards
// are the only thing on this screen that names a group. A card dropped onto another card is refused
// rather than filed beside it — in a grid with no headings there is nothing to say which group that
// would be, and a gesture whose result you cannot see before you let go is one that files machines
// somewhere the user did not intend.
DragDrop.AddDragOverHandler(GroupGrid, OnDragOver);
DragDrop.AddDragLeaveHandler(GroupGrid, OnDragLeave);
DragDrop.AddDropHandler(GroupGrid, OnDrop);
// Everywhere else the pointer can be during a drag, and it is a handler rather than the absence of
// one because AllowDrop is an inherited property: it is set on the scroller so that a drag anywhere
// over the grid is reported at all, and that makes every card inside it a drop target as far as the
// platform is concerned. This is where all of them but a group card are turned down — and where a
// drag held at the top or bottom edge pulls the grid towards the target.
DragDrop.AddDragOverHandler(Scroll, OnDragOverScroll);
}
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
@@ -190,17 +116,17 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private void WireTheBand()
{
// Esc and Ctrl+A, on the grid rather than on the window: both are ordinary editing keys that mean
// Esc and Ctrl+A, on the board rather than on the window: both are ordinary editing keys that mean
// something else everywhere else, and Ctrl+A in the find box above has to go on selecting the text
// in it. The grid takes focus on a press, so the keys work from the moment anything has been
// in it. The board takes focus on a press, so the keys work from the moment anything has been
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
HostGrid.KeyDown += OnGridKey;
Board.KeyDown += OnGridKey;
// ◆ The band is the scroller's rather than the list's, because the space it is dragged out over is
// mostly not the list's: a WrapPanel of cards is exactly as tall as its cards, so everything below
// the last row — usually most of the screen — belongs to the stack around it. Tunnelled for the
// reason the list's own press is, and it runs first, so it has to recognise a press on a card and
// leave it alone.
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
// the board's own press is, and it runs first, so it has to recognise a press on a card and leave it
// alone.
Scroll.AddHandler(PointerPressedEvent, OnBandPressed, RoutingStrategies.Tunnel);
Scroll.PointerMoved += OnBandMoved;
Scroll.PointerReleased += OnBandReleased;
@@ -224,15 +150,13 @@ internal sealed partial class HostsScreen : UserControl
/// <para>
/// It has to be a control the keyboard can actually go to. <c>Focus()</c> on a collapsed control is
/// measurably a no-op and is not replayed when the control is revealed, so handing the keyboard to
/// something that is not there would swallow it: the terminal would let go and nothing would take it.
/// The grid no longer folds away as the sidebar's list could, but an empty grid is still a
/// <c>ListBox</c> with no item to take focus — and an empty grid is exactly what a filter that matches
/// nothing produces, which is a state somebody typing is very likely to be in. The find box is the
/// answer then, and it is a good one: it is where they were typing.
/// something that is not there would swallow it. <c>Board</c> is focusable and holds every section, so it
/// answers as long as one card is on it; an empty board is what a filter matching nothing produces, and
/// the find box is the answer then — it is where they were typing.
/// </para>
/// </remarks>
internal IInputElement KeyboardTarget =>
Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
/// <remarks>
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
@@ -241,31 +165,15 @@ internal sealed partial class HostsScreen : UserControl
/// </remarks>
private void OnHostActivated(object? sender, TappedEventArgs e)
{
// Only over a card. A double-tap on the space around them must not connect to whichever host was
// selected before — which is what an unguarded handler would do, on a machine the user is not even
// pointing at.
// Only over a card. A double-tap on the space around them, or on a section heading, must not connect
// to whichever host was selected before — which is what an unguarded handler would do, on a machine
// the user is not even pointing at.
if (Vault is { } vault && RowUnder(e.Source) is HostRowViewModel)
{
_ = vault.ConnectCommand.ExecuteAsync(null);
}
}
/// <summary>
/// Opens the group card that was double-clicked.
/// </summary>
/// <remarks>
/// Guarded over the space around the cards exactly as the host grid's is, and it is the same mistake
/// being guarded against: an unguarded handler would open whichever group happened to be selected when
/// somebody double-clicked the gap beside it, throwing every host outside that group off the screen.
/// </remarks>
private void OnGroupActivated(object? sender, TappedEventArgs e)
{
if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
{
vault.OpenGroupCommand.Execute(group);
}
}
/// <summary>
/// Points the menu at whatever was right-clicked.
/// </summary>
@@ -283,8 +191,9 @@ internal sealed partial class HostsScreen : UserControl
/// behind the menu.
/// </para>
/// <para>
/// Cancelled outright over the space around the cards. That is not a host, and a menu offering Connect,
/// Edit and Delete over it would be entries that either do nothing or act on something else entirely.
/// Cancelled outright over the space around the cards, or on a section heading. Neither is a host, and a
/// menu offering Connect, Edit and Delete over either would be entries that either do nothing or act on
/// something else entirely.
/// </para>
/// </remarks>
private void OnContextRequested(object? sender, ContextRequestedEventArgs e)
@@ -302,68 +211,39 @@ internal sealed partial class HostsScreen : UserControl
if (!vault.IsChoosingHosts)
{
vault.SelectedSidebarRow = row;
vault.SelectedHost = row;
}
}
/// <summary>
/// Points the group menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
/// them now, so aiming it is the whole of aiming them.
/// </para>
/// <para>
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
/// the fallback is exactly what would make the menu look like it worked there.
/// </para>
/// </remarks>
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
{
e.Handled = true;
return;
}
vault.SelectedGroup = row;
}
/// <summary>
/// Ticks cards, or remembers a press that may become a drag.
/// Ticks cards, or moves the selection.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Ctrl and Shift are answered here and go no further.</b> Both mark the event handled, which is
/// what stops the <c>ListBox</c> underneath from moving its own selection onto the card: a Ctrl-click
/// that also selected would light the card it had just unticked, and the drawer would open on a machine
/// the user was removing from a set. Handled on the way down is the only place that can be said —
/// by the time the press bubbles the control has already answered it.
/// what stops whichever section's <c>ListBox</c> the press landed in from moving its own selection onto
/// the card: a Ctrl-click that also selected would light the card it had just unticked, and the drawer
/// would open on a machine the user was removing from a set. Handled on the way down is the only place
/// that can be said — by the time the press bubbles the control has already answered it.
/// </para>
/// <para>
/// <b>A plain press drops the set</b>, unless it lands on a card already in it. That is the rule every
/// file manager has and the reason the two selections on this screen can never disagree: after an
/// ordinary click there is exactly one card in play. Landing on a ticked card defers the same decision
/// to the release, because the press may be the start of a drag of the whole set; see
/// <see cref="collapse"/>.
/// <b>A plain press moves the selection and drops the chosen set</b>, which is every file manager's rule
/// and the reason the two selections on this screen can never disagree: after an ordinary click there is
/// exactly one card in play. Not marked handled — the section list's own native selection is left to
/// follow along, which is harmless now that it is only <c>OneWay</c> bound: whatever it shows locally is
/// overwritten the moment <see cref="VaultViewModel.SelectedHost"/>'s change reaches every section again.
/// </para>
/// <para>
/// Nothing here refuses while the drawer's editor is open, and the phone's own <c>ChooseHost</c> does.
/// The difference is real: there a sheet is over the list and the row under the finger is not what was
/// aimed at, where the grid sits beside the editor in plain view. Ticking is free anyway — the seven
/// aimed at, where the board sits beside the editor in plain view. Ticking is free anyway — the seven
/// things that can then be done to a set each refuse for themselves, and say so.
/// </para>
/// </remarks>
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
{
Forget();
if (Vault is not { } vault
|| !e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed
|| !e.GetCurrentPoint(Board).Properties.IsLeftButtonPressed
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
@@ -375,7 +255,7 @@ internal sealed partial class HostsScreen : UserControl
{
vault.ChooseHostRun(anchor ?? vault.SelectedHost, row, replacing: !control);
HostGrid.Focus();
Board.Focus();
e.Handled = true;
return;
@@ -386,7 +266,7 @@ internal sealed partial class HostsScreen : UserControl
vault.ToggleHostChoiceCommand.Execute(row);
anchor = row;
HostGrid.Focus();
Board.Focus();
e.Handled = true;
return;
@@ -394,82 +274,23 @@ internal sealed partial class HostsScreen : UserControl
anchor = row;
// Read before anything is cleared, since clearing is one of the two things it decides.
if (row.IsChosen)
{
collapse = row;
pickedUp = vault.ChosenHosts;
}
else
{
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
pickedUp = [row];
vault.SelectedHost = row;
}
press = e;
origin = e.GetPosition(HostGrid);
}
/// <remarks>
/// The drag is started from the remembered press once the pointer has travelled far enough — see
/// <see cref="DragThreshold"/>. Fire-and-forget, because the drag loop runs for as long as the user holds
/// the button and an event handler cannot wait on that; what happens after it is only clearing the mark.
/// </remarks>
private void OnPointerMoved(object? sender, PointerEventArgs e)
{
if (press is not { } pressed || pickedUp.Count == 0)
{
return;
}
if (!e.GetCurrentPoint(HostGrid).Properties.IsLeftButtonPressed)
{
Forget();
return;
}
var moved = e.GetPosition(HostGrid) - origin;
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
{
return;
}
var carried = pickedUp;
Forget();
_ = DragAsync(pressed, carried);
}
/// <remarks>
/// Where a press on a ticked card turns out to have been a click after all: no drag started, so it means
/// what every other click on a card means. See <see cref="collapse"/>.
/// </remarks>
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
{
if (collapse is not null && Vault is { } vault)
{
vault.ClearHostChoiceCommand.Execute(null);
}
Forget();
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
/// <summary>
/// Esc drops the set, and Ctrl+A is every card on the screen.
/// Esc drops the set, and Ctrl+A is every card on the board.
/// </summary>
/// <remarks>
/// <para>
/// Every card <em>on the screen</em>: <c>VisibleHosts</c>, which is one level of the tree with whatever
/// is in the find box already applied. Ctrl+A over a filtered grid that quietly ticked forty machines
/// including the thirty-two not being shown would be the worst possible input to Delete.
/// Every card <em>on the board</em>: <see cref="VaultViewModel.HostBoardOrder"/>, which is every section
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every card a
/// fold has not hidden. Ctrl+A over a filtered board that quietly ticked machines it is not showing would
/// be the worst possible input to Delete.
/// </para>
/// <para>
/// Handled only when they did something. Esc has other jobs on this window — it closes the palette, and
@@ -492,10 +313,12 @@ internal sealed partial class HostsScreen : UserControl
return;
}
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasVisibleHosts)
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{
vault.ChooseHosts(vault.VisibleHosts, replacing: true);
anchor = vault.VisibleHosts[0];
var order = vault.HostBoardOrder.ToList();
vault.ChooseHosts(order, replacing: true);
anchor = order[0];
e.Handled = true;
}
}
@@ -505,16 +328,16 @@ internal sealed partial class HostsScreen : UserControl
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Only below the top of the host grid, and only where there is nothing to press.</b> The scroller
/// also holds the group cards, the trail and whichever panel the set's menu raised, and a rectangle
/// dragged out from inside any of those would be a gesture aimed at hosts started on something that is
/// not one. The ceiling is the grid's own top edge rather than a list of exclusions, so a panel opening
/// above it moves the ceiling with no code here changing.
/// ◆ <b>Only where there is nothing to press.</b> The scroller holds every section heading, every card
/// list and whichever panel the set's menu raised, and a rectangle dragged out from inside any of those
/// would be a gesture aimed at hosts started on something that is not one. <see cref="TakesThePress"/> is
/// the guard, and it is ancestor-based rather than naming one control, which is what lets it work
/// regardless of how many section lists happen to be realized.
/// </para>
/// <para>
/// The pointer is captured, because a band is nearly always dragged past the edge of the control it
/// started in — down onto the status bar, or off the window entirely — and without capture the moves
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the grid with
/// stop arriving and the release lands somewhere else, leaving a rectangle painted over the board with
/// nothing left to take it down.
/// </para>
/// <para>
@@ -533,19 +356,12 @@ internal sealed partial class HostsScreen : UserControl
return;
}
var at = e.GetPosition(Scroll);
if (at.Y < (HostGrid.TranslatePoint(default, Scroll)?.Y ?? double.MaxValue))
{
return;
}
banding = true;
bandFrom = at;
bandFrom = e.GetPosition(Scroll);
bandAdds = e.KeyModifiers.HasFlag(KeyModifiers.Control);
e.Pointer.Capture(Scroll);
HostGrid.Focus();
Board.Focus();
}
/// <remarks>
@@ -628,15 +444,16 @@ internal sealed partial class HostsScreen : UserControl
/// <summary>The hosts whose cards a rectangle over the scroller touches.</summary>
/// <remarks>
/// Touches rather than contains, which is what makes a band usable at all: cards are 232 pixels wide and
/// a rectangle that had to swallow one whole would mean dragging across the full width of every card in
/// the run, and would tick nothing at all for a band drawn down the middle of a column.
/// Touches rather than contains, which is what makes a band usable at all: a rectangle that had to
/// swallow a card whole would tick nothing for a band drawn down the middle of a column. Gathered from
/// every section's own list — see <see cref="AllCardContainers"/> — rather than one named control, since
/// a band drawn across two sections has to touch cards in both.
/// </remarks>
private List<HostRowViewModel> CardsIn(Rect box)
{
var hit = new List<HostRowViewModel>();
foreach (var container in HostGrid.GetRealizedContainers())
foreach (var container in AllCardContainers())
{
if (container.DataContext is HostRowViewModel row
&& container.TranslatePoint(default, Scroll) is { } corner
@@ -649,227 +466,41 @@ internal sealed partial class HostsScreen : UserControl
return hit;
}
/// <summary>Every realized card, across every section's own list.</summary>
/// <remarks>
/// A section is a <c>ListBox</c> of its own — see the type's remarks — so there is no single control
/// whose <c>GetRealizedContainers()</c> would answer for the whole board. Found by class rather than by
/// name, because how many sections exist is a fact about the keychain rather than the markup: a flat
/// keychain draws one, a filed one draws several, and neither shape is known until the vault says so.
/// </remarks>
private List<ListBoxItem> AllCardContainers() =>
[.. this.GetVisualDescendants()
.OfType<ListBox>()
.Where(list => list.Classes.Contains("sectioncards"))
.SelectMany(list => list.GetRealizedContainers())
.OfType<ListBoxItem>()];
/// <summary>Whether what was pressed is something that answers a press itself.</summary>
/// <remarks>
/// Everything a band must not start from, asked as "is this inside one" rather than by hit-testing a
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up. Missing the scrollbar
/// is the one that would be felt every day — dragging the thumb would paint a band down the grid and
/// tick everything it passed.
/// rectangle: the cards, the scrollbar, and the controls on whichever panel is up or in a section's own
/// heading. Missing the scrollbar is the one that would be felt every day — dragging the thumb would
/// paint a band down the board and tick everything it passed.
/// </remarks>
private static bool TakesThePress(object? source) => source is Visual visual
&& visual.GetSelfAndVisualAncestors().Any(element =>
element is ListBoxItem or ScrollBar or Button or ComboBox or TextBox);
/// <summary>Carries the picked-up hosts for as long as the user holds them.</summary>
private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList<HostRowViewModel> rows)
{
var carried = new DataTransfer();
carried.Add(DataTransferItem.Create(HostFormat, rows));
try
{
// ConfigureAwait(true): what follows touches the grid's own containers, and those are the UI
// thread's.
await DragDrop
.DoDragDropAsync(pressed, carried, DragDropEffects.Move)
.ConfigureAwait(true);
}
finally
{
// Whatever the drop did or did not do. A mark left behind would be a card that looks like a
// target for a drag that ended somewhere else entirely.
Unmark();
}
}
/// <summary>
/// Says whether what is under the pointer would take these hosts, and marks it if it would.
/// </summary>
/// <remarks>
/// A drag whose hosts are all already in the group under the pointer is refused, which is not pedantry:
/// <c>DragDropEffects.None</c> is what turns the cursor into the "no" one, and a drag that looks like it
/// would do something and then does nothing is worse than one that says so while it is still in the air.
/// A set with even one host from somewhere else is accepted, because filing that one is a real move.
/// </remarks>
private void OnDragOver(object? sender, DragEventArgs e)
{
e.Handled = true;
if (Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
Unmark();
return;
}
e.DragEffects = DragDropEffects.Move;
Mark(target.Container);
}
private void OnDragLeave(object? sender, DragEventArgs e) => Unmark();
/// <summary>
/// Carries the grid under a drag that is over the cards rather than over a group.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this the gesture is only available to whoever can see both ends of it.</b> The group cards
/// are the first thing in the scrolling stack and the host being filed may be the fortieth card down, and
/// a drag cannot use the wheel — the pointer button is held. So a drag held near the top edge pulls the
/// grid down towards the target, which is what every file manager does with a drag near the edge of a
/// list.
/// </para>
/// <para>
/// A step per event rather than a timer, and that is a real limit rather than a simplification: a
/// stationary pointer receives no drag events on any platform this runs on, so the scroll follows the
/// pointer moving inside the band and stops when it stops. A timer would scroll on its own and would then
/// need cancelling on the drop, on the leave, and on the drag that ends outside the window entirely.
/// </para>
/// <para>
/// Reached only when the group cards did not handle the event first, which is what makes refusing the
/// drop here correct: the space around the cards is not a target, and saying so keeps the "no" cursor on
/// everything that is not a group.
/// </para>
/// </remarks>
private void OnDragOverScroll(object? sender, DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is null)
{
return;
}
e.Handled = true;
e.DragEffects = DragDropEffects.None;
Unmark();
var at = e.GetPosition(Scroll).Y;
var height = Scroll.Bounds.Height;
var step = at switch
{
_ when at < EdgeBand => -EdgeStep,
_ when at > height - EdgeBand => EdgeStep,
_ => 0,
};
if (step == 0)
{
return;
}
var furthest = Math.Max(0, Scroll.Extent.Height - Scroll.Viewport.Height);
Scroll.Offset = Scroll.Offset.WithY(Math.Clamp(Scroll.Offset.Y + step, 0, furthest));
}
/// <remarks>
/// <para>
/// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
/// <c>MoveHostToGroup</c>, which has always been what a drag does and which keeps that host selected. A
/// set goes through <c>FileChosenHostsUnder</c>, which makes the refusals once rather than per host —
/// a group belongs to one vault — and reports a count rather than forty status lines in a row.
/// </para>
/// <para>
/// Fire-and-forget, like every other command this control runs: the move writes to the vault and reports
/// itself onto the status line, and a drop handler that awaited it would be an event handler returning a
/// task nothing observes.
/// </para>
/// </remarks>
private void OnDrop(object? sender, DragEventArgs e)
{
e.Handled = true;
Unmark();
if (Vault is not { } vault || Target(e) is not { } target)
{
e.DragEffects = DragDropEffects.None;
return;
}
e.DragEffects = DragDropEffects.Move;
if (target.Hosts.Count > 1)
{
vault.FileChosenHostsUnderCommand.Execute(target.Group);
return;
}
vault.MoveHostToGroupCommand.Execute(new HostGroupMove(target.Hosts[0], target.Group.EntityId));
}
/// <summary>
/// Where a drag currently is, or null if it is over nothing that would take it.
/// </summary>
/// <remarks>
/// <para>
/// One kind of target: a group card. It is the control that already answers "which group", it is drawn
/// at the top of the screen where a drag can reach it from anywhere in the grid, and what it does when
/// dropped on is what it says on it. The space around the cards takes nothing.
/// </para>
/// <para>
/// A group the vault no longer has is read as no group at all, which is what the list already does with
/// a dangling reference — see <c>VaultViewModel.RebuildSidebarRows</c>. That is decided in the command
/// rather than here, so the rule has one home.
/// </para>
/// </remarks>
private static DropTarget? Target(DragEventArgs e)
{
if (e.DataTransfer.TryGetValue(HostFormat) is not { Count: > 0 } dragged
|| Container(e.Source) is not { DataContext: HostGroupRowViewModel group } container
|| dragged.All(row => row.Host.GroupId == group.EntityId))
{
return null;
}
return new DropTarget(dragged, group, container);
}
private void Mark(ListBoxItem container)
{
if (ReferenceEquals(marked, container))
{
return;
}
Unmark();
marked = container;
marked.Classes.Add("droptarget");
}
private void Unmark()
{
marked?.Classes.Remove("droptarget");
marked = null;
}
/// <summary>Lets go of a press that turned out not to be a drag, or has become one.</summary>
private void Forget()
{
press = null;
pickedUp = [];
collapse = null;
}
/// <summary>The view model of the grid item an event happened on, if it happened on one.</summary>
private static object? RowUnder(object? source) => Container(source)?.DataContext;
/// <summary>The view model of the grid item an event happened on, if it happened on a host card.</summary>
private static HostRowViewModel? RowUnder(object? source) =>
Container(source)?.DataContext as HostRowViewModel;
/// <remarks>
/// Walks up from whatever was actually hit — a text block, a border, the card's own grid — because that
/// is what an event's source is. Anything not inside an item, which is the space around the cards,
/// yields null.
/// is what an event's source is. Anything not inside a card, which includes a section's own heading and
/// the space around them, yields null.
/// </remarks>
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType<ListBoxItem>(includeSelf: true)
: null;
/// <summary>A drag in flight, and where it would land.</summary>
/// <remarks>
/// The group is the card rather than its id, which it was while only one host could be dragged: the
/// command that files a set takes the card, because the refusal it makes is about which vault the group
/// is in. The way out of a group is still the host's own editor, which is the one place "no group" can be
/// said in words.
/// </remarks>
private sealed record DropTarget(
IReadOnlyList<HostRowViewModel> Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
}
@@ -0,0 +1,54 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Turns a host card's <c>MonogramHue</c> string into the background or foreground brush the mock pairs it
/// with.
/// </summary>
/// <remarks>
/// <para>
/// The four pairs are the design's own and are not in <c>Palette.axaml</c>: that file is another wave's, and
/// none of the shared palette's existing keys is this hue ramp — a monogram's colour is a property of the
/// four-way hash in <c>HostRowViewModel.MonogramHue</c>, not a fact the rest of the theme has any use for.
/// Held here, beside the one screen that reads it, rather than invented as four new shared resources for a
/// single card.
/// </para>
/// <para>
/// Two instances rather than one converter taking a "which half" parameter, so the markup names each with a
/// <c>StaticResource</c>-shaped reference instead of a converter parameter that means nothing without
/// reading this file.
/// </para>
/// </remarks>
internal sealed class MonogramBrushConverter(bool background) : IValueConverter
{
/// <summary>The fill behind the two letters.</summary>
internal static MonogramBrushConverter Background { get; } = new(background: true);
/// <summary>The colour of the two letters themselves.</summary>
internal static MonogramBrushConverter Foreground { get; } = new(background: false);
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram.</summary>
private static readonly Dictionary<string, (string Background, string Foreground)> Pairs = new(
StringComparer.Ordinal)
{
["violet"] = ("#241C4F", "#A78BFA"),
["green"] = ("#103A2F", "#34D399"),
["amber"] = ("#423211", "#F5B942"),
["gray"] = ("#1E1E2C", "#9C9EB4"),
};
/// <inheritdoc />
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var pair = value is string hue && Pairs.TryGetValue(hue, out var found) ? found : Pairs["gray"];
return new SolidColorBrush(Color.Parse(background ? pair.Background : pair.Foreground));
}
/// <inheritdoc />
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException("The monogram hue is read-only.");
}
@@ -57,6 +57,21 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
/// <summary>What the desktop board's per-section toggle says beside the chevron.</summary>
internal string CollapseLabel => IsExpanded ? "Collapse" : "Expand";
/// <summary>
/// Whether this is the first heading the desktop's flat board drew.
/// </summary>
/// <remarks>
/// The mock draws Collapse all / Expand all on the first heading row's right edge rather than as a
/// control of its own above the board, and a virtualised list has no clean way to ask a container "are
/// you the first realized one" from inside its own template — so the row carries the answer instead,
/// stamped on once by <see cref="VaultViewModel.RebuildHostSections"/>. Always false on the phone's
/// headings, which draw no such control, and on every heading but the first on the desktop's own board.
/// </remarks>
internal bool IsFirstBoardSection { get; init; }
}
/// <summary>One group as it came out of a vault, with the vault it came out of.</summary>
@@ -136,6 +151,49 @@ internal sealed record GroupChoice(Guid? EntityId, string Label)
internal static GroupChoice None { get; } = new(null, "No group");
}
/// <summary>One section of the desktop's flat host board: a heading, and the cards under it.</summary>
/// <remarks>
/// <para>
/// Regrouped out of the same flat rows the phone's <see cref="VaultViewModel.SidebarRows"/> draws one after
/// another — see <see cref="VaultViewModel.FlattenIntoSections"/> — because a section's own
/// <c>WrapPanel</c> of cards needs its members as a list rather than as headings mixed into one stream. The
/// alternative was one <c>ListBox</c> for the whole board with a heading item pretending to be as wide as
/// the row it sits on so the wrap panel breaks a line for it; that is a real technique and it is a fragile
/// one, and a second, per-section <c>ListBox</c> is not. See the note on <c>HostsScreen.axaml</c> for why
/// this shape was chosen over the phone's single flat list.
/// </para>
/// <para>
/// <see cref="Header"/> is null for exactly one case: a keychain with no groups at all draws one section
/// with no heading, which is what makes the feature invisible until it is used — see
/// <see cref="VaultViewModel.RebuildHostSections"/>.
/// </para>
/// </remarks>
internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnlyList<HostRowViewModel> hosts)
{
internal SidebarGroupHeader? Header => header;
internal bool HasHeader => header is not null;
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
}
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
/// <param name="GroupId">The group this entry narrows the board to.</param>
/// <param name="Label">What to show — vault-qualified where the session holds more than one.</param>
/// <param name="IsChecked">Whether this group currently narrows the board.</param>
/// <remarks>
/// Rebuilt whenever the checked set changes rather than mutated, on the same reasoning
/// <see cref="TagChoice"/> gives: a chip is a value, and equality is contents.
/// </remarks>
internal sealed record GroupFilterChoice(Guid GroupId, string Label, bool IsChecked);
/// <summary>An entry in the toolbar's Tag ▾ filter flyout.</summary>
/// <param name="TagId">The tag this entry narrows the board to.</param>
/// <param name="Label">What to show.</param>
/// <param name="IsChecked">Whether this tag currently narrows the board.</param>
/// <remarks>A host passes the filter by wearing <em>any</em> checked tag, not all of them.</remarks>
internal sealed record TagFilterChoice(Guid TagId, string Label, bool IsChecked);
/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
@@ -488,6 +546,84 @@ internal sealed partial class HostRowViewModel(
/// </remarks>
[ObservableProperty]
private bool isChosen;
/// <summary>How many folders are pinned on this host.</summary>
/// <remarks>
/// Reads <see cref="Host"/> rather than being cached, so it needs no refreshing of its own: the row is
/// replaced wholesale whenever the host changes, exactly as <see cref="Summary"/> is, and both read the
/// same decrypted secret this constructor was handed.
/// </remarks>
internal int PinCount => host.Secret.PinnedPaths.Count;
/// <summary>Whether the card draws a pin-count badge at all.</summary>
internal bool HasPins => PinCount > 0;
/// <summary>
/// The two letters a card's monogram avatar draws.
/// </summary>
/// <remarks>
/// The first letters of the first two words in the label, or the first two characters where the label is
/// one word — the mock's own rule. Lowercased because that is how the design draws every monogram, not
/// because a name typed in capitals means anything different from one that was not.
/// </remarks>
internal string Monogram
{
get
{
var words = Label.Split(
MonogramWordSeparators,
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var letters = words.Length >= 2
? string.Concat(words[0][0], words[1][0])
: Label.Length >= 2 ? Label[..2] : Label;
return letters.ToLowerInvariant();
}
}
private static readonly char[] MonogramWordSeparators = [' ', '-', '_', '.'];
/// <summary>
/// Which of the four monogram hues this card's avatar is painted in.
/// </summary>
/// <remarks>
/// A stable hash of the label, deliberately not <see cref="string.GetHashCode()"/> — .NET randomises
/// that per process as a defence against hash-flooding, so the same host would draw a different colour
/// every time the application started. FNV-1a costs nothing to hold constant across runs, which is the
/// one property a monogram's colour needs: the same machine has to look like the same machine tomorrow.
/// </remarks>
internal string MonogramHue => MonogramHues[StableHash(Label) % (uint)MonogramHues.Length];
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram, in a fixed order.</summary>
private static readonly string[] MonogramHues = ["violet", "green", "amber", "gray"];
private static uint StableHash(string value)
{
unchecked
{
var hash = 2166136261u;
foreach (var character in value)
{
hash = (hash ^ character) * 16777619u;
}
return hash;
}
}
/// <summary>
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
/// </summary>
/// <remarks>
/// <b>Not computed here.</b> A later wave fills this from the synced connection log on the hosts
/// screen's activation and on <c>SessionEnded</c>, and restrings it on a tick while the screen is
/// visible. This row carries only the string, the way <see cref="IsConnected"/> carries a fact the vault
/// does not own either.
/// </remarks>
[ObservableProperty]
private string lastConnectedText = string.Empty;
}
/// <summary>What a host can authenticate with.</summary>
@@ -1248,8 +1384,24 @@ internal sealed partial class VaultViewModel(
private Dictionary<Guid, TagSecret> tagsById = [];
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
/// <remarks>
/// Shared by <see cref="SidebarRows"/> and <see cref="HostSections"/> deliberately: it is a fact about
/// which shelves are open, not about which head is asking, and the two heads never run in the same
/// process to disagree about it.
/// </remarks>
private readonly HashSet<Guid> collapsedGroups = [];
/// <summary>The groups the toolbar's Group ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
/// <remarks>Empty means every group, which is what "All groups" resets it to.</remarks>
private readonly HashSet<Guid> checkedGroupFilterIds = [];
/// <summary>The tags the toolbar's Tag ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
/// <remarks>
/// A host passes by wearing <em>any</em> of these, not all of them — the same semantics
/// <see cref="TagChoice"/>'s picker uses for what a host wears, just read the other way round.
/// </remarks>
private readonly HashSet<Guid> checkedTagFilterIds = [];
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
@@ -1392,6 +1544,94 @@ internal sealed partial class VaultViewModel(
/// </remarks>
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
/// <summary>
/// The desktop's own flattening of <see cref="Hosts"/>: one section per group in label order, "No
/// group" first, narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
/// </summary>
/// <remarks>
/// <para>
/// Built from the same <see cref="FlattenIntoSections"/> pass as <see cref="SidebarRows"/>, regrouped
/// into one entry per heading rather than left as a flat stream of rows — see
/// <see cref="HostSectionViewModel"/> for why, and <see cref="RebuildHostSections"/> for how.
/// </para>
/// <para>
/// <b>A keychain with no groups draws one section with no heading</b>, which is what
/// <see cref="HostSectionViewModel.HasHeader"/> is for: the invariant the hosts screen documents, that a
/// groupless keychain is one flat grid and nothing above it.
/// </para>
/// </remarks>
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
/// <remarks>
/// Computed rather than stored, since it is nothing a control binds directly: it is what a shift-click's
/// run is measured against and what Ctrl+A ticks, both of which want the flat order rather than the
/// sections that draw it. See <see cref="ChooseHostRun"/> and <c>HostsScreen.axaml.cs</c>.
/// </remarks>
internal IEnumerable<HostRowViewModel> HostBoardOrder =>
HostSections.SelectMany(section => section.Hosts);
/// <summary>Whether the board has any card on it at all.</summary>
internal bool HasHostBoardEntries => HostSections.Any(section => section.Hosts.Count > 0);
/// <summary>The count chip beside the "Hosts" title — every host in a shown vault, unfiltered.</summary>
/// <remarks>
/// Deliberately not narrowed by the find box or the two toolbar flyouts: it answers "how big is this
/// keychain", the way the vault screen's own item count does, not "how many cards are on screen right
/// now" — which is already on the row beneath it, in each section's own heading.
/// </remarks>
internal int HostBoardTotalCount => Hosts.Count(row => IsVaultShown(row.VaultId));
/// <summary>Whether the Group ▾ flyout is currently narrowing the board.</summary>
internal bool HasActiveGroupFilter => checkedGroupFilterIds.Count > 0;
/// <summary>Whether the Tag ▾ flyout is currently narrowing the board.</summary>
internal bool HasActiveTagFilter => checkedTagFilterIds.Count > 0;
/// <summary>
/// What the board says when it has nothing on it.
/// </summary>
/// <remarks>
/// One answer per reason it can be empty, on <see cref="NoVisibleHostsMessage"/>'s own reasoning: an
/// empty keychain is an invitation, a vault switched off is a setting to revisit, a filter that has
/// narrowed everything away is a filter to widen, and a search that matches nothing is not an invitation
/// to add a host that may well already be there.
/// </remarks>
internal string NoHostBoardMessage =>
(Hosts.Count, HasHiddenVaults, HasActiveGroupFilter || HasActiveTagFilter, HostFilter.Trim().Length)
switch
{
(0, _, _, _) =>
"No hosts yet. Press + New host to add one, or import the machines already in this "
+ "computer's ~/.ssh/config from Preferences.",
(_, true, false, 0) =>
"Every host here is in a vault you have switched off. Press the vault menu in the tab strip "
+ "to switch one back on.",
(_, _, true, _) =>
"Nothing matches the Group or Tag filter. Press All groups or All tags to widen it.",
_ => "No host matches that. The name, the address and the notes are all searched.",
};
/// <summary>The toolbar's Group ▾ entries: one per group, checkable, narrowing the board when ticked.</summary>
internal ObservableCollection<GroupFilterChoice> GroupFilterChoices { get; } = [];
/// <summary>The toolbar's Tag ▾ entries: one per tag, checkable, narrowing the board when ticked.</summary>
internal ObservableCollection<TagFilterChoice> TagFilterChoices { get; } = [];
/// <summary>
/// What the board's Collapse all / Expand all control says, on the first heading it draws.
/// </summary>
/// <remarks>
/// Collapses when anything is open and expands only once everything already is, which is the ordinary
/// meaning of the pair: a mix of open and folded shelves is "more to fold away" until none are left.
/// </remarks>
internal string CollapseAllLabel =>
HostSections.Select(section => section.Header)
.OfType<SidebarGroupHeader>()
.Any(header => header.IsExpanded)
? "Collapse all"
: "Expand all";
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
/// <remarks>
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
@@ -3711,6 +3951,48 @@ internal sealed partial class VaultViewModel(
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedHost?.EntityId;
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
// The group rows carry a host count, so the boards' headings on both heads are built from them.
RebuildGroups();
RebuildVisibleHosts();
// The desktop's own board and its two toolbar flyouts. After the group rows for the reason above,
// and after tagsById — filled by ReloadTagsAsync, which ReloadAsync always runs first — since the
// tag flyout reads it rather than the active vault's own Tags.
RebuildGroupFilterChoices();
RebuildTagFilterChoices();
RebuildHostSections();
return unreadable;
}
/// <summary>Reads every readable vault's hosts and resolves each into a row, without touching <see cref="Hosts"/>.</summary>
/// <remarks>
/// Split out of <see cref="ReloadHostsAsync"/> purely for length — this project's analyser caps a method
/// at 60 lines, and the two halves this makes are "read and resolve" and "sort, select and rebuild",
/// which is a seam that was already there.
/// </remarks>
private async Task<(List<HostRowViewModel> Rows, int Unreadable)> FetchHostRowsAsync(
CancellationToken cancellationToken)
{
var unreadable = 0;
var rows = new List<HostRowViewModel>();
@@ -3746,28 +4028,7 @@ internal sealed partial class VaultViewModel(
}));
}
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating.
foreach (var host in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Hosts.Add(host);
}
SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts();
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
// the group rows.
RebuildGroups();
RebuildVisibleHosts();
return unreadable;
return (rows, unreadable);
}
/// <summary>Which host a freshly filled <see cref="Hosts"/> leaves selected.</summary>
@@ -4469,33 +4730,162 @@ internal sealed partial class VaultViewModel(
// the same question: every group it has as a heading, every host filed under one of them, and no way
// to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so
// a list narrowed to the outermost level would be a list showing only the hosts nobody had filed.
//
// Ungrouped last, which is the order this list has drawn since groups existed — see
// FlattenIntoSections for why the desktop's own board no longer has to agree.
var shown = Hosts.Where(MatchesFilters).ToArray();
foreach (var row in FlattenIntoSections(shown, ungroupedFirst: false, ungroupedLabel: "UNGROUPED"))
{
SidebarRows.Add(row);
}
}
/// <summary>
/// Refills <see cref="HostSections"/>: the desktop's own flattening of the same hosts, "No group" first
/// and narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
/// </summary>
/// <remarks>
/// <para>
/// Reuses <see cref="FlattenIntoSections"/> and then regroups its flat rows into one
/// <see cref="HostSectionViewModel"/> per heading — see that type for why a per-section
/// <c>ListBox</c> wants its members as a list rather than as headings mixed into one stream.
/// </para>
/// <para>
/// <b>The no-groups invariant is drawn here rather than left to the regrouping loop below.</b> With no
/// groups at all <see cref="FlattenIntoSections"/> hands back the hosts and nothing else, and one section
/// with a null header is what the board's own XAML reads as "draw the cards and nothing above them" —
/// the same rule <see cref="RebuildSidebarRows"/> has always followed for the phone's list.
/// </para>
/// </remarks>
private void RebuildHostSections()
{
HostSections.Clear();
foreach (var built in BuildHostSections())
{
HostSections.Add(built);
}
// Computed rather than stored, and none of the four has a change notification of its own — the same
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
// same reason: a control bound to one of these has no other way to learn it should ask again.
OnPropertyChanged(nameof(HasHostBoardEntries));
OnPropertyChanged(nameof(HostBoardTotalCount));
OnPropertyChanged(nameof(NoHostBoardMessage));
OnPropertyChanged(nameof(CollapseAllLabel));
}
private List<HostSectionViewModel> BuildHostSections()
{
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
var flat = FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group");
if (flat.Count == 0)
{
return [];
}
if (Groups.Count == 0)
{
foreach (var host in shown)
{
SidebarRows.Add(host);
return [new HostSectionViewModel(null, [.. flat.Cast<HostRowViewModel>()])];
}
return;
var sections = new List<HostSectionViewModel>();
SidebarGroupHeader? header = null;
var members = new List<HostRowViewModel>();
var firstHeading = true;
foreach (var row in flat)
{
if (row is SidebarGroupHeader next)
{
if (header is not null)
{
sections.Add(new HostSectionViewModel(header, members));
}
// Marked on the first heading only, which is where the mock draws Collapse all / Expand
// all — see IsFirstBoardSection and the XAML.
header = firstHeading ? next with { IsFirstBoardSection = true } : next;
firstHeading = false;
members = [];
}
else if (row is HostRowViewModel host)
{
members.Add(host);
}
}
if (header is not null)
{
sections.Add(new HostSectionViewModel(header, members));
}
return sections;
}
/// <summary>
/// Every group as a heading and its members underneath, in label order — the phone's whole list, and the
/// desktop's board besides it.
/// </summary>
/// <param name="shown">The hosts that survived whatever filters the caller applies before this runs.</param>
/// <param name="ungroupedFirst">
/// Whether the heading for hosts filed under nothing comes before every group or after them. The phone
/// puts it last, which is the order this list has drawn since groups existed; the mock puts the
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
/// </param>
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
/// <remarks>
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
/// headings and on one that draws sections for them. A host whose group has been deleted falls under the
/// ungrouped heading rather than disappearing, for the reason <see cref="HostSecret.GroupId"/> is
/// allowed to dangle. An empty group still gets its heading, since it is a shelf the user made; the
/// ungrouped heading is dropped instead when nothing is on it, since that is only ever the box a search
/// left empty.
/// </remarks>
private List<ISidebarRow> FlattenIntoSections(
IReadOnlyList<HostRowViewModel> shown, bool ungroupedFirst, string ungroupedLabel)
{
var rows = new List<ISidebarRow>();
if (Groups.Count == 0)
{
rows.AddRange(shown);
return rows;
}
var known = Groups.Select(group => group.EntityId).ToHashSet();
foreach (var group in Groups)
{
AddSidebarSection(shown, group, host => host.Host.GroupId == group.EntityId);
}
AddSidebarSection(
void AddUngrouped() => AddFlatSection(
rows,
shown,
null,
host => host.Host.GroupId is not { } id || !known.Contains(id),
ungroupedLabel,
onlyWhenOccupied: true);
if (ungroupedFirst)
{
AddUngrouped();
}
/// <summary>Adds one heading to the sidebar, and the hosts under it when it is not folded away.</summary>
foreach (var group in Groups)
{
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
}
if (!ungroupedFirst)
{
AddUngrouped();
}
return rows;
}
/// <summary>Appends one heading to a flat row list, and its members when it is not folded away.</summary>
/// <param name="rows">The list being built, shared by every section a caller adds in one pass.</param>
/// <param name="shown">The hosts that survived the filters, which every section draws its members from.</param>
/// <param name="group">
/// The group this heading is for, or null for the ungrouped one. The row rather than its id and label,
@@ -4503,11 +4893,14 @@ internal sealed partial class VaultViewModel(
/// it is every vault's unfiled hosts at once.
/// </param>
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
private void AddSidebarSection(
private void AddFlatSection(
List<ISidebarRow> rows,
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> belongs,
string ungroupedLabel,
bool onlyWhenOccupied = false)
{
var members = shown.Where(belongs).ToArray();
@@ -4519,9 +4912,9 @@ internal sealed partial class VaultViewModel(
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
SidebarRows.Add(new SidebarGroupHeader(
rows.Add(new SidebarGroupHeader(
group?.EntityId,
group?.Label ?? "UNGROUPED",
group?.Label ?? ungroupedLabel,
members.Length,
expanded)
{
@@ -4533,10 +4926,124 @@ internal sealed partial class VaultViewModel(
return;
}
foreach (var member in members)
{
SidebarRows.Add(member);
rows.AddRange(members);
}
/// <summary>Whether a host passes the toolbar's Group ▾ and Tag ▾ flyouts, on top of the ordinary filters.</summary>
/// <remarks>
/// Empty means every group or every tag, which is what "All groups" and "All tags" reset the checked
/// sets to — an empty filter narrows nothing, on the same reading <see cref="MatchesFilters"/> gives an
/// empty find box. A host passes the tag half by wearing <em>any</em> checked tag, not all of them.
/// </remarks>
private bool MatchesHostBoardFilters(HostRowViewModel row) =>
MatchesFilters(row)
&& (checkedGroupFilterIds.Count == 0
|| (row.Host.GroupId is { } groupId && checkedGroupFilterIds.Contains(groupId)))
&& (checkedTagFilterIds.Count == 0 || row.Host.TagIds.Any(checkedTagFilterIds.Contains));
/// <summary>Refills the toolbar's Group ▾ entries from <see cref="Groups"/> and the checked set.</summary>
private void RebuildGroupFilterChoices()
{
GroupFilterChoices.Clear();
var several = session.ReadableVaults.Take(2).Count() > 1;
foreach (var group in Groups)
{
GroupFilterChoices.Add(new GroupFilterChoice(
group.EntityId,
several && group.HasVaultBadge ? $"{group.Label} ({group.VaultName})" : group.Label,
checkedGroupFilterIds.Contains(group.EntityId)));
}
}
/// <summary>Refills the toolbar's Tag ▾ entries from every readable vault's tags and the checked set.</summary>
/// <remarks>
/// Read from <see cref="tagsById"/> rather than <see cref="Tags"/>, which is the active vault's alone —
/// see the remark on <see cref="ReloadTagsAsync"/>. A filter that could only narrow to the active vault's
/// tags would leave a team's own tags unreachable from this flyout while still drawn as chips on cards.
/// </remarks>
private void RebuildTagFilterChoices()
{
TagFilterChoices.Clear();
foreach (var tag in tagsById.OrderBy(pair => pair.Value.Label, StringComparer.CurrentCulture))
{
TagFilterChoices.Add(new TagFilterChoice(
tag.Key, tag.Value.Label, checkedTagFilterIds.Contains(tag.Key)));
}
}
/// <summary>Ticks or unticks one group in the toolbar's Group ▾ flyout.</summary>
[RelayCommand]
private void ToggleGroupFilter(GroupFilterChoice? choice)
{
if (choice is null)
{
return;
}
if (!checkedGroupFilterIds.Remove(choice.GroupId))
{
checkedGroupFilterIds.Add(choice.GroupId);
}
RebuildGroupFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveGroupFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>"All groups" — the reset at the foot of the Group ▾ flyout's checked entries.</summary>
[RelayCommand]
private void ResetGroupFilter()
{
if (checkedGroupFilterIds.Count == 0)
{
return;
}
checkedGroupFilterIds.Clear();
RebuildGroupFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveGroupFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Ticks or unticks one tag in the toolbar's Tag ▾ flyout.</summary>
[RelayCommand]
private void ToggleTagFilter(TagFilterChoice? choice)
{
if (choice is null)
{
return;
}
if (!checkedTagFilterIds.Remove(choice.TagId))
{
checkedTagFilterIds.Add(choice.TagId);
}
RebuildTagFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveTagFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>"All tags" — the reset at the foot of the Tag ▾ flyout's checked entries.</summary>
[RelayCommand]
private void ResetTagFilter()
{
if (checkedTagFilterIds.Count == 0)
{
return;
}
checkedTagFilterIds.Clear();
RebuildTagFilterChoices();
RebuildHostSections();
OnPropertyChanged(nameof(HasActiveTagFilter));
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Folds one group's hosts away, or brings them back.</summary>
@@ -4545,6 +5052,9 @@ internal sealed partial class VaultViewModel(
/// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
/// forgotten a minute after it was set. The ungrouped heading uses <see cref="Guid.Empty"/>, which is not
/// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
///
/// Rebuilds both <see cref="SidebarRows"/> and <see cref="HostSections"/>: the set they fold against is
/// shared, and whichever head is actually drawing needs its own collection rebuilt regardless.
/// </remarks>
[RelayCommand]
private void ToggleGroup(SidebarGroupHeader? header)
@@ -4562,7 +5072,46 @@ internal sealed partial class VaultViewModel(
}
RebuildSidebarRows();
RebuildHostSections();
SelectedSidebarRow = SelectedHost;
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>Collapse all / Expand all, on the desktop board's first heading.</summary>
/// <remarks>
/// Collapses everything when anything is open, and expands everything only once all of it already is —
/// see <see cref="CollapseAllLabel"/>. A no-op on a groupless keychain, which draws no headings to fold.
/// </remarks>
[RelayCommand]
private void ToggleAllGroups()
{
var headers = HostSections.Select(section => section.Header).OfType<SidebarGroupHeader>().ToList();
if (headers.Count == 0)
{
return;
}
var collapsing = headers.Any(header => header.IsExpanded);
foreach (var header in headers)
{
var key = header.GroupId ?? Guid.Empty;
if (collapsing)
{
collapsedGroups.Add(key);
}
else
{
collapsedGroups.Remove(key);
}
}
RebuildSidebarRows();
RebuildHostSections();
SelectedSidebarRow = SelectedHost;
OnPropertyChanged(nameof(CollapseAllLabel));
}
/// <summary>
@@ -5474,14 +6023,16 @@ internal sealed partial class VaultViewModel(
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
/// <remarks>
/// <para>
/// The order is the grid's own — <see cref="VisibleHosts"/>, which is the collection the cards are drawn
/// from — so "between" means what the eye says it means, with whatever filter is in the box and whatever
/// group is open already applied. Taking it from <see cref="Hosts"/> instead would tick machines that are
/// not on the screen, which is the version of this mistake that ends in a deletion.
/// The order is the board's own — <see cref="HostBoardOrder"/>, which is <see cref="HostSections"/>
/// flattened back out in the order the cards are drawn — so "between" means what the eye says it means,
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every section
/// a fold has not hidden. Taking it from <see cref="Hosts"/> instead would tick machines that are not on
/// the screen, which is the version of this mistake that ends in a deletion.
/// </para>
/// <para>
/// Either end missing from that collection ticks nothing rather than guessing. That is a shift-click
/// arriving after the run's other end has been filtered away, and the honest answer to it is no run.
/// Either end missing from that order ticks nothing rather than guessing. That is a shift-click arriving
/// after the run's other end has been filtered away, or folded inside a collapsed section, and the
/// honest answer to it is no run.
/// </para>
/// </remarks>
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
@@ -5491,8 +6042,9 @@ internal sealed partial class VaultViewModel(
return;
}
var from = VisibleHosts.IndexOf(anchor);
var until = VisibleHosts.IndexOf(to);
var order = HostBoardOrder.ToList();
var from = order.IndexOf(anchor);
var until = order.IndexOf(to);
if (from < 0 || until < 0)
{
@@ -5502,7 +6054,7 @@ internal sealed partial class VaultViewModel(
var first = Math.Min(from, until);
var last = Math.Max(from, until);
ChooseHosts(VisibleHosts.Skip(first).Take(last - first + 1).ToList(), replacing);
ChooseHosts(order.Skip(first).Take(last - first + 1).ToList(), replacing);
}
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
@@ -11581,9 +12133,15 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
/// hosts already in memory, with no decryption and nothing on disk behind it.
/// hosts already in memory, with no decryption and nothing on disk behind it. Both boards rebuild — the
/// phone's flat list and the desktop's sectioned one — since the box is the one filter they share.
/// </remarks>
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
partial void OnHostFilterChanged(string value)
{
RebuildVisibleHosts();
RebuildHostSections();
OnPropertyChanged(nameof(NoHostBoardMessage));
}
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
/// <remarks>