Files
lanework/Kanban/UI/Card/CardWindowView.swift
T
rzen 40322247e0 Build the style, details, and actions sidebar sections
The sidebar completes: the shared style editor gains a second anchor —
StyleEditorLayout carries the geometry (the popover keeps its settled
268/14/7/8 untouched as the default; the sidebar packs columns to its
width with no inner scroller) while every well, the batch display, the
arrow grammar, and the one applyStyle bracket stay the shared
component's. The card anchor is fixed, not tracking: the target is
this card, and the fate walk retires the window when the card goes.
Details renders every unknown frontmatter key read-only in file order —
Card.document already carried them — showing the author's own bytes
where the raw span is a value and the engine's rendering for block
scalars and empties; reserved enhanced-schema keys are ordinary
unknowns, and no keys means no section. Actions: Delete rides the same
tombstone bytes as Backspace and drop-on-trash through a one-line
seam, says nothing about selection, and lets the fate walk dismiss;
Reveal in Finder resolves through the attachment scope so the two
paths cannot disagree. History reserves its m7 slot without drawing a
header no base board can honor.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 12:22:04 -04:00

281 lines
15 KiB
Swift

import SwiftUI
import UniformTypeIdentifiers
// MARK: - CardWindowView
/// The card window's content: **two full-height, independently scrolling columns** — a wide body
/// column leading, a narrow attributes sidebar trailing (05-card-window.md ▸ Composition).
///
/// ### 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 sidebar's History section, whose place in the stack is reserved and whose content waits on
/// a git mode to be honest about (`historySlot`).
///
/// 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" — no split view, no stored divider
/// position, nothing for a drag to disagree with.
///
/// ### 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 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
/// tombstone. 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 ⌫ tombstone" forbid.
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 thumbnail memory, held by the host so it outlives a snapshot.
let thumbnails: AttachmentThumbnailCache
/// 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
/// 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 }
/// 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 {
CardRawSourceView(session: rawSource, presentation: bodyPresentation)
} else {
HStack(spacing: 0) {
bodyColumn
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Divider()
sidebar
// Fixed, and the one place it comes from.
.frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize))
.frame(maxHeight: .infinity, alignment: .top)
.background(.background.secondary)
}
}
}
// 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 where
/// git history may not exist.
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, **in 05's settled order**: Attachments, Style, Details, History,
/// Actions.
///
/// Two of the five are conditional, and both conditions are 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"), and **History** is absent on boards without app-managed git.
/// Everything else in the stack is unconditional, so the composition a user learns on one card is
/// the composition they get on the next.
private var sidebar: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: bodyPointSize * 1.25) {
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
CardStyleSection(store: store, recents: recents, cardID: card.id)
// 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))
historySlot
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize))
}
}
/// **The History section's reserved place in the stack** — between Details and Actions, 05's
/// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject,
/// relative date, author").
///
/// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not
/// exist here, so a header over empty space would claim a commit trail on every board — and on
/// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one
/// that can never arrive. What the slot reserves is the **position**, so filling it in moves
/// nothing above or below it.
///
// m7-git: the trail itself, plus the two rules that come with it — absence on boards without
// app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md)
// and View ▸ History, which focuses this section (11-command-nexus.md).
@ViewBuilder
private var historySlot: some View {
EmptyView()
}
}
// 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
}
}
}