Skip to content
Closed
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
1 change: 1 addition & 0 deletions Sources/Canvas/CanvasHostedPanelContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct CanvasHostedPanelContentView: View {
hasUnreadNotification: false,
terminalAgentContext: "",
onFocus: onRequestPanelFocus,
onFocusTextBox: onRequestPanelFocus,
onRequestPanelFocus: onRequestPanelFocus,
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
Expand Down
6 changes: 6 additions & 0 deletions Sources/DockSplitPanelContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ struct DockSplitPanelContentView: View, Equatable {
window: NSApp.keyWindow ?? NSApp.mainWindow
)
},
onFocusTextBox: {
store.focusPanelFromDockInteraction(
panel.id,
window: NSApp.keyWindow ?? NSApp.mainWindow
)
},
onRequestPanelFocus: {
store.focusPanelFromDockInteraction(
panel.id,
Expand Down
2 changes: 2 additions & 0 deletions Sources/Panels/PanelContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ struct PanelContentView: View {
/// of trusting the SwiftUI snapshot captured before a cross-container move.
var terminalPaneOwnershipResolver: (@MainActor () -> Bool)? = nil
let onFocus: () -> Void
let onFocusTextBox: () -> Void
let onRequestPanelFocus: () -> Void
let onResumeAgentHibernation: () -> Void
let onAutoResumeAgentHibernation: () -> Void
Expand Down Expand Up @@ -68,6 +69,7 @@ struct PanelContentView: View {
hasUnreadNotification: hasUnreadNotification,
terminalAgentContext: terminalAgentContext,
onFocus: onFocus,
onFocusTextBox: onFocusTextBox,
onResumeAgentHibernation: onResumeAgentHibernation,
onAutoResumeAgentHibernation: onAutoResumeAgentHibernation,
onTriggerFlash: onTriggerFlash
Expand Down
3 changes: 2 additions & 1 deletion Sources/Panels/TerminalPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct TerminalPanelView: View {
let hasUnreadNotification: Bool
let terminalAgentContext: String
let onFocus: () -> Void
let onFocusTextBox: () -> Void
let onResumeAgentHibernation: () -> Void
let onAutoResumeAgentHibernation: () -> Void
let onTriggerFlash: () -> Void
Expand Down Expand Up @@ -159,7 +160,7 @@ struct TerminalPanelView: View {
),
onFocusTextBox: {
panel.textBoxDidBecomeFocused()
onFocus()
onFocusTextBox()
},
onToggleFocus: {
_ = panel.focusTextBoxInputOrTerminal()
Expand Down
4 changes: 4 additions & 0 deletions Sources/RemoteTmuxWindowMirrorSplitView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ struct RemoteTmuxWindowMirrorSplitView: View {
onOuterFocus()
mirror.setActivePane(tmuxPaneId, fromTmux: false)
},
onFocusTextBox: {
onOuterFocus()
mirror.setActivePane(tmuxPaneId, fromTmux: false)
},
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
onTriggerFlash: {}
Expand Down
7 changes: 5 additions & 2 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11677,13 +11677,16 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
/// guard at the workspace boundary makes the active pane border and the
/// keyboard target follow the same selected workspace.
@discardableResult
func focusPanelFromTerminalInput(_ panelId: UUID) -> Bool {
func focusPanelFromTerminalInput(
_ panelId: UUID,
trigger: FocusPanelTrigger = .terminalFirstResponder
) -> Bool {
guard panels[panelId] != nil,
(owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id))?.selectedTabId == id,
focusedPanelId != panelId else {
return false
}
focusPanel(panelId, trigger: .terminalFirstResponder)
focusPanel(panelId, trigger: trigger)
return true
}

Expand Down
22 changes: 22 additions & 0 deletions Sources/WorkspaceContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ private struct WorkspacePanelContentHostView: View {
let isFocused: Bool
let isSelectedInPane: Bool
let isVisibleInUI: Bool
let isWorkspaceInputActive: Bool
let allowsPointerInput: Bool
let portalPriority: Int
let isSplit: Bool
Expand Down Expand Up @@ -92,6 +93,11 @@ private struct WorkspacePanelContentHostView: View {
return workspace.bonsplitController.selectedTabId(inPane: paneId) == tabId
},
onFocus: onFocus,
onFocusTextBox: WorkspaceContentView.makeComposerFocusHandler(
workspace: workspace,
panel: panel,
isWorkspaceInputActive: isWorkspaceInputActive
),
onRequestPanelFocus: onRequestPanelFocus,
onResumeAgentHibernation: onResumeAgentHibernation,
onAutoResumeAgentHibernation: onAutoResumeAgentHibernation,
Expand Down Expand Up @@ -202,6 +208,21 @@ struct WorkspaceContentView: View {
@Environment(\.minimalModeInvalidationProbe) private var minimalModeInvalidationProbe
#endif

/// Builds the production callback used when a terminal composer becomes
/// first responder. It selects the live panel's pane without scheduling a
/// second terminal focus request that could steal the composer responder.
static func makeComposerFocusHandler(
workspace: Workspace,
panel: any Panel,
isWorkspaceInputActive: Bool
) -> () -> Void {
{
guard isWorkspaceInputActive else { return }
guard workspace.panels[panel.id] != nil else { return }
workspace.focusPanelFromTerminalInput(panel.id, trigger: .standard)
}
}

var body: some View {
#if DEBUG
let _ = {
Expand Down Expand Up @@ -295,6 +316,7 @@ struct WorkspaceContentView: View {
isFocused: isFocused,
isSelectedInPane: isSelectedInPane,
isVisibleInUI: isVisibleInUI,
isWorkspaceInputActive: isWorkspaceInputActive,
allowsPointerInput: isWorkspaceInputActive
&& isWorkspaceVisible
&& isSelectedInPane,
Expand Down
1 change: 1 addition & 0 deletions cmuxTests/BrowserPanelViewIdentityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ private struct BrowserPanelReplacementHarness: View {
terminalAgentContext: "",
paneOwnershipOverride: true,
onFocus: {},
onFocusTextBox: {},
onRequestPanelFocus: {},
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
Expand Down
1 change: 1 addition & 0 deletions cmuxTests/BrowserPanelWindowVisibilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ private struct BrowserPanelWindowVisibilityHarness: View {
terminalAgentContext: "",
paneOwnershipOverride: true,
onFocus: {},
onFocusTextBox: {},
onRequestPanelFocus: {},
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
Expand Down
2 changes: 2 additions & 0 deletions cmuxTests/SimulatorPanelThemeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ struct SimulatorPanelVisibilityTests {
hasUnreadNotification: false,
terminalAgentContext: "",
onFocus: {},
onFocusTextBox: {},
onRequestPanelFocus: {},
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
Expand Down Expand Up @@ -217,6 +218,7 @@ struct SimulatorPanelThemeTests {
hasUnreadNotification: false,
terminalAgentContext: "",
onFocus: {},
onFocusTextBox: {},
onRequestPanelFocus: {},
onResumeAgentHibernation: {},
onAutoResumeAgentHibernation: {},
Expand Down
76 changes: 76 additions & 0 deletions cmuxTests/SurfacePaneFactoryFocusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,82 @@ import SwiftUI
#expect(destination.focusedPanelId == localDestinationPanel)
}

@Test("Cloud composer focus selects its pane through the host callback")
func cloudComposerFocusSelectsItsPane() throws {
let harness = try Harness()
defer { harness.tearDown() }
let workspace = harness.workspace
let sourcePane = try #require(workspace.bonsplitController.focusedPaneId)
let localPanelID = try #require(workspace.focusedPanelId)
let cloudPanel = try #require(workspace.makeRemoteTmuxPanePanel(onInput: { _ in }))
_ = try workspace.insertCloudManualMirrorPanel(
cloudPanel,
at: .split(workspaceID: workspace.id, paneID: sourcePane.id.uuidString, direction: .right),
focus: false,
isLoading: false
)
let window = try #require(harness.appDelegate.mainWindow(for: harness.windowId))
let contentView = try #require(window.contentView)
let textView = TextBoxInputTextView(frame: NSRect(x: 0, y: 0, width: 240, height: 30))
let scrollView = NSScrollView(frame: textView.frame)
scrollView.documentView = textView
contentView.addSubview(scrollView)
defer { scrollView.removeFromSuperview() }
let focusComposer = WorkspaceContentView.makeComposerFocusHandler(
workspace: workspace,
panel: cloudPanel,
isWorkspaceInputActive: true
)
textView.onFocusTextBox = {
cloudPanel.textBoxDidBecomeFocused()
focusComposer()
}
cloudPanel.registerTextBoxInputView(textView)
textView.string = "unsubmitted cloud draft"
#expect(workspace.focusedPanelId == localPanelID)

#expect(window.makeFirstResponder(textView))

let cloudPane = try #require(workspace.paneId(forPanelId: cloudPanel.id))
#expect(workspace.focusedPanelId == cloudPanel.id)
#expect(workspace.bonsplitController.focusedPaneId == cloudPane)
#expect(window.firstResponder === textView)
#expect(cloudPanel.preferredFocusIntentForActivation() == .terminal(.textBoxInput))
#expect(textView.string == "unsubmitted cloud draft")
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
#expect(workspace.focusedPanelId == cloudPanel.id)
#expect(window.firstResponder === textView)
}

@Test("Background Cloud composer callbacks do not change pane selection")
func backgroundCloudComposerDoesNotChangePaneSelection() throws {
let harness = try Harness()
defer { harness.tearDown() }
let workspace = harness.workspace
let sourcePane = try #require(workspace.bonsplitController.focusedPaneId)
let localPanelID = try #require(workspace.focusedPanelId)
let cloudPanel = try #require(workspace.makeRemoteTmuxPanePanel(onInput: { _ in }))
_ = try workspace.insertCloudManualMirrorPanel(
cloudPanel,
at: .split(workspaceID: workspace.id, paneID: sourcePane.id.uuidString, direction: .right),
focus: false,
isLoading: false
)
let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowId))
let selectedWorkspace = manager.addWorkspace(select: true, eagerLoadTerminal: false)

let focusComposer = WorkspaceContentView.makeComposerFocusHandler(
workspace: workspace,
panel: cloudPanel,
isWorkspaceInputActive: false
)
cloudPanel.textBoxDidBecomeFocused()
focusComposer()

#expect(manager.selectedTabId == selectedWorkspace.id)
#expect(workspace.focusedPanelId == localPanelID)
}

@Test("Cloud shortcut inheritance uses the live remote foreground cwd")
func cloudShortcutInheritanceUsesLiveRemoteForegroundCwd() async throws {
let harness = try Harness()
Expand Down
Loading