Phase 2 swaps every consumer: Liveness and its ancestor walk are gone, replaced by ItemContainer — a UUID set plus the container side it lives on, presence the whole test, one selection boundary instead of the old liveness law. Deletion stages by place: board cards move to the trash at a store-minted head rank, trash-side delete is permanent behind its confirmation, Delete Immediately skips the trash from anywhere, lane delete captures the subtree and removes the folder. Restore has no method at all — moveCards resolves members in either container, so drag-out and cut-paste are the ordinary moves 13 calls them, registering ordinary Move steps. The delete inverse moves the card back to its captured lane and rank; redo replays the captured trash rank, a value the gesture actually wrote; lane undo recreates the subtree byte-faithfully in session. Purges register nothing — where 13's trash section contradicts its own Rules on that, Rules wins, filed for ruling. Staleness collapsed to present-or-absent: a container is a path, so a foreign restore fails the delete step's expectation structurally. Legacy tombstones migrate on the loose-file tail hook, cards oldest-first so minting above top reproduces the retired newest-first column, lanes returning live, one folded loss row naming both directions. Put Back, restoreByDrag, receiveRestoredCards, TrashEntry, and the kind machinery are deleted; the trash column renders the container correctly with its full face rework left to phase 3. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
130 lines
5.1 KiB
Swift
130 lines
5.1 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - The drag types
|
|
|
|
extension UTType {
|
|
|
|
/// A drag carrying board **cards** — live card faces or trash rows (04-interactions.md ▸ Drag
|
|
/// and drop, ▸ The trash). Declared as an exported type in `Info.plist`, because a system drag
|
|
/// session is what crosses window boundaries, shows the copy badge, and gives the full-size
|
|
/// replica preview (DRAG-REORDER.md § Cross-board sessions).
|
|
static let laneworkCards = UTType(exportedAs: "dev.rzen.indie.kanban.cards")
|
|
|
|
/// A drag carrying board **lanes** — the strip's own reorder and the cross-board lane transfer.
|
|
static let laneworkLanes = UTType(exportedAs: "dev.rzen.indie.kanban.lanes")
|
|
}
|
|
|
|
// MARK: - What a drag carries
|
|
|
|
/// Which of the board's two levels a drag session carries. The selection is homogeneous by kind
|
|
/// (04-interactions.md § Selection), so a session is one or the other and never both.
|
|
enum DragKind: String, Codable, Sendable, Equatable {
|
|
case cards
|
|
case lanes
|
|
|
|
var type: UTType {
|
|
switch self {
|
|
case .cards: .laneworkCards
|
|
case .lanes: .laneworkLanes
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The JSON a drag session puts on the pasteboard.
|
|
///
|
|
/// **Same-app transfer is the only real consumer.** Both boards are open in this app, so the hover
|
|
/// path reads identity from `DragSession` — captured synchronously at drag start — and never decodes
|
|
/// anything mid-flight; the payload is the formal drop data, and what makes the session a *system*
|
|
/// session at all (which is what crosses windows and draws the badge). It carries folder URLs
|
|
/// because that is what the destination store's commits take (`receiveCards`, `receiveLanes`), and
|
|
/// the source board root beside them because locality — the Finder volume model — is a comparison of
|
|
/// roots (04-interactions.md ▸ Drag and drop).
|
|
///
|
|
/// A **plain-text secondary representation** (the dragged titles, newline-joined) rides along so a
|
|
/// stray drop into a text editor does something sane rather than nothing.
|
|
struct DragPayload: Codable, Sendable, Equatable {
|
|
|
|
/// One dragged item: its UUID, its folder on disk, and its title for the text representation.
|
|
struct Item: Codable, Sendable, Equatable {
|
|
var id: String
|
|
var folder: String
|
|
var title: String?
|
|
}
|
|
|
|
/// The **source** board's root folder — the left-hand side of the locality comparison.
|
|
var boardRoot: String
|
|
|
|
var kind: DragKind
|
|
|
|
/// Which container the drag started in — a trash card's drag is a card drag from `.trash`, which
|
|
/// is the whole of what makes its within-board drop a restore (04-interactions.md ▸ The trash).
|
|
/// `ItemContainer` is `String`-backed and `Codable` precisely so it can ride a pasteboard.
|
|
var container: ItemContainer
|
|
|
|
/// The dragged items **in flatten order** — "lane `order` first, then card `order`"
|
|
/// (04-interactions.md ▸ Drag and drop). The drop commits trust this order rather than
|
|
/// re-deriving it, because a cross-board destination has no flatten order for items it does not
|
|
/// hold.
|
|
var items: [Item]
|
|
|
|
var ids: [ItemID] { items.map { ItemID(rawValue: $0.id) } }
|
|
|
|
var folders: [URL] { items.map { URL(fileURLWithPath: $0.folder, isDirectory: true) } }
|
|
|
|
var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
|
|
|
|
/// The secondary representation: one title per line, untitled items rendered as they are on the
|
|
/// board (03-board-ui.md § Card face — "Untitled" is a rendering, never a value).
|
|
var plainText: String {
|
|
items.map { $0.title ?? "Untitled" }.joined(separator: "\n")
|
|
}
|
|
|
|
// MARK: Coding
|
|
|
|
func encoded() -> Data? {
|
|
try? JSONEncoder().encode(self)
|
|
}
|
|
|
|
init(boardRoot: URL, kind: DragKind, container: ItemContainer, items: [Item]) {
|
|
self.boardRoot = boardRoot.path
|
|
self.kind = kind
|
|
self.container = container
|
|
self.items = items
|
|
}
|
|
|
|
init?(data: Data) {
|
|
guard let decoded = try? JSONDecoder().decode(DragPayload.self, from: data) else { return nil }
|
|
self = decoded
|
|
}
|
|
|
|
/// The item provider a `.onDrag` hands back: the JSON under this session's own type, plus the
|
|
/// plain-text fallback.
|
|
///
|
|
/// The custom type is registered `.ownProcess` deliberately — the payload names folders inside
|
|
/// the user's boards, and no other app has any business reading it; the *text* is the
|
|
/// representation other apps get.
|
|
func itemProvider() -> NSItemProvider {
|
|
let provider = NSItemProvider()
|
|
if let data = encoded() {
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: kind.type.identifier,
|
|
visibility: .ownProcess
|
|
) { completion in
|
|
completion(data, nil)
|
|
return nil
|
|
}
|
|
}
|
|
let text = Data(plainText.utf8)
|
|
provider.registerDataRepresentation(
|
|
forTypeIdentifier: UTType.utf8PlainText.identifier,
|
|
visibility: .all
|
|
) { completion in
|
|
completion(text, nil)
|
|
return nil
|
|
}
|
|
return provider
|
|
}
|
|
}
|