Files
keevault/docs/superpowers/plans/2026-09-19-flat-entry-list-ui.md
T

1118 lines
39 KiB
Markdown
Raw Normal View History

# Flat Entry List UI Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace MyPass's group-drilling navigation (`GroupBrowserView`) with a flat, filterable entry list, a multi-select group-filter sidebar, and a toggleable search bar — unified across iOS and macOS via a single `NavigationSplitView`.
**Architecture:** Two new pure, unit-testable helpers in `MyPassCore` (tree-flattening for the breadcrumb/list, and group-subtree selection for the filter) back two new app-target view models (`VaultViewModel` gains filter state, `GroupFilterViewModel` is new). `ContentView` is rewritten around a `NavigationSplitView(columnVisibility:)` whose sidebar is the group filter and whose detail column is the new flat list, fixing a real state-loss bug in the existing view-model lifecycle along the way.
**Tech Stack:** Swift 5.9+, SwiftUI (`NavigationSplitView`, `.searchable(text:isPresented:)`), same `MyPassCore` package and `MyPass` app target as the rest of the project.
**Spec:** `docs/superpowers/specs/2026-05-21-mypass-design.md` — see the "UI Navigation" section (revised 2026-09-19) and the updated "Testing" section. Background/decisions on the original architecture (KeePassKit vendoring, security model, AutoFill extension) live in that same spec and aren't repeated here. The original task-by-task plan is `docs/superpowers/plans/2026-05-21-mypass.md` — Phases 1-4 there are done and committed; Phase 5 (AutoFill extension) is still pending and unaffected by this plan.
## Global Constraints
- Group filter and search combine as **AND** — group filter narrows first, then search filters within that result.
- Breadcrumbs (on entry rows and in the group picker) **exclude the vault's root group** — an entry directly in `root > Work > Email` shows as `Work > Email`, not `Root > Work > Email`.
- Entry icons are **always shown**, approximated from `iconIndex` via `KeePassIconMapper`; unmapped indices fall back to `"key.fill"`. This is a best-effort visual match, not exact KeePass artwork.
- `NavigationSplitView` is used on **both** iOS and macOS — no separate per-platform navigation code.
- Dark mode requires no new work — the codebase already uses only semantic SwiftUI colors.
- Selecting a group in the filter sidebar selects **that group and its entire subgroup subtree**; deselecting a parent deselects the whole subtree too.
---
## Task 1: KeePassIconMapper
**Files:**
- Create: `MyPassCore/Sources/MyPassCore/Icons/KeePassIconMapper.swift`
- Create: `MyPassCore/Tests/MyPassCoreTests/KeePassIconMapperTests.swift`
**Interfaces:**
- Produces: `KeePassIconMapper.symbolName(forIconIndex index: Int) -> String` — used by Task 7's `EntryRow`.
- [ ] **Step 1: Write the failing tests**
```swift
// MyPassCore/Tests/MyPassCoreTests/KeePassIconMapperTests.swift
import XCTest
@testable import MyPassCore
final class KeePassIconMapperTests: XCTestCase {
func test_knownIndex_returnsMappedSymbol() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 14), "envelope.fill")
}
func test_indexZero_isKeyFill() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 0), "key.fill")
}
func test_unmappedIndex_fallsBackToKeyFill() {
XCTAssertEqual(KeePassIconMapper.symbolName(forIconIndex: 999), "key.fill")
}
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
cd MyPassCore && swift test --filter KeePassIconMapperTests
```
Expected: fails to build — `KeePassIconMapper` does not exist.
- [ ] **Step 3: Implement KeePassIconMapper**
```swift
// MyPassCore/Sources/MyPassCore/Icons/KeePassIconMapper.swift
import Foundation
/// Maps KeePass's standard icon palette (indices 0-68) to an approximate SF Symbol.
/// This is a best-effort visual match, not the actual KeePass icon artwork -- MyPass's
/// data model only stores the numeric iconIndex, not custom icon images.
public enum KeePassIconMapper {
private static let symbolsByIndex: [Int: String] = [
0: "key.fill",
1: "globe",
2: "exclamationmark.triangle.fill",
3: "server.rack",
4: "doc.on.clipboard.fill",
5: "pencil",
6: "camera.fill",
7: "wrench.fill",
8: "person.text.rectangle.fill",
9: "externaldrive.fill",
10: "creditcard.fill",
11: "chevron.left.forwardslash.chevron.right",
12: "chart.bar.fill",
13: "gamecontroller.fill",
14: "envelope.fill",
15: "folder.fill",
16: "lock.fill",
17: "checkmark.circle.fill",
18: "star.fill",
19: "house.fill",
20: "network",
]
public static func symbolName(forIconIndex index: Int) -> String {
symbolsByIndex[index] ?? "key.fill"
}
}
```
- [ ] **Step 4: Run tests — all pass**
```bash
swift test --filter KeePassIconMapperTests
```
- [ ] **Step 5: Commit**
```bash
git add MyPassCore/Sources/MyPassCore/Icons/KeePassIconMapper.swift MyPassCore/Tests/MyPassCoreTests/KeePassIconMapperTests.swift
git commit -m "feat: add KeePassIconMapper (iconIndex -> SF Symbol)"
```
---
## Task 2: Tree-flattening helpers (FlatEntry, FlatGroup)
**Files:**
- Create: `MyPassCore/Sources/MyPassCore/Session/GroupTree.swift`
- Create: `MyPassCore/Tests/MyPassCoreTests/GroupTreeTests.swift`
**Interfaces:**
- Consumes: `Group` / `Entry` (existing, `MyPassCore/Sources/MyPassCore/Models/Group.swift` and `Entry.swift`).
- Produces:
- `public struct FlatEntry: Identifiable, Equatable { public let entry: Entry; public let parentGroupId: UUID; public let breadcrumb: [Group] }` — used by Task 5 (`VaultViewModel`) and Task 7 (`EntryListView`).
- `public struct FlatGroup: Identifiable, Equatable { public let group: Group; public let breadcrumb: [Group] }` — used by Task 4 (`EntryEditView`'s group picker).
- `public func flattenEntries(in root: Group) -> [FlatEntry]`
- `public func flattenGroups(in root: Group) -> [FlatGroup]` (includes `root` itself, first, with an empty breadcrumb).
- [ ] **Step 1: Write the failing tests**
```swift
// MyPassCore/Tests/MyPassCoreTests/GroupTreeTests.swift
import XCTest
@testable import MyPassCore
final class GroupTreeTests: XCTestCase {
private func makeSampleTree() -> (root: Group, work: Group, email: Group) {
let supportEntry = Entry(title: "Support Login", username: "alice")
let email = Group(name: "Email", entries: [supportEntry])
let bankEntry = Entry(title: "Bank", username: "bob")
let work = Group(name: "Work", subgroups: [email], entries: [bankEntry])
let rootEntry = Entry(title: "Root Entry", username: "root-user")
let root = Group(name: "Root", subgroups: [work], entries: [rootEntry])
return (root, work, email)
}
func test_flattenEntries_rootEntry_hasEmptyBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenEntries(in: root)
let rootEntry = flat.first { $0.entry.title == "Root Entry" }
XCTAssertEqual(rootEntry?.breadcrumb, [])
XCTAssertEqual(rootEntry?.parentGroupId, root.id)
}
func test_flattenEntries_directChildEntry_hasSingleGroupBreadcrumb() {
let (root, work, _) = makeSampleTree()
let flat = flattenEntries(in: root)
let bankEntry = flat.first { $0.entry.title == "Bank" }
XCTAssertEqual(bankEntry?.breadcrumb.map(\.name), ["Work"])
XCTAssertEqual(bankEntry?.parentGroupId, work.id)
}
func test_flattenEntries_nestedEntry_hasFullBreadcrumb() {
let (root, _, email) = makeSampleTree()
let flat = flattenEntries(in: root)
let supportEntry = flat.first { $0.entry.title == "Support Login" }
XCTAssertEqual(supportEntry?.breadcrumb.map(\.name), ["Work", "Email"])
XCTAssertEqual(supportEntry?.parentGroupId, email.id)
}
func test_flattenGroups_includesRootFirstWithEmptyBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenGroups(in: root)
XCTAssertEqual(flat.first?.group.id, root.id)
XCTAssertEqual(flat.first?.breadcrumb, [])
}
func test_flattenGroups_nestedGroup_hasBreadcrumb() {
let (root, _, _) = makeSampleTree()
let flat = flattenGroups(in: root)
let email = flat.first { $0.group.name == "Email" }
XCTAssertEqual(email?.breadcrumb.map(\.name), ["Work"])
}
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
swift test --filter GroupTreeTests
```
Expected: fails to build — `FlatEntry`/`FlatGroup`/`flattenEntries`/`flattenGroups` don't exist.
- [ ] **Step 3: Implement GroupTree**
```swift
// MyPassCore/Sources/MyPassCore/Session/GroupTree.swift
import Foundation
/// An entry paired with the full ancestor chain of groups leading to it, excluding the
/// vault's root group. Empty `breadcrumb` means the entry lives directly in the root group.
public struct FlatEntry: Identifiable, Equatable {
public let entry: Entry
public let parentGroupId: UUID
public let breadcrumb: [Group]
public var id: UUID { entry.id }
}
/// A group paired with the ancestor chain leading to its *parent*, excluding the vault's
/// root group. The root group itself appears with an empty breadcrumb.
public struct FlatGroup: Identifiable, Equatable {
public let group: Group
public let breadcrumb: [Group]
public var id: UUID { group.id }
}
/// Walks the vault tree once, pairing every entry with its full ancestor path.
public func flattenEntries(in root: Group) -> [FlatEntry] {
var result: [FlatEntry] = []
func walk(_ group: Group, ancestorBreadcrumb: [Group]) {
for entry in group.entries {
result.append(FlatEntry(entry: entry, parentGroupId: group.id, breadcrumb: ancestorBreadcrumb))
}
for sub in group.subgroups {
walk(sub, ancestorBreadcrumb: ancestorBreadcrumb + [sub])
}
}
walk(root, ancestorBreadcrumb: [])
return result
}
/// Walks the vault tree once, producing every group (including the root) with its ancestor path.
public func flattenGroups(in root: Group) -> [FlatGroup] {
var result: [FlatGroup] = [FlatGroup(group: root, breadcrumb: [])]
func walk(_ group: Group, ancestorBreadcrumb: [Group]) {
for sub in group.subgroups {
result.append(FlatGroup(group: sub, breadcrumb: ancestorBreadcrumb))
walk(sub, ancestorBreadcrumb: ancestorBreadcrumb + [sub])
}
}
walk(root, ancestorBreadcrumb: [])
return result
}
```
- [ ] **Step 4: Run tests — all pass**
```bash
swift test --filter GroupTreeTests
```
- [ ] **Step 5: Commit**
```bash
git add MyPassCore/Sources/MyPassCore/Session/GroupTree.swift MyPassCore/Tests/MyPassCoreTests/GroupTreeTests.swift
git commit -m "feat: add flattenEntries/flattenGroups tree-flattening helpers"
```
---
## Task 3: GroupSelection (subtree select/deselect)
**Files:**
- Create: `MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift`
- Create: `MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift`
**Interfaces:**
- Consumes: `Group` (existing).
- Produces:
- `public static func GroupSelection.subtreeIds(of groupId: UUID, in root: Group) -> Set<UUID>`
- `public static func GroupSelection.toggling(_ groupId: UUID, in root: Group, current: Set<UUID>) -> Set<UUID>` — used by Task 6 (`GroupFilterViewModel`).
- [ ] **Step 1: Write the failing tests**
```swift
// MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift
import XCTest
@testable import MyPassCore
final class GroupSelectionTests: XCTestCase {
private func makeSampleTree() -> (root: Group, work: Group, email: Group, personal: Group) {
let email = Group(name: "Email")
let work = Group(name: "Work", subgroups: [email])
let personal = Group(name: "Personal")
let root = Group(name: "Root", subgroups: [work, personal])
return (root, work, email, personal)
}
func test_subtreeIds_includesGroupAndAllDescendants() {
let (root, work, email, _) = makeSampleTree()
let ids = GroupSelection.subtreeIds(of: work.id, in: root)
XCTAssertEqual(ids, [work.id, email.id])
}
func test_subtreeIds_leafGroup_returnsOnlyItself() {
let (root, _, email, _) = makeSampleTree()
let ids = GroupSelection.subtreeIds(of: email.id, in: root)
XCTAssertEqual(ids, [email.id])
}
func test_toggling_selectsGroupAndSubtree() {
let (root, work, email, _) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [])
XCTAssertEqual(result, [work.id, email.id])
}
func test_toggling_deselectsGroupAndSubtree() {
let (root, work, email, _) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [work.id, email.id])
XCTAssertEqual(result, [])
}
func test_toggling_doesNotAffectUnrelatedSelection() {
let (root, work, email, personal) = makeSampleTree()
let result = GroupSelection.toggling(work.id, in: root, current: [personal.id])
XCTAssertEqual(result, [personal.id, work.id, email.id])
}
}
```
- [ ] **Step 2: Run to confirm failure**
```bash
swift test --filter GroupSelectionTests
```
Expected: fails to build — `GroupSelection` does not exist.
- [ ] **Step 3: Implement GroupSelection**
```swift
// MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift
import Foundation
public enum GroupSelection {
/// `groupId` and all of its descendant subgroup IDs, found by searching `root`'s tree.
/// Returns an empty set if `groupId` isn't found anywhere in `root`.
public static func subtreeIds(of groupId: UUID, in root: Group) -> Set<UUID> {
guard let found = findGroup(groupId, in: root) else { return [] }
return allIds(in: found)
}
/// Toggles `groupId` and its full subtree in `current`: if the group is already selected,
/// it and its subtree are removed; otherwise they're added.
public static func toggling(_ groupId: UUID, in root: Group, current: Set<UUID>) -> Set<UUID> {
let subtree = subtreeIds(of: groupId, in: root)
guard !subtree.isEmpty else { return current }
if current.contains(groupId) {
return current.subtracting(subtree)
} else {
return current.union(subtree)
}
}
private static func findGroup(_ id: UUID, in group: Group) -> Group? {
if group.id == id { return group }
for sub in group.subgroups {
if let found = findGroup(id, in: sub) { return found }
}
return nil
}
private static func allIds(in group: Group) -> Set<UUID> {
var ids: Set<UUID> = [group.id]
for sub in group.subgroups {
ids.formUnion(allIds(in: sub))
}
return ids
}
}
```
- [ ] **Step 4: Run tests — all pass**
```bash
swift test --filter GroupSelectionTests
```
- [ ] **Step 5: Run the full MyPassCore suite before moving to app-target work**
```bash
swift test
```
Expected: all tests pass (the pre-existing 18 plus the new ones from Tasks 1-3).
- [ ] **Step 6: Commit**
```bash
git add MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift
git commit -m "feat: add GroupSelection subtree select/deselect logic"
```
---
## Task 4: EntryEditViewModel groupId + group picker, EntryDetailView groupId
**Files:**
- Modify: `MyPass/ViewModels/EntryEditViewModel.swift`
- Modify: `MyPass/Views/EntryEditView.swift`
- Modify: `MyPass/Views/EntryDetailView.swift`
**Interfaces:**
- Consumes: `flattenGroups(in:)` (Task 2).
- Produces: `EntryEditViewModel.init(session:groupId:existing:)` where `groupId: UUID` is now **required** (no default) — used by Task 7 (`EntryListView`)'s add-entry sheet.
> **Note:** this task intentionally breaks `GroupBrowserView.swift`'s compile (it still calls the 2-argument `EntryEditViewModel(session:existing:)` form for editing, which no longer exists). That's expected and fixed in Task 7, which deletes `GroupBrowserView.swift` entirely — matching the same "build settles a few tasks later" pattern already used in this project's original plan (see `docs/superpowers/plans/2026-05-21-mypass.md` Task 18).
- [ ] **Step 1: Make groupId required in EntryEditViewModel**
Replace the whole file:
```swift
// MyPass/ViewModels/EntryEditViewModel.swift
import Foundation
import Combine
import SwiftUI
import MyPassCore
@MainActor
final class EntryEditViewModel: ObservableObject {
@Published var title: String
@Published var username: String
@Published var password: String
@Published var url: String
@Published var notes: String
@Published var customFields: [CustomField]
@Published var groupId: UUID
@Published var errorMessage: String?
@Published var isSaving: Bool = false
let session: VaultSession
private let existingEntry: Entry?
/// `groupId` is the target group for a new entry, or the entry's current group when
/// editing (shown read-only in that case -- MyPass doesn't support re-parenting an
/// existing entry to a different group yet).
init(session: VaultSession, groupId: UUID, existing: Entry? = nil) {
self.session = session
self.groupId = groupId
self.existingEntry = existing
title = existing?.title ?? ""
username = existing?.username ?? ""
password = existing?.password.reveal() ?? ""
url = existing?.url ?? ""
notes = existing?.notes ?? ""
customFields = existing?.customFields ?? []
}
var isEditing: Bool { existingEntry != nil }
var flatGroups: [FlatGroup] {
guard let root = session.database?.root else { return [] }
return flattenGroups(in: root)
}
func save(dismiss: @escaping () -> Void) {
Task {
isSaving = true
errorMessage = nil
do {
if var entry = existingEntry {
entry.title = title
entry.username = username
entry.password = ProtectedString(password, isProtected: true)
entry.url = url
entry.notes = notes
entry.customFields = customFields
try session.updateEntry(entry)
} else {
let entry = Entry(
title: title,
username: username,
password: ProtectedString(password, isProtected: true),
url: url,
notes: notes,
customFields: customFields
)
try session.addEntry(entry, toGroupId: groupId)
}
dismiss()
} catch {
errorMessage = error.localizedDescription
}
isSaving = false
}
}
func addCustomField() {
customFields.append(CustomField(key: "", value: ProtectedString("", isProtected: false)))
}
func removeCustomField(at offsets: IndexSet) {
customFields.remove(atOffsets: offsets)
}
}
```
- [ ] **Step 2: Add the group picker to EntryEditView**
Replace the whole file:
```swift
// MyPass/Views/EntryEditView.swift
import SwiftUI
import MyPassCore
struct EntryEditView: View {
@ObservedObject var vm: EntryEditViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
if !vm.isEditing {
Section("Group") {
Picker("Group", selection: $vm.groupId) {
ForEach(vm.flatGroups) { flatGroup in
Text(groupLabel(for: flatGroup)).tag(flatGroup.group.id)
}
}
}
}
Section("Credentials") {
TextField("Title", text: $vm.title)
TextField("Username", text: $vm.username)
.textContentType(.username)
.autocorrectionDisabled()
SecureField("Password", text: $vm.password)
.textContentType(.password)
TextField("URL", text: $vm.url)
.textContentType(.URL)
#if os(iOS)
.keyboardType(.URL)
#endif
.autocorrectionDisabled()
}
Section("Notes") {
TextEditor(text: $vm.notes)
.frame(minHeight: 80)
}
Section {
ForEach($vm.customFields) { $field in
HStack {
TextField("Key", text: $field.key)
Divider()
TextField("Value", text: Binding(
get: { field.value.reveal() },
set: { field.value = ProtectedString($0, isProtected: field.value.isProtected) }
))
}
}
.onDelete(perform: vm.removeCustomField)
Button("Add Field", action: vm.addCustomField)
} header: {
Text("Custom Fields")
}
if let msg = vm.errorMessage {
Section { Text(msg).foregroundStyle(.red) }
}
}
.navigationTitle(vm.isEditing ? "Edit Entry" : "New Entry")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { vm.save { dismiss() } }
.disabled(vm.title.isEmpty || vm.isSaving)
}
}
}
}
private func groupLabel(for flatGroup: FlatGroup) -> String {
(flatGroup.breadcrumb.map(\.name) + [flatGroup.group.name]).joined(separator: " > ")
}
}
```
- [ ] **Step 3: Give EntryDetailView the entry's group id**
`EntryDetailView` currently only receives `entry: Entry`, but constructing `EntryEditViewModel` for its edit sheet now requires a `groupId`. Add a `groupId: UUID` parameter:
In `MyPass/Views/EntryDetailView.swift`, change:
```swift
struct EntryDetailView: View {
let entry: Entry
let session: VaultSession
```
to:
```swift
struct EntryDetailView: View {
let entry: Entry
let groupId: UUID
let session: VaultSession
```
and change:
```swift
.sheet(isPresented: $showEditSheet) {
EntryEditView(vm: EntryEditViewModel(session: session, existing: entry))
}
```
to:
```swift
.sheet(isPresented: $showEditSheet) {
EntryEditView(vm: EntryEditViewModel(session: session, groupId: groupId, existing: entry))
}
```
- [ ] **Step 4: Build (GroupBrowserView.swift is expected to fail here)**
`⌘B`, or:
```bash
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: errors only in `GroupBrowserView.swift` (both its `EntryDetailView(entry:session:)` calls, missing the new `groupId:` argument, and its `EntryEditViewModel(session:existing:)` call, missing `groupId:`). No errors in any other file.
- [ ] **Step 5: Commit**
```bash
git add MyPass/ViewModels/EntryEditViewModel.swift MyPass/Views/EntryEditView.swift MyPass/Views/EntryDetailView.swift
git commit -m "feat: require groupId in EntryEditViewModel, add group picker to EntryEditView"
```
---
## Task 5: VaultViewModel — group + search filtering
**Files:**
- Modify: `MyPass/ViewModels/VaultViewModel.swift`
**Interfaces:**
- Consumes: `flattenEntries(in:)`, `FlatEntry` (Task 2).
- Produces: `VaultViewModel.selectedGroupIds: Set<UUID>` (published), `VaultViewModel.displayedEntries: [FlatEntry]`, `VaultViewModel.deleteEntry(_ flatEntry: FlatEntry)` — used by Task 6 (`ContentView`'s `.onChange`) and Task 7 (`EntryListView`).
- [ ] **Step 1: Replace the whole file**
```swift
// MyPass/ViewModels/VaultViewModel.swift
import Foundation
import Combine
import MyPassCore
@MainActor
final class VaultViewModel: ObservableObject {
@Published var searchQuery: String = ""
@Published var selectedGroupIds: Set<UUID> = []
@Published var errorMessage: String?
let session: VaultSession
init(session: VaultSession) {
self.session = session
}
/// All entries in the vault, flattened with their group breadcrumb, before any filtering.
private var allFlatEntries: [FlatEntry] {
guard let root = session.database?.root else { return [] }
return flattenEntries(in: root)
}
/// Entries after applying the group filter, then the search filter (AND semantics).
var displayedEntries: [FlatEntry] {
var entries = allFlatEntries
if !selectedGroupIds.isEmpty {
entries = entries.filter { selectedGroupIds.contains($0.parentGroupId) }
}
if !searchQuery.isEmpty {
let q = searchQuery.lowercased()
entries = entries.filter {
$0.entry.title.lowercased().contains(q)
|| $0.entry.username.lowercased().contains(q)
|| $0.entry.url.lowercased().contains(q)
}
}
return entries
}
func deleteEntry(_ flatEntry: FlatEntry) {
Task {
do { try session.deleteEntry(id: flatEntry.entry.id, fromGroupId: flatEntry.parentGroupId) }
catch { errorMessage = error.localizedDescription }
}
}
}
```
- [ ] **Step 2: Build**
`⌘B`. Expected: same errors as Task 4's end state (still only `GroupBrowserView.swift`, which references the now-removed `filteredEntries`/`isSearching`/old `deleteEntry(_:fromGroup:)` too) — no new errors elsewhere.
- [ ] **Step 3: Commit**
```bash
git add MyPass/ViewModels/VaultViewModel.swift
git commit -m "feat: add group + search filtering to VaultViewModel"
```
---
## Task 6: GroupFilterViewModel + GroupFilterView (sidebar)
**Files:**
- Create: `MyPass/ViewModels/GroupFilterViewModel.swift`
- Create: `MyPass/Views/GroupFilterView.swift`
**Interfaces:**
- Consumes: `GroupSelection.toggling(_:in:current:)` (Task 3).
- Produces: `GroupFilterViewModel.selectedGroupIds: Set<UUID>` (published) — used by Task 8 (`ContentView`'s `.onChange` syncs this into `VaultViewModel.selectedGroupIds`). `GroupFilterView(vm:rootGroup:)` — used by Task 8.
- [ ] **Step 1: Write GroupFilterViewModel**
```swift
// MyPass/ViewModels/GroupFilterViewModel.swift
import Foundation
import Combine
import MyPassCore
@MainActor
final class GroupFilterViewModel: ObservableObject {
@Published var selectedGroupIds: Set<UUID> = []
private let session: VaultSession
init(session: VaultSession) {
self.session = session
}
func isSelected(_ group: MyPassCore.Group) -> Bool {
selectedGroupIds.contains(group.id)
}
func toggle(_ group: MyPassCore.Group) {
guard let root = session.database?.root else { return }
selectedGroupIds = GroupSelection.toggling(group.id, in: root, current: selectedGroupIds)
}
func clear() {
selectedGroupIds = []
}
}
```
- [ ] **Step 2: Write GroupFilterView**
```swift
// MyPass/Views/GroupFilterView.swift
import SwiftUI
import MyPassCore
struct GroupFilterView: View {
@ObservedObject var vm: GroupFilterViewModel
let rootGroup: MyPassCore.Group
var body: some View {
List {
if !vm.selectedGroupIds.isEmpty {
Button("Clear Filter", action: vm.clear)
}
GroupFilterNode(group: rootGroup, vm: vm)
}
.navigationTitle("Groups")
}
}
private struct GroupFilterNode: View {
let group: MyPassCore.Group
@ObservedObject var vm: GroupFilterViewModel
var body: some View {
if group.subgroups.isEmpty {
row
} else {
DisclosureGroup {
ForEach(group.subgroups) { sub in
GroupFilterNode(group: sub, vm: vm)
}
} label: {
row
}
}
}
private var row: some View {
Button {
vm.toggle(group)
} label: {
HStack {
Image(systemName: vm.isSelected(group) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(vm.isSelected(group) ? Color.accentColor : Color.secondary)
Label(group.name, systemImage: "folder")
}
}
.buttonStyle(.plain)
}
}
```
- [ ] **Step 3: Build**
`⌘B`. Expected: same `GroupBrowserView.swift`-only errors as before — no new errors.
- [ ] **Step 4: Commit**
```bash
git add MyPass/ViewModels/GroupFilterViewModel.swift MyPass/Views/GroupFilterView.swift
git commit -m "feat: add GroupFilterViewModel and GroupFilterView sidebar"
```
---
## Task 7: EntryListView (replaces GroupBrowserView), delete old files
**Files:**
- Create: `MyPass/Views/EntryListView.swift`
- Delete: `MyPass/Views/GroupBrowserView.swift`
- Delete: `MyPass/Views/SearchView.swift` (its role — a standalone filtered-results view — is fully absorbed by `EntryListView`'s own `.searchable()`)
**Interfaces:**
- Consumes: `VaultViewModel.displayedEntries`/`.deleteEntry(_:)` (Task 5), `KeePassIconMapper.symbolName(forIconIndex:)` (Task 1), `EntryDetailView(entry:groupId:session:)` (Task 4).
- Produces: `EntryListView(vm:)` — used by Task 8 (`ContentView`).
- [ ] **Step 1: Write EntryListView**
```swift
// MyPass/Views/EntryListView.swift
import SwiftUI
import MyPassCore
struct EntryListView: View {
@ObservedObject var vm: VaultViewModel
@State private var isSearching = false
@State private var showAddEntry = false
var body: some View {
List {
ForEach(vm.displayedEntries) { flatEntry in
NavigationLink(
destination: EntryDetailView(
entry: flatEntry.entry,
groupId: flatEntry.parentGroupId,
session: vm.session
)
) {
EntryRow(flatEntry: flatEntry)
}
}
.onDelete { offsets in
offsets.map { vm.displayedEntries[$0] }.forEach(vm.deleteEntry)
}
}
.navigationTitle("MyPass")
.searchable(text: $vm.searchQuery, isPresented: $isSearching, prompt: "Search entries…")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button { isSearching.toggle() } label: {
Image(systemName: "magnifyingglass")
}
}
ToolbarItem(placement: .primaryAction) {
Button { showAddEntry = true } label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showAddEntry) {
EntryEditView(vm: EntryEditViewModel(session: vm.session, groupId: defaultGroupId))
}
.alert("Error", isPresented: Binding(
get: { vm.errorMessage != nil },
set: { if !$0 { vm.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { vm.errorMessage = nil }
} message: {
Text(vm.errorMessage ?? "")
}
}
/// The sole selected filter group if exactly one is active, otherwise the vault root.
private var defaultGroupId: UUID {
if vm.selectedGroupIds.count == 1, let only = vm.selectedGroupIds.first {
return only
}
return vm.session.database?.root.id ?? UUID()
}
}
private struct EntryRow: View {
let flatEntry: FlatEntry
private var breadcrumbText: String? {
guard !flatEntry.breadcrumb.isEmpty else { return nil }
return flatEntry.breadcrumb.map(\.name).joined(separator: " > ")
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: KeePassIconMapper.symbolName(forIconIndex: flatEntry.entry.iconIndex))
.foregroundStyle(.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(flatEntry.entry.title).font(.body)
if let breadcrumbText {
Text("\(flatEntry.entry.username) · \(breadcrumbText)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
} else {
Text(flatEntry.entry.username)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
```
- [ ] **Step 2: Delete the superseded files**
```bash
git rm MyPass/Views/GroupBrowserView.swift MyPass/Views/SearchView.swift
```
- [ ] **Step 3: Build**
`⌘B`. Expected: errors now move to `ContentView.swift` (it still references the just-deleted `GroupBrowserView`, plus the old macOS-only `MacVaultView`/`GroupSidebarView`/`GroupNode` structs defined inside it, which used the old single-select `Group?` sidebar model). No errors anywhere else. This is expected — Task 8 rewrites `ContentView.swift` next.
- [ ] **Step 4: Commit**
```bash
git add MyPass/Views/EntryListView.swift
git commit -m "feat: add EntryListView, remove GroupBrowserView and SearchView"
```
---
## Task 8: ContentView — unified NavigationSplitView, fix view-model lifecycle bug
**Files:**
- Modify: `MyPass/ContentView.swift`
**Interfaces:**
- Consumes: `GroupFilterView(vm:rootGroup:)` (Task 6), `EntryListView(vm:)` (Task 7), `VaultViewModel`/`GroupFilterViewModel` (Tasks 5-6).
> **Note on a pre-existing bug this task fixes:** the current `ContentView.vaultView` is a `@ViewBuilder` computed property that does `let vaultVM = VaultViewModel(session: session)` inline. Because it's recomputed on *every* re-render of `ContentView` -- which happens on any `@Published` change on `session`, including ones unrelated to locking (e.g. `session.database` changing after every entry add/edit/delete) -- this silently creates a **brand new** `VaultViewModel` each time, discarding the user's search query and (with this plan's changes) group filter selection on every entry edit. This plan fixes it by moving the view models into a child view's `@StateObject`s, which SwiftUI preserves across re-renders as long as that child view's identity in the tree doesn't change (it only gets recreated when the app actually transitions from locked to unlocked, which is exactly when losing that state is fine/expected).
- [ ] **Step 1: Replace the whole file**
```swift
// MyPass/ContentView.swift
//
// ContentView.swift
// MyPass
//
// Created by Christophe Vila on 21/05/2026.
//
import SwiftUI
import MyPassCore
#if os(macOS)
import AppKit
#endif
struct ContentView: View {
@StateObject private var session = VaultSession()
var body: some View {
SwiftUI.Group {
if session.isLocked {
UnlockView(vm: UnlockViewModel(session: session))
} else {
VaultRootView(session: session)
}
}
.onReceive(
NotificationCenter.default.publisher(for: sceneBackgroundNotification)
) { _ in
session.lock()
}
}
private var sceneBackgroundNotification: Notification.Name {
#if os(iOS)
UIScene.didEnterBackgroundNotification
#else
NSApplication.didResignActiveNotification
#endif
}
}
/// Owns the vault/group-filter view models for as long as the vault stays unlocked -- their
/// state (search query, group filter selection) persists across re-renders but resets if the
/// vault locks and is unlocked again (a fresh VaultRootView is created), which matches the
/// expected "locking clears your filter" behavior.
private struct VaultRootView: View {
@StateObject private var vaultVM: VaultViewModel
@StateObject private var filterVM: GroupFilterViewModel
@State private var columnVisibility: NavigationSplitViewVisibility = .all
init(session: VaultSession) {
_vaultVM = StateObject(wrappedValue: VaultViewModel(session: session))
_filterVM = StateObject(wrappedValue: GroupFilterViewModel(session: session))
}
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
if let root = vaultVM.session.database?.root {
GroupFilterView(vm: filterVM, rootGroup: root)
}
} detail: {
NavigationStack {
EntryListView(vm: vaultVM)
.toolbar {
ToolbarItem(placement: .navigation) {
Button {
columnVisibility = columnVisibility == .all ? .detailOnly : .all
} label: {
Image(systemName: "line.3.horizontal")
}
}
}
}
}
.onChange(of: filterVM.selectedGroupIds) { _, newValue in
vaultVM.selectedGroupIds = newValue
}
}
}
```
- [ ] **Step 2: Build — should now succeed with zero errors**
```bash
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"
```
Expected: no output (no errors).
```bash
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'platform=macOS' build 2>&1 | grep -i "error:"
```
Expected: no output.
- [ ] **Step 3: Commit**
```bash
git add MyPass/ContentView.swift
git commit -m "feat: unify iOS/macOS navigation as NavigationSplitView, fix VM lifecycle"
```
---
## Task 9: Final verification
**Files:** none (verification only).
- [ ] **Step 1: Run the full MyPassCore test suite**
```bash
cd MyPassCore && swift test
```
Expected: all tests pass (18 pre-existing + the new ones from Tasks 1-3).
- [ ] **Step 2: Build both platforms clean**
```bash
cd ..
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | tail -5
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'platform=macOS' build 2>&1 | tail -5
```
Expected: `** BUILD SUCCEEDED **` for both.
- [ ] **Step 3: Manual smoke test in the Simulator**
Install and launch on a booted simulator (see this project's existing pattern for doing so), then verify:
- Unlocking shows a flat entry list (not a group tree) with icon, username, and breadcrumb per row
- Tapping the hamburger icon shows/hides the group filter sidebar
- Selecting a group with subgroups in the sidebar selects the subgroups too, and narrows the entry list to just that subtree
- Typing in search (after tapping the search icon) further narrows the already-group-filtered list (AND, not OR)
- Tapping `+` opens the add-entry sheet with the group picker defaulted per the rule in Task 7's `defaultGroupId`
- Editing an existing entry does *not* show the group picker (group picker is add-only per Task 4)
- Deleting an entry via swipe removes it from the list and the underlying vault
- [ ] **Step 4: Final commit (if the smoke test surfaced fixes)**
```bash
git add -A
git commit -m "fix: address issues found in flat entry list manual verification"
```
(Skip this step if the smoke test found nothing to fix.)