diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index d3ed8c6..3288948 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -289,6 +289,11 @@ struct CardWindowHost: View { /// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card /// windows are two documents, and the menu bar reaches the frontmost one through the focus system. @State private var cardPrint = CardPrintSubject() + /// This window's card-level actions — today, just Delete Card, promoted off the sidebar's former + /// Actions section to the toolbar (`CardWindowActions`, `CardToolbar`). Window-scoped for + /// `CardBodyPresentation`'s reason: File ▸ Delete Card (`DeleteCardCommand`) reaches the + /// frontmost card window through the focus system exactly as Add Attachment… does. + @State private var cardActions = CardWindowActions() /// This window's thumbnail memory. Held here rather than in the section so it survives every /// snapshot the store applies — a cache that died with the view would regenerate every thumbnail /// on every reload (`AttachmentThumbnailCache`). @@ -394,6 +399,10 @@ struct CardWindowHost: View { // File ▸ Print… (⌘P) reaches the frontmost card window the same way — the card-window scope // of a row the board window answers with its whole board (11-command-nexus.md). .focusedSceneValue(\.cardPrint, cardPrint) + // File ▸ Delete Card reaches the frontmost card window the same way — the toolbar's own + // Delete Card item reads this same handle directly, wired at install time rather than + // through the focus system (`configureWindow`). + .focusedSceneValue(\.cardWindowActions, cardActions) // The raw-source outlet's detailed alert, presented over this window — a validation // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather // than inside the editor because the second of those fires while source mode is still @@ -536,6 +545,9 @@ struct CardWindowHost: View { .onChange(of: store.isReadOnly, initial: true) { _, locked in attachments.isEditable = !locked session.comments.isEditable = !locked + // Delete Card's own read-only gate — the sidebar Actions button's `.disabled(store + // .isReadOnly)`, carried over verbatim to its toolbar and menu-row twins. + cardActions.isDeletable = !locked } // **The thread re-reads on every landed reload** (05 ▸ The comments column: "the pane // reloads its thread from the same FSEvents stream"). @@ -729,6 +741,24 @@ struct CardWindowHost: View { attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard ) Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) + Self.configureActions(cardActions, store: store, cardID: cardID) + } + + /// Points this window's card-level actions at its card — **the one place File ▸ Delete Card and + /// the toolbar's Delete Card item learn which card they act on**, `configureAttachments`'s pattern + /// applied to the seam Actions ▸ Delete left behind when the sidebar section retired. + /// + /// The store is captured **weakly**, `configureAttachments`'s own reason: a toolbar click or menu + /// row still firing after the board window has gone should write nothing rather than resurrect a + /// released store. + /// + /// `static`, and taking every collaborator as a parameter, for `configureAttachments`'s reason: + /// which card a gesture lands on is invisible in a running window until it is wrong, and this + /// shape is what lets a test drive the real wiring rather than a re-typed copy of it. + static func configureActions(_ actions: CardWindowActions, store: BoardStore, cardID: ItemID) { + actions.deleteCard = { [weak store] in + store?.deleteCard(cardID) + } } /// Points this window's session at **its own undo stack** — the three seams the two-level model @@ -951,13 +981,19 @@ struct CardWindowHost: View { // The window's customizable toolbar — Edit Body · Raw Source · Add Attachment, "the // window's three committed functions" (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸ - // Window), joined by Show Sidebar (below). It carries the three window-scoped handles above - // rather than a store, which is why it is installed here and not at attach: those are this - // window's, and so is it. Show Sidebar needs none of them — its read and write default to + // Window), joined by Show Sidebar and, since the sidebar's Actions section retired, Delete + // Card and Reveal in Finder (below). It carries the four window-scoped handles above rather + // than a store, which is why it is installed here and not at attach: those are this window's, + // and so is it. Show Sidebar needs none of them — its read and write default to // `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card // window answers to, so this call leaves the two injectable parameters unnamed. windowController.installToolbar( - CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments) + CardToolbar.controller( + body: bodyPresentation, + rawSource: rawSource, + attachments: attachments, + actions: cardActions + ) ) // **No title in the title bar** — the card's name is shown as part of the card's body diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index 536dd27..226c2fc 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -236,6 +236,14 @@ struct KanbanApp: App { SetAsHeroCommand() RemoveHeroCommand() AddCommentCommand() + // **Delete Card joined 2026-08-09** (05-card-window.md ▸ Actions, retired — Pipeline card + // bcd3b323): the card window's own delete, promoted off the sidebar's Actions section to + // a menu row so the new toolbar item mirroring it (`CardToolbar`) has the menu twin every + // toolbar function needs. Distinctly titled from the row below — "Delete" is that row's + // own singleton title (11-command-nexus.md) — and deliberately chord-less: an enabled + // delete-key equivalent here would steal delete-to-line-start from this window's text + // surfaces, the reason `TrashCommands`' own ⌘⌫ was never extended to the card window. + DeleteCardCommand() Divider() diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 716fb4f..192dab8 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -4513,8 +4513,10 @@ public final class BoardStore: HealHost { _ = moveLanesToTrash(lanes) } - /// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card - /// to the trash … the window then dismisses itself"). + /// **The card window's own delete** — the sidebar's Actions ▸ Delete button until 2026-08-09 + /// (Pipeline card bcd3b323), now the window's toolbar item and File ▸ Delete Card + /// (`CardWindowActions`, `CardToolbar`) — moving the card to the trash; the window then dismisses + /// itself (05-card-window.md ▸ Actions, ▸ Deletion & lifecycle). /// /// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable /// from one deleted with ⌫ on the board or dropped on the trash column. What differs is the same diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift index 85f1900..d5b5ee1 100644 --- a/Kanban/UI/Card/CardSidebarSections.swift +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -256,75 +256,3 @@ struct CardStyleSection: View { ) } } - -// MARK: - Actions - -/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions): -/// **Delete** — moves the card to the trash — and **Reveal in Finder** — the card's folder. -/// -/// ### Delete writes; the window's dismissal is not its business -/// -/// The button calls `BoardStore.deleteCard`, which is the ⌫ delete exactly (same write op, same -/// bracket, same stamps). It does not close this window: the card's move into `.trash/` rounds back through -/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a -/// delete from the board — or from an agent — already takes. Dismissing from here as well would be a -/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a -/// pair ("moves the card to the trash; the window then dismisses itself"). -/// -/// Recovery is the board's trash column, which is why this needs no confirmation: the card is still -/// there to drag out or cut and paste back (03-board-ui.md § Trash — there is no Put Back), and 03 -/// reserves the alert for the purge that isn't recoverable. -/// -/// ### Reveal is not edit-shaped -/// -/// So it stays enabled under the read-only lock, where Delete does not — inspection is a read (04 ▸ -/// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from -/// `CardAttachments.revealURLs`, the same rule File ▸ Reveal in Finder's card-window scope answers -/// through: this button is that rule's card-folder branch by construction, since it is the *card's* -/// action rather than the attachment list's. -struct CardActionsSection: View { - - let store: BoardStore - let cardID: ItemID - /// The card's own folder — `nil` only where the window has no board to build it from, which is a - /// window on its way out. - let cardFolder: URL? - - private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } - - private var revealURLs: [URL] { - CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false) - } - - var body: some View { - VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { - CardSidebarSectionHeader(title: "Actions") - - // Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05 - // — the one control in this window that takes the card away. Disabled under the - // read-only lock like every other mutation (02-architecture.md's every-entry-point - // predicate); that is the attachments section's `isEditable`, read from the store - // directly because there is no handle to route it through here and nothing else in this - // section that would want one. - Button(role: .destructive) { - store.deleteCard(cardID) - } label: { - // The width is the *label's*, not the button's: a bordered button sizes to its label, - // so a frame around the button would centre a small pill in a wide row instead of - // filling it. Both rows do it, so the two are one column rather than two widths. - Text("Delete").frame(maxWidth: .infinity) - } - .tint(.red) - .disabled(store.isReadOnly) - - Button { - NSWorkspace.shared.activateFileViewerSelecting(revealURLs) - } label: { - Text("Reveal in Finder").frame(maxWidth: .infinity) - } - .disabled(revealURLs.isEmpty) - } - .buttonStyle(.bordered) - .frame(maxWidth: .infinity, alignment: .leading) - } -} diff --git a/Kanban/UI/Card/CardToolbar.swift b/Kanban/UI/Card/CardToolbar.swift index 4a9aad1..b97537f 100644 --- a/Kanban/UI/Card/CardToolbar.swift +++ b/Kanban/UI/Card/CardToolbar.swift @@ -7,6 +7,8 @@ extension NSToolbarItem.Identifier { static let cardRawSource = Self("card.rawSource") static let cardAddAttachment = Self("card.addAttachment") static let cardShowSidebar = Self("card.showSidebar") + static let cardDeleteCard = Self("card.deleteCard") + static let cardRevealInFinder = Self("card.revealInFinder") } // MARK: - The card window's toolbar @@ -14,14 +16,19 @@ extension NSToolbarItem.Identifier { /// The card window's toolbar (03-board-ui.md ▸ Toolbar; 05-card-window.md ▸ Window). /// /// "**Card window default: Edit Body · Raw Source · Add Attachment** — the window's three committed -/// functions, all discoverable from its toolbar; the catalog is the same trio." So the default set -/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of -/// items — which is exactly what a window with three functions should offer. **Show Sidebar joined -/// them** (05-card-window.md ▸ Composition, the toggle added beside the toolbar-customization work): -/// a fourth default item, the trailing sidebar's own show/hide control, the `Show Trash` precedent -/// applied to the card window's one collapsible pane. +/// functions, all discoverable from its toolbar." **Show Sidebar joined them** (05-card-window.md ▸ +/// Composition, the toggle added beside the toolbar-customization work): a fourth default item, the +/// trailing sidebar's own show/hide control, the `Show Trash` precedent applied to the card window's +/// one collapsible pane. **Delete Card joined them too** (05 ▸ Actions, retired — Pipeline card +/// bcd3b323): the sidebar's Actions section is gone, and its destructive Delete moved here as the +/// fifth default item, trailing a `.flexibleSpace` so it sits apart from the four creation-and-view +/// items ahead of it — HIG's "destructive actions read as separate from the everyday cluster", +/// `Mail.app`'s own toolbar Delete being the nearest system precedent for a one-click, no-confirm, +/// recoverable-by-trash button. **Reveal in Finder joined the catalog** the same day, catalog-only — +/// it already has a menu-bar twin with no default chord (`RevealInFinderCommand`), so nothing was +/// unreachable before this; the toolbar item is Customize's shortcut to it, not its only path. /// -/// ### The four items are the four menu rows, predicates included +/// ### The six items are six menu rows, predicates included /// /// - **Edit Body** is "a single toggle button (on-state in Edit — mirroring the View ▸ Edit Body /// checkmark)", and it disables while source mode is active. That clause is not restated here: the @@ -41,6 +48,16 @@ extension NSToolbarItem.Identifier { /// app-wide, persisted bit `CardWindowView` renders from and every card window's toolbar answers /// to alike. Always enabled, `ShowCommentsCommand`'s own posture: showing or hiding a pane is not a /// mutation, so the read-only lock has no say in it. +/// - **Delete Card** is a push button carrying `trash`, mirroring **File ▸ Delete Card** +/// (`DeleteCardCommand`) — a new, distinctly titled row (never "Delete": 11-command-nexus.md files +/// that title as the board-scope ⌘⌫ row's own singleton). Its predicate is the sidebar button's own +/// read-only check (`CardWindowActions.isDeletable`), and firing it calls `BoardStore.deleteCard(_:)` +/// directly — the same write, same bracket, same stamps ⌫ and the sidebar button always used. **No +/// confirmation**, matching the delete flow it replaces exactly: recovery is the board's trash lane, +/// so the alert 03 reserves is for the permanent purge, not this move. +/// - **Reveal in Finder** mirrors the existing card-window branch of `RevealInFinderCommand` — same +/// computation (`CardAttachments.revealURLs`), same `NSWorkspace` call, so the toolbar item and the +/// menu row can never disagree about what "the selected attachment, or the card's folder" means. /// /// Labels are the menu titles minus a trailing ellipsis, so File ▸ Add Attachment… labels as **Add /// Attachment** (03's own example). @@ -49,16 +66,24 @@ enum CardToolbar { static let identifier = "dev.rzen.indie.Kanban.card" - /// "The catalog is the same trio" plus Show Sidebar — so the defaults are the whole catalog, in - /// this file's order. + /// The trio plus Show Sidebar plus Delete Card — the whole catalog except Reveal in Finder, which + /// is catalog-only (already reachable off its own menu row, no default chord). The trailing + /// `.flexibleSpace` is what "placed apart from the creation-side defaults" means in code: Delete + /// Card is still a default, just not adjacent to the four items ahead of it. static let defaultItems: [NSToolbarItem.Identifier] = [ .cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, + .flexibleSpace, + .cardDeleteCard, ] /// - Parameters: + /// - actions: Delete Card's window-scoped handle (`CardWindowActions`) — the same object + /// `DeleteCardCommand` reads through the focus system, handed here directly since the toolbar + /// is installed once at window-open time rather than reactively (`CardWindowHost + /// .configureWindow`). /// - isSidebarShown: Show Sidebar's read, defaulted to the real bit /// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without /// touching the developer's own `UserDefaults.standard` domain — `StyleRecents`' own caution, @@ -69,6 +94,7 @@ enum CardToolbar { body: CardBodyPresentation, rawSource: CardRawSourceSession, attachments: CardAttachments, + actions: CardWindowActions, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> [ToolbarItemSpec] { @@ -124,13 +150,59 @@ enum CardToolbar { symbol: "sidebar.right", behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown) ), + // **The sidebar's former Actions ▸ Delete** (05 ▸ Actions, retired) — same write, same + // no-confirmation posture: recovery is the board's trash lane, so there is nothing here + // for an alert to guard. Placed last, after the trailing `.flexibleSpace` in + // `defaultItems`, so it never sits adjacent to the four creation/view items ahead of it. + .mirroring( + menuTitle: "Delete Card", + identifier: .cardDeleteCard, + symbol: "trash", + behavior: .button( + isEnabled: { [weak actions] in DeleteCardCommand.isEnabled(actions) }, + perform: { [weak actions] in actions?.deleteCard?() } + ) + ), + // **The sidebar's former Actions ▸ Reveal in Finder** (05 ▸ Actions, retired) — catalog + // only, since it already has a menu-bar twin with no default chord + // (`RevealInFinderCommand`); Customize is this button's whole reason to exist, not its + // only path. The computation is that row's card-window branch, verbatim, so the two + // surfaces can never disagree about what "the selected attachment, or the card's folder" + // means. + .mirroring( + menuTitle: "Reveal in Finder", + identifier: .cardRevealInFinder, + symbol: "folder", + behavior: .button( + isEnabled: { [weak attachments] in + guard let attachments else { return false } + return !Self.revealURLs(attachments).isEmpty + }, + perform: { [weak attachments] in + guard let attachments else { return } + NSWorkspace.shared.activateFileViewerSelecting(Self.revealURLs(attachments)) + } + ) + ), ] } + /// `RevealInFinderCommand`'s own card-window branch, read here rather than re-derived — the + /// selected attachment's file when the section has the keyboard, the card's folder otherwise + /// (`CardAttachments.revealURLs`'s own rule). + private static func revealURLs(_ attachments: CardAttachments) -> [URL] { + CardAttachments.revealURLs( + cardFolder: attachments.cardFolder, + selectedURL: attachments.selectedURL, + isSectionFocused: attachments.isFocused + ) + } + static func controller( body: CardBodyPresentation, rawSource: CardRawSourceSession, attachments: CardAttachments, + actions: CardWindowActions, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> WindowToolbarController { @@ -140,6 +212,7 @@ enum CardToolbar { body: body, rawSource: rawSource, attachments: attachments, + actions: actions, isSidebarShown: isSidebarShown, setSidebarShown: setSidebarShown ), diff --git a/Kanban/UI/Card/CardWindowActions.swift b/Kanban/UI/Card/CardWindowActions.swift new file mode 100644 index 0000000..8822b78 --- /dev/null +++ b/Kanban/UI/Card/CardWindowActions.swift @@ -0,0 +1,81 @@ +import SwiftUI + +// MARK: - The window's card-level actions, as a handle + +/// One card window's card-level actions, reduced to what things *outside* it need — today, exactly +/// one: the write that used to be the sidebar's Actions ▸ Delete (05-card-window.md ▸ Actions, retired +/// with the Actions section — Pipeline card bcd3b323), now the card window's own toolbar item +/// (`CardToolbar`) and its required menu twin (`DeleteCardCommand`). +/// +/// `CardAttachments`'s shape and for its own reason: published through the focus system so a menu +/// item and a toolbar item can reach the frontmost card window without a which-window-is-key +/// register, `@State` in the host so it dies with the window it belongs to. +/// +/// **Deliberately not folded into `CardAttachments`.** That type is "reduced to what things outside +/// it need" about the *attachments section* specifically — reused elsewhere only as the "a card +/// window is frontmost" presence signal (`ShowSidebarCommand`'s own note) — and a delete has nothing +/// to do with attachments. Folding it in would leave `CardAttachments`'s own doc comment wrong about +/// what the type is for. +@MainActor +@Observable +public final class CardWindowActions { + + /// Whether the delete write is offered at all — `!store.isReadOnly`, the read-only lock applied + /// to the one mutation this handle starts (02-architecture.md's every-entry-point predicate, + /// `CardActionsSection`'s own posture before it retired). + public var isDeletable = false + + /// Moves this window's card to the trash — filled by the host with `BoardStore.deleteCard(_:)`, + /// the same write ⌫ and the trash column's own drop already make (`CardActionsSection`'s former + /// doc comment, carried over verbatim: "the ⌫ delete exactly — same write op, same bracket, same + /// stamps"). It does not dismiss the window; the window's fate is re-derived from every snapshot, + /// exactly as it was when this lived in the sidebar. + public var deleteCard: (() -> Void)? + + public init() {} +} + +/// The focused card window's actions handle — `FocusedCardAttachmentsKey`'s own shape, one type over. +struct FocusedCardWindowActionsKey: FocusedValueKey { + typealias Value = CardWindowActions +} + +extension FocusedValues { + var cardWindowActions: CardWindowActions? { + get { self[FocusedCardWindowActionsKey.self] } + set { self[FocusedCardWindowActionsKey.self] = newValue } + } +} + +// MARK: - File ▸ Delete Card + +/// The card window's delete, as a menu row — the required twin `CardToolbar`'s own item needs +/// ("toolbars are pure enhancement: every function they host already has a menu item + shortcut", +/// 03-board-ui.md ▸ Toolbar) now that the sidebar's Actions ▸ Delete button has retired. +/// +/// **Deliberately titled "Delete Card", not "Delete"** — 11-command-nexus.md's File ▸ Delete row is +/// explicit that the chord's title is a singleton ("the chord's only owner — no twin menu items, no +/// shared-equivalent routing"), and that row is unchanged: it still validates against the board +/// window's own focused store and still carries ⌘⌫. This is a second, distinctly named row, scoped to +/// the card window alone, so the two can never be mistaken for one another in the File menu. +/// +/// **No default chord, on purpose** — the reason the sidebar button existed in the first place and +/// the reason `TrashCommands`' own ⌘⌫ was "deliberately not extended to the card window": an enabled +/// delete-key equivalent here would steal delete-to-line-start from this window's text surfaces (the +/// body editor, the title field). `View ▸ History`'s own "no default chord" is the precedent for a +/// menu row that means something without owning a key. +struct DeleteCardCommand: View { + + @FocusedValue(\.cardWindowActions) private var actions + + static func isEnabled(_ actions: CardWindowActions?) -> Bool { + actions?.isDeletable == true + } + + var body: some View { + Button("Delete Card") { + actions?.deleteCard?() + } + .disabled(!Self.isEnabled(actions)) + } +} diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index edb4c99..7e7f24c 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -69,12 +69,14 @@ struct CardWindowView: View { /// The board this card belongs to. /// /// The one place in this window a whole store is handed to a view rather than a narrow seam, and - /// the sidebar is why: the Style section hosts the **shared** style editor, whose API is - /// store-shaped by design (it reads the target set's current values and writes through the one - /// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own - /// move-to-trash. Routing either through a closure of this window's own would be a second - /// card-styling or card-deleting path to keep in step with the first — exactly what "one - /// component, one behavior" and "exactly the ⌫ delete" forbid. + /// the Style section is why: it hosts the **shared** style editor, whose API is store-shaped by + /// design (it reads the target set's current values and writes through the one `applyStyle` + /// bracket every anchor shares). Routing it through a closure of this window's own would be a + /// second card-styling path to keep in step with the first — exactly what "one component, one + /// behavior" forbids. Delete moved off the sidebar's own Actions section and onto the card + /// window's toolbar (`CardToolbar`, `CardWindowActions`) — still the store's own move-to-trash, + /// still "exactly the ⌫ delete", just reached through the host's toolbar wiring rather than this + /// view. let store: BoardStore /// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸ /// Controls) — app state, not board state, which is why it arrives beside the store rather than @@ -398,21 +400,23 @@ struct CardWindowView: View { // MARK: - Attributes sidebar - /// The sidebar's sections, **reordered 2026-08-09** (Pipeline card 8f26b029) so **Attachments - /// sits at the bottom of the stack**: Style, Details, Actions, Attachments. Section internals are - /// untouched — this is ordering only, and it is exactly the `VStack`'s child order, so keyboard - /// Tab order and VoiceOver's reading order follow it for free; ⇧⌘A (`AddAttachmentCommand`) - /// still opens the same file panel regardless of where the section sits in the stack, since it - /// reaches the window through the focus system rather than through this view's layout. + /// The sidebar's sections: **Style, Details, Attachments** — Attachments at the bottom of the + /// stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same + /// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own + /// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This + /// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order + /// follow it for free; ⇧⌘A (`AddAttachmentCommand`) still opens the same file panel regardless of + /// where Attachments sits in the stack, since it reaches the window through the focus system + /// rather than through this view's layout. /// - /// 05-card-window.md ▸ The attributes sidebar still documents the pathfinder's original order - /// (Attachments, Style, Details, Actions) — an owed amendment, tracked on that card's own thread - /// rather than made here. + /// 05-card-window.md ▸ The attributes sidebar still documents the pathfinder's four-section order + /// with Actions at the bottom — an owed amendment, tracked on the two cards' own threads rather + /// than made here. /// - /// One of the four is conditional, and the condition is the section's own rather than a rule - /// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys - /// ("shown only when any exist"). Everything else in the stack is unconditional, so the - /// composition a user learns on one card is the composition they get on the next. + /// One of the two remaining sections is conditional, and the condition is the section's own + /// rather than a rule restated here: **Details** renders nothing when the card carries no unknown + /// frontmatter keys ("shown only when any exist"). Style and Attachments are unconditional, so + /// the composition a user learns on one card is the composition they get on the next. /// /// The History section that once sat between Details and Actions left with app-managed git /// (strategy/01-git-excision.md, 2026-08-08); View ▸ History (`FutureCommands.swift`) is the only @@ -426,8 +430,6 @@ struct CardWindowView: View { // keys and their order included, and `Card` has carried it since (`BoardModel`). CardDetailsSection(rows: CardDetails.rows(of: card.document)) - CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder) - CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) } .frame(maxWidth: .infinity, alignment: .leading) diff --git a/KanbanTests/CardSessionUndoTests.swift b/KanbanTests/CardSessionUndoTests.swift index 758e306..58b6aaf 100644 --- a/KanbanTests/CardSessionUndoTests.swift +++ b/KanbanTests/CardSessionUndoTests.swift @@ -404,8 +404,9 @@ struct CardSessionCloseTests { let original = try body(fixture, cardPath) editBody(window, to: "Edited.\n") - // The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which - // dismisses this window (05-card-window.md ▸ Deletion & lifecycle). + // The card window's own delete (its toolbar item and File ▸ Delete Card since 2026-08-09, + // the sidebar's Actions ▸ Delete button before it) — a board gesture, on the board's stack, + // which dismisses this window (05-card-window.md ▸ Deletion & lifecycle). window.store.deleteCard(cardID) window.store.handleWatcherEvent(.treeChanged(.appMediated)) await window.store.awaitQuiescence() diff --git a/KanbanTests/ToolbarTests.swift b/KanbanTests/ToolbarTests.swift index 71040aa..b48d75f 100644 --- a/KanbanTests/ToolbarTests.swift +++ b/KanbanTests/ToolbarTests.swift @@ -580,14 +580,15 @@ struct BoardToolbarTests { // MARK: - The card window's toolbar -/// The card toolbar's trio, and the two state clauses 03 states about it: Edit Body's on-state and -/// its raw-source disable, and Add Attachment staying live in every mode. +/// The card toolbar's six items: the trio's two state clauses (Edit Body's on-state and its +/// raw-source disable, Add Attachment staying live in every mode), Show Sidebar, and — since the +/// sidebar's Actions section retired (Pipeline card bcd3b323) — Delete Card and Reveal in Finder. @MainActor @Suite("Toolbar ▸ the card window") struct CardToolbarTests { - /// The three window-scoped handles a card window's toolbar reads, wired as the host wires them. - private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments) { + /// The four window-scoped handles a card window's toolbar reads, wired as the host wires them. + private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments, CardWindowActions) { let body = CardBodyPresentation() let raw = CardRawSourceSession() raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") } @@ -595,40 +596,62 @@ struct CardToolbarTests { let attachments = CardAttachments() attachments.isEditable = true attachments.cardFolder = URL(filePath: "/tmp/board/lane/card") - return (body, raw, attachments) + let actions = CardWindowActions() + actions.isDeletable = true + return (body, raw, attachments, actions) } - @Test("The default set is the whole catalog — the trio plus Show Sidebar, in this file's order") - func defaultsAreTheCatalog() { - let (body, raw, attachments) = makeHandles() - let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) + @Test("The default set is the trio, Show Sidebar, and Delete Card — separated by a flexible space") + func defaultsAreSeparatedFromDeleteCard() { + // "Card window default: Edit Body · Raw Source · Add Attachment" joined by Show Sidebar (the + // toolbar-toggle card) and now Delete Card, HIG's "apart from the creation-side defaults" + // spelled as a literal `.flexibleSpace` between the two clusters — `BoardToolbar.defaultItems`' + // own leading-spacer pattern, turned trailing here. + #expect(CardToolbar.defaultItems == [ + .cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .flexibleSpace, .cardDeleteCard, + ]) + #expect(CardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [ + .cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .cardDeleteCard, + ]) + } - // "Card window default: Edit Body · Raw Source · Add Attachment … the catalog is the same - // trio" — joined by Show Sidebar, the fourth default item the toolbar-toggle card added. - #expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar]) - #expect(specs.map(\.identifier) == CardToolbar.defaultItems) - #expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment", "Show Sidebar"]) + @Test("Reveal in Finder is catalog-only — it already has a menu row with no default chord") + func revealInFinderIsCatalogOnly() { + #expect(!CardToolbar.defaultItems.contains(.cardRevealInFinder)) + } + + @Test("The catalog is the six items, in this file's order, labeled off their menu titles") + func catalogIsTheSixItems() { + let (body, raw, attachments, actions) = makeHandles() + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) + + #expect(specs.map(\.identifier) == [ + .cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .cardDeleteCard, .cardRevealInFinder, + ]) + #expect(specs.map(\.label) == [ + "Edit Body", "Raw Source", "Add Attachment", "Show Sidebar", "Delete Card", "Reveal in Finder", + ]) } @Test("Every item's symbol resolves on this system") func symbolsResolve() { - let (body, raw, attachments) = makeHandles() - for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) { + let (body, raw, attachments, actions) = makeHandles() + for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) { guard let symbol = spec.symbol else { continue } #expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have") } } - @Test("Every item of the trio actually builds, and the toolbar is customizable") + @Test("Every catalog item actually builds, and the toolbar is customizable") func everyItemBuilds() throws { - let (body, raw, attachments) = makeHandles() - let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments) + let (body, raw, attachments, actions) = makeHandles() + let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments, actions: actions) #expect(controller.toolbar.allowsUserCustomization) #expect(controller.toolbar.allowsDisplayModeCustomization) #expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems) - for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) { + for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) { let item = try #require(controller.toolbar( controller.toolbar, itemForItemIdentifier: spec.identifier, @@ -641,8 +664,8 @@ struct CardToolbarTests { @Test("Edit Body is a single toggle showing on-state in Edit") func editBodyShowsItsMode() { - let (body, raw, attachments) = makeHandles() - let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) + let (body, raw, attachments, actions) = makeHandles() + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) guard let editBody = specs.spec(.cardEditBody) else { Issue.record("no Edit Body item") return @@ -661,8 +684,8 @@ struct CardToolbarTests { @Test("Raw Source active disables Edit Body — the row's own predicate, mirrored") func rawSourceDisablesEditBody() { - let (body, raw, attachments) = makeHandles() - let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) + let (body, raw, attachments, actions) = makeHandles() + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else { Issue.record("the card toolbar is missing an item") return @@ -687,8 +710,8 @@ struct CardToolbarTests { @Test("Add Attachment stays enabled in every mode, including an open raw edit") func addAttachmentIsAlwaysAvailable() { - let (body, raw, attachments) = makeHandles() - let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) + let (body, raw, attachments, actions) = makeHandles() + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) guard let addAttachment = specs.spec(.cardAddAttachment) else { Issue.record("no Add Attachment item") return @@ -719,12 +742,13 @@ struct CardToolbarTests { /// item here with no window-scoped handle to hold a scratch value instead). @Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read") func showSidebarTogglesItsOwnBit() { - let (body, raw, attachments) = makeHandles() + let (body, raw, attachments, actions) = makeHandles() var shown = true let specs = CardToolbar.specs( body: body, rawSource: raw, attachments: attachments, + actions: actions, isSidebarShown: { shown }, setSidebarShown: { shown = $0 } ) @@ -750,6 +774,57 @@ struct CardToolbarTests { attachments.isEditable = false #expect(showSidebar.isEnabled) } + + /// **Delete Card is the sidebar Actions button's former write, on a push button** — gated by the + /// same read-only predicate (`CardWindowActions.isDeletable`, wired from `!store.isReadOnly` + /// exactly as the sidebar button's `.disabled(store.isReadOnly)` was), and firing calls the same + /// closure the host wires from `BoardStore.deleteCard(_:)` (`CardWindowHost.configureActions`). + @Test("Delete Card is a push button, gated by the read-only lock, that fires the wired delete") + func deleteCardFiresTheWiredDelete() { + let (body, raw, attachments, actions) = makeHandles() + var deleted = 0 + actions.deleteCard = { deleted += 1 } + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) + guard let deleteCard = specs.spec(.cardDeleteCard) else { + Issue.record("no Delete Card item") + return + } + + #expect(deleteCard.isOn == nil, "Delete Card is a push button, not a toggle") + #expect(deleteCard.isEnabled) + + deleteCard.activate() + #expect(deleted == 1, "the item fires the same write the host wires from BoardStore.deleteCard") + + // The read-only lock is the sidebar button's own predicate, carried over whole. + actions.isDeletable = false + #expect(!deleteCard.isEnabled) + #expect(deleteCard.isEnabled == DeleteCardCommand.isEnabled(actions)) + } + + /// **Reveal in Finder computes exactly what `RevealInFinderCommand`'s card-window branch does** — + /// same function (`CardAttachments.revealURLs`), so the two surfaces can never disagree. Firing it + /// is not exercised here, `addAttachmentIsAlwaysAvailable`'s own restraint: both open a real + /// system surface (a panel there, Finder here), which a unit test does not drive. + @Test("Reveal in Finder is enabled exactly when the row's own computation finds something to reveal") + func revealInFinderMirrorsTheRowsComputation() { + let (body, raw, attachments, actions) = makeHandles() + let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) + guard let reveal = specs.spec(.cardRevealInFinder) else { + Issue.record("no Reveal in Finder item") + return + } + + // `makeHandles()` gives the section a folder and no focus — the card-folder branch. + #expect(reveal.isEnabled) + #expect(!CardAttachments.revealURLs( + cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused + ).isEmpty) + + // A window on its way out — no folder, nothing to reveal — is the row's own disabled clause. + attachments.cardFolder = nil + #expect(!reveal.isEnabled) + } } // MARK: - ⌘F and the search field's two homes