Files
keevault/docs/superpowers/plans/2026-09-19-flat-entry-list-ui.md
krissandClaude Sonnet 5 f2ce285839 docs: add implementation plan for flat entry list UI redesign
Task-by-task plan for the group-drilling -> flat filterable entry
list redesign approved in the design spec's UI Navigation section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYqycDFsynHH9VnnK7LNSf
2026-09-19 16:49:39 +02:00

39 KiB

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

// 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
cd MyPassCore && swift test --filter KeePassIconMapperTests

Expected: fails to build — KeePassIconMapper does not exist.

  • Step 3: Implement KeePassIconMapper
// 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
swift test --filter KeePassIconMapperTests
  • Step 5: Commit
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

// 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
swift test --filter GroupTreeTests

Expected: fails to build — FlatEntry/FlatGroup/flattenEntries/flattenGroups don't exist.

  • Step 3: Implement GroupTree
// 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
swift test --filter GroupTreeTests
  • Step 5: Commit
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

// 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
swift test --filter GroupSelectionTests

Expected: fails to build — GroupSelection does not exist.

  • Step 3: Implement GroupSelection
// 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
swift test --filter GroupSelectionTests
  • Step 5: Run the full MyPassCore suite before moving to app-target work
swift test

Expected: all tests pass (the pre-existing 18 plus the new ones from Tasks 1-3).

  • Step 6: Commit
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:

// 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:

// 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:

struct EntryDetailView: View {
    let entry: Entry
    let session: VaultSession

to:

struct EntryDetailView: View {
    let entry: Entry
    let groupId: UUID
    let session: VaultSession

and change:

        .sheet(isPresented: $showEditSheet) {
            EntryEditView(vm: EntryEditViewModel(session: session, existing: entry))
        }

to:

        .sheet(isPresented: $showEditSheet) {
            EntryEditView(vm: EntryEditViewModel(session: session, groupId: groupId, existing: entry))
        }
  • Step 4: Build (GroupBrowserView.swift is expected to fail here)

⌘B, or:

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
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

// 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
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

// 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
// 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
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

// 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
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
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 @StateObjects, 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
// 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
xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -i "error:"

Expected: no output (no errors).

xcodebuild -project MyPass.xcodeproj -scheme MyPass -destination 'platform=macOS' build 2>&1 | grep -i "error:"

Expected: no output.

  • Step 3: Commit
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
cd MyPassCore && swift test

Expected: all tests pass (18 pre-existing + the new ones from Tasks 1-3).

  • Step 2: Build both platforms clean
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)

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.)