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
119 lines
5.2 KiB
Swift
119 lines
5.2 KiB
Swift
import Foundation
|
|
|
|
// MARK: - NativeHistoryProvider
|
|
|
|
/// The free tier's undo substrate: one stack per board session (13-native-undo.md).
|
|
///
|
|
/// ### Two arrays, and why not `NSUndoManager`
|
|
///
|
|
/// This provider was an `NSUndoManager` for exactly one milestone, on the argument that the *command
|
|
/// surface* is the platform's — Edit ▸ Undo and Edit ▸ Redo are the system's own nil-target
|
|
/// `undo:`/`redo:` rows (11-command-nexus.md), the toolbar pair carries the same actions
|
|
/// (`BoardToolbar`), and both light up, disable and **retitle** from whatever `UndoManager` the
|
|
/// focused window hands back. All of that is still true, and none of it lives here: the retitling is
|
|
/// `BoardUndoManager.undoMenuItemTitle` composing `undoMenuTitle(forUndoActionName:)` over the bare
|
|
/// phrase this seam vends as a `String?`. The adapter is the `UndoManager`; the substrate never
|
|
/// needed to be one.
|
|
///
|
|
/// What forced the change is the staleness milestone's third outcome. `HistoryStepOutcome.failed`
|
|
/// means **the step stays put** — a disk error is retryable, so ⌘Z must still be able to reach the
|
|
/// step it just could not write. `NSUndoManager` pops a group before running it and offers no way to
|
|
/// put it back: a registration made while undoing lands on the *redo* stack by its own documented
|
|
/// rule, and one made after the crossing returns clears the redo stack outright. Either way a failed
|
|
/// undo would have quietly destroyed something. Two arrays express all three outcomes exactly, and
|
|
/// the grammar they have to implement is four lines long.
|
|
///
|
|
/// ### One `register` call is exactly one step
|
|
///
|
|
/// Nothing here groups, coalesces, or waits for the end of a run-loop turn — 13's "one gesture, one
|
|
/// undo step" is a property of the Writer call sites (a multi-card move registers *one* step with a
|
|
/// plural title), and the substrate's job is to not have opinions about it. This is what
|
|
/// `NSUndoManager`'s `groupsByEvent = false` was buying, as an absence rather than a setting.
|
|
///
|
|
/// ### Undo flips to redo by reversing
|
|
///
|
|
/// A step that applies is pushed onto the opposite stack **reversed** — its two halves swapped
|
|
/// (`HistoryStep.reversed`) — which gives the whole classic dance (undo → redo → undo …) with one
|
|
/// rule. Both stacks therefore hold steps oriented so that *crossing them means calling `undo`*, and
|
|
/// a skipped step leaves nothing behind at all: it is popped and never re-pushed, which is 13's
|
|
/// "popped from the stack ... and ⌘Z falls through to the next step".
|
|
@MainActor
|
|
public final class NativeHistoryProvider: HistoryProviding {
|
|
|
|
/// The two stacks, top last. Both hold steps oriented for crossing — see the type's note.
|
|
private var undoSteps: [HistoryStep] = []
|
|
private var redoSteps: [HistoryStep] = []
|
|
|
|
public init() {}
|
|
|
|
// MARK: - HistoryProviding
|
|
|
|
public var canUndo: Bool { !undoSteps.isEmpty }
|
|
|
|
public var canRedo: Bool { !redoSteps.isEmpty }
|
|
|
|
/// The phrase the menu title is composed from, or `nil` when there is nothing to cross — and
|
|
/// also `nil` for a step registered without a name, which is the emptiness the adapter's `""`
|
|
/// contract is written against.
|
|
public var undoActionName: String? { name(of: undoSteps.last) }
|
|
|
|
public var redoActionName: String? { name(of: redoSteps.last) }
|
|
|
|
/// Records one undoable step and clears the redo stack — the classic rule, and the one every
|
|
/// substrate shares.
|
|
public func register(_ step: HistoryStep) {
|
|
undoSteps.append(step)
|
|
redoSteps.removeAll()
|
|
}
|
|
|
|
public func undo() { cross(.undo) }
|
|
|
|
public func redo() { cross(.redo) }
|
|
|
|
public func clear() {
|
|
undoSteps.removeAll()
|
|
redoSteps.removeAll()
|
|
}
|
|
|
|
// MARK: - The crossing
|
|
|
|
/// Crosses one step, and keeps going while the steps it crosses decline as **stale** — 13's
|
|
/// fall-through: "the step is skipped, not applied ... and ⌘Z falls through to the next step".
|
|
///
|
|
/// The loop's own exit is an empty stack, so a stack of nothing but stale steps empties itself
|
|
/// and stops rather than spinning. The other two outcomes each end the crossing after one step:
|
|
/// an applied step is the ⌘Z the user asked for, and a failed one leaves the stack exactly as it
|
|
/// found it (`HistoryStepOutcome.failed`).
|
|
private func cross(_ direction: HistoryDirection) {
|
|
while let step = pop(direction) {
|
|
switch step.undo(direction) {
|
|
case .applied:
|
|
push(step.reversed, onto: direction.opposite)
|
|
return
|
|
case .skipped:
|
|
continue
|
|
case .failed:
|
|
push(step, onto: direction)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func pop(_ direction: HistoryDirection) -> HistoryStep? {
|
|
direction == .undo ? undoSteps.popLast() : redoSteps.popLast()
|
|
}
|
|
|
|
private func push(_ step: HistoryStep, onto direction: HistoryDirection) {
|
|
if direction == .undo {
|
|
undoSteps.append(step)
|
|
} else {
|
|
redoSteps.append(step)
|
|
}
|
|
}
|
|
|
|
private func name(of step: HistoryStep?) -> String? {
|
|
guard let name = step?.name, !name.isEmpty else { return nil }
|
|
return name
|
|
}
|
|
}
|