From 06f9dcfc27f2ad2c1eddbf21dd9a135a6e21d364 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Fri, 7 Aug 2026 15:26:00 +0200 Subject: [PATCH] Snapshot the v5 hosts screen mid-restructure, with handoff notes to resume it --- HANDOFF-hosts-v5.md | 168 +++++ hosts-v5-design-spec.md | 130 ++++ .../Views/HostsScreen.axaml.cs | 639 ++++------------- .../Views/MonogramBrushConverter.cs | 54 ++ .../ViewModels/VaultViewModel.cs | 658 ++++++++++++++++-- 5 files changed, 1095 insertions(+), 554 deletions(-) create mode 100644 HANDOFF-hosts-v5.md create mode 100644 hosts-v5-design-spec.md create mode 100644 src/DodoSSH.Client.App/Views/MonogramBrushConverter.cs 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. ///