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:
@@ -72,6 +72,25 @@ public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConv
|
||||
}
|
||||
}
|
||||
|
||||
/// What a cross-board drop is doing to the items it carries — the **effective** operation the
|
||||
/// locality model resolved (04-interactions.md ▸ Drag and drop, settled).
|
||||
///
|
||||
/// Not a modifier and not a direction: by the time a store sees one of these the Finder volume
|
||||
/// model has already been applied — within a board a drag is a move, between boards a copy, ⌥
|
||||
/// forces copy and ⌘ forces move, each a no-op where it is already the default — and the badge the
|
||||
/// user was looking at said exactly this. Two cases and no `.none`: a drag with no valid proposal
|
||||
/// never reaches a commit at all (▸ Drag and drop, rule 2: "release with no valid proposal
|
||||
/// cancels").
|
||||
public enum TransferOperation: Sendable, Equatable {
|
||||
/// Fresh-GUID duplicates land at the drop, originals stay, `created` is kept — a copy is a
|
||||
/// fork (01-storage-format.md).
|
||||
case copy
|
||||
|
||||
/// A real filesystem move: identity travels, and only the import boundary remints, per folder
|
||||
/// (01-storage-format.md's per-folder degradation).
|
||||
case move
|
||||
}
|
||||
|
||||
// MARK: - BoardStore
|
||||
|
||||
/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
|
||||
@@ -1101,6 +1120,332 @@ public final class BoardStore {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag & drop commits
|
||||
|
||||
// The writes a released drag performs (04-interactions.md ▸ Drag and drop; the geometry that
|
||||
// produces their `index` is DRAG-REORDER.md's, implemented in `DropSlotMath`).
|
||||
//
|
||||
// **One `performWrite` bracket per gesture**, whatever the set's size — the style batch's and
|
||||
// the tombstone batch's rule, for their reason: one gesture, one app-mediated reload, one commit
|
||||
// on git boards.
|
||||
//
|
||||
// **`index` always means the same thing**: a position among the destination's *rendered* items
|
||||
// counted with the dragged run already removed — the resting layout's own convention, so the
|
||||
// number the geometry produced is the number these methods consume, unrewritten. Every one of
|
||||
// them clamps it rather than trusting it: a proposal computed against a snapshot one reload old
|
||||
// must not trap.
|
||||
//
|
||||
// **Ranks are inserted, never permuted.** A drop rewrites only the dragged items' `order`, so
|
||||
// the siblings' files — and `modified`, and a git commit — stay honest about what actually
|
||||
// moved. That is the one place these differ from `sortSelection`, which permutes because its
|
||||
// gesture is a permutation. `Ranks.insertionRanks` answering `nil` is the renumber trigger, and
|
||||
// the fallback is `moveLane`'s: compact the destination, then place against the fresh ladder.
|
||||
//
|
||||
// **Silent no-ops throughout**, all of them the reload being the authority rather than the
|
||||
// gesture: a destination lane that is gone or tombstoned (04's "a card is never filed under a
|
||||
// `deleted:` parent"), a dragged set emptied by a foreign reload, and a drop that lands exactly
|
||||
// where everything already is (a drag that ends where it started must not stamp `modified` or
|
||||
// mint a commit — the resize drag's rule).
|
||||
|
||||
/// One member of a dragged card set, resolved against the snapshot: which lane holds it *now*.
|
||||
private struct DraggedCard {
|
||||
let id: ItemID
|
||||
let laneID: ItemID
|
||||
}
|
||||
|
||||
/// `ids` narrowed to live cards under live lanes and sorted into **flatten order** — "lane
|
||||
/// `order` first, then card `order`" (`SelectionGrammar.liveCards`), which is what "drop inserts
|
||||
/// contiguously in preserved relative order" means and the only order a `Set` cannot supply.
|
||||
///
|
||||
/// Members that vanished or flipped liveness since the drag began are simply absent: drag
|
||||
/// membership is a UUID set that vanished items leave silently (02-architecture.md), and "partial
|
||||
/// vanishing drops the survivors" is the design's own wording.
|
||||
private func draggedCards(_ ids: Set<ItemID>) -> [DraggedCard] {
|
||||
var lanes: [ItemID: ItemID] = [:]
|
||||
for lane in snapshot.lanes where !lane.isDeleted {
|
||||
for card in lane.cards where !card.isDeleted {
|
||||
lanes[card.id] = lane.id
|
||||
}
|
||||
}
|
||||
return SelectionGrammar.liveCards(in: snapshot)
|
||||
.filter { ids.contains($0) }
|
||||
.compactMap { id in lanes[id].map { DraggedCard(id: id, laneID: $0) } }
|
||||
}
|
||||
|
||||
/// The within-board card drop: `ids` land contiguously at logical position `index` among
|
||||
/// `laneID`'s rendered cards, in flatten order.
|
||||
///
|
||||
/// **Uniformly `moveItem`, cross-lane members and same-lane ones alike.** A member already in
|
||||
/// the destination takes the writer's same-parent degenerate path, which rewrites exactly one
|
||||
/// file — its `order` — and never touches the filesystem; a member arriving from another lane
|
||||
/// moves its folder and carries the same explicit rank. That is `moveItem`'s own promise ("a
|
||||
/// drop that lands back in its own lane is the same gesture as one that lands elsewhere"), and
|
||||
/// leaning on it is what keeps this method from growing two branches that could disagree about
|
||||
/// ordering.
|
||||
///
|
||||
/// The selection is deliberately untouched: every id survives the move, and the cards the user
|
||||
/// is dragging should stay the cards the user is dragging.
|
||||
public func moveCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
let members = draggedCards(ids)
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let memberIDs = members.map(\.id)
|
||||
let remaining = rendered.filter { !ids.contains($0.id) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
|
||||
// The no-op guard, stated as the arrangement rather than as a special case: if the lane
|
||||
// would render exactly what it renders now, nothing moved. A member sitting in another lane
|
||||
// makes the two lists differ by construction, so this covers the cross-lane case too.
|
||||
guard DropSlotMath.applied(rendered.map(\.id), moving: memberIDs, to: target) != rendered.map(\.id)
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: remaining.map(\.order), at: target, count: members.count)
|
||||
if ranks == nil {
|
||||
// Compact and place again. The renumber assigns in display order over the lane's
|
||||
// *live* cards, so the compacted ladder lines up one-for-one with `rendered`; the
|
||||
// members already in this lane are dropped from it before the neighbours are
|
||||
// consulted, exactly as `moveLane` drops the dragged lane's own rung.
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
let compacted = zip(rendered, Ranks.renumbered(count: rendered.count))
|
||||
.filter { !ids.contains($0.0.id) }
|
||||
.map(\.1)
|
||||
ranks = Ranks.insertionRanks(amongVisible: compacted, at: target, count: members.count)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
let folder = root
|
||||
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: folder,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The within-board ⌥-drag: fresh-GUID duplicates of `ids` land contiguously at `index` among
|
||||
/// `laneID`'s rendered cards, **originals untouched** (04-interactions.md ▸ Drag and drop:
|
||||
/// "originals stay, cursor shows the copy badge, fresh-GUID duplicates land at the drop").
|
||||
/// `created` survives because a copy is a fork — `CopyStamps.fork`, the same stamps paste uses.
|
||||
///
|
||||
/// **The ranks are placed among the lane's *full* rendered set**, not among the set with the
|
||||
/// dragged members removed — the one place a copy's arithmetic differs from a move's. The
|
||||
/// originals are lifted out of the layout for the duration of the drag whatever the effective
|
||||
/// operation is (⌥ can be pressed and released mid-drag; a layout that re-admitted them on every
|
||||
/// flip would flap the whole board), but they are still *on disk* holding their ranks, and a
|
||||
/// rank chosen in the gap they appear to have vacated would collide with them the instant they
|
||||
/// reappear. So the drop's index is mapped through to the neighbour it names — the card the run
|
||||
/// lands in front of — and the rank is taken there.
|
||||
public func copyCards(_ ids: Set<ItemID>, toLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) else { return }
|
||||
let members = draggedCards(ids)
|
||||
guard !members.isEmpty else { return }
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let remaining = rendered.filter { !ids.contains($0.id) }
|
||||
let target = min(max(0, index), remaining.count)
|
||||
// The resting-layout index, re-read against the layout the originals are still part of.
|
||||
let placement = target < remaining.count
|
||||
? (rendered.firstIndex { $0.id == remaining[target].id } ?? rendered.count)
|
||||
: rendered.count
|
||||
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: placement, count: members.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: placement,
|
||||
count: members.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (member, rank) in zip(members, ranks) {
|
||||
let folder = root
|
||||
.appendingPathComponent(member.laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(member.id.rawValue, isDirectory: true)
|
||||
_ = try BoardWriter.copyItem(at: folder, toParent: laneFolder, order: rank, stamps: .fork)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cross-board arrivals
|
||||
//
|
||||
// Executed by the **destination** store, inside *its* bracket, because the destination is where
|
||||
// the write's effects have to round-trip. A move mutates the source board's tree outside that
|
||||
// board's own bracket, which is correct and needs no coordination: the source store's watcher
|
||||
// sees a foreign change and reloads, which is exactly what a foreign change is.
|
||||
//
|
||||
// `sources` are the items' folder URLs in the source board — both boards are open in this app,
|
||||
// so both roots are already security-scoped and the payload can carry plain URLs. The source
|
||||
// board root is read back off the path rather than passed alongside: 01-storage-format.md's
|
||||
// fractal layout fixes the depth (`<root>/<lane>` and `<root>/<lane>/<card>`), so the URL
|
||||
// already carries it and a second parameter could only ever disagree with the first.
|
||||
|
||||
/// The board root a lane folder sits directly under.
|
||||
nonisolated static func boardRoot(ofLaneFolder folder: URL) -> URL {
|
||||
folder.deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// The board root a card folder sits two levels under.
|
||||
nonisolated static func boardRoot(ofCardFolder folder: URL) -> URL {
|
||||
folder.deletingLastPathComponent().deletingLastPathComponent()
|
||||
}
|
||||
|
||||
/// A cross-board card drop, landing contiguously at `index` among `laneID`'s rendered cards.
|
||||
///
|
||||
/// - `.copy` (the default between boards) — `copyItem` per folder: fresh GUIDs throughout,
|
||||
/// `created` kept, originals untouched. Copies mint by construction, so the import boundary's
|
||||
/// collision question never arises.
|
||||
/// - `.move` (⌘-drag) — `moveItem` per folder: identity travels, and the import boundary remints
|
||||
/// **only** the folders whose UUID the destination board already holds, per folder at the
|
||||
/// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own
|
||||
/// behaviour rather than something this method arranges).
|
||||
public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
|
||||
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: false)
|
||||
}
|
||||
|
||||
/// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows
|
||||
/// dropped on *another* board.
|
||||
///
|
||||
/// Identical to `receiveCards` but for one extra write per arrival — `deleted:` is removed once
|
||||
/// the folder is at its destination, so what lands is **live**, "like copying a file out of
|
||||
/// Finder's Trash". The two cases the design names fall straight out of the operation:
|
||||
///
|
||||
/// - `.copy` (the default) — a live copy lands here and the tombstoned original stays in the
|
||||
/// source board's trash, exactly as ⌘C out of the trash behaves.
|
||||
/// - `.move` (⌘-drag) — the true cross-board restore-move: the tombstone leaves the source
|
||||
/// board entirely, ordinary cross-board move semantics apply, and `deleted:` is cleared at the
|
||||
/// destination.
|
||||
///
|
||||
/// The strip is a second `updateIndex` rather than a flag on the first because the arrival's
|
||||
/// `order` is written by `copyItem`/`moveItem` before this store has a folder to point at, and
|
||||
/// because `restoreItem` is already the one expression in the app for "remove the `deleted:`
|
||||
/// key" — the bytes are never rewritten any other way.
|
||||
public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) {
|
||||
receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: true)
|
||||
}
|
||||
|
||||
private func receive(
|
||||
_ sources: [URL],
|
||||
operation: TransferOperation,
|
||||
toLane laneID: ItemID,
|
||||
at index: Int,
|
||||
clearingTombstones: Bool
|
||||
) {
|
||||
guard !sources.isEmpty,
|
||||
let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
|
||||
else { return }
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let root = rootURL
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
let arrived: ItemID
|
||||
switch operation {
|
||||
case .copy:
|
||||
arrived = try BoardWriter.copyItem(at: source, toParent: laneFolder, order: rank, stamps: .fork)
|
||||
case .move:
|
||||
arrived = try BoardWriter.moveItem(
|
||||
at: source,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: Self.boardRoot(ofCardFolder: source),
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
).id
|
||||
}
|
||||
guard clearingTombstones else { continue }
|
||||
try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A cross-board lane drop, landing contiguously at `stripIndex` among this board's live lanes.
|
||||
///
|
||||
/// The two operations differ in exactly one place beyond identity, and it is 04-interactions.md
|
||||
/// ▸ Drag and drop's rule:
|
||||
///
|
||||
/// - `.copy` — "Lanes copy cards and all", then **the copy strips tombstoned cards**: the copy
|
||||
/// transfers content, and trash isn't content (09-templates.md's instantiation precedent — a
|
||||
/// board isn't born with trash). The tombstoned originals stay recoverable in the source
|
||||
/// board. `copyItem` offers no filter hook — it copies the tree verbatim by design, which is
|
||||
/// what makes attachments and strays arrive byte-identical — so the strip is the line after
|
||||
/// (`BoardWriter.stripTombstonedChildren`), pointed at a folder minted seconds earlier.
|
||||
/// - `.move` — "A ⌘-drag *move* carries them whole — the folder moves as-is, and they land in
|
||||
/// the destination's trash." Nothing to arrange: a move never reads below its root, so the
|
||||
/// tombstones travel and the destination's trash quasi-lane renders them.
|
||||
///
|
||||
/// Within-board lane reorders are `moveLane(_:toIndex:)`, and a within-board lane *copy* does
|
||||
/// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one
|
||||
/// home), so this method is cross-board by construction.
|
||||
public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) {
|
||||
guard !sources.isEmpty else { return }
|
||||
|
||||
let root = rootURL
|
||||
let rendered = snapshot.lanes.filter { !$0.isDeleted }
|
||||
let target = min(max(0, stripIndex), rendered.count)
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var ranks = Ranks.insertionRanks(amongVisible: rendered.map(\.order), at: target, count: sources.count)
|
||||
if ranks == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: root)
|
||||
ranks = Ranks.insertionRanks(
|
||||
amongVisible: Ranks.renumbered(count: rendered.count),
|
||||
at: target,
|
||||
count: sources.count
|
||||
)
|
||||
}
|
||||
guard let ranks else { return }
|
||||
|
||||
for (source, rank) in zip(sources, ranks) {
|
||||
switch operation {
|
||||
case .copy:
|
||||
let arrived = try BoardWriter.copyItem(at: source, toParent: root, order: rank, stamps: .fork)
|
||||
try BoardWriter.stripTombstonedChildren(
|
||||
of: root.appendingPathComponent(arrived.rawValue, isDirectory: true)
|
||||
)
|
||||
case .move:
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: source,
|
||||
toParent: root,
|
||||
sourceBoardRoot: Self.boardRoot(ofLaneFolder: source),
|
||||
destinationBoardRoot: root,
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Within-lane sort
|
||||
|
||||
/// The lane and the new card ordering one ⌥⌘↑/⌥⌘↓ press would produce, or `nil` when the press
|
||||
@@ -1328,52 +1673,79 @@ public final class BoardStore {
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
/// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane**
|
||||
/// (03-board-ui.md § Trash, 04-interactions.md ▸ The trash).
|
||||
/// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane,
|
||||
/// at the drop position** — `deleted:` removed and `order` set (03-board-ui.md § Trash,
|
||||
/// 04-interactions.md ▸ The trash: "dropping a tombstoned card into one of its own board's lanes
|
||||
/// restores it at the drop position").
|
||||
///
|
||||
/// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first — the folder
|
||||
/// is still where the trash row said it was — then, only when the destination differs, the move.
|
||||
/// Doing it the other way round would have the second call chasing a folder the first one had
|
||||
/// already relocated.
|
||||
/// `index` is the drag model's own index — a position among the destination lane's rendered
|
||||
/// cards, which the tombstoned card is by definition not among (DRAG-REORDER.md § The drop
|
||||
/// commits). Clamped, like every other drop commit.
|
||||
///
|
||||
/// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card
|
||||
/// returns at its recorded `order` rather than at the bottom. "Folder moved only if the
|
||||
/// destination lane differs" is the design's own wording, and the position-perfect restore is the
|
||||
/// point of the trash being a pure view.
|
||||
/// **Cross-lane is two writes in one bracket, and the order is load-bearing**: `restoreItem`
|
||||
/// first — the folder is still where the trash row said it was — then the move, carrying the
|
||||
/// rank. Doing it the other way round would have the second call chasing a folder the first one
|
||||
/// had already relocated.
|
||||
///
|
||||
// m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop —
|
||||
// the design restores "at the drop position", and the append below is the interim; the rank comes
|
||||
// from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder`
|
||||
// computes it. (2) **Cross-board locality** — a drop on another board is a live *copy* by
|
||||
// default with the tombstoned original staying put, and ⌘-drag forces the true restore-move.
|
||||
// Both need the drag controller's target vocabulary; this method is deliberately within-board.
|
||||
/// **Same lane never moves a folder**, so it is one write: the key removed and, only when the
|
||||
/// drop actually names a different rank than the card already carries, the `order` beside it.
|
||||
/// That guard is what preserves the position-perfect restore the trash's pure-view design pays
|
||||
/// for — a row dropped back where its recorded order already puts it comes back *exactly* there,
|
||||
/// with no rank invented for it and no neighbour disturbed.
|
||||
///
|
||||
/// **Within-board only.** A drop on another board follows the locality model instead
|
||||
/// (`receiveRestoredCards`): a live copy by default with the tombstoned original staying put,
|
||||
/// and ⌘-drag forcing the true restore-move.
|
||||
///
|
||||
/// Silent no-ops, all of them the reload being the authority rather than this gesture: a
|
||||
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
|
||||
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
|
||||
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) {
|
||||
guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID, at index: Int) {
|
||||
guard let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }),
|
||||
let source = snapshot.lanes.first(where: { lane in
|
||||
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
|
||||
})
|
||||
}),
|
||||
let card = source.cards.first(where: { $0.id == cardID })
|
||||
else { return }
|
||||
|
||||
let root = rootURL
|
||||
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
|
||||
let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
let laneFolder = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
let crossesLanes = source.id != laneID
|
||||
|
||||
let rendered = destination.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let recordedOrder = card.order
|
||||
|
||||
try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
var rank = Ranks.insertionRank(amongVisible: rendered.map(\.order), at: target)
|
||||
if rank == nil {
|
||||
try BoardWriter.renumberVisibleChildren(of: laneFolder)
|
||||
rank = Ranks.insertionRank(amongVisible: Ranks.renumbered(count: rendered.count), at: target)
|
||||
}
|
||||
guard let rank else { return }
|
||||
|
||||
guard crossesLanes else {
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: cardFolder,
|
||||
// `.restore(title: nil)`: `updateIndex` enriches it off the document it reads.
|
||||
operation: .restore(title: nil)
|
||||
) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
if rank != recordedOrder {
|
||||
document.set(FrontmatterKeys.order, to: .double(rank))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try BoardWriter.restoreItem(at: cardFolder)
|
||||
guard crossesLanes else { return }
|
||||
// `order: nil` is the Writer's own append — computed over the destination's *visible*
|
||||
// siblings, which the arriving card is not yet among.
|
||||
_ = try BoardWriter.moveItem(
|
||||
at: cardFolder,
|
||||
toParent: destination,
|
||||
toParent: laneFolder,
|
||||
sourceBoardRoot: root,
|
||||
destinationBoardRoot: root,
|
||||
order: nil
|
||||
order: rank
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user