docs: redesign main vault UI as flat filterable entry list

Replaces the group-drilling navigation model with a flat entry list,
a multi-select group-filter sidebar (unified NavigationSplitView on
both iOS and macOS instead of separate per-platform navigation code),
and a toggleable search bar. Covers the entry row layout (icon,
title, username, breadcrumb), group-filter subtree-selection
semantics, how group filter and search combine (AND), the new
explicit group-picker field EntryEditView needs now that there's no
implicit "current group", and confirms dark mode needs no new work
since the app already uses only semantic colors.

Brainstormed and approved with the user before writing this up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
This commit is contained in:
2026-09-19 16:40:03 +02:00
co-authored by Claude Sonnet 5
parent ee350c4c02
commit c5a2e4c3ec
@@ -34,14 +34,15 @@ MyPassCore (Swift Package)
├── Entry / Group / Attachment — value-type models
├── TOTPGenerator — RFC 6238 TOTP from otp:// custom field
├── VaultSession — owns the live KDBXDatabase; lock/unlock lifecycle
── KeychainStore — read/write master password in shared Keychain group
── KeychainStore — read/write master password in shared Keychain group
└── KeePassIconMapper — approximates KeePass's standard iconIndex (0-68) as SF Symbols
MyPass (Main App Target) ──links──► MyPassCore
├── UnlockView
├── GroupBrowserView
├── EntryListView — flat, filterable entry list (see UI Navigation)
├── GroupFilterView — sidebar: multi-select group tree, filters EntryListView
├── EntryDetailView
├── EntryEditView
├── SearchView
├── FileBookmarkService — security-scoped bookmark management
├── BiometricAuthService — LAContext wrapper
└── ClipboardService — copy with 30 s expiry
@@ -135,28 +136,59 @@ struct TOTPConfig {
## UI Navigation
### iOS — NavigationStack
**Revised 2026-09-19** — replaces the original group-drilling design (see git history for the prior version). The main area now shows a flat, filterable list of entries instead of requiring the user to navigate into groups one at a time; groups become a filter, not a place you browse into.
### Shared structure (iOS + macOS — same component, one implementation)
```
UnlockView
└─► GroupBrowserView (root group)
├─► GroupBrowserView (subgroup, pushed recursively)
└─► EntryDetailView
└─► EntryEditView (sheet)
NavigationSplitView(columnVisibility:)
├── sidebar: GroupFilterView — multi-select group tree (see below)
└── detail: NavigationStack
└─► EntryListView (flat, filtered)
└─► EntryDetailView
└─► EntryEditView (sheet)
```
- Search bar at the top of every `GroupBrowserView` filters entries globally across all groups
- Swipe-to-delete on entries; toolbar `+` button adds an entry in the current group
- Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s)
- TOTP code displays with a countdown ring and refreshes automatically
`NavigationSplitView` is used on **both** platforms instead of maintaining separate iOS/macOS navigation code. On iPhone-width layouts it automatically collapses the sidebar into an overlay; on iPad/Mac it can sit persistently alongside the detail column. This behavior is built into the component — no platform-specific branching needed for it.
### macOS — NavigationSplitView (3 columns)
- **Hamburger button** (leading toolbar item, `"line.3.horizontal"`) toggles `columnVisibility` between `.all` and `.detailOnly`, showing/hiding the sidebar.
- **Search icon** (trailing toolbar item, `"magnifyingglass"`) toggles a `.searchable()` bar over `EntryListView`; tapping again hides it and clears the query.
- Swipe-to-delete on entry rows; toolbar `+` button opens `EntryEditView` for a new entry (see "Add-entry flow" below).
- Tapping a password field in `EntryDetailView` copies to clipboard (clears after 30 s).
- TOTP code displays with a countdown ring and refreshes automatically.
- Keyboard shortcuts (macOS): `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet.
| Sidebar | Middle | Detail |
|---|---|---|
| Group tree (expandable) | Entry list for selected group + search bar | Entry fields + Edit button |
### Entry list (`EntryListView`)
Keyboard shortcuts: `⌘C` copies password, `⌘⌥C` copies TOTP code, `⌘E` opens edit sheet.
Flat list, one row per entry across the whole vault (or the filtered subset — see below). Each row:
```
[icon] Title
username · Group > Subgroup > Sub-subgroup
```
- **Icon:** always shown, mapped from the entry's `iconIndex` via `KeePassIconMapper` (an approximate SF Symbol per KeePass's standard 068 icon palette; unmapped indices fall back to `"key.fill"`). The data model has no custom-icon-image support yet — only the standard palette index — so this is a best-effort visual match, not pixel-accurate.
- **Breadcrumb:** full ancestor path excluding the vault's top-level `root` group (e.g. an entry directly in `root > Work > Email` shows as `Work > Email`, not `Root > Work > Email` — the root node isn't a meaningful group to the user). Truncated in the middle if too long for the row. Computed by a tree-flattening helper (`VaultViewModel.flatEntries: [(entry: Entry, groupPath: [Group])]`) that walks the vault once per access, since entries don't natively carry a back-reference to their parent group. An entry stored directly in the root group (no breadcrumb) just shows the username line with nothing after it.
### Group filter (`GroupFilterView`, the sidebar)
A tree of all groups (never entries), each row a `DisclosureGroup` with a leading checkbox-style tap target (`circle` / `checkmark.circle.fill`) — a real `Toggle` isn't used here since it would conflict with the disclosure triangle's own tap target.
- Selecting a group selects it and its entire subgroup subtree (recursive select-down); deselecting a parent deselects the whole subtree too.
- Selection state (`Set<UUID>`) lives in `GroupFilterViewModel`, separate from `VaultViewModel` — the sidebar's tree-interaction concerns (expand/collapse, checkbox propagation) are distinct from list-filtering concerns. `ContentView` wires the resulting set into `VaultViewModel.selectedGroupIds`.
- Empty selection = no filter (show everything). A "Clear filter" affordance resets it.
### Filtering: group selection and search combine with AND
`VaultViewModel.displayedEntries` applies, in order: (1) group filter — keep only entries whose direct parent group ID is in `selectedGroupIds` (or all entries if empty), then (2) search filter — substring match on title/username/URL, same as the existing behavior. Both active at once narrows the result to their intersection (e.g. select "Work", then search "email" → only Work entries matching "email").
### Add-entry flow
Flattening removes the implicit "current group" that group-drilling used to provide. `EntryEditView` gets an explicit **group picker field** (listing the group tree by full path) instead, defaulting to: the sole selected filter group if exactly one is active, otherwise the vault's root group. The user can change it before saving. `EntryEditViewModel.groupId` becomes a real, always-present field rather than the previous optional-with-silent-failure.
### Dark mode
No new work needed — the codebase already uses only semantic SwiftUI colors (`.secondary`, `.tint`, system backgrounds) with zero hardcoded color values, which follow the system appearance automatically. No in-app override setting for now; this may become a settings item later.
---
@@ -226,15 +258,20 @@ Entry URL field is matched against the `serviceIdentifier` using host comparison
- `TOTPGenerator`: known test vectors from RFC 6238
- `ProtectedString`: obfuscation / reveal symmetry
- AutoFill URL matching: table-driven tests covering exact match, subdomain, no-URL entries
- `KeePassIconMapper`: known indices map to expected symbols, unmapped indices fall back correctly
### `MyPassTests` (integration)
- Unlock flow with a test KDBX fixture
- Add / edit / delete entry persisted to file
- Tree-flattening helper: entries paired with correct full `groupPath`, including nested subgroups
- Group-filter subtree selection: selecting a parent group selects all descendant subgroup IDs; deselecting a parent deselects the whole subtree
- Group filter + search combine as AND: narrowing by group then searching returns only the intersection
### `MyPassUITests`
- Unlock → browse groups → view entry detail → copy password
- Add entry → verify it appears in list
- Unlock → view flat entry list → tap entry → view detail → copy password
- Add entry → verify it appears in list with the group picker's chosen group
- Search: query returns expected entries
- Group filter: selecting a group in the sidebar narrows the list; selecting a parent with subgroups includes descendant entries
---