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
+11
View File
@@ -311,6 +311,15 @@ public final class ClipboardStore {
/// **The armed cut is tried first and consumed on success** "first armed paste MOVES the /// **The armed cut is tried first and consumed on success** "first armed paste MOVES the
/// surviving originals a second paste materializes copies from staging" and everything else /// surviving originals a second paste materializes copies from staging" and everything else
/// is the copy path, which is also where a voided cut lands. /// is the copy path, which is also where a voided cut lands.
///
/// **A paste is a user-initiated creation, so it clears the destination's search**
/// (04-interactions.md § Search, stated by mechanism "N, Return-creation, the header button,
/// empty-space double-click, paste, and Finder file drops alike"). Cards and lanes alike the
/// clipboard holds one or the other, and either arrives as an item the query may well not match.
/// Unqualified, too: 04 names the *mechanism*, so the armed cut's move clears exactly as the copy
/// does rather than earning a sub-rule for the one case where the items were already on this
/// board. It is cleared here rather than at V so the two staleness guards keep their meaning: a
/// paste the pasteboard moved under lands nothing, and so clears nothing.
private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) { private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) {
refresh() refresh()
// The pasteboard moved under this paste (another app copied while the chain settled): the // The pasteboard moved under this paste (another app copied while the chain settled): the
@@ -318,6 +327,8 @@ public final class ClipboardStore {
// doing nothing. // doing nothing.
guard payload?.copyID == manifest.copyID else { return } guard payload?.copyID == manifest.copyID else { return }
store.transient.noteUserCreation()
if let move = armedMove(for: manifest) { if let move = armedMove(for: manifest) {
let sources = move.folders.map(BoardStore.ItemSource.folder) let sources = move.folders.map(BoardStore.ItemSource.folder)
switch plan { switch plan {
+10
View File
@@ -1723,6 +1723,14 @@ public final class BoardStore {
/// string, since a missing key is the untitled state and `""` would be a real, blank title /// string, since a missing key is the untitled state and `""` would be a real, blank title
/// (01-storage-format.md § Frontmatter). /// (01-storage-format.md § Frontmatter).
/// ///
/// **A Finder file drop is a user-initiated creation, so it clears the search**
/// (04-interactions.md § Search, stated by mechanism: "N, Return-creation, the header button,
/// empty-space double-click, paste, and Finder file drops alike"). Cleared at the gesture, in
/// front of the write, exactly as the placeholder's begin clears it in front of the typing
/// `TransientBoardState.noteUserCreation()` is the rule's one home, and the *attach* half of the
/// same gesture (`importAttachments`) deliberately does not call it, because a drop on a card
/// creates nothing that could be born invisible.
///
/// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file /// **Partial failure is honest, and leaves no half-made card.** The batch stops at the first file
/// that cannot be imported an unreadable source, a vanished one, a disk with no room left /// that cannot be imported an unreadable source, a vanished one, a disk with no room left
/// which banners naming it; the cards already made keep their files, matching `importAttachments`' /// which banners naming it; the cards already made keep their files, matching `importAttachments`'
@@ -1735,6 +1743,8 @@ public final class BoardStore {
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
else { return } else { return }
transient.noteUserCreation()
let rendered = lane.cards.filter { !$0.isDeleted } let rendered = lane.cards.filter { !$0.isDeleted }
let target = min(max(0, index), rendered.count) let target = min(max(0, index), rendered.count)
let root = rootURL let root = rootURL
+61 -9
View File
@@ -257,6 +257,15 @@ public struct NewCardPlaceholder: Sendable, Equatable {
/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed /// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed
/// into another lane mid-typing is still renamed correctly. /// into another lane mid-typing is still renamed correctly.
/// ///
/// **The filter never touches it** (04-interactions.md § Search, settled): "an open inline rename
/// survives the filter hiding its card the editor is a surface the filter doesn't reach; it stays
/// open and focused, commits by UUID wherever the card lives, Escape abandons and the
/// vanish-discard rule stays reserved for true liveness flips". Two halves, in two places: this
/// editor outlives a reload that stops its card matching, because `resolve(against:)` discards only
/// on a *vanish* and `constrainToSearch(in:)` touches the selection and nothing else; and its
/// *field* outlives it too, because `LaneView.rendered` keeps the renaming card's slot in the
/// masonry for as long as the editor is open.
///
/// There is no phase enum. The placeholder needs one because it outlives its own commit (the /// There is no phase enum. The placeholder needs one because it outlives its own commit (the
/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for /// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for
/// the item is already on screen, and the commit's round trip simply updates it. /// the item is already on screen, and the commit's round trip simply updates it.
@@ -498,6 +507,44 @@ public final class TransientBoardState {
lastActiveLaneID = laneID lastActiveLaneID = laneID
} }
// MARK: - Creation's carve-out
/// **A user-initiated creation clears the query** 04-interactions.md § Search's one exception
/// to the pure predicate, and the single place it is stated.
///
/// > creating a card clears the search creation's carve-out exists because a brand-new card
/// > must not be born invisible, and it is **stated by mechanism, not by gesture** (settled):
/// > *any* user-initiated creation on the board clears the query N, Return-creation, the
/// > header button, empty-space double-click, paste, and Finder file drops alike while
/// > foreign/agent-filed cards keep riding the live filter.
///
/// So this is a *seam*, not a gesture's line: every path that mints an item because the user
/// asked for one calls it, and there are exactly three of them
///
/// - `beginPlaceholder(inLane:after:)` below, which is itself the funnel for the four inline
/// creation gestures (N, Return on a lane, the header button, a double-click on empty space);
/// - `ClipboardStore.perform` V, cards and lanes alike, at the moment the items actually
/// land (a paste the pasteboard went stale under lands nothing and so clears nothing);
/// - `BoardStore.createCards(fromFiles:inLane:at:)` the Finder file drop's *create* half. Its
/// attach half (`importAttachments`) deliberately does not call this: dropping files on a card
/// creates nothing, so there is no card to be born invisible.
///
/// **What is deliberately not here** is the other half of the same sentence: an item this board
/// receives without the user asking *it* for one keeps riding the filter. A cross-board drag
/// arrival is the near miss it is a transfer whose destination-side clear 04 does not state,
/// and the enumerated mechanisms above are the ones it does and an agent filing a card is the
/// far one (02-architecture.md's derived-result rule). **New Lane is not here either**, for the
/// carve-out's own reason rather than in spite of it: "lanes are never filtered out", so a lane
/// cannot be born invisible and has nothing to be rescued from N under a query adds a lane
/// showing a `0` badge, which is the filter behaving exactly as designed.
///
/// Widening the universe invalidates nothing, so unlike a *narrowing* write this needs no
/// snapshot and no `constrainToSearch(in:)` behind it which is why the rule can live on this
/// type at all rather than on the store (see `searchQuery`).
public func noteUserCreation() {
searchQuery = ""
}
// MARK: - The placeholder's lifecycle // MARK: - The placeholder's lifecycle
/// Opens the inline editor for a new card in `laneID`, replacing any editor already open and /// Opens the inline editor for a new card in `laneID`, replacing any editor already open and
@@ -511,10 +558,11 @@ public final class TransientBoardState {
/// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the /// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
/// focus is being taken by another editor before the user has said they are done. /// focus is being taken by another editor before the user has said they are done.
/// ///
/// **Creation clears the search, and this is the funnel** (04-interactions.md § Search): "a /// **Creation clears the search, and this is the funnel for the inline gestures**
/// brand-new card must not be born invisible". Every entry point to creation goes through here /// (04-interactions.md § Search): "a brand-new card must not be born invisible". N, Return on a
/// N, Return on a lane, the lane header's button, a double-click on empty space so the /// lane, the lane header's button and a double-click on empty space all arrive here, so the four
/// carve-out is stated once instead of four times. /// of them state the carve-out once by calling the rule's one home, `noteUserCreation()`,
/// which the paste and Finder-file-drop paths call too.
/// ///
/// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate /// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate
/// with one exception, not two". A rename committed under an active search re-runs the /// with one exception, not two". A rename committed under an active search re-runs the
@@ -526,7 +574,7 @@ public final class TransientBoardState {
/// rule), or `nil` for the lane's bottom which is what Return, the header button, and a /// rule), or `nil` for the lane's bottom which is what Return, the header button, and a
/// double-click on empty space all pass. /// double-click on empty space all pass.
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) { public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
searchQuery = "" noteUserCreation()
renameEditor = nil renameEditor = nil
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID) newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
noteActiveLane(laneID) noteActiveLane(laneID)
@@ -722,10 +770,14 @@ public final class TransientBoardState {
/// step from somewhere the user cannot see. Neither has to stay *in* the selection that /// step from somewhere the user cannot see. Neither has to stay *in* the selection that
/// asymmetry is `resolve`'s and survives here untouched. /// asymmetry is `resolve`'s and survives here untouched.
/// ///
/// **The drag and the pending cut are deliberately left alone.** 04 hides cards and says one /// **The drag, the pending cut and the rename editor are deliberately left alone.** 04 hides
/// thing about the consequence that they leave the *selection*. A cut is staged content /// cards and says one thing about the consequence that they leave the *selection*. A cut is
/// waiting for a paste that may well happen after the search clears, and a drag under a live /// staged content waiting for a paste that may well happen after the search clears, and a drag
/// filter is a gesture in flight, not a set the filter has any claim on. /// under a live filter is a gesture in flight, not a set the filter has any claim on. The
/// editor's absence is the settled ruling in person: "an open inline rename survives the filter
/// hiding its card the vanish-discard rule stays reserved for true liveness flips"
/// (`RenameEditor`), so a foreign edit that stops the renaming card matching drops it from the
/// selection here and leaves the keystrokes exactly where the user left them.
public func constrainToSearch(in snapshot: BoardModel) { public func constrainToSearch(in snapshot: BoardModel) {
let filter = SearchFilter(query: searchQuery) let filter = SearchFilter(query: searchQuery)
guard filter.isActive else { return } guard filter.isActive else { return }
+18 -1
View File
@@ -505,7 +505,8 @@ struct BoardView: View {
/// simply divides across one more unit and every lane compresses. /// simply divides across one more unit and every lane compresses.
/// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read /// - a **cross-board lane arrival**'s units while its shadow hovers here, by the same rule read
/// from the destination's side: the shadow occupies its units, and the strip has to make room /// from the destination's side: the shadow occupies its units, and the strip has to make room
/// for them or the shadow would be drawn at a width the lane will not have. /// for them or the shadow would be drawn at a width the lane will not have
/// (`arrivingLaneUnits`).
private func standardWidth(stripWidth: CGFloat) -> CGFloat { private func standardWidth(stripWidth: CGFloat) -> CGFloat {
if resize.isActive { return resize.standard } if resize.isActive { return resize.standard }
var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0) var units = LaneLayoutMath.totalUnits(of: liveLanes, trashUnits: isTrashVisible ? 1 : 0)
@@ -519,6 +520,22 @@ struct BoardView: View {
/// The units a cross-board lane run would add to this strip while its shadow is proposed here; /// The units a cross-board lane run would add to this strip while its shadow is proposed here;
/// zero for a within-board drag, whose lanes are already counted. /// zero for a within-board drag, whose lanes are already counted.
///
/// **A cross-board lane arrival pre-divides the destination strip during hover**
/// (04-interactions.md Drag and drop, settled): "while a foreign lane drag proposes into a
/// board, the destination's standard width is computed with the arriving run's units included, so
/// the shadow draws at the width the lane will actually take without this it overflows the
/// strip (the pathfinder's `stripWidthUnits`)". The units are the run's own, frozen at pickup by
/// the *source* board (`DragSession.laneUnits`) both boards are open in this app, so nothing
/// has to cross the pasteboard for the destination to know how wide its visitor is.
///
/// Gated on a live proposal *on this board* rather than on hover alone, which is the same bullet's
/// accepted residue: "the first entry samples the un-widened standard for one frame before
/// hysteresis settles accepted, imperceptible".
///
/// One number, two readers: body evaluation draws the strip and the shadow with it
/// (`standardWidth`), and the drop delegates retarget against it through `dropContext.standard`,
/// so the zones can never disagree with what is on screen.
private var arrivingLaneUnits: Int { private var arrivingLaneUnits: Int {
let session = appModel.dragSession let session = appModel.dragSession
guard session.isDraggingLanes, guard session.isDraggingLanes,
+44 -4
View File
@@ -298,7 +298,14 @@ struct LaneView: View {
/// masonry iterates. That is deliberate rather than incidental: "The count reads the search /// masonry iterates. That is deliberate rather than incidental: "The count reads the search
/// filter like every other surface during a search it shows the visible count, not the /// filter like every other surface during a search it shows the visible count, not the
/// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge /// total", so when m5's search card narrows `renderedCards` to the filter's survivors the badge
/// follows by construction, with no second rule to keep in step. /// follows by construction, with no second rule to keep in step. **A lane the query empties
/// shows `0` and keeps its slot** (04-interactions.md § Search, settled: "lanes are never
/// filtered out the search filters cards, and the board's structure is not a search result")
/// which is `BoardView.liveLanes` never consulting the filter at all, made visible here.
///
/// The rename exemption rides along for the same reason every other rule does: the badge counts
/// what the body renders, and while an inline rename is open its card is one of the things the
/// body renders (see `renderedCards`).
private var countBadge: some View { private var countBadge: some View {
Text("\(renderedCards.count)") Text("\(renderedCards.count)")
.font(.caption) .font(.caption)
@@ -708,10 +715,43 @@ struct LaneView: View {
/// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder /// `countBadge`), the drop zones' resting layout, the marquee registration and the Finder
/// file-drop targets all read this list or the registry it populates, so none of them needs a /// file-drop targets all read this list or the registry it populates, so none of them needs a
/// rule of its own. /// rule of its own.
///
/// **With one exception, and it is the open inline rename** (04-interactions.md § Search,
/// settled): "an open inline rename survives the filter hiding its card the editor is a surface
/// the filter doesn't reach; it stays open and focused, commits by UUID wherever the card lives,
/// Escape abandons". The editor is drawn *inside* its card's slot (`CardFaceView.isRenaming`), so
/// on this board a surface the filter doesn't reach means precisely a slot the filter doesn't
/// take away: filtering the card out would unmount the field mid-keystroke and silently drop what
/// the user had typed, which is the dirty-buffer courtesy read backwards. The exemption lasts
/// exactly as long as the editor commit or Escape retires it, the predicate applies again in
/// the same pass, and a card that no longer matches animates out then (which is also the whole of
/// "rename deliberately gets no carve-out": the *query* still stands throughout).
///
/// It cannot arrive by typing, because focusing the search field is focus loss and commits the
/// rename first; the case it serves is a foreign edit that stops the card matching while the user
/// is renaming it.
private var renderedCards: [Card] { private var renderedCards: [Card] {
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL) Self.rendered(
let filter = store.searchFilter lane.cards,
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) } hiddenByDrag: drops.session.hiddenMembers(onBoardRooted: store.rootURL),
filter: store.searchFilter,
renaming: store.transient.renameEditor?.targetID
)
}
/// `renderedCards` as a pure function of its four inputs see there for every rule it applies.
/// Split out only so the rules can be pinned without a view (`SearchFilterTests`); the lane's
/// masonry, its count badge and its drop zones all read the property, which reads this.
nonisolated static func rendered(
_ cards: [Card],
hiddenByDrag hidden: Set<ItemID>,
filter: SearchFilter,
renaming: ItemID?
) -> [Card] {
cards.filter { card in
guard !card.isDeleted, !hidden.contains(card.id) else { return false }
return filter.matches(card) || card.id == renaming
}
} }
// MARK: - Selection // MARK: - Selection
+10 -7
View File
@@ -97,13 +97,16 @@ extension View {
/// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the /// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the
/// masonry rather than re-running the predicate. /// masonry rather than re-running the predicate.
/// ///
/// One bounded honesty about that: a card leaving under the filter's transition stays registered /// **A leaving card stays input-reachable for its out-transition** (04-interactions.md § Search,
/// until the transition ends (`onDisappear` fires when the view really goes, not when the query /// settled): "marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card
/// changed), so for the length of one content-reflow spring a fading card is still sweepable and /// mid-departure is briefly reachable while already out of the selection accepted: it is
/// still an arrow's neighbour. It is on screen for exactly that span, and it has already left the /// literally on screen for that span, and closing the window would teach three input sites a
/// selection (`TransientBoardState.constrainToSearch(in:)` runs at the keystroke), so the window /// predicate the layout already applied". That is this modifier's construction rather than a rule
/// is visible rather than phantom accepted rather than closed by teaching three input sites a /// it implements: `onDisappear` fires when SwiftUI really removes the view at the end of the
/// predicate the layout already applied. /// card transition `Motion.contentReflow` is timing not when the query changed, so the
/// registration outlives the filter by exactly the length of the animation and not a frame more.
/// The card has already left the selection by then (`TransientBoardState.constrainToSearch(in:)`
/// runs at the keystroke), which is what makes the window visible rather than phantom.
@MainActor @MainActor
func marqueeTarget( func marqueeTarget(
_ id: ItemID, _ id: ItemID,
+55
View File
@@ -142,6 +142,24 @@ struct ImportAttachmentsToCardTests {
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
/// **Attaching is not creating, so the query stands** (04-interactions.md § Search): the
/// carve-out is for an item that would otherwise be born invisible, and a drop on a card mints
/// nothing. Its twin the *create* half clearing is `creatingFromFilesClearsTheSearch` below.
@Test("A drop that only attaches leaves the search exactly where it was")
func attachingLeavesTheSearch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.searchQuery = "first"
store.importAttachments([shot], toCard: card1)
#expect(store.searchQuery == "first")
}
@Test("A name already taken is renamed Finder-style rather than overwritten") @Test("A name already taken is renamed Finder-style rather than overwritten")
func collisionsRename() throws { func collisionsRename() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
@@ -415,6 +433,43 @@ struct CreateCardsFromFilesTests {
#expect(store.banners.oneShots.count == 1) #expect(store.banners.oneShots.count == 1)
#expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png")) #expect(store.banners.oneShots.first?.error.operation == .importAttachment(filename: "gone.png"))
} }
/// **The create half is a user-initiated creation, so it clears the query** (04-interactions.md
/// § Search, stated by mechanism: "N, Return-creation, the header button, empty-space
/// double-click, paste, and Finder file drops alike"). The card is titled `shot`, which the
/// standing query would hide the whole point of the carve-out.
@Test("A file drop that creates cards clears the search — the same rule ⌘N obeys")
func creatingFromFilesClearsTheSearch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.searchQuery = "first"
store.createCards(fromFiles: [shot], inLane: lane1, at: 1)
#expect(store.searchQuery.isEmpty)
}
/// The other side of the same rule: a drop that creates nothing clears nothing. A destination the
/// reload took away is not a creation either nothing was minted, so nothing could be born
/// invisible.
@Test("A drop with no destination creates nothing and leaves the query standing")
func aRefusedCreateLeavesTheSearch() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let sources = try DropSources()
defer { sources.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
let shot = try sources.file("shot.png")
store.searchQuery = "first"
store.createCards(fromFiles: [shot], inLane: ItemID(rawValue: "no-such-lane"), at: 0)
#expect(store.searchQuery == "first")
}
} }
// MARK: - Folders, refused // MARK: - Folders, refused
+102
View File
@@ -311,6 +311,108 @@ struct PasteFromTrashTests {
} }
} }
// MARK: - The destination's search, and the stale pasteboard
/// Two rules that meet at the same guard.
///
/// **A paste is a user-initiated creation, so it clears the destination's query**
/// (04-interactions.md § Search, stated by mechanism: "N, Return-creation, the header button,
/// empty-space double-click, paste, and Finder file drops alike") cards and lanes alike, since the
/// clipboard holds one or the other and both mint items on arrival.
///
/// **The pasteboard is re-read lazily, and a stale paste no-ops** (04 Clipboard, settled):
/// "changeCount is checked on activation, on menu validation, and before paste no timers the
/// paste itself re-validates and no-ops nothing stale ever lands, which is the guarantee that
/// matters". Both checks are pinned here, and the clear rides behind the second of them: a paste that
/// lands nothing clears nothing.
@MainActor
@Suite("Paste ▸ the destination's search and the stale pasteboard")
struct PasteSearchAndStalenessTests {
@Test("A card paste clears the destination board's search")
func aCardPasteClearsTheSearch() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
// "First" would be hidden by this query exactly the card that must not arrive invisible.
target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value
#expect(target.searchQuery.isEmpty)
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident", "First"])
}
@Test("A lane paste clears it too — the rule is creation, not the payload's kind")
func aLanePasteClearsTheSearch() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardLane2], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.searchQuery = "resident"
await harness.clipboard.paste(into: target)?.value
#expect(target.searchQuery.isEmpty)
#expect(try pasted(destination).lanes.count == 2)
}
@Test("Another app taking the pasteboard before ⌘V: the paste is refused outright")
func aTakeoverBeforeThePasteRefuses() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
harness.pasteboard.takeOver()
target.select([destinationLane], liveness: .live)
target.searchQuery = "resident"
// `refresh()` at the front of the paste sees the moved changeCount, so there is no payload
// and no task at all the same condition the menu item's enablement reads.
#expect(harness.clipboard.paste(into: target) == nil)
#expect(harness.clipboard.canPaste(into: target) == false)
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
#expect(target.searchQuery == "resident")
}
@Test("Another app taking it while the staging chain settles: the paste re-validates and lands nothing")
func aTakeoverMidPasteLandsNothing() async throws {
let harness = try makeClipboardHarness()
defer { harness.tearDown() }
let destination = try makeDestination()
defer { destination.tearDown() }
let target = try BoardStore(rootURL: destination.root)
harness.store.select([clipboardCard1], liveness: .live)
harness.clipboard.copy(from: harness.store)
target.select([destinationLane], liveness: .live)
target.searchQuery = "resident"
// The gesture passed validation; the takeover lands while the task is still waiting on the
// staging chain, which is the window 04 calls the brief lie. Synchronous, so the task cannot
// have run yet: it can only resume where this test suspends.
let paste = harness.clipboard.paste(into: target)
harness.pasteboard.takeOver()
await paste?.value
#expect(try pastedTitles(destinationLane, in: destination) == ["Resident"])
// Nothing landed, so nothing was created and the query the user was running stands.
#expect(target.searchQuery == "resident")
}
}
// MARK: - The deferred cut // MARK: - The deferred cut
@MainActor @MainActor
+112
View File
@@ -549,3 +549,115 @@ struct SearchFilterStoreTests {
#expect(store.selection.ids == [card2]) #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)
}
}