Files
lanework/Kanban/UI/Card/CardWindowView.swift
T
rzen 797d020d01 Materialize the trash — faces, menus, and grammar
Phase 3 finishes the pivot at the surface. One card face serves two
containers: CardFaceView extracted with a role — board or trash — so
stripe, tint, chip, selection stroke, cut dim, marquee registration,
and drag are shared by construction, the trash side differing only in
its absences: no Open, no rename, no Style, no file-hover highlight,
and a Delete that goes through the confirmation host. The column
rewrote around the lanes' own single-column masonry so drag reflow
reads as positional slides; chrome stays the hatched header, symbol,
and count — 11 gives Empty Trash to the File menu alone. Two real
grammar bugs die here: plain Backspace on a trash selection purged
without the confirmation the menu raises, and the context menu's
Delete resolved against the standing selection, so right-clicking a
trash card under a board selection silently did nothing — it now
stages the clicked set explicitly. Open, Rename, Style, and Empty
Trash validation became testable store seams; the column is one named
accessibility container of ordinary card elements. The tombstone era
is swept: deleteItem, restoreItem, stripTombstonedChildren — dead
since lane copies stopped nesting trash — the restore verb, the
unreachable put-back banner row, and every quasi-lane doc comment.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-28 18:18:39 -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
/// move-to-trash. Routing either through a closure of this window's own would be a second
/// card-styling or card-deleting path to keep in step with the first — exactly what "one
/// component, one behavior" and "exactly the ⌫ delete" forbid.
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
}
}
}