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
501 lines
29 KiB
Swift
501 lines
29 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// MARK: - CardWindowView
|
|
|
|
/// The card window's content: **three componentized panes** — a wide body pane leading, the comments
|
|
/// pane in the middle when it is shown, and the narrow attributes sidebar trailing
|
|
/// (05-card-window.md ▸ Composition, re-composed 2026-07-29).
|
|
///
|
|
/// ### The composition arranges; the panes do not know about each other
|
|
///
|
|
/// > each an independent component with its own scroll, arranged by the window's layout rather than
|
|
/// > wired to each other; componentization is the rule, so the comments pane mounts beside the body
|
|
/// > or below it (the layout option) without either pane knowing which.
|
|
///
|
|
/// That is enforced here by there being nothing to enforce: `CardCommentsPane` takes no layout
|
|
/// parameter and the body column takes none either. This view puts one of them in a frame; the
|
|
/// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable.
|
|
///
|
|
/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width when
|
|
/// shown, and the resize flex still goes to the body and never to the sidebar. **The comments column
|
|
/// is no longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the
|
|
/// beside mount, so the flex the body and the comments pane split is now the user's to move, within
|
|
/// the two floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing.
|
|
///
|
|
/// **Whether it shows at all is its own bit** (View ▸ Show Sidebar, extended for the toolbar-toggle
|
|
/// card): app-wide, persisted `showSidebar` below, mirrored by the card window's toolbar
|
|
/// (`CardToolbar`). Hidden, the pane and its divider are simply absent from the `HStack` and the body
|
|
/// column takes the width back through its own `.infinity` frame — no third state, no collapsed
|
|
/// sliver, the comments pane's own "costs the window no width while it is not mounted" rule applied
|
|
/// to the sidebar's visibility instead of the comments pane's mount.
|
|
///
|
|
/// ### What this milestone builds, and what it deliberately does not
|
|
///
|
|
/// The *shell*: the two columns, their scrolling, the sidebar's fixed width, and the read-only
|
|
/// renderings of what the loader already knows — the card's title and its created/modified line —
|
|
/// over the body column's two live surfaces (Preview and Edit, `CardBodySurface`), with the
|
|
/// raw-source outlet swapping the pair of columns out entirely when it is active. Everything that
|
|
/// reads or writes beyond that is later work and is marked where it lands:
|
|
///
|
|
/// - the title as an editable field (commit on Return / focus loss, Escape abandons).
|
|
///
|
|
/// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are
|
|
/// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in
|
|
/// underneath without the composition moving.
|
|
///
|
|
/// ### The width rule, in one line
|
|
///
|
|
/// The sidebar has a fixed width from `CardWindowMetrics`; the body column takes `.infinity`. That
|
|
/// is the whole of "the window's resize flex goes to the body, never the sidebar" — no split view
|
|
/// between body and sidebar, no stored divider position there, nothing for a drag to disagree with.
|
|
///
|
|
/// **The comments column keeps its own, separate width rule** (added 2026-08-09,
|
|
/// `CommentsColumnDivider`): a real divider, a drag, and a stored width — the exception this
|
|
/// sentence used to have none of, and still does not have between the body and the *sidebar*.
|
|
///
|
|
/// ### Why the title no longer scrolls with the body
|
|
///
|
|
/// The body surface is a hosted `NSScrollView` (`CardBodySurface`), because ⌘F's find bar lives in
|
|
/// one — "Edit ▸ Find (⌘F) is find-in-text here … the standard find bar" (05 ▸ Preview). A scroll
|
|
/// view inside a scroll view is a scroll view that fights, so the column's header — the title and
|
|
/// its created/modified line — sits above the body's scroller rather than inside it. 05 fixes the
|
|
/// column's *order* ("Body column, top to bottom") and the columns' independent scrolling, and both
|
|
/// still hold; which of the two things scrolls the title away was never settled, and pinning the
|
|
/// card's name over its own body is the better reading of a window whose subtitle already follows it.
|
|
struct CardWindowView: View {
|
|
|
|
let card: Card
|
|
/// 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 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
|
|
/// on it.
|
|
let recents: StyleRecents
|
|
/// The card's folder on disk — what relative images and links in the body resolve against
|
|
/// (05 ▸ Preview). `nil` only where a caller has no board root to build it from.
|
|
let cardFolder: URL?
|
|
/// This window's body-column state: which mode it is in, and the find-bar hook.
|
|
let bodyPresentation: CardBodyPresentation
|
|
/// This window's Edit buffer. It holds the text **both** surfaces show: the editor writes into
|
|
/// it, Preview renders it, and `adopt(diskBody:)` below is where the snapshot gets a say —
|
|
/// which is exactly the point at which dirty-buffer-wins is decided.
|
|
let bodySession: CardBodyEditSession
|
|
/// This window's raw-source outlet. While it is active the two columns are gone entirely — see
|
|
/// `body`.
|
|
let rawSource: CardRawSourceSession
|
|
/// Whether a checkbox may write — `false` under the board's read-only lock.
|
|
let isEditable: Bool
|
|
/// This window's attachments section: the listing, the selection, and the two writes it starts
|
|
/// (05 ▸ Attachments).
|
|
let attachments: CardAttachments
|
|
/// This window's comments pane: the thread, the composer's draft buffer, and the one open inline
|
|
/// edit session (05 ▸ The comments column). It lives on the window's *session* so the close flush
|
|
/// can reach it, which is why it arrives here rather than being made here.
|
|
let comments: CardComments
|
|
/// This window's thumbnail memory, held by the host so it outlives a snapshot.
|
|
let thumbnails: AttachmentThumbnailCache
|
|
/// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels). It arrives for exactly
|
|
/// one consumer — the Style section, the one sidebar anchor that *writes* — because a gesture
|
|
/// issued in this window registers on this window's stack. It lives on the window's session so
|
|
/// the close can fold it, which is why it arrives here rather than being made here.
|
|
let undo: CardWindowUndo
|
|
/// The whole-window file drop (05 ▸ Attachments: "the drop surface remains the **whole
|
|
/// window**"). `nil` only where a caller has no store to import through.
|
|
let fileDrop: CardWindowDropDelegate?
|
|
/// Commits a checkbox flip: the marker's byte offset in the body, and the state the user saw.
|
|
let onToggleTask: (Int, Bool) -> Void
|
|
|
|
/// **View ▸ Show Comments** and **View ▸ Comments Beside Body** — app-wide, persisted, read here
|
|
/// rather than passed in (05 ▸ The comments column; ▸ Composition).
|
|
///
|
|
/// `@AppStorage` because the two bits genuinely are app-wide: every open card window obeys the
|
|
/// same pair, so a window that took them as parameters would need something above it keeping
|
|
/// every window in step with a value that has exactly one instance. It is also what makes the
|
|
/// menu rows' checkmarks and these panes provably the same bit (`ShowCommentsCommand`).
|
|
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
|
|
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
|
|
|
|
/// **View ▸ Show Sidebar** — the trailing attributes sidebar's own visibility bit, `showComments`'s
|
|
/// neighbour and shape exactly (05-card-window.md ▸ Composition, extended for the toolbar toggle):
|
|
/// app-wide, persisted, read here rather than passed in so this pane and the toolbar item that
|
|
/// mirrors it (`CardToolbar`) and the View-menu row that mirrors it too (`ShowSidebarCommand`) are
|
|
/// provably the same bit.
|
|
@AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true
|
|
|
|
/// Reduce Motion, read from the environment because this is a view — `Motion`'s own split between
|
|
/// callers that have one and callers (a menu command, a toolbar item) that reach `Motion
|
|
/// .prefersReducedMotion` instead.
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
/// **The comments column's user-set width**, in points — the divider's memory (05-card-window.md
|
|
/// ▸ The comments column, extended 2026-08-09). `@AppStorage` for the same reason its two
|
|
/// neighbours above are: every open card window's divider answers to one figure. `0` is the
|
|
/// "never dragged" reading (`AppPreferences.commentsColumnWidthKey`'s doc) — a real drag can
|
|
/// never land there, since `CardWindowMetrics.clampedCommentsColumnWidth`'s floor is always
|
|
/// positive.
|
|
@AppStorage(AppPreferences.commentsColumnWidthKey) private var storedCommentsColumnWidth: Double = 0
|
|
|
|
/// **The divider's in-flight width**, live only while a drag is running — `nil` the rest of the
|
|
/// time. `LaneResizeSession.liveWidth`'s same split applied to a much smaller session: the drag
|
|
/// tracks the cursor in memory on every tick (`CommentsColumnDivider.onChange`), and only the
|
|
/// *release* writes `storedCommentsColumnWidth` (`.onCommit`), so a flick that fires dozens of
|
|
/// events writes `UserDefaults` — and every sibling card window's divider — exactly once.
|
|
@State private var commentsColumnWidthOverride: CGFloat?
|
|
|
|
/// The body font's point size, read once per body evaluation: every measurement in this view —
|
|
/// the sidebar's width, both gutters, the vertical rhythm — is derived from it, so they scale
|
|
/// together when the system text size changes.
|
|
private var bodyPointSize: CGFloat { CardWindowMetrics.bodyPointSize }
|
|
|
|
/// Where the comments pane sits, when it is shown at all.
|
|
private var mount: CommentsMount { CommentsMount(besideBody: commentsBesideBody) }
|
|
|
|
/// The comments column's width before this render's clamp: the live drag's own width while one is
|
|
/// running, the persisted figure once the divider has ever been dragged and released, and
|
|
/// `CardWindowMetrics.commentsColumnWidth`'s font-derived default before either has ever happened.
|
|
/// Unclamped on purpose: clamping needs the beside `HStack`'s live container width, which only the
|
|
/// `.beside` render branch has (`clampedCommentsColumnWidth(in:)`), so this stays the one place
|
|
/// that decides *which number* to clamp rather than also deciding *how far*.
|
|
private var commentsColumnWidth: CGFloat {
|
|
if let commentsColumnWidthOverride { return commentsColumnWidthOverride }
|
|
return storedCommentsColumnWidth > 0
|
|
? CGFloat(storedCommentsColumnWidth)
|
|
: CardWindowMetrics.commentsColumnWidth(bodyPointSize: bodyPointSize)
|
|
}
|
|
|
|
/// `commentsColumnWidth` clamped against `containerWidth` — the beside `HStack`'s own width this
|
|
/// render (`CardWindowMetrics.clampedCommentsColumnWidth`). Re-derived every render rather than
|
|
/// written back into `storedCommentsColumnWidth`: a window too narrow for the persisted figure
|
|
/// (a smaller display, say) shows the clamped width without silently shrinking the preference a
|
|
/// wider window will want back.
|
|
private func clampedCommentsColumnWidth(in containerWidth: CGFloat) -> CGFloat {
|
|
CardWindowMetrics.clampedCommentsColumnWidth(
|
|
commentsColumnWidth,
|
|
bodyPointSize: bodyPointSize,
|
|
containerWidth: containerWidth
|
|
)
|
|
}
|
|
|
|
/// The two columns — **or the raw-source editor in place of both of them**.
|
|
///
|
|
/// A swap rather than an overlay, which is 05 ▸ Raw source outlet's own word for it ("swaps the
|
|
/// **entire content area — title, body, and sidebar —** for the literal on-disk `index.md`") and
|
|
/// what the rule underneath it requires: the same frontmatter is being edited as raw text, so a
|
|
/// sidebar still offering to restyle the card, or a title field still writing to `title`, would
|
|
/// be two editors racing for one file. Unmounting them is the only version of "they can't fight"
|
|
/// that cannot be got wrong later.
|
|
///
|
|
/// The cost is one thing and it is accepted: the body editor's scroll position and selection do
|
|
/// not survive a round trip through source mode, because its text view genuinely goes away. What
|
|
/// does survive is the buffer, which is the Edit session's, not the view's — and it was flushed
|
|
/// to disk on the way in regardless.
|
|
var body: some View {
|
|
content
|
|
// **The drop surface is the whole window** (05 ▸ Attachments), which is why it hangs
|
|
// here — outside the raw-source swap, so a file dropped while the source outlet is open
|
|
// still imports — rather than on the attachments section it fills. The body editor lets
|
|
// file drags through to this by declining the file types
|
|
// (`CardBodyTextView.acceptableDragTypes`); dragged *text* never matches `.fileURL` and
|
|
// so is never offered here at all, which is the other half of the payload split.
|
|
.modifier(WindowFileDrop(delegate: fileDrop))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
if rawSource.isActive {
|
|
// **All three panes**, comments included: "Raw Source still swaps the entire content area
|
|
// — all panes, comments included; the raw outlet's rule is unchanged" (05 ▸ The comments
|
|
// column). The swap encloses the whole composition below rather than any one pane, which
|
|
// is what keeps that true as the composition grows.
|
|
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
|
|
} else {
|
|
HStack(spacing: 0) {
|
|
contentPanes
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
// **View ▸ Show Sidebar** (`ShowSidebarCommand`, `CardToolbar`): the whole trailing
|
|
// column, divider included, comes and goes with the one bit — the body/comments
|
|
// columns above take the width back through their own `.infinity` frames, exactly as
|
|
// they do while the pane is merely narrow. A structural reflow, the Show Trash
|
|
// re-divide's own voice (`Motion.structural`), with the pane itself sliding off the
|
|
// trailing edge it lives on (`Motion.cardSidebarTransition`) rather than just
|
|
// vanishing.
|
|
if showSidebar {
|
|
Divider()
|
|
|
|
sidebar
|
|
// Fixed, and the one place it comes from.
|
|
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
.background(.background.secondary)
|
|
.transition(Motion.cardSidebarTransition(reduced: reduceMotion))
|
|
}
|
|
}
|
|
.animation(Motion.structural(reduced: reduceMotion), value: showSidebar)
|
|
}
|
|
}
|
|
|
|
/// The body pane and the comments pane, in whichever of the two mounts is current — or the body
|
|
/// pane alone, when Show Comments is off.
|
|
///
|
|
/// **The thread stays visible through body Edit in either mount**, and needs no rule of its own:
|
|
/// Edit swaps the content of the body pane (`CardBodySurface`), which is *inside* the body column
|
|
/// here, so nothing about the composition changes when the mode flips. That is the sidebar's own
|
|
/// precedent, which 05 names when it states the rule.
|
|
@ViewBuilder
|
|
private var contentPanes: some View {
|
|
if showComments {
|
|
switch mount {
|
|
case .beside:
|
|
// The `GeometryReader` is new here for the divider's sake — a resizable column needs
|
|
// the `HStack`'s own live width to clamp against (`clampedCommentsColumnWidth(in:)`),
|
|
// exactly the reason the stacked branch below already reaches for one.
|
|
GeometryReader { proxy in
|
|
HStack(spacing: 0) {
|
|
bodyColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
CommentsColumnDivider(
|
|
bodyPointSize: bodyPointSize,
|
|
containerWidth: proxy.size.width,
|
|
currentWidth: clampedCommentsColumnWidth(in: proxy.size.width),
|
|
onChange: { commentsColumnWidthOverride = $0 },
|
|
onCommit: { width in
|
|
storedCommentsColumnWidth = Double(width)
|
|
// The persisted figure now says the same thing the override does —
|
|
// clearing it hands `commentsColumnWidth` back to one source of truth
|
|
// rather than two that happen to agree.
|
|
commentsColumnWidthOverride = nil
|
|
}
|
|
)
|
|
|
|
commentsPane
|
|
// **User-resizable since 2026-08-09** — the sidebar's "resize flex
|
|
// always goes to the body" (05 ▸ Composition) still holds between the
|
|
// body and the *sidebar*; the body and the *comments column* now split
|
|
// it by the divider's drag instead, clamped so neither pane's own floor
|
|
// gives way (`CardWindowMetrics.clampedCommentsColumnWidth`).
|
|
.frame(width: clampedCommentsColumnWidth(in: proxy.size.width))
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
}
|
|
}
|
|
|
|
case .stacked:
|
|
// The ≈3:2 split needs a height to divide, and a `GeometryReader` is the only way to
|
|
// have one — `layoutPriority` and flexible frames express *preferences*, and this is
|
|
// a ratio the design fixes. The body takes its share; the comments pane takes the
|
|
// remainder, so the divider between them can never leave a gap or overlap.
|
|
GeometryReader { proxy in
|
|
VStack(spacing: 0) {
|
|
bodyColumn
|
|
.frame(height: mount.bodyHeight(in: proxy.size.height))
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
|
|
Divider()
|
|
|
|
commentsPane
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
bodyColumn
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
|
|
private var commentsPane: some View {
|
|
CardCommentsPane(comments: comments, cardFolder: cardFolder, thumbnails: thumbnails)
|
|
}
|
|
|
|
// MARK: - Body column
|
|
|
|
/// Title, the quiet created/modified line, then the body — 05's top-to-bottom order.
|
|
private var bodyColumn: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: bodyPointSize * 0.5) {
|
|
// m6-card-body: the title *field* — large and borderless, committing to frontmatter
|
|
// on Return or focus loss, clearing to remove the `title` key, Escape abandoning to
|
|
// the on-disk title. Read-only here; the placeholder rendering is already final.
|
|
Text(card.title.value ?? "Untitled")
|
|
.font(.largeTitle)
|
|
// "Untitled" is a rendering, never a value (03-board-ui.md § Card face) — the
|
|
// same secondary treatment the face gives it.
|
|
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
|
.textSelection(.enabled)
|
|
|
|
if let dateLine {
|
|
Text(dateLine)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
.padding(.top, CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
|
|
CardBodySurface(
|
|
// The session's text, never `card.body` directly: a dirty buffer outranks the
|
|
// snapshot (05 ▸ Write rules) and a flushed one is ahead of it by a reload, so the
|
|
// buffer is the truer of the two in both modes — which is also how Preview shows the
|
|
// text that produced it the instant Edit is left.
|
|
body: bodySession.text,
|
|
mode: bodyPresentation.mode,
|
|
cardFolder: cardFolder,
|
|
presentation: bodyPresentation,
|
|
session: bodySession,
|
|
isTaskToggleEnabled: isEditable,
|
|
onToggleTask: onToggleTask
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
// **Dirty-buffer-wins, applied on every snapshot** (05 ▸ Write rules): the session takes
|
|
// disk's word for what the file says, and takes it into the editor only when the buffer has
|
|
// nothing unsaved. `initial: true` is also how the buffer is filled at all — a window opens
|
|
// by adopting its card's body.
|
|
.onChange(of: card.body, initial: true) { _, body in
|
|
bodySession.adopt(diskBody: body)
|
|
}
|
|
// **The opening rule, applied once** (05 ▸ Mode grammar): a card opens in Preview unless its
|
|
// body is empty, in which case it opens straight into Edit. `openIfNeeded` is what makes it
|
|
// "once" — a later reload that empties the file must not drag a reader into Edit.
|
|
.task { bodyPresentation.openIfNeeded(body: card.body) }
|
|
}
|
|
|
|
/// "Created ⟨date⟩ · Modified ⟨date⟩ · by ⟨modified-by⟩", **omitting whichever keys are absent**
|
|
/// (05 ▸ Composition) — the whole line disappears when the card carries none of the three.
|
|
///
|
|
/// The "by" segment renders only with the self-reported provenance stamp present
|
|
/// (01-storage-format.md), which is the point of showing it at all: provenance made visible with
|
|
/// no commit trail to read it from.
|
|
private var dateLine: String? {
|
|
var parts: [String] = []
|
|
if let created = card.created.value {
|
|
parts.append("Created \(Self.dateText(created))")
|
|
}
|
|
if let modified = card.modified.value {
|
|
parts.append("Modified \(Self.dateText(modified))")
|
|
}
|
|
if let by = card.modifiedBy.value, !by.isEmpty {
|
|
parts.append("by \(by)")
|
|
}
|
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
|
}
|
|
|
|
private static func dateText(_ date: Date) -> String {
|
|
date.formatted(date: .abbreviated, time: .shortened)
|
|
}
|
|
|
|
// MARK: - Attributes sidebar
|
|
|
|
/// 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 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 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
|
|
/// surviving reservation of that slot, and it anticipates the foreign-change journal successor.
|
|
private var sidebar: some View {
|
|
ScrollView(.vertical) {
|
|
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
|
|
CardStyleSection(store: store, recents: recents, cardID: card.id, undo: undo)
|
|
|
|
// The snapshot's own document, not a re-read: the loader parsed this file, unknown
|
|
// keys and their order included, and `Card` has carried it since (`BoardModel`).
|
|
CardDetailsSection(rows: CardDetails.rows(of: card.document))
|
|
|
|
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View ▸ Show Sidebar
|
|
|
|
/// View ▸ Show Sidebar (checkmark toggle, **no default chord**) — the trailing attributes sidebar's
|
|
/// visibility (05-card-window.md ▸ Composition, extended for the toolbar toggle).
|
|
///
|
|
/// `ShowCommentsCommand`'s shape and reasons exactly: one bit, app-wide and persisted, the checkmark
|
|
/// reading exactly that bit so the row never lies, and it mirrors the toolbar's own Show Sidebar item
|
|
/// (`CardToolbar`) the way View ▸ Show Trash mirrors its toolbar twin — a menu command with a second
|
|
/// face, never two implementations of one.
|
|
///
|
|
/// Validation is **scope and nothing else**, `ShowCommentsCommand`'s own posture: with no card window
|
|
/// in front there is no focused value and the row disables, and the read-only lock plays no part —
|
|
/// showing or hiding a pane is not a mutation. `\.cardAttachments` is read only as the "a card window
|
|
/// is frontmost" signal `AddAttachmentCommand` already uses; the sidebar this toggles shows attachments
|
|
/// among its other sections, but the row does not otherwise touch that handle.
|
|
struct ShowSidebarCommand: View {
|
|
|
|
@FocusedValue(\.cardAttachments) private var attachments
|
|
@AppStorage(AppPreferences.showCardSidebarKey) private var isShown = true
|
|
|
|
static func isEnabled(_ attachments: CardAttachments?) -> Bool {
|
|
attachments != nil
|
|
}
|
|
|
|
/// The toggle's binding, `ShowTrashCommand.isVisible`'s own shape: the setter routes through
|
|
/// `AppPreferences.setShowCardSidebar` — the one write path this bit's two faces share — rather
|
|
/// than writing `$isShown` directly, which is what makes the animated reflow the toolbar item
|
|
/// gets true of this row too.
|
|
private var isVisible: Binding<Bool> {
|
|
Binding(get: { isShown }, set: { AppPreferences.setShowCardSidebar($0) })
|
|
}
|
|
|
|
var body: some View {
|
|
Toggle("Show Sidebar", isOn: isVisible)
|
|
.disabled(!Self.isEnabled(attachments))
|
|
}
|
|
}
|
|
|
|
// MARK: - The window-wide drop
|
|
|
|
/// Attaches the whole-window file drop, or nothing at all.
|
|
///
|
|
/// A modifier rather than an `if` inside `body` because `.onDrop` has to be applied to the *same*
|
|
/// view identity in both cases: a window whose store arrives a turn after its view would otherwise
|
|
/// re-mount its entire content when the drop target appeared, throwing away the body's scroll
|
|
/// position for nothing.
|
|
private struct WindowFileDrop: ViewModifier {
|
|
|
|
let delegate: CardWindowDropDelegate?
|
|
|
|
func body(content: Content) -> some View {
|
|
if let delegate {
|
|
// `.fileURL` alone: a text drag never matches, so it is never offered here and falls to
|
|
// the Edit editor, where `NSTextView` inserts it at the caret (05 ▸ Attachments).
|
|
content.onDrop(of: [.fileURL], delegate: delegate)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|