import SwiftUI /// The card list for one lane — `BoardRoute.cards`'s destination. /// /// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from /// `session.snapshot` on every body evaluation, so a write this screen makes — or one relayed /// from elsewhere through the metadata query — reaches the list the moment the session's reload /// lands, and a lane deleted on another device is noticed rather than shown stale. Long-pressing /// a card drags it to a new position, reordering the lane's own manual arrangement. struct LaneScreen: View { let boardRoot: URL let laneID: ItemID @Environment(BoardIndexStore.self) private var index @Environment(\.dismiss) private var dismiss @State private var isPresentingNewCard = false @State private var cardPendingMove: ItemID? private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var lane: Lane? { session.snapshot?.lanes.first { $0.id == laneID } } var body: some View { content .navigationTitle(navigationTitle) .toolbar { if lane != nil { Button("New Card", systemImage: "plus") { isPresentingNewCard = true } } } .task { session.open() } .titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in createCard(titled: title.isEmpty ? nil : title) } .confirmationDialog( "Move Card", isPresented: Binding(get: { cardPendingMove != nil }, set: { if !$0 { cardPendingMove = nil } }), titleVisibility: .visible ) { ForEach(otherLanes) { destination in Button(displayTitle(of: destination)) { if let cardID = cardPendingMove { move(cardID, to: destination.id) } cardPendingMove = nil } } Button("Cancel", role: .cancel) { cardPendingMove = nil } } } private var navigationTitle: String { lane.map(displayTitle(of:)) ?? "Lane" } private var otherLanes: [Lane] { (session.snapshot?.lanes ?? []).filter { $0.id != laneID } } private func displayTitle(of lane: Lane) -> String { guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" } return title } @ViewBuilder private var content: some View { if let lane { 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 lane.cards.isEmpty { ContentUnavailableView( "No Cards Yet", systemImage: "rectangle.on.rectangle", description: Text("Add a card to this lane.") ) } else { // `lane.cards` is already in display order, exactly as the lane list trusts // `BoardModel.lanes` — so it's trusted here rather than re-sorted. ForEach(lane.cards) { card in NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) { CardSummaryRow(card: card) } .swipeActions(edge: .trailing) { Button(role: .destructive) { trash(card.id) } label: { Label("Trash", systemImage: "trash") } } .swipeActions(edge: .leading) { if !otherLanes.isEmpty { Button { cardPendingMove = card.id } label: { Label("Move", systemImage: "arrow.right.arrow.left") } .tint(.blue) } } } .onMove(perform: { moveRows(from: $0, to: $1) }) } } .refreshable { session.reload() } } else if case .ready = session.phase { // The lane is gone from a `.ready` snapshot — deleted elsewhere while this screen was // open. Nothing left to list; back out rather than leave a dead screen on top of the // stack. ContentUnavailableView("Lane Removed", systemImage: "trash", description: Text("This lane was deleted.")) .task { dismiss() } } else { ProgressView("Opening lane") } } private func createCard(titled title: String?) { let laneID = self.laneID 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 let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) return try BoardWriter.createCard(inLane: laneFolder, title: title) } } } private func trash(_ cardID: ItemID) { let laneID = self.laneID Task { await session.perform { (root: URL) throws(BoardWriteError) -> ItemID in let cardFolder = root .appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(cardID.rawValue, isDirectory: true) return try BoardWriter.deleteCardToTrash(at: cardFolder, inBoard: root) } } } private func move(_ cardID: ItemID, to destinationLaneID: ItemID) { let laneID = self.laneID Task { await session.perform { (root: URL) throws(BoardWriteError) -> MoveResult in let sourceFolder = root .appendingPathComponent(laneID.rawValue, isDirectory: true) .appendingPathComponent(cardID.rawValue, isDirectory: true) let destinationParent = root.appendingPathComponent(destinationLaneID.rawValue, isDirectory: true) // `order: nil` — append at the end of the destination lane's visible cards, the // same placement a hand-created card gets. return try BoardWriter.moveItem( at: sourceFolder, toParent: destinationParent, sourceBoardRoot: root, destinationBoardRoot: root, order: nil ) } } } /// Commits a card drag's release. /// /// `manualOrder` is `lane.cards` with the drag already applied locally (`Array.move`), so the /// dragged card'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 for lanes. The happy path asks for the midpoint between the two neighbours the /// card lands between; on the rare lane 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 card in the lane to a fresh whole-multiple-of-1024 ladder in `manualOrder`'s own order, /// one `BoardWriter.moveItem` reorder call per card 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 lane, lane.cards.indices.contains(sourceIndex) else { return } var manualOrder = lane.cards 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) let laneID = self.laneID // 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 let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true) if let rank = Ranks.insertionRank(amongVisible: siblingOrders, at: landingIndex) { return try BoardWriter.moveItem( at: laneFolder.appendingPathComponent(moved.id.rawValue, isDirectory: true), toParent: laneFolder, sourceBoardRoot: root, destinationBoardRoot: root, order: rank ) } for (card, freshRank) in zip(finalOrder, Ranks.renumbered(count: finalOrder.count)) { _ = try BoardWriter.moveItem( at: laneFolder.appendingPathComponent(card.id.rawValue, isDirectory: true), toParent: laneFolder, sourceBoardRoot: root, destinationBoardRoot: root, order: freshRank ) } return MoveResult(id: moved.id, reminted: []) } } } } /// One card row: its icon when it has one, title, and an attachment-count hint. /// /// **No label chips.** `labels` is not a field `BoardModel`/`FrontmatterFields` expose — see /// `CardAttributesSection`'s doc comment for why — so `attachments` (a field the model already /// carries cheaply) stands in as the row's "cheap material" instead. private struct CardSummaryRow: View { let card: Card var body: some View { HStack { ItemIconView(icon: card.icon.value, iconColor: card.iconColor.value) VStack(alignment: .leading, spacing: 2) { Text(displayTitle) if !card.attachments.isEmpty { Label("\(card.attachments.count)", systemImage: "paperclip") .font(.caption) .foregroundStyle(.secondary) } } } } private var displayTitle: String { guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" } return title } }