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
This commit is contained in:
@@ -316,7 +316,7 @@ private final class DuplicateCancellation {
|
||||
/// to a command — the thing 04's contract forbids.
|
||||
///
|
||||
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
|
||||
/// not edit-shaped and stays enabled on tombstoned selections" (04 ▸ The trash), and inspection is a
|
||||
/// not edit-shaped and stays enabled on trash selections" (04 ▸ The trash), and inspection is a
|
||||
/// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the
|
||||
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
|
||||
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
|
||||
|
||||
@@ -626,19 +626,15 @@ public final class AppModel {
|
||||
/// The lane and card counts stamped into the registry at close — **live items only** (02
|
||||
/// § Per-board app state, settled).
|
||||
///
|
||||
/// > tombstoned lanes and cards — and cards hidden beneath a tombstoned lane — don't count; the
|
||||
/// > row advertises the board's working size, and the trash is an errand, not inventory.
|
||||
///
|
||||
/// The nesting is the ancestor walk: a tombstoned lane is skipped whole, so its cards are never
|
||||
/// reached whatever their own flags say. `Lane.isDeleted`/`Card.isDeleted` are presence-of-key,
|
||||
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
|
||||
/// else.
|
||||
/// > deleted lanes and cards don't count; the row advertises the board's working size, and the
|
||||
/// > trash is an errand, not inventory.
|
||||
///
|
||||
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
|
||||
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
|
||||
/// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and
|
||||
/// none could be forgotten. The flag walk above is the retiring half of the same rule, kept
|
||||
/// while boards written by older versions still carry `deleted:` keys.
|
||||
/// none could be forgotten. The tombstone era's ancestor walk over `deleted:` flags is gone with
|
||||
/// the flag; a board an older version wrote counts its unmigrated cards until the migration moves
|
||||
/// them, which is the safe direction and lasts exactly one write.
|
||||
///
|
||||
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
|
||||
/// discover a counting bug.
|
||||
|
||||
@@ -9,10 +9,10 @@ import Foundation
|
||||
/// board, where identities could collide; a whole-board copy is a new namespace, and Duplicate's
|
||||
/// fork-keeps-history guarantee requires it (copied `.git` history must keep naming the paths it
|
||||
/// describes)".
|
||||
/// - **Tombstoned items are carried too** (03, settled): "Duplicate is a full fork, trash included —
|
||||
/// dropping them would leave the copy's working tree disagreeing with its own copied HEAD". This
|
||||
/// file does nothing to achieve that: a tombstone is a `deleted:` key inside a file, so a copy
|
||||
/// carries it by declining to be clever.
|
||||
/// - **`.trash/` is carried too** (03, settled, re-grounded 2026-07-28 for the materialized trash):
|
||||
/// "Duplicate is a full fork, `.trash/` included — dropping it would leave the copy's working tree
|
||||
/// disagreeing with its own copied HEAD". This file does nothing to achieve that: the trash is an
|
||||
/// ordinary folder under the root, so the tree walk carries it by declining to be clever.
|
||||
/// - **`.git` comes along** — a duplicate of a git board is a fork of its history — with only its
|
||||
/// remote configuration stripped, which is m7's.
|
||||
/// - Timestamps, unknown keys, strays, `CLAUDE.user.md`, attachments: verbatim, for the same reason.
|
||||
|
||||
@@ -16,7 +16,7 @@ import Foundation
|
||||
// Application Support, each folder loaded through `BoardLoader` — `name`/`blurb`/`icon` off the
|
||||
// template board's own `index.md`, order off its `template.order`, an unloadable user template still
|
||||
// listed (by folder name, marked unloadable, carrying the loader's specifics) but not instantiable.
|
||||
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips tombstones,
|
||||
// `laneTitles` stops existing at that point: instantiation becomes a tree copy that skips `.trash/`,
|
||||
// mints fresh GUIDs, and stamps `created`/`modified` fresh (`BoardWriter.CopyStamps.born`), never
|
||||
// copying `.git`. The chooser's mini preview renders from the loaded `BoardModel` rather than from
|
||||
// these strings.
|
||||
|
||||
@@ -21,7 +21,7 @@ public struct CardPlacement: Equatable {
|
||||
/// A named decision rather than a scattering of `if`s, because 05-card-window.md ▸ Deletion &
|
||||
/// lifecycle and 02-architecture.md § Live-reload resilience state the same rule from two directions
|
||||
/// and both have to be true of one piece of code. Making it a value also makes it a *pure* function
|
||||
/// of a snapshot, which is the only way the tombstoned-lane case gets tested without a window.
|
||||
/// of a snapshot, which is the only way the deleted-lane case gets tested without a window.
|
||||
public enum CardWindowFate: Equatable {
|
||||
case shows(CardPlacement)
|
||||
case dismisses
|
||||
@@ -38,10 +38,10 @@ public enum CardWindowFate: Equatable {
|
||||
/// nothing the second time, which is what makes those two paths safe to both exist.
|
||||
///
|
||||
/// **What ending means now**: the Edit buffer's debounce is cancelled and its text written — the
|
||||
/// "window close" third of 05's flush rule, and on a dismissal caused by a tombstone the surgical
|
||||
/// body write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the tombstoned
|
||||
/// card's folder before the window dismisses ... so the keystrokes survive Put Back").
|
||||
/// `BoardStore.writeCardBody` resolves tombstoned cards on purpose for exactly this.
|
||||
/// "window close" third of 05's flush rule, and on a dismissal caused by a delete the surgical body
|
||||
/// write 05 ▸ Deletion & lifecycle promises ("a dirty Edit buffer flushes into the card's folder at
|
||||
/// its new `.trash/` location before the window dismisses ... so the keystrokes survive a later
|
||||
/// restore"). `BoardStore.writeCardBody` resolves trash cards on purpose for exactly this.
|
||||
///
|
||||
/// A *failing* close flush is not this object's problem to solve: it is `DirtyBufferGuard`'s modal
|
||||
/// moment, which the host runs earlier, on `windowShouldClose`, while there is still a window to
|
||||
@@ -100,10 +100,11 @@ final class CardWindowSession: CardSessionFlushing {
|
||||
/// ### Its whole identity is `(board, card)`
|
||||
///
|
||||
/// Which is why this host is mostly a set of dismissal rules. The window follows its card between
|
||||
/// lanes for free — the key names neither — and it dismisses in the three cases where the key stops
|
||||
/// naming anything: the card is tombstoned, its *lane* is tombstoned (effective liveness is
|
||||
/// ancestor-walked, 02 § Live-reload resilience), or the card is simply not in this board's snapshot
|
||||
/// any more, which is what a cross-board move looks like from here.
|
||||
/// lanes for free — the key names neither — and it dismisses whenever the key stops naming a card
|
||||
/// **on the board**: the card moved into `.trash/` ("entering the trash counts as deleted" —
|
||||
/// 05-card-window.md ▸ Deletion & lifecycle, resettled 2026-07-28), its *lane* was deleted and took
|
||||
/// it along, or the card is simply not in this board's snapshot any more, which is what a cross-board
|
||||
/// move looks like from here.
|
||||
///
|
||||
/// ### It can never outlive its board window
|
||||
///
|
||||
@@ -164,10 +165,12 @@ struct CardWindowHost: View {
|
||||
|
||||
/// Whether a card window keyed on `cardID` still has a card, given this board's snapshot.
|
||||
///
|
||||
/// The three dismissal cases collapse into two lines: a card that is not in the snapshot is gone
|
||||
/// (deleted outright, or moved to another board — the board half of the key no longer names it),
|
||||
/// and a card whose **effective** liveness is trashed renders nowhere, whether the tombstone is
|
||||
/// its own or its lane's. Only a live card in a live lane keeps its window.
|
||||
/// **One walk over the lanes is the whole rule** (05-card-window.md ▸ Deletion & lifecycle,
|
||||
/// resettled 2026-07-28 — the materialized trash): deletion is a *move*, so a trashed card has
|
||||
/// physically left its lane and answers `.dismisses` by simply not being found — "entering the
|
||||
/// trash counts as deleted", with no liveness flag to read and no ancestor walk to run. A card
|
||||
/// whose lane was deleted, one purged outright and one moved to another board all fall out of the
|
||||
/// same absence. Only a card in one of this board's lanes keeps its window.
|
||||
///
|
||||
/// Takes the id as the ref stores it — a raw folder name — and compares it as an `ItemID`, so two
|
||||
/// case-spellings of one UUID are one card here exactly as they are everywhere else.
|
||||
|
||||
@@ -29,7 +29,7 @@ extension UTType {
|
||||
///
|
||||
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
|
||||
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
|
||||
/// live-XOR-tombstoned invariants are exactly the ones those two types already carry. Their raw
|
||||
/// board-XOR-trash invariants are exactly the ones those two types already carry. Their raw
|
||||
/// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch.
|
||||
///
|
||||
/// `entries` are in the order the copy read them — flatten order on the live side ("lane `order`,
|
||||
@@ -83,10 +83,11 @@ public struct ClipboardManifest: Codable, Sendable, Equatable {
|
||||
/// A **lane** entry's cards, index text and all — "a lane entry embeds its cards' too,
|
||||
/// attachment-less". Empty for a card entry.
|
||||
///
|
||||
/// **Live cards only**, which is not a shortcut: a lane *copy* strips tombstoned cards
|
||||
/// (04-interactions.md ▸ Clipboard, ▸ The trash), and the fallback only ever materializes a
|
||||
/// copy — a cut's move carries the real folder whole and never comes near this array. So the
|
||||
/// embedded set is exactly what a fallback paste should produce.
|
||||
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards —
|
||||
/// the trash is board-level, so there is nothing lane-nested to strip or carry"
|
||||
/// (04-interactions.md ▸ Drag and drop, resettled 2026-07-28), and the fallback only ever
|
||||
/// materializes a copy — a cut's move carries the real folder whole and never comes near this
|
||||
/// array. So the embedded set is exactly what a fallback paste should produce.
|
||||
public var cards: [Card]
|
||||
|
||||
/// One card inside a copied lane.
|
||||
|
||||
@@ -38,7 +38,7 @@ import os
|
||||
/// ⌘X stages, writes the pasteboard, and arms the source board's `transient.pendingCut` — the items
|
||||
/// dim in place. The cut is **armed** while the pasteboard still holds its `copyID`, the source store
|
||||
/// is still open, and the pending cut still names something; "deletion voids per item" needs no code
|
||||
/// here at all, because `TransientBoardState.resolve` already ejects a tombstoned or vanished member
|
||||
/// here at all, because `TransientBoardState.resolve` already ejects a member that moved to the trash or vanished
|
||||
/// on every reload, so "paste moves only the survivors" is the reload rule read at paste time.
|
||||
/// Voiding undims and downgrades the paste to a copy from staging.
|
||||
///
|
||||
|
||||
@@ -42,7 +42,7 @@ struct FutureCommand: View {
|
||||
///
|
||||
// m9-templates: copies the open board into the user templates store, close-flushed first exactly as
|
||||
// Duplicate is (09 ▸ Save as Template: "The copy is preceded by the close flush"), `.git` stripped,
|
||||
// tombstones dropped, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
|
||||
// `.trash/` excluded, a `template:` key stamped. Validation will be `acceptsBoardMutations` plus 09's
|
||||
// one carve-out from the read-only lock — live under the unwritable-location state unless an open
|
||||
// Edit/raw-source session holds unsaved content — so it cannot simply borrow
|
||||
// `DuplicateBoardCommand`'s predicate outright.
|
||||
|
||||
@@ -38,10 +38,13 @@ public enum HistoryPhrase {
|
||||
public enum Verb: String, Sendable, CaseIterable {
|
||||
/// A create — File ▸ New Lane, the new-card placeholder's commit, a Finder file drop's cards.
|
||||
case add = "Add"
|
||||
/// A tombstone (⌫, drop-on-trash, the card window's Actions ▸ Delete).
|
||||
/// A delete — ⌫, drop-on-trash, the card window's Actions ▸ Delete — which is a *move* into
|
||||
/// `.trash/` for a card and a physical removal for a lane (03-board-ui.md § Trash).
|
||||
///
|
||||
/// **There is no `restore` verb**: restoring is an ordinary move out (drag or ⌘X/⌘V), so it
|
||||
/// registers as `move` like any other, and Put Back is retired with the tombstone model
|
||||
/// (resettled 2026-07-28).
|
||||
case delete = "Delete"
|
||||
/// Put Back and drag-to-restore.
|
||||
case restore = "Restore"
|
||||
/// A drop that changes an item's parent.
|
||||
case move = "Move"
|
||||
/// A drop, a lane drag or ⌥⌘↑/⌥⌘↓ that changes rank among unchanged siblings.
|
||||
|
||||
@@ -631,9 +631,8 @@ public final class BannerCenter {
|
||||
/// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
|
||||
/// "the item" rather than guessing "card" and being wrong about a lane.
|
||||
///
|
||||
/// The trash verbs match the commands the user pressed — **Delete**, Put Back, Delete
|
||||
/// Immediately — which is 03-board-ui.md § Trash's naming constraint, settled with the trash UI
|
||||
/// copy: "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
|
||||
/// The trash verbs match the commands the user pressed — **Delete**, Delete Immediately, Empty
|
||||
/// Trash — which is 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says
|
||||
/// 'Delete'". A banner saying a card could not be *moved to the trash* would name the wrong one
|
||||
/// of the app's two trashes (the card window's attachment Remove is the other).
|
||||
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
|
||||
@@ -652,8 +651,6 @@ public final class BannerCenter {
|
||||
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
|
||||
case let .delete(title):
|
||||
if let title { "Couldn't delete '\(title)'" } else { "Couldn't delete the item" }
|
||||
case let .restore(title):
|
||||
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
||||
case let .purge(title):
|
||||
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
||||
case let .migrateTombstone(title):
|
||||
@@ -683,7 +680,7 @@ public final class BannerCenter {
|
||||
// it. The naming constraint above reserves "move to the Trash" for the *system* Trash,
|
||||
// and this is the operation that uses it: the file really did go (or fail to go) where
|
||||
// Finder's own ⌘⌫ sends things, so saying anything else — "remove", "delete" — would
|
||||
// describe the board's quasi-lane instead and promise the wrong recovery.
|
||||
// describe the board's own trash instead and promise the wrong recovery.
|
||||
"Couldn't move '\(filename)' to the Trash"
|
||||
case .renumberChildren:
|
||||
"Couldn't renumber cards"
|
||||
|
||||
@@ -786,60 +786,6 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Physically removes every tombstoned card from a **just-copied** lane, and reports which —
|
||||
/// the tail of a lane copy (04-interactions.md ▸ Drag and drop: "A lane copy **strips
|
||||
/// tombstoned cards**: the copy transfers content, and trash isn't content"; the same rule
|
||||
/// governs a pasted lane copy).
|
||||
///
|
||||
/// **Removed, not tombstoned.** These folders were minted seconds ago by `copyItem` and were
|
||||
/// never content in this board, so there is nothing here for a Put Back to recover and no
|
||||
/// tombstone to leave standing — the tombstoned *originals* stay recoverable in the source
|
||||
/// board, which is where the recovery story lives. A lane **move** carries them whole and never
|
||||
/// calls this: the folder travels as-is and its tombstones land in the destination's trash by
|
||||
/// rendering.
|
||||
///
|
||||
/// **Only ever pointed at a fresh copy.** `copyItem` has no filter hook — it copies the tree
|
||||
/// verbatim by design, which is what makes attachments and strays arrive byte-identical — so
|
||||
/// the strip is a second step rather than a parameter, and a caller that aimed it at a lane the
|
||||
/// user actually owns would be destroying their trash. Every call site in the app is the line
|
||||
/// after a `copyItem` that materialized the folder.
|
||||
///
|
||||
/// A child whose `index.md` is missing or unreadable is **left alone**: the liveness question
|
||||
/// cannot be answered for it, and the conservative direction is to keep the folder — the same
|
||||
/// leniency `copyItem` extends below its root. Liveness is read exactly as the loader reads it
|
||||
/// (a present `deleted` key, malformed or not).
|
||||
///
|
||||
/// The operation vocabulary is `.copy`, not `.purge`: the user pressed nothing called "delete",
|
||||
/// and a failure here must say the app could not copy the lane (02-architecture.md §
|
||||
/// Write-failure surfacing).
|
||||
@discardableResult
|
||||
public static func stripTombstonedChildren(of laneFolder: URL) throws(BoardWriteError) -> [ItemID] {
|
||||
let operation = WriteOperation.copy(title: nil)
|
||||
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
|
||||
try checkIsUUIDShaped(laneFolder, operation: operation)
|
||||
|
||||
var removed: [ItemID] = []
|
||||
for child in childCandidates(of: laneFolder) {
|
||||
let indexURL = child.appendingPathComponent(BoardLoader.indexFileName)
|
||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
||||
let document = try? readDocument(at: indexURL, operation: operation),
|
||||
!document.deleted.isMissing
|
||||
else { continue }
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: child)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: child.path,
|
||||
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
removed.append(ItemID(rawValue: child.lastPathComponent))
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/// Materializes an item from **supplied `index.md` text** rather than from a folder on disk —
|
||||
/// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged
|
||||
/// snapshot is missing or unreadable at paste time, paste falls back to the embedded
|
||||
@@ -1391,82 +1337,20 @@ public enum BoardWriter: Sendable {
|
||||
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
// MARK: - Tombstone (retired, awaiting removal)
|
||||
//
|
||||
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
|
||||
// app's delete is the physical move above, and no `deleted:` key is ever written again.
|
||||
//
|
||||
// **These have no app callers left.** Every consumer moved across with the store swap — the
|
||||
// delete is `deleteCardToTrash`, the lane delete is `removeLane`, the restore is an ordinary
|
||||
// `moveItem`, and the legacy keys are handled by the two `migrate…` calls above. They are kept
|
||||
// here for exactly one more beat because `purgeItem` below is still live (Delete Immediately's
|
||||
// board-side purge) and the three read as one family; the pair and
|
||||
// `stripTombstonedChildren` go together in the trash's cleanup pass, with the suites that
|
||||
// still pin their byte-level behaviour.
|
||||
|
||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
||||
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
|
||||
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
|
||||
/// file — its cards' files are exactly as they were.
|
||||
///
|
||||
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
|
||||
/// — the same guard `moveItem`/`copyItem` lean on — refuses any folder whose name isn't
|
||||
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
|
||||
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
|
||||
/// it), but this call is simply never able to *produce* one: it has no board-root code
|
||||
/// path to fall through, only a refusal.
|
||||
///
|
||||
/// Deleting an **already-tombstoned** item is not refused — it just refreshes the
|
||||
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
|
||||
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
|
||||
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
|
||||
/// cleared, atomic replace.
|
||||
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.delete(title: nil)
|
||||
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||
|
||||
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||
document.set(FrontmatterKeys.deleted, to: .date(Date()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
|
||||
/// **Position-perfect by construction** — the folder never moved, so the item simply
|
||||
/// re-enters the visible set at its recorded `order` among its current siblings
|
||||
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
|
||||
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
|
||||
/// tombstone the instant the winning occurrence is gone.
|
||||
///
|
||||
/// Restoring an item that **isn't** tombstoned is not refused — it is a harmless stamped
|
||||
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
|
||||
/// police liveness (a second, independent liveness check here could only drift from the
|
||||
/// store UI's own, which is what actually decides whether Put Back is offered at all).
|
||||
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.restore(title: nil)
|
||||
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||
|
||||
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the
|
||||
/// folder tree from disk. Irreversible, and distinct from tombstoning — this call does
|
||||
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
|
||||
/// tombstone stage by design.
|
||||
/// folder tree from disk. Irreversible, and distinct from the ordinary delete, which is a
|
||||
/// *move* into `.trash/` — this call does **not** require the item to be in the trash first,
|
||||
/// since Delete Immediately "skips the trash from anywhere" by design.
|
||||
///
|
||||
/// **A folder that is already gone is success, not an error** — checked first, before the
|
||||
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
|
||||
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
|
||||
/// tombstone... is also a delete"), so there is nothing left here to distinguish: a stray
|
||||
/// path that never existed and a once-real item someone already threw away in Finder both
|
||||
/// purge cleanly, silently, without inspecting what used to be there.
|
||||
/// produce (01-storage-format.md § Deletion, "a folder that disappears ... is also a
|
||||
/// delete"), so there is nothing left here to distinguish: a stray path that never existed
|
||||
/// and a once-real item someone already threw away in Finder both purge cleanly, silently,
|
||||
/// without inspecting what used to be there.
|
||||
///
|
||||
/// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability
|
||||
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
|
||||
/// `moveItem`/`copyItem` rely on: a board root or a stray never purges through this
|
||||
/// call, only a lane or a card.
|
||||
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
|
||||
let operation = WriteOperation.purge(title: nil)
|
||||
@@ -1837,7 +1721,7 @@ public enum BoardWriter: Sendable {
|
||||
/// The order of checks is the contract:
|
||||
///
|
||||
/// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
|
||||
/// cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
|
||||
/// cards, the same shape guard `moveItem`/`copyItem`/`purgeItem` lean on
|
||||
/// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
|
||||
/// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
|
||||
/// one exception to "subfolders are never created by the app" (§ Attachments); every
|
||||
@@ -2400,10 +2284,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case reorder(title: String?)
|
||||
case copy(title: String?)
|
||||
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
||||
/// outright (`removeLane`). Still the word the user pressed; the retiring tombstone write
|
||||
/// shares it while it lasts.
|
||||
/// outright (`removeLane`). The word the user pressed, whichever staging it took.
|
||||
///
|
||||
/// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed
|
||||
/// restore says the app couldn't *move* the card — which is exactly what it couldn't do
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — Put Back is retired with the tombstone model).
|
||||
case delete(title: String?)
|
||||
case restore(title: String?)
|
||||
case purge(title: String?)
|
||||
|
||||
/// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key
|
||||
@@ -2511,7 +2397,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .reorder: .reorder(title: title)
|
||||
case .copy: .copy(title: title)
|
||||
case .delete: .delete(title: title)
|
||||
case .restore: .restore(title: title)
|
||||
case .purge: .purge(title: title)
|
||||
case .migrateTombstone: .migrateTombstone(title: title)
|
||||
case .style: .style(title: title)
|
||||
@@ -2539,7 +2424,6 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .reorder(title): Self.phrase("reorder", title)
|
||||
case let .copy(title): Self.phrase("copy", title)
|
||||
case let .delete(title): Self.phrase("delete", title)
|
||||
case let .restore(title): Self.phrase("restore", title)
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
|
||||
@@ -138,19 +138,7 @@ struct OpenCardCommand: View {
|
||||
|
||||
private var isEnabled: Bool {
|
||||
guard let store, opener?.open != nil else { return false }
|
||||
return store.isEditingInline || soleSelectedCard != nil
|
||||
}
|
||||
|
||||
/// The sole selected **board card**, or `nil`. A lane, a multi-selection and a trash
|
||||
/// selection all answer `nil` — "everything edit-shaped is disabled on trash selections … Open
|
||||
/// Card, Rename, Style…" (04 ▸ The trash), and a card window is tied to one card.
|
||||
private var soleSelectedCard: ItemID? {
|
||||
guard let store else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
BoardStore.boardItem(id, in: store.snapshot)?.cardID != nil
|
||||
else { return nil }
|
||||
return id
|
||||
return store.isEditingInline || store.openCardTarget != nil
|
||||
}
|
||||
|
||||
private func open() {
|
||||
@@ -176,7 +164,61 @@ struct OpenCardCommand: View {
|
||||
return
|
||||
}
|
||||
|
||||
if let card = soleSelectedCard { open(card) }
|
||||
if let card = store.openCardTarget { open(card) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The three edit-shaped targets, as pure predicates
|
||||
|
||||
/// **Everything edit-shaped refuses a trash selection** (04-interactions.md ▸ The trash: "Everything
|
||||
/// edit-shaped is disabled on trash selections — Open Card, Rename, Style…"), and each of the three
|
||||
/// answers that with one expression used for both its `disabled` state and its action — the
|
||||
/// `newCardTarget` idiom, for its reason: two derivations of a rule are two chances to disagree.
|
||||
///
|
||||
/// They live on the store rather than inside the three menu rows so the grammar can be pinned
|
||||
/// without a menu (`SelectionGrammarTests`) — the same reason `TrashModel`'s validation is a pure
|
||||
/// function of a snapshot and a selection. A view-private predicate is a rule nobody can test.
|
||||
extension BoardStore {
|
||||
|
||||
/// Board ▸ Open Card's target: the sole selected **board card**, or `nil`. A lane, a
|
||||
/// multi-selection and a trash selection all answer `nil` — a card window is tied to one card,
|
||||
/// and trash cards don't open ("double-click stops at selection; move it out first" — 03 §
|
||||
/// Trash).
|
||||
///
|
||||
/// Deliberately free of `acceptsBoardMutations`: opening a window is not a mutation, and the
|
||||
/// item's own mid-edit branch is the focused-editor rule's one carve-out (`OpenCardCommand`).
|
||||
var openCardTarget: ItemID? {
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
Self.boardItem(id, in: snapshot)?.cardID != nil
|
||||
else { return nil }
|
||||
return id
|
||||
}
|
||||
|
||||
/// Board ▸ Rename's target: the sole selected board item, card or lane, with the title to seed
|
||||
/// the editor with — or `nil`. A trash selection never enables it, which `ItemReferenceSet`'s
|
||||
/// container answers directly.
|
||||
var renameTarget: (id: ItemID, title: String?)? {
|
||||
guard acceptsBoardMutations else { return nil }
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
let item = Self.boardItem(id, in: snapshot)
|
||||
else { return nil }
|
||||
return (id: id, title: item.title)
|
||||
}
|
||||
|
||||
/// Board ▸ Style…'s target: the selected board items, or **the board itself** when nothing is
|
||||
/// selected — "Board window: selected cards or lane; nothing selected = the board".
|
||||
///
|
||||
/// **A trash selection disables it rather than falling through to the board**: quietly restyling
|
||||
/// the board because the user had a trashed card selected would be the silent retarget 03
|
||||
/// forbids.
|
||||
var boardStyleTarget: StyleTarget? {
|
||||
guard acceptsBoardMutations else { return nil }
|
||||
guard !selection.isEmpty else { return .board }
|
||||
guard selection.container == .board else { return nil }
|
||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||
// items that render — the same universe its own reload rule will hold it to.
|
||||
let live = selection.resolved(against: snapshot).ids
|
||||
return live.isEmpty ? nil : .items(live)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +229,7 @@ struct OpenCardCommand: View {
|
||||
/// half alongside the lane-width pair).
|
||||
///
|
||||
/// **Validation and action read one answer** (`BoardStore.sortPlan`), the width pair's rule: the
|
||||
/// items disable on everything the design calls inert — a lane selection, a tombstoned selection, a
|
||||
/// items disable on everything the design calls inert — a lane selection, a trash selection, a
|
||||
/// card selection spanning lanes ("cards never change lanes by ⌘-arrow") — and additionally on a
|
||||
/// block already at its lane's end, where the only outcome would be a silent no-op.
|
||||
///
|
||||
@@ -227,7 +269,7 @@ struct MoveCardCommands: View {
|
||||
/// multi-lane move has no single unambiguous meaning ("one slot" for a discontiguous pair is not one
|
||||
/// answer). So the items validate on exactly one selected live lane.
|
||||
///
|
||||
/// **Never into the trash** costs nothing: the quasi-lane is not in the live lane order, so a step
|
||||
/// **Never into the trash** costs nothing: the column is not in the lane order, so a step
|
||||
/// past the last real lane is simply off the end — which is also the disable rule at the walls,
|
||||
/// following the width stepper's floor style rather than letting the store no-op silently.
|
||||
///
|
||||
@@ -391,19 +433,10 @@ struct BoardRenameCommand: View {
|
||||
|
||||
var body: some View {
|
||||
Button("Rename") {
|
||||
guard let store, let target = renameTarget else { return }
|
||||
guard let store, let target = store.renameTarget else { return }
|
||||
store.transient.beginRename(of: target.id, currentTitle: target.title)
|
||||
}
|
||||
.disabled(renameTarget == nil)
|
||||
}
|
||||
|
||||
private var renameTarget: (id: ItemID, title: String?)? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard selection.container == .board, selection.ids.count == 1, let id = selection.ids.first,
|
||||
let item = BoardStore.boardItem(id, in: store.snapshot)
|
||||
else { return nil }
|
||||
return (id: id, title: item.title)
|
||||
.disabled(store?.renameTarget == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,32 +453,19 @@ struct BoardRenameCommand: View {
|
||||
///
|
||||
/// Validation is `acceptsBoardMutations` — the lock and the focused-editor rule, the latter naming
|
||||
/// Style in its own list of board-scoped commands (04-interactions.md ▸ Grammar) — plus one rule of
|
||||
/// its own: **a tombstoned selection disables it rather than falling through to the board.**
|
||||
/// Everything edit-shaped is disabled on tombstoned selections (04 ▸ The trash), and quietly
|
||||
/// restyling the board because the user had a trashed card selected would be the silent retarget
|
||||
/// 03 forbids.
|
||||
/// its own: **a trash selection disables it rather than falling through to the board**
|
||||
/// (`BoardStore.boardStyleTarget`, where both halves live).
|
||||
struct BoardStyleCommand: View {
|
||||
|
||||
@FocusedValue(\.boardStore) private var store
|
||||
|
||||
var body: some View {
|
||||
Button("Style…") {
|
||||
guard let store, let target = styleTarget else { return }
|
||||
guard let store, let target = store.boardStyleTarget else { return }
|
||||
store.transient.beginStyleEditor(for: target)
|
||||
}
|
||||
.keyboardShortcut("s", modifiers: [.option, .command])
|
||||
.disabled(styleTarget == nil)
|
||||
}
|
||||
|
||||
private var styleTarget: StyleTarget? {
|
||||
guard let store, store.acceptsBoardMutations else { return nil }
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return .board }
|
||||
guard selection.container == .board else { return nil }
|
||||
// Re-resolved against the snapshot on the way in, so the session starts out holding only
|
||||
// items that render — the same universe its own reload rule will hold it to.
|
||||
let live = selection.resolved(against: store.snapshot).ids
|
||||
return live.isEmpty ? nil : .items(live)
|
||||
.disabled(store?.boardStyleTarget == nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ struct BoardDropContext {
|
||||
// MARK: Re-grounding
|
||||
|
||||
/// **Rule 2 of the mid-drag re-grounding trio** (04-interactions.md ▸ Drag and drop): a proposal
|
||||
/// whose target lane was tombstoned or vanished in a reload is invalidated — tombstoned lanes are
|
||||
/// whose target lane vanished in a reload is invalidated — deleted lanes are
|
||||
/// never drop targets — the shadow withdraws, and no proposal stands until the pointer reaches a
|
||||
/// live target.
|
||||
///
|
||||
@@ -152,7 +152,7 @@ struct BoardDropContext {
|
||||
/// this strip's standard width — and the cursor is the physical mouse, so neither input is a
|
||||
/// measured frame (03-board-ui.md § Motion).
|
||||
///
|
||||
/// **The terminal slot is clamped before the trash.** The quasi-lane consumes one unit while
|
||||
/// **The terminal slot is clamped before the trash.** The column consumes one unit while
|
||||
/// shown and is never a landing spot for anything (04-interactions.md ▸ The trash: "no move or
|
||||
/// paste ever targets the trash"), so it is absent from the slot list by construction and the end
|
||||
/// slot's uncapped reach past the last real lane lands *before* it.
|
||||
@@ -264,7 +264,7 @@ struct BoardDropContext {
|
||||
/// **A refusal falls through to the strip's own answer rather than withdrawing the proposal**,
|
||||
/// which is precisely what this column did before it had a drop target of its own: a cursor over
|
||||
/// it resolves to no lane, so `retargetCardsFromStrip` holds whatever the shadows already show
|
||||
/// and `retargetLanes` clamps the terminal slot in front of the quasi-lane. That is the
|
||||
/// and `retargetLanes` clamps the terminal slot in front of the trash column. That is the
|
||||
/// hysteresis contract (DRAG-REORDER.md § Hysteresis) and it is also the honest reading of "the
|
||||
/// trash proposes nothing for you": the column declines to be a target, it does not cancel the
|
||||
/// drag the user is still holding. So a lane drag reorders across the column exactly as it always
|
||||
@@ -390,9 +390,9 @@ struct BoardDropContext {
|
||||
/// dispatch) and the live handler for the strip's own surfaces — and unlike a card session, those
|
||||
/// surfaces genuinely clear the proposal rather than holding it. A cursor over a gap, the outer
|
||||
/// margin, or **the trash column** is over no lane at all (`LaneLayoutMath.laneIndex` answers
|
||||
/// `nil` there, since the quasi-lane is absent from the live lane list by construction), so the
|
||||
/// `nil` there, since the trash column is absent from the lane list by construction), so the
|
||||
/// highlight withdraws and a release refuses: "Finder file drops (attachment import) on
|
||||
/// tombstoned cards are inert" and the trash column is never a target (04-interactions.md ▸ The
|
||||
/// trash cards are inert" and the trash column is never a file-drop target (04-interactions.md ▸ The
|
||||
/// trash).
|
||||
func retargetFileFromStrip(_ info: DropInfo) {
|
||||
guard acceptsFileDrop(info), let cursor = stripCursor() else {
|
||||
@@ -490,7 +490,7 @@ struct BoardDropContext {
|
||||
/// against whatever the last render believed:
|
||||
///
|
||||
/// 1. the geometry was re-derived on every sample and the proposal is what it produced;
|
||||
/// 2. a proposal naming a vanished or tombstoned lane is invalidated, and **release with no valid
|
||||
/// 2. a proposal naming a vanished lane is invalidated, and **release with no valid
|
||||
/// proposal cancels** — items return, nothing is written;
|
||||
/// 3. an emptied drag cancels itself, and a partly emptied one drops the survivors.
|
||||
///
|
||||
@@ -531,7 +531,7 @@ struct BoardDropContext {
|
||||
case .lanes:
|
||||
// **A lane drag never targets the trash**, and never a masonry either — a lane session
|
||||
// proposes only lane slots (04-interactions.md ▸ The trash). True by construction, since
|
||||
// `retargetLanes` is the only thing that proposes for one and the quasi-lane is absent
|
||||
// `retargetLanes` is the only thing that proposes for one and the trash column is absent
|
||||
// from its slot list; written down because a commit that trusted the container implicitly
|
||||
// would be the one place the invariant could break silently.
|
||||
guard target.container == .strip else {
|
||||
@@ -758,7 +758,7 @@ struct StripDropDelegate: DropDelegate {
|
||||
/// runs is the strip's `retargetLanes` — lane reordering keeps working across the column exactly as
|
||||
/// it did when the column was a hole in the strip's target;
|
||||
/// - **Finder file sessions** by clearing the highlight outright: "Finder file drops (attachment
|
||||
/// import) on tombstoned cards are inert" (▸ The trash), and the column has nothing else to offer
|
||||
/// import) on trash cards are inert" (▸ The trash), and the column has nothing else to offer
|
||||
/// them — no lane, no card, nothing to attach to.
|
||||
struct TrashDropDelegate: DropDelegate {
|
||||
|
||||
@@ -783,7 +783,7 @@ struct TrashDropDelegate: DropDelegate {
|
||||
context.session.proposeFile(nil)
|
||||
}
|
||||
|
||||
/// A release on the column commits whatever stands — the tombstone when the trash is the
|
||||
/// A release on the column commits whatever stands — the delete when the trash is the
|
||||
/// proposal, and otherwise the proposal the column declined to displace, which is the same
|
||||
/// "the drop lands where the shadows show" promise as anywhere else.
|
||||
func performDrop(info: DropInfo) -> Bool {
|
||||
|
||||
@@ -19,14 +19,14 @@ import SwiftUI
|
||||
///
|
||||
/// - **Lane resize** — the right-edge grab strip (above). Deliberately *not* a drag session
|
||||
/// (DRAG-REORDER.md § Adjacent interaction).
|
||||
/// - **Drag & drop** — cards, lanes and trash rows travel as **system drag sessions**, which is what
|
||||
/// - **Drag & drop** — cards (in either container) and lanes travel as **system drag sessions**, which is what
|
||||
/// crosses window boundaries, draws the copy badge and gives the full-size replica
|
||||
/// (`DragSession`, `BoardDrops.swift`, DRAG-REORDER.md). The strip owns the drop geometry
|
||||
/// registry and the strip-level drop target; the lanes own theirs.
|
||||
/// - **The rubber band** — a drag from any empty surface sweeps a selection (`MarqueeSession`,
|
||||
/// `MarqueeMath`); the strip owns the session and the target registry, and hands both down.
|
||||
/// - **The board's fixed grammar keys** (11-command-nexus.md ▸ Fixed grammar keys) — the four
|
||||
/// arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s tombstone, Escape's step
|
||||
/// arrows and their ⇧/⌥ modes, Return's create/rename dispatch, ⌫'s staged delete, Escape's step
|
||||
/// outward, and Select All. The chorded commands are the menu's (`BoardCommands`); everything
|
||||
/// here is a plain key or a grammar modifier, which is exactly the split 04-interactions.md ▸
|
||||
/// Configurable bindings draws between what remaps and what does not.
|
||||
@@ -35,7 +35,7 @@ import SwiftUI
|
||||
/// carries those titles, and titles are the remapping mechanism's key, so a second row sharing one
|
||||
/// is ruled out (04-interactions.md ▸ Configurable bindings).
|
||||
///
|
||||
/// - **The trash quasi-lane** — trailing, one fixed unit, joining and leaving the width division as
|
||||
/// - **The trash column** — trailing, one fixed unit, joining and leaving the width division as
|
||||
/// View ▸ Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
|
||||
///
|
||||
/// ### The live search filter
|
||||
@@ -91,7 +91,7 @@ struct BoardView: View {
|
||||
@State private var marquee = MarqueeSession()
|
||||
|
||||
/// Where every sweepable item is drawn, in strip coordinates. Owned here because the band is —
|
||||
/// the cards and trash rows only *register* into it (`MarqueeTargetRegistry`).
|
||||
/// the card faces on either side only *register* into it (`MarqueeTargetRegistry`).
|
||||
@State private var marqueeTargets = MarqueeTargetRegistry()
|
||||
|
||||
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
|
||||
@@ -252,7 +252,7 @@ struct BoardView: View {
|
||||
case let .lane(lane):
|
||||
laneSlot(lane, standard: standard)
|
||||
// "Appear/disappear is scale + fade … lanes ~0.9" (03-board-ui.md § Motion).
|
||||
// A create, a delete and a Put Back all reach the strip as a lane arriving in
|
||||
// A create, a delete and an undo all reach the strip as a lane arriving in
|
||||
// or leaving this `ForEach`; whether that *performs* is decided upstream, at
|
||||
// the reload that carried it (`Motion.reloadAnimates`) — a transition with no
|
||||
// animated transaction around it is simply an appearance.
|
||||
@@ -267,7 +267,7 @@ struct BoardView: View {
|
||||
}
|
||||
}
|
||||
if isTrashVisible {
|
||||
// Trailing, always — the quasi-lane has no position of its own to lose, which is
|
||||
// Trailing, always — the column has no position of its own to lose, which is
|
||||
// also why it never appears in the drop proposal's inputs (those are built from
|
||||
// `boardLanes`) and why the terminal slot clamps in front of it.
|
||||
TrashLaneView(
|
||||
@@ -436,7 +436,7 @@ struct BoardView: View {
|
||||
|
||||
// MARK: - Trash
|
||||
|
||||
/// Whether the trash quasi-lane is on screen — transient, board-scoped, hidden on every open
|
||||
/// Whether the trash column is on screen — transient, board-scoped, hidden on every open
|
||||
/// (03-board-ui.md § Trash ▸ Visibility). Read in two places (the unit total and the slot), so it
|
||||
/// gets a name rather than being spelled twice.
|
||||
private var isTrashVisible: Bool {
|
||||
@@ -609,23 +609,26 @@ struct BoardView: View {
|
||||
return .handled
|
||||
}
|
||||
|
||||
/// **Plain ⌫ tombstones the live selection** — "a plain-key synonym of File ▸ Delete, kept
|
||||
/// grammar so no second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The
|
||||
/// map).
|
||||
/// **Plain ⌫ deletes the selection** — "a plain-key synonym of File ▸ Delete, kept grammar so no
|
||||
/// second 'Delete' title exists" (11-command-nexus.md; 04-interactions.md ▸ The map).
|
||||
///
|
||||
/// **Both stagings**, unlike the tombstone era's live-only reading: "Plain ⌫ performs the same
|
||||
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28), and the delete
|
||||
/// is staged by place inside the store (`BoardStore.deleteSelection`) rather than by two menu
|
||||
/// items sharing a chord. Put Back — the reason the bare key had to stay off the trash — is
|
||||
/// retired with the tombstone model.
|
||||
/// delete as fixed grammar" (04-interactions.md ▸ The map, resettled 2026-07-28) — a board
|
||||
/// selection moves into `.trash/`, a trash selection deletes permanently.
|
||||
///
|
||||
/// **Which means the same confirmation, too.** It goes through `TrashConfirmations.requestDelete`
|
||||
/// rather than straight to `BoardStore.deleteSelection`, because "the same chord deletes
|
||||
/// permanently … confirmation per 03's recoverability rule" and a bare key that skipped the alert
|
||||
/// the menu item raises would be the one path in the app where one keystroke destroys a card
|
||||
/// silently. The staging itself is still the store's — the alert is the only thing this adds.
|
||||
///
|
||||
/// Inert while an inline editor is open, like every grammar key: the field owns ⌫ as backspace,
|
||||
/// and a stray one reaching the board mid-edit would delete the item being renamed.
|
||||
private func handleDelete(_ press: KeyPress) -> KeyPress.Result {
|
||||
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete / Put Back),
|
||||
// ⌥⌘⌫ (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
|
||||
// **Plain ⌫, spelled out.** The modified chords belong to the menu — ⌘⌫ (Delete), ⌥⌘⌫
|
||||
// (Delete Immediately), ⇧⌘⌫ (Empty Trash…) — and AppKit routes a key equivalent to the
|
||||
// menu before the view sees it. But ⌥⌫ and ⌃⌫ are nobody's key equivalent, and a fall-through
|
||||
// that tombstoned the selection on a mistyped text-editing chord would be exactly the kind of
|
||||
// that deleted the selection on a mistyped text-editing chord would be exactly the kind of
|
||||
// accident 04-interactions.md's fixed grammar is careful to avoid.
|
||||
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
|
||||
return .ignored
|
||||
@@ -633,7 +636,7 @@ struct BoardView: View {
|
||||
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
|
||||
let selection = store.selection
|
||||
guard !selection.isEmpty else { return .ignored }
|
||||
store.deleteSelection()
|
||||
confirmations.requestDelete(in: store)
|
||||
return .handled
|
||||
}
|
||||
|
||||
@@ -860,7 +863,7 @@ struct BoardView: View {
|
||||
}
|
||||
|
||||
/// **⌥↑/⌥↓ jump to the current container's first/last card** — the lane's, or the trash
|
||||
/// quasi-lane's when that is where the cursor is.
|
||||
/// trash column's when that is where the cursor is.
|
||||
///
|
||||
/// **⌥↑ escalates into the lane domain** (04 ▸ Grammar, settled — "the keyboard's one entry to
|
||||
/// lane selection"): with the lane's first card already the sole selection, the next ⌥↑ selects
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **three views draw it**: the real face
|
||||
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||||
/// `.awaitingArrival` face), and — since the face itself is what the trash column renders — nothing
|
||||
/// else at all. 02-architecture.md ▸ TransientBoardState ▸ overlays makes the create handoff "read as
|
||||
/// one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and exact is
|
||||
/// only checkable if there is one set of numbers rather than two that happen to agree.
|
||||
enum CardFaceMetrics {
|
||||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||||
/// of the card's edge rather than a bar laid over it.
|
||||
static let cornerRadius: CGFloat = 8
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||||
static let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
// MARK: - Which side of the board a face is on
|
||||
|
||||
/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
|
||||
/// container's grammar needs.
|
||||
///
|
||||
/// 03-board-ui.md § Trash, resettled 2026-07-28: "A trashed card is an ordinary card in a special
|
||||
/// place — search, selection, rendering, styling, and clipboard all treat it exactly like any other
|
||||
/// card". So there is **one** card face in this app, and this is the whole of what differs between
|
||||
/// its two homes. Everything the two sides share — the plate, the stripe, the icon tint, the
|
||||
/// attachments chip, the selection treatment, the cut dim, the marquee registration, the drag — is
|
||||
/// shared by construction rather than by two views agreeing.
|
||||
///
|
||||
/// The three differences are all *absences on the trash side*, and each is 04-interactions.md ▸ The
|
||||
/// trash's "everything edit-shaped is disabled on trash selections" showing up as a branch that is
|
||||
/// simply not taken:
|
||||
///
|
||||
/// - **no Open** — no double-click gesture at all ("trash cards don't open — double-click stops at
|
||||
/// selection"), which is also why `openCard` is the board case's payload rather than the view's;
|
||||
/// - **no Rename** — the inline editor is board-only (`isRenaming`), so a rename that somehow
|
||||
/// targeted a trashed card would render nothing rather than open a field over it;
|
||||
/// - **no Style…** — no popover anchor, and no Style rows in the context menu.
|
||||
///
|
||||
/// Plus the two that are not about editing: Finder file drops are inert over the trash (▸ The trash),
|
||||
/// so the file-hover highlight is board-only; and the trash's context-menu Delete is *permanent*, so
|
||||
/// it needs the window's confirmation host (11-command-nexus.md ▸ Context menus' Trash cards row).
|
||||
enum CardFaceRole {
|
||||
|
||||
/// A card in a lane. Carries the board window's card opener — ⌘↩'s pointer twin
|
||||
/// (04-interactions.md ▸ Selection).
|
||||
case board(openCard: (ItemID) -> Void)
|
||||
|
||||
/// A card in `<root>/.trash/`. Carries the window's purge-alert host, because the trash's Delete
|
||||
/// is the permanent one and "confirms exactly where the loss is real" (03 § Trash).
|
||||
case trash(confirmations: TrashConfirmations)
|
||||
|
||||
/// Which container a click on this face selects in, which container its drag begins in, and which
|
||||
/// container the rubber band sweeps it as — one answer, so the three can never disagree
|
||||
/// (`SelectionGrammar`: "the container is the surface's, not the item's").
|
||||
var container: ItemContainer {
|
||||
switch self {
|
||||
case .board: .board
|
||||
case .trash: .trash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card face
|
||||
|
||||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||||
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
|
||||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
||||
///
|
||||
/// ### One face, two containers
|
||||
///
|
||||
/// **This is the trash's row too** (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized
|
||||
/// trash): "a trashed card is an ordinary card in a special place … rendering … treat it exactly like
|
||||
/// any other card". The tombstone era's compact dimmed plate is retired with the tombstones it drew;
|
||||
/// a trashed card wears its style, its stripe, its icon tint and its attachments chip exactly as it
|
||||
/// did in its lane, because it is the same card and the same view. What the trash takes away is
|
||||
/// listed on `CardFaceRole` and nowhere else.
|
||||
///
|
||||
/// ### Title-only, deliberately
|
||||
///
|
||||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
||||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
||||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
||||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
||||
///
|
||||
/// ### Two lenient fields, two different fallbacks
|
||||
///
|
||||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// ### One presentation, selection styling only
|
||||
///
|
||||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||||
/// face).
|
||||
struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
|
||||
/// Which side of the board this face is drawn on, and what that side's grammar needs — the view's
|
||||
/// one axis (`CardFaceRole`).
|
||||
let role: CardFaceRole
|
||||
|
||||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// draw this same plate for the create handoff to read as one arrival.
|
||||
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||
/// treatment shootout).
|
||||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||
|
||||
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
||||
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
||||
/// trash's no-Open/no-Rename/no-Style is expressed by code that is not written rather than by
|
||||
/// gestures that fire and refuse (`CardFaceRole`).
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch role {
|
||||
case let .board(openCard):
|
||||
face
|
||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||
//
|
||||
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
|
||||
// second tap recogniser on the same view makes the single click *wait* to see whether a
|
||||
// second one arrives, and selection must stay instant. Simultaneous means the first click
|
||||
// of the pair selects and the second opens — Finder's own behaviour.
|
||||
//
|
||||
// **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
|
||||
// a window out from under a range the user is still building would be a surprise.
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
.contextMenu { boardMenu(openCard: openCard) }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
case let .trash(confirmations):
|
||||
face
|
||||
.contextMenu { trashMenu(confirmations: confirmations) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the two containers share — which, after the pivot, is the face itself.
|
||||
private var face: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||||
// hovered card that is *also* selected still reads as the target.
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||
)
|
||||
)
|
||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
|
||||
// genuinely fires on the trash side too: "⌘X works — cut in the trash, paste into a lane is
|
||||
// the keyboard-native restore" (04 ▸ The trash, resettled 2026-07-28).
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
// The face being dragged out dims the same way while the session is in flight on the trash
|
||||
// side, where the source stays visible: a restore is not a removal until the write lands.
|
||||
// (On the board a dragged card is lifted out of the resting layout entirely, so this never
|
||||
// has anything to act on there — `LaneView.renderedCards`.)
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
.contentShape(Rectangle())
|
||||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||||
// two-stage Finder rename): one click selects and that is all it does — no timer, no
|
||||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||
//
|
||||
// **The container travels with the click**, and that is what keeps the one remaining
|
||||
// homogeneity boundary true: a ⌘-click across it replaces rather than mixing
|
||||
// (04-interactions.md ▸ The trash). A double click in the trash is two of these and nothing
|
||||
// more — no editor, no card window, no timer.
|
||||
.onTapGesture {
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, container: role.container), modifier: .current)
|
||||
}
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`). The trash side registers too: a trash card dragged out is an
|
||||
// ordinary card session, and the shadow it opens in the destination lane should be its real
|
||||
// footprint rather than the nominal guess.
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, container: role.container, in: marquee.registry)
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins this card's system drag session — in **its own container**, which is the whole of what
|
||||
/// makes a trash card's drag a restore (04-interactions.md ▸ The trash; `DragLocality.operation`).
|
||||
private func startDrag() -> NSItemProvider {
|
||||
switch role.container {
|
||||
case .board: startBoardCardDrag()
|
||||
case .trash: startTrashCardDrag()
|
||||
}
|
||||
}
|
||||
|
||||
/// A lane card's drag (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startBoardCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let ids = draggedIDs
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for member in lane.cards where ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .board,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id]
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// A trash card's drag out — **the restore**, and deliberately not special: an ordinary `.cards`
|
||||
/// session in the `.trash` container, which `BoardDropContext.commitDrop` hands to the same
|
||||
/// `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an ordinary move
|
||||
/// out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
||||
///
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||
private func startTrashCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let ids = draggedIDs
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .trash,
|
||||
items: rows.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// What travels: the whole selection when this card is in it, else this card alone — the drag's
|
||||
/// half of the context-menu targeting rule, and container-scoped like everything else.
|
||||
private var draggedIDs: Set<ItemID> {
|
||||
let selection = store.selection
|
||||
guard selection.container == role.container,
|
||||
selection.ids.contains(card.id),
|
||||
selection.ids.count > 1
|
||||
else { return [card.id] }
|
||||
return selection.ids
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == role.container && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers, and crucially no marquee registration (one built out of the
|
||||
/// live face would re-register the card's frame from inside the preview's geometry and then
|
||||
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||||
/// arrow keys).
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menus
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||||
/// menus' Card row, in its order.
|
||||
@ViewBuilder
|
||||
private func boardMenu(openCard: @escaping (ItemID) -> Void) -> some View {
|
||||
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||||
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
|
||||
// Delete below it, this row never widens to the selection; Open never opens multiple, even
|
||||
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
|
||||
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
|
||||
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
|
||||
// first — only the plain open.
|
||||
Button("Open") {
|
||||
openCard(card.id)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||||
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
|
||||
// this card to be the *sole* selection; a context menu already names its target by where it
|
||||
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
|
||||
Button("Rename") {
|
||||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
|
||||
Divider()
|
||||
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
|
||||
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
|
||||
// so this row gets it for free.
|
||||
Button("Delete") {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||||
///
|
||||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||||
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||||
/// exactly the errand it exists for.
|
||||
///
|
||||
/// The Delete row goes through the window's confirmation host rather than straight to the store,
|
||||
/// because this delete is the **permanent** one and the alert is what stands between it and an
|
||||
/// unrecoverable loss (03 § Trash; `TrashConfirmations.requestTrashDelete`).
|
||||
@ViewBuilder
|
||||
private func trashMenu(confirmations: TrashConfirmations) -> some View {
|
||||
Button("Delete") {
|
||||
confirmations.requestTrashDelete(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
}
|
||||
|
||||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
.items(targetIDs)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly. Container-scoped, so a trash row's menu never
|
||||
/// widens to a board selection and vice versa.
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.container == role.container, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
/// The folders Reveal in Finder points at — resolved in this face's container, so a trash row
|
||||
/// reveals `<root>/.trash/<uuid>` and never a lane path that no longer holds the card.
|
||||
private var targetFolders: [URL] {
|
||||
ItemPath.resolve(targetIDs, in: role.container, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
titleOrEditor
|
||||
// The title takes the row's width so the indicator sits hard against the trailing
|
||||
// edge — and so the rename field fills the same span the title occupied.
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this card is the rename target. The four exits and their
|
||||
/// store calls are 04-interactions.md ▸ Grammar's, stated once in `InlineTitleField`.
|
||||
@ViewBuilder
|
||||
private var titleOrEditor: some View {
|
||||
if isRenaming {
|
||||
InlineTitleField(
|
||||
text: draft,
|
||||
prompt: "Card title",
|
||||
onCommit: { store.commitRename() },
|
||||
onAbandon: { store.transient.discardRename() },
|
||||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||||
// the card window's title field").
|
||||
onFocusLoss: { store.commitRename() },
|
||||
onCommitAndOpen: {
|
||||
let id = card.id
|
||||
store.commitRename()
|
||||
if case let .board(openCard) = role { openCard(id) }
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
} else {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
|
||||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let color = Palette.color(for: card.iconColor) {
|
||||
AnyShapeStyle(color)
|
||||
} else {
|
||||
AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||||
}
|
||||
}
|
||||
|
||||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||||
/// content does — a title wrapping across its full four lines included.
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let color = Palette.color(for: card.background) {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||||
.fill(color)
|
||||
.frame(width: stripeWidth)
|
||||
// Decoration only: the whole plate is one click target for selection.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == role.container && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||||
/// to one implementation (`BoardDrops`).
|
||||
///
|
||||
/// **Never on the trash side**: "Finder file drops on trash cards are inert" (04-interactions.md
|
||||
/// ▸ The trash), and the trash column's own delegate clears the file highlight rather than
|
||||
/// proposing one — so this is a second, structural statement of the same rule.
|
||||
private var isFileHovered: Bool {
|
||||
role.container == .board && drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
||||
}
|
||||
|
||||
/// **Board-only** — "everything edit-shaped is disabled on trash selections … Rename"
|
||||
/// (04-interactions.md ▸ The trash). No path opens a rename on a trashed card, and this makes a
|
||||
/// stray one render nothing rather than putting a live field over a card that cannot be edited.
|
||||
private var isRenaming: Bool {
|
||||
role.container == .board && store.transient.renameEditor?.targetID == card.id
|
||||
}
|
||||
|
||||
private var draft: Binding<String> {
|
||||
Binding(
|
||||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||||
set: { store.transient.updateRenameDraft($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import SwiftUI
|
||||
// MARK: - The Edit menu's clipboard row
|
||||
|
||||
/// Edit ▸ Cut / Copy / Paste (⌘X / ⌘C / ⌘V) on the board — 11-command-nexus.md's Edit row, whose
|
||||
/// scope is "Board window: cards and lanes … in the trash, ⌘C copy-out only (card and lane entries),
|
||||
/// ⌘X disabled … text editors: standard text clipboard".
|
||||
/// scope is "Board window: cards and lanes … in the trash, ⌘C copies out and ⌘X/⌘V is the keyboard
|
||||
/// restore path … paste never targets the trash; text editors: standard text clipboard".
|
||||
///
|
||||
/// ### Why this is a responder answer and not three menu items
|
||||
///
|
||||
@@ -57,13 +57,13 @@ extension View {
|
||||
|
||||
/// **Cut items dim in place until paste moves them** (04-interactions.md ▸ Clipboard).
|
||||
///
|
||||
/// The same reduced opacity a trash row wears while it is being dragged, and for the same reason:
|
||||
/// The same reduced opacity a trash card wears while it is being dragged out, and for the same reason:
|
||||
/// the item is still there, still selectable, still the user's — it is simply spoken for. A cut
|
||||
/// item is deliberately *not* lifted out of the layout the way a dragged one is; a Finder-style
|
||||
/// deferred cut promises the board looks unchanged until the paste lands.
|
||||
///
|
||||
/// Membership is read straight off `TransientBoardState.pendingCut`, which is where the rules
|
||||
/// already live: a reload ejects a tombstoned or vanished member (so a deleted cut card undims by
|
||||
/// already live: a reload ejects a member that crossed into the trash or vanished (so a deleted cut card undims by
|
||||
/// itself), and `ClipboardStore` clears the set outright when the cut is consumed or voided.
|
||||
func cutTreatment(of id: ItemID, in store: BoardStore) -> some View {
|
||||
opacity(store.transient.pendingCut.ids.contains(id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
|
||||
@@ -48,7 +48,7 @@ struct DropTarget: Equatable, Sendable {
|
||||
// MARK: - Dropping on the trash
|
||||
|
||||
/// **Drop-on-trash deletes** (04-interactions.md ▸ The trash, settled 2026-07-28: "the drag becomes
|
||||
/// the pointer's delete gesture — release tombstones the dragged card(s), exactly the ⌫ tombstone"),
|
||||
/// the pointer's delete gesture — release moves the dragged card(s) into `.trash/`, exactly the ⌫ delete"),
|
||||
/// as the two pure facts the gesture is made of (`TrashDropTests`).
|
||||
///
|
||||
/// Kept out of the drop context so the ruling is checkable without a window, and stated once so the
|
||||
@@ -70,7 +70,7 @@ enum TrashDrop {
|
||||
/// refusal 04 states in its own words:
|
||||
///
|
||||
/// - **Lanes are not deliverable this way** — "a lane drag proposes only lane slots". (The strip's
|
||||
/// slot list has never contained the quasi-lane, so this is belt over braces; it is written down
|
||||
/// slot list has never contained the trash column, so this is belt over braces; it is written down
|
||||
/// because a guard that is only true by construction is one refactor from being false.)
|
||||
/// - **A trash card is already there.** A `.trash` session's vocabulary is restore and copy-out;
|
||||
/// dropping it back where it came from writes nothing.
|
||||
@@ -78,7 +78,7 @@ enum TrashDrop {
|
||||
/// delivered *into* this board's trash would be a transfer-and-delete compound, an operation the
|
||||
/// design gives no name and no undo story. The card stays where it is.
|
||||
/// - **⌥ is refused.** Copying into the trash is not a thing — the copy grammar promises the
|
||||
/// original stays exactly where it was, and there is nothing to tombstone but the original.
|
||||
/// original stays exactly where it was, and there is nothing to delete but the original.
|
||||
/// - **Hidden, the trash is invisible to every gesture.** True by construction too (the column is
|
||||
/// not rendered, so it has no drop region), and stated here so the claim is testable.
|
||||
/// - **The mutating-gesture rule**, like every other write the pointer can start.
|
||||
@@ -306,7 +306,7 @@ final class DragSession {
|
||||
// MARK: Where it would land
|
||||
|
||||
/// The current proposal, or `nil` when the drag has none — a fresh session before the first
|
||||
/// sample, or one whose target lane was tombstoned in a reload (rule 2 of the re-grounding
|
||||
/// sample, or one whose target lane vanished in a reload (rule 2 of the re-grounding
|
||||
/// trio). **Release with no valid proposal cancels.**
|
||||
private(set) var proposal: DropTarget?
|
||||
|
||||
|
||||
@@ -66,11 +66,11 @@ enum LaneLayoutMath {
|
||||
/// The unit total a strip of `lanes` divides across — the sum of their display units, never
|
||||
/// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
|
||||
///
|
||||
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
|
||||
/// a tombstoned lane renders nowhere on the board (03-board-ui.md § Trash collapses it to a
|
||||
/// single trash entry) and so consumes none of the window's width.
|
||||
/// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
|
||||
/// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
|
||||
/// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
|
||||
///
|
||||
/// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown**
|
||||
/// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
|
||||
/// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter,
|
||||
/// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it
|
||||
/// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
|
||||
@@ -89,7 +89,7 @@ enum LaneLayoutMath {
|
||||
/// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
|
||||
///
|
||||
/// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
|
||||
/// lane — which is where the trash quasi-lane sits. That is the whole of drag-to-restore's
|
||||
/// lane — which is where the trash column sits. That is the whole of drag-to-restore's
|
||||
/// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
|
||||
/// squarely on a live lane writes nothing rather than guessing at the nearest one.
|
||||
///
|
||||
|
||||
@@ -473,9 +473,9 @@ struct LaneView: View {
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
role: .board(openCard: openCard),
|
||||
marquee: marquee,
|
||||
drops: drops,
|
||||
openCard: openCard
|
||||
drops: drops
|
||||
)
|
||||
case let .placeholder(phase):
|
||||
NewCardStubView(store: store, phase: phase, openCard: openCard)
|
||||
@@ -487,7 +487,7 @@ struct LaneView: View {
|
||||
}
|
||||
}
|
||||
// "Appear/disappear is scale + fade (cards scale from ~0.8 …)"
|
||||
// (03-board-ui.md § Motion), which is how a create, a delete, a Put Back and
|
||||
// (03-board-ui.md § Motion), which is how a create, a delete, a restore and
|
||||
// (m5) a search filter's leavers all reach the masonry. The placeholder wears it
|
||||
// too: it is the card, one round trip early. Whether any of it *performs* is
|
||||
// decided upstream — at the reload for the real cards (`Motion.reloadAnimates`),
|
||||
@@ -636,10 +636,10 @@ struct LaneView: View {
|
||||
return result
|
||||
}
|
||||
|
||||
/// **Tombstoned cards render nowhere**, and neither do the cards of a tombstoned lane — the
|
||||
/// ancestor walk is absolute (01-storage-format.md § Deletion, 02-architecture.md's effective
|
||||
/// liveness). The lane half of that rule is `BoardView`'s, which never builds a `LaneView` for a
|
||||
/// tombstoned lane at all.
|
||||
/// **A deleted card renders nowhere, and needs no rule to**: deletion is a *move* into `.trash/`
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28), so a deleted card has physically left
|
||||
/// `lane.cards` and the trash column renders it instead. The tombstone era's ancestor walk and
|
||||
/// effective-liveness predicate are retired with the flag they read.
|
||||
///
|
||||
/// **A dragged card renders nowhere either, for as long as the session lasts.** It is lifted out
|
||||
/// of the resting layout at pickup and stays out until release *whatever the effective operation
|
||||
@@ -811,423 +811,6 @@ enum LaneSlot: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **two views draw it**: the real face
|
||||
/// (`CardFaceView`) and the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||||
/// `.awaitingArrival` face). 02-architecture.md ▸ TransientBoardState ▸ overlays makes the handoff
|
||||
/// "read as one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and
|
||||
/// exact is only checkable if there is one set of numbers rather than two that happen to agree.
|
||||
private enum CardFaceMetrics {
|
||||
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
|
||||
/// of the card's edge rather than a bar laid over it.
|
||||
static let cornerRadius: CGFloat = 8
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
|
||||
/// or not a stripe paints, so colouring a card never shifts its title.
|
||||
static let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
// MARK: - Card face
|
||||
|
||||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||||
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
|
||||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
||||
///
|
||||
/// ### Title-only, deliberately
|
||||
///
|
||||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
||||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
||||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
||||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
||||
///
|
||||
/// ### Two lenient fields, two different fallbacks
|
||||
///
|
||||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// ### One presentation, selection styling only
|
||||
///
|
||||
/// The face is a top-aligned title row and its two decorations — the accent stripe and the
|
||||
/// selection stroke — are shapes in overlays. **A card has one presentation** (resettled
|
||||
/// 2026-07-28, reversing the pathfinder's selection-keyed carousel): selection changes only this
|
||||
/// face's styling — the selection stroke below — and never its geometry, so the masonry never
|
||||
/// reflows on a click and every consumer of a card's drawn frame (the drop model's resting grid,
|
||||
/// the rubber band's sweep universe) can trust a selection change to leave it untouched. Viewing an
|
||||
/// attachment's media is the card window's job (⌘↩ / double-click, 05-card-window.md), not the
|
||||
/// face's — the paperclip chip below is the face's whole attachment story (03-board-ui.md § Card
|
||||
/// face).
|
||||
private struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
|
||||
/// The strip's rubber band — the registry this face registers its drawn frame into.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// The board window's drop machinery: this face registers its measured height into the geometry
|
||||
/// registry (the resting grid's input) and starts the card drag session from `.onDrag`.
|
||||
let drops: BoardDropContext
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// draw this same plate for the create handoff to read as one arrival.
|
||||
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||
/// treatment shootout).
|
||||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||
|
||||
var body: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
// The selection treatment, which a hovering Finder file drag borrows outright: "the card
|
||||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||||
// hovered card that is *also* selected still reads as the target.
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||
)
|
||||
)
|
||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits.
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
.contentShape(Rectangle())
|
||||
// **Clicking never edits** (04-interactions.md ▸ Selection, a pivot from the pathfinder's
|
||||
// two-stage Finder rename): one click selects and that is all it does — no timer, no
|
||||
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
|
||||
// Board ▸ Rename. The modifier grammar — plain replaces, ⌘ toggles, ⇧ ranges — is
|
||||
// `SelectionGrammar`'s, reached through the store's one funnel.
|
||||
.onTapGesture {
|
||||
store.click(SelectionTarget(id: card.id, kind: .card, container: .board), modifier: .current)
|
||||
}
|
||||
// "A fast double-click opens the card window (⌘↩'s pointer twin)" (04 ▸ Selection).
|
||||
//
|
||||
// `simultaneousGesture` rather than a second `onTapGesture(count: 2)`, deliberately: a
|
||||
// second tap recogniser on the same view makes the single click *wait* to see whether a
|
||||
// second one arrives, and selection must stay instant. Simultaneous means the first click
|
||||
// of the pair selects and the second opens — Finder's own behaviour.
|
||||
//
|
||||
// **Plain only.** ⌘ and ⇧ double-clicks are selection gestures that happened twice; opening
|
||||
// a window out from under a range the user is still building would be a surprise.
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
guard ClickModifier.current == .plain else { return }
|
||||
openCard(card.id)
|
||||
})
|
||||
// **The whole face is the drag surface** (04-interactions.md ▸ Drag and drop). `.onDrag`
|
||||
// beside the tap recognisers above is the click-versus-drag split, the system's own: it holds
|
||||
// the session off until the pointer really moves, so selecting and opening stay instant.
|
||||
.onDrag(startCardDrag, preview: { dragReplica })
|
||||
// The card's height, for the drop model's analytic resting grid. A height is content-driven
|
||||
// and does not animate under the reflow — only positions do, and those are never measured
|
||||
// (`LaneDropRegistry`).
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
|
||||
drops.registry.update(height: height, for: card.id)
|
||||
}
|
||||
.onDisappear { drops.registry.removeHeight(card.id) }
|
||||
.marqueeTarget(card.id, kind: .card, container: .board, in: marquee.registry)
|
||||
.contextMenu { cardMenu }
|
||||
.popover(isPresented: styleEditorPresentation(store, anchor: card.id), arrowEdge: .bottom) {
|
||||
StyleEditorPopover(store: store, recents: appModel.styleRecents)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The card drag
|
||||
|
||||
/// Begins the card's system drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop).
|
||||
///
|
||||
/// **Dragging any member of a multi-selection drags the whole selection**, in **flatten order** —
|
||||
/// "lane `order` first, then card `order`", `SelectionGrammar.boardCards`' single definition of
|
||||
/// it, which is also the order the drop inserts in. A card outside the selection drags alone.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture; a refusal is an item provider carrying nothing, so no session begins.
|
||||
private func startCardDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let snapshot = store.snapshot
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.container == .board
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
// Flatten order, and the lane each member currently lives in — the folder path's middle
|
||||
// component.
|
||||
var lanesByCard: [ItemID: ItemID] = [:]
|
||||
var titles: [ItemID: String] = [:]
|
||||
for lane in snapshot.lanes {
|
||||
for member in lane.cards where ids.contains(member.id) {
|
||||
lanesByCard[member.id] = lane.id
|
||||
titles[member.id] = member.title.value
|
||||
}
|
||||
}
|
||||
let ordered = SelectionGrammar.boardCards(in: snapshot).filter { ids.contains($0) }
|
||||
guard !ordered.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .board,
|
||||
items: ordered.compactMap { id in
|
||||
guard let laneID = lanesByCard[id] else { return nil }
|
||||
return DragPayload.Item(
|
||||
id: id.rawValue,
|
||||
folder: root
|
||||
.appendingPathComponent(laneID.rawValue, isDirectory: true)
|
||||
.appendingPathComponent(id.rawValue, isDirectory: true)
|
||||
.path,
|
||||
title: titles[id]
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
ordered,
|
||||
folders: payload.folders,
|
||||
// The dragged items' sizes, frozen at drag start — the pickup transition scales the
|
||||
// replica, and its lingering "last measured frame" would mis-size the shadow and the
|
||||
// span-cap (03-board-ui.md § Motion).
|
||||
heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight },
|
||||
container: .board,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image travelling under the cursor: this card's face at its real size, fanned with ghosts
|
||||
/// and a count badge when the whole multi-selection rides along (03-board-ui.md § Motion).
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { replicaFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { replicaFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
/// editor and no geometry observers.
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.lineLimit(4)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
|
||||
// MARK: - Context menu
|
||||
|
||||
/// Open, Rename, Style…, the quick-style recents row, Delete — 11-command-nexus.md ▸ Context
|
||||
/// menus' Card row, in its order, complete as of m5.
|
||||
@ViewBuilder
|
||||
private var cardMenu: some View {
|
||||
// Open: Board ▸ Open Card's pointer twin (`OpenCardCommand`), restricted to the clicked card
|
||||
// alone — "a card window is tied to one card" (11-command-nexus.md), so unlike Style… and
|
||||
// Delete below it, this row never widens to the selection; Open never opens multiple, even
|
||||
// when the clicked card is part of one. It calls the very `openCard` closure the double-click
|
||||
// gesture above uses, not `OpenCardCommand`'s mid-edit branches: there is no gesture path from
|
||||
// a focused inline editor to *this* card's context menu, so there is nothing here to commit
|
||||
// first — only the plain open.
|
||||
Button("Open") {
|
||||
openCard(card.id)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Rename: Board ▸ Rename's exact store path (`BoardRenameCommand`) — `beginRename(of:
|
||||
// currentTitle:)`, seeded with the card's live title. The menu-bar item additionally requires
|
||||
// this card to be the *sole* selection; a context menu already names its target by where it
|
||||
// was invoked, so — standard macOS practice — it acts on the clicked card outright.
|
||||
Button("Rename") {
|
||||
store.transient.beginRename(of: card.id, currentTitle: card.title.value)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
StyleMenuItems(store: store, recents: appModel.styleRecents, target: styleTarget)
|
||||
|
||||
Divider()
|
||||
|
||||
// Delete: File ▸ Delete's exact store path (`store.delete`, `TrashCommands`'s twin), on the
|
||||
// widened target set below (`targetIDs`) — the successor-selection rule is `delete(_:)`'s own,
|
||||
// so this row gets it for free.
|
||||
Button("Delete") {
|
||||
store.delete(targetIDs)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
}
|
||||
|
||||
/// What this card's menu acts on: the whole selection when this card is part of it, else this card
|
||||
/// alone — standard macOS context-menu targeting, shared by Style… (`styleTarget`) and Delete
|
||||
/// (`targetIDs`) alike. Right-clicking something outside the selection acts on what was clicked.
|
||||
private var styleTarget: StyleTarget {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return .items([card.id])
|
||||
}
|
||||
return .items(store.selection.ids)
|
||||
}
|
||||
|
||||
/// Delete's target set — the same widening `styleTarget` does, spelled as a plain `Set<ItemID>`
|
||||
/// because `store.delete(_:)` takes one directly (the context-menu targeting rule, reused here
|
||||
/// on the live side).
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.container == .board, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
titleOrEditor
|
||||
// The title takes the row's width so the indicator sits hard against the trailing
|
||||
// edge — and so the rename field fills the same span the title occupied.
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this card is the rename target. Unchanged from the
|
||||
/// stub this face replaces: the four exits and their store calls are 04-interactions.md ▸
|
||||
/// Grammar's, stated once in `InlineTitleField`.
|
||||
@ViewBuilder
|
||||
private var titleOrEditor: some View {
|
||||
if isRenaming {
|
||||
InlineTitleField(
|
||||
text: draft,
|
||||
prompt: "Card title",
|
||||
onCommit: { store.commitRename() },
|
||||
onAbandon: { store.transient.discardRename() },
|
||||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||||
// the card window's title field").
|
||||
onFocusLoss: { store.commitRename() },
|
||||
onCommitAndOpen: {
|
||||
let id = card.id
|
||||
store.commitRename()
|
||||
openCard(id)
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
} else {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
|
||||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let color = Palette.color(for: card.iconColor) {
|
||||
AnyShapeStyle(color)
|
||||
} else {
|
||||
AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||||
}
|
||||
}
|
||||
|
||||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||||
/// content does — a title wrapping across its full four lines included.
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let color = Palette.color(for: card.background) {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||||
.fill(color)
|
||||
.frame(width: stripeWidth)
|
||||
// Decoration only: the whole plate is one click target for selection.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == .board && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||||
/// to one implementation (`BoardDrops`).
|
||||
private var isFileHovered: Bool {
|
||||
drops.session.fileAttachTarget(onBoardRooted: store.rootURL) == card.id
|
||||
}
|
||||
|
||||
private var isRenaming: Bool {
|
||||
store.transient.renameEditor?.targetID == card.id
|
||||
}
|
||||
|
||||
private var draft: Binding<String> {
|
||||
Binding(
|
||||
get: { store.transient.renameEditor?.draftTitle ?? "" },
|
||||
set: { store.transient.updateRenameDraft($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The new-card placeholder
|
||||
|
||||
/// The card being created, drawn as a pseudo-card in the masonry flow at standard card width —
|
||||
@@ -1371,7 +954,7 @@ private struct NewCardStubView: View {
|
||||
/// and then the field disappears, which also fires the focus-loss handler an instant later. The
|
||||
/// store's `commitRename`/`commitPlaceholder` and the transient state's `discard…` all no-op against
|
||||
/// an editor that is already closed, so the overlap costs nothing.
|
||||
private struct InlineTitleField: View {
|
||||
struct InlineTitleField: View {
|
||||
|
||||
@Binding var text: String
|
||||
let prompt: String
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
/// > a multi-selection anchors at its last member in flatten order (lane `order`, then card
|
||||
/// > `order`, the multi-drag order; the same anchor serves paste): creation follows the last
|
||||
/// > selected card, or appends to the last selected lane; … with nothing selected — or a
|
||||
/// > **tombstoned** selection, which never anchors creation — the **last-active lane** — the lane
|
||||
/// > **trash** selection, which never anchors creation — the **last-active lane** — the lane
|
||||
/// > that most recently held selection or a creation in this window session — falling back to the
|
||||
/// > first lane. … **Zero-lane board**: card creation … disable[s] via menu validation until a
|
||||
/// > lane exists.
|
||||
@@ -59,7 +59,7 @@ enum NewCardTarget {
|
||||
if let anchor = flattenAnchor(selection: selection, snapshot: snapshot) {
|
||||
return anchor
|
||||
}
|
||||
// Nothing selected, a tombstoned selection, or a stale one — the ids name nothing the board
|
||||
// Nothing selected, a trash selection, or a stale one — the ids name nothing the board
|
||||
// renders, a selection the next reload will drop. Falls through rather than refusing: the
|
||||
// user pressed ⌘N and the board has lanes. The target is then the lane that most recently
|
||||
// held selection or a creation, and the first lane when there is no such lane (or it has
|
||||
@@ -81,7 +81,7 @@ enum NewCardTarget {
|
||||
/// rule 04 says they share.
|
||||
///
|
||||
/// `nil` covers the three cases that anchor nothing, which the callers then answer their own way:
|
||||
/// an empty selection, a **tombstoned** one ("a tombstoned selection never anchors paste",
|
||||
/// an empty selection, a **trash** one ("a trash selection never anchors paste",
|
||||
/// settled — and "a trashed card's live disk-lane never leaks in as 'the selected card's lane'",
|
||||
/// which falls out of never looking at the trashed side at all), and a stale one whose ids name
|
||||
/// nothing the board renders.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// The two rules verbatim, and each clause's branch below:
|
||||
///
|
||||
/// > Paste lands after the anchor card (or appends to a selected lane); a multi-selection anchors at
|
||||
/// > its last member in flatten order — the ⌘N target rule's shared anchor. … **A tombstoned
|
||||
/// > its last member in flatten order — the ⌘N target rule's shared anchor. … **A trash
|
||||
/// > selection never anchors paste**: ⌘V stays enabled and behaves exactly as with nothing selected —
|
||||
/// > a card payload appends to the last-active lane, a lane payload lands at the board's right end.
|
||||
///
|
||||
@@ -68,7 +68,7 @@ enum PasteTarget {
|
||||
guard let anchor = NewCardTarget.flattenAnchor(selection: selection, snapshot: snapshot),
|
||||
let position = lanes.firstIndex(where: { $0.id == anchor.laneID })
|
||||
else {
|
||||
// Nothing selected, a tombstoned selection, or a stale one: the right end.
|
||||
// Nothing selected, a trash selection, or a stale one: the right end.
|
||||
return lanes.count
|
||||
}
|
||||
return position + 1
|
||||
|
||||
@@ -52,17 +52,36 @@ final class TrashConfirmations {
|
||||
/// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
|
||||
/// nothing else — the two can never disagree about which write a ⌘⌫ performs.
|
||||
func requestDelete(in store: BoardStore) {
|
||||
guard store.selection.container == .trash, store.purgeIsUnrecoverable else {
|
||||
guard store.selection.container == .trash else {
|
||||
store.deleteSelection()
|
||||
return
|
||||
}
|
||||
requestTrashDelete(of: store.selection.ids, in: store)
|
||||
}
|
||||
|
||||
/// The **permanent** half of that staging, aimed at an explicit set — the trash card's
|
||||
/// context-menu Delete (11-command-nexus.md ▸ Context menus' Trash cards row).
|
||||
///
|
||||
/// Its own entry point because a context menu names its target by where it was invoked, not by
|
||||
/// what is selected: right-clicking a trash card while a *board* selection stands must purge the
|
||||
/// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
|
||||
/// container and silently do nothing.
|
||||
///
|
||||
/// Same alert, same rule: it "confirms exactly where the loss is real", so
|
||||
/// `purgeIsUnrecoverable` decides — and where it does not, the purge runs straight through, which
|
||||
/// is the same shrug Delete Immediately gives on a board that keeps history.
|
||||
func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
|
||||
guard store.purgeIsUnrecoverable else {
|
||||
store.deleteTrashCards(ids)
|
||||
return
|
||||
}
|
||||
guard let prompt = TrashModel.purgePrompt(
|
||||
for: store.selection.ids,
|
||||
for: ids,
|
||||
in: .trash,
|
||||
snapshot: store.snapshot,
|
||||
unrecoverable: true
|
||||
) else { return }
|
||||
pending = Pending(prompt: prompt, action: .deleteTrashCards(store.selection.ids))
|
||||
pending = Pending(prompt: prompt, action: .deleteTrashCards(ids))
|
||||
}
|
||||
|
||||
/// Raises Delete Immediately's alert — **or purges outright** where the loss is not real.
|
||||
@@ -70,6 +89,10 @@ final class TrashConfirmations {
|
||||
/// The mode check is the one thing that decides between the two, and it lives on the store as a
|
||||
/// named predicate (`BoardStore.purgeIsUnrecoverable`) so the git milestone changes one
|
||||
/// expression rather than three call sites.
|
||||
///
|
||||
/// Its one caller is File ▸ Delete Immediately, which passes the selection's own ids — which is
|
||||
/// what makes reading `store.selection.container` for the prompt correct here and wrong for a
|
||||
/// context menu (`requestTrashDelete` above exists for exactly that difference).
|
||||
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
|
||||
guard store.purgeIsUnrecoverable else {
|
||||
store.deleteImmediately(ids)
|
||||
@@ -177,15 +200,26 @@ struct TrashCommands: View {
|
||||
return TrashModel.canDeleteImmediately(selection: store.selection, in: store.snapshot)
|
||||
}
|
||||
|
||||
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for this row) — where
|
||||
/// "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash: "menu
|
||||
/// validation's non-empty reads `.trash/`, not the filtered view").
|
||||
private var canEmptyTrash: Bool {
|
||||
store?.canEmptyTrash == true
|
||||
}
|
||||
}
|
||||
|
||||
extension BoardStore {
|
||||
|
||||
/// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash… row) —
|
||||
/// where "non-empty" reads `.trash/` itself and never the filtered view (03-board-ui.md § Trash:
|
||||
/// "menu validation's non-empty reads `.trash/`, not the filtered view", so a search that hides
|
||||
/// every trash card leaves the command enabled and its confirmation still names the true count).
|
||||
///
|
||||
/// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
|
||||
/// invisible to every gesture".
|
||||
private var canEmptyTrash: Bool {
|
||||
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
|
||||
return !store.snapshot.trash.isEmpty
|
||||
///
|
||||
/// On the store rather than private to the menu row so the validation can be pinned without a
|
||||
/// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
|
||||
var canEmptyTrash: Bool {
|
||||
guard acceptsBoardMutations, transient.isTrashVisible else { return false }
|
||||
return !snapshot.trash.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+114
-289
@@ -7,14 +7,25 @@ import SwiftUI
|
||||
/// The trash column: the trailing, visually distinct column the board's `.trash/` cards live in
|
||||
/// (03-board-ui.md § Trash, resettled 2026-07-28 — the materialized trash).
|
||||
///
|
||||
/// ### A rendering of `snapshot.trash`, and a quasi-lane
|
||||
/// ### Ordinary cards, in a column that says where they are
|
||||
///
|
||||
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive: the column
|
||||
/// renders `store.snapshot.trash`, which the loader parsed with the same card parse the lanes use
|
||||
/// and sorted by `order` like any lane's children. Newest-first falls out of the ranks (every
|
||||
/// arrival mints one above the current top), so there is no timestamp sort and no entry type here at
|
||||
/// all. It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's
|
||||
/// machinery:
|
||||
/// **Its contents are ordinary cards in a special place**, so there is nothing to derive and nothing
|
||||
/// to draw differently: the column renders `store.snapshot.trash` — which the loader parsed with the
|
||||
/// same card parse the lanes use and sorted by `order` like any lane's children — through the very
|
||||
/// same `CardFaceView` a lane renders. "A trashed card is an ordinary card in a special place —
|
||||
/// search, selection, rendering, styling, and clipboard all treat it exactly like any other card"
|
||||
/// (03 § Trash), and one view is the only way to make *rendering* literally true rather than
|
||||
/// approximately so: a trashed card keeps its icon, its icon tint, its left-edge accent stripe, its
|
||||
/// attachments chip and its four-line title, because it is the same card and the same face.
|
||||
///
|
||||
/// Newest-first falls out of the ranks (every arrival mints one above the current top), so there is
|
||||
/// no timestamp sort and no entry type here at all.
|
||||
///
|
||||
/// ### What makes it a column and not a lane
|
||||
///
|
||||
/// The chrome 03 asks for, and nothing beyond it — "trailing (rightmost) position when shown,
|
||||
/// visually distinct — dimmed/hatched header, trash SF Symbol, count badge; no new-card button; not
|
||||
/// draggable, not resizable, excluded from lane reordering":
|
||||
///
|
||||
/// - it spans a **fixed one width unit** — no `width` frontmatter, no stepper, no resize handle, and
|
||||
/// the edge drag never reaches it (`LaneLayoutMath.totalUnits(of:trashUnits:)` supplies the unit,
|
||||
@@ -22,7 +33,9 @@ import SwiftUI
|
||||
/// - it is **not draggable and not reorderable** — the header carries no gesture, and it is absent
|
||||
/// from the drop proposal's slot list by construction, since `BoardView` builds that from the
|
||||
/// snapshot's lanes;
|
||||
/// - it has **no new-card button**: nothing is created in the trash.
|
||||
/// - it has **no new-card button**: nothing is created in the trash. There is deliberately no Empty
|
||||
/// Trash button either — that command's home is File ▸ Empty Trash… (⇧⌘⌫), and 11-command-nexus.md
|
||||
/// gives the column no pointer affordance of its own.
|
||||
///
|
||||
/// ### The drop it takes, and the drag it starts
|
||||
///
|
||||
@@ -34,33 +47,34 @@ import SwiftUI
|
||||
/// row**, because every arrival mints a rank above the current top.
|
||||
///
|
||||
/// The drag *out* is the restore, and it is deliberately not special: a trash card's drag is an
|
||||
/// ordinary `.cards` session in the `.trash` container, and `BoardDropContext.commitDrop` hands it
|
||||
/// to the same `moveCards`/`copyCards`/`receiveCards` every board card uses. "Restoring is an
|
||||
/// ordinary move out … there is no restore-specific machinery and no Put Back" (03 § Trash).
|
||||
/// ordinary `.cards` session in the `.trash` container (`CardFaceView.startTrashCardDrag`), and
|
||||
/// `BoardDropContext.commitDrop` hands it to the same `moveCards`/`copyCards`/`receiveCards` every
|
||||
/// board card uses. "Restoring is an ordinary move out … there is no restore-specific machinery and
|
||||
/// no Put Back" (03 § Trash).
|
||||
///
|
||||
/// **Finder file drops stay inert** — "Finder file drops on trash cards are inert" (▸ The trash) —
|
||||
/// and say so directly: the delegate clears the file highlight over the column.
|
||||
/// and say so twice: the delegate clears the file highlight over the column, and the face's hover
|
||||
/// treatment is board-only (`CardFaceRole`).
|
||||
///
|
||||
/// ### No editing in the trash
|
||||
///
|
||||
/// "No editing in the trash: trash cards don't open — double-click stops at selection; move it out
|
||||
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style… row.
|
||||
/// first." That is the face's `.trash` role: no double-tap recogniser, no rename editor, no Style…
|
||||
/// rows — absences rather than a pile of `disabled` modifiers.
|
||||
///
|
||||
/// ### What is still phase 3's
|
||||
/// ### Accessibility
|
||||
///
|
||||
/// The column renders the materialized trash correctly and its selection, drag, drop, filter and
|
||||
/// context menu all speak the new container vocabulary — but its *visual* treatment is still the
|
||||
/// tombstone era's compact dimmed plate rather than the card face 03 now implies ("a trashed card is
|
||||
/// an ordinary card in a special place"). Reworking the plate into the ordinary face, and the
|
||||
/// accessibility labelling 10-accessibility.md asks for, is the trash's own phase-3 card.
|
||||
/// "When shown, it is the last container, labeled as Trash with its count. Its cards are ordinary
|
||||
/// card elements" (10-accessibility.md, resettled 2026-07-28). The container name and value are set
|
||||
/// here; the elements inside are ordinary card faces because they *are* ordinary card faces. The full
|
||||
/// element tree — labels, values, traits, actions — is the accessibility milestone's.
|
||||
struct TrashLaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
|
||||
/// The window's purge-alert host, threaded down rather than read from the focus system: a
|
||||
/// context menu's content is built in its own host, where a `@FocusedValue` is not reliably the
|
||||
/// board window's, and the row's Delete Immediately must raise the *same* alert the menu bar's
|
||||
/// does.
|
||||
/// board window's, and the row's Delete must raise the *same* alert the menu bar's does.
|
||||
let confirmations: TrashConfirmations
|
||||
|
||||
/// The board window's drop machinery — a card's drag is an ordinary card session in the
|
||||
@@ -69,10 +83,10 @@ struct TrashLaneView: View {
|
||||
|
||||
/// The strip's rubber band. The column's empty space is its third surface, in the **trash**
|
||||
/// container — "the rubber band stays on the side it started on" (04-interactions.md ▸ The
|
||||
/// trash) — and every row registers its frame into the same registry.
|
||||
/// trash) — and every card face registers its frame into the same registry.
|
||||
let marquee: MarqueeControl
|
||||
|
||||
/// Reduce Motion, for the row transition below — 10-accessibility.md names the trash
|
||||
/// Reduce Motion, for the card transition below — 10-accessibility.md names the trash
|
||||
/// specifically ("and trash animations all get reduced variants").
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -80,18 +94,13 @@ struct TrashLaneView: View {
|
||||
/// the lanes rather than as a different kind of object.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
|
||||
private let rowSpacing: CGFloat = 6
|
||||
|
||||
/// The height a shadow row holds open. A trash row's height is content-driven (one or two title
|
||||
/// lines) and the cards being proposed have no row yet to be measured, so the shadow is drawn at
|
||||
/// the nominal single-line plate — `LaneDropRegistry`'s own answer to the same question, in this
|
||||
/// column's smaller idiom.
|
||||
private let nominalRowHeight: CGFloat = 32
|
||||
/// Between the cards — `LaneView.cardSpacing`, because these are the same cards.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header
|
||||
rows
|
||||
cards
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
@@ -103,6 +112,13 @@ struct TrashLaneView: View {
|
||||
// deepest region whatever session is in flight and a narrower target would strand the rest
|
||||
// (`TrashDropDelegate`).
|
||||
.onDrop(of: boardDropTypes, delegate: TrashDropDelegate(context: drops))
|
||||
// "The last container, labeled as Trash with its count" (10-accessibility.md ▸ Trash lane).
|
||||
// `.contain` rather than `.combine`: the cards inside are ordinary card elements and must
|
||||
// stay individually reachable — combining them would collapse the container the design asks
|
||||
// VoiceOver to enter.
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("Trash")
|
||||
.accessibilityValue(TrashModel.phrase(renderedCards.count))
|
||||
}
|
||||
|
||||
/// The cards the column shows.
|
||||
@@ -112,9 +128,24 @@ struct TrashLaneView: View {
|
||||
/// collection exactly as it narrows `LaneView.renderedCards`, and the count badge follows for
|
||||
/// free because it reads this same value. Hidden, the column renders nothing and registers
|
||||
/// nothing, so "hidden trash is invisible to search" needs no code at all.
|
||||
private var cards: [Card] {
|
||||
let filter = store.searchFilter
|
||||
return store.snapshot.trash.filter { filter.matches($0) }
|
||||
///
|
||||
/// **A card being dragged out renders here anyway**, unlike a lane's: the source stays visible in
|
||||
/// the trash while the session is in flight, dimmed by the face's own treatment, because a
|
||||
/// restore is not a removal until the write lands.
|
||||
private var renderedCards: [Card] {
|
||||
Self.rendered(store.snapshot.trash, filter: store.searchFilter)
|
||||
}
|
||||
|
||||
/// `renderedCards` as a pure function of its two inputs — `LaneView.rendered`'s trash-side twin,
|
||||
/// split out for the same reason: so the rule can be pinned without a view
|
||||
/// (`SearchFilterTests`). The column, its count badge and its marquee registration all read the
|
||||
/// property, which reads this.
|
||||
///
|
||||
/// Two of the lane's four inputs are absent, and each absence is a ruling: **no rename
|
||||
/// exemption**, because nothing renames in the trash (04 ▸ The trash), and **no drag hiding**,
|
||||
/// because a card dragged *out* of the trash stays visible in it until the write lands.
|
||||
nonisolated static func rendered(_ trash: [Card], filter: SearchFilter) -> [Card] {
|
||||
trash.filter { filter.matches($0) }
|
||||
}
|
||||
|
||||
// MARK: - The delete gesture's landing
|
||||
@@ -125,14 +156,14 @@ struct TrashLaneView: View {
|
||||
drops.session.trashProposal(onBoardRooted: store.rootURL)
|
||||
}
|
||||
|
||||
/// The rows the `VStack` lays out: the entries, with the delete gesture's shadow run opened at
|
||||
/// the top.
|
||||
/// The slots the column lays out: the cards, with the delete gesture's shadow run opened at the
|
||||
/// top.
|
||||
///
|
||||
/// The run stands until the echo reload brings the real tombstones — the committed-overlay hold
|
||||
/// keeps the arrangement the release proposed on screen for that round trip, exactly as every
|
||||
/// other container's does (`CommittedHold`).
|
||||
/// The run stands until the echo reload brings the real cards — the committed-overlay hold keeps
|
||||
/// the arrangement the release proposed on screen for that round trip, exactly as every other
|
||||
/// container's does (`CommittedHold`).
|
||||
private var slots: [TrashSlot] {
|
||||
var result = cards.map(TrashSlot.card)
|
||||
var result = renderedCards.map(TrashSlot.card)
|
||||
guard let proposal else { return result }
|
||||
let run = (0..<drops.session.shadowCount).map(TrashSlot.shadow)
|
||||
result.insert(contentsOf: run, at: min(max(0, proposal), result.count))
|
||||
@@ -145,8 +176,12 @@ struct TrashLaneView: View {
|
||||
/// (03-board-ui.md § Trash ▸ Rendering).
|
||||
///
|
||||
/// The hatching is what makes the column read as *not a lane* at a glance — the design asks for
|
||||
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. It
|
||||
/// carries no gesture at all: no selection (the quasi-lane "is never selectable as a lane"), no
|
||||
/// "visually distinct", and a lane's header is the surface this must not be mistaken for. Now
|
||||
/// that the cards inside wear their ordinary faces, this header is the *whole* of "you are
|
||||
/// looking at the trash", which is why it keeps its full treatment rather than softening.
|
||||
/// **State is never colour-alone** (10-accessibility.md): the header is hatched *plus* labeled.
|
||||
///
|
||||
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
|
||||
/// reorder drag, no context menu.
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
@@ -175,13 +210,15 @@ struct TrashLaneView: View {
|
||||
))
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
// The container carries the label and the count (see `body`), so the header itself is
|
||||
// decoration for VoiceOver rather than a second element saying the same thing.
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
/// The card count — the same collection the body renders, so the badge cannot disagree with
|
||||
/// what is on screen (`LaneView.countBadge`'s rule).
|
||||
private var countBadge: some View {
|
||||
Text("\(cards.count)")
|
||||
Text("\(renderedCards.count)")
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -190,51 +227,57 @@ struct TrashLaneView: View {
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
// MARK: - Cards
|
||||
|
||||
/// The rows, scrollable, with the navigation head kept in view.
|
||||
/// The cards, scrollable, with the navigation head kept in view.
|
||||
///
|
||||
/// **"Selection scrolls into view"** (04-interactions.md ▸ Grammar), watching the head rather
|
||||
/// than the whole selection so exactly one column responds to any one arrow — `LaneView`'s rule,
|
||||
/// on the trash side.
|
||||
private var rows: some View {
|
||||
private var cards: some View {
|
||||
ScrollViewReader { proxy in
|
||||
scrollableRows
|
||||
scrollableCards
|
||||
.onChange(of: store.transient.selectionHead) { _, head in
|
||||
guard let head, cards.contains(where: { $0.id == head }) else { return }
|
||||
guard let head, renderedCards.contains(where: { $0.id == head }) else { return }
|
||||
proxy.scrollTo(TrashSlot.identity(of: head))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var scrollableRows: some View {
|
||||
private var scrollableCards: some View {
|
||||
ScrollView(.vertical) {
|
||||
// **A plain `VStack`, deliberately not lazy.** Every row must keep its drawn frame
|
||||
// **`MasonryLayout` at one column, and a plain `VStack` deliberately not.** The trash is
|
||||
// one width unit, so its masonry is a single column — but it is the *same* layout the
|
||||
// lanes use, which is what makes the drag's make-room reflow read as positional slides
|
||||
// here exactly as it does there (DRAG-REORDER.md § The card masonry).
|
||||
//
|
||||
// Not lazy, for `LaneView`'s reason squared: every face must keep its drawn frame
|
||||
// registered in `MarqueeTargetRegistry` — the rubber band sweeps those frames and the
|
||||
// arrows navigate by them (`NavigationMath`) — and a lazy stack only builds the rows it
|
||||
// has scrolled to, so an unbuilt row is invisible to both. The constraint is affordable
|
||||
// because a trash is small: it holds one board's tombstones, and Empty Trash… exists.
|
||||
VStack(alignment: .leading, spacing: rowSpacing) {
|
||||
// because a trash is small: it holds one board's deletions, and Empty Trash… exists.
|
||||
MasonryLayout(columns: 1, spacing: cardSpacing) {
|
||||
ForEach(slots) { slot in
|
||||
Group {
|
||||
switch slot {
|
||||
case let .card(card):
|
||||
TrashCardRow(
|
||||
CardFaceView(
|
||||
store: store,
|
||||
card: card,
|
||||
confirmations: confirmations,
|
||||
drops: drops,
|
||||
registry: marquee.registry
|
||||
role: .trash(confirmations: confirmations),
|
||||
marquee: marquee,
|
||||
drops: drops
|
||||
)
|
||||
case .shadow:
|
||||
// The delete gesture's shadow, holding the topmost row open
|
||||
// (04-interactions.md ▸ The trash).
|
||||
DragShadow(cornerRadius: 6)
|
||||
// (04-interactions.md ▸ The trash). At the nominal card height: the cards
|
||||
// being proposed have no face here yet to be measured.
|
||||
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: nominalRowHeight)
|
||||
.frame(height: LaneDropRegistry.nominalCardHeight)
|
||||
}
|
||||
}
|
||||
// A row is a card, so it arrives and leaves in the card's dialect — a delete
|
||||
// A slot is a card, so it arrives and leaves in the card's dialect — a delete
|
||||
// files one in, a restore or a purge takes one out, and both halves of that pair
|
||||
// should read alike from either side of the strip. The transaction is the
|
||||
// reload's, like the lanes' (`Motion.reloadAnimates`).
|
||||
@@ -248,73 +291,32 @@ struct TrashLaneView: View {
|
||||
// the rule `BoardView` applies to the strip and `LaneView` to its masonry.
|
||||
.animation(Motion.dragReflow(reduced: reduceMotion), value: proposal)
|
||||
// `maxHeight: .infinity` here, not just `maxWidth`, is what makes the gesture surface
|
||||
// below reach the column's full height rather than stopping where the last row ends —
|
||||
// below reach the column's full height rather than stopping where the last card ends —
|
||||
// the same fix `LaneView.scrollableCards` applies to its masonry, and for the identical
|
||||
// reason: a `ScrollView` proposes its content only the height that content asks for, so a
|
||||
// view sized to fit its rows leaves the blank space beneath them un-hit-testable. "The
|
||||
// view sized to fit its cards leaves the blank space beneath them un-hit-testable. "The
|
||||
// column's gesture surface is full height" (04-interactions.md ▸ The trash, settled)
|
||||
// needs that blank space to actually belong to the view the gesture below is on.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(6)
|
||||
.contentShape(Rectangle())
|
||||
// The band's trash-side surface. It arms from the column's empty space, full height
|
||||
// (above) included, so a drag can start from the blank area below the last row exactly
|
||||
// as the board background allows on the live side — a drag begun on a row instead is that
|
||||
// row's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||
// (above) included, so a drag can start from the blank area below the last card exactly
|
||||
// as the board background allows on the live side — a drag begun on a face instead is that
|
||||
// card's drag-out, and the begin guard makes that geometric rather than a matter of
|
||||
// gesture priority (`MarqueeControl`).
|
||||
.simultaneousGesture(marquee.gesture(in: .trash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - The plate
|
||||
|
||||
/// The trash row's *appearance*, with none of anybody's behaviour — the compact, dimmed plate a
|
||||
/// tombstone wears.
|
||||
///
|
||||
/// **Two views draw it and neither may drift**: the row itself, and its own drag replica (a drag
|
||||
/// image is a snapshot, and one built out of the live plate would re-register the row's frame from
|
||||
/// inside the preview's geometry and then deregister it when the image went away — quietly stealing
|
||||
/// the row from the rubber band and the arrow keys).
|
||||
private struct TrashRowPlate: View {
|
||||
|
||||
let symbol: String
|
||||
|
||||
/// The title as written, or `nil` for an untitled card — "Untitled" is a rendering, never a value
|
||||
/// (03-board-ui.md § Card face).
|
||||
let title: String?
|
||||
|
||||
var isSelected: Bool = false
|
||||
|
||||
private let cornerRadius: CGFloat = 6
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.small)
|
||||
Text(title ?? "Untitled")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary.opacity(0.6)))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the column lays out
|
||||
|
||||
/// One row of the trash column — an entry, or one slot of the delete gesture's run.
|
||||
/// One slot of the trash column — a card, or one slot of the delete gesture's run.
|
||||
///
|
||||
/// `LaneSlot`'s smaller sibling, and keyed on the same principle: a slot's id decides whether the
|
||||
/// echo reload reads as a *swap* or as a removal and an insertion.
|
||||
/// `LaneSlot`'s smaller sibling — smaller by exactly one case, because nothing is ever created in the
|
||||
/// trash so there is no placeholder to stand in for it — and keyed on the same principle: a slot's id
|
||||
/// decides whether the echo reload reads as a *swap* or as a removal and an insertion.
|
||||
private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A card the snapshot's trash already holds.
|
||||
@@ -334,7 +336,7 @@ private enum TrashSlot: Identifiable {
|
||||
|
||||
/// A card slot's id, spelled once so `scrollTo` and the slot cannot disagree about what the
|
||||
/// scroll reader is looking for (`LaneSlot.identity(of:)`).
|
||||
static func identity(of item: ItemID) -> String { "entry:\(item.rawValue)" }
|
||||
static func identity(of item: ItemID) -> String { "trash:\(item.rawValue)" }
|
||||
}
|
||||
|
||||
// MARK: - The hatch
|
||||
@@ -359,180 +361,3 @@ private struct DiagonalHatch: Shape {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rows
|
||||
|
||||
/// One trash row: a compact, dimmed plate carrying the card's symbol and title.
|
||||
///
|
||||
/// **Face-like but not a card face.** It shares the plate, the symbol and the title, and it
|
||||
/// deliberately shares none of the face's *editing* affordances: no rename editor, no Open, no
|
||||
/// Style…. That is 03-board-ui.md's no-editing-in-the-trash rule expressed as an absence rather
|
||||
/// than as a pile of `disabled` modifiers. (Making it the ordinary card face is phase 3's — see
|
||||
/// `TrashLaneView`.)
|
||||
private struct TrashCardRow: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
let confirmations: TrashConfirmations
|
||||
let drops: BoardDropContext
|
||||
|
||||
/// Where the rubber band looks up what it is sweeping — the card face's rule, in the trash
|
||||
/// container (`View.marqueeTarget`).
|
||||
let registry: MarqueeTargetRegistry
|
||||
|
||||
var body: some View {
|
||||
plate.onDrag(startRowDrag, preview: { dragReplica })
|
||||
}
|
||||
|
||||
private var plate: some View {
|
||||
rowFace
|
||||
// The row being dragged out dims in place — the source stays visible in the trash,
|
||||
// because a restore is not a removal until the write lands.
|
||||
.opacity(drops.session.isDragging(card.id) ? ClipboardTreatment.dimmedOpacity : 1)
|
||||
// The deferred cut wears the same dim wherever it lands. It genuinely fires here now:
|
||||
// "⌘X works — cut in the trash, paste into a lane is the keyboard-native restore"
|
||||
// (04-interactions.md ▸ The trash, resettled 2026-07-28).
|
||||
.cutTreatment(of: card.id, in: store)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { select() }
|
||||
.marqueeTarget(card.id, kind: .card, container: .trash, in: registry)
|
||||
.contextMenu { menu }
|
||||
}
|
||||
|
||||
/// This card as the shared plate draws it — appearance only, no gesture, no context menu and
|
||||
/// crucially no marquee registration, which is what makes it safe for the drag replica to render
|
||||
/// (see `TrashRowPlate`).
|
||||
private var rowFace: some View {
|
||||
TrashRowPlate(
|
||||
symbol: ItemSymbol.name(card.icon, fallback: ItemSymbol.card),
|
||||
title: card.title.value,
|
||||
isSelected: isSelected
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Selection
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// A click selects this card in the **trash** container, through the same grammar the board's
|
||||
/// surfaces use — plain replaces, ⌘ toggles, ⇧ ranges (`SelectionGrammar`).
|
||||
///
|
||||
/// The container travels with the click, and that is what keeps the one remaining homogeneity
|
||||
/// boundary true: a ⌘-click across it replaces rather than mixing (04-interactions.md ▸ The
|
||||
/// trash). There is no kind axis inside the trash any more — lanes are never trashed. No
|
||||
/// `togglesOnRepeat` — click-again-to-unselect is the lane's behaviour, not a card's.
|
||||
///
|
||||
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
|
||||
private func select() {
|
||||
store.click(
|
||||
SelectionTarget(id: card.id, kind: .card, container: .trash),
|
||||
modifier: .current
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Drag out
|
||||
|
||||
/// Begins the card's drag out of the trash — an ordinary **card session in the trash container**,
|
||||
/// which is the whole of what makes it a restore (04-interactions.md ▸ The trash;
|
||||
/// `DragLocality.operation`).
|
||||
///
|
||||
/// Where it lands is the ordinary drop model's answer: `DropSlotMath.cardSlot` over the
|
||||
/// destination lane's masonry, so "an ordinary move to the drop position" is the same arithmetic
|
||||
/// every other card drop uses, committed by the same `moveCards`.
|
||||
///
|
||||
/// **Multi-drag carries the whole trash selection**, in the column's own order — the order the
|
||||
/// rows are drawn in, which is `order` ascending like any lane's.
|
||||
///
|
||||
/// Refused under the read-only lock and while an inline editor is focused, like every other
|
||||
/// mutating gesture.
|
||||
private func startRowDrag() -> NSItemProvider {
|
||||
guard !store.isReadOnly, !store.isEditingInline else { return NSItemProvider() }
|
||||
let selection = store.selection
|
||||
let ids: Set<ItemID> = selection.container == .trash
|
||||
&& selection.ids.contains(card.id)
|
||||
&& selection.ids.count > 1
|
||||
? selection.ids
|
||||
: [card.id]
|
||||
|
||||
let rows = store.snapshot.trash.filter { ids.contains($0.id) }
|
||||
guard !rows.isEmpty else { return NSItemProvider() }
|
||||
|
||||
let root = store.rootURL
|
||||
let payload = DragPayload(
|
||||
boardRoot: root,
|
||||
kind: .cards,
|
||||
container: .trash,
|
||||
items: rows.map {
|
||||
DragPayload.Item(
|
||||
id: $0.id.rawValue,
|
||||
folder: ItemPath.trashCard($0.id).folder(under: root).path,
|
||||
title: $0.title.value
|
||||
)
|
||||
}
|
||||
)
|
||||
drops.session.beginCards(
|
||||
rows.map(\.id),
|
||||
folders: payload.folders,
|
||||
heights: rows.map { _ in LaneDropRegistry.nominalCardHeight },
|
||||
container: .trash,
|
||||
source: store
|
||||
)
|
||||
return payload.itemProvider()
|
||||
}
|
||||
|
||||
/// The image under the cursor: the row as it is drawn, fanned with a count badge for a
|
||||
/// multi-drag — the card replica's treatment, at a trash row's size.
|
||||
private var dragReplica: some View {
|
||||
let count = store.selection.container == .trash && store.selection.ids.contains(card.id)
|
||||
? max(1, store.selection.ids.count)
|
||||
: 1
|
||||
return ZStack {
|
||||
if count > 2 { rowFace.offset(x: 10, y: 10).opacity(0.45) }
|
||||
if count > 1 { rowFace.offset(x: 5, y: 5).opacity(0.7) }
|
||||
rowFace
|
||||
}
|
||||
.frame(width: 200)
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
// MARK: - The trash card's context menu
|
||||
|
||||
/// Delete and Reveal in Finder — the two rows 11-command-nexus.md gives a trash card, and no
|
||||
/// others ("Trash cards | Delete (permanent — 03's recoverability confirm), Reveal in Finder").
|
||||
///
|
||||
/// **Put Back is gone** with the tombstone model: restoring is drag-out or ⌘X/⌘V (03 § Trash).
|
||||
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
|
||||
/// enabled on trash selections", read-only lock included — inspecting a folder before a purge is
|
||||
/// exactly the errand it exists for.
|
||||
@ViewBuilder
|
||||
private var menu: some View {
|
||||
Button("Delete") {
|
||||
confirmations.requestPurge(of: targetIDs, in: store)
|
||||
}
|
||||
.disabled(!store.acceptsBoardMutations)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting(targetFolders)
|
||||
}
|
||||
}
|
||||
|
||||
/// What this row's menu acts on: the whole selection when this row is part of it, else this row
|
||||
/// alone — standard macOS context-menu targeting, and the same rule the card face and the lane
|
||||
/// header apply to Style….
|
||||
private var targetIDs: Set<ItemID> {
|
||||
guard store.selection.container == .trash, store.selection.ids.contains(card.id) else {
|
||||
return [card.id]
|
||||
}
|
||||
return store.selection.ids
|
||||
}
|
||||
|
||||
private var targetFolders: [URL] {
|
||||
ItemPath.resolve(targetIDs, in: .trash, snapshot: store.snapshot)
|
||||
.map { $0.folder(under: store.rootURL) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,20 +65,20 @@ struct CardStyleSection: View {
|
||||
// MARK: - Actions
|
||||
|
||||
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions):
|
||||
/// **Delete** — tombstones the card — and **Reveal in Finder** — the card's folder.
|
||||
/// **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 ⌫ tombstone exactly (same write op, same
|
||||
/// bracket, same stamps). It does not close this window: the card's tombstone rounds back through
|
||||
/// 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 ("tombstones the card; the window then dismisses itself").
|
||||
/// pair ("moves the card to the trash; the window then dismisses itself").
|
||||
///
|
||||
/// Recovery is the board's trash quasi-lane, which is why this needs no confirmation: the row is
|
||||
/// still there to Put Back, and 03-board-ui.md reserves the alert for the purge that isn't
|
||||
/// recoverable.
|
||||
/// 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
|
||||
///
|
||||
|
||||
@@ -46,9 +46,9 @@ struct CardWindowView: View {
|
||||
/// 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.
|
||||
/// 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
|
||||
|
||||
@@ -154,7 +154,7 @@ enum Motion {
|
||||
reduced ? .crossfade : .scaleAndFade(from: AppearScale.lane)
|
||||
}
|
||||
|
||||
/// A card arriving or leaving — a create, a delete, a Put Back, and the search filter's leavers
|
||||
/// A card arriving or leaving — a create, a delete, a restore, and the search filter's leavers
|
||||
/// and arrivers ("Search-hiding rides the same structural transition — hiding is removal, not a
|
||||
/// special fade"). The trash's rows wear it too: they are cards, and 10 requires the trash
|
||||
/// animations to have a reduced variant like everything else.
|
||||
@@ -162,7 +162,7 @@ enum Motion {
|
||||
cardAppearance(reduced: reduced).transition
|
||||
}
|
||||
|
||||
/// A lane arriving or leaving — and the trash quasi-lane joining or leaving the width division,
|
||||
/// A lane arriving or leaving — and the trash column joining or leaving the width division,
|
||||
/// which is lane-shaped and reads as one.
|
||||
static func laneTransition(reduced: Bool) -> AnyTransition {
|
||||
laneAppearance(reduced: reduced).transition
|
||||
|
||||
Reference in New Issue
Block a user