File ▸ Export ▸ writes the frontmost board as Obsidian Kanban Markdown, a plain Markdown outline, or RFC 4180 CSV; File ▸ Import Board… reads any of the three back into a fresh board, format detected rather than asked. Every format encodes order as document position, so an export writes no ranks and an import mints them in parse order on the ordinary create path. Lossy exports post a warning-tone loss row naming the comments and attachments the destination cannot carry. Convert-once: nothing watches, nothing merges. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
387 lines
21 KiB
Swift
387 lines
21 KiB
Swift
import AppKit
|
|
import IndieAbout
|
|
import SwiftUI
|
|
|
|
/// The scene graph (02-architecture.md § Windows, § Launch and window lifecycle).
|
|
///
|
|
/// ### Four scenes, and why each is the kind it is
|
|
///
|
|
/// - **Welcome** is a `Window`: there is one of it, ever, and `openWindow(id:)` focuses the existing
|
|
/// one rather than making a second.
|
|
/// - **The restore bootstrap** is a `Window` too, and a deliberate oddity — see
|
|
/// `RestoreBootstrapView` for why launch-time work has to wear a window at all.
|
|
/// - **Boards** and **cards** are `WindowGroup(for:)`s, because their identity is a *value*: opening
|
|
/// with a ref that already has a window focuses it, which is how "one board window per root" and
|
|
/// "at most one card window per card (reopen focuses)" are enforced by the scene rather than by
|
|
/// bookkeeping.
|
|
///
|
|
/// ### Restoration is the registry's, not the system's
|
|
///
|
|
/// Both groups declare `.restorationBehavior(.disabled)`. The app already knows which boards were
|
|
/// open — the registry's open-now flags, which survive a crash and reopen in `lastOpened` order —
|
|
/// and letting AppKit *also* restore windows would produce duplicates, and worse, card windows
|
|
/// restored behind boards that never opened. One mechanism, and it is the one that can explain
|
|
/// itself when a board has moved or gone.
|
|
///
|
|
/// ### Which window appears at launch
|
|
///
|
|
/// Exactly one of welcome and the bootstrap, decided once in `init` and never re-derived (see
|
|
/// `LaunchPlan`): the preference is read before any scene exists, and `restorables()` costs a
|
|
/// bookmark resolution per known board — a computed property here would pay that on every
|
|
/// scene-graph evaluation.
|
|
@main
|
|
struct KanbanApp: App {
|
|
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
@State private var appModel: AppModel
|
|
|
|
/// What this launch does: welcome, the registry's restoration pass, or the accessibility audit
|
|
/// suite's fixture board (`LaunchPlan`, `UITestLaunch`).
|
|
private let launchPlan: LaunchPlan
|
|
|
|
init() {
|
|
// **AppKit window restoration is fully disowned** — restore-at-launch is the registry's job
|
|
// (02-architecture.md § Launch and window lifecycle), every scene below declares
|
|
// `.restorationBehavior(.disabled)`, and left alive the machinery is actively harmful: AppKit
|
|
// counts its saved state (even a windowless one) as "a restored session", and SwiftUI then
|
|
// treats every `defaultLaunchBehavior` as moot — the app launches with no windows at all and
|
|
// no way to get one, since `windowOpener` is captured by the first scene that appears
|
|
// (observed on macOS 26, 2026-07-29; `-ApplePersistenceIgnoreState YES` on the command line
|
|
// proved the mechanism). Registered here because `App.init` runs before `NSApplicationMain`,
|
|
// which is what makes a registration-domain default early enough for AppKit's read.
|
|
UserDefaults.standard.register(defaults: ["ApplePersistenceIgnoreState": true])
|
|
|
|
// Read first, because it decides *which app-side state the model is built over* — a fixture
|
|
// launch keeps its recents and its clipboard snapshots in the scratch directory rather than in
|
|
// the app's ordinary Application Support home.
|
|
let isUITestFixtureLaunch = UITestLaunch.isFixtureLaunch
|
|
if isUITestFixtureLaunch {
|
|
UITestLaunch.prepareScratchDirectory()
|
|
}
|
|
let model = AppModel(
|
|
registryStorageURL: isUITestFixtureLaunch
|
|
? UITestLaunch.registryStorageURL
|
|
: BoardRegistry.defaultStorageURL,
|
|
clipboardStagingRoot: isUITestFixtureLaunch
|
|
? UITestLaunch.clipboardStagingRoot
|
|
: ClipboardStore.defaultStagingRoot
|
|
)
|
|
_appModel = State(initialValue: model)
|
|
launchPlan = LaunchPlan.decide(
|
|
isUITestFixtureLaunch: isUITestFixtureLaunch,
|
|
restorePreference: AppPreferences.restoreOpenBoardsAtLaunch,
|
|
hasRestorables: !model.boardRegistry.restorables().isEmpty
|
|
)
|
|
// The delegate is constructed by the adaptor before this runs, so this is the one place the
|
|
// app's model and its AppKit half meet.
|
|
appDelegate.appModel = model
|
|
}
|
|
|
|
var body: some Scene {
|
|
Window("Welcome to Lanework", id: WindowID.welcome) {
|
|
WelcomeView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
// **Never presented by the system** — the restore bootstrap below opens welcome through
|
|
// `AppModel.showWelcome()` when the launch pass ends with nothing else on screen. `.automatic`
|
|
// was tried here (conditioned on the plan) and macOS 26 answered it by presenting *no scene at
|
|
// all*: not welcome, not even a `.presented` bootstrap — a windowless launch with no way back,
|
|
// since `windowOpener` is captured by the first scene that appears. One presenter, one rule.
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.restorationBehavior(.disabled)
|
|
// "Welcome: resizable, no title bar (background drag)" (03-board-ui.md § Welcome screen &
|
|
// templates). `.contentMinSize` rather than `.contentSize`, because the view states a
|
|
// *minimum* and the recents list is meant to take whatever space the user gives it; the
|
|
// hidden title bar is why `WelcomeView` carries a `WindowDragGesture`.
|
|
.windowResizability(.contentMinSize)
|
|
.defaultSize(width: 820, height: 500)
|
|
.windowStyle(.hiddenTitleBar)
|
|
// Its automatic Window-menu item is replaced by the explicit command below, so the title is
|
|
// the one 11-command-nexus.md names rather than whatever the scene happens to be called.
|
|
.commandsRemoved()
|
|
|
|
// The template chooser (09-templates.md), reached from File ▸ New Board… ⌥⌘N and from
|
|
// welcome's own button. A `Window` for welcome's reason — there is one of it, ever — and
|
|
// suppressed at launch, since ⌥⌘N is the only thing that ever asks for it.
|
|
Window("New Board", id: WindowID.templateChooser) {
|
|
TemplateChooserView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.restorationBehavior(.disabled)
|
|
.windowResizability(.contentSize)
|
|
.commandsRemoved()
|
|
|
|
Window("", id: WindowID.restoreBootstrap) {
|
|
RestoreBootstrapView(plan: launchPlan)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
// **Presented at every launch, whatever the plan** — the bootstrap is the app's one reliable
|
|
// way to put a window on screen. Welcome's `.automatic` above is a request the system is free
|
|
// to decline, and on macOS 26 it does: a launch with nothing to restore presented *no* scene
|
|
// at all, which left `windowOpener` uncaptured and the app a windowless shell no menu action
|
|
// could revive (observed 2026-07-29). The pass itself
|
|
// still dispatches on the plan — a `.welcome` launch restores nothing and shows welcome —
|
|
// and this window stays invisible and dismisses itself either way.
|
|
.defaultLaunchBehavior(.presented)
|
|
.restorationBehavior(.disabled)
|
|
.windowStyle(.plain)
|
|
.defaultSize(width: 1, height: 1)
|
|
.commandsRemoved()
|
|
|
|
WindowGroup(id: WindowID.board, for: BoardWindowRef.self) { $ref in
|
|
if let ref {
|
|
BoardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
.commands { menuCommands }
|
|
|
|
WindowGroup(id: WindowID.card, for: CardWindowRef.self) { $ref in
|
|
if let ref {
|
|
CardWindowHost(ref: ref)
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
.restorationBehavior(.disabled)
|
|
.defaultLaunchBehavior(.suppressed)
|
|
// The **first** card window's size, and only that one: every later window opens at the
|
|
// last-used size or at its card's remembered frame, both applied by the host as the window
|
|
// attaches (05-card-window.md ▸ Window). Derived from font metrics like every other
|
|
// measurement in that window rather than written down in points.
|
|
.defaultSize(CardWindowMetrics.defaultSize(bodyPointSize: CardWindowMetrics.bodyPointSize))
|
|
|
|
Settings {
|
|
SettingsView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
|
|
/// Every menu command 11-command-nexus.md inventories, at full row parity: built and validated
|
|
/// where the window behind it already exists, present-but-`FutureCommand`-disabled where it
|
|
/// doesn't (`FutureCommands.swift`).
|
|
///
|
|
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
|
/// mechanism — `NSUserKeyEquivalents` under the hood — remaps menu items *by title*, so these
|
|
/// strings are the keys a user's custom binding is stored under. They are spelled exactly as
|
|
/// 11-command-nexus.md inventories them, unique across the whole menu bar, and changing one
|
|
/// silently breaks every remap of it. Nothing below builds that mechanism — a stable title is the
|
|
/// whole of the contract, and the system supplies the rest.
|
|
@CommandsBuilder
|
|
private var menuCommands: some Commands {
|
|
// The About window (11-command-nexus.md files About under the app menu's standard
|
|
// furniture): icon, version/build/date from the Info.plist that `update_build_info.sh`
|
|
// stamped at build time — never a hardcoded string — with the version line opening the
|
|
// bundled changelog. `AboutBox` names no edition (12-editions.md ▸ PIVOT 2026-08-08) —
|
|
// there is one version of this app, so there is one box.
|
|
IndieAboutCommand(configuration: AboutBox.configuration)
|
|
|
|
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, New Board…,
|
|
// Open…, Open Recent ▸, Board Info, Duplicate, Save as Template, Share…, Reveal in Finder,
|
|
// Add Attachment…, then the trash trio and Empty Trash…. The board-scoped ones validate
|
|
// against the frontmost board through the focus system, so each is simply absent-of-effect
|
|
// when no board is in front; New Board… and Open Recent are available everywhere, including
|
|
// with no window at all. Close is the system's own item — no row of ours to add.
|
|
//
|
|
// **Share… joined 2026-08-09** (design ruling, card 72691b11) right after Save as
|
|
// Template: the third command in a row that copies the whole board wholesale, each to a
|
|
// different destination — a sibling folder, the templates store, or a share sheet.
|
|
CommandGroup(after: .newItem) {
|
|
BoardCreationCommands()
|
|
NewBoardCommand(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
Button("Open…") {
|
|
appModel.presentOpenPanel()
|
|
}
|
|
.keyboardShortcut("o", modifiers: .command)
|
|
|
|
// **Beside Open…, not beside Export** (15-import-export.md): the two rows next to each
|
|
// other are the two ways a board arrives from disk, and like Open… this one needs no board
|
|
// in front — it makes one. Export lives further down with the board-scoped commands that
|
|
// act on the board already there. No default chord, like Welcome's row.
|
|
ImportBoardCommand(appModel: appModel)
|
|
|
|
OpenRecentMenu(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
BoardInfoCommand()
|
|
|
|
Divider()
|
|
|
|
DuplicateBoardCommand(appModel: appModel)
|
|
SaveAsTemplateCommand(appModel: appModel)
|
|
ShareBoardCommand(appModel: appModel)
|
|
// **Export ▸ joined 2026-08-09** (15-import-export.md) as the fourth row in a row that
|
|
// takes the whole board somewhere else — a sibling folder, the templates store, a share
|
|
// sheet, and now one foreign document. Three named rows rather than one row with a format
|
|
// popup; see `ExportBoardMenu` for why, and for why the titles matter.
|
|
ExportBoardMenu(appModel: appModel)
|
|
RevealInFinderCommand()
|
|
AddAttachmentCommand()
|
|
// The attachment row's hero pair, as the menu-bar twins 11-command-nexus.md's
|
|
// context-menu contract requires ("no function's only home"). Two rows rather than one
|
|
// that renames itself, for titles-are-API's reason (`SetAsHeroCommand`).
|
|
SetAsHeroCommand()
|
|
RemoveHeroCommand()
|
|
AddCommentCommand()
|
|
|
|
Divider()
|
|
|
|
TrashCommands()
|
|
}
|
|
|
|
// Edit ▸ Undo/Redo, replacing the system's own pair — **the command surface is the app's**
|
|
// (13-native-undo.md ▸ Rules, re-ruled 2026-08-08): the platform's nil-target rows resolve
|
|
// through `NSWindow.undoManager`, which a SwiftUI window latches empty before any delegate of
|
|
// ours can vend the board's, so the rows below read the focused session's stack themselves
|
|
// (`UndoCommands.swift`).
|
|
UndoRedoCommands()
|
|
|
|
// The Edit menu: Paste as Board Background, then Find (⌘F) and its card-window stepping
|
|
// twins, placed after the standard Cut/Copy/Paste/Select All group, which is where macOS puts
|
|
// Find. The clipboard items above them stay the system's, answered by the board as a
|
|
// responder (`ClipboardCommands.swift`) — a second item sharing one of those titles is what
|
|
// titles-are-API forbids. Undo and Redo were the system's too until the latch (the group just
|
|
// above).
|
|
//
|
|
// **Paste as Board Background joined 2026-08-09** with the image-data paste branch: it sits
|
|
// directly under the system's Paste because it is the one paste this app has that ⌘V could
|
|
// not carry — a backdrop has no selection to target, so it needs a name rather than a
|
|
// modifier (`PasteBoardBackgroundCommand`). No default chord, like Welcome's row.
|
|
CommandGroup(after: .pasteboard) {
|
|
PasteBoardBackgroundCommand(clipboard: appModel.clipboard)
|
|
|
|
Divider()
|
|
|
|
FindCommand()
|
|
FindSteppingCommands()
|
|
}
|
|
|
|
// The system's own toolbar rows — Show/Hide Toolbar and **Customize Toolbar…**, which
|
|
// 11-command-nexus.md files under Standard macOS furniture ("Customize Toolbar… per system
|
|
// convention (03)"). They are the platform's, spelled by the platform: both are nil-target
|
|
// AppKit actions the key window's toolbar answers, so they validate per window (disabled on
|
|
// welcome, live on the board and card windows) with nothing of ours in between. The
|
|
// right-click ▸ Customize Toolbar… path 03 names is AppKit's too, and needs no row at all.
|
|
ToolbarCommands()
|
|
|
|
// The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own
|
|
// items live in — which is where 11-command-nexus.md files Show Trash. Three dividers split it
|
|
// by scope, which is the only grouping the inventory implies: the board's toggle, then the
|
|
// board's zoom ladder, then the card window's view-state rows, then the app-wide appearance
|
|
// override — last, because unlike everything above it, it needs no window in front at all.
|
|
//
|
|
// "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window
|
|
// menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a
|
|
// second one would collide (the rule this file's own header states).
|
|
CommandGroup(after: .toolbar) {
|
|
ShowTrashCommand()
|
|
|
|
Divider()
|
|
|
|
ZoomCommands(appModel: appModel)
|
|
|
|
Divider()
|
|
|
|
CardViewCommands()
|
|
|
|
Divider()
|
|
|
|
AppearanceCommands(appModel: appModel)
|
|
}
|
|
|
|
// The Board menu (11-command-nexus.md), complete and in its inventoried row order — Open
|
|
// Card, Copy Link, Rename, Style…, the card moves, the lane moves, the width pair. Its items
|
|
// act on the frontmost board window, which they reach through the focus system rather than
|
|
// through the app model — see `BoardCommands.swift`, which also owns their validation.
|
|
//
|
|
// **Board Settings… came out 2026-08-07** with the sheet it opened (03 ▸ Board settings
|
|
// sheet, marked retired; the 2026-07-31 popover/sheet split reversed): a board is configured
|
|
// in its popover, whose keyboard door is File ▸ Board Info ⌘I, so this menu's last divider
|
|
// went with the row.
|
|
//
|
|
// **The Board ▸ Pull/Push row (`RemoteCommands`) came out 2026-08-08** with app-managed git
|
|
// itself (strategy/01-git-excision.md): the width pair is now the menu's last row.
|
|
//
|
|
// **Copy Link joined 2026-08-09** (design ruling, card 737a949f) right beside Open Card: the
|
|
// two read-only rows — nothing here mutates the board — grouped ahead of the edit-shaped
|
|
// block below (Rename, Style…), which is where the context menu puts the same two.
|
|
CommandMenu("Board") {
|
|
OpenCardCommand()
|
|
CopyLinkCommand()
|
|
BoardRenameCommand()
|
|
BoardStyleCommand()
|
|
|
|
Divider()
|
|
|
|
MoveCardCommands()
|
|
MoveLaneCommands()
|
|
|
|
Divider()
|
|
|
|
LaneWidthCommands()
|
|
}
|
|
|
|
CommandGroup(after: .windowList) {
|
|
// No default chord — "— (no default)" in the Nexus is deliberate, not a gap; it remaps
|
|
// like any other item.
|
|
Button("Welcome to Lanework") {
|
|
appModel.showWelcome()
|
|
}
|
|
}
|
|
|
|
// File ▸ Print… (⌘P) — **the app's own row**, replacing the system's nil-target one
|
|
// (11-command-nexus.md's Print row; the "No Print story in v1 (⌘P unused)" line it retired).
|
|
//
|
|
// `replacing: .printItem` rather than an addition, for the same reason Undo/Redo replace the
|
|
// platform's pair: the standard rows are nil-target actions resolved through the responder
|
|
// chain, and this app's print target is the *frontmatter-shaped document behind the focused
|
|
// window*, which no responder vends. Two items sharing the title "Print…" is also exactly what
|
|
// titles-are-API forbids.
|
|
//
|
|
// A reported "This application does not support printing" alert (2026-08-09) turned out to be
|
|
// the sandbox denying `NSPrintOperation` for want of `com.apple.security.print`
|
|
// (`Kanban.entitlements`) — the actual fix, not anything here. Found alongside it, and worth
|
|
// keeping regardless: `PrintCommand` used to disable itself over a window with no board and no
|
|
// card, and a disabled `Button` still owns its `.keyboardShortcut` — the unclaimed chord fell
|
|
// straight through to AppKit's own nil-target `printDocument:`, whose stock failure happens to be
|
|
// the identical alert text by a different route. The row now claims ⌘P unconditionally and
|
|
// answers the no-board-no-card case itself (`PrintCommand`'s doc comment).
|
|
//
|
|
// Page Setup… stays absent with it: the paper questions are answered in the print panel's own
|
|
// page-setup group (`PrintCoordinator`), so a second dialog would be a second place to set one
|
|
// margin.
|
|
CommandGroup(replacing: .printItem) {
|
|
PrintCommand(appModel: appModel)
|
|
}
|
|
|
|
// Help carries "the one line teaching the System Settings remap path" (11-command-nexus.md ▸
|
|
// Standard macOS furniture) — this app's whole Help menu, since there is no other content to
|
|
// give it. The pane identifier is the modern System Settings extension id, not the legacy
|
|
// `com.apple.preference.keyboard` prefpane path: verified on macOS 26 by observing
|
|
// `KeyboardSettings.appex` (service `com.apple.Keyboard-Settings.extension`) launch in
|
|
// response to this exact URL. `?Shortcuts` is as deep as a URL reaches; **App Shortcuts**
|
|
// itself is one more click, the sidebar row inside that pane.
|
|
CommandGroup(after: .help) {
|
|
Button("Customize Keyboard Shortcuts…") {
|
|
guard let url = URL(
|
|
string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension?Shortcuts"
|
|
) else { return }
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
}
|