Rows learn to be carried — long-press reorder for lanes and cards, manual mode only

An onMove on both lists, live only while the sort picker says Manual:
the drag mints a midpoint rank between its landing neighbours through
BoardWriter.moveItem's same-parent reorder path, and when a gap is
exhausted the whole container renumbers to a fresh 1024 ladder in one
perform bracket. Neither path stamps modified, so a reorder never
disturbs the Recent sort. UI suite 4/4 in the simulator.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-08-08 13:28:24 -04:00
parent e1f89d9cf9
commit df986566e5
3 changed files with 127 additions and 2 deletions
+61 -1
View File
@@ -7,7 +7,9 @@ import SwiftUI
/// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment
/// the session's reload lands. A segmented control in the toolbar lets the list be sorted by
/// name or by most recent change, on top of the board's own manual arrangement, remembered across
/// launches.
/// launches. Long-pressing a row drags it to a new position while `.manual` is selected under
/// `.name`/`.recent` the gesture is inert, since dragging a re-sorted list would reorder lanes the
/// user is not looking at in rank order.
struct BoardScreen: View {
let boardRoot: URL
@@ -151,10 +153,68 @@ struct BoardScreen: View {
LaneSummaryRow(lane: lane)
}
}
// `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() }
}
/// Commits a lane 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 `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.