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:
@@ -1,5 +1,7 @@
|
|||||||
**August 2026**
|
**August 2026**
|
||||||
|
|
||||||
|
Press and hold a lane or card to drag it into a new order when sorting is set to manual.
|
||||||
|
|
||||||
Settings now lives behind the gear button on the board list instead of a separate tab, so boards fill the whole screen.
|
Settings now lives behind the gear button on the board list instead of a separate tab, so boards fill the whole screen.
|
||||||
|
|
||||||
Lists now open sorted by most recent change.
|
Lists now open sorted by most recent change.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import SwiftUI
|
|||||||
/// write from anywhere in the stack (a lane created, a card moved) reaches this screen the moment
|
/// 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
|
/// 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
|
/// 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 {
|
struct BoardScreen: View {
|
||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
|
|
||||||
@@ -151,10 +153,68 @@ struct BoardScreen: View {
|
|||||||
LaneSummaryRow(lane: lane)
|
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() }
|
.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.
|
/// One lane row: its icon when it has one, title, and its card count.
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import SwiftUI
|
|||||||
/// from elsewhere through the metadata query — reaches the list the moment the session's reload
|
/// 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
|
/// 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
|
/// 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 {
|
struct LaneScreen: View {
|
||||||
let boardRoot: URL
|
let boardRoot: URL
|
||||||
let laneID: ItemID
|
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() }
|
.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.
|
/// One card row: its icon when it has one, title, and an attachment-count hint.
|
||||||
|
|||||||
Reference in New Issue
Block a user