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
12 changes: 12 additions & 0 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7684,6 +7684,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
keyboardFocusCoordinator(for: window)?.syncAfterResponderChange()
}

/// Hands keyboard focus from the right sidebar to the workspace's focused
/// panel, when the sidebar currently owns it. Used after a sidebar-origin
/// action opens a panel in the main area (for example a file drag-drop):
/// the drag never resigns the sidebar's first responder, so without this
/// the find/shortcut router keeps targeting the sidebar. No-op when the
/// sidebar does not own focus.
@discardableResult
func restoreMainPanelKeyboardFocusFromRightSidebar(in window: NSWindow?) -> Bool {
keyboardFocusCoordinator(for: window)?
.restoreFocusedPanelFocusFromRightSidebarIfNeeded() ?? false
}

@discardableResult
func focusRightSidebarInActiveMainWindow(
mode requestedMode: RightSidebarMode? = nil,
Expand Down
2 changes: 1 addition & 1 deletion Sources/Find/MarkdownFindWebViewEvaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class MarkdownFindWebViewEvaluator: BrowserFindScriptEvaluating {
}

func evaluate(_ script: BrowserFindScript) async throws -> Any? {
guard let webView = panel?.rendererSession.findScriptWebView else { return nil }
guard let webView = panel?.rendererSession.webView else { return nil }
return try await webView.evaluateJavaScript(script.source)
}
}
13 changes: 13 additions & 0 deletions Sources/Panels/FilePreviewWorkspaceOpenSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ extension Workspace {
let shouldFocusNewTabs = focus ?? (bonsplitController.focusedPaneId == paneId)
var nextIndex = targetIndex
var openedPanels: [any Panel] = []
defer {
// Shared across every focused open entrypoint (sidebar click,
// sidebar drag-drop, CLI/socket open, workspace actions): when
// the right sidebar owns keyboard focus, hand it to the opened
// panel so the find/shortcut router targets the document. A
// freshly created panel's view mounts a runloop turn later and
// cannot take first responder during activation, so this happens
// at the coordinator level. No-op when the sidebar does not own
// focus.
if shouldFocusNewTabs, let firstPanel = openedPanels.first {
handKeyboardFocusFromRightSidebarAfterFileOpen(to: firstPanel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a focused open targets a non-key main window, this handoff can resolve the wrong window and leave the target sidebar as first responder, so Cmd+F still routes to the sidebar. Resolve and pass the target workspace's owning window instead of falling back to NSApp.keyWindow/mainWindow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/FilePreviewWorkspaceOpenSupport.swift, line 27:

<comment>When a focused open targets a non-key main window, this handoff can resolve the wrong window and leave the target sidebar as first responder, so Cmd+F still routes to the sidebar. Resolve and pass the target workspace's owning window instead of falling back to `NSApp.keyWindow`/`mainWindow`.</comment>

<file context>
@@ -14,6 +14,19 @@ extension Workspace {
+            // at the coordinator level. No-op when the sidebar does not own
+            // focus.
+            if shouldFocusNewTabs, let firstPanel = openedPanels.first {
+                handKeyboardFocusFromRightSidebarAfterFileOpen(to: firstPanel)
+            }
+        }
</file context>

}
}

for filePath in filePaths {
let panel: (any Panel)?
Expand Down
38 changes: 34 additions & 4 deletions Sources/Panels/MarkdownPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
private var saveGeneration: Int = 0
private var activeSaveGeneration: Int?
private var pendingSearchNeedle: String?
/// Set when activation asks a preview panel to focus before SwiftUI has
/// mounted its WKWebView. The renderer fulfills this at window attach.
private var pendingPreviewFocus = false
private weak var textView: NSTextView?
private var isClosed: Bool = false
// NotificationCenter token; removal is thread-safe so deinit can drop it.
Expand Down Expand Up @@ -138,6 +141,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
startWatching()
observeTypographyDefaults()
rendererSession.onMarkdownRendered = { [weak self] in
self?.replayPendingPreviewFocusAfterWindowAttach()
self?.replayActiveFindAfterRender()
}
}
Expand Down Expand Up @@ -371,17 +375,43 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
// MARK: - Panel protocol

func focus() {
guard displayMode == .text else { return }
_ = textView?.window?.makeFirstResponder(textView)
applyPendingSearchNeedleIfPossible()
if displayMode == .text {
pendingPreviewFocus = false
_ = textView?.window?.makeFirstResponder(textView)
applyPendingSearchNeedleIfPossible()
return
}
// Preview mode: the rendered web view is the panel's keyboard
// surface. Taking first responder on activation is what moves the
// keyboard out of wherever it was (for example the right-sidebar
// file list after a click- or drag-open), so the find/shortcut
// router targets this panel — the same behavior terminal and
// browser panels have. No-op while the web view is not mounted;
// the drop/open paths also hand off focus at the coordinator level.
guard let webView = rendererSession.webView, let window = webView.window else {
pendingPreviewFocus = true
return
}
let didBecomeFirstResponder = window.makeFirstResponder(webView)
&& window.firstResponder === webView
Comment on lines +395 to +396

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When WebKit installs a descendant as the window’s first responder, this strict identity check treats a successful preview focus as pending. Later content renders then replay focus() and steal keyboard focus back from the sidebar or another control. Use the makeFirstResponder result, or check responder-chain containment like BrowserPanel does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownPanel.swift, line 395:

<comment>When WebKit installs a descendant as the window’s first responder, this strict identity check treats a successful preview focus as pending. Later content renders then replay `focus()` and steal keyboard focus back from the sidebar or another control. Use the `makeFirstResponder` result, or check responder-chain containment like `BrowserPanel` does.</comment>

<file context>
@@ -371,17 +375,43 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
+            pendingPreviewFocus = true
+            return
+        }
+        let didBecomeFirstResponder = window.makeFirstResponder(webView)
+            && window.firstResponder === webView
+        pendingPreviewFocus = !didBecomeFirstResponder
</file context>
Suggested change
let didBecomeFirstResponder = window.makeFirstResponder(webView)
&& window.firstResponder === webView
let didBecomeFirstResponder = window.makeFirstResponder(webView)

pendingPreviewFocus = !didBecomeFirstResponder
}

/// Completes a preview focus request recorded before the renderer view was
/// attached to its window. The callback is event-driven, so it cannot
/// steal focus after this panel has been unfocused in the meantime.
func replayPendingPreviewFocusAfterWindowAttach() {
guard pendingPreviewFocus, displayMode == .preview else { return }
focus()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: In preview mode, a pending focus request (pendingPreviewFocus = true) is replayed from both viewDidMoveToWindow attach and every onMarkdownRendered re-render, but the flag is only cleared by panel deselection (unfocus), success, or close. If the user opens the panel pre-mount and then moves keyboard focus to a non-panel surface (sidebar search, Cmd+P palette) without de-selecting it, a later file re-render or pane re-attach calls focus() and makeFirstResponder(webView), stealing focus from that surface. Consider clearing pendingPreviewFocus when focus intent leaves the panel (or gating the replay on the panel still being the active surface) before relying on it going stale.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Panels/MarkdownPanel.swift, line 405:

<comment>In preview mode, a pending focus request (`pendingPreviewFocus = true`) is replayed from both `viewDidMoveToWindow` attach and every `onMarkdownRendered` re-render, but the flag is only cleared by panel deselection (`unfocus`), success, or close. If the user opens the panel pre-mount and then moves keyboard focus to a non-panel surface (sidebar search, Cmd+P palette) without de-selecting it, a later file re-render or pane re-attach calls `focus()` and `makeFirstResponder(webView)`, stealing focus from that surface. Consider clearing `pendingPreviewFocus` when focus intent leaves the panel (or gating the replay on the panel still being the active surface) before relying on it going stale.</comment>

<file context>
@@ -371,17 +375,43 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel
+    /// steal focus after this panel has been unfocused in the meantime.
+    func replayPendingPreviewFocusAfterWindowAttach() {
+        guard pendingPreviewFocus, displayMode == .preview else { return }
+        focus()
     }
 
</file context>

}

func unfocus() {
// No-op for read-only panel.
pendingPreviewFocus = false
}

func close() {
isClosed = true
pendingPreviewFocus = false
searchState = nil
rendererSession.close()
GlobalSearchCoordinator.shared.purgePanel(id: id)
Expand Down
5 changes: 4 additions & 1 deletion Sources/Panels/MarkdownPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ struct MarkdownPanelView: View {
fontFamily: panel.fontFamily,
maxContentWidth: panel.maxContentWidth,
session: panel.rendererSession,
onRequestPanelFocus: onRequestPanelFocus
onRequestPanelFocus: onRequestPanelFocus,
onViewAttachedToWindow: { [weak panel] in
panel?.replayPendingPreviewFocusAfterWindowAttach()
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.opacity(panel.displayMode == .preview ? 1 : 0)
Expand Down
8 changes: 8 additions & 0 deletions Sources/Panels/MarkdownWebRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ struct MarkdownWebRenderer: NSViewRepresentable {
let maxContentWidth: Double
let session: MarkdownRendererSession
let onRequestPanelFocus: () -> Void
/// Called after the renderer view is attached to a window. A panel can
/// request focus before SwiftUI mounts its WebKit view, so the panel uses
/// this lifecycle signal to complete that request without polling.
let onViewAttachedToWindow: () -> Void = {}

func makeCoordinator() -> Coordinator {
session.coordinator(panelId: panelId, workspaceId: workspaceId, filePath: filePath)
Expand All @@ -34,6 +38,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
webView.removeFromSuperview()
}
webView.onPointerDown = onRequestPanelFocus
webView.onAttachToWindow = onViewAttachedToWindow
webView.setVisibleInUI(isVisibleInUI)
webView.onLeaveWindow = { [weak coordinator = context.coordinator] in
coordinator?.handleViewLeftWindow()
Expand Down Expand Up @@ -67,6 +72,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
)
let webView = MarkdownWebView(frame: .zero, configuration: config)
webView.onPointerDown = onRequestPanelFocus
webView.onAttachToWindow = onViewAttachedToWindow
webView.setVisibleInUI(isVisibleInUI)
webView.onLeaveWindow = { [weak coordinator = context.coordinator] in
coordinator?.handleViewLeftWindow()
Expand Down Expand Up @@ -119,6 +125,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
nsView.navigationDelegate = nil
nsView.uiDelegate = nil
(nsView as? MarkdownWebView)?.onPointerDown = nil
(nsView as? MarkdownWebView)?.onAttachToWindow = nil
(nsView as? MarkdownWebView)?.onLeaveWindow = nil
(nsView as? MarkdownWebView)?.onReenterWindow = nil
coordinator.cancelImageLoads()
Expand Down Expand Up @@ -268,6 +275,7 @@ struct MarkdownWebRenderer: NSViewRepresentable {
webView.navigationDelegate = nil
webView.uiDelegate = nil
webView.onPointerDown = nil
webView.onAttachToWindow = nil
webView.onLeaveWindow = nil
webView.onReenterWindow = nil
}
Expand Down
13 changes: 10 additions & 3 deletions Sources/Panels/MarkdownWebSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ final class MarkdownWebRenderingCoordinator {
@MainActor
final class MarkdownWebView: WKWebView {
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
/// SwiftUI mounted the WebKit view.
var onAttachToWindow: (() -> Void)?
/// Invoked when the view leaves its window (the detach half of a pane
/// re-parent). Lets the renderer coordinator record whether the document
/// was healthy at detach time so re-entry recovery can tell a detach
Expand Down Expand Up @@ -302,6 +306,8 @@ final class MarkdownWebView: WKWebView {
// This callback only records renderer health. All WebKit lifecycle
// selectors and layout/display work stay on the deferred path.
onLeaveWindow?()
} else {
onAttachToWindow?()
}
}

Expand Down Expand Up @@ -421,9 +427,10 @@ struct MarkdownWebTheme: Equatable {
final class MarkdownRendererSession {
private let ownedCoordinator = MarkdownWebRenderer.Coordinator()

/// The live preview web view, for find-in-page script evaluation.
/// `nil` until the renderer has been mounted once.
var findScriptWebView: WKWebView? {
/// The live preview web view — the panel's keyboard surface in preview
/// mode, and the evaluation target for find-in-page scripts. `nil` until
/// the renderer has been mounted once.
var webView: WKWebView? {
ownedCoordinator.webView
}

Expand Down
27 changes: 25 additions & 2 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12308,12 +12308,16 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
targetIndex: index
).isEmpty
case .split(let paneId, let orientation, let insertFirst):
return splitPaneWithFileSurface(
guard let panel = splitPaneWithFileSurface(
targetPane: paneId,
orientation: orientation,
insertFirst: insertFirst,
filePath: entry.filePath
) != nil
) else {
return false
}
handKeyboardFocusFromRightSidebarAfterFileOpen(to: panel)
return true
}
}

Expand All @@ -12330,6 +12334,8 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos

switch request.destination {
case .insert(let paneId, let index):
// openFileSurfaces(focus: true) performs the sidebar focus
// handoff itself.
return !openFileSurfaces(
inPane: paneId,
filePaths: entries.map(\.filePath),
Expand All @@ -12354,10 +12360,27 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
filePaths: entries.dropFirst().map(\.filePath),
focus: true
)
handKeyboardFocusFromRightSidebarAfterFileOpen(to: firstPanel)
return true
}
}

/// A sidebar-initiated open (click on a file row, or a drag whose
/// mouse-down made the sidebar first responder) never resigns the
/// sidebar's keyboard focus by itself, and a freshly created panel's
/// view may not be mounted yet when activation asks it to take first
/// responder. Without this handoff the find/shortcut router keeps
/// targeting the sidebar (Cmd+F lands in its file search instead of the
/// just-opened document). Hand keyboard focus to the opened panel the
/// same way the text-drop path does; the call is a no-op when the
/// sidebar does not own focus (opens from Finder, the CLI, or between
/// panes).
func handKeyboardFocusFromRightSidebarAfterFileOpen(to panel: any Panel) {
_ = AppDelegate.shared?.restoreMainPanelKeyboardFocusFromRightSidebar(
in: activationWindow(for: panel)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When another main window is key during a file drop, this fallback can select the wrong focus coordinator because Markdown and file-preview panels do not expose their owning window here. Resolve the window from this workspace's owning tab manager before restoring focus, or pass the drop's source/target window explicitly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Sources/Workspace.swift, line 12380:

<comment>When another main window is key during a file drop, this fallback can select the wrong focus coordinator because Markdown and file-preview panels do not expose their owning window here. Resolve the window from this workspace's owning tab manager before restoring focus, or pass the drop's source/target window explicitly.</comment>

<file context>
@@ -12354,10 +12360,27 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
+    /// panes).
+    func handKeyboardFocusFromRightSidebarAfterFileOpen(to panel: any Panel) {
+        _ = AppDelegate.shared?.restoreMainPanelKeyboardFocusFromRightSidebar(
+            in: activationWindow(for: panel)
+        )
+    }
</file context>
Suggested change
in: activationWindow(for: panel)
in: AppDelegate.shared?.mainWindowContainingWorkspace(id) ?? activationWindow(for: panel)

)
}

@discardableResult
private func splitPaneWithFileSurface(
targetPane paneId: PaneID,
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2052,6 +2052,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; };
EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; };
B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; };
A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; };
B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; };
A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; };
8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; };
Expand Down Expand Up @@ -4945,6 +4946,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef
D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = "<group>"; };
EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = "<group>"; };
B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = "<group>"; };
A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = "<group>"; };
B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = "<group>"; };
A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = "<group>"; };
8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -8262,6 +8264,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef
6419B0026419B0026419B002 /* AppDelegateShortcutRoutingRepairProbe.swift */,
4E6A6F5C1D2B4980A1234567 /* AppDelegateSurfaceShortcutRoutingTests.swift */,
6512F0C06512F0C06512F001 /* MainWindowFocusRestoreTests.swift */,
A7FD1001 /* SidebarFileDropFindRoutingTests.swift */,
C3467AB10000000000000002 /* ShortcutWhenClauseTests.swift */,
5330A0025330A0025330A002 /* TextBoxInlineAttachmentRenderingTests.swift */,
C0DE7B300000000000000002 /* TextBoxMentionCompletionTests.swift */,
Expand Down Expand Up @@ -11993,6 +11996,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef
F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */,
C3467AB10000000000000001 /* ShortcutWhenClauseTests.swift in Sources */,
B804C0030000000000000003 /* SidebarAppKitRowCellTests.swift in Sources */,
A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */,
B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */,
8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */,
B8624C010000000000000001 /* SidebarHiddenPresentationTests.swift in Sources */,
Expand Down
Loading
Loading