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:
@@ -142,6 +142,24 @@ struct ImportAttachmentsToCardTests {
|
||||
#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")
|
||||
func collisionsRename() throws {
|
||||
let fixture = try makeBoard()
|
||||
@@ -415,6 +433,43 @@ struct CreateCardsFromFilesTests {
|
||||
#expect(store.banners.oneShots.count == 1)
|
||||
#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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user