Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5c6b9f0
Fix phantom terminal text selection
austinywang Mar 12, 2026
42ce88b
Address mouse repair review feedback
austinywang Mar 12, 2026
c366464
Fix mouse repair edge cases
austinywang Mar 13, 2026
4a240bf
Merge origin/main and preserve mouse-state repair
austinywang Aug 27, 2026
f130fbb
Harden mouse release cleanup across portal detach
austinywang Aug 27, 2026
b802362
Add forwarded mouse-up regression coverage
austinywang Aug 27, 2026
44abc89
Preserve forwarded drag target through mouse-up
austinywang Aug 27, 2026
2f0f86d
Clear pending release after synthesized mouse repair
austinywang Aug 27, 2026
a221b7f
Avoid duplicate releases after mouse repair
austinywang Aug 28, 2026
88d7e9c
Sequence mouse repair with clipboard input replay
austinywang Aug 28, 2026
cdf1b34
Preserve queued drag events during mouse repair
austinywang Aug 28, 2026
eba1eda
Expose mouse repair seam to clipboard extension
austinywang Aug 28, 2026
aeb81f8
Consolidate terminal mouse ownership in surface ledger
austinywang Aug 28, 2026
0404a05
Merge remote-tracking branch 'origin/main' into issue-1229-phantom-te…
austinywang Aug 28, 2026
dc6af4d
Defer lifecycle mouse repair outside portal reconciliation
austinywang Aug 28, 2026
875ac24
Harden identity-scoped mouse repair scheduling
austinywang Aug 28, 2026
dc0956a
Close mouse session ledger teardown gaps
austinywang Aug 28, 2026
446923c
Remove mouse monitor on runtime transition
austinywang Aug 28, 2026
e47a26a
Release tracked context-menu mouse sessions
austinywang Aug 28, 2026
56c8d35
Close deferred mouse repair after runtime loss
austinywang Aug 28, 2026
4257bb0
Harden lost-release and context-menu routing
austinywang Aug 28, 2026
cc0820a
Split mouse session helper types by ownership
austinywang Aug 28, 2026
94e766e
Make lifecycle mouse repair side-effect safe
austinywang Aug 28, 2026
37a9a1d
Fence lifecycle releases and surface validation
austinywang Aug 28, 2026
7cf752f
Make lifecycle mouse cancellation side-effect free
austinywang Aug 28, 2026
6e62e64
Keep deferred repair out of context-menu tracking
austinywang Aug 28, 2026
74649b8
Fix debug mouse repair surface interpolation
austinywang Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/FileDropOverlayMouseDragButton.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// Identifies the mouse button whose AppKit drag target is captured.
enum FileDropOverlayMouseDragButton: Hashable {
case left
case right
case other(Int)
}
10 changes: 10 additions & 0 deletions Sources/FileDropOverlayMouseDragTarget.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import AppKit

/// Weakly captures one AppKit view for the lifetime of a forwarded drag.
final class FileDropOverlayMouseDragTarget {
weak var view: NSView?

init(view: NSView) {
self.view = view
}
}
111 changes: 89 additions & 22 deletions Sources/FileDropOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,14 @@ extension BrowserPaneDropTargetView: FileDropPaneTarget {
/// Mouse events are forwarded to the views below via a hide-send-unhide pattern so clicks,
/// scrolls, and other interactions pass through normally.
final class FileDropOverlayView: NSView {
private typealias ForwardedMouseDragButton = FileDropOverlayMouseDragButton
private typealias ForwardedMouseDragTarget = FileDropOverlayMouseDragTarget

/// Fallback handler when no terminal is found under the drop point.
var onDrop: (([URL]) -> Bool)?
private var isForwardingMouseEvent = false
private weak var forwardedMouseDragTarget: NSView?
private var forwardedMouseDragButton: ForwardedMouseDragButton?
private var forwardedMouseDragTargets:
[ForwardedMouseDragButton: ForwardedMouseDragTarget] = [:]
/// The WKWebView currently receiving forwarded drag events, so we can
/// synthesize draggingExited/draggingEntered as the cursor moves.
weak var activeDragWebView: WKWebView?
Expand Down Expand Up @@ -74,12 +77,6 @@ final class FileDropOverlayView: NSView {

required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }

private enum ForwardedMouseDragButton: Equatable {
case left
case right
case other(Int)
}

private func dragButton(for event: NSEvent) -> ForwardedMouseDragButton? {
switch event.type {
case .leftMouseDown, .leftMouseUp, .leftMouseDragged:
Expand Down Expand Up @@ -111,6 +108,67 @@ final class FileDropOverlayView: NSView {
}
}

/// Clears one captured target, or every capture when `button` is nil.
/// All forwarded-drag reset paths go through this method so target and
/// button ownership cannot be cleared independently.
private func clearForwardedMouseDragState(
for button: ForwardedMouseDragButton? = nil,
reason: String
) {
#if DEBUG
let buttons: String
if let button {
buttons = String(describing: button)
} else {
buttons = forwardedMouseDragTargets.keys
.map(String.init(describing:))
.sorted()
.joined(separator: ",")
}
if !buttons.isEmpty {
dlog(
"overlay.forwardedDrag.reset reason=\(reason) " +
"button=\(buttons)"
)
}
#endif
if let button {
forwardedMouseDragTargets.removeValue(forKey: button)
} else {
forwardedMouseDragTargets.removeAll(keepingCapacity: true)
}
}

private func repairForwardedMouseDragStateIfNeeded(for event: NSEvent) {
// A portal target can disappear independently for each button. Drop
// only the detached capture so another button's drag remains intact.
for button in Array(forwardedMouseDragTargets.keys) {
guard let target = forwardedMouseDragTargets[button]?.view else {
clearForwardedMouseDragState(for: button, reason: "targetDetached")
continue
}
guard target.window != nil else {
clearForwardedMouseDragState(for: button, reason: "targetDetached")
continue
}
}

if let eventButton = dragButton(for: event),
shouldTrackForwardedMouseDragStart(for: event.type),
forwardedMouseDragTargets[eventButton] != nil {
clearForwardedMouseDragState(for: eventButton, reason: "repeatedMouseDown")
}
}

private func isForwardedMouseDragMotion(_ eventType: NSEvent.EventType) -> Bool {
switch eventType {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return true
default:
return false
}
}

// MARK: Hit-testing — participation is routed by DragOverlayRoutingPolicy so
// file-drop, bonsplit tab drags, and sidebar tab reorder drags cannot conflict.

Expand Down Expand Up @@ -155,6 +213,7 @@ final class FileDropOverlayView: NSView {
private func forwardEvent(_ event: NSEvent) {
guard !isForwardingMouseEvent else { return }
guard let window, let contentView = window.contentView else { return }
repairForwardedMouseDragStateIfNeeded(for: event)
let eventButton = dragButton(for: event)

isForwardingMouseEvent = true
Expand All @@ -166,30 +225,33 @@ final class FileDropOverlayView: NSView {

let target: NSView?
if let eventButton,
forwardedMouseDragButton == eventButton,
let activeTarget = forwardedMouseDragTarget,
(isForwardedMouseDragMotion(event.type)
|| shouldTrackForwardedMouseDragEnd(for: event.type)),
let activeTarget = forwardedMouseDragTargets[eventButton]?.view,
activeTarget.window != nil {
// Preserve normal AppKit mouse-delivery semantics: once a drag starts,
// keep routing dragged/up events to the original mouseDown target.
// Preserve normal AppKit mouse-delivery semantics: once a drag
// starts, keep routing dragged/up events to that button's original
// mouseDown target.
target = activeTarget
} else {
// A stale file-drop pasteboard can make the overlay receive a
// dragged/up event even though its down was delivered underneath.
// Recover the normal target in that case instead of dropping the
// event and leaving the underlying selection gesture incomplete.
let point = contentView.convert(event.locationInWindow, from: nil)
target = contentView.hitTest(point)
}

guard let target, target !== self else {
if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
let eventButton {
clearForwardedMouseDragState(for: eventButton, reason: "targetUnavailable")
}
return
}

if shouldTrackForwardedMouseDragStart(for: event.type), let eventButton {
forwardedMouseDragTarget = target
forwardedMouseDragButton = eventButton
forwardedMouseDragTargets[eventButton] = ForwardedMouseDragTarget(view: target)
}

switch event.type {
Expand All @@ -207,10 +269,15 @@ final class FileDropOverlayView: NSView {
}

if shouldTrackForwardedMouseDragEnd(for: event.type),
let eventButton,
forwardedMouseDragButton == eventButton {
forwardedMouseDragTarget = nil
forwardedMouseDragButton = nil
let eventButton {
clearForwardedMouseDragState(for: eventButton, reason: "mouseUp")
}
}

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearForwardedMouseDragState(reason: "overlayDetached")
}
}

Expand Down
152 changes: 152 additions & 0 deletions Sources/GhosttyMouseSessionLedger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import AppKit
import GhosttyKit

/// Owns the button sessions that a ``GhosttyNSView`` has sent to one native
/// Ghostty surface.
///
/// A session is bound to the runtime generation and native pointer that
/// received its press. Releasing a session therefore requires the same
/// generation token; a later surface or press cannot be released by an older
/// event. Pointer coordinates are kept in the same ledger so a synthesized
/// release uses the last event belonging to that surface.
final class GhosttyMouseSessionLedger {
private(set) var activeSurface: SurfaceIdentity?
private(set) var pointerState: PointerState?
private var sessions: [Button: Session] = [:]
private var nextGeneration: UInt64 = 0

/// The buttons currently owned by this ledger.
var activeButtons: Set<Button> {
Set(sessions.keys)
}

/// Returns the sessions currently owned by `surface` in deterministic
/// button order.
func sessions(on surface: SurfaceIdentity?) -> [Session] {
guard let surface else { return [] }
return sessions.values
.filter { $0.surface == surface }
.sorted { lhs, rhs in
lhs.button.ordering < rhs.button.ordering
}
}

/// Whether the ledger owns a session for `button` on `surface`.
func hasSession(
for button: Button,
on surface: SurfaceIdentity?
) -> Bool {
guard let surface else { return false }
return sessions[button]?.surface == surface
}

/// Returns the current session for `button` only when it belongs to
/// `surface`.
func session(
for button: Button,
on surface: SurfaceIdentity?
) -> Session? {
guard let surface,
let session = sessions[button],
session.surface == surface else {
return nil
}
return session
}

/// Changes the native-surface identity and invalidates sessions from the
/// previous runtime. This is the only transition that can replace the
/// ledger's surface owner.
@discardableResult
func transition(to surface: SurfaceIdentity?) -> Bool {
guard activeSurface != surface else { return false }
let hadState = !sessions.isEmpty || pointerState != nil
sessions.removeAll(keepingCapacity: true)
pointerState = nil
activeSurface = surface
return hadState
}

/// Invalidates all pointer state, including a detached surface.
func invalidate() {
sessions.removeAll(keepingCapacity: true)
pointerState = nil
activeSurface = nil
}

/// Records the latest pointer snapshot for the current surface.
///
/// Callers must transition the ledger before recording. A mismatched
/// snapshot is ignored rather than being allowed to move state to a
/// replacement runtime implicitly.
func rememberPointer(
_ pointer: PointerState,
on surface: SurfaceIdentity
) {
guard activeSurface == surface else { return }
pointerState = pointer
}

/// Starts a new button session on the current surface.
@discardableResult
func begin(
_ button: Button,
on surface: SurfaceIdentity
) -> Session? {
guard activeSurface == surface else { return nil }
nextGeneration &+= 1
let session = Session(
button: button,
generation: nextGeneration,
surface: surface
)
sessions[button] = session
return session
}

/// Finishes a session only when its generation still owns the button.
@discardableResult
func finish(_ session: Session) -> Bool {
guard sessions[session.button] == session else { return false }
sessions.removeValue(forKey: session.button)
return true
}

/// Finishes a button session by its generation token.
@discardableResult
func finish(
_ button: Button,
generation: UInt64
) -> Bool {
guard let session = sessions[button],
session.generation == generation else {
return false
}
sessions.removeValue(forKey: button)
return true
}

/// Returns sessions that should receive a synthesized release.
///
/// `physicalButtons` is a reconciliation signal, not ownership state. It
/// is supplied only from non-drag event boundaries; drag dispatch itself
/// never consults it. Explicitly forced session tokens are always returned
/// when they still belong to `surface`.
func sessionsNeedingRepair(
on surface: SurfaceIdentity,
physicalButtons: Int?,
forcedSessions: Set<Session>
) -> [Session] {
guard activeSurface == surface else { return [] }
return sessions.values
.filter { session in
forcedSessions.contains(session)
|| physicalButtons.map {
($0 & session.button.pressedMouseButtonsMask) == 0
} == true
}
.sorted { lhs, rhs in
lhs.button.ordering < rhs.button.ordering
}
}
}
Loading