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:
@@ -311,6 +311,15 @@ public final class ClipboardStore {
|
||||
/// **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
|
||||
/// 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) {
|
||||
refresh()
|
||||
// The pasteboard moved under this paste (another app copied while the chain settled): the
|
||||
@@ -318,6 +327,8 @@ public final class ClipboardStore {
|
||||
// doing nothing.
|
||||
guard payload?.copyID == manifest.copyID else { return }
|
||||
|
||||
store.transient.noteUserCreation()
|
||||
|
||||
if let move = armedMove(for: manifest) {
|
||||
let sources = move.folders.map(BoardStore.ItemSource.folder)
|
||||
switch plan {
|
||||
|
||||
@@ -1723,6 +1723,14 @@ public final class BoardStore {
|
||||
/// string, since a missing key is the untitled state and `""` would be a real, blank title
|
||||
/// (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
|
||||
/// 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`'
|
||||
@@ -1735,6 +1743,8 @@ public final class BoardStore {
|
||||
let lane = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted })
|
||||
else { return }
|
||||
|
||||
transient.noteUserCreation()
|
||||
|
||||
let rendered = lane.cards.filter { !$0.isDeleted }
|
||||
let target = min(max(0, index), rendered.count)
|
||||
let root = rootURL
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// 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.
|
||||
@@ -498,6 +507,44 @@ public final class TransientBoardState {
|
||||
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
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// brand-new card must not be born invisible". Every entry point to creation goes through here
|
||||
/// — ⌘N, Return on a lane, the lane header's button, a double-click on empty space — so the
|
||||
/// carve-out is stated once instead of four times.
|
||||
/// **Creation clears the search, and this is the funnel for the inline gestures**
|
||||
/// (04-interactions.md § Search): "a brand-new card must not be born invisible". ⌘N, Return on a
|
||||
/// lane, the lane header's button and a double-click on empty space all arrive here, so the four
|
||||
/// 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
|
||||
/// 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
|
||||
/// double-click on empty space all pass.
|
||||
public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
|
||||
searchQuery = ""
|
||||
noteUserCreation()
|
||||
renameEditor = nil
|
||||
newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
|
||||
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
|
||||
/// asymmetry is `resolve`'s and survives here untouched.
|
||||
///
|
||||
/// **The drag and the pending cut are deliberately left alone.** 04 hides cards and says one
|
||||
/// thing about the consequence — that they leave the *selection*. A cut is staged content
|
||||
/// waiting for a paste that may well happen after the search clears, and a drag under a live
|
||||
/// filter is a gesture in flight, not a set the filter has any claim on.
|
||||
/// **The drag, the pending cut and the rename editor are deliberately left alone.** 04 hides
|
||||
/// cards and says one thing about the consequence — that they leave the *selection*. A cut is
|
||||
/// staged content waiting for a paste that may well happen after the search clears, and a drag
|
||||
/// 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) {
|
||||
let filter = SearchFilter(query: searchQuery)
|
||||
guard filter.isActive else { return }
|
||||
|
||||
@@ -505,7 +505,8 @@ struct BoardView: View {
|
||||
/// 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
|
||||
/// 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 {
|
||||
if resize.isActive { return resize.standard }
|
||||
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;
|
||||
/// 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 {
|
||||
let session = appModel.dragSession
|
||||
guard session.isDraggingLanes,
|
||||
|
||||
@@ -298,7 +298,14 @@ struct LaneView: View {
|
||||
/// 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
|
||||
/// 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 {
|
||||
Text("\(renderedCards.count)")
|
||||
.font(.caption)
|
||||
@@ -708,10 +715,43 @@ struct LaneView: View {
|
||||
/// `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
|
||||
/// 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] {
|
||||
let hidden = drops.session.hiddenMembers(onBoardRooted: store.rootURL)
|
||||
let filter = store.searchFilter
|
||||
return lane.cards.filter { !$0.isDeleted && !hidden.contains($0.id) && filter.matches($0) }
|
||||
Self.rendered(
|
||||
lane.cards,
|
||||
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
|
||||
|
||||
@@ -97,13 +97,16 @@ extension View {
|
||||
/// it registers nothing, and the two surfaces that navigate by drawn frames narrow with the
|
||||
/// masonry rather than re-running the predicate.
|
||||
///
|
||||
/// One bounded honesty about that: a card leaving under the filter's transition stays registered
|
||||
/// until the transition ends (`onDisappear` fires when the view really goes, not when the query
|
||||
/// changed), so for the length of one content-reflow spring a fading card is still sweepable and
|
||||
/// still an arrow's neighbour. It is on screen for exactly that span, and it has already left the
|
||||
/// selection (`TransientBoardState.constrainToSearch(in:)` runs at the keystroke), so the window
|
||||
/// is visible rather than phantom — accepted rather than closed by teaching three input sites a
|
||||
/// predicate the layout already applied.
|
||||
/// **A leaving card stays input-reachable for its out-transition** (04-interactions.md § Search,
|
||||
/// settled): "marquee and arrow targets deregister when the ~0.28 s animate-out ends, so a card
|
||||
/// mid-departure is briefly reachable while already out of the selection — accepted: it is
|
||||
/// literally on screen for that span, and closing the window would teach three input sites a
|
||||
/// predicate the layout already applied". That is this modifier's construction rather than a rule
|
||||
/// it implements: `onDisappear` fires when SwiftUI really removes the view — at the end of the
|
||||
/// 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
|
||||
func marqueeTarget(
|
||||
_ id: ItemID,
|
||||
|
||||
@@ -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