Files
lanework/Kanban/UI/Board/BoardToolbar.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

137 lines
6.6 KiB
Swift

import AppKit
// MARK: - Identifiers
extension NSToolbarItem.Identifier {
static let boardSearch = Self("board.search")
static let boardNewCard = Self("board.newCard")
static let boardNewLane = Self("board.newLane")
static let boardUndo = Self("board.undo")
static let boardRedo = Self("board.redo")
static let boardShowTrash = Self("board.showTrash")
}
// MARK: - The board window's toolbar
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
///
/// ### The default is one item, and the catalog is five more
///
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
///
/// "**Catalog** (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash (toggle
/// state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
///
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
///
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
///
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
/// carry the same actions with the same nil target, so "matching their menu items" (03) is not a
/// predicate written here — it is literally the same validation. Both reach the board window, whose
/// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore
/// enable exactly when that board has a step to cross and no read-only lock stands
/// (13-native-undo.md ▸ Rules). **Every board has undo in every tier**, so there is no tier-shaped
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
/// only a Pro subscription binds.
///
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
@MainActor
enum BoardToolbar {
/// Shared by every board window, which is what makes the user's arrangement the *app's* rather
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
static let identifier = "dev.rzen.indie.Kanban.board"
/// "The search field, nothing else — trailing, the one default item."
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [ToolbarItemSpec] {
[
.mirroring(
menuTitle: "New Card",
identifier: .boardNewCard,
symbol: "doc.badge.plus",
behavior: .button(
isEnabled: { [weak store] in store?.newCardTarget != nil },
perform: { [weak store] in store?.beginNewCard() }
)
),
.mirroring(
menuTitle: "New Lane",
identifier: .boardNewLane,
symbol: "rectangle.stack.badge.plus",
behavior: .button(
isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
perform: { [weak store] in store?.createLane() }
)
),
.staticLabel(
"Undo",
identifier: .boardUndo,
symbol: "arrow.uturn.backward",
behavior: .responderAction(NSSelectorFromString("undo:"))
),
.staticLabel(
"Redo",
identifier: .boardRedo,
symbol: "arrow.uturn.forward",
behavior: .responderAction(NSSelectorFromString("redo:"))
),
.mirroring(
menuTitle: "Show Trash",
identifier: .boardShowTrash,
symbol: "trash",
behavior: .toggle(
isEnabled: { [weak store] in store != nil },
isOn: { [weak store] in store?.transient.isTrashVisible == true },
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
)
),
.staticLabel(
"Search",
identifier: .boardSearch,
symbol: nil,
// The field's width in *characters* rather than points (10-accessibility.md ▸ Text
// scaling: "no fixed point sizes") — the same 17 ems the transient bar's field takes
// (`BoardSearchBar`), so ⌘F's two homes are one width whichever the user is in.
behavior: .control(width: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)) {
[weak store] willBeInserted in
guard willBeInserted, let store else {
return BoardSearchFieldController.makePaletteField()
}
return BoardSearchFieldController.makeField(
store: store,
presentation: search,
home: .toolbar
)
}
),
]
}
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
/// toolbar, invoking it surfaces the field transiently until the search clears").
static func controller(store: BoardStore, search: BoardSearchPresentation) -> WindowToolbarController {
let controller = WindowToolbarController(
identifier: identifier,
specs: specs(store: store, search: search),
defaults: defaultItems
)
controller.onInstalledItemsChanged = { [weak search] identifiers in
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
}
return controller
}
}