diff --git a/docs/superpowers/plans/2026-09-19-flat-entry-list-ui.md b/docs/superpowers/plans/2026-09-19-flat-entry-list-ui.md new file mode 100644 index 0000000..60db9b4 --- /dev/null +++ b/docs/superpowers/plans/2026-09-19-flat-entry-list-ui.md @@ -0,0 +1,1117 @@ +# 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` + - `public static func GroupSelection.toggling(_ groupId: UUID, in root: Group, current: Set) -> Set` — 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 { + 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) -> Set { + 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 { + var ids: Set = [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` (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 = [] + @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` (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 = [] + + 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.)