import SwiftUI /// The lane list for one board — `BoardRoute.lanes`'s destination. /// /// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its /// snapshot are pulled from the environment's index store fresh on every body evaluation, so a /// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment /// the session's reload lands. Long-pressing a row drags it to a new position, reordering the /// board's own manual arrangement. struct BoardScreen: View { let boardRoot: URL @Environment(BoardIndexStore.self) private var index @State private var isPresentingNewLane = false private var session: BoardSession { index.session(forBoardAt: boardRoot) } var body: some View { content .navigationTitle(title) .toolbar { if case .ready = session.phase { Button("New Lane", systemImage: "plus") { isPresentingNewLane = true } } } .task { session.open() } .onDisappear { // Stops the materializing/downloading retry timer. In practice a no-op here: a // lane row (the only thing on this screen that pushes forward) only renders in // `.ready`, the one phase where `close()`'s `cancelRetry()` has nothing to cancel — // so pushing deeper into the board costs nothing, and popping back out to the // board list is where this actually stops the timer. session.close() } .titlePromptAlert("New Lane", isPresented: $isPresentingNewLane, placeholder: "Lane Title") { title in let newTitle: String? = title.isEmpty ? nil : title Task { // The closure's throws type must be spelled out — a trailing closure literal // does not pick up `perform`'s `throws(BoardWriteError)` from context alone. await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in try BoardWriter.createLane(inBoard: root, title: newTitle) } } } } private var title: String { // The same fallback `BoardSummaryScanner` uses — the folder name minus `.kanban` — so the // title never blanks out while the session is still opening. session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent } @ViewBuilder private var content: some View { switch session.phase { case .idle, .loading: ProgressView("Opening board") case let .materializing(progress): ProgressView(value: progress.fractionMaterialized) { Text("Downloading board") } .padding() case let .failed(error): ContentUnavailableView { Label("Can't Open Board", systemImage: "exclamationmark.triangle") } description: { Text(error.description) } actions: { Button("Try Again") { session.reload() } } case .ready: if let snapshot = session.snapshot { laneList(snapshot) } else { // Unreachable in practice — `.ready` is only ever set alongside a landed load, // which sets `snapshot` in the same step (`BoardSession.apply`). Kept so the // switch is exhaustive without a force-unwrap. ProgressView("Opening board") } } } @ViewBuilder private func laneList(_ snapshot: BoardModel) -> some View { List { if session.lastError != nil { Section { Label("Showing the last saved version — a recent update didn't load.", systemImage: "exclamationmark.triangle") .font(.footnote) .foregroundStyle(.secondary) } } if snapshot.lanes.isEmpty { ContentUnavailableView( "No Lanes Yet", systemImage: "rectangle.split.3x1", description: Text("Add a lane to start organizing cards.") ) } else { // `snapshot.lanes` is already in display order (the loader's `Ranks.sortedForDisplay`), // so it's trusted here rather than re-sorted. ForEach(snapshot.lanes) { lane in NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) { LaneSummaryRow(lane: lane) } } .onMove(perform: { moveRows(from: $0, to: $1) }) } } .refreshable { session.reload() } } /// Commits a lane drag's release. /// /// `manualOrder` is `snapshot.lanes` with the drag already applied locally (`Array.move`), so the /// dragged lane's post-drag index is both the row SwiftUI just drew and the position /// `Ranks.insertionRank` mints a rank against — the same index convention `BoardStore.moveLane` /// uses on the Mac. The happy path asks for the midpoint between the two neighbours the lane /// lands between; on the rare board where that gap is already exhausted /// (01-storage-format.md § Ordering), there is no `HealScheduler` to reach for — that engine /// lives in `Kanban/LiveStore`, which this target does not compile — so the fallback renumbers /// every lane to a fresh whole-multiple-of-1024 ladder in `manualOrder`'s own order, one /// `BoardWriter.moveItem` reorder call per lane inside the same `session.perform` bracket. Both /// paths are same-parent reorders, so neither stamps `modified` (the reorders-don't-stamp rule). private func moveRows(from source: IndexSet, to destination: Int) { guard let sourceIndex = source.first, let snapshot = session.snapshot, snapshot.lanes.indices.contains(sourceIndex) else { return } var manualOrder = snapshot.lanes let moved = manualOrder[sourceIndex] manualOrder.move(fromOffsets: IndexSet(integer: sourceIndex), toOffset: destination) guard let landingIndex = manualOrder.firstIndex(where: { $0.id == moved.id }) else { return } let siblingOrders = manualOrder.filter { $0.id != moved.id }.map(\.order) // A `let` copy — the mutating `Array.move` above needs `manualOrder` as a `var`, but a // `@Sendable` closure cannot capture a mutable var, and the fallback loop below needs the // finished (post-move) arrangement, not the pre-move one. let finalOrder = manualOrder Task { await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in if let rank = Ranks.insertionRank(amongVisible: siblingOrders, at: landingIndex) { return try BoardWriter.moveItem( at: root.appendingPathComponent(moved.id.rawValue, isDirectory: true), toParent: root, sourceBoardRoot: root, destinationBoardRoot: root, order: rank ) } for (lane, freshRank) in zip(finalOrder, Ranks.renumbered(count: finalOrder.count)) { _ = try BoardWriter.moveItem( at: root.appendingPathComponent(lane.id.rawValue, isDirectory: true), toParent: root, sourceBoardRoot: root, destinationBoardRoot: root, order: freshRank ) } return MoveResult(id: moved.id, reminted: []) } } } } /// One lane row: its icon when it has one, title, and its card count. private struct LaneSummaryRow: View { let lane: Lane var body: some View { HStack { ItemIconView(icon: lane.icon.value, iconColor: lane.iconColor.value) VStack(alignment: .leading, spacing: 2) { Text(displayTitle) Text("\(lane.cards.count) card\(lane.cards.count == 1 ? "" : "s")") .font(.caption) .foregroundStyle(.secondary) } } } private var displayTitle: String { guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" } return title } }