The sidebar's Actions section retires — Delete and Reveal in Finder move to the card window's toolbar
Actions is gone from the trailing sidebar. Its two rows land on the card
window's toolbar instead: Delete Card is a new default item (trash SF
Symbol), and Reveal in Finder joins the customizable catalog. The sidebar's
final order is now Style, Details, Attachments — no fourth section.
Delete Card is a push button gated by the same read-only predicate the
sidebar button carried, and it fires the identical write
(BoardStore.deleteCard(_:)) — same bracket, same stamps, no confirmation,
matching the delete flow exactly: recovery is the board's trash lane, so
there is nothing here for an alert to guard. It sits behind a trailing
flexibleSpace in the default set, apart from the four creation/view items
ahead of it, the HIG separation Mail.app's own toolbar Delete models —
one-click, no-confirm, recoverable by trash.
Reveal in Finder is catalog-only: it already had a menu-bar twin with no
default chord (File ▸ Reveal in Finder / RevealInFinderCommand), so nothing
was unreachable before this — the toolbar item is Customize's shortcut to
the same computation (CardAttachments.revealURLs), not a new path.
Delete Card needed a menu-row twin of its own before it could sit on the
toolbar at all ("toolbars are pure enhancement: every function they host
already has a menu item + shortcut" — 03-board-ui.md ▸ Toolbar). File ▸
Delete Card is that row: distinctly titled from the board-scope File ▸
Delete (whose title 11-command-nexus.md calls out as the ⌘⌫ chord's
singleton), and deliberately chord-less — an enabled delete-key equivalent
in the card window would steal delete-to-line-start from its text surfaces,
the same reason the board's own ⌘⌫ was never extended here in the first
place. A new small handle, CardWindowActions, carries the wiring through the
focus system the way CardAttachments and CardPrintSubject already do for
their own single-purpose seams — kept separate from CardAttachments on
purpose, since a delete has nothing to do with the attachments section that
type is scoped to.
CardToolbarTests grows the BoardToolbarTests split (defaults vs. catalog,
now that they differ) plus two new suites: Delete Card firing the wired
write under the lock, and Reveal in Finder's enablement mirroring the menu
row's own card-window computation.
05-card-window.md's Actions section and 11-command-nexus.md's File-menu
inventory are now stale; both amendments are owed and tracked on the card's
own thread rather than made here.
Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -289,6 +289,11 @@ struct CardWindowHost: View {
|
|||||||
/// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card
|
/// 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.
|
/// windows are two documents, and the menu bar reaches the frontmost one through the focus system.
|
||||||
@State private var cardPrint = CardPrintSubject()
|
@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
|
/// 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
|
/// snapshot the store applies — a cache that died with the view would regenerate every thumbnail
|
||||||
/// on every reload (`AttachmentThumbnailCache`).
|
/// 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
|
// 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).
|
// of a row the board window answers with its whole board (11-command-nexus.md).
|
||||||
.focusedSceneValue(\.cardPrint, cardPrint)
|
.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
|
// 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
|
// 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
|
// 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
|
.onChange(of: store.isReadOnly, initial: true) { _, locked in
|
||||||
attachments.isEditable = !locked
|
attachments.isEditable = !locked
|
||||||
session.comments.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
|
// **The thread re-reads on every landed reload** (05 ▸ The comments column: "the pane
|
||||||
// reloads its thread from the same FSEvents stream").
|
// 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
|
attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard
|
||||||
)
|
)
|
||||||
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
|
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
|
/// 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
|
// 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'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
|
// Window), joined by Show Sidebar and, since the sidebar's Actions section retired, Delete
|
||||||
// rather than a store, which is why it is installed here and not at attach: those are this
|
// Card and Reveal in Finder (below). It carries the four window-scoped handles above rather
|
||||||
// window's, and so is it. Show Sidebar needs none of them — its read and write default to
|
// 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
|
// `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card
|
||||||
// window answers to, so this call leaves the two injectable parameters unnamed.
|
// window answers to, so this call leaves the two injectable parameters unnamed.
|
||||||
windowController.installToolbar(
|
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
|
// **No title in the title bar** — the card's name is shown as part of the card's body
|
||||||
|
|||||||
@@ -236,6 +236,14 @@ struct KanbanApp: App {
|
|||||||
SetAsHeroCommand()
|
SetAsHeroCommand()
|
||||||
RemoveHeroCommand()
|
RemoveHeroCommand()
|
||||||
AddCommentCommand()
|
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()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -4513,8 +4513,10 @@ public final class BoardStore: HealHost {
|
|||||||
_ = moveLanesToTrash(lanes)
|
_ = moveLanesToTrash(lanes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — moves the card
|
/// **The card window's own delete** — the sidebar's Actions ▸ Delete button until 2026-08-09
|
||||||
/// to the trash … the window then dismisses itself").
|
/// (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
|
/// 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
|
/// from one deleted with ⌫ on the board or dropped on the trash column. What differs is the same
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ extension NSToolbarItem.Identifier {
|
|||||||
static let cardRawSource = Self("card.rawSource")
|
static let cardRawSource = Self("card.rawSource")
|
||||||
static let cardAddAttachment = Self("card.addAttachment")
|
static let cardAddAttachment = Self("card.addAttachment")
|
||||||
static let cardShowSidebar = Self("card.showSidebar")
|
static let cardShowSidebar = Self("card.showSidebar")
|
||||||
|
static let cardDeleteCard = Self("card.deleteCard")
|
||||||
|
static let cardRevealInFinder = Self("card.revealInFinder")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The card window's toolbar
|
// 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).
|
/// 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
|
/// "**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
|
/// functions, all discoverable from its toolbar." **Show Sidebar joined them** (05-card-window.md ▸
|
||||||
/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of
|
/// Composition, the toggle added beside the toolbar-customization work): a fourth default item, the
|
||||||
/// items — which is exactly what a window with three functions should offer. **Show Sidebar joined
|
/// trailing sidebar's own show/hide control, the `Show Trash` precedent applied to the card window's
|
||||||
/// them** (05-card-window.md ▸ Composition, the toggle added beside the toolbar-customization work):
|
/// one collapsible pane. **Delete Card joined them too** (05 ▸ Actions, retired — Pipeline card
|
||||||
/// a fourth default item, the trailing sidebar's own show/hide control, the `Show Trash` precedent
|
/// bcd3b323): the sidebar's Actions section is gone, and its destructive Delete moved here as the
|
||||||
/// applied to the card window's one collapsible pane.
|
/// 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
|
/// - **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
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// Labels are the menu titles minus a trailing ellipsis, so File ▸ Add Attachment… labels as **Add
|
||||||
/// Attachment** (03's own example).
|
/// Attachment** (03's own example).
|
||||||
@@ -49,16 +66,24 @@ enum CardToolbar {
|
|||||||
|
|
||||||
static let identifier = "dev.rzen.indie.Kanban.card"
|
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
|
/// The trio plus Show Sidebar plus Delete Card — the whole catalog except Reveal in Finder, which
|
||||||
/// this file's order.
|
/// 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] = [
|
static let defaultItems: [NSToolbarItem.Identifier] = [
|
||||||
.cardEditBody,
|
.cardEditBody,
|
||||||
.cardRawSource,
|
.cardRawSource,
|
||||||
.cardAddAttachment,
|
.cardAddAttachment,
|
||||||
.cardShowSidebar,
|
.cardShowSidebar,
|
||||||
|
.flexibleSpace,
|
||||||
|
.cardDeleteCard,
|
||||||
]
|
]
|
||||||
|
|
||||||
/// - Parameters:
|
/// - 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
|
/// - isSidebarShown: Show Sidebar's read, defaulted to the real bit
|
||||||
/// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without
|
/// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without
|
||||||
/// touching the developer's own `UserDefaults.standard` domain — `StyleRecents`' own caution,
|
/// touching the developer's own `UserDefaults.standard` domain — `StyleRecents`' own caution,
|
||||||
@@ -69,6 +94,7 @@ enum CardToolbar {
|
|||||||
body: CardBodyPresentation,
|
body: CardBodyPresentation,
|
||||||
rawSource: CardRawSourceSession,
|
rawSource: CardRawSourceSession,
|
||||||
attachments: CardAttachments,
|
attachments: CardAttachments,
|
||||||
|
actions: CardWindowActions,
|
||||||
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
||||||
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
||||||
) -> [ToolbarItemSpec] {
|
) -> [ToolbarItemSpec] {
|
||||||
@@ -124,13 +150,59 @@ enum CardToolbar {
|
|||||||
symbol: "sidebar.right",
|
symbol: "sidebar.right",
|
||||||
behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown)
|
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(
|
static func controller(
|
||||||
body: CardBodyPresentation,
|
body: CardBodyPresentation,
|
||||||
rawSource: CardRawSourceSession,
|
rawSource: CardRawSourceSession,
|
||||||
attachments: CardAttachments,
|
attachments: CardAttachments,
|
||||||
|
actions: CardWindowActions,
|
||||||
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
|
||||||
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
|
||||||
) -> WindowToolbarController {
|
) -> WindowToolbarController {
|
||||||
@@ -140,6 +212,7 @@ enum CardToolbar {
|
|||||||
body: body,
|
body: body,
|
||||||
rawSource: rawSource,
|
rawSource: rawSource,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
|
actions: actions,
|
||||||
isSidebarShown: isSidebarShown,
|
isSidebarShown: isSidebarShown,
|
||||||
setSidebarShown: setSidebarShown
|
setSidebarShown: setSidebarShown
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,12 +69,14 @@ struct CardWindowView: View {
|
|||||||
/// The board this card belongs to.
|
/// 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 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
|
/// the Style section is why: it hosts the **shared** style editor, whose API is store-shaped by
|
||||||
/// store-shaped by design (it reads the target set's current values and writes through the one
|
/// design (it reads the target set's current values and writes through the one `applyStyle`
|
||||||
/// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own
|
/// bracket every anchor shares). Routing it through a closure of this window's own would be a
|
||||||
/// move-to-trash. Routing either through a closure of this window's own would be a second
|
/// second card-styling path to keep in step with the first — exactly what "one component, one
|
||||||
/// card-styling or card-deleting path to keep in step with the first — exactly what "one
|
/// behavior" forbids. Delete moved off the sidebar's own Actions section and onto the card
|
||||||
/// component, one behavior" and "exactly the ⌫ delete" forbid.
|
/// 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
|
let store: BoardStore
|
||||||
/// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸
|
/// 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
|
/// 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
|
// MARK: - Attributes sidebar
|
||||||
|
|
||||||
/// The sidebar's sections, **reordered 2026-08-09** (Pipeline card 8f26b029) so **Attachments
|
/// The sidebar's sections: **Style, Details, Attachments** — Attachments at the bottom of the
|
||||||
/// sits at the bottom of the stack**: Style, Details, Actions, Attachments. Section internals are
|
/// stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
|
||||||
/// untouched — this is ordering only, and it is exactly the `VStack`'s child order, so keyboard
|
/// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own
|
||||||
/// Tab order and VoiceOver's reading order follow it for free; ⇧⌘A (`AddAttachmentCommand`)
|
/// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This
|
||||||
/// still opens the same file panel regardless of where the section sits in the stack, since it
|
/// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order
|
||||||
/// reaches the window through the focus system rather than through this view's layout.
|
/// 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
|
/// 05-card-window.md ▸ The attributes sidebar still documents the pathfinder's four-section order
|
||||||
/// (Attachments, Style, Details, Actions) — an owed amendment, tracked on that card's own thread
|
/// with Actions at the bottom — an owed amendment, tracked on the two cards' own threads rather
|
||||||
/// rather than made here.
|
/// than made here.
|
||||||
///
|
///
|
||||||
/// One of the four is conditional, and the condition is the section's own rather than a rule
|
/// One of the two remaining sections is conditional, and the condition is the section's own
|
||||||
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys
|
/// rather than a rule restated here: **Details** renders nothing when the card carries no unknown
|
||||||
/// ("shown only when any exist"). Everything else in the stack is unconditional, so the
|
/// frontmatter keys ("shown only when any exist"). Style and Attachments are unconditional, so
|
||||||
/// composition a user learns on one card is the composition they get on the next.
|
/// 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
|
/// 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
|
/// (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`).
|
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
||||||
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
||||||
|
|
||||||
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
|
|
||||||
|
|
||||||
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|||||||
@@ -404,8 +404,9 @@ struct CardSessionCloseTests {
|
|||||||
let original = try body(fixture, cardPath)
|
let original = try body(fixture, cardPath)
|
||||||
editBody(window, to: "Edited.\n")
|
editBody(window, to: "Edited.\n")
|
||||||
|
|
||||||
// The card window's own Actions ▸ Delete — a board gesture, on the board's stack, which
|
// The card window's own delete (its toolbar item and File ▸ Delete Card since 2026-08-09,
|
||||||
// dismisses this window (05-card-window.md ▸ Deletion & lifecycle).
|
// 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.deleteCard(cardID)
|
||||||
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
window.store.handleWatcherEvent(.treeChanged(.appMediated))
|
||||||
await window.store.awaitQuiescence()
|
await window.store.awaitQuiescence()
|
||||||
|
|||||||
+102
-27
@@ -580,14 +580,15 @@ struct BoardToolbarTests {
|
|||||||
|
|
||||||
// MARK: - The card window's toolbar
|
// 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
|
/// The card toolbar's six items: the trio's two state clauses (Edit Body's on-state and its
|
||||||
/// its raw-source disable, and Add Attachment staying live in every mode.
|
/// 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
|
@MainActor
|
||||||
@Suite("Toolbar ▸ the card window")
|
@Suite("Toolbar ▸ the card window")
|
||||||
struct CardToolbarTests {
|
struct CardToolbarTests {
|
||||||
|
|
||||||
/// The three window-scoped handles a card window's toolbar reads, wired as the host wires them.
|
/// The four window-scoped handles a card window's toolbar reads, wired as the host wires them.
|
||||||
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments) {
|
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments, CardWindowActions) {
|
||||||
let body = CardBodyPresentation()
|
let body = CardBodyPresentation()
|
||||||
let raw = CardRawSourceSession()
|
let raw = CardRawSourceSession()
|
||||||
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
|
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
|
||||||
@@ -595,40 +596,62 @@ struct CardToolbarTests {
|
|||||||
let attachments = CardAttachments()
|
let attachments = CardAttachments()
|
||||||
attachments.isEditable = true
|
attachments.isEditable = true
|
||||||
attachments.cardFolder = URL(filePath: "/tmp/board/lane/card")
|
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")
|
@Test("The default set is the trio, Show Sidebar, and Delete Card — separated by a flexible space")
|
||||||
func defaultsAreTheCatalog() {
|
func defaultsAreSeparatedFromDeleteCard() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
// "Card window default: Edit Body · Raw Source · Add Attachment" joined by Show Sidebar (the
|
||||||
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
// 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
|
@Test("Reveal in Finder is catalog-only — it already has a menu row with no default chord")
|
||||||
// trio" — joined by Show Sidebar, the fourth default item the toolbar-toggle card added.
|
func revealInFinderIsCatalogOnly() {
|
||||||
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar])
|
#expect(!CardToolbar.defaultItems.contains(.cardRevealInFinder))
|
||||||
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
|
}
|
||||||
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment", "Show Sidebar"])
|
|
||||||
|
@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")
|
@Test("Every item's symbol resolves on this system")
|
||||||
func symbolsResolve() {
|
func symbolsResolve() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) {
|
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) {
|
||||||
guard let symbol = spec.symbol else { continue }
|
guard let symbol = spec.symbol else { continue }
|
||||||
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
|
#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 {
|
func everyItemBuilds() throws {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments)
|
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments, actions: actions)
|
||||||
|
|
||||||
#expect(controller.toolbar.allowsUserCustomization)
|
#expect(controller.toolbar.allowsUserCustomization)
|
||||||
#expect(controller.toolbar.allowsDisplayModeCustomization)
|
#expect(controller.toolbar.allowsDisplayModeCustomization)
|
||||||
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems)
|
#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(
|
let item = try #require(controller.toolbar(
|
||||||
controller.toolbar,
|
controller.toolbar,
|
||||||
itemForItemIdentifier: spec.identifier,
|
itemForItemIdentifier: spec.identifier,
|
||||||
@@ -641,8 +664,8 @@ struct CardToolbarTests {
|
|||||||
|
|
||||||
@Test("Edit Body is a single toggle showing on-state in Edit")
|
@Test("Edit Body is a single toggle showing on-state in Edit")
|
||||||
func editBodyShowsItsMode() {
|
func editBodyShowsItsMode() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
|
||||||
guard let editBody = specs.spec(.cardEditBody) else {
|
guard let editBody = specs.spec(.cardEditBody) else {
|
||||||
Issue.record("no Edit Body item")
|
Issue.record("no Edit Body item")
|
||||||
return
|
return
|
||||||
@@ -661,8 +684,8 @@ struct CardToolbarTests {
|
|||||||
|
|
||||||
@Test("Raw Source active disables Edit Body — the row's own predicate, mirrored")
|
@Test("Raw Source active disables Edit Body — the row's own predicate, mirrored")
|
||||||
func rawSourceDisablesEditBody() {
|
func rawSourceDisablesEditBody() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
|
||||||
guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else {
|
guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else {
|
||||||
Issue.record("the card toolbar is missing an item")
|
Issue.record("the card toolbar is missing an item")
|
||||||
return
|
return
|
||||||
@@ -687,8 +710,8 @@ struct CardToolbarTests {
|
|||||||
|
|
||||||
@Test("Add Attachment stays enabled in every mode, including an open raw edit")
|
@Test("Add Attachment stays enabled in every mode, including an open raw edit")
|
||||||
func addAttachmentIsAlwaysAvailable() {
|
func addAttachmentIsAlwaysAvailable() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
|
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
|
||||||
guard let addAttachment = specs.spec(.cardAddAttachment) else {
|
guard let addAttachment = specs.spec(.cardAddAttachment) else {
|
||||||
Issue.record("no Add Attachment item")
|
Issue.record("no Add Attachment item")
|
||||||
return
|
return
|
||||||
@@ -719,12 +742,13 @@ struct CardToolbarTests {
|
|||||||
/// item here with no window-scoped handle to hold a scratch value instead).
|
/// 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")
|
@Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read")
|
||||||
func showSidebarTogglesItsOwnBit() {
|
func showSidebarTogglesItsOwnBit() {
|
||||||
let (body, raw, attachments) = makeHandles()
|
let (body, raw, attachments, actions) = makeHandles()
|
||||||
var shown = true
|
var shown = true
|
||||||
let specs = CardToolbar.specs(
|
let specs = CardToolbar.specs(
|
||||||
body: body,
|
body: body,
|
||||||
rawSource: raw,
|
rawSource: raw,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
|
actions: actions,
|
||||||
isSidebarShown: { shown },
|
isSidebarShown: { shown },
|
||||||
setSidebarShown: { shown = $0 }
|
setSidebarShown: { shown = $0 }
|
||||||
)
|
)
|
||||||
@@ -750,6 +774,57 @@ struct CardToolbarTests {
|
|||||||
attachments.isEditable = false
|
attachments.isEditable = false
|
||||||
#expect(showSidebar.isEnabled)
|
#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
|
// MARK: - ⌘F and the search field's two homes
|
||||||
|
|||||||
Reference in New Issue
Block a user