Realign search, clipboard, and lane-hover code with the second batch

Creation now clears the search by mechanism, not gesture: one seam
(noteUserCreation) states 04's rule once, called from the placeholder
funnel, paste — cards and lanes, after the staleness guard so a stale
paste clears nothing — and Finder file-drop creation; the attach path
deliberately doesn't clear, and cross-board arrivals and New Lane stay
outside the seam (a transfer isn't creation; a lane can't be born
invisible). An open inline rename now survives the filter hiding its
card: the model already kept the editor, but the field renders in the
card's slot, so renderedCards keeps the renaming card's slot exactly as
long as the editor is open — the query stands throughout, and commit or
Escape lets the predicate apply in the same pass. Verified conformant
and newly pinned: query-emptied lanes keep their slot with a 0 badge,
pasteboard staleness (takeover before paste and mid-staging both no-op),
out-transition reachability and the strip pre-divide hold by
construction with comments citing their rulings.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 08:45:12 -04:00
parent 524488122f
commit 5c0c0e5619
9 changed files with 423 additions and 21 deletions
+112
View File
@@ -549,3 +549,115 @@ struct SearchFilterStoreTests {
#expect(store.selection.ids == [card2])
}
}
// MARK: - What the filter does not reach
/// The three surfaces 04-interactions.md § Search exempts from the predicate, each settled and each
/// with a different mechanism behind it:
///
/// - **lanes**, which "are never filtered out" nothing consults the filter to decide whether to
/// build a lane, so an all-misses lane keeps its slot and its badge simply reads `0`;
/// - **an open inline rename**, which "survives the filter hiding its card" the editor outlives a
/// reload that stops its card matching, and its card keeps the masonry slot the field is drawn in;
/// - the **selection's** own carve-out is the opposite claim and lives in the suite above.
@MainActor
@Suite("SearchFilter — the surfaces it does not reach")
struct SearchFilterExemptionTests {
@Test("A lane the query empties keeps its slot: the board's structure is not a search result")
func lanesAreNeverFilteredOut() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "login")
// Every live lane is in the visible universe, `Done` which holds only a miss included.
let visible = filter.visibleIDs(in: model, on: .live)
#expect(visible.isSuperset(of: [lane1, lane2, lane3]))
#expect(!visible.contains(card5))
// And what the emptied lane *renders* is nothing at all, which is the `0` badge: the count
// reads this same list (`LaneView.countBadge`).
let done = try #require(model.lanes.first { $0.id == lane3 })
#expect(LaneView.rendered(done.cards, hiddenByDrag: [], filter: filter, renaming: nil).isEmpty)
// The lane with one match keeps exactly that one.
let todo = try #require(model.lanes.first { $0.id == lane1 })
#expect(LaneView.rendered(todo.cards, hiddenByDrag: [], filter: filter, renaming: nil)
.map(\.id) == [card1])
}
@Test("An open inline rename keeps its card's slot, and loses it the moment the editor closes")
func theRenamingCardKeepsItsSlot() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let filter = SearchFilter(query: "login")
let doing = try #require(model.lanes.first { $0.id == lane2 })
// `card4` misses the query; while it is being renamed it renders anyway, because the field is
// drawn in its slot and unmounting the slot would discard the keystrokes.
#expect(LaneView.rendered(doing.cards, hiddenByDrag: [], filter: filter, renaming: card4)
.map(\.id) == [card3, card4])
#expect(LaneView.rendered(doing.cards, hiddenByDrag: [], filter: filter, renaming: nil)
.map(\.id) == [card3])
}
@Test("A dragged card stays lifted even while it is the one being renamed")
func theLiftOutranksTheExemption() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let model = try load(fixture)
let doing = try #require(model.lanes.first { $0.id == lane2 })
// The exemption is about the *filter*, and only the filter: a card lifted out of the resting
// layout is not being hidden, it is being carried.
#expect(LaneView.rendered(
doing.cards, hiddenByDrag: [card3], filter: .inactive, renaming: card3
).map(\.id) == [card4])
}
@Test("A foreign edit that stops the renaming card matching leaves the editor open and focused")
func theEditorSurvivesAFilteringReload() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.select([card1], liveness: .live)
store.transient.beginRename(of: card1, currentTitle: "Fix login")
store.transient.updateRenameDraft("Fix login thoroughly")
// An agent edits the title out of the match while the user is typing. Not a vanish the card
// is still there so the vanish-discard rule stays out of it.
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
item(order: "1024", title: "Fix auth", body: "The auth flow breaks on retry.")
)
await reload(store)
#expect(store.transient.renameEditor?.targetID == card1)
#expect(store.transient.renameEditor?.draftTitle == "Fix login thoroughly")
// The card left the *selection* that rule is untouched and the commit still writes it.
#expect(store.selection.isEmpty)
store.commitRename()
await reload(store, origin: .appMediated)
#expect(try card(card1, in: store.snapshot).title.value == "Fix login thoroughly")
}
@Test("A vanish still discards the editor — the carve-out is the filter's, not liveness's")
func aVanishStillDiscardsTheEditor() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.searchQuery = "login"
store.transient.beginRename(of: card1, currentTitle: "Fix login")
try fixture.item(
"\(Ident.lane1)/\(Ident.card1)",
tombstoned(order: "1024", title: "Fix login", body: "The auth flow breaks on retry.")
)
await reload(store)
#expect(store.transient.renameEditor == nil)
}
}