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
+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
@MainActor