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. A segmented /// control in the toolbar lets the list be sorted by name or by most recent change, on top /// of the lane's own manual arrangement, remembered across launches. Long-pressing a card drags it /// to a new position while `.manual` is selected, for the same reason the lane list's drag is gated /// — `.name`/`.recent` are not the rank order a reorder would rewrite. 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? /// Persisted as its raw value, not the enum itself — same reasoning as `BoardsTabView`'s /// `sortOrderRaw`. @AppStorage("cardListSortOrder") private var sortOrderRaw = ItemSortOrder.recent.rawValue private var session: BoardSession { index.session(forBoardAt: boardRoot) } /// The stored raw value as the enum, defaulting to `.recent` on anything the store didn't /// write itself — an unset key or a stale raw value from a build that no longer has this case. private var sortOrder: Binding { Binding( get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent }, set: { sortOrderRaw = $0.rawValue } ) } 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 } } } .toolbar { sortToolbarItem } .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 } /// The row's display title, "Untitled Card" fallback included — fed to `ItemSortOrder.sorted` /// so `.name` sorts on exactly what the row shows. private func displayTitle(of card: Card) -> String { guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" } return title } /// Shown only once there is something to sort — an empty or not-yet-loaded card list has no /// rows for the segments to reorder. @ToolbarContentBuilder private var sortToolbarItem: some ToolbarContent { if let lane, !lane.cards.isEmpty { ToolbarItem(placement: .principal) { Picker("Sort", selection: sortOrder) { Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual) Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name) Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent) } .pickerStyle(.segmented) .frame(maxWidth: 260) } } } @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 { // The trusted order is `.manual`'s base — `Lane.cards` is already in display // order, exactly as the lane list trusts `BoardModel.lanes` — and `.name`/ // `.recent` layer a display-only re-sort on top, never touching that base or // what's on disk. ForEach(sortOrder.wrappedValue.sorted(lane.cards, title: displayTitle(of:), modified: { $0.modified.value })) { 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) } } } // `nil` under `.name`/`.recent` disables the drag outright — `onMove` accepts an // optional closure, so the gate lives here rather than in a branch of the `ForEach`. .onMove(perform: sortOrder.wrappedValue == .manual ? { moveRows(from: $0, to: $1) } : nil) } } .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, gated to `.manual` by the `onMove` call site above — reordering /// a `.name`/`.recent` re-sort would rewrite ranks for rows the user isn't seeing in rank order. /// /// `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 } }