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. 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 { // Already in display order (`Lane.cards`'s own contract) — trusted rather than // re-sorted here, exactly as the lane list trusts `BoardModel.lanes`. 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) } } } } } .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 ) } } } } /// One card row: title, an attachment-count hint, and the card's own icon when it has one. /// /// **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 { VStack(alignment: .leading, spacing: 2) { Text(displayTitle) if !card.attachments.isEmpty { Label("\(card.attachments.count)", systemImage: "paperclip") .font(.caption) .foregroundStyle(.secondary) } } Spacer() if let icon = card.icon.value, !icon.isEmpty { Image(systemName: icon) .foregroundStyle(iconTint) } } } private var displayTitle: String { guard let title = card.title.value, !title.isEmpty else { return "Untitled Card" } return title } private var iconTint: Color { card.iconColor.value.flatMap { CardPalette.color(named: $0, in: CardPalette.foregrounds) } ?? .secondary } }