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
|
/// **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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user