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.
+64 -1
View File
@@ -7,7 +7,9 @@ import SwiftUI
/// 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.
/// 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
@@ -152,6 +154,9 @@ struct LaneScreen: View {
}
}
}
// `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() }
@@ -210,6 +215,64 @@ struct LaneScreen: View {
}
}
}
/// 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.