Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
134 changes: 108 additions & 26 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -826,12 +826,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent

// Performance tracing (10% of transactions)
options.tracesSampleRate = 0.1
// App hang timeout (default is 2s, be explicit)
options.appHangTimeoutInterval = 2.0
// Keep app-hang tracking enabled, but avoid reporting short main-thread stalls
// as hangs in normal user interaction flows.
options.appHangTimeoutInterval = 8.0
// Attach stack traces to all events
options.attachStacktrace = true
// Capture failed HTTP requests
options.enableCaptureFailedRequests = true
// Avoid recursively capturing failed requests from Sentry's own ingestion endpoint.
options.enableCaptureFailedRequests = false
}

if !isRunningUnderXCTest {
Expand Down Expand Up @@ -6616,9 +6617,23 @@ private var cmuxFirstResponderGuardCurrentEventOverride: NSEvent?
private var cmuxFirstResponderGuardHitViewOverride: NSView?
#endif
private var cmuxBrowserReturnForwardingDepth = 0
private var cmuxFieldEditorOwningWebViewAssociationKey: UInt8 = 0

private final class CmuxFieldEditorOwningWebViewBox: NSObject {
weak var webView: CmuxWebView?

init(webView: CmuxWebView?) {
self.webView = webView
}
}

private extension NSWindow {
@objc func cmux_makeFirstResponder(_ responder: NSResponder?) -> Bool {
let currentEvent = Self.cmuxCurrentEvent(for: self)
let responderWebView = responder.flatMap {
Self.cmuxOwningWebView(for: $0, in: self, event: currentEvent)
}

if AppDelegate.shared?.shouldBlockFirstResponderChangeWhileCommandPaletteVisible(
window: self,
responder: responder
Expand All @@ -6633,9 +6648,8 @@ private extension NSWindow {
}

if let responder,
let webView = Self.cmuxOwningWebView(for: responder),
let webView = responderWebView,
!webView.allowsFirstResponderAcquisitionEffective {
let currentEvent = Self.cmuxCurrentEvent(for: self)
let pointerInitiatedFocus = Self.cmuxShouldAllowPointerInitiatedWebViewFocus(
window: self,
webView: webView,
Expand Down Expand Up @@ -6668,7 +6682,7 @@ private extension NSWindow {
}
#if DEBUG
if let responder,
let webView = Self.cmuxOwningWebView(for: responder) {
let webView = responderWebView {
dlog(
"focus.guard allowFirstResponder responder=\(String(describing: type(of: responder))) " +
"window=\(ObjectIdentifier(self)) " +
Expand All @@ -6678,7 +6692,15 @@ private extension NSWindow {
)
}
#endif
return cmux_makeFirstResponder(responder)
let result = cmux_makeFirstResponder(responder)
if result {
if let fieldEditor = responder as? NSTextView, fieldEditor.isFieldEditor {
Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView)
} else if let fieldEditor = self.firstResponder as? NSTextView, fieldEditor.isFieldEditor {
Self.cmuxTrackFieldEditor(fieldEditor, owningWebView: responderWebView)
}
}
return result
}

@objc func cmux_sendEvent(_ event: NSEvent) {
Expand Down Expand Up @@ -6733,7 +6755,9 @@ private extension NSWindow {
// (handleCustomShortcut) already handles app-level shortcuts, and anything
// remaining should be menu items.
let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder)
let firstResponderWebView = self.firstResponder.flatMap { Self.cmuxOwningWebView(for: $0) }
let firstResponderWebView = self.firstResponder.flatMap {
Self.cmuxOwningWebView(for: $0, in: self, event: event)
}
if let ghosttyView = firstResponderGhosttyView {
// If the IME is composing, don't intercept key events — let them flow
// through normal AppKit event dispatch so the input method can process them.
Expand Down Expand Up @@ -6857,12 +6881,8 @@ private extension NSWindow {
return webView
}

if let textView = responder as? NSTextView,
let delegateView = textView.delegate as? NSView,
let webView = cmuxOwningWebView(for: delegateView) {
return webView
}

// NSTextView.delegate is unsafe-unretained in AppKit. Reading it here while
// a responder chain is tearing down can trap with "unowned reference".
var current = responder.nextResponder
while let next = current {
Comment thread
lawrencecchen marked this conversation as resolved.
if let webView = next as? CmuxWebView {
Expand All @@ -6878,6 +6898,28 @@ private extension NSWindow {
return nil
}

private static func cmuxOwningWebView(
for responder: NSResponder,
in window: NSWindow,
event: NSEvent?
) -> CmuxWebView? {
if let webView = cmuxOwningWebView(for: responder) {
return webView
}

guard let textView = responder as? NSTextView, textView.isFieldEditor else {
return nil
}

if let event,
let hitWebView = cmuxPointerHitWebView(in: window, event: event) {
cmuxTrackFieldEditor(textView, owningWebView: hitWebView)
return hitWebView
}

return cmuxTrackedOwningWebView(for: textView)
}

private static func cmuxOwningWebView(for view: NSView) -> CmuxWebView? {
if let webView = view as? CmuxWebView {
return webView
Expand Down Expand Up @@ -6912,28 +6954,68 @@ private extension NSWindow {
return window.contentView?.hitTest(event.locationInWindow)
}

private static func cmuxShouldAllowPointerInitiatedWebViewFocus(
window: NSWindow,
webView: CmuxWebView,
event: NSEvent?
) -> Bool {
guard let event else { return false }
private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) {
if let webView {
objc_setAssociatedObject(
fieldEditor,
&cmuxFieldEditorOwningWebViewAssociationKey,
CmuxFieldEditorOwningWebViewBox(webView: webView),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
} else {
objc_setAssociatedObject(
fieldEditor,
&cmuxFieldEditorOwningWebViewAssociationKey,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}

private static func cmuxTrackedOwningWebView(for fieldEditor: NSTextView) -> CmuxWebView? {
guard let box = objc_getAssociatedObject(
fieldEditor,
&cmuxFieldEditorOwningWebViewAssociationKey
) as? CmuxFieldEditorOwningWebViewBox else {
return nil
}
guard let webView = box.webView else {
cmuxTrackFieldEditor(fieldEditor, owningWebView: nil)
return nil
}
return webView
}

private static func cmuxIsPointerDownEvent(_ event: NSEvent) -> Bool {
switch event.type {
case .leftMouseDown, .rightMouseDown, .otherMouseDown:
break
return true
default:
return false
}
}

private static func cmuxPointerHitWebView(in window: NSWindow, event: NSEvent) -> CmuxWebView? {
guard cmuxIsPointerDownEvent(event) else { return nil }
if event.windowNumber != 0, event.windowNumber != window.windowNumber {
return false
return nil
}
if let eventWindow = event.window, eventWindow !== window {
return false
return nil
}
guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event) else {
return nil
}
return cmuxOwningWebView(for: hitView)
}

guard let hitView = cmuxHitViewForCurrentEvent(in: window, event: event),
let hitWebView = cmuxOwningWebView(for: hitView) else {
private static func cmuxShouldAllowPointerInitiatedWebViewFocus(
window: NSWindow,
webView: CmuxWebView,
event: NSEvent?
) -> Bool {
guard let event,
let hitWebView = cmuxPointerHitWebView(in: window, event: event) else {
return false
}
return hitWebView === webView
Expand Down
43 changes: 41 additions & 2 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,11 @@ class GhosttyApp {
private var scrollLagSampleCount = 0
private var scrollLagTotalMs: Double = 0
private var scrollLagMaxMs: Double = 0
private let scrollLagThresholdMs: Double = 25 // Alert if tick takes >25ms during scroll
private let scrollLagThresholdMs: Double = 40
private let scrollLagMinimumSamples = 8
private let scrollLagMinimumAverageMs: Double = 12
private let scrollLagReportCooldownSeconds: TimeInterval = 300
private var lastScrollLagReportUptime: TimeInterval?
private var scrollEndTimer: DispatchWorkItem?

func markScrollActivity(hasMomentum: Bool, momentumEnded: Bool) {
Expand Down Expand Up @@ -356,7 +360,18 @@ class GhosttyApp {
let maxLag = scrollLagMaxMs
let samples = scrollLagSampleCount
let threshold = scrollLagThresholdMs
if maxLag > threshold {
let nowUptime = ProcessInfo.processInfo.systemUptime
if Self.shouldCaptureScrollLagEvent(
samples: samples,
averageMs: avgLag,
maxMs: maxLag,
thresholdMs: threshold,
minimumSamples: scrollLagMinimumSamples,
minimumAverageMs: scrollLagMinimumAverageMs,
nowUptime: nowUptime,
lastReportedUptime: lastScrollLagReportUptime,
cooldown: scrollLagReportCooldownSeconds
) {
SentrySDK.capture(message: "Scroll lag detected") { scope in
scope.setLevel(.warning)
scope.setContext(value: [
Expand All @@ -366,6 +381,7 @@ class GhosttyApp {
"threshold_ms": threshold
], key: "scroll_lag")
}
lastScrollLagReportUptime = nowUptime
}
// Reset stats
scrollLagSampleCount = 0
Expand Down Expand Up @@ -624,6 +640,29 @@ class GhosttyApp {
previousColorScheme != currentColorScheme
}

static func shouldCaptureScrollLagEvent(
samples: Int,
averageMs: Double,
maxMs: Double,
thresholdMs: Double,
minimumSamples: Int = 8,
minimumAverageMs: Double = 12,
nowUptime: TimeInterval,
lastReportedUptime: TimeInterval?,
cooldown: TimeInterval = 300
) -> Bool {
guard samples >= minimumSamples else { return false }
guard averageMs.isFinite, maxMs.isFinite, thresholdMs.isFinite, nowUptime.isFinite, cooldown.isFinite else {
return false
}
guard averageMs >= minimumAverageMs else { return false }
guard maxMs > thresholdMs else { return false }
if let lastReportedUptime, nowUptime - lastReportedUptime < cooldown {
return false
}
return true
}

private func loadLegacyGhosttyConfigIfNeeded(_ config: ghostty_config_t) {
#if os(macOS)
// Ghostty 1.3+ prefers `config.ghostty`, but some users still have their real
Expand Down
Loading