Files
lanework/Kanban/UI/Board/DragPayload.swift
T
rzen 546ed94412 The pasteboard UTType constants become edition twins — Pro stops claiming ownership it doesn't have
Latent since the m7 split: base's Info.plist exports the three family
pasteboard types (cards, lanes, clipboard) and Pro's imports them, but
the shared constants declared all three UTType(exportedAs:) — a claim
of ownership the system checks against the running app's plist. In Pro
that claim is false, and the first touch (the clipboard type, via the
launch sweep) raised a runtime fault that blocked board loading under
the debugger.

EditionAbout's twin-file pattern, applied to an initializer: the
constants move to Kanban/App/EditionTypes.swift (exportedAs) with a
KanbanPro/Edition/EditionTypes.swift twin (importedAs, identifiers
verbatim); project.yml excludes base's copy from Pro. Type identity is
the string, so payloads cross editions unchanged. Verified: Pro
fixture launch opens the board with zero UTI warnings; both suites
green; verify-editions 30/30.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 21:46:27 -04:00

126 lines
5.1 KiB
Swift

import AppKit
import Foundation
import UniformTypeIdentifiers
// MARK: - The drag types
//
// `UTType.laneworkCards` — a drag carrying board **cards**, live faces or trash rows
// (04-interactions.md ▸ Drag and drop, ▸ The trash) — and `.laneworkLanes`, the strip's own reorder
// and the cross-board lane transfer. Declared in `Info.plist` beside the clipboard type, 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). The constants themselves live
// in `EditionTypes.swift` — base exports the types, Pro imports them, and each edition's twin uses
// the initializer its own plist can honor.
// 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
}
}