Files
lanework/Kanban/UI/Board/DragPayload.swift
T
rzen 092300c7d2 Collapse the edition split — one target, twins merged, verify-editions retired
Phase 1 of the 2026-07-30 one-app pivot (DESIGN 0bec9a6, card c3a3ddd5):
the KanbanPro target, LaneworkPro scheme, KanbanProTests module-alias
bundle, KanbanPro/ source root and scripts/verify-editions.sh retire
wholesale. project.yml reads as a single-target file again (anchors
inlined, header rewritten in tier vocabulary).

The edition twins merge: EditionTypes -> PasteboardTypes (one
UTType(exportedAs:) home — the one app owns the family types),
EditionAbout -> AboutBox (the quiet Pro signpost survives as the About
box's one line; "…in Settings" deferred until the StoreKit phase gives
it somewhere to point). InertGitTests drops its Base prefix — the
inert-.git posture is unconditional app behavior, unsubscribed and
lapsed being one state.

Entitlements gain com.apple.security.network.client, declared now and
dormant until Pro's remotes use it; no keychain access group. The App
Group key deliberately stays — it goes with AppGroup.swift in phase 2,
since pulling it first would silently drop the app into the fallback
container. README/RELEASE.md build-and-pipeline prose updated to the
one-record world; the subscription story lands with phase 3.

1893 tests in 322 suites green.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-30 17:25:50 -04:00

125 lines
5.0 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
// with the clipboard type in `PasteboardTypes.swift`, exported once by the app that owns them.
// 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
}
}