Deletion becomes a two-stage, Finder-style story. File > Delete and plain Backspace tombstone the live selection; View > Show Trash (no chord — Shift-Cmd-T stays with the system's tab bar) reveals the quasi-lane: trailing, one fixed width unit consumed only while shown, hatched dimmed header, count badge, no new-card button, exempt from resize and reorder alike. Its contents are a pure view over the snapshot — the deterministic sort (deleted newest first, folder-name ties, unparseable stamps oldest) interleaves card rows with a tombstoned lane's single entry, whose count names what Put Back returns; the ancestor walk is absolute, so an own-flag card beneath a tombstoned lane has no row and recovery is deliberately two steps. Put Back twins Delete on Cmd-Backspace with validation enabling exactly one; restore fidelity is byte-perfect because nothing ever moved. Delete Immediately confirms exactly where loss is real (every board is mode-none today; the predicate names the git carve-out for m7), Empty Trash always confirms with the true whole-board count, and dragging a tombstoned card onto a live lane restores it there — positional drops and cross-board locality arrive with m5's machinery. The banner's delete phrasing drops "move to the trash" per the naming constraint: board deletion says Delete, "Move to Trash" stays reserved for the system Trash. 47 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
163 lines
6.6 KiB
Swift
163 lines
6.6 KiB
Swift
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: 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
|
|
|
|
/// Whether this launch restores boards: the preference is on **and** there is something flagged
|
|
/// to restore. Welcome "appears only when nothing restores".
|
|
private let shouldRestoreAtLaunch: Bool
|
|
|
|
init() {
|
|
let model = AppModel()
|
|
_appModel = State(initialValue: model)
|
|
shouldRestoreAtLaunch = AppPreferences.restoreOpenBoardsAtLaunch
|
|
&& !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)
|
|
}
|
|
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .suppressed : .automatic)
|
|
.restorationBehavior(.disabled)
|
|
.windowResizability(.contentSize)
|
|
// 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()
|
|
|
|
Window("", id: WindowID.restoreBootstrap) {
|
|
RestoreBootstrapView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
.defaultLaunchBehavior(shouldRestoreAtLaunch ? .presented : .suppressed)
|
|
.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)
|
|
|
|
Settings {
|
|
SettingsView()
|
|
.environment(appModel)
|
|
.captureWindowActions(into: appModel)
|
|
}
|
|
}
|
|
|
|
/// The menu items the app has built so far.
|
|
///
|
|
/// **The titles are API** (04-interactions.md ▸ Configurable bindings): macOS's App Shortcuts
|
|
/// mechanism 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, and
|
|
/// changing one silently breaks every remap of it.
|
|
@CommandsBuilder
|
|
private var menuCommands: some Commands {
|
|
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…,
|
|
// still owed), Open…, (Open Recent, still owed), Board Info, (Duplicate / Save as Template /
|
|
// Reveal in Finder, still owed), then the trash trio and Empty Trash…. Every one of them
|
|
// validates against the frontmost board through the focus system, so each is simply
|
|
// absent-of-effect when no board is in front.
|
|
CommandGroup(after: .newItem) {
|
|
BoardCreationCommands()
|
|
|
|
Divider()
|
|
|
|
Button("Open…") {
|
|
appModel.presentOpenPanel()
|
|
}
|
|
.keyboardShortcut("o", modifiers: .command)
|
|
|
|
Divider()
|
|
|
|
BoardInfoCommand()
|
|
|
|
Divider()
|
|
|
|
TrashCommands()
|
|
}
|
|
|
|
// 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, alongside the card
|
|
// window's Edit Body / Raw Source / History still to come.
|
|
CommandGroup(after: .toolbar) {
|
|
ShowTrashCommand()
|
|
}
|
|
|
|
// The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…,
|
|
// then the width pair, with Open Card and the Move items still owed. 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.
|
|
CommandMenu("Board") {
|
|
BoardRenameCommand()
|
|
BoardStyleCommand()
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|