Files
Zhongyue Lin 4c092ce344 fix(menu): defer copy-icon click work off the NSMenu tracking loop (macOS 26 beachball) (#1389)
* fix(menu): defer copy-icon click work off the NSMenu tracking loop

On macOS 26.4.1, clicking the doc.on.doc "Copy error" icon inside a hosted
menu card freezes the cursor for several seconds. Two SwiftUI / NSView
handlers run work synchronously inside the live NSMenu tracking event
loop:

- `CopyIconButton.body` (MenuCardView.swift:316) calls
  `withAnimation { didCopy = true }` immediately on click and queues a
  second `withAnimation { didCopy = false }` 0.9s later. Each
  withAnimation inside a tracking-mode hosted view forces a synchronous
  SwiftUI hosting layout pass on the main thread that the menu engine
  cannot service mid-tracking.
- `ClickToCopyView.mouseDown` (ClickToCopyOverlay.swift) writes to
  `NSPasteboard.general` synchronously. Pasteboard writes emit
  distributed notifications whose synchronous watchers can re-enter the
  menu engine; the tighter main-thread budget on macOS 26 makes this
  user-visible.

Both handlers now `DispatchQueue.main.async` their work off the current
tracking tick (so it runs after AppKit unwinds back to a normal mode),
drop `withAnimation` in favour of plain state mutation, and guard
`updateNSView` against no-op writes so a stable parent card re-render
does not invalidate the NSView. The checkmark feedback still works (it
flips on the next tick and reverts after 0.9s) and the UX — clicking
the icon next to the error to copy — is preserved.

Adds `Tests/CodexBarTests/ClickToCopyOverlayTests.swift` covering the
pasteboard sentinel write, `acceptsFirstMouse` behaviour, and
`copyText` storage.

Fixes #1388.

* fix: defer all in-menu copy work

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-06-10 07:02:38 -07:00

78 lines
2.2 KiB
Swift

import AppKit
import SwiftUI
@MainActor
enum MenuPasteboardCopy {
typealias DeferredAction = @MainActor @Sendable () -> Void
typealias Scheduler = @MainActor @Sendable (@escaping DeferredAction) -> Void
typealias Writer = @MainActor @Sendable (String) -> Void
static func perform(
_ text: String,
scheduler: Scheduler = Self.schedule,
writer: @escaping Writer = Self.write,
completion: @escaping DeferredAction = {})
{
scheduler {
writer(text)
completion()
}
}
private static func schedule(_ action: @escaping DeferredAction) {
DispatchQueue.main.async(execute: action)
}
private static func write(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
}
struct ClickToCopyOverlay: NSViewRepresentable {
let copyText: String
func makeNSView(context: Context) -> ClickToCopyView {
ClickToCopyView(copyText: self.copyText)
}
func updateNSView(_ nsView: ClickToCopyView, context: Context) {
// Guard against no-op writes to avoid AppKit view invalidation on every
// parent card SwiftUI diff (each MenuCardView body re-eval runs through
// .overlay { ClickToCopyOverlay(...) }, which calls updateNSView even
// when copyText is unchanged).
guard nsView.copyText != self.copyText else { return }
nsView.copyText = self.copyText
}
}
final class ClickToCopyView: NSView {
var copyText: String
private let copyAction: (String) -> Void
init(
copyText: String,
copyAction: @escaping (String) -> Void = { MenuPasteboardCopy.perform($0) })
{
self.copyText = copyText
self.copyAction = copyAction
super.init(frame: .zero)
self.wantsLayer = false
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
true
}
override func mouseDown(with event: NSEvent) {
_ = event
self.copyAction(self.copyText)
}
}