Build the trash quasi-lane

Deletion becomes a two-stage, Finder-style story. File > Delete and
plain Backspace tombstone the live selection; View > Show Trash (no
chord — Shift-Cmd-T stays with the system's tab bar) reveals the
quasi-lane: trailing, one fixed width unit consumed only while shown,
hatched dimmed header, count badge, no new-card button, exempt from
resize and reorder alike. Its contents are a pure view over the
snapshot — the deterministic sort (deleted newest first, folder-name
ties, unparseable stamps oldest) interleaves card rows with a
tombstoned lane's single entry, whose count names what Put Back
returns; the ancestor walk is absolute, so an own-flag card beneath a
tombstoned lane has no row and recovery is deliberately two steps.
Put Back twins Delete on Cmd-Backspace with validation enabling
exactly one; restore fidelity is byte-perfect because nothing ever
moved. Delete Immediately confirms exactly where loss is real (every
board is mode-none today; the predicate names the git carve-out for
m7), Empty Trash always confirms with the true whole-board count,
and dragging a tombstoned card onto a live lane restores it there —
positional drops and cross-board locality arrive with m5's machinery.
The banner's delete phrasing drops "move to the trash" per the naming
constraint: board deletion says Delete, "Move to Trash" stays
reserved for the system Trash. 47 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-27 15:38:34 -04:00
parent aa6aaf2a11
commit 2090d742b9
14 changed files with 2428 additions and 18 deletions
+9 -1
View File
@@ -39,6 +39,11 @@ struct BoardWindowHost: View {
/// menu item through the focus system, like the store.
@State private var boardInfo = BoardInfoPresentation()
/// This window's purge alert, open or not (03-board-ui.md § Trash). `@State` for `boardInfo`'s
/// reason and reaching the menu bar the same way: Delete Immediately and Empty Trash are
/// menu-bar items, and a menu item cannot present anything of its own.
@State private var trashConfirmations = TrashConfirmations()
@State private var phase: Phase = .opening
private enum Phase {
@@ -80,15 +85,18 @@ struct BoardWindowHost: View {
BoardView(
store: store,
window: { windowController.window },
confirmations: trashConfirmations,
openCard: { cardID in
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
}
)
}
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
// beside it the window's own popover flag, which is what File Board Info toggles.
// beside it the window's own popover flag, which is what File Board Info toggles, and
// its purge-alert host, which the trash's two confirmed commands raise.
.focusedSceneValue(\.boardStore, store)
.focusedSceneValue(\.boardInfo, boardInfo)
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
}
}
+14 -2
View File
@@ -108,8 +108,9 @@ struct KanbanApp: App {
@CommandsBuilder
private var menuCommands: some Commands {
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board,
// still owed), Open, (Open Recent, still owed), Board Info. The creation pair and Board
// Info all validate against the frontmost board through the focus system, so each is simply
// still owed), Open, (Open Recent, still owed), Board Info, (Duplicate / Save as Template /
// Reveal in Finder, still owed), then the trash trio and Empty Trash. Every one of them
// validates against the frontmost board through the focus system, so each is simply
// absent-of-effect when no board is in front.
CommandGroup(after: .newItem) {
BoardCreationCommands()
@@ -124,6 +125,17 @@ struct KanbanApp: App {
Divider()
BoardInfoCommand()
Divider()
TrashCommands()
}
// The View menu. `CommandGroupPlacement.toolbar` *is* View the menu the toolbar's own
// items live in which is where 11-command-nexus.md files Show Trash, alongside the card
// window's Edit Body / Raw Source / History still to come.
CommandGroup(after: .toolbar) {
ShowTrashCommand()
}
// The Board menu (11-command-nexus.md), in its inventoried order Rename, then Style,
+8 -4
View File
@@ -422,9 +422,13 @@ public final class BannerCenter {
///
/// Titles are quoted where the operation carries one and the phrasing stays graceful where it
/// 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 are
/// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash,
/// Put Back, Delete Immediately.
/// "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
/// '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 {
switch operation {
case .createBoard:
@@ -440,7 +444,7 @@ public final class BannerCenter {
case let .copy(title):
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
case let .delete(title):
if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" }
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):
+182
View File
@@ -1070,6 +1070,188 @@ public final class BoardStore {
}
}
// MARK: - The trash
/// Whether physically removing an item on this board destroys the only copy of it and
/// therefore whether Delete Immediately stands an alert between one keystroke and unrecoverable
/// deletion (03-board-ui.md § Trash, "Delete Immediately confirms exactly where the loss is
/// real").
///
/// **Every board is `true` today**, because every board is history mode *none*: nothing in the
/// app keeps a second copy, so a purge is final everywhere.
///
// m7-git: git boards answer `false` here "on git boards it acts immediately, since the content
// remains reachable in history" (06-history-undo.md's delete-never-forgets). Repo-nested boards
// stay `true` alongside mode none: the app manages no history for them either. The named
// predicate exists now so the committer card changes one expression rather than hunting the
// confirmation logic out of two menu items and an alert.
public var purgeIsUnrecoverable: Bool { true }
/// Tombstones the current selection File Delete and its plain- grammar twin
/// (04-interactions.md The map, 11-command-nexus.md).
///
/// A convenience over `delete(_:)` so the two call sites cannot disagree about *what* the
/// command acts on.
public func deleteSelection() {
delete(selection.ids)
}
/// Tombstones every live item in `ids` cards or lanes, in one bracket.
///
/// **One `performWrite` whatever the set's size**, matching the style batch's rule and for its
/// reason: one gesture, one app-mediated reload, and (on git boards) one commit rather than N.
/// A lane's tombstone rewrites only the lane's own `index.md` hiding the subtree is the
/// renderer's ancestor walk, not a stored flag (`BoardWriter.deleteItem`).
///
/// **Tombstoned ids are silently skipped**, not refused: the paths are resolved on the live side
/// only, so a selection the next reload will drop writes nothing rather than re-stamping a
/// `deleted:` that is already there. An empty resolution never opens the bracket at all.
///
/// The selection is **cleared**, not moved to a successor. 04-interactions.md The map asks for
/// the Finder-style successor sibling ("repeated walks down a lane"), which needs the
/// navigation order the keyboard grammar defines that is m5's card. Clearing is the honest
/// interim: what was selected renders nowhere now, and the reload's resolve rule would empty the
/// set a moment later anyway.
public func delete(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .live, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.deleteItem(at: folder)
}
}
// m5-keyboard: the successor-selection grammar replaces this line.
clearSelection()
}
/// Put Back: removes `deleted:` from every tombstoned item in `ids`, in one bracket
/// (03-board-ui.md § Trash).
///
/// **Restore fidelity is perfect because nothing ever moved** the item re-enters the visible
/// set at its recorded `order` among its current siblings, and the folder is exactly where it
/// has been all along (`BoardWriter.restoreItem`).
///
/// **Putting back a lane splits its contents by flag for free.** The write is the lane's own
/// `index.md` and nothing else, so cards hidden *with* the lane return with it while cards
/// carrying their own `deleted:` stay tombstoned and their rows reappear in the trash, which
/// is exactly the two-step recovery the design settled on.
///
/// A card whose lane is itself tombstoned cannot be reached this way through the UI (it has no
/// row `TrashModel.entries`), but the path resolution admits it, and the outcome is the pure
/// view's honest one: the key is removed, and the card still renders nowhere because its lane
/// is still tombstoned. Putting the lane back then shows it.
///
/// The selection is deliberately left alone: the restored items flip liveness, and the reload's
/// resolve rule ejects them from a `.trashed` set as a vanish the same silent shrink an
/// external restore would produce.
public func putBack(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.restoreItem(at: folder)
}
}
}
/// Delete Immediately : physically removes every tombstoned item in `ids` (03-board-ui.md §
/// Trash), in one bracket.
///
/// **The confirmation is not here.** Whether the loss is real is `purgeIsUnrecoverable`'s
/// question and the alert is the window's; a store method that put up its own dialog could not
/// be driven from a test, and the same purge is reached by two surfaces (the menu item and the
/// trash row's context menu) that must not each grow their own copy of the rule.
///
/// Purging a **lane** takes its whole folder every card inside it, tombstoned or not. That is
/// what the lane entry subsuming its subtree means on disk.
public func deleteImmediately(_ ids: Set<ItemID>) {
let folders = TrashModel.paths(of: ids, on: .trashed, in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.purgeItem(at: folder)
}
}
// Nothing the set named exists any more, on either side of the boundary unlike Put Back,
// where the items merely changed sides, there is no vanish for the reload to notice on the
// trashed side that would not equally be a vanish here.
clearSelection()
}
/// Empty Trash : purges **every** tombstone on the board, in one bracket.
///
/// **Whole-trash scope, search-independent** (03-board-ui.md § Trash, settled): the targets come
/// from the snapshot, never from the filtered view "a bulk command about the trash itself never
/// silently narrows to the visible subset". The filter does not reach this method at all, which
/// is the strongest form of that guarantee.
///
/// Cards carrying their own `deleted:` under a tombstoned lane go too, without being listed:
/// they live inside the lane folder this removes (`TrashModel.emptyTrashTargets`).
public func emptyTrash() {
let folders = TrashModel.emptyTrashTargets(in: snapshot).map { $0.folder(under: rootURL) }
guard !folders.isEmpty else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
for folder in folders {
try BoardWriter.purgeItem(at: folder)
}
}
clearSelection()
}
/// Drag-to-restore: a tombstoned card row dropped over a live lane comes back **into that lane**
/// (03-board-ui.md § Trash, 04-interactions.md The trash).
///
/// Two writes in **one bracket**, and the order is load-bearing: `restoreItem` first the folder
/// is still where the trash row said it was then, only when the destination differs, the move.
/// Doing it the other way round would have the second call chasing a folder the first one had
/// already relocated.
///
/// **Same lane is a plain Put Back**: the key is removed and nothing else is touched, so the card
/// returns at its recorded `order` rather than at the bottom. "Folder moved only if the
/// destination lane differs" is the design's own wording, and the position-perfect restore is the
/// point of the trash being a pure view.
///
// m5-drag: two things arrive with the drag card's `DropSlot` port. (1) The **positional** drop
// the design restores "at the drop position", and the append below is the interim; the rank comes
// from `Ranks.insertionRank` over the destination's visible cards, exactly as `commitPlaceholder`
// computes it. (2) **Cross-board locality** a drop on another board is a live *copy* by
// default with the tombstoned original staying put, and -drag forces the true restore-move.
// Both need the drag controller's target vocabulary; this method is deliberately within-board.
///
/// Silent no-ops, all of them the reload being the authority rather than this gesture: a
/// destination lane that is gone or tombstoned, a card that is not a trash row (its own flag
/// unset, or its lane tombstoned so it has no row to drag), and an id that names nothing.
public func restoreByDrag(cardID: ItemID, intoLane laneID: ItemID) {
guard snapshot.lanes.contains(where: { $0.id == laneID && !$0.isDeleted }),
let source = snapshot.lanes.first(where: { lane in
!lane.isDeleted && lane.cards.contains { $0.id == cardID && $0.isDeleted }
})
else { return }
let root = rootURL
let cardFolder = TrashModel.ItemPath(laneID: source.id, cardID: cardID).folder(under: root)
let destination = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
let crossesLanes = source.id != laneID
try? performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.restoreItem(at: cardFolder)
guard crossesLanes else { return }
// `order: nil` is the Writer's own append computed over the destination's *visible*
// siblings, which the arriving card is not yet among.
_ = try BoardWriter.moveItem(
at: cardFolder,
toParent: destination,
sourceBoardRoot: root,
destinationBoardRoot: root,
order: nil
)
}
}
// MARK: - Selection (delegated)
// The thin pass-throughs to `transient`, and the only ones.
+396
View File
@@ -0,0 +1,396 @@
import Foundation
// MARK: - TrashEntry
/// One row of the trash quasi-lane (03-board-ui.md § Trash).
///
/// **Two cases, not one**, because a tombstoned lane is not a tombstoned card wearing a different
/// symbol: it is "a single restorable entry" that *subsumes* everything beneath it, and Put Back on
/// it "returns [the lane] whole, cards and all" (04-interactions.md The trash). The card count it
/// carries is part of the entry rather than something the row re-derives, because the rule for what
/// that number means is subtle enough to want one home see `TrashModel.entries(of:)`.
///
/// The card case carries its lane's id for the same reason `BoardStore.liveItem` returns two
/// components rather than a URL: the row's folder is `<root>/<lane>/<card>`, and the root is the
/// store's to supply a mid-session folder rename may have moved it.
public enum TrashEntry: Identifiable, Sendable, Equatable {
/// A card carrying its own `deleted:` under a **live** lane. A card whose lane is tombstoned
/// never becomes one of these see `TrashModel.entries(of:)`'s ancestor walk.
case card(Card, laneID: ItemID)
/// A tombstoned lane, and how many of its cards Put Back would return to the board.
case lane(Lane, returningCardCount: Int)
public var id: ItemID {
switch self {
case let .card(card, _): card.id
case let .lane(lane, _): lane.id
}
}
/// The title as written, or `nil` for an untitled item "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face).
public var title: String? {
switch self {
case let .card(card, _): card.title.value
case let .lane(lane, _): lane.title.value
}
}
/// Which kind of row this is the axis 04-interactions.md The trash makes a selection
/// homogeneous over ("a selection never mixes card entries and lane entries").
public var isLaneEntry: Bool {
if case .lane = self { return true }
return false
}
/// The `icon` field this row renders, so the row's symbol obeys the same lenient rule the board
/// face does (`ItemSymbol`).
public var icon: FieldValue<String> {
switch self {
case let .card(card, _): card.icon
case let .lane(lane, _): lane.icon
}
}
/// Where this row's folder sits under the board root.
public var path: TrashModel.ItemPath {
switch self {
case let .card(card, laneID): TrashModel.ItemPath(laneID: laneID, cardID: card.id)
case let .lane(lane, _): TrashModel.ItemPath(laneID: lane.id, cardID: nil)
}
}
}
// MARK: - TrashModel
/// The trash quasi-lane's contents, as a pure function of a snapshot (`TrashModelTests`)
/// 03-board-ui.md § Trash's Contents rules with nothing else mixed in.
///
/// **Pure because the trash is a pure view.** "Tombstoned cards keep their `deleted:` key and stay
/// exactly where they are on disk; nothing about the storage schema is trash-specific", so there is
/// no trash *state* anywhere only this derivation, re-run against whatever snapshot is current.
/// A reload therefore rebuilds the rows for free, exactly as it rebuilds the lanes.
///
/// The three rules it owns, each of which the design settles explicitly:
///
/// 1. **The absolute ancestor walk.** A tombstoned lane's entry subsumes everything beneath it "a
/// card that carries its own `deleted:` under a tombstoned lane has **no row of its own**". There
/// is no trash carve-out from 01-storage-format.md's consumer rule.
/// 2. **The returning count.** A lane entry's number "counts what Put Back returns to the board
/// cards without their own flag; individually tombstoned descendants aren't in that number, since
/// they come back to the *trash*".
/// 3. **The deterministic sort.** Newest `deleted` first; ties by folder name ascending; an
/// unparseable stamp sorts as *oldest*, after every dated entry, folder-name-ordered among its
/// kind; lane entries interleave in the same single ordering by their own stamp. The order is
/// load-bearing for input "arrow walks, -ranges, and the rubber band all read it" so it is
/// total, not merely stable.
public enum TrashModel {
// MARK: - Where a row lives
/// An item's folder, as its identity components rather than as a URL.
///
/// Same shape and same reasoning as `BoardStore.liveItem`'s return: the caller builds the URL off
/// the store's *current* `rootURL`, so a board renamed or moved mid-session writes at the new
/// location (02-architecture.md § Write-failure surfacing).
public struct ItemPath: Sendable, Equatable {
public let laneID: ItemID
/// `nil` for a lane the path is then the lane folder itself.
public let cardID: ItemID?
public init(laneID: ItemID, cardID: ItemID?) {
self.laneID = laneID
self.cardID = cardID
}
public var isLane: Bool { cardID == nil }
/// This path resolved under a board root.
public func folder(under root: URL) -> URL {
var url = root.appendingPathComponent(laneID.rawValue, isDirectory: true)
if let cardID {
url.append(component: cardID.rawValue, directoryHint: .isDirectory)
}
return url
}
}
// MARK: - Entries
/// The trash's rows, in the order the quasi-lane shows them.
///
/// The walk is one level deep because the tree is (01-storage-format.md § Fractal layout), and
/// the branch on `lane.isDeleted` *is* the ancestor walk: a tombstoned lane contributes exactly
/// one entry and its cards contribute none, whatever their own flags say.
public static func entries(of snapshot: BoardModel) -> [TrashEntry] {
var rows: [Row] = []
for lane in snapshot.lanes {
if lane.isDeleted {
// Rule 2: only the cards *without* their own flag come back with the lane. The ones
// that carry a flag stay tombstoned and get their rows back in the trash which is
// why Put Back on such a card is deliberately two steps.
let returning = lane.cards.filter { !$0.isDeleted }.count
rows.append(Row(
entry: .lane(lane, returningCardCount: returning),
deleted: lane.deleted.value,
name: lane.id.rawValue
))
continue
}
for card in lane.cards where card.isDeleted {
rows.append(Row(
entry: .card(card, laneID: lane.id),
deleted: card.deleted.value,
name: card.id.rawValue
))
}
}
return rows.sorted(by: isOrdered).map(\.entry)
}
/// Whether the board has anything in its trash at all the "non-empty" half of Empty Trash's
/// menu validation, which "reads the board's tombstones, not the filtered view" (03 Trash).
///
/// Cheaper than building the entries and used where only the answer matters.
public static func isEmpty(_ snapshot: BoardModel) -> Bool {
!snapshot.lanes.contains { lane in
lane.isDeleted || lane.cards.contains(where: \.isDeleted)
}
}
/// One entry's sort inputs, kept beside it so the comparator never re-reads the model.
private struct Row {
let entry: TrashEntry
/// The parsed `deleted` stamp, or `nil` when the value is present but unparseable
/// 01-storage-format.md's unusable-timestamp rule, which still deletes (presence, not
/// validity) but supplies no position in time.
let deleted: Date?
/// The folder name, byte-for-byte the tie-break the loader's display order already uses
/// (`Ranks.sortedForDisplay`, `name: { $0.id.rawValue }`), so the trash breaks ties the same
/// way the board does.
let name: String
}
/// The sort, stated once: newest first among dated entries, then every undated entry.
///
/// **Undated sorts oldest, not first.** "A corrupt stamp must not outrank fresh deletions for the
/// trash's most prominent rows" so an unparseable value loses to every real timestamp, however
/// old, and orders by folder name among its own kind.
private static func isOrdered(_ lhs: Row, _ rhs: Row) -> Bool {
switch (lhs.deleted, rhs.deleted) {
case let (left?, right?):
return left == right ? lhs.name < rhs.name : left > right
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
return lhs.name < rhs.name
}
}
// MARK: - Paths for the trash's writes
/// The folders `ids` names, in display order, restricted to one **effective** liveness side.
///
/// The universe rule is `ItemReferenceSet.idUniverse`'s, spelled here in terms of paths rather
/// than ids because a write needs to know *where* the item is: a lane contributes itself on the
/// side its own flag names, and a card on the side `lane.isDeleted || card.isDeleted` names.
///
/// Display order lanes left to right, each lane then its cards rather than the caller's set
/// iteration order, which is not an order at all: a batch that fails partway must fail the same
/// way twice (`BoardStore.styleSubjects` makes the same choice for the same reason).
public static func paths(of ids: Set<ItemID>, on side: Liveness, in snapshot: BoardModel) -> [ItemPath] {
guard !ids.isEmpty else { return [] }
var result: [ItemPath] = []
for lane in snapshot.lanes {
let laneSide = Liveness(isDeleted: lane.isDeleted)
if laneSide == side, ids.contains(lane.id) {
result.append(ItemPath(laneID: lane.id, cardID: nil))
}
for card in lane.cards
where Liveness(isDeleted: lane.isDeleted || card.isDeleted) == side && ids.contains(card.id) {
result.append(ItemPath(laneID: lane.id, cardID: card.id))
}
}
return result
}
/// Every folder Empty Trash removes "emptying purges every tombstone on the board, filter or
/// no filter" (03 Trash).
///
/// **A tombstoned lane contributes only itself**, and that is not an omission: removing the lane
/// folder removes the tree beneath it, own-flag cards included. Listing those cards as well would
/// be redundant purges of paths the first removal already took (harmless `purgeItem` treats a
/// folder that is already gone as success but noise).
public static func emptyTrashTargets(in snapshot: BoardModel) -> [ItemPath] {
var result: [ItemPath] = []
for lane in snapshot.lanes {
if lane.isDeleted {
result.append(ItemPath(laneID: lane.id, cardID: nil))
continue
}
for card in lane.cards where card.isDeleted {
result.append(ItemPath(laneID: lane.id, cardID: card.id))
}
}
return result
}
// MARK: - Counts and phrasing
/// How many lane entries and card entries a set of entries holds the confirmation dialogs'
/// only input beyond the item titles.
public struct EntryCounts: Sendable, Equatable {
public var lanes: Int = 0
public var cards: Int = 0
public var total: Int { lanes + cards }
public var isEmpty: Bool { total == 0 }
}
public static func counts(of entries: [TrashEntry]) -> EntryCounts {
var counts = EntryCounts()
for entry in entries {
if entry.isLaneEntry { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// The counts a set of `ItemPath`s describes the same two numbers, from the shape the write
/// path actually carries.
public static func counts(of paths: [ItemPath]) -> EntryCounts {
var counts = EntryCounts()
for path in paths {
if path.isLane { counts.lanes += 1 } else { counts.cards += 1 }
}
return counts
}
/// "2 lanes and 3 cards", "41 cards", "1 lane" 06-history-undo.md's **plural folding** applied
/// to a mixed trash selection.
///
/// An empty count reads "nothing", which no caller renders: both confirmations refuse to open on
/// an empty scope. It is spelled anyway so the function is total.
public static func phrase(_ counts: EntryCounts) -> String {
switch (counts.lanes, counts.cards) {
case (0, 0): "nothing"
case let (0, cards): plural(cards, "card")
case let (lanes, 0): plural(lanes, "lane")
case let (lanes, cards): "\(plural(lanes, "lane")) and \(plural(cards, "card"))"
}
}
private static func plural(_ count: Int, _ noun: String) -> String {
"\(count) \(noun)\(count == 1 ? "" : "s")"
}
// MARK: - Confirmations
/// A purge confirmation's three strings, built once and rendered by the window's alert.
///
/// A value rather than a view so the phrasing rules plural folding, naming a sole item, the
/// lane caveat, and whether the loss is actually irreversible are testable without an alert on
/// screen (`TrashModelTests`).
public struct PurgePrompt: Sendable, Equatable {
public let title: String
public let message: String
public let confirmTitle: String
}
/// Delete Immediately's alert "the alert stands between one keystroke and unrecoverable
/// deletion" (03-board-ui.md § Trash).
///
/// A sole item is **named**; several fold into counts. `nil` when the ids name nothing
/// tombstoned, which is also the command's own refusal so the prompt and the action can never
/// disagree about whether there is anything to purge.
public static func purgePrompt(
for ids: Set<ItemID>,
in snapshot: BoardModel,
unrecoverable: Bool
) -> PurgePrompt? {
let targets = paths(of: ids, on: .trashed, in: snapshot)
guard !targets.isEmpty else { return nil }
let counts = counts(of: targets)
let subject: String
if targets.count == 1, let only = targets.first {
subject = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
} else {
subject = phrase(counts)
}
return PurgePrompt(
title: "Permanently delete \(subject)?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
}
/// Empty Trash's alert **always shown** ("bulk scope, not per-item recoverability, is what it
/// guards"), and always naming the **true count**: every tombstone on the board, never the
/// filtered view.
///
/// Counts rather than names even for a single entry, because the command is about the trash
/// rather than about an item: "Permanently delete 41 cards" is the design's own example phrasing
/// (06-history-undo.md's plural folding).
public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
let counts = counts(of: emptyTrashTargets(in: snapshot))
guard !counts.isEmpty else { return nil }
return PurgePrompt(
title: "Permanently delete \(phrase(counts))?",
message: message(lanes: counts.lanes, unrecoverable: unrecoverable),
confirmTitle: "Delete"
)
}
/// The alert's body: what a lane takes with it, and whether any of it comes back.
///
/// The lane sentence is not decoration a lane entry's row says "3 cards" (what Put Back would
/// return), while purging the lane folder takes *every* card inside it, individually tombstoned
/// ones included. That gap is exactly what a confirmation is for.
private static func message(lanes: Int, unrecoverable: Bool) -> String {
var parts: [String] = []
if lanes > 0 {
parts.append("Deleting a lane also deletes every card inside it.")
}
// m7-git: on a git board the content stays reachable in history, so the second sentence is
// the honest one and Delete Immediately does not confirm there at all
// (`BoardStore.purgeIsUnrecoverable`).
parts.append(unrecoverable
? "This can\u{2019}t be undone."
: "The board\u{2019}s history still has them.")
return parts.joined(separator: " ")
}
/// What to call an item in a prompt its title, or the "Untitled" rendering.
///
/// Total by construction: a path whose item has gone since the prompt was asked for reads
/// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
/// the app gives.
private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
guard let lane = snapshot.lanes.first(where: { $0.id == path.laneID }) else { return "Untitled" }
guard let cardID = path.cardID else { return lane.title.value ?? "Untitled" }
return lane.cards.first { $0.id == cardID }?.title.value ?? "Untitled"
}
// MARK: - Menu validation
/// Whether File Delete has something to tombstone a **live**, non-empty selection that still
/// names something the board renders.
///
/// The liveness side is the whole of the binary: "menu validation stays binary Delete for live
/// selections, Put Back / Delete Immediately for tombstoned ones" (04 The trash). The
/// resolution against the snapshot is what keeps a selection the next reload will drop from
/// enabling an item that would write nothing.
public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
selection.liveness == .live && !paths(of: selection.ids, on: .live, in: snapshot).isEmpty
}
/// Whether File Put Back and File Delete Immediately have something to act on the exact
/// mirror of `canDelete`, which is what makes the two twins enable exactly one of themselves.
public static func canActOnTrash(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
selection.liveness == .trashed && !paths(of: selection.ids, on: .trashed, in: snapshot).isEmpty
}
}
+105 -7
View File
@@ -23,14 +23,15 @@ import SwiftUI
/// order.
/// - **The keyboard's narrow slice** Return's create/rename dispatch and Escape's step outward.
///
/// - **The trash quasi-lane** trailing, one fixed unit, joining and leaving the width division as
/// View Show Trash toggles it (`TrashLaneView`, 03-board-ui.md § Trash).
///
/// ### What is deliberately not here yet
///
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
/// **selection grammar** here is likewise minimal a click replaces the selection, and that is all:
/// -click toggling, -click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
/// m5's selection-model card.
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is
/// likewise minimal a click replaces the selection, and that is all: -click toggling, -click
/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card.
struct BoardView: View {
let store: BoardStore
@@ -40,6 +41,10 @@ struct BoardView: View {
/// after the first body evaluation.
let window: @MainActor () -> NSWindow?
/// The window's purge-alert host see `TrashConfirmations` for why a menu item's confirmation
/// has to be presented from here.
let confirmations: TrashConfirmations
/// Opens a card's window 's second half (04-interactions.md Grammar). A closure from
/// `BoardWindowHost` rather than an `openWindow` call here, because building a `CardWindowRef`
/// needs the board's own window ref, which is the host's identity and not the board's.
@@ -56,6 +61,14 @@ struct BoardView: View {
/// One reorder at a time, per window same lifetime, same reasoning.
@State private var reorder = LaneReorderSession()
/// One drag out of the trash at a time, per window same lifetime again.
@State private var trashDrag = TrashDragSession()
/// The name of the strip's coordinate space, which is what a drop out of the trash is resolved
/// in: `LaneLayoutMath.laneIndex` reads an x measured from the strip's leading edge, outer margin
/// included, and no global or lane-local space is that.
static let stripSpace = "board-strip"
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@@ -77,7 +90,11 @@ struct BoardView: View {
? resize.standard
: LaneLayoutMath.standardWidth(
stripWidth: viewport.size.width,
totalUnits: LaneLayoutMath.totalUnits(of: lanes),
// The trash's one fixed unit joins the division **only while shown**, which is
// the whole of "Show/Hide Trash is a re-divide trigger" (03-board-ui.md § Trash):
// the window is never touched, the existing width simply divides across one more
// unit and every lane compresses a lane add's behaviour, exactly.
totalUnits: LaneLayoutMath.totalUnits(of: lanes, trashUnits: isTrashVisible ? 1 : 0),
gap: spacing)
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
// the drag's would-be order while a reorder is in flight which is how the siblings
@@ -89,11 +106,29 @@ struct BoardView: View {
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
laneSlot(lane, at: position, among: shown, standard: standard)
}
if isTrashVisible {
// Trailing, always the quasi-lane has no position of its own to lose, which is
// also why it never appears in the reorder proposal's inputs (those are built
// from `liveLanes`).
TrashLaneView(
store: store,
confirmations: confirmations,
drag: TrashRowDrag { x in laneUnder(x: x, standard: standard) },
dragSession: trashDrag
)
.frame(width: LaneLayoutMath.slotWidth(units: 1, standard: standard, gap: spacing))
.frame(maxHeight: .infinity, alignment: .top)
}
}
.padding(spacing)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// The space a drop out of the trash is resolved in see `BoardView.stripSpace`. It goes
// on the padded container so x = 0 is the strip's leading edge with the outer margin
// included, which is the origin `LaneLayoutMath`'s arithmetic assumes.
.coordinateSpace(.named(Self.stripSpace))
}
.background(boardBackground)
.trashPurgeAlert(store: store, confirmations: confirmations)
// The board's own anchor for the Style popover the surface a board-targeted session hangs
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
@@ -113,6 +148,7 @@ struct BoardView: View {
}
.onKeyPress(.return) { handleReturn() }
.onKeyPress(.escape) { handleEscape() }
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
}
// MARK: - Styling
@@ -186,6 +222,16 @@ struct BoardView: View {
)
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
}
// The drop highlight for a drag out of the trash: the lane the pointer is currently over.
// Feedback lives on the *target* rather than on a travelling replica, because the replica
// its lift, its settle, the copy/move badge is m5's drag card (03-board-ui.md § Motion).
.overlay {
if trashDrag.isTarget(lane.id) {
RoundedRectangle(cornerRadius: 10)
.strokeBorder(Color.accentColor, lineWidth: 2)
.allowsHitTesting(false)
}
}
.frame(width: slotWidth, alignment: .topLeading)
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
.opacity(dragging ? 0.9 : 1)
@@ -218,6 +264,32 @@ struct BoardView: View {
store.snapshot.lanes.filter { !$0.isDeleted }
}
// MARK: - Trash
/// Whether the trash quasi-lane 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 {
store.transient.isTrashVisible
}
/// The live lane under `x` in strip coordinates, or `nil` the strip's half of drag-to-restore.
///
/// Re-derived against `liveLanes` at gesture time rather than captured at drag start, which is
/// 04-interactions.md Drag and drop's re-grounding rule: a foreign reload that adds or
/// tombstones a lane mid-drag just moves the zones, and the next proposal targets the board as it
/// now is. A tombstoned lane is never a drop target because it is never in this list.
private func laneUnder(x: CGFloat, standard: CGFloat) -> ItemID? {
let lanes = liveLanes
guard let index = LaneLayoutMath.laneIndex(
atX: x,
unitCounts: unitCounts(of: lanes),
standard: standard,
gap: spacing
) else { return nil }
return lanes.indices.contains(index) ? lanes[index].id : nil
}
// MARK: - Reorder
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
@@ -320,6 +392,32 @@ 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).
///
/// Deliberately **live-only**: the nexus scopes this key to a live selection, and 's trash-side
/// role belongs to the twins, not to the bare key. A tombstoned selection is therefore inert
/// here Put Back is a chord.
///
/// 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
// 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
// accident 04-interactions.md's fixed grammar is careful to avoid.
guard press.modifiers.intersection([.command, .option, .control, .shift]).isEmpty else {
return .ignored
}
guard !store.isEditingInline, !store.isReadOnly else { return .ignored }
let selection = store.selection
guard selection.liveness == .live, !selection.isEmpty else { return .ignored }
store.deleteSelection()
return .handled
}
/// **Escape steps outward one layer per press** (04 Grammar): abandon an open editor, else
/// clear the selection.
///
+36 -4
View File
@@ -68,10 +68,42 @@ enum LaneLayoutMath {
///
/// 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. When the trash quasi-lane
/// arrives it joins this total as one fixed unit "Show/Hide Trash is a re-divide trigger".
static func totalUnits(of lanes: [Lane]) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
/// single trash entry) and so consumes none of the window's width.
///
/// **`trashUnits` is the quasi-lane'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
/// value anywhere that a reorder, a resize or a width write could reach.
///
/// Show/Hide Trash is therefore a **re-divide trigger** and nothing more the window is
/// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
/// lane add does (§ Layout full visibility).
static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
}
// MARK: - Hit testing
/// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin
/// 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
/// "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.
///
/// Same analytic geometry as `LaneReorderMath.proposedIndex` resting positions computed from
/// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
/// into logic").
static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
var left = gap
for (index, units) in unitCounts.enumerated() {
let width = slotWidth(units: units, standard: standard, gap: gap)
if x >= left, x < left + width { return index }
left += width + gap
}
return nil
}
// MARK: - The drag's snap
+250
View File
@@ -0,0 +1,250 @@
import Observation
import SwiftUI
// MARK: - The window's confirmation host
/// The board window's purge alert, as a piece of window-local state (03-board-ui.md § Trash).
///
/// **It exists because a menu item cannot present anything.** Delete Immediately and Empty Trash
/// live in the menu bar, the trash row's context menu carries a twin of the first, and all three must
/// raise *the same* alert on *the window in front* so the request travels through the focus system
/// exactly as `BoardInfoPresentation` does, and the alert itself is hosted once by `BoardView`.
///
/// `@State` in `BoardWindowHost`, therefore one per window and dying with it: a half-answered
/// confirmation is not something to carry across a window's life.
@MainActor
@Observable
final class TrashConfirmations {
/// The alert waiting to be answered, or `nil` when none is.
///
/// The **phrasing is captured when the request is made**, not recomputed at render time: the
/// user is being asked about the trash as it was when they invoked the command, and a foreign
/// reload landing mid-alert must not silently change the sentence they are reading. The *action*
/// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on an
/// item that has since gone `BoardWriter.purgeItem` treats an absent folder as success.
private(set) var pending: Pending?
struct Pending: Identifiable, Equatable {
let id = UUID()
let prompt: TrashModel.PurgePrompt
let action: Action
/// What the confirmation is standing in front of. Two cases, because the two commands have
/// genuinely different scopes: one names a selection, the other names the whole trash and
/// re-derives its targets at the moment it runs.
enum Action: Equatable {
case purge(Set<ItemID>)
case emptyTrash
}
}
/// Raises Delete Immediately's alert **or purges outright** where the loss is not real.
///
/// 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 two call sites.
func requestPurge(of ids: Set<ItemID>, in store: BoardStore) {
guard store.purgeIsUnrecoverable else {
store.deleteImmediately(ids)
return
}
guard let prompt = TrashModel.purgePrompt(
for: ids,
in: store.snapshot,
unrecoverable: true
) else { return }
pending = Pending(prompt: prompt, action: .purge(ids))
}
/// Raises Empty Trash's alert. **Always** it guards bulk scope rather than per-item
/// recoverability, so no board skips it.
func requestEmptyTrash(in store: BoardStore) {
guard let prompt = TrashModel.emptyTrashPrompt(
in: store.snapshot,
unrecoverable: store.purgeIsUnrecoverable
) else { return }
pending = Pending(prompt: prompt, action: .emptyTrash)
}
/// Runs the pending action and dismisses. Idempotent: an alert answered twice (the button, then
/// the dismissal SwiftUI drives from the binding) acts once.
func confirm(in store: BoardStore) {
guard let pending else { return }
self.pending = nil
switch pending.action {
case let .purge(ids): store.deleteImmediately(ids)
case .emptyTrash: store.emptyTrash()
}
}
func cancel() {
pending = nil
}
}
/// The focused board window's confirmation host beside `FocusedValues.boardStore` and
/// `.boardInfo`, and reached the same way by the same kinds of caller.
struct FocusedTrashConfirmationsKey: FocusedValueKey {
typealias Value = TrashConfirmations
}
extension FocusedValues {
var trashConfirmations: TrashConfirmations? {
get { self[FocusedTrashConfirmationsKey.self] }
set { self[FocusedTrashConfirmationsKey.self] = newValue }
}
}
// MARK: - File Delete / Put Back / Delete Immediately / Empty Trash
/// The File menu's trash rows (11-command-nexus.md).
///
/// ### The chord twins
///
/// Delete and Put Back are **two items sharing one key equivalent**, and validation enables exactly
/// one of them: "AppKit routes a shared key equivalent to the enabled item" (04-interactions.md
/// The map, which names Finder's own Move to Trash/Put Back pair as the precedent). The two
/// predicates are mirror images over the selection's liveness side
/// (`TrashModel.canDelete`/`canActOnTrash`), so they can neither both enable nor both disable while
/// something is selected and a selection can never be mixed, because
/// `ItemReferenceSet.resolved(against:)` treats a liveness flip as a vanish.
///
/// **Both titles stay stable** (titles-are-API): each remaps independently through the system
/// mechanism, and remapping one never moves the other's role.
struct TrashCommands: View {
@FocusedValue(\.boardStore) private var store
@FocusedValue(\.trashConfirmations) private var confirmations
var body: some View {
Button("Delete") {
store?.deleteSelection()
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(!canDelete)
Button("Put Back") {
guard let store else { return }
store.putBack(store.selection.ids)
}
.keyboardShortcut(.delete, modifiers: .command)
.disabled(!canActOnTrash)
Button("Delete Immediately") {
guard let store, let confirmations else { return }
confirmations.requestPurge(of: store.selection.ids, in: store)
}
.keyboardShortcut(.delete, modifiers: [.option, .command])
.disabled(!canActOnTrash || confirmations == nil)
Button("Empty Trash…") {
guard let store, let confirmations else { return }
confirmations.requestEmptyTrash(in: store)
}
.keyboardShortcut(.delete, modifiers: [.shift, .command])
.disabled(!canEmptyTrash)
}
/// A live, non-empty selection on a board that accepts writes.
private var canDelete: Bool {
guard let store, store.acceptsBoardMutations else { return false }
return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
}
/// A tombstoned, non-empty selection Put Back's condition and Delete Immediately's alike, the
/// two being the trash side's pair (04-interactions.md The trash: "menu validation stays
/// binary").
private var canActOnTrash: Bool {
guard let store, store.acceptsBoardMutations else { return false }
return TrashModel.canActOnTrash(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 the *board's* tombstones and never the filtered view (03-board-ui.md §
/// Trash: "a bulk command about the trash itself never silently narrows to the visible subset").
///
/// The visibility clause is 04-interactions.md's, stated for the whole quasi-lane: "hidden, it is
/// invisible to every gesture".
private var canEmptyTrash: Bool {
guard let store, store.acceptsBoardMutations, store.transient.isTrashVisible else { return false }
return !TrashModel.isEmpty(store.snapshot)
}
}
// MARK: - View Show Trash
/// View Show Trash a checkmark toggle with **no default chord** (11-command-nexus.md).
///
/// T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled, so the chord
/// is the system's, and a user who wants one here assigns it through the remapping mechanism.
///
/// **One stable title with a checkmark state** "Show Trash" stays "Show Trash" when checked, never
/// becomes "Hide Trash" (04-interactions.md Configurable bindings, since the title is the key a
/// custom binding is stored under).
///
/// Neither the read-only lock nor the focused-editor rule closes it: showing the trash is a view
/// change, not a mutation, and a locked board is exactly when a user wants to look at what is in
/// there.
struct ShowTrashCommand: View {
@FocusedValue(\.boardStore) private var store
var body: some View {
Toggle("Show Trash", isOn: isVisible)
.disabled(store == nil)
}
/// The toggle's binding and the one place hiding the trash has a consequence beyond layout.
///
/// **Hiding drops a tombstoned selection.** The rows it pointed at are no longer on screen, and
/// "nothing invisible may stay selected" is the invariant every item-referencing set in this app
/// already obeys (`ItemReferenceSet`); leaving one behind would also leave Put Back and Delete
/// Immediately enabled against a trash the user cannot see, which 04-interactions.md rules out
/// outright ("hidden, it is invisible to every gesture"). A *live* selection is untouched the
/// board it names is still right there.
private var isVisible: Binding<Bool> {
Binding(
get: { store?.transient.isTrashVisible ?? false },
set: { shown in
guard let store else { return }
store.transient.isTrashVisible = shown
if !shown, store.selection.liveness == .trashed {
store.clearSelection()
}
}
)
}
}
// MARK: - The alert
extension View {
/// Hosts the board window's purge alert one surface for every path that asks for one.
///
/// An **alert** rather than a confirmation dialog: this is a modal, destructive yes/no about
/// named items, which is what macOS's alert is for, and what Finder puts in front of the same
/// gesture.
func trashPurgeAlert(store: BoardStore, confirmations: TrashConfirmations) -> some View {
alert(
confirmations.pending?.prompt.title ?? "",
isPresented: Binding(
get: { confirmations.pending != nil },
// Any dismissal that is not the confirm button is a cancel Escape, a click
// outside, the sheet being torn down.
set: { presented in if !presented { confirmations.cancel() } }
),
presenting: confirmations.pending
) { pending in
Button(pending.prompt.confirmTitle, role: .destructive) {
confirmations.confirm(in: store)
}
Button("Cancel", role: .cancel) {
confirmations.cancel()
}
} message: { pending in
Text(pending.prompt.message)
}
}
}
+394
View File
@@ -0,0 +1,394 @@
import AppKit
import Observation
import SwiftUI
// MARK: - The strip's half of a trash row's drag
/// What the strip lends the trash so a row can be dragged back onto the board (03-board-ui.md §
/// Trash Drag-to-restore).
///
/// `LaneHeaderDrag`'s sibling, and for its reason: the gesture lives on the row, but the one thing it
/// needs *which lane is under the cursor* is the strip's geometry, and it must be read at gesture
/// time rather than at body-evaluation time.
@MainActor
struct TrashRowDrag {
/// The live lane under an x in strip coordinates, or `nil` when the point is not over one a
/// gap, the outer margin, or the trash itself (`LaneLayoutMath.laneIndex`).
let laneUnder: (CGFloat) -> ItemID?
}
// MARK: - TrashDragSession
/// Window-local state for an in-flight drag out of the trash `LaneReorderSession`'s sibling, and
/// deliberately as small.
///
/// It holds only what the pointer contributes: which card, and which lane is currently under it.
/// Everything else the strip's geometry, the lanes themselves is read fresh at render time, so a
/// foreign reload mid-drag cannot leave this holding a stale board (04-interactions.md Drag and
/// drop's re-grounding rule).
@MainActor
@Observable
final class TrashDragSession {
/// The card being dragged out of the trash; `nil` when idle.
private(set) var cardID: ItemID?
/// The live lane the pointer is over, or `nil` when it is over nothing droppable. Observed: the
/// lanes read it to draw the drop highlight, which is this milestone's whole visual feedback.
private(set) var targetLaneID: ItemID?
/// How far the pointer must travel before a click on a row becomes a drag the same threshold
/// the lane header uses, so the two gestures feel alike.
static let threshold: CGFloat = 4
var isActive: Bool { cardID != nil }
func isDragging(_ id: ItemID) -> Bool { cardID == id }
func isTarget(_ id: ItemID) -> Bool { targetLaneID == id }
func begin(cardID: ItemID) {
self.cardID = cardID
targetLaneID = nil
}
func update(targetLaneID: ItemID?) {
guard isActive else { return }
self.targetLaneID = targetLaneID
}
/// Ends the drag, handing the caller nothing the *commit* needs the current snapshot, which
/// the view has and this session deliberately does not. Idempotent, because a gesture can end
/// after the card it was carrying has already vanished.
func end() {
cardID = nil
targetLaneID = nil
}
}
// MARK: - TrashLaneView
/// The trash quasi-lane: the trailing, visually distinct column tombstoned items live in
/// (03-board-ui.md § Trash).
///
/// ### A pure view, and a quasi-lane
///
/// **Nothing here moves anything on disk.** Tombstoned items keep their `deleted:` key and stay
/// exactly where they are; this column is a rendering of `TrashModel.entries(of:)` and nothing more.
/// It is a *quasi*-lane because it wears a lane's shape while sharing none of a lane's machinery:
///
/// - 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,
/// so there is no `Lane` value for any of those to act on);
/// - it is **not draggable and not reorderable** the header carries no gesture, and it is absent
/// from the reorder proposal's `unitCounts` by construction, since `BoardView` builds that from
/// the snapshot's live lanes;
/// - it has **no new-card button**: nothing is created in the trash.
///
/// ### No editing in the trash
///
/// "Tombstoned cards don't open double-click does nothing beyond selection; Put Back or drag out
/// first." There is deliberately no double-tap recogniser, no rename editor, and no Style row: the
/// trash is for restoring or purging, not working.
///
/// ### What is still a later card's
///
/// The **search filter** ("shown, it participates in the filter like any lane") and the full
/// **keyboard grammar** arrow walks into and out of the column, -ranges that stop at both the
/// liveness and the kind boundary, the rubber band, C copy-out are m5's. So is the drag's replica:
/// what ships here is the drop, with the target lane highlighted and the source row dimmed in place.
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.
let confirmations: TrashConfirmations
/// The strip's drop resolution see `TrashRowDrag`.
let drag: TrashRowDrag
/// The window's one drag-out session, owned by `BoardView` for the same lifetime the reorder
/// session has.
let dragSession: TrashDragSession
/// The lane plate's corner radius matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
private let rowSpacing: CGFloat = 6
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
rows
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.35))
)
}
/// The rows the column shows.
///
// m5-search: the shown trash "participates in the filter like any lane", so the search predicate
// narrows this collection exactly as it narrows `LaneView.renderedCards` and the count badge
// follows for free, because it reads this same value.
private var entries: [TrashEntry] {
TrashModel.entries(of: store.snapshot)
}
// MARK: - Header
/// Dimmed and hatched, with the trash symbol, the stable "Trash" title and a count badge
/// (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
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: "trash")
.foregroundStyle(.secondary)
.imageScale(.medium)
Text("Trash")
.font(.headline)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
countBadge
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(.quaternary.opacity(0.5))
.overlay {
DiagonalHatch()
.stroke(.quaternary, lineWidth: 1)
.clipShape(UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
topTrailingRadius: cornerRadius
))
}
}
.accessibilityElement(children: .combine)
}
/// The entry count the same collection the body renders, so the badge cannot disagree with
/// what is on screen (`LaneView.countBadge`'s rule, and it is why m5's filter needs no second
/// change here).
private var countBadge: some View {
Text("\(entries.count)")
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 1)
.background(Capsule().fill(.quaternary))
}
// MARK: - Rows
private var rows: some View {
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: rowSpacing) {
ForEach(entries) { entry in
TrashEntryRow(
store: store,
entry: entry,
confirmations: confirmations,
drag: drag,
dragSession: dragSession
)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(6)
}
}
}
// MARK: - The hatch
/// Diagonal hatching for the trash header the "dimmed/hatched" treatment 03-board-ui.md asks for,
/// drawn rather than imaged so it takes whatever width the division gives the column.
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
private struct DiagonalHatch: Shape {
var spacing: CGFloat = 7
func path(in rect: CGRect) -> Path {
var path = Path()
guard spacing > 0, rect.height > 0 else { return path }
var x = rect.minX - rect.height
while x < rect.maxX {
path.move(to: CGPoint(x: x, y: rect.maxY))
path.addLine(to: CGPoint(x: x + rect.height, y: rect.minY))
x += spacing
}
return path
}
}
// MARK: - Rows
/// One trash row: a compact, dimmed plate carrying the item's symbol and title and, for a lane
/// entry, the count of cards Put Back would return with it.
///
/// **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, no attachment carousel. 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.
private struct TrashEntryRow: View {
let store: BoardStore
let entry: TrashEntry
let confirmations: TrashConfirmations
let drag: TrashRowDrag
let dragSession: TrashDragSession
private let cornerRadius: CGFloat = 6
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Image(systemName: ItemSymbol.name(entry.icon, fallback: symbolFallback))
.foregroundStyle(.secondary)
.imageScale(.small)
VStack(alignment: .leading, spacing: 2) {
Text(entry.title ?? "Untitled")
.font(.callout)
.foregroundStyle(.secondary)
.lineLimit(2)
if case let .lane(_, returning) = entry {
// "N cards" what Put Back brings back with the lane, not how many folders sit
// inside it (`TrashModel.entries`' returning-count rule).
Text("\(returning) card\(returning == 1 ? "" : "s")")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.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)
)
// The row being dragged out dims further, so the gesture reads even without a replica.
.opacity(dragSession.isDragging(entry.id) ? 0.45 : 1)
.contentShape(Rectangle())
.gesture(rowGesture)
.contextMenu { menu }
}
private var symbolFallback: String {
entry.isLaneEntry ? ItemSymbol.lane : ItemSymbol.card
}
// MARK: - Selection
private var isSelected: Bool {
store.selection.liveness == .trashed && store.selection.ids.contains(entry.id)
}
/// A click replaces the selection with this one row, on the **trashed** side.
///
/// Replace-only is what enforces both invariants at once here: a selection that is always exactly
/// one row can never mix live with tombstoned, nor card entries with lane entries
/// (04-interactions.md The trash). The extension grammar -click, -ranges that go inert at
/// both boundaries, the rubber band that stays on the side it started on is **m5's
/// selection-model card**, and nothing here should pre-empt it.
///
/// **A double click is two of these and nothing more**: no editor, no card window, no timer.
private func select() {
store.select([entry.id], liveness: .trashed)
}
// MARK: - Drag out
/// One gesture recognising the same click-versus-drag split the lane header uses: a plain click
/// selects, and only movement past the threshold begins a drag out of the trash.
///
/// **Lane entries are not draggable** (03-board-ui.md § Trash: "a lane entry is not draggable
/// its entry is a compact row, not the lane; its move-out is Put Back"), so the drag half is
/// simply absent for them and the release still selects.
///
/// The coordinate space is the strip's, because what the release needs is a *position* over the
/// board, not a translation.
private var rowGesture: some Gesture {
DragGesture(minimumDistance: 0, coordinateSpace: .named(BoardView.stripSpace))
.onChanged { value in
guard isDraggable, !store.isReadOnly, !store.isEditingInline else { return }
if !dragSession.isDragging(entry.id) {
let travelled = max(abs(value.translation.width), abs(value.translation.height))
guard travelled > TrashDragSession.threshold else { return }
dragSession.begin(cardID: entry.id)
}
dragSession.update(targetLaneID: drag.laneUnder(value.location.x))
}
.onEnded { value in
guard dragSession.isDragging(entry.id) else {
select()
return
}
dragSession.end()
// A drop over anything but a live lane the trash itself, a gap, the outer margin
// writes nothing. There is no replica to snap back; the row never left.
guard let lane = drag.laneUnder(value.location.x) else { return }
store.restoreByDrag(cardID: entry.id, intoLane: lane)
}
}
private var isDraggable: Bool { !entry.isLaneEntry }
// MARK: - The trash entry's context menu
/// Put Back, Delete Immediately, Reveal in Finder the three rows 11-command-nexus.md gives a
/// trash entry, and no others.
///
/// Reveal in Finder is the odd one out and deliberately so: it is "not edit-shaped and stays
/// enabled on tombstoned 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("Put Back") {
store.putBack(targetIDs)
}
.disabled(!store.acceptsBoardMutations)
Button("Delete Immediately") {
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. Right-clicking something outside the selection acts on what was
/// clicked, which is also what keeps a cross-kind menu from ever acting on a mixed set.
private var targetIDs: Set<ItemID> {
guard store.selection.liveness == .trashed, store.selection.ids.contains(entry.id) else {
return [entry.id]
}
return store.selection.ids
}
private var targetFolders: [URL] {
TrashModel.paths(of: targetIDs, on: .trashed, in: store.snapshot)
.map { $0.folder(under: store.rootURL) }
}
}