From 5c0c0e5619fdc592b32befafae2ed9f6854229ce Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 08:45:12 -0400 Subject: [PATCH] Realign search, clipboard, and lane-hover code with the second batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/ClipboardStore.swift | 11 ++ Kanban/LiveStore/BoardStore.swift | 10 ++ Kanban/LiveStore/TransientBoardState.swift | 70 +++++++++++-- Kanban/UI/Board/BoardView.swift | 19 +++- Kanban/UI/Board/LaneView.swift | 48 ++++++++- Kanban/UI/Board/SelectionClicks.swift | 17 ++-- KanbanTests/FileDropWriteTests.swift | 55 ++++++++++ KanbanTests/PasteWriteTests.swift | 102 +++++++++++++++++++ KanbanTests/SearchFilterTests.swift | 112 +++++++++++++++++++++ 9 files changed, 423 insertions(+), 21 deletions(-) diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index b68fcc7..28c0e59 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -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 { diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index df457a4..f1d34d3 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -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 diff --git a/Kanban/LiveStore/TransientBoardState.swift b/Kanban/LiveStore/TransientBoardState.swift index 5b22e0c..b2ae2df 100644 --- a/Kanban/LiveStore/TransientBoardState.swift +++ b/Kanban/LiveStore/TransientBoardState.swift @@ -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 } diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index 834c191..9f2f302 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -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, diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 866960e..15ba9f1 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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, + 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 diff --git a/Kanban/UI/Board/SelectionClicks.swift b/Kanban/UI/Board/SelectionClicks.swift index 4aa0266..0128938 100644 --- a/Kanban/UI/Board/SelectionClicks.swift +++ b/Kanban/UI/Board/SelectionClicks.swift @@ -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, diff --git a/KanbanTests/FileDropWriteTests.swift b/KanbanTests/FileDropWriteTests.swift index d144cbe..25fc6d6 100644 --- a/KanbanTests/FileDropWriteTests.swift +++ b/KanbanTests/FileDropWriteTests.swift @@ -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 diff --git a/KanbanTests/PasteWriteTests.swift b/KanbanTests/PasteWriteTests.swift index fb4e00d..607a8b5 100644 --- a/KanbanTests/PasteWriteTests.swift +++ b/KanbanTests/PasteWriteTests.swift @@ -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 diff --git a/KanbanTests/SearchFilterTests.swift b/KanbanTests/SearchFilterTests.swift index 38f2e39..b1b9215 100644 --- a/KanbanTests/SearchFilterTests.swift +++ b/KanbanTests/SearchFilterTests.swift @@ -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) + } +}