diff --git a/HANDOFF-hosts-v5.md b/HANDOFF-hosts-v5.md
new file mode 100644
index 0000000..7ab4066
--- /dev/null
+++ b/HANDOFF-hosts-v5.md
@@ -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; `` 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` + `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 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 -- --filter-method ''` — 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.
diff --git a/hosts-v5-design-spec.md b/hosts-v5-design-spec.md
new file mode 100644
index 0000000..def0a47
--- /dev/null
+++ b/hosts-v5-design-spec.md
@@ -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 ``/`{{ }}` bits are the design tool's templating; `style-hover` is a hover style.
+Sample data lives in the script block at the bottom.
+
+```html
+
+
+
+
+
+
+
Hosts
+
10
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+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`.
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
index 0d695e0..678cb34 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
@@ -9,113 +9,75 @@ using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
///
-/// 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.
///
///
///
-/// All of this was HostSidebar'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 .
+/// ◆ v5: THE GRID OF GROUP CARDS AND THE BREADCRUMB TRAIL ARE GONE, AND SO IS THE DRAG. 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 SidebarRows 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 VaultViewModel.HostSections.
///
///
-/// ◆ There are two selections on this screen and they are not the same thing. The ListBox holds
-/// one — the card the drawer, CONNECT and the ordinary menu are about — and the vault holds a set,
-/// 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 VaultViewModel.ChooseHosts and Android's
-/// HostsScreen. 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.
+/// ◆ ONE SECTION, ONE ListBox. The two shapes worth weighing were a single mixed list of
+/// header rows and host rows in one WrapPanel, or an ItemsControl 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 ListBox owning one SelectedItem,
+/// 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 WrapPanel 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.
///
///
-/// Its data context is the VaultViewModel, as 's is, so every binding in the
-/// markup is a property of the vault. The window hands it over; see . The drawer
-/// beside the grid inherits the same one.
+/// ◆ SELECTION IS ONE-WAY INTO EVERY SECTION AND WRITTEN BY HAND ON THE WAY OUT. Each section's
+/// ListBox binds SelectedItem from VaultViewModel.SelectedSidebarRow 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 Reset (one per filter keystroke, one per background sync) racing
+/// to null the one shared property, which is the same hazard VaultViewModel.RebuildVisibleHosts
+/// 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 .
+///
+///
+/// ◆ THERE ARE STILL TWO SELECTIONS ON THIS SCREEN, AND THEY ARE STILL NOT THE SAME THING.
+/// SelectedHost 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 IsChosen 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 VaultViewModel.ChooseHosts and Android's HostsScreen.
+///
+///
+/// Its data context is the VaultViewModel, as 's is. The window hands it
+/// over; see .
///
///
internal sealed partial class HostsScreen : UserControl
{
- ///
- /// How hosts travel from the cards they were picked up on to the group card they are dropped on.
- ///
+ /// How far the pointer has to travel before a press on the empty space becomes a band.
///
- ///
- /// 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.
- ///
- ///
- /// 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.
- ///
- ///
- private static readonly DataFormat> HostFormat =
- DataFormat.CreateInProcessFormat>("dodossh-host-rows");
-
- /// How far the pointer has to travel before a press becomes a drag.
- ///
- /// 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 . Starting a band on the press itself would turn every one
+ /// of those into a rectangle nobody asked for.
///
private const double DragThreshold = 5;
- /// How close to the top or bottom of the grid a drag has to be held to scroll it.
- ///
- /// 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.
- ///
- private const double EdgeBand = 48;
-
- /// How far one drag event inside that band moves the grid.
- ///
- /// 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.
- ///
- private const double EdgeStep = 24;
-
- /// The press a drag would start from, or null once it has become one or been let go of.
- ///
- /// Held because 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.
- ///
- private PointerPressedEventArgs? press;
-
- /// What that press would carry: the ticked hosts, or the one card it landed on.
- private IReadOnlyList pickedUp = [];
-
- private Point origin;
-
- /// The group card the pointer is currently over, while a drag is in flight.
- private ListBoxItem? marked;
-
- ///
- /// Where a Shift-click measures its run from: the last card pressed without one.
- ///
+ /// Where a Shift-click measures its run from: the last card pressed without one.
///
/// 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.
///
private HostRowViewModel? anchor;
- ///
- /// The ticked card a plain press landed on, which the release collapses the set onto.
- ///
- ///
- /// ◆ 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. 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, clears this on the way out and the set survives the journey.
- ///
- private HostRowViewModel? collapse;
-
- /// Whether a band is being dragged out over the grid right now.
+ /// Whether a band is being dragged out over the board right now.
private bool banding;
/// The corner it was started from, in the scroller's own coordinates.
@@ -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);
}
/// The band's own wiring, and the two keys that go with a set.
@@ -190,17 +116,17 @@ internal sealed partial class HostsScreen : UserControl
///
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
///
/// It has to be a control the keyboard can actually go to. Focus() 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
- /// ListBox 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. Board 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.
///
///
internal IInputElement KeyboardTarget =>
- Vault is { HasVisibleHosts: true } ? HostGrid : HostFilter;
+ Vault is { HasHostBoardEntries: true } ? Board : HostFilter;
///
/// 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
///
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);
}
}
- ///
- /// Opens the group card that was double-clicked.
- ///
- ///
- /// 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.
- ///
- private void OnGroupActivated(object? sender, TappedEventArgs e)
- {
- if (Vault is { } vault && RowUnder(e.Source) is HostGroupRowViewModel group)
- {
- vault.OpenGroupCommand.Execute(group);
- }
- }
-
///
/// Points the menu at whatever was right-clicked.
///
@@ -283,8 +191,9 @@ internal sealed partial class HostsScreen : UserControl
/// behind the menu.
///
///
- /// 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.
///
///
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;
}
}
///
- /// Points the group menu at whatever was right-clicked.
- ///
- ///
- ///
- /// The host grid's rule, applied to the cards above it — see . What is
- /// different is what an unaimed menu would have done: GroupTarget 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.
- ///
- ///
- /// 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.
- ///
- ///
- 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;
- }
-
- ///
- /// Ticks cards, or remembers a press that may become a drag.
+ /// Ticks cards, or moves the selection.
///
///
///
/// ◆ Ctrl and Shift are answered here and go no further. Both mark the event handled, which is
- /// what stops the ListBox 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 ListBox 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.
///
///
- /// A plain press drops the set, 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
- /// .
+ /// A plain press moves the selection and drops the chosen set, 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 OneWay bound: whatever it shows locally is
+ /// overwritten the moment 's change reaches every section again.
///
///
/// Nothing here refuses while the drawer's editor is open, and the phone's own ChooseHost 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.
///
///
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];
- }
-
- press = e;
- origin = e.GetPosition(HostGrid);
- }
-
- ///
- /// The drag is started from the remembered press once the pointer has travelled far enough — see
- /// . 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.
- ///
- 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);
- }
-
- ///
- /// 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 .
- ///
- private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
- {
- if (collapse is not null && Vault is { } vault)
+ if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
- Forget();
+ vault.SelectedHost = row;
}
- private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e) => Forget();
-
///
- /// 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.
///
///
///
- /// Every card on the screen: VisibleHosts, 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 on the board: , 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.
///
///
/// 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
///
///
///
- /// ◆ Only below the top of the host grid, and only where there is nothing to press. 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.
+ /// ◆ Only where there is nothing to press. 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. 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.
///
///
/// 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.
///
///
@@ -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();
}
///
@@ -628,15 +444,16 @@ internal sealed partial class HostsScreen : UserControl
/// The hosts whose cards a rectangle over the scroller touches.
///
- /// 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 — rather than one named control, since
+ /// a band drawn across two sections has to touch cards in both.
///
private List CardsIn(Rect box)
{
var hit = new List();
- 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;
}
+ /// Every realized card, across every section's own list.
+ ///
+ /// A section is a ListBox of its own — see the type's remarks — so there is no single control
+ /// whose GetRealizedContainers() 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.
+ ///
+ private List AllCardContainers() =>
+ [.. this.GetVisualDescendants()
+ .OfType()
+ .Where(list => list.Classes.Contains("sectioncards"))
+ .SelectMany(list => list.GetRealizedContainers())
+ .OfType()];
+
/// Whether what was pressed is something that answers a press itself.
///
/// 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.
///
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);
- /// Carries the picked-up hosts for as long as the user holds them.
- private async Task DragAsync(PointerPressedEventArgs pressed, IReadOnlyList 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();
- }
- }
-
- ///
- /// Says whether what is under the pointer would take these hosts, and marks it if it would.
- ///
- ///
- /// A drag whose hosts are all already in the group under the pointer is refused, which is not pedantry:
- /// DragDropEffects.None 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.
- ///
- 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();
-
- ///
- /// Carries the grid under a drag that is over the cards rather than over a group.
- ///
- ///
- ///
- /// Without this the gesture is only available to whoever can see both ends of it. 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.
- ///
- ///
- /// 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.
- ///
- ///
- /// 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.
- ///
- ///
- 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));
- }
-
- ///
- ///
- /// Two commands for the same gesture, and which one runs is how many cards were picked up. One host is
- /// MoveHostToGroup, which has always been what a drag does and which keeps that host selected. A
- /// set goes through FileChosenHostsUnder, 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.
- ///
- ///
- /// 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.
- ///
- ///
- 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));
- }
-
- ///
- /// Where a drag currently is, or null if it is over nothing that would take it.
- ///
- ///
- ///
- /// 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.
- ///
- ///
- /// 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 VaultViewModel.RebuildSidebarRows. That is decided in the command
- /// rather than here, so the rule has one home.
- ///
- ///
- 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;
- }
-
- /// Lets go of a press that turned out not to be a drag, or has become one.
- private void Forget()
- {
- press = null;
- pickedUp = [];
- collapse = null;
- }
-
- /// The view model of the grid item an event happened on, if it happened on one.
- private static object? RowUnder(object? source) => Container(source)?.DataContext;
+ /// The view model of the grid item an event happened on, if it happened on a host card.
+ private static HostRowViewModel? RowUnder(object? source) =>
+ Container(source)?.DataContext as HostRowViewModel;
///
/// 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.
///
private static ListBoxItem? Container(object? source) => source is Visual visual
? visual.FindAncestorOfType(includeSelf: true)
: null;
-
- /// A drag in flight, and where it would land.
- ///
- /// 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.
- ///
- private sealed record DropTarget(
- IReadOnlyList Hosts, HostGroupRowViewModel Group, ListBoxItem Container);
}
diff --git a/src/DodoSSH.Client.App/Views/MonogramBrushConverter.cs b/src/DodoSSH.Client.App/Views/MonogramBrushConverter.cs
new file mode 100644
index 0000000..7abe5e6
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/MonogramBrushConverter.cs
@@ -0,0 +1,54 @@
+using System.Globalization;
+using Avalonia.Data.Converters;
+using Avalonia.Media;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Turns a host card's MonogramHue string into the background or foreground brush the mock pairs it
+/// with.
+///
+///
+///
+/// The four pairs are the design's own and are not in Palette.axaml: 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 HostRowViewModel.MonogramHue, 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.
+///
+///
+/// Two instances rather than one converter taking a "which half" parameter, so the markup names each with a
+/// StaticResource-shaped reference instead of a converter parameter that means nothing without
+/// reading this file.
+///
+///
+internal sealed class MonogramBrushConverter(bool background) : IValueConverter
+{
+ /// The fill behind the two letters.
+ internal static MonogramBrushConverter Background { get; } = new(background: true);
+
+ /// The colour of the two letters themselves.
+ internal static MonogramBrushConverter Foreground { get; } = new(background: false);
+
+ /// violet, green, amber, gray — the four pairs the mock assigns a monogram.
+ private static readonly Dictionary Pairs = new(
+ StringComparer.Ordinal)
+ {
+ ["violet"] = ("#241C4F", "#A78BFA"),
+ ["green"] = ("#103A2F", "#34D399"),
+ ["amber"] = ("#423211", "#F5B942"),
+ ["gray"] = ("#1E1E2C", "#9C9EB4"),
+ };
+
+ ///
+ 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));
+ }
+
+ ///
+ public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
+ throw new NotSupportedException("The monogram hue is read-only.");
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index ad24569..6e46c95 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -57,6 +57,21 @@ internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count
/// The chevron, as text, because the heading is drawn in the list's own item template.
internal string Chevron => IsExpanded ? "▾" : "▸";
+
+ /// What the desktop board's per-section toggle says beside the chevron.
+ internal string CollapseLabel => IsExpanded ? "Collapse" : "Expand";
+
+ ///
+ /// Whether this is the first heading the desktop's flat board drew.
+ ///
+ ///
+ /// 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 . 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.
+ ///
+ internal bool IsFirstBoardSection { get; init; }
}
/// One group as it came out of a vault, with the vault it came out of.
@@ -136,6 +151,49 @@ internal sealed record GroupChoice(Guid? EntityId, string Label)
internal static GroupChoice None { get; } = new(null, "No group");
}
+/// One section of the desktop's flat host board: a heading, and the cards under it.
+///
+///
+/// Regrouped out of the same flat rows the phone's draws one after
+/// another — see — because a section's own
+/// WrapPanel of cards needs its members as a list rather than as headings mixed into one stream. The
+/// alternative was one ListBox 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 ListBox is not. See the note on HostsScreen.axaml for why
+/// this shape was chosen over the phone's single flat list.
+///
+///
+/// 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
+/// .
+///
+///
+internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnlyList hosts)
+{
+ internal SidebarGroupHeader? Header => header;
+
+ internal bool HasHeader => header is not null;
+
+ internal IReadOnlyList Hosts => hosts;
+}
+
+/// An entry in the toolbar's Group ▾ filter flyout.
+/// The group this entry narrows the board to.
+/// What to show — vault-qualified where the session holds more than one.
+/// Whether this group currently narrows the board.
+///
+/// Rebuilt whenever the checked set changes rather than mutated, on the same reasoning
+/// gives: a chip is a value, and equality is contents.
+///
+internal sealed record GroupFilterChoice(Guid GroupId, string Label, bool IsChecked);
+
+/// An entry in the toolbar's Tag ▾ filter flyout.
+/// The tag this entry narrows the board to.
+/// What to show.
+/// Whether this tag currently narrows the board.
+/// A host passes the filter by wearing any checked tag, not all of them.
+internal sealed record TagFilterChoice(Guid TagId, string Label, bool IsChecked);
+
/// One host, and the group it is being filed under.
/// The host to move.
/// The group it should end up in, or null for none.
@@ -488,6 +546,84 @@ internal sealed partial class HostRowViewModel(
///
[ObservableProperty]
private bool isChosen;
+
+ /// How many folders are pinned on this host.
+ ///
+ /// Reads rather than being cached, so it needs no refreshing of its own: the row is
+ /// replaced wholesale whenever the host changes, exactly as is, and both read the
+ /// same decrypted secret this constructor was handed.
+ ///
+ internal int PinCount => host.Secret.PinnedPaths.Count;
+
+ /// Whether the card draws a pin-count badge at all.
+ internal bool HasPins => PinCount > 0;
+
+ ///
+ /// The two letters a card's monogram avatar draws.
+ ///
+ ///
+ /// 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.
+ ///
+ 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 = [' ', '-', '_', '.'];
+
+ ///
+ /// Which of the four monogram hues this card's avatar is painted in.
+ ///
+ ///
+ /// A stable hash of the label, deliberately not — .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.
+ ///
+ internal string MonogramHue => MonogramHues[StableHash(Label) % (uint)MonogramHues.Length];
+
+ /// violet, green, amber, gray — the four pairs the mock assigns a monogram, in a fixed order.
+ 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;
+ }
+ }
+
+ ///
+ /// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
+ ///
+ ///
+ /// Not computed here. A later wave fills this from the synced connection log on the hosts
+ /// screen's activation and on SessionEnded, and restrings it on a tick while the screen is
+ /// visible. This row carries only the string, the way carries a fact the vault
+ /// does not own either.
+ ///
+ [ObservableProperty]
+ private string lastConnectedText = string.Empty;
}
/// What a host can authenticate with.
@@ -1248,8 +1384,24 @@ internal sealed partial class VaultViewModel(
private Dictionary tagsById = [];
/// The groups whose hosts are folded away, by id, with for ungrouped.
+ ///
+ /// Shared by and 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.
+ ///
private readonly HashSet collapsedGroups = [];
+ /// The groups the toolbar's Group ▾ flyout has ticked, narrowing .
+ /// Empty means every group, which is what "All groups" resets it to.
+ private readonly HashSet checkedGroupFilterIds = [];
+
+ /// The tags the toolbar's Tag ▾ flyout has ticked, narrowing .
+ ///
+ /// A host passes by wearing any of these, not all of them — the same semantics
+ /// 's picker uses for what a host wears, just read the other way round.
+ ///
+ private readonly HashSet checkedTagFilterIds = [];
+
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
@@ -1392,6 +1544,94 @@ internal sealed partial class VaultViewModel(
///
internal ObservableCollection SidebarRows { get; } = [];
+ ///
+ /// The desktop's own flattening of : 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.
+ ///
+ ///
+ ///
+ /// Built from the same pass as , regrouped
+ /// into one entry per heading rather than left as a flat stream of rows — see
+ /// for why, and for how.
+ ///
+ ///
+ /// A keychain with no groups draws one section with no heading, which is what
+ /// is for: the invariant the hosts screen documents, that a
+ /// groupless keychain is one flat grid and nothing above it.
+ ///
+ ///
+ internal ObservableCollection HostSections { get; } = [];
+
+ /// Every host on the board, in the order its cards are drawn — every section, one after another.
+ ///
+ /// 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 and HostsScreen.axaml.cs.
+ ///
+ internal IEnumerable HostBoardOrder =>
+ HostSections.SelectMany(section => section.Hosts);
+
+ /// Whether the board has any card on it at all.
+ internal bool HasHostBoardEntries => HostSections.Any(section => section.Hosts.Count > 0);
+
+ /// The count chip beside the "Hosts" title — every host in a shown vault, unfiltered.
+ ///
+ /// 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.
+ ///
+ internal int HostBoardTotalCount => Hosts.Count(row => IsVaultShown(row.VaultId));
+
+ /// Whether the Group ▾ flyout is currently narrowing the board.
+ internal bool HasActiveGroupFilter => checkedGroupFilterIds.Count > 0;
+
+ /// Whether the Tag ▾ flyout is currently narrowing the board.
+ internal bool HasActiveTagFilter => checkedTagFilterIds.Count > 0;
+
+ ///
+ /// What the board says when it has nothing on it.
+ ///
+ ///
+ /// One answer per reason it can be empty, on '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.
+ ///
+ 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.",
+ };
+
+ /// The toolbar's Group ▾ entries: one per group, checkable, narrowing the board when ticked.
+ internal ObservableCollection GroupFilterChoices { get; } = [];
+
+ /// The toolbar's Tag ▾ entries: one per tag, checkable, narrowing the board when ticked.
+ internal ObservableCollection TagFilterChoices { get; } = [];
+
+ ///
+ /// What the board's Collapse all / Expand all control says, on the first heading it draws.
+ ///
+ ///
+ /// 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.
+ ///
+ internal string CollapseAllLabel =>
+ HostSections.Select(section => section.Header)
+ .OfType()
+ .Any(header => header.IsExpanded)
+ ? "Collapse all"
+ : "Expand all";
+
/// The groups in this vault, with the number of hosts filed under each.
///
/// 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 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;
+ }
+
+ /// Reads every readable vault's hosts and resolves each into a row, without touching .
+ ///
+ /// Split out of 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.
+ ///
+ private async Task<(List Rows, int Unreadable)> FetchHostRowsAsync(
+ CancellationToken cancellationToken)
+ {
var unreadable = 0;
var rows = new List();
@@ -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);
}
/// Which host a freshly filled leaves selected.
@@ -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);
+ }
+ }
+
+ ///
+ /// Refills : 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.
+ ///
+ ///
+ ///
+ /// Reuses and then regroups its flat rows into one
+ /// per heading — see that type for why a per-section
+ /// ListBox wants its members as a list rather than as headings mixed into one stream.
+ ///
+ ///
+ /// The no-groups invariant is drawn here rather than left to the regrouping loop below. With no
+ /// groups at all 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 has always followed for the phone's list.
+ ///
+ ///
+ 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 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()])];
+ }
- return;
+ var sections = new List();
+ SidebarGroupHeader? header = null;
+ var members = new List();
+ 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;
+ }
+
+ ///
+ /// Every group as a heading and its members underneath, in label order — the phone's whole list, and the
+ /// desktop's board besides it.
+ ///
+ /// The hosts that survived whatever filters the caller applies before this runs.
+ ///
+ /// 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.
+ ///
+ /// What that heading says — "UNGROUPED" on the phone, "No group" here.
+ ///
+ /// No groups means no headings, 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 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.
+ ///
+ private List FlattenIntoSections(
+ IReadOnlyList shown, bool ungroupedFirst, string ungroupedLabel)
+ {
+ var rows = new List();
+
+ 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();
+ }
+
+ foreach (var group in Groups)
+ {
+ AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
+ }
+
+ if (!ungroupedFirst)
+ {
+ AddUngrouped();
+ }
+
+ return rows;
}
- /// Adds one heading to the sidebar, and the hosts under it when it is not folded away.
+ /// Appends one heading to a flat row list, and its members when it is not folded away.
+ /// The list being built, shared by every section a caller adds in one pass.
/// The hosts that survived the filters, which every section draws its members from.
///
/// 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.
///
/// Which of the shown hosts fall under it.
+ /// What the heading says when is null.
/// Whether an empty section is left out altogether.
- private void AddSidebarSection(
+ private void AddFlatSection(
+ List rows,
IReadOnlyList shown,
HostGroupRowViewModel? group,
Func 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,18 +4926,135 @@ internal sealed partial class VaultViewModel(
return;
}
- foreach (var member in members)
+ rows.AddRange(members);
+ }
+
+ /// Whether a host passes the toolbar's Group ▾ and Tag ▾ flyouts, on top of the ordinary filters.
+ ///
+ /// 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 gives an
+ /// empty find box. A host passes the tag half by wearing any checked tag, not all of them.
+ ///
+ 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));
+
+ /// Refills the toolbar's Group ▾ entries from and the checked set.
+ private void RebuildGroupFilterChoices()
+ {
+ GroupFilterChoices.Clear();
+
+ var several = session.ReadableVaults.Take(2).Count() > 1;
+
+ foreach (var group in Groups)
{
- SidebarRows.Add(member);
+ GroupFilterChoices.Add(new GroupFilterChoice(
+ group.EntityId,
+ several && group.HasVaultBadge ? $"{group.Label} ({group.VaultName})" : group.Label,
+ checkedGroupFilterIds.Contains(group.EntityId)));
}
}
+ /// Refills the toolbar's Tag ▾ entries from every readable vault's tags and the checked set.
+ ///
+ /// Read from rather than , which is the active vault's alone —
+ /// see the remark on . 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.
+ ///
+ 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)));
+ }
+ }
+
+ /// Ticks or unticks one group in the toolbar's Group ▾ flyout.
+ [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));
+ }
+
+ /// "All groups" — the reset at the foot of the Group ▾ flyout's checked entries.
+ [RelayCommand]
+ private void ResetGroupFilter()
+ {
+ if (checkedGroupFilterIds.Count == 0)
+ {
+ return;
+ }
+
+ checkedGroupFilterIds.Clear();
+ RebuildGroupFilterChoices();
+ RebuildHostSections();
+ OnPropertyChanged(nameof(HasActiveGroupFilter));
+ OnPropertyChanged(nameof(NoHostBoardMessage));
+ }
+
+ /// Ticks or unticks one tag in the toolbar's Tag ▾ flyout.
+ [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));
+ }
+
+ /// "All tags" — the reset at the foot of the Tag ▾ flyout's checked entries.
+ [RelayCommand]
+ private void ResetTagFilter()
+ {
+ if (checkedTagFilterIds.Count == 0)
+ {
+ return;
+ }
+
+ checkedTagFilterIds.Clear();
+ RebuildTagFilterChoices();
+ RebuildHostSections();
+ OnPropertyChanged(nameof(HasActiveTagFilter));
+ OnPropertyChanged(nameof(NoHostBoardMessage));
+ }
+
/// Folds one group's hosts away, or brings them back.
///
/// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
/// 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 , which is not
/// a legal group id: HostSecret.TryValidate refuses one.
+ ///
+ /// Rebuilds both and : the set they fold against is
+ /// shared, and whichever head is actually drawing needs its own collection rebuilt regardless.
///
[RelayCommand]
private void ToggleGroup(SidebarGroupHeader? header)
@@ -4562,7 +5072,46 @@ internal sealed partial class VaultViewModel(
}
RebuildSidebarRows();
+ RebuildHostSections();
SelectedSidebarRow = SelectedHost;
+ OnPropertyChanged(nameof(CollapseAllLabel));
+ }
+
+ /// Collapse all / Expand all, on the desktop board's first heading.
+ ///
+ /// Collapses everything when anything is open, and expands everything only once all of it already is —
+ /// see . A no-op on a groupless keychain, which draws no headings to fold.
+ ///
+ [RelayCommand]
+ private void ToggleAllGroups()
+ {
+ var headers = HostSections.Select(section => section.Header).OfType().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));
}
///
@@ -5474,14 +6023,16 @@ internal sealed partial class VaultViewModel(
/// Whether the run is the selection now, or is added to it.
///
///
- /// The order is the grid's own — , 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 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 — , which is
+ /// 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 instead would tick machines that are not on
+ /// the screen, which is the version of this mistake that ends in a deletion.
///
///
- /// 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.
///
///
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);
}
/// Leaves selection mode, which is the cross at the left of the bar.
@@ -11581,9 +12133,15 @@ internal sealed partial class VaultViewModel(
///
/// 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.
///
- partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
+ partial void OnHostFilterChanged(string value)
+ {
+ RebuildVisibleHosts();
+ RebuildHostSections();
+ OnPropertyChanged(nameof(NoHostBoardMessage));
+ }
/// Adds the tag rows to the table, when the table is showing them.
///