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
125 lines
5.0 KiB
Swift
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
|
|
}
|
|
}
|