Build the drop-slot model and the drop commits — drag & drop, first half
The pathfinder's drag-reorder model, ported and generalized (DRAG-REORDER.md travels with it, rewritten for lanes, the interior masonry, multi-drag, cross-board sessions, the re-grounding trio, and the committed-overlay hold): - DropSlotMath — resting-layout zones from analytic lane arithmetic and the pure masonry placement (MasonryLayout now lays out through the same MasonryPlacement the drag reads, so geometry cannot drift), span-capped triggers sized to the dragged run's future footprint, hysteresis holds with the fresh-entry fallback, boundary ties, own-slot no-ops; nil means hold. - DragAutoScrollMath — the activation bands and velocity ramp, pure. - The drop commits, one performWrite bracket each: moveCards/copyCards within a board (insertion ranks touch only the dragged cards; renumber fallback); receiveCards/receiveLanes/receiveRestoredCards on the destination store for cross-board copy and ⌘-move with the import-boundary remint, lane copies stripping tombstoned cards while moves carry them; restoreByDrag is now positional, writing order only when the drop names a new one. Gestures, sessions, previews, and delegates are the second half. 773 unit tests (87 new since the keyboard grammar). Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -776,6 +776,60 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which —
|
||||
/// the tail of a lane copy (04-interactions.md ▸ Drag and drop: "A lane copy **strips
|
||||
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
|
||||
/// governs a pasted lane copy).
|
||||
///
|
||||
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
|
||||
/// never content in this board, so there is nothing here for a Put Back to recover and no
|
||||
/// tombstone to leave standing — the tombstoned *originals* stay recoverable in the source
|
||||
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
|
||||
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
|
||||
/// rendering.
|
||||
///
|
||||
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook — it copies the tree
|
||||
/// verbatim by design, which is what makes attachments and strays arrive byte-identical — so
|
||||
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
|
||||
/// user actually owns would be destroying their trash. Every call site in the app is the line
|
||||
/// after a `copyItem` that materialized the folder.
|
||||
///
|
||||
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
|
||||
/// cannot be answered for it, and the conservative direction is to keep the folder — the same
|
||||
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
|
||||
/// (a present `deleted` key, malformed or not).
|
||||
///
|
||||
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
|
||||
/// and a failure here must say the app could not copy the lane (02-architecture.md §
|
||||
/// Write-failure surfacing).
|
||||
@discardableResult
|
||||
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.copy(title: nil)
|
||||
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
|
||||
try checkIsUUIDShaped(laneFolder, operation: operation)
|
||||
|
||||
var removed: [ItemID] = []
|
||||
for child in childCandidates(of: laneFolder) {
|
||||
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
||||
let document = try? readDocument(at: indexURL, operation: operation),
|
||||
!document.deleted.isMissing
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: child)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: child.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
removed.append(ItemID(rawValue: child.lastPathComponent))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// MARK: - Tombstone
|
||||
|
||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||
|
||||
@@ -61,6 +61,55 @@ enum Ranks: Sendable {
|
||||
return midpoint(between: orders[index - 1], and: orders[index])
|
||||
}
|
||||
|
||||
/// The `count` ranks a **contiguous run** takes when it lands at display position `index`
|
||||
/// among `orders` — `insertionRank(amongVisible:at:)` for a multi-drag, whose whole set inserts
|
||||
/// at one spot in preserved order (04-interactions.md ▸ Drag and drop, DRAG-REORDER.md §
|
||||
/// Multi-drag).
|
||||
///
|
||||
/// `orders` is the visible siblings **in display order with the run itself already excluded** —
|
||||
/// the resting layout's convention, the same one the geometry's index is counted in.
|
||||
///
|
||||
/// The three cases mirror the single-rank twin, spread over `count` values:
|
||||
///
|
||||
/// - at or before the head → `count` whole gaps *below* the first sibling, ascending;
|
||||
/// - at or past the end (an empty `orders` included) → `count` whole gaps above the last;
|
||||
/// - between two siblings → `count` evenly spaced points strictly inside their interval.
|
||||
///
|
||||
/// **`nil` means the gap is exhausted, not that the insertion is illegal** — the interior case
|
||||
/// fails when the two neighbours are close enough that `count` distinct, strictly increasing
|
||||
/// `Double`s do not fit between them (adjacent doubles, or the duplicate-order tie). That is
|
||||
/// the renumber trigger (01-storage-format.md § Ordering) and the caller's cue to compact and
|
||||
/// ask again, exactly as an exhausted midpoint is everywhere else.
|
||||
static func insertionRanks(amongVisible orders: [Double], at index: Int, count: Int) -> [Double]? {
|
||||
guard count > 0 else { return [] }
|
||||
if orders.isEmpty || index >= orders.count {
|
||||
let base = orders.max() ?? 0
|
||||
return (1...count).map { base + gap * Double($0) }
|
||||
}
|
||||
if index <= 0 {
|
||||
let base = orders.min() ?? 0
|
||||
// Ascending, and every value below `base`: the deepest is `count` gaps down.
|
||||
return (1...count).map { base - gap * Double(count - $0 + 1) }
|
||||
}
|
||||
|
||||
let lower = orders[index - 1]
|
||||
let upper = orders[index]
|
||||
guard lower < upper else { return nil }
|
||||
let step = (upper - lower) / Double(count + 1)
|
||||
var ranks: [Double] = []
|
||||
var previous = lower
|
||||
for position in 1...count {
|
||||
let rank = lower + step * Double(position)
|
||||
// Every rank must sit strictly inside the interval *and* strictly above the last one:
|
||||
// at the precision floor the arithmetic silently collapses onto a neighbour, and a
|
||||
// duplicate rank would hand display order to the folder-name tie-break.
|
||||
guard rank > previous, rank < upper else { return nil }
|
||||
ranks.append(rank)
|
||||
previous = rank
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
/// `count` fresh ranks, whole multiples of 1024 in ascending order
|
||||
/// (1024, 2048, …) — the renumber target when midpoint precision is
|
||||
/// exhausted. Deterministic by construction; the writer applies these,
|
||||
|
||||
Reference in New Issue
Block a user