The lane and card lists give up their pickers — rank order is the only order below the boards

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
This commit is contained in:
2026-08-08 13:41:09 -04:00
parent 2d919b8131
commit 28abaac2d9
4 changed files with 16 additions and 169 deletions
+2 -4
View File
@@ -1,12 +1,10 @@
**August 2026** **August 2026**
Press and hold a lane or card to drag it into a new order when sorting is set to manual. Press and hold a lane or card to drag it into a new order.
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. The board list now opens sorted by most recent change.
Sort lanes and cards by your own arrangement, name, or most recent change.
Boards, lanes, and cards now show their icon and color in the lists. Boards, lanes, and cards now show their icon and color in the lists.
+7 -54
View File
@@ -5,32 +5,16 @@ import SwiftUI
/// Holds only `boardRoot`, never a `BoardSummary`/`BoardModel` value: the session and its /// 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 /// 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 /// 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. Long-pressing a row drags it to a new position, reordering the
/// name or by most recent change, on top of the board's own manual arrangement, remembered across /// board's own manual arrangement.
/// 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
@Environment(BoardIndexStore.self) private var index @Environment(BoardIndexStore.self) private var index
@State private var isPresentingNewLane = false @State private var isPresentingNewLane = false
/// Persisted as its raw value, not the enum itself same reasoning as `BoardsTabView`'s
/// `sortOrderRaw`.
@AppStorage("laneListSortOrder") private var sortOrderRaw = ItemSortOrder.recent.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.recent` 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<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue }
)
}
var body: some View { var body: some View {
content content
.navigationTitle(title) .navigationTitle(title)
@@ -41,9 +25,6 @@ struct BoardScreen: View {
} }
} }
} }
.toolbar {
sortToolbarItem
}
.task { session.open() } .task { session.open() }
.onDisappear { .onDisappear {
// Stops the materializing/downloading retry timer. In practice a no-op here: a // Stops the materializing/downloading retry timer. In practice a no-op here: a
@@ -71,30 +52,6 @@ struct BoardScreen: View {
session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent session.snapshot?.title.value ?? boardRoot.deletingPathExtension().lastPathComponent
} }
/// The row's display title, "Untitled Lane" fallback included fed to `ItemSortOrder.sorted`
/// so `.name` sorts on exactly what the row shows.
private func displayTitle(of lane: Lane) -> String {
guard let title = lane.title.value, !title.isEmpty else { return "Untitled Lane" }
return title
}
/// Shown only once there is something to sort an empty or not-yet-loaded lane list has no
/// rows for the segments to reorder.
@ToolbarContentBuilder
private var sortToolbarItem: some ToolbarContent {
if case .ready = session.phase, let snapshot = session.snapshot, !snapshot.lanes.isEmpty {
ToolbarItem(placement: .principal) {
Picker("Sort", selection: sortOrder) {
Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual)
Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name)
Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
}
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
switch session.phase { switch session.phase {
@@ -145,24 +102,20 @@ struct BoardScreen: View {
description: Text("Add a lane to start organizing cards.") description: Text("Add a lane to start organizing cards.")
) )
} else { } else {
// The trusted order is `.manual`'s base `BoardModel.lanes` is already in display // `snapshot.lanes` is already in display order (the loader's `Ranks.sortedForDisplay`),
// order (the loader's `Ranks.sortedForDisplay`) and `.name`/`.recent` layer a // so it's trusted here rather than re-sorted.
// display-only re-sort on top, never touching that base or what's on disk. ForEach(snapshot.lanes) { lane in
ForEach(sortOrder.wrappedValue.sorted(snapshot.lanes, title: displayTitle(of:), modified: { $0.modified.value })) { lane in
NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) { NavigationLink(value: BoardRoute.cards(boardRoot: boardRoot, laneID: lane.id)) {
LaneSummaryRow(lane: lane) LaneSummaryRow(lane: lane)
} }
} }
// `nil` under `.name`/`.recent` disables the drag outright `onMove` accepts an .onMove(perform: { moveRows(from: $0, to: $1) })
// 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 /// Commits a lane drag's release.
/// 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 /// `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 /// dragged lane's post-drag index is both the row SwiftUI just drew and the position
+7 -55
View File
@@ -5,11 +5,8 @@ import SwiftUI
/// Holds `boardRoot` and `laneID`, never a `Lane` value: the lane is re-read from /// 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 /// `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 /// 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. Long-pressing
/// control in the toolbar lets the list be sorted by name or by most recent change, on top /// a card drags it to a new position, reordering the lane's own manual arrangement.
/// 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
@@ -20,21 +17,8 @@ struct LaneScreen: View {
@State private var isPresentingNewCard = false @State private var isPresentingNewCard = false
@State private var cardPendingMove: ItemID? @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.recent.rawValue
private var session: BoardSession { index.session(forBoardAt: boardRoot) } private var session: BoardSession { index.session(forBoardAt: boardRoot) }
/// The stored raw value as the enum, defaulting to `.recent` 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<ItemSortOrder> {
Binding(
get: { ItemSortOrder(rawValue: sortOrderRaw) ?? .recent },
set: { sortOrderRaw = $0.rawValue }
)
}
private var lane: Lane? { private var lane: Lane? {
session.snapshot?.lanes.first { $0.id == laneID } session.snapshot?.lanes.first { $0.id == laneID }
} }
@@ -49,9 +33,6 @@ struct LaneScreen: View {
} }
} }
} }
.toolbar {
sortToolbarItem
}
.task { session.open() } .task { session.open() }
.titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in .titlePromptAlert("New Card", isPresented: $isPresentingNewCard, placeholder: "Card Title") { title in
createCard(titled: title.isEmpty ? nil : title) createCard(titled: title.isEmpty ? nil : title)
@@ -86,30 +67,6 @@ struct LaneScreen: View {
return title 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) {
Label("Manual", systemImage: "hand.draw").tag(ItemSortOrder.manual)
Label("Name", systemImage: "textformat.abc").tag(ItemSortOrder.name)
Label("Recent", systemImage: "clock").tag(ItemSortOrder.recent)
}
.pickerStyle(.segmented)
.frame(maxWidth: 260)
}
}
}
@ViewBuilder @ViewBuilder
private var content: some View { private var content: some View {
if let lane { if let lane {
@@ -128,11 +85,9 @@ struct LaneScreen: View {
description: Text("Add a card to this lane.") description: Text("Add a card to this lane.")
) )
} else { } else {
// The trusted order is `.manual`'s base `Lane.cards` is already in display // `lane.cards` is already in display order, exactly as the lane list trusts
// order, exactly as the lane list trusts `BoardModel.lanes` and `.name`/ // `BoardModel.lanes` so it's trusted here rather than re-sorted.
// `.recent` layer a display-only re-sort on top, never touching that base or ForEach(lane.cards) { card in
// 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)) { NavigationLink(value: BoardRoute.card(boardRoot: boardRoot, laneID: laneID, cardID: card.id)) {
CardSummaryRow(card: card) CardSummaryRow(card: card)
} }
@@ -154,9 +109,7 @@ struct LaneScreen: View {
} }
} }
} }
// `nil` under `.name`/`.recent` disables the drag outright `onMove` accepts an .onMove(perform: { moveRows(from: $0, to: $1) })
// 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() }
@@ -216,8 +169,7 @@ struct LaneScreen: View {
} }
} }
/// Commits a card drag's release, gated to `.manual` by the `onMove` call site above reordering /// Commits a card drag's release.
/// 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 /// `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 /// dragged card's post-drag index is both the row SwiftUI just drew and the position
-56
View File
@@ -1,56 +0,0 @@
import Foundation
/// How the lane and card lists order their rows. Raw values are persisted (`AppStorage`), so
/// they are API. `.manual` is the board's own arrangement the rank order the loader already
/// delivered and sorting here is display-only: it never rewrites ranks on disk.
enum ItemSortOrder: String, CaseIterable, Sendable {
case manual
case name
case recent
}
extension ItemSortOrder {
/// Orders `items` for display without touching the manual arrangement they arrived in.
///
/// **`.manual` is a no-op** `items` is already in the loader's rank order, so this returns it
/// unchanged.
///
/// **`.name` sorts by `title`**, case/diacritic-insensitive (`localizedStandardCompare`). Callers
/// pass the row's own display title the "Untitled Lane"/"Untitled Card" fallback already
/// applied so there is no separate nil case to sort here.
///
/// **`.recent` sorts by `modified` descending, with `nil` last.** Both sorts tie-break the same
/// way: Swift's `sort` is not a stable sort, so ties equal titles, equal or absent dates keep
/// the incoming order via each item's original offset, exactly as `BoardSortOrder.sorted` does.
nonisolated func sorted<Item>(
_ items: [Item],
title: (Item) -> String,
modified: (Item) -> Date?
) -> [Item] {
switch self {
case .manual:
return items
case .name:
return items.enumerated().sorted { lhs, rhs in
let comparison = title(lhs.element).localizedStandardCompare(title(rhs.element))
if comparison != .orderedSame { return comparison == .orderedAscending }
return lhs.offset < rhs.offset
}.map(\.element)
case .recent:
return items.enumerated().sorted { lhs, rhs in
switch (modified(lhs.element), modified(rhs.element)) {
case let (l?, r?) where l != r:
return l > r
case (nil, .some):
return false
case (.some, nil):
return true
default:
return lhs.offset < rhs.offset
}
}.map(\.element)
}
}
}