| 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" |