The sort control stays on the board list alone; lanes and cards return to the arrangement the ranks state, long-press reorder now ungated because the rows always show the order a drag would rewrite. ItemSortOrder leaves with the pickers that needed it. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
194 lines
8.5 KiB
Swift
194 lines
8.5 KiB
Swift
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
|
|
}
|
|
}
|