Collapsible lanes — frontmatter-backed slim strips outside the width division
A lane folds to a fixed slim vertical strip carrying its glyph, its card-count badge and its title turned on its side, and the strip is deliberately not part of the window's division: the expanded lanes' units divide what is left once each folded strip's fixed width has come off the top, so folding a lane is a re-divide trigger of the Show/Hide Trash family — the window never moves and the siblings grow into what the lane gave up. The state is a first-class lane frontmatter key, `collapsed: true`, and document state exactly as `width` is: the files are the board, so an agent folds a lane by writing one key. Absent means expanded, expanding removes the key rather than writing `false` (the remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None well's `background`), and the lane's `width` rides along untouched so expanding restores the lane the user had. The read is `width`'s leniency one type over — a boolean scalar or a quoted boolean word reads as itself, everything else has no reading at all and renders as expanded, bytes preserved either way. Toggling is the header's always-visible collapse chevron, the lane context menu's single Collapse Lane / Expand Lane row, and a plain click anywhere on the strip; a modified click on the strip stays the ordinary selection grammar, so a folded lane is still selectable by pointer. The title reads bottom-up and is justified to the top of the room below the strip's chrome (owner ruling 2026-08-08), truncating against the strip's own height. While folded the lane draws no cards at all, which is what makes every exclusion true by construction rather than by a guard per gesture: no card face means no marquee target and no navigation frame, and no registered grid means the masonry's drop zones have nothing to resolve against. What did need code is the half that names absolute destinations — the option-arrow jumps and the arrow seed scan past a folded lane, the lane domain's down-arrow is inert on one, and New Card skips it (a selection inside one falls through to the last-active lane, the stale selection's rule). A drop on the strip appends at the lane's end, cards and Finder files alike, with an accent edge standing in for the shadow the strip has no masonry to open; there is no hover-to-auto- expand yet. Lane reorder works on the strip, and a dragged folded lane carries its fold, so its shadow and its replica are the strip rather than its units. The write is `writeLaneWidths` clause for clause — one `updateIndex` bracket, the same stamp behaviour, the same three do-nothing paths — with two new `WriteOperation` cases and two new undo verbs rather than one of each, because a banner or an Edit-menu row that said "resize" after Collapse Lane would name a control the user never touched. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -897,6 +897,14 @@ public final class BannerCenter {
|
||||
"Couldn't generate this board's background"
|
||||
case let .resize(title):
|
||||
if let title { "Couldn't resize '\(title)'" } else { "Couldn't resize the item" }
|
||||
case let .collapse(title):
|
||||
// **The menu row's own word, in both directions** (03-board-ui.md § Lane ▸ Collapsed
|
||||
// lanes): the user pressed Collapse Lane or Expand Lane, and a shared sentence would tell
|
||||
// half of them about a gesture they did not make. It says "lane" rather than "the item"
|
||||
// when untitled, which is the one place in this switch that can: only a lane has the key.
|
||||
if let title { "Couldn't collapse '\(title)'" } else { "Couldn't collapse the lane" }
|
||||
case let .expand(title):
|
||||
if let title { "Couldn't expand '\(title)'" } else { "Couldn't expand the lane" }
|
||||
case let .rename(title):
|
||||
// The title here is the item's name *before* the edit — the one the user is still
|
||||
// looking at — which is what makes "Couldn't rename 'Fix login'" (02's own example
|
||||
|
||||
@@ -1590,6 +1590,118 @@ public final class BoardStore: HealHost {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lane collapse
|
||||
|
||||
/// Folds a lane to its slim strip, or unfolds it — the one commit point every collapse gesture
|
||||
/// shares (03-board-ui.md § Lane ▸ Collapsed lanes): the header chevron, the context menu's
|
||||
/// Collapse/Expand Lane row, and a click on the collapsed strip's body all land here.
|
||||
///
|
||||
/// **`width`'s write, one key over, deliberately.** Collapse is *document* state exactly as width
|
||||
/// is — the files are the board, so an agent folds a lane by writing `collapsed: true` and the app
|
||||
/// follows on the next reload — so this mirrors `writeLaneWidths` clause for clause: the same
|
||||
/// `updateIndex` bracket, the same stamp behaviour (a collapse rewrites content, so `modified` is
|
||||
/// stamped and `modified-by` cleared — `WriteOperation.rewritesOrderOnly` is `false` for both new
|
||||
/// cases), the same three do-nothing paths, and the same remove-at-default rule.
|
||||
///
|
||||
/// **Expanding removes the key; nothing ever writes `false`** (`FrontmatterKeys.collapsed` — the
|
||||
/// remove-at-default family beside a one-unit `width`, the empty rename's `title` and the None
|
||||
/// well's `background`). A hand-written `collapsed: false` reads as expanded, so asking to expand
|
||||
/// such a lane is an unchanged value and writes nothing at all — the legal hand edit survives
|
||||
/// until the app itself next edits the key, which is `width: 1`'s rule verbatim.
|
||||
///
|
||||
/// **The lane's `width` is untouched**, which is the whole of "expanding restores the lane the user
|
||||
/// had": one key is written, the other is not read.
|
||||
///
|
||||
/// Three ways this does nothing, all `setLaneWidth`'s: an id that is not in the snapshot is ignored
|
||||
/// (the lane vanished under the gesture), a value the lane already reads as writes nothing (a
|
||||
/// double-collapse must not stamp `modified`), and a refusal is the banner's — `performWrite` posts
|
||||
/// every `BoardWriteError` before it rethrows, so the rethrow is swallowed and the lane stays as it
|
||||
/// was, which is the truth.
|
||||
///
|
||||
/// **A card selection inside the lane becomes the lane** (this milestone's call, one of the two
|
||||
/// selection rules a fold needs): the cards stop being rendered, so a selection naming them would
|
||||
/// leave the arrows with no frame to step from and the ⌫ target invisible. Re-selecting the lane is
|
||||
/// the placeholder commit's own move ("Return commits and re-selects the lane") applied to the
|
||||
/// gesture that takes the cards away, and it leaves the strip itself as the selected thing — which
|
||||
/// is what the user just acted on. A *lane* selection is untouched, and expanding never touches the
|
||||
/// selection at all.
|
||||
///
|
||||
/// **Returns whether bytes reached disk**, `setLaneWidth`'s answer for its reason; discardable
|
||||
/// because every caller here is a menu row or a click with nothing to do with it.
|
||||
@discardableResult
|
||||
public func setLaneCollapsed(_ id: ItemID, collapsed: Bool) -> Bool {
|
||||
guard let lane = snapshot.lanes.first(where: { $0.id == id }),
|
||||
LaneLayoutMath.isCollapsed(lane) != collapsed
|
||||
else { return false }
|
||||
|
||||
let folder = rootURL.appendingPathComponent(id.rawValue)
|
||||
let prior = lane.collapsed
|
||||
let title = lane.title.value
|
||||
|
||||
// The closure's signature is spelled out for `writeLaneWidths`' reason — `try?` widens an
|
||||
// inferred typed `throws` to `any Error`, which `performWrite` will not take.
|
||||
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
|
||||
try Self.setCollapsed(collapsed, at: folder)
|
||||
}
|
||||
guard landed != nil else { return false }
|
||||
|
||||
// collapse/expand → the prior reading (13-native-undo.md ▸ Rules). The validated field is
|
||||
// `collapsed`, read as the app reads it: `nil` is the absent key, which is what an expand
|
||||
// leaves and what the inverse of a collapse restores whenever the prior was anything but a
|
||||
// real `true` — a missing key, a hand-written `false`, and a malformed value all inverse to
|
||||
// "remove it", exactly as `restoreCollapsed` writes them.
|
||||
registerStep(
|
||||
HistoryPhrase.name(collapsed ? .collapse : .expand, kind: .lane),
|
||||
subject: title,
|
||||
undoExpects: [.present(folder, .collapsed(collapsed ? true : nil))],
|
||||
redoExpects: [.present(folder, .collapsed(prior.value == true ? true : nil))]
|
||||
) { _ in
|
||||
try BoardWriter.updateIndex(
|
||||
inItemFolder: folder,
|
||||
operation: prior.value == true ? .collapse(title: nil) : .expand(title: nil)
|
||||
) { document in
|
||||
Self.restoreCollapsed(prior, in: &document)
|
||||
}
|
||||
} redo: { _ in
|
||||
try Self.setCollapsed(collapsed, at: folder)
|
||||
}
|
||||
|
||||
if collapsed, selection.container == .board,
|
||||
lane.cards.contains(where: { selection.ids.contains($0.id) }) {
|
||||
select([id], in: .board, anchor: id, head: id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// The collapse write itself, spelled once so the gesture and its redo cannot drift apart on the
|
||||
/// remove-at-default rule — `setWidth`'s shape, and the operation follows the *direction* so a
|
||||
/// failure is announced in the word the user pressed. The title rides along from `updateIndex`'s
|
||||
/// own enrichment, as it does for every other operation that carries one.
|
||||
private static func setCollapsed(_ collapsed: Bool, at folder: URL) throws(BoardWriteError) {
|
||||
let operation: WriteOperation = collapsed ? .collapse(title: nil) : .expand(title: nil)
|
||||
try BoardWriter.updateIndex(inItemFolder: folder, operation: operation) { document in
|
||||
if collapsed {
|
||||
document.set(FrontmatterKeys.collapsed, to: .bool(true))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.collapsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The inverse's write: the key back as a real `true`, or gone.
|
||||
///
|
||||
/// **A malformed or `false` prior inverses to the removed key**, never to the bytes that were
|
||||
/// there: this app writes `collapsed` in exactly one shape, and reproducing somebody's
|
||||
/// `collapsed: maybe` would be the undo inventing a value. Either way the reading is restored
|
||||
/// exactly — both of those read as expanded — which is what an inverse owes the user.
|
||||
private static func restoreCollapsed(_ prior: FieldValue<Bool>, in document: inout FrontmatterDocument) {
|
||||
if prior.value == true {
|
||||
document.set(FrontmatterKeys.collapsed, to: .bool(true))
|
||||
} else {
|
||||
document.remove(FrontmatterKeys.collapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Styling
|
||||
|
||||
/// One item a style gesture is about to act on: where its `index.md` is, and what the three
|
||||
|
||||
Reference in New Issue
Block a user