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
40 changes: 36 additions & 4 deletions Sources/App/WindowKeyDownReplayGuard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,41 @@ private struct CmuxForceDispatchedKeyEventIdentity: Hashable {
/// dispatch has fully unwound) is still force-dispatched normally.
private var cmuxInFlightForceDispatchedKeyEventIdentities = Set<CmuxForceDispatchedKeyEventIdentity>()

extension NSApplication {
/// Routes undo and redo before AppKit can invoke the shared window undo
/// manager, whose registrations may belong to a view that was closed.
func cmuxRouteApplicationUndoRedoCommandEquivalent(_ event: NSEvent) -> Bool {
let window = event.window
?? AppDelegate.shared?.shortcutRoutingActiveWindow
?? keyWindow
?? mainWindow
return window?.cmuxRouteApplicationUndoRedoCommandEquivalent(event) == true
}
}

extension NSWindow {
/// Routes an application undo or redo command through the focused cmux
/// surface, keeping AppKit's shared window undo stack out of the path.
func cmuxRouteApplicationUndoRedoCommandEquivalent(_ event: NSEvent) -> Bool {
guard event.cmuxIsUndoRedoCommandEquivalent else { return false }

let terminalView = firstResponder.cmuxTerminalKeyEquivalentOwningGhosttyView()
let webView = firstResponder.flatMap {
NSWindow.cmuxOwningWebView(for: $0, in: self, event: event)
} ?? firstResponder.flatMap { cmuxOwningUndoableWebView(for: $0) }
return cmuxRouteUndoRedoCommandEquivalentAwayFromAppKit(
event,
terminalView: terminalView,
webView: webView,
webKitKeyDownReentry: webView?.browserNativeInputDeliveryOwner.isDispatchActive ?? false
)
}

func cmuxRouteUndoRedoCommandEquivalentAwayFromAppKit(
_ event: NSEvent,
terminalView: GhosttyNSView?,
webView: CmuxWebView?,
browserWebKitKeyDownReentry: Bool
webView: CmuxUndoableWebView?,
webKitKeyDownReentry: Bool
) -> Bool {
guard event.cmuxIsUndoRedoCommandEquivalent,
!cmuxFirstResponderPreservesLocalUndoRedo,
Expand All @@ -71,7 +100,7 @@ extension NSWindow {
return true
}
if let webView {
if browserWebKitKeyDownReentry {
if webKitKeyDownReentry {
#if DEBUG
cmuxDebugLog(" -> undo/redo browser reentry suppressed before AppKit menu")
#endif
Expand All @@ -94,7 +123,10 @@ extension NSWindow {
// editable AppKit responders and Web Inspector are exempted above.
return true
}
return false
// No cmux surface owns an application-level undo operation here. Do
// not expose the shared window stack to an unknown target: it can
// contain an edit command whose view was already torn down.
return true
}

private var cmuxFirstResponderPreservesLocalUndoRedo: Bool {
Expand Down
7 changes: 3 additions & 4 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19034,7 +19034,7 @@ private extension NSApplication {
if ShortcutRecorderEventRouter.dispatchActiveRecordingEvent(
event,
preferredWindow: event.window ?? AppDelegate.shared?.shortcutRoutingActiveWindow ?? keyWindow ?? mainWindow
) {
) || cmuxRouteApplicationUndoRedoCommandEquivalent(event) {
return
}
if AppDelegate.shared?.shouldSuppressStaleCmuxMenuShortcut(event: event) == true {
Expand Down Expand Up @@ -19136,7 +19136,6 @@ extension AppDelegate {
allBrowserPanelsForInspectorWindowClose()
}
}

private extension NSWindow {
static func cmuxCommandPaletteOwnsFieldEditor(_ textView: NSTextView?, in window: NSWindow) -> Bool {
guard let textView,
Expand Down Expand Up @@ -19546,7 +19545,7 @@ private extension NSWindow {
}
return false
}
if cmuxRouteUndoRedoCommandEquivalentAwayFromAppKit(event, terminalView: firstResponderGhosttyView, webView: firstResponderWebView, browserWebKitKeyDownReentry: browserWebKitKeyDownReentry) { return true }
if cmuxRouteUndoRedoCommandEquivalentAwayFromAppKit(event, terminalView: firstResponderGhosttyView, webView: firstResponderWebView, webKitKeyDownReentry: browserWebKitKeyDownReentry) { return true }
if let mode = AppDelegate.shared?.rightSidebarModeShortcut(for: event),
AppDelegate.shared?.shouldRouteRightSidebarModeShortcut(in: self) == true {
_ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow(
Expand Down Expand Up @@ -19955,7 +19954,7 @@ private extension NSWindow {
return nil
}

private static func cmuxOwningWebView(
fileprivate static func cmuxOwningWebView(
for responder: NSResponder,
in window: NSWindow,
event: NSEvent?
Expand Down
2 changes: 1 addition & 1 deletion Sources/ExtensionSidebarWorkspaceRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ struct CmuxExtensionWorkspaceInspectorBrowserView: NSViewRepresentable {

func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: configuration)
let webView = CmuxUndoableWebView(frame: .zero, configuration: configuration)
webView.setValue(false, forKey: "drawsBackground")
return webView
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Panels/AgentSessionWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import AppKit
import WebKit

@MainActor
final class AgentSessionWebView: WKWebView {
final class AgentSessionWebView: CmuxUndoableWebView {
var onPointerDown: (() -> Void)?

override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
Expand Down
8 changes: 1 addition & 7 deletions Sources/Panels/CmuxWebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import WebKit
/// Route app/menu shortcuts first, but allow browser content to try browser-local
/// Find shortcuts. The configured shortcut stays app-owned so cmux can choose browser
/// find or right-sidebar file search from the current focus owner.
final class CmuxWebView: WKWebView {
final class CmuxWebView: CmuxUndoableWebView {
var browserViewportModel: BrowserViewportModel?
var onBrowserViewportHierarchyChanged: (() -> Void)?

Expand Down Expand Up @@ -38,12 +38,6 @@ final class CmuxWebView: WKWebView {
clearTrustedInternalNavigationGrants()
}

// WebKit registers web-content edit commands on the view's `undoManager`;
// owning one per web view keeps every page's undo stack scoped to this
// view's lifetime instead of the window's shared undo manager.
// See CmuxWebViewWebContentUndo.swift.
let webContentUndoManager = UndoManager()

// Some sites/WebKit paths report middle-click link activations as
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
// middle-click so navigation delegates can recover intent reliably.
Expand Down
59 changes: 54 additions & 5 deletions Sources/Panels/CmuxWebViewWebContentUndo.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,57 @@
import AppKit
import WebKit

// Cmd+Z / Cmd+Shift+Z for browser web views.
/// Base class for every WebKit view that cmux embeds in an app window.
///
/// WebKit registers page edit commands through the view's `undoManager`.
/// `NSResponder` otherwise resolves that property to the window's shared
/// manager, which lets commands from a closed web view remain in a different
/// object's undo stack and later message a dangling target.
class CmuxUndoableWebView: WKWebView {
let webContentUndoManager = UndoManager()

override var undoManager: UndoManager? { webContentUndoManager }

/// Completes a routed WebKit undo command after the page declines it.
override func keyDown(with event: NSEvent) {
if performWebContentUndoRedo(for: event) {
return
}
super.keyDown(with: event)
}
}

extension NSWindow {
/// Finds an embedded WebKit view from a responder without depending on
/// browser-only panel state. Agent-session and Markdown views use this
/// path when their web content owns first responder.
func cmuxOwningUndoableWebView(for responder: NSResponder) -> CmuxUndoableWebView? {
if let webView = responder as? CmuxUndoableWebView {
return webView
}
if let view = responder as? NSView {
var current: NSView? = view.superview
while let candidate = current {
if let webView = candidate as? CmuxUndoableWebView {
return webView
}
current = candidate.superview
}
}
var current = responder.nextResponder
var hops = 0
while let next = current, hops < 64 {
if let webView = next as? CmuxUndoableWebView {
return webView
}
current = next.nextResponder
hops += 1
}
return nil
}
}

// Cmd+Z / Cmd+Shift+Z for embedded WebKit views.
//
// WebKit registers every web-content edit command on the web view's
// `undoManager` (`WebViewImpl::registerEditCommand` calls
Expand All @@ -14,15 +65,13 @@ import AppKit
// nothing performed the command anymore
// (https://github.com/manaflow-ai/cmux/issues/9677).
//
// Owning an undo manager per web view restores both invariants:
// Owning an undo manager per WebKit view restores both invariants:
// - web-content edit commands never reach the window's shared undo manager
// and never outlive their web view, so the stale-target class of crashes is
// impossible by construction;
// - the routed chords can perform undo/redo directly on the focused web
// view's own stack, matching Safari's Edit-menu behavior.
extension CmuxWebView {
override var undoManager: UndoManager? { webContentUndoManager }

extension CmuxUndoableWebView {
/// Performs the page's native editing undo/redo for a routed
/// Cmd+Z / Cmd+Shift+Z chord.
///
Expand Down
2 changes: 1 addition & 1 deletion Sources/Panels/MarkdownWebSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ final class MarkdownWebRenderingCoordinator {
}

@MainActor
final class MarkdownWebView: WKWebView {
final class MarkdownWebView: CmuxUndoableWebView {
var onPointerDown: (() -> Void)?
/// Invoked after this view is attached to a window. Keep this separate
/// from pointer focus so a panel can complete a focus request made before
Expand Down
Loading
Loading