eleven / messenger Public

mac: pull-based drop routing — resolve the target pane at drag time, not via isActive events (#634)

The #632 router claimed/released a single owner slot on isActive flips,
and a missed or coalesced onChange left the router unclaimed while the
visible feed believed it owned drops (hit returning to an earlier
space: no + badge, drag dead). Registration is now structural — every
mounted feed registers under its pane's instance origin and unregisters
on unmount (token-checked against within-pane thread-switch ordering) —
and the drop delegate resolves the receiver from the LIVE selection at
drag time, so there is no event choreography left to get wrong. Router
claims/deliveries log via Logger.identity for field diagnosis.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 73c94467198b540a888f60d8b8f4a10053f7e8f7 parent a62dbaa
2 files changed, +83 −49
  • ios/LChatMac/NativeFeedView.swift +60 −40
  • ios/LChatMac/RootView.swift +23 −9
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index b6b5227..60f6c8a 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -3,36 +3,57 @@ import SwiftUI
import AVFoundation
import os
-// Routes drop-to-attach from RootView's ONE drop region to the active feed.
+// Routes drop-to-attach from RootView's ONE drop region to the visible feed.
// Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop
// targeting resolves to the topmost region regardless of opacity or
// allowsHitTesting, so a hidden space's region either swallowed the drop
// (staging into its own composer, invisibly) or — type-gated — dead-ended
-// the drag with no + badge for the pane actually on screen. One region,
-// one dynamic owner, no overlap. Main-thread only (claims come from view
-// lifecycle, delivery from the drop delegate).
+// the drag with no + badge for the pane actually on screen.
+//
+// Routing is PULL-based, deliberately: feeds register by pane key on mount
+// and unregister on unmount, and the drop asks "which pane is selected NOW"
+// at drag time. The first cut had the active feed claim/release a single
+// slot on isActive flips — and a missed/coalesced onChange left the router
+// unclaimed while the visible feed believed it was the owner (bitten on a
+// return to an earlier space). Mount lifecycle is structural and can't be
+// coalesced away; selection is read live, so there is no event ordering to
+// get wrong. Main-thread only (registration comes from view lifecycle,
+// delivery from the drop delegate).
final class PaneDropRouter {
static let shared = PaneDropRouter()
- private var owner: UUID?
- private var handler: (([NSItemProvider]) -> Void)?
- // Drives the drag's + badge via validateDrop: no active claimant (locked
- // pane, default-command thread, first-run) → the detail area rejects the
- // drag outright, matching the old empty-type-list behaviour.
- var canDrop: Bool { handler != nil }
- func claim(_ token: UUID, handler: @escaping ([NSItemProvider]) -> Void) {
- owner = token
- self.handler = handler
+ private struct Entry {
+ let token: UUID
+ let handler: ([NSItemProvider]) -> Void
}
- // Owner-checked so pane-switch ordering can't evict the newcomer: the
- // outgoing feed's release may land after the incoming feed's claim.
- func release(_ token: UUID) {
- guard owner == token else { return }
- owner = nil
- handler = nil
+ // Keyed by the pane's instance origin — hidden panes stay registered
+ // (they stay mounted); selection picks between them at drop time.
+ private var entries: [String: Entry] = [:]
+
+ func register(_ key: String, token: UUID,
+ handler: @escaping ([NSItemProvider]) -> Void) {
+ Logger.identity.info("drop router: register \(key, privacy: .public)")
+ entries[key] = Entry(token: token, handler: handler)
+ }
+ // Token-checked so a within-pane thread switch can't misfire: the old
+ // feed's onDisappear may land after the new feed registered the same key.
+ func unregister(_ key: String, token: UUID) {
+ guard entries[key]?.token == token else { return }
+ Logger.identity.info("drop router: unregister \(key, privacy: .public)")
+ entries[key] = nil
}
- func deliver(_ providers: [NSItemProvider]) -> Bool {
- guard let handler else { return false }
- handler(providers)
+ // Drives the drag's + badge via validateDrop: no registered feed for the
+ // selected pane (locked pane, default-command thread, first-run) → the
+ // detail area rejects the drag, matching the old empty-type-list gate.
+ func canDrop(over key: String?) -> Bool {
+ key.flatMap { entries[$0] } != nil
+ }
+ func deliver(_ providers: [NSItemProvider], over key: String?) -> Bool {
+ guard let key, let entry = entries[key] else {
+ Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced")
+ return false
+ }
+ Logger.identity.info("drop router: deliver \(providers.count) provider(s) to \(key, privacy: .public)")
+ entry.handler(providers)
return true
}
}
@@ -184,9 +205,9 @@ struct NativeFeedView: View {
// horizontal scroll events (vertical ones pass through untouched).
@State private var paneFrame: CGRect = .zero
@State private var swipeMonitor: Any?
- // Identifies this feed's claim on the shared drop router, so a stale
- // release (pane switch races the newcomer's claim) can't evict the
- // rightful owner.
+ // Identifies this feed's registration on the shared drop router, so a
+ // stale unregister (a thread switch's old feed disappearing after the new
+ // one registered the same pane key) can't evict the rightful receiver.
@State private var dropToken = UUID()
// True while a horizontal swipe is actively dragging the gutter — the
// zero-delta release event snaps only when a drag was in flight.
@@ -455,18 +476,17 @@ struct NativeFeedView: View {
model.start() // idempotent — a parked model is already live
frames.isActive = isActive
installSwipeMonitor()
- syncDropClaim()
- }
- .onChange(of: isActive) { _, active in
- frames.isActive = active
- syncDropClaim()
+ registerForDrops()
}
+ .onChange(of: isActive) { _, active in frames.isActive = active }
.onDisappear {
// Deliberately NOT model.stop(): parked models keep their socket
// (the FeedWebCache rule); the cache's eviction stops them.
if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
swipeMonitor = nil
- PaneDropRouter.shared.release(dropToken)
+ if let key = instance?.origin {
+ PaneDropRouter.shared.unregister(key, token: dropToken)
+ }
}
// Drop-to-attach is NOT a per-feed .onDrop: every visited space stays
// alive in RootView's ZStack, and drop-region resolution picks the
@@ -474,7 +494,8 @@ struct NativeFeedView: View {
// pane's region either stole the drop (staging into the wrong space's
// composer) or, gated, dead-ended the drag with no + badge at all.
// Instead RootView holds the ONE drop region for the whole detail
- // area and the active feed claims the router (below) to receive it.
+ // area and PaneDropRouter (top of file) hands each drop to whichever
+ // registered feed belongs to the pane selected at drag time.
}
// What the feed accepts on a drop: real files, promised files (how most
@@ -485,15 +506,14 @@ struct NativeFeedView: View {
return types
}()
- // The active feed owns drops for the whole detail pane; a default-command
- // thread (text-only surface) claims nothing, so the drag shows no + badge
+ // Register this feed as its pane's drop receiver for as long as it is
+ // mounted — visibility is the delegate's problem, not ours. A
+ // default-command thread (text-only surface) or an instance-less mount
+ // (the Debug diff pane) registers nothing, so the drag shows no + badge
// there — same behaviour the per-feed type-list gate used to give.
- private func syncDropClaim() {
- if isActive && defaultCommand == nil {
- PaneDropRouter.shared.claim(dropToken) { stageProviders($0) }
- } else {
- PaneDropRouter.shared.release(dropToken)
- }
+ private func registerForDrops() {
+ guard defaultCommand == nil, let key = instance?.origin else { return }
+ PaneDropRouter.shared.register(key, token: dropToken) { stageProviders($0) }
}
static let bottomSentinel = "bottom-sentinel"
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 2d098e2..78cc1f7 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -191,20 +191,34 @@ struct RootView: View {
.onAppear { if let s = app.selection { activated.insert(s) } }
.onChange(of: app.selection) { _, s in if let s { activated.insert(s) } }
// THE drop-to-attach region — one for the whole detail area, delivered
- // to whichever feed currently claims the router. Never per-pane: the
- // keep-alive ZStack means overlapping regions, and drop targeting picks
- // the topmost one regardless of opacity/allowsHitTesting, so a hidden
- // space's region hijacks or dead-ends drops meant for the visible one.
- .onDrop(of: NativeFeedView.droppableTypes, delegate: PaneDropDelegate())
+ // to the registered feed of the pane selected at drag time. Never
+ // per-pane: the keep-alive ZStack means overlapping regions, and drop
+ // targeting picks the topmost one regardless of opacity or
+ // allowsHitTesting, so a hidden space's region hijacks or dead-ends
+ // drops meant for the visible one.
+ .onDrop(of: NativeFeedView.droppableTypes, delegate: PaneDropDelegate(app: app))
}
}
-// validateDrop is consulted per drag session (unlike the registered type list),
-// so the + badge tracks the live claimant: no active feed → the drag bounces.
+// Resolves the drop's pane at DRAG time, from the live selection — never from
+// view-lifecycle events (an isActive-flip claim scheme lost the owner to a
+// coalesced onChange; see PaneDropRouter). The key mirrors nativeInstance(for:):
+// a chat routes to the feed registered under its instance's origin.
private struct PaneDropDelegate: DropDelegate {
- func validateDrop(info: DropInfo) -> Bool { PaneDropRouter.shared.canDrop }
+ let app: MacAppState
+ private var selectedPaneKey: String? {
+ guard let sel = app.selection,
+ let chat = app.chats.first(where: { $0.id == sel }) else { return nil }
+ return chat.origin?.absoluteString
+ }
+ // Consulted per drag session (unlike the registered type list), so the
+ // + badge tracks the live selection: no registered feed → the drag bounces.
+ func validateDrop(info: DropInfo) -> Bool {
+ PaneDropRouter.shared.canDrop(over: selectedPaneKey)
+ }
func performDrop(info: DropInfo) -> Bool {
- PaneDropRouter.shared.deliver(info.itemProviders(for: NativeFeedView.droppableTypes))
+ PaneDropRouter.shared.deliver(info.itemProviders(for: NativeFeedView.droppableTypes),
+ over: selectedPaneKey)
}
}