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:
@@ -39,6 +39,11 @@ struct BoardWindowHost: View {
|
|||||||
/// menu item through the focus system, like the store.
|
/// menu item through the focus system, like the store.
|
||||||
@State private var boardInfo = BoardInfoPresentation()
|
@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
|
@State private var phase: Phase = .opening
|
||||||
|
|
||||||
private enum Phase {
|
private enum Phase {
|
||||||
@@ -80,15 +85,18 @@ struct BoardWindowHost: View {
|
|||||||
BoardView(
|
BoardView(
|
||||||
store: store,
|
store: store,
|
||||||
window: { windowController.window },
|
window: { windowController.window },
|
||||||
|
confirmations: trashConfirmations,
|
||||||
openCard: { cardID in
|
openCard: { cardID in
|
||||||
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
openWindow(id: WindowID.card, value: CardWindowRef(board: ref, cardID: cardID))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// "The board in front", for the menu items that act on it (`LaneWidthCommands`), and
|
// "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(\.boardStore, store)
|
||||||
.focusedSceneValue(\.boardInfo, boardInfo)
|
.focusedSceneValue(\.boardInfo, boardInfo)
|
||||||
|
.focusedSceneValue(\.trashConfirmations, trashConfirmations)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -108,8 +108,9 @@ struct KanbanApp: App {
|
|||||||
@CommandsBuilder
|
@CommandsBuilder
|
||||||
private var menuCommands: some Commands {
|
private var menuCommands: some Commands {
|
||||||
// The File group, in 11-command-nexus.md's own row order: New Card, New Lane, (New Board…,
|
// 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
|
// still owed), Open…, (Open Recent, still owed), Board Info, (Duplicate / Save as Template /
|
||||||
// Info all validate against the frontmost board through the focus system, so each is simply
|
// 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.
|
// absent-of-effect when no board is in front.
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
BoardCreationCommands()
|
BoardCreationCommands()
|
||||||
@@ -124,6 +125,17 @@ struct KanbanApp: App {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
BoardInfoCommand()
|
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…,
|
// The Board menu (11-command-nexus.md), in its inventoried order — Rename, then Style…,
|
||||||
|
|||||||
@@ -422,9 +422,13 @@ public final class BannerCenter {
|
|||||||
///
|
///
|
||||||
/// Titles are quoted where the operation carries one and the phrasing stays graceful where it
|
/// 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
|
/// 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
|
/// "the item" rather than guessing "card" and being wrong about a lane.
|
||||||
/// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash,
|
///
|
||||||
/// Put Back, Delete Immediately.
|
/// 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 {
|
private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
|
||||||
switch operation {
|
switch operation {
|
||||||
case .createBoard:
|
case .createBoard:
|
||||||
@@ -440,7 +444,7 @@ public final class BannerCenter {
|
|||||||
case let .copy(title):
|
case let .copy(title):
|
||||||
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
|
if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
|
||||||
case let .delete(title):
|
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):
|
case let .restore(title):
|
||||||
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
||||||
case let .purge(title):
|
case let .purge(title):
|
||||||
|
|||||||
@@ -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)
|
// MARK: - Selection (delegated)
|
||||||
|
|
||||||
// The thin pass-throughs to `transient`, and the only ones.
|
// The thin pass-throughs to `transient`, and the only ones.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,14 +23,15 @@ import SwiftUI
|
|||||||
/// order.
|
/// order.
|
||||||
/// - **The keyboard's narrow slice** — Return's create/rename dispatch and Escape's step outward.
|
/// - **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
|
/// ### What is deliberately not here yet
|
||||||
///
|
///
|
||||||
/// The trash quasi-lane, the toolbar, search, styling, the lane context menu, and drag & drop's real
|
/// The toolbar, search, and drag & drop's real machinery (multi-drag, cross-board locality, the
|
||||||
/// machinery (multi-drag, cross-board locality, the shadow's hold rule) all belong to later
|
/// shadow's hold rule) all belong to later milestone cards. The **selection grammar** here is
|
||||||
/// milestone cards, and the card face inside `LaneView` is still a stub those cards replace. The
|
/// likewise minimal — a click replaces the selection, and that is all: ⌘-click toggling, ⇧-click
|
||||||
/// **selection grammar** here is likewise minimal — a click replaces the selection, and that is all:
|
/// ranges, the rubber band and the cards-XOR-lanes homogeneity rule are m5's selection-model card.
|
||||||
/// ⌘-click toggling, ⇧-click ranges, the rubber band and the cards-XOR-lanes homogeneity rule are
|
|
||||||
/// m5's selection-model card.
|
|
||||||
struct BoardView: View {
|
struct BoardView: View {
|
||||||
|
|
||||||
let store: BoardStore
|
let store: BoardStore
|
||||||
@@ -40,6 +41,10 @@ struct BoardView: View {
|
|||||||
/// after the first body evaluation.
|
/// after the first body evaluation.
|
||||||
let window: @MainActor () -> NSWindow?
|
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
|
/// 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`
|
/// `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.
|
/// 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.
|
/// One reorder at a time, per window — same lifetime, same reasoning.
|
||||||
@State private var reorder = LaneReorderSession()
|
@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
|
/// 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
|
/// 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.
|
/// must go back to meaning create/rename rather than nothing at all.
|
||||||
@@ -77,7 +90,11 @@ struct BoardView: View {
|
|||||||
? resize.standard
|
? resize.standard
|
||||||
: LaneLayoutMath.standardWidth(
|
: LaneLayoutMath.standardWidth(
|
||||||
stripWidth: viewport.size.width,
|
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)
|
gap: spacing)
|
||||||
// The lanes in the order the strip should *show* them: their snapshot order at rest, and
|
// 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
|
// 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
|
ForEach(Array(shown.enumerated()), id: \.element.id) { position, lane in
|
||||||
laneSlot(lane, at: position, among: shown, standard: standard)
|
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)
|
.padding(spacing)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.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)
|
.background(boardBackground)
|
||||||
|
.trashPurgeAlert(store: store, confirmations: confirmations)
|
||||||
// The board's own anchor for the Style… popover — the surface a board-targeted session hangs
|
// 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).
|
// off, since the board has no item to attach to (`styleEditorPresentation`'s `nil` anchor).
|
||||||
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
|
||||||
@@ -113,6 +148,7 @@ struct BoardView: View {
|
|||||||
}
|
}
|
||||||
.onKeyPress(.return) { handleReturn() }
|
.onKeyPress(.return) { handleReturn() }
|
||||||
.onKeyPress(.escape) { handleEscape() }
|
.onKeyPress(.escape) { handleEscape() }
|
||||||
|
.onKeyPress(keys: [.delete], phases: .down) { handleDelete($0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Styling
|
// MARK: - Styling
|
||||||
@@ -186,6 +222,16 @@ struct BoardView: View {
|
|||||||
)
|
)
|
||||||
.frame(width: resizing ? resize.liveWidth : slotWidth, alignment: .leading)
|
.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)
|
.frame(width: slotWidth, alignment: .topLeading)
|
||||||
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
.offset(x: dragging ? travelOffset(at: position, among: shown, standard: standard) : 0)
|
||||||
.opacity(dragging ? 0.9 : 1)
|
.opacity(dragging ? 0.9 : 1)
|
||||||
@@ -218,6 +264,32 @@ struct BoardView: View {
|
|||||||
store.snapshot.lanes.filter { !$0.isDeleted }
|
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
|
// MARK: - Reorder
|
||||||
|
|
||||||
/// The order the strip shows: the snapshot's at rest, the drag's proposal while one is in
|
/// 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
|
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
|
/// **Escape steps outward one layer per press** (04 ▸ Grammar): abandon an open editor, else
|
||||||
/// clear the selection.
|
/// clear the selection.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -68,10 +68,42 @@ enum LaneLayoutMath {
|
|||||||
///
|
///
|
||||||
/// The caller decides *which* lanes: the strip passes the live ones in snapshot order, because
|
/// 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
|
/// 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
|
/// single trash entry) and so consumes none of the window's width.
|
||||||
/// arrives it joins this total as one fixed unit — "Show/Hide Trash is a re-divide trigger".
|
///
|
||||||
static func totalUnits(of lanes: [Lane]) -> Int {
|
/// **`trashUnits` is the quasi-lane's fixed one unit, and it is *only* consumed while shown**
|
||||||
max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) })
|
/// (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
|
// MARK: - The drag's snap
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -375,6 +375,24 @@ struct BannerCenterPhrasingTests {
|
|||||||
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
|
!= BannerCenter.headline(for: error(.style(title: "Fix login"))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("The trash trio speaks the board's vocabulary, never the system Trash's")
|
||||||
|
func trashVerbsFollowTheNamingConstraint() {
|
||||||
|
// 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash"
|
||||||
|
// concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash;
|
||||||
|
// board deletion says 'Delete'". A banner is UI copy like any other.
|
||||||
|
#expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full")))
|
||||||
|
== "Couldn't delete 'Fix login' — the disk is full")
|
||||||
|
#expect(BannerCenter.headline(for: error(.restore(title: "Fix login"), .io(message: "the disk is full")))
|
||||||
|
== "Couldn't put 'Fix login' back — the disk is full")
|
||||||
|
#expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full")))
|
||||||
|
== "Couldn't permanently delete 'Fix login' — the disk is full")
|
||||||
|
|
||||||
|
for operation in [WriteOperation.delete(title: "Fix login"), .delete(title: nil)] {
|
||||||
|
#expect(!BannerCenter.headline(for: error(operation)).contains("Trash"))
|
||||||
|
#expect(!BannerCenter.headline(for: error(operation)).contains("trash"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test("The cause tail comes from the error's reason and nowhere else")
|
@Test("The cause tail comes from the error's reason and nowhere else")
|
||||||
func causeTailCarriesTheDiagnosis() {
|
func causeTailCarriesTheDiagnosis() {
|
||||||
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
#expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
|
||||||
|
|||||||
@@ -173,6 +173,67 @@ struct LaneDisplayUnitsTests {
|
|||||||
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
|
#expect(LaneLayoutMath.displayUnits(of: wide) == 40)
|
||||||
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
|
#expect(LaneLayoutMath.totalUnits(of: [wide]) == 40)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("The trash's one fixed unit joins the total only while it is shown")
|
||||||
|
func trashUnitJoinsTheDivision() throws {
|
||||||
|
// 03-board-ui.md § Trash: the quasi-lane "spans a fixed one width unit … consumed only
|
||||||
|
// while shown", and Show/Hide Trash is therefore a re-divide trigger — the window is never
|
||||||
|
// touched, the same width simply divides across one more unit.
|
||||||
|
let loaded = try lanes(widths: ["2", nil, "3"])
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: loaded) == 6)
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: loaded, trashUnits: 1) == 7)
|
||||||
|
|
||||||
|
// Shown on a zero-lane board it is the whole division, not a second unit alongside the
|
||||||
|
// empty board's floor of one.
|
||||||
|
#expect(LaneLayoutMath.totalUnits(of: [], trashUnits: 1) == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Hit testing
|
||||||
|
|
||||||
|
/// `laneIndex(atX:…)` — the strip's half of drag-to-restore (03-board-ui.md § Trash). Same lane
|
||||||
|
/// geometry as the layout above: standard 100, gap 12, so with lanes of 1× and 2× units the slots
|
||||||
|
/// run [12, 112), [124, 336) and everything past 348 is the trash's side of the strip.
|
||||||
|
@Suite("LaneLayoutMath ▸ hit testing")
|
||||||
|
struct LaneHitTestingTests {
|
||||||
|
|
||||||
|
private let units = [1, 2, 1]
|
||||||
|
private func hit(_ x: CGFloat) -> Int? {
|
||||||
|
LaneLayoutMath.laneIndex(atX: x, unitCounts: units, standard: 100, gap: 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A point inside a lane's slot names that lane, width counted in units")
|
||||||
|
func insideALaneSlot() {
|
||||||
|
#expect(hit(12) == 0)
|
||||||
|
#expect(hit(111.9) == 0)
|
||||||
|
#expect(hit(124) == 1)
|
||||||
|
// The 2× lane swallows the interior gap it spans, so its slot runs 212pt, not 200.
|
||||||
|
#expect(hit(335.9) == 1)
|
||||||
|
#expect(hit(348) == 2)
|
||||||
|
#expect(hit(447.9) == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The margins, the gaps, and everything past the last lane name nothing")
|
||||||
|
func gapsAndMarginsAreNotLanes() {
|
||||||
|
// The outer margin, before the first lane.
|
||||||
|
#expect(hit(0) == nil)
|
||||||
|
#expect(hit(11.9) == nil)
|
||||||
|
// The inter-lane gaps.
|
||||||
|
#expect(hit(112) == nil)
|
||||||
|
#expect(hit(123.9) == nil)
|
||||||
|
#expect(hit(336) == nil)
|
||||||
|
// Past the last lane — which is exactly where the trash quasi-lane sits, so a row dropped
|
||||||
|
// back into the trash writes nothing.
|
||||||
|
#expect(hit(448) == nil)
|
||||||
|
#expect(hit(10_000) == nil)
|
||||||
|
// A negative x (the pointer dragged off the leading edge) is not a lane either.
|
||||||
|
#expect(hit(-5) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty strip has no lane under any point")
|
||||||
|
func emptyStrip() {
|
||||||
|
#expect(LaneLayoutMath.laneIndex(atX: 50, unitCounts: [], standard: 100, gap: 12) == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - The snap
|
// MARK: - The snap
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `TrashModel` is the trash quasi-lane's whole heart, and it is a pure function — so this suite is
|
||||||
|
/// the executable form of 03-board-ui.md § Trash ▸ Contents: the deterministic sort, the absolute
|
||||||
|
/// ancestor walk, and what a lane entry's card count actually counts.
|
||||||
|
///
|
||||||
|
/// The snapshots are **loaded from real temp boards** rather than hand-built, for the reason every
|
||||||
|
/// other model suite here does it: the interesting inputs are `FieldValue<Date>` shapes — a valid
|
||||||
|
/// stamp, an unparseable one, an absent key — and only the loader produces those the way production
|
||||||
|
/// does. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
/// A few more literal identities than `Ident` offers: the sort tests need enough rows to prove an
|
||||||
|
/// *ordering* rather than a comparison, and folder name is the tie-break, so the names matter.
|
||||||
|
private enum More {
|
||||||
|
static let laneA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
||||||
|
static let laneB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
||||||
|
static let cardD = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"
|
||||||
|
static let cardE = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"
|
||||||
|
static let cardF = "ffffffff-ffff-4fff-8fff-ffffffffffff"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item with a `deleted:` key — `stamp` goes in verbatim, so a test can write an unparseable one.
|
||||||
|
private func tombstoned(order: String, title: String, deleted: String) -> String {
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
deleted: \(deleted)
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func live(order: String, title: String) -> String {
|
||||||
|
"---\nschema: 1\ntitle: \(title)\norder: \(order)\n---\n\(title) body.\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load(_ fixture: WriterFixture) throws -> BoardModel {
|
||||||
|
try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ids(_ entries: [TrashEntry]) -> [String] {
|
||||||
|
entries.map(\.id.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The sort
|
||||||
|
|
||||||
|
@Suite("TrashModel ▸ sort")
|
||||||
|
struct TrashModelSortTests {
|
||||||
|
|
||||||
|
@Test("Dated entries sort newest first, and lane entries interleave by their own stamp")
|
||||||
|
func newestFirstWithLanesInterleaved() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "Oldest card", deleted: "2026-03-01T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Newest card", deleted: "2026-03-03T09:00:00Z"))
|
||||||
|
// A tombstoned lane whose own stamp falls between the two cards': the sort is one ordering
|
||||||
|
// over both kinds, not cards-then-lanes.
|
||||||
|
try fixture.item(Ident.lane2,
|
||||||
|
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
|
||||||
|
|
||||||
|
let entries = TrashModel.entries(of: try load(fixture))
|
||||||
|
#expect(ids(entries) == [Ident.card2, Ident.lane2, Ident.card1])
|
||||||
|
#expect(entries[1].isLaneEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ties on the second break by folder name, ascending")
|
||||||
|
func tiesBreakByFolderName() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
// One multi-card ⌫ stamps one second onto N cards. The three are written in an order that
|
||||||
|
// is neither their folder-name order nor their `order` order, so only the rule can produce
|
||||||
|
// the expectation below.
|
||||||
|
let stamp = "2026-03-03T09:00:00Z"
|
||||||
|
try fixture.item("\(Ident.lane1)/\(More.cardF)", tombstoned(order: "1024", title: "F", deleted: stamp))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", tombstoned(order: "2048", title: "5", deleted: stamp))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(More.cardD)", tombstoned(order: "3072", title: "D", deleted: stamp))
|
||||||
|
|
||||||
|
let entries = TrashModel.entries(of: try load(fixture))
|
||||||
|
#expect(ids(entries) == [Ident.card1, More.cardD, More.cardF])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An unparseable stamp sorts oldest — after every dated entry, however old")
|
||||||
|
func unparseableSortsOldest() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "Corrupt", deleted: "whenever"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Ancient", deleted: "1999-01-01T00:00:00Z"))
|
||||||
|
|
||||||
|
let model = try load(fixture)
|
||||||
|
// The premise: the key is present but has no date reading — still a tombstone (presence,
|
||||||
|
// not validity), and still with no position in time.
|
||||||
|
let corrupt = try #require(model.lanes.first?.cards.first { $0.id.rawValue == Ident.card1 })
|
||||||
|
#expect(corrupt.isDeleted)
|
||||||
|
#expect(corrupt.deleted.value == nil)
|
||||||
|
|
||||||
|
// A corrupt stamp must not outrank a fresh deletion for the trash's most prominent rows —
|
||||||
|
// and here it does not even outrank a 1999 one.
|
||||||
|
#expect(ids(TrashModel.entries(of: model)) == [Ident.card2, Ident.card1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Undated entries order by folder name among themselves, lanes included")
|
||||||
|
func undatedOrderByFolderName() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(More.laneB, tombstoned(order: "3072", title: "B lane", deleted: "not-a-date"))
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(More.cardE)", tombstoned(order: "1024", title: "E", deleted: "corrupt"))
|
||||||
|
try fixture.item(More.laneA, tombstoned(order: "2048", title: "A lane", deleted: "later"))
|
||||||
|
|
||||||
|
// `a… < b… < e…`: one folder-name ordering across both kinds, exactly as the dated half has
|
||||||
|
// one date ordering across both kinds.
|
||||||
|
#expect(ids(TrashModel.entries(of: try load(fixture))) == [More.laneA, More.laneB, More.cardE])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The ancestor walk and the returning count
|
||||||
|
|
||||||
|
@Suite("TrashModel ▸ contents")
|
||||||
|
struct TrashModelContentsTests {
|
||||||
|
|
||||||
|
/// The board every test below reads: one live lane holding a tombstoned card and a live one, and
|
||||||
|
/// one tombstoned lane holding a live card, a card with its own flag, and another live card.
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "Trashed card", deleted: "2026-03-03T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", live(order: "2048", title: "Live card"))
|
||||||
|
try fixture.item(Ident.lane2,
|
||||||
|
tombstoned(order: "2048", title: "Doing", deleted: "2026-03-02T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", live(order: "1024", title: "Rides along"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(More.cardD)",
|
||||||
|
tombstoned(order: "2048", title: "Own flag", deleted: "2026-03-04T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(More.cardE)", live(order: "3072", title: "Rides along too"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A card with its own deleted: under a tombstoned lane has no row — the walk is absolute")
|
||||||
|
func ownFlagUnderTombstonedLaneHasNoRow() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let entries = TrashModel.entries(of: try load(fixture))
|
||||||
|
// `cardD`'s stamp is the newest on the board, so if it had a row at all it would be the
|
||||||
|
// first one. The lane's single entry subsumes it instead.
|
||||||
|
#expect(ids(entries) == [Ident.card1, Ident.lane2])
|
||||||
|
#expect(!ids(entries).contains(More.cardD))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane entry counts what Put Back returns — cards without their own flag")
|
||||||
|
func returningCountExcludesOwnFlaggedCards() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let entries = TrashModel.entries(of: try load(fixture))
|
||||||
|
let lane = try #require(entries.first { $0.id.rawValue == Ident.lane2 })
|
||||||
|
guard case let .lane(_, returning) = lane else {
|
||||||
|
Issue.record("expected a lane entry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Three cards sit in the folder; two come back with the lane. The third comes back to the
|
||||||
|
// *trash*, which is why it is not in this number.
|
||||||
|
#expect(returning == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane entry with nothing to return counts zero rather than being suppressed")
|
||||||
|
func emptyLaneEntryCountsZero() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, tombstoned(order: "1024", title: "Empty", deleted: "2026-03-03T09:00:00Z"))
|
||||||
|
|
||||||
|
let entries = TrashModel.entries(of: try load(fixture))
|
||||||
|
#expect(entries.count == 1)
|
||||||
|
guard case let .lane(_, returning) = entries[0] else {
|
||||||
|
Issue.record("expected a lane entry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(returning == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty trash is empty, and a board with any tombstone is not")
|
||||||
|
func emptiness() throws {
|
||||||
|
let clean = try WriterFixture()
|
||||||
|
defer { clean.tearDown() }
|
||||||
|
try clean.item("", Item.board)
|
||||||
|
try clean.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try clean.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Card"))
|
||||||
|
#expect(TrashModel.isEmpty(try load(clean)))
|
||||||
|
#expect(TrashModel.entries(of: try load(clean)).isEmpty)
|
||||||
|
|
||||||
|
let dirty = try makeBoard()
|
||||||
|
defer { dirty.tearDown() }
|
||||||
|
#expect(!TrashModel.isEmpty(try load(dirty)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Paths resolve on the effective liveness side, in display order")
|
||||||
|
func pathsResolveByEffectiveLiveness() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try load(fixture)
|
||||||
|
let everything: Set<ItemID> = [
|
||||||
|
ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2),
|
||||||
|
ItemID(rawValue: Ident.lane2), ItemID(rawValue: Ident.card3), ItemID(rawValue: More.cardD),
|
||||||
|
]
|
||||||
|
|
||||||
|
// Live: the live lane and its live card. `card3` is live by its own flag but its lane is
|
||||||
|
// tombstoned, so the ancestor walk puts it on the other side.
|
||||||
|
let liveSide = TrashModel.paths(of: everything, on: .live, in: model)
|
||||||
|
#expect(liveSide.map { ($0.laneID.rawValue, $0.cardID?.rawValue) }.map { "\($0.0)/\($0.1 ?? "-")" }
|
||||||
|
== ["\(Ident.lane1)/-", "\(Ident.lane1)/\(Ident.card2)"])
|
||||||
|
|
||||||
|
// Trashed: the tombstoned card, the tombstoned lane, and everything the lane hides — in
|
||||||
|
// display order, lanes left to right and each lane before its cards.
|
||||||
|
let trashedSide = TrashModel.paths(of: everything, on: .trashed, in: model)
|
||||||
|
#expect(trashedSide.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
|
||||||
|
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-",
|
||||||
|
"\(Ident.lane2)/\(Ident.card3)", "\(Ident.lane2)/\(More.cardD)"])
|
||||||
|
|
||||||
|
#expect(TrashModel.paths(of: [], on: .live, in: model).isEmpty)
|
||||||
|
#expect(TrashModel.paths(of: [ItemID(rawValue: Ident.indexless)], on: .trashed, in: model).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash targets every tombstone, a tombstoned lane contributing only its own folder")
|
||||||
|
func emptyTrashTargets() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let targets = TrashModel.emptyTrashTargets(in: try load(fixture))
|
||||||
|
// The own-flag card inside the tombstoned lane is not listed — removing the lane folder
|
||||||
|
// takes it, and a second purge of a path that is already gone would be noise.
|
||||||
|
#expect(targets.map { "\($0.laneID.rawValue)/\($0.cardID?.rawValue ?? "-")" }
|
||||||
|
== ["\(Ident.lane1)/\(Ident.card1)", "\(Ident.lane2)/-"])
|
||||||
|
#expect(TrashModel.counts(of: targets) == TrashModel.EntryCounts(lanes: 1, cards: 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A path resolves under whichever root it is given")
|
||||||
|
func pathsResolveUnderTheGivenRoot() {
|
||||||
|
let root = URL(fileURLWithPath: "/tmp/Board.kanban")
|
||||||
|
let card = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: ItemID(rawValue: Ident.card1))
|
||||||
|
#expect(card.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
#expect(!card.isLane)
|
||||||
|
|
||||||
|
let lane = TrashModel.ItemPath(laneID: ItemID(rawValue: Ident.lane1), cardID: nil)
|
||||||
|
#expect(lane.folder(under: root).path == "/tmp/Board.kanban/\(Ident.lane1)")
|
||||||
|
#expect(lane.isLane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Counts, phrasing, and the confirmations
|
||||||
|
|
||||||
|
@Suite("TrashModel ▸ phrasing")
|
||||||
|
struct TrashModelPhrasingTests {
|
||||||
|
|
||||||
|
@Test("Plural folding reads counts, singular and plural, cards and lanes and both")
|
||||||
|
func pluralFolding() {
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 0, cards: 41)) == "41 cards")
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 0, cards: 1)) == "1 card")
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 1, cards: 0)) == "1 lane")
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 3, cards: 0)) == "3 lanes")
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 2, cards: 3)) == "2 lanes and 3 cards")
|
||||||
|
#expect(TrashModel.phrase(.init(lanes: 1, cards: 1)) == "1 lane and 1 card")
|
||||||
|
#expect(TrashModel.phrase(.init()) == "nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Entry counts split lane entries from card entries")
|
||||||
|
func entryCounts() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
|
||||||
|
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Gone", deleted: "2026-03-03T09:00:00Z"))
|
||||||
|
|
||||||
|
let counts = TrashModel.counts(of: TrashModel.entries(of: try load(fixture)))
|
||||||
|
#expect(counts == TrashModel.EntryCounts(lanes: 1, cards: 2))
|
||||||
|
#expect(counts.total == 3)
|
||||||
|
#expect(!counts.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Delete Immediately names a sole item and folds several into counts")
|
||||||
|
func purgePrompt() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "Fix login", deleted: "2026-03-01T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Ship it", deleted: "2026-03-02T09:00:00Z"))
|
||||||
|
try fixture.item(Ident.lane2, tombstoned(order: "2048", title: "Doing", deleted: "2026-03-03T09:00:00Z"))
|
||||||
|
let model = try load(fixture)
|
||||||
|
|
||||||
|
let sole = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: true))
|
||||||
|
#expect(sole.title == "Permanently delete \u{201C}Fix login\u{201D}?")
|
||||||
|
#expect(sole.message == "This can\u{2019}t be undone.")
|
||||||
|
#expect(sole.confirmTitle == "Delete")
|
||||||
|
|
||||||
|
let several = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)],
|
||||||
|
in: model, unrecoverable: true))
|
||||||
|
#expect(several.title == "Permanently delete 2 cards?")
|
||||||
|
|
||||||
|
// A lane in the set earns the sentence that matters most: the row says how many cards come
|
||||||
|
// *back*, while a purge takes every card in the folder.
|
||||||
|
let withLane = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.lane2)],
|
||||||
|
in: model, unrecoverable: true))
|
||||||
|
#expect(withLane.title == "Permanently delete 1 lane and 1 card?")
|
||||||
|
#expect(withLane.message.hasPrefix("Deleting a lane also deletes every card inside it."))
|
||||||
|
|
||||||
|
// m7-git: a board whose history keeps the content says so instead.
|
||||||
|
let recoverable = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.card1)], in: model, unrecoverable: false))
|
||||||
|
#expect(recoverable.message == "The board\u{2019}s history still has them.")
|
||||||
|
|
||||||
|
// Nothing tombstoned in the set: no prompt, which is also the command's own refusal.
|
||||||
|
#expect(TrashModel.purgePrompt(for: [ItemID(rawValue: Ident.lane1)], in: model, unrecoverable: true) == nil)
|
||||||
|
#expect(TrashModel.purgePrompt(for: [], in: model, unrecoverable: true) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An untitled item is named by its rendering, never by an empty string")
|
||||||
|
func untitledPrompt() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
"---\nschema: 1\norder: 1024\ndeleted: 2026-03-01T09:00:00Z\n---\nbody\n")
|
||||||
|
|
||||||
|
let prompt = try #require(TrashModel.purgePrompt(
|
||||||
|
for: [ItemID(rawValue: Ident.card1)], in: try load(fixture), unrecoverable: true))
|
||||||
|
#expect(prompt.title == "Permanently delete \u{201C}Untitled\u{201D}?")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash names the whole trash's count and refuses on an empty one")
|
||||||
|
func emptyTrashPrompt() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
#expect(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true) == nil)
|
||||||
|
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)",
|
||||||
|
tombstoned(order: "1024", title: "One", deleted: "2026-03-01T09:00:00Z"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Two", deleted: "2026-03-02T09:00:00Z"))
|
||||||
|
let prompt = try #require(TrashModel.emptyTrashPrompt(in: try load(fixture), unrecoverable: true))
|
||||||
|
// Counts even for a small trash: the command is about the trash, not about an item.
|
||||||
|
#expect(prompt.title == "Permanently delete 2 cards?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Menu validation
|
||||||
|
|
||||||
|
@Suite("TrashModel ▸ validation")
|
||||||
|
struct TrashModelValidationTests {
|
||||||
|
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, live(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", live(order: "1024", title: "Live"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)",
|
||||||
|
tombstoned(order: "2048", title: "Trashed", deleted: "2026-03-01T09:00:00Z"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The ⌘⌫ twins enable exactly one of themselves, by the selection's liveness side")
|
||||||
|
func chordTwinsAreBinary() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try load(fixture)
|
||||||
|
|
||||||
|
let liveSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .live)
|
||||||
|
#expect(TrashModel.canDelete(selection: liveSelection, in: model))
|
||||||
|
#expect(!TrashModel.canActOnTrash(selection: liveSelection, in: model))
|
||||||
|
|
||||||
|
let trashedSelection = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card2)], liveness: .trashed)
|
||||||
|
#expect(!TrashModel.canDelete(selection: trashedSelection, in: model))
|
||||||
|
#expect(TrashModel.canActOnTrash(selection: trashedSelection, in: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Neither enables on an empty selection, or on one whose members have gone")
|
||||||
|
func nothingToActOn() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try load(fixture)
|
||||||
|
|
||||||
|
#expect(!TrashModel.canDelete(selection: .empty, in: model))
|
||||||
|
#expect(!TrashModel.canActOnTrash(selection: ItemReferenceSet(ids: [], liveness: .trashed), in: model))
|
||||||
|
|
||||||
|
// A selection the next reload will drop: the id names nothing, so the item that would write
|
||||||
|
// nothing does not look available.
|
||||||
|
let stale = ItemReferenceSet(ids: [ItemID(rawValue: Ident.indexless)], liveness: .live)
|
||||||
|
#expect(!TrashModel.canDelete(selection: stale, in: model))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A selection whose side disagrees with the item's own flag enables neither")
|
||||||
|
func sideMustMatch() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let model = try load(fixture)
|
||||||
|
|
||||||
|
// The homogeneous-by-liveness invariant makes this unreachable through the UI, and the
|
||||||
|
// reload rule ejects it if a foreign edit ever produces it — but the predicates must not
|
||||||
|
// trust that, since they are what stands between a stale set and a write.
|
||||||
|
let wrongSide = ItemReferenceSet(ids: [ItemID(rawValue: Ident.card1)], liveness: .trashed)
|
||||||
|
#expect(!TrashModel.canActOnTrash(selection: wrongSide, in: model))
|
||||||
|
#expect(!TrashModel.canDelete(selection: wrongSide, in: model))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// `BoardStore`'s trash operations — Delete, Put Back, Delete Immediately, Empty Trash, and
|
||||||
|
/// drag-to-restore (03-board-ui.md § Trash).
|
||||||
|
///
|
||||||
|
/// These drive a **real store over a real temp board** and then read the **raw bytes** back, never
|
||||||
|
/// the app's own read path, like every other write suite here: the interesting claims are about the
|
||||||
|
/// files — which key lands, which stamps follow it, which folders survive, and what comes through
|
||||||
|
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
|
||||||
|
|
||||||
|
// MARK: - Fixtures
|
||||||
|
|
||||||
|
private func tombstoned(order: String, title: String) -> String {
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: \(title)
|
||||||
|
order: \(order)
|
||||||
|
project: lanework # agent overlay
|
||||||
|
created: 2026-01-01T09:00:00Z
|
||||||
|
deleted: 2026-03-03T09:00:00Z
|
||||||
|
---
|
||||||
|
\(title) body.
|
||||||
|
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two live lanes with two cards each, plus one already-tombstoned card and one already-tombstoned
|
||||||
|
/// lane holding a live card and an own-flagged one — enough for every rule in this file.
|
||||||
|
@MainActor
|
||||||
|
private func makeBoard() throws -> WriterFixture {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", tombstoned(order: "2048", title: "Trashed"))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
|
||||||
|
try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
|
||||||
|
try fixture.item(Ident.lane3, tombstoned(order: "3072", title: "Gone"))
|
||||||
|
try fixture.item("\(Ident.lane3)/\(Ident.card4)", Item.rich(order: "1024", title: "Rides along"))
|
||||||
|
try fixture.item("\(Ident.lane3)/\(Ident.indexless)", tombstoned(order: "2048", title: "Own flag"))
|
||||||
|
return fixture
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lane1 = ItemID(rawValue: Ident.lane1)
|
||||||
|
private let lane2 = ItemID(rawValue: Ident.lane2)
|
||||||
|
private let lane3 = ItemID(rawValue: Ident.lane3)
|
||||||
|
private let card1 = ItemID(rawValue: Ident.card1)
|
||||||
|
private let card2 = ItemID(rawValue: Ident.card2)
|
||||||
|
private let card3 = ItemID(rawValue: Ident.card3)
|
||||||
|
private let card4 = ItemID(rawValue: Ident.card4)
|
||||||
|
private let ownFlag = ItemID(rawValue: Ident.indexless)
|
||||||
|
|
||||||
|
/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
|
||||||
|
/// through a tombstone or a Put Back byte-for-byte, in order.
|
||||||
|
private func untouchedLines(_ text: String) -> [Substring] {
|
||||||
|
text.split(separator: "\n", omittingEmptySubsequences: false).filter {
|
||||||
|
!$0.hasPrefix("modified") && !$0.hasPrefix("deleted:")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file's bytes and mtime — "minimal touch" stated the way `WriteFidelityTests` states it.
|
||||||
|
private func stat(_ fixture: WriterFixture, _ relativePath: String) throws -> (data: Data, modified: Date) {
|
||||||
|
let indexURL = fixture.url(relativePath).appendingPathComponent("index.md")
|
||||||
|
let data = try Data(contentsOf: indexURL)
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: indexURL.path)
|
||||||
|
guard let modified = attributes[.modificationDate] as? Date else {
|
||||||
|
Issue.record("no modification date for \(relativePath)")
|
||||||
|
return (data, .distantPast)
|
||||||
|
}
|
||||||
|
return (data, modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func reload(_ store: BoardStore) async {
|
||||||
|
store.handleWatcherEvent(.treeChanged(.foreign))
|
||||||
|
await store.awaitQuiescence()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ delete")
|
||||||
|
struct TrashDeleteTests {
|
||||||
|
|
||||||
|
@Test("Delete stamps deleted and modified, clears modified-by, and touches nothing else")
|
||||||
|
func tombstonesAndStamps() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
store.delete([card1])
|
||||||
|
|
||||||
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
let document = try FrontmatterDocument.parse(after)
|
||||||
|
#expect(document.deleted.value != nil)
|
||||||
|
#expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
|
||||||
|
// Everything the write does not own survives exactly, in order — the unknown key with its
|
||||||
|
// inline comment, the reserved `labels`, the original `created`, and the body.
|
||||||
|
#expect(untouchedLines(after) == untouchedLines(before))
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A multi-item delete is one bracket, and a lane's tombstone rewrites only the lane")
|
||||||
|
func batchAndLaneMinimalTouch() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let card = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)")
|
||||||
|
|
||||||
|
store.delete([lane2, card1])
|
||||||
|
|
||||||
|
// Both landed.
|
||||||
|
#expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane2)).deleted.value != nil)
|
||||||
|
#expect(try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
.deleted.value != nil)
|
||||||
|
// Hiding the subtree is the renderer's ancestor walk, not a stored flag: the lane's card is
|
||||||
|
// untouched, bytes *and* mtime.
|
||||||
|
let cardAfter = try stat(fixture, "\(Ident.lane2)/\(Ident.card3)")
|
||||||
|
#expect(cardAfter.data == card.data)
|
||||||
|
#expect(cardAfter.modified == card.modified)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Delete clears the selection")
|
||||||
|
func clearsSelection() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.select([card1], liveness: .live)
|
||||||
|
|
||||||
|
store.deleteSelection()
|
||||||
|
|
||||||
|
// m5's successor-selection grammar replaces this; until then, what was selected renders
|
||||||
|
// nowhere and the selection says so.
|
||||||
|
#expect(store.selection.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing")
|
||||||
|
func liveOnly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
|
||||||
|
let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)")
|
||||||
|
|
||||||
|
// `card2` carries its own flag; `card4` is live by its own flag but its lane is tombstoned,
|
||||||
|
// so effective liveness puts it on the trashed side too.
|
||||||
|
store.delete([card2, card4])
|
||||||
|
store.delete([])
|
||||||
|
store.delete([ItemID(rawValue: "00000000-0000-4000-8000-000000000000")])
|
||||||
|
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified)
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A read-only board refuses the delete without a second banner")
|
||||||
|
func readOnlyRefusesQuietly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
store.delete([card1])
|
||||||
|
|
||||||
|
#expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
|
||||||
|
#expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
|
||||||
|
#expect(store.bannerRows.contains { $0.id == "read-only-lock" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A readable-but-uneditable item refuses the write, banners it, and keeps its bytes")
|
||||||
|
func uneditableBanners() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable)
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.delete([card1])
|
||||||
|
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == Item.uneditable)
|
||||||
|
let posted = try #require(store.banners.oneShots.first)
|
||||||
|
#expect(posted.error.operation == .delete(title: "Odd"))
|
||||||
|
#expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't delete 'Odd' — "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Put Back
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ put back")
|
||||||
|
struct TrashPutBackTests {
|
||||||
|
|
||||||
|
@Test("A delete→Put Back round trip differs from the original only in the modified timestamp")
|
||||||
|
func roundTripFidelity() async throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let path = "\(Ident.lane1)/\(Ident.card1)"
|
||||||
|
let original = try fixture.indexText(path)
|
||||||
|
|
||||||
|
store.delete([card1])
|
||||||
|
await reload(store)
|
||||||
|
store.putBack([card1])
|
||||||
|
|
||||||
|
let after = try fixture.indexText(path)
|
||||||
|
// Restore fidelity is perfect because nothing ever moved: no residue of the key that made
|
||||||
|
// this a tombstone, and every other line — the inline comment included — as written.
|
||||||
|
#expect(!after.contains("deleted"))
|
||||||
|
#expect(untouchedLines(after) == untouchedLines(original))
|
||||||
|
#expect(try FrontmatterDocument.parse(after).order == .valid(1024))
|
||||||
|
|
||||||
|
// Position among siblings survives byte-for-byte, which is what "at its old order" means.
|
||||||
|
let cards = try #require(BoardLoader.load(boardRoot: fixture.root).model.lanes.first?.cards)
|
||||||
|
#expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2])
|
||||||
|
#expect(cards[0].isDeleted == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Putting back a lane splits its contents by flag — own-flag cards stay tombstoned")
|
||||||
|
func laneSplitsByFlag() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let ridesAlong = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)")
|
||||||
|
let ownFlagged = try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)")
|
||||||
|
|
||||||
|
store.putBack([lane3])
|
||||||
|
|
||||||
|
// Only the lane's own file was rewritten. The card that rides along was never flagged, so
|
||||||
|
// it simply reappears; the own-flagged one keeps its key and its row moves back to the
|
||||||
|
// trash — recovering it is deliberately a second Put Back.
|
||||||
|
#expect(!(try fixture.indexText(Ident.lane3).contains("deleted")))
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == ridesAlong.modified)
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane3)/\(Ident.indexless)").modified == ownFlagged.modified)
|
||||||
|
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
let lane = try #require(model.lanes.first { $0.id == lane3 })
|
||||||
|
#expect(!lane.isDeleted)
|
||||||
|
#expect(lane.cards.first { $0.id == card4 }?.isDeleted == false)
|
||||||
|
#expect(lane.cards.first { $0.id == ownFlag }?.isDeleted == true)
|
||||||
|
// And the own-flagged card now has a row of its own, which it did not while the lane was
|
||||||
|
// tombstoned.
|
||||||
|
#expect(TrashModel.entries(of: model).map(\.id).contains(ownFlag))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Put Back on a live item, an empty set, or an unknown id writes nothing")
|
||||||
|
func noOps() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let live = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
store.putBack([card1])
|
||||||
|
store.putBack([])
|
||||||
|
store.putBack([ItemID(rawValue: Ident.indexless.uppercased())])
|
||||||
|
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == live.modified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delete Immediately and Empty Trash
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ purge")
|
||||||
|
struct TrashPurgeTests {
|
||||||
|
|
||||||
|
@Test("Delete Immediately removes the folder and leaves everything else alone")
|
||||||
|
func purgeRemovesTheFolder() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let sibling = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
store.deleteImmediately([card2])
|
||||||
|
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == sibling.modified)
|
||||||
|
#expect(store.selection.isEmpty)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Purging a lane takes its whole folder, tombstoned cards and all")
|
||||||
|
func purgingALaneTakesItsSubtree() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.deleteImmediately([lane3])
|
||||||
|
|
||||||
|
#expect(!fixture.exists(Ident.lane3))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.card4)"))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)"))
|
||||||
|
#expect(fixture.exists(Ident.lane1), "the live lanes are untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Delete Immediately never reaches a live item")
|
||||||
|
func purgeIsTrashedOnly() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.deleteImmediately([card1, lane1])
|
||||||
|
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(fixture.exists(Ident.lane1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash purges every tombstone on the board, own-flag cards under a lane included")
|
||||||
|
func emptyTrashPurgesEverything() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.emptyTrash()
|
||||||
|
|
||||||
|
// The tombstoned card under a live lane, the tombstoned lane, and — through the lane's own
|
||||||
|
// folder — the card that carried its own flag inside it.
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(!fixture.exists(Ident.lane3))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane3)/\(Ident.indexless)"))
|
||||||
|
|
||||||
|
// Everything live survives, and the board now has an empty trash.
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card3)"))
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
#expect(TrashModel.isEmpty(model))
|
||||||
|
#expect(store.selection.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash on a board with no tombstones writes nothing")
|
||||||
|
func emptyTrashOnACleanBoard() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let card = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
store.emptyTrash()
|
||||||
|
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == card.modified)
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Every board is unrecoverable today, so Delete Immediately always confirms")
|
||||||
|
func purgeIsUnrecoverableEverywhere() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
// m7-git: the git milestone is what makes this answer `false` for some boards.
|
||||||
|
#expect(store.purgeIsUnrecoverable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Drag to restore
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("BoardStore ▸ drag to restore")
|
||||||
|
struct TrashDragRestoreTests {
|
||||||
|
|
||||||
|
@Test("A drop back on the card's own lane removes the key only — the folder never moves")
|
||||||
|
func sameLaneIsAPutBack() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let original = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||||
|
|
||||||
|
store.restoreByDrag(cardID: card2, intoLane: lane1)
|
||||||
|
|
||||||
|
let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(!after.contains("deleted"))
|
||||||
|
// The `order` is untouched, so the card returns where it was rather than at the bottom —
|
||||||
|
// the position-perfect restore a pure-view trash makes possible.
|
||||||
|
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
|
||||||
|
#expect(untouchedLines(after) == untouchedLines(original))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A drop on another lane restores and appends at that lane's bottom, in one bracket")
|
||||||
|
func crossLaneMovesAndAppends() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
|
||||||
|
store.restoreByDrag(cardID: card2, intoLane: lane2)
|
||||||
|
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(fixture.exists("\(Ident.lane2)/\(Ident.card2)"))
|
||||||
|
let after = try fixture.indexText("\(Ident.lane2)/\(Ident.card2)")
|
||||||
|
#expect(!after.contains("deleted"))
|
||||||
|
// m5-drag: the positional drop replaces this append. Lane two's one visible card is at
|
||||||
|
// 1024, so the arrival lands at 2048 — the Writer's own append over visible siblings.
|
||||||
|
#expect(try FrontmatterDocument.parse(after).order == .valid(2048))
|
||||||
|
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
let lane = try #require(model.lanes.first { $0.id == lane2 })
|
||||||
|
#expect(lane.cards.map(\.id.rawValue) == [Ident.card3, Ident.card2])
|
||||||
|
#expect(lane.cards.allSatisfy { !$0.isDeleted })
|
||||||
|
#expect(TrashModel.isEmpty(model) == false, "the tombstoned lane is still in the trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A drop that names nothing droppable writes nothing")
|
||||||
|
func noOps() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let trashedCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)")
|
||||||
|
let liveCard = try stat(fixture, "\(Ident.lane1)/\(Ident.card1)")
|
||||||
|
|
||||||
|
// A tombstoned destination lane is never a drop target (04 ▸ Drag and drop: "a card is
|
||||||
|
// never filed under a `deleted:` parent").
|
||||||
|
store.restoreByDrag(cardID: card2, intoLane: lane3)
|
||||||
|
// A lane that is not on the board at all.
|
||||||
|
store.restoreByDrag(cardID: card2, intoLane: ItemID(rawValue: Ident.indexless))
|
||||||
|
// A card that is not a trash row: live, and — for `card4` — hidden by its lane rather than
|
||||||
|
// by its own flag, so it has no row to drag in the first place.
|
||||||
|
store.restoreByDrag(cardID: card1, intoLane: lane2)
|
||||||
|
store.restoreByDrag(cardID: card4, intoLane: lane1)
|
||||||
|
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashedCard.modified)
|
||||||
|
#expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card1)").modified == liveCard.modified)
|
||||||
|
#expect(fixture.exists("\(Ident.lane3)/\(Ident.card4)"))
|
||||||
|
#expect(store.banners.oneShots.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A read-only board refuses the drop")
|
||||||
|
func readOnlyRefuses() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
store.enterVanishedRootLock()
|
||||||
|
|
||||||
|
store.restoreByDrag(cardID: card2, intoLane: lane2)
|
||||||
|
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("deleted:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The confirmations
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("TrashConfirmations")
|
||||||
|
struct TrashConfirmationsTests {
|
||||||
|
|
||||||
|
@Test("Delete Immediately raises the alert where the loss is real, and purges on confirm")
|
||||||
|
func purgeConfirmsThenActs() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
|
||||||
|
confirmations.requestPurge(of: [card2], in: store)
|
||||||
|
|
||||||
|
let pending = try #require(confirmations.pending)
|
||||||
|
#expect(pending.prompt.title == "Permanently delete \u{201C}Trashed\u{201D}?")
|
||||||
|
#expect(pending.action == .purge([card2]))
|
||||||
|
// Nothing has happened yet — the alert is what stands between the keystroke and the loss.
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
|
||||||
|
confirmations.confirm(in: store)
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
#expect(confirmations.pending == nil)
|
||||||
|
// Idempotent: the binding's own dismissal fires an instant after the button.
|
||||||
|
confirmations.confirm(in: store)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Cancelling dismisses and writes nothing")
|
||||||
|
func cancelWritesNothing() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
|
||||||
|
confirmations.requestPurge(of: [card2], in: store)
|
||||||
|
confirmations.cancel()
|
||||||
|
|
||||||
|
#expect(confirmations.pending == nil)
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card2)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash always confirms, and its scope is the whole trash")
|
||||||
|
func emptyTrashConfirms() throws {
|
||||||
|
let fixture = try makeBoard()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
|
||||||
|
confirmations.requestEmptyTrash(in: store)
|
||||||
|
|
||||||
|
let pending = try #require(confirmations.pending)
|
||||||
|
#expect(pending.action == .emptyTrash)
|
||||||
|
#expect(pending.prompt.title == "Permanently delete 1 lane and 1 card?")
|
||||||
|
|
||||||
|
confirmations.confirm(in: store)
|
||||||
|
#expect(TrashModel.isEmpty(try BoardLoader.load(boardRoot: fixture.root).model))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Neither command raises an alert with nothing to act on")
|
||||||
|
func nothingToConfirm() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
|
||||||
|
let store = try BoardStore(rootURL: fixture.root)
|
||||||
|
let confirmations = TrashConfirmations()
|
||||||
|
|
||||||
|
confirmations.requestEmptyTrash(in: store)
|
||||||
|
#expect(confirmations.pending == nil)
|
||||||
|
|
||||||
|
confirmations.requestPurge(of: [lane1], in: store)
|
||||||
|
#expect(confirmations.pending == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ Lanework is in early development. This list tracks what has actually shipped and
|
|||||||
|
|
||||||
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
- **Styling** — one style editor serves every anchor: a background grid of the twelve palette wells behind a leading None well that *removes* the key, and a curated grid of five dozen kanban-relevant SF Symbols behind a leading level-default well that does the same. It is selection-aware (the selected cards or lanes; the board with nothing selected) and states the current value per dimension across the whole target set — agreement selects a well, disagreement reads "—", and a hand-written hex or uncurated symbol states itself verbatim outside the grids, replaced by any well you choose. A batch applies as one bracketed commit that skips every target already carrying the value, and the open editor tracks its targets live: one deleted out from under it leaves the set, and the last one closes the editor rather than quietly retargeting the board. Reached from Board ▸ Style… (⌥⌘S) or a card's or lane's context menu, where a compact row of app-wide recent colours recolours in one click and a lane's menu also carries its width stepper. Colour renders at all three levels — a card's `background` as a left-edge stripe, a lane's as a full-width band along its top edge, the board's as the window's content background — each painting nothing at all when the value doesn't resolve, bytes on disk untouched.
|
||||||
|
|
||||||
|
- **The trash** — deletion is two-stage and Finder-shaped. File ▸ Delete (⌘⌫, or a plain ⌫) tombstones the selection in place — cards or lanes, one bracketed write for the whole batch — and View ▸ Show Trash reveals a trailing quasi-lane where tombstoned items live. It is a pure view: nothing moves on disk, so Put Back is byte-perfect and returns an item to its old position. The column takes exactly one width unit while shown, so showing it re-divides the window rather than resizing it. Its order is fully deterministic (newest deletion first, ties by folder name, an unreadable timestamp sorting oldest) and its ancestor walk is absolute — a tombstoned lane is one entry subsuming everything beneath it, its count naming the cards Put Back would return, so recovering a separately deleted card inside it is deliberately two steps. Drag a row out onto any lane to restore it there. Purging is Delete Immediately (⌥⌘⌫) and Empty Trash… (⇧⌘⌫), each confirmed where the loss is real and named by count; Empty Trash always covers the whole trash, never just what a filter is showing. Nothing edit-shaped — Open, Rename, Style… — applies to a tombstoned selection, and a selection never mixes trashed with live.
|
||||||
|
|
||||||
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
|
- **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board, and below that a Git section that says plainly that this board has no repository yet. The read-only lock disables the surface without closing it.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
Reference in New Issue
Block a user