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. 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.manual.rawValue private var session: BoardSession { index.session(forBoardAt: boardRoot) } /// The stored raw value as the enum, defaulting to `.manual` 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) ?? .manual }, 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) { Text("Manual").tag(ItemSortOrder.manual) Text("Name").tag(ItemSortOrder.name) Text("Recent").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) } } } } } .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: 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 } }