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
10 changes: 1 addition & 9 deletions Sources/GhosttyNSView+PointerFocusActivation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,7 @@ extension GhosttyNSView {
// A remote-tmux mirror pane surface is not a workspace panel, so
// without it every pointer-down in one of those would ask the
// container to select a pane it does not own.
if let workspace = terminalSurface.owningWorkspace(),
workspace.panels[terminalSurface.id] != nil,
(workspace.owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: workspace.id))?.selectedTabId == workspace.id,
workspace.focusedPanelId != terminalSurface.id {
workspace.focusPanel(
terminalSurface.id,
trigger: .terminalFirstResponder
)
}
terminalSurface.owningWorkspace()?.focusPanelFromTerminalInput(terminalSurface.id)
case .rightSidebarDock:
DockSplitStore.focusPanelFromDockPointer(terminalSurface.id, window: window)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Panels/TerminalPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ final class TerminalPanel: Panel, ObservableObject {
/// Optional owner hook for a manual mirror that becomes the active pane.
/// Ordinary terminals leave this unset.
var onTerminalFocus: (() -> Void)?
/// Optional input hook owned by a manual-mirror session. Workspace routing
/// composes this hook into its current owner callback so a panel that moves
/// between workspaces keeps its Cloud input behavior.
var onManualMirrorExplicitInput: (() -> Void)?

private var cancellables = Set<AnyCancellable>()
/// Shared monotonic gate for AppKit and workspace-overlay flash renderers.
Expand Down
20 changes: 12 additions & 8 deletions Sources/Surfaces/CmuxTuiSurfaceProvider+ManualMirror.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,18 @@ extension CmuxTuiSurfaceProvider {
)
}
session.bind(surface: created.surface)
// Preserve the workspace's existing notification-dismissal hook
// while re-claiming geometry when this pane receives explicit
// input. A cloud terminal can have more than one local projection;
// the pane the user is typing in must be the authoritative owner.
let existingExplicitInput = created.surface.onExplicitInput
created.surface.onExplicitInput = { [weak session] in
existingExplicitInput?()
session?.noteExplicitInput()
// Keep Cloud input convergence on the panel. Workspace transfer
// rebinds the ordinary terminal callback, while this hook resolves
// the panel's current owner after a move.
if let createdPanel = Workspace.liveWorkspace(id: created.workspaceID)?.panels[created.panelID] as? TerminalPanel {
Workspace.bindCloudManualMirrorInputConvergence(
panel: createdPanel,
isActive: { [weak session, weak createdPanel] in
guard let session, let createdPanel else { return false }
return session.phase != .stopped && session.surface === createdPanel.surface
},
onExplicitInput: { [weak session] in session?.noteExplicitInput() }
)
}
manualMirrorSessions[created.panelID] = session
session.reconnect(socketPath: connected.socketPath)
Expand Down
16 changes: 16 additions & 0 deletions Sources/Surfaces/Workspace+CloudManualMirror.swift
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,22 @@ extension Workspace {
panel.cloudAttachment = attachment
}

/// Keeps Cloud input convergence attached to the panel rather than to the
/// workspace that happened to create it. Workspace transfer rebinds the
/// ordinary terminal callback, while this hook follows the panel and
/// resolves its current workspace at input time.
static func bindCloudManualMirrorInputConvergence(
panel: TerminalPanel,
isActive: @escaping @MainActor () -> Bool = { true },
onExplicitInput: @escaping @MainActor () -> Void
) {
panel.onManualMirrorExplicitInput = { [weak panel] in
guard let panel, isActive() else { return }
Workspace.liveWorkspace(id: panel.workspaceId)?.focusPanelFromTerminalInput(panel.id)
Comment thread
teamleaderleo marked this conversation as resolved.
onExplicitInput()
}
}

/// Places an already-built manual-mirror panel at `destination` and returns its id.
/// `isLoading` marks the tab strip while an optimistic pane waits for its terminal.
func insertCloudManualMirrorPanel(
Expand Down
1 change: 1 addition & 0 deletions Sources/Workspace+AttentionFlashRouting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ extension Workspace {
func installTerminalVisualBellRouting(for terminalPanel: TerminalPanel) {
terminalPanel.surface.onExplicitInput = { [weak self, weak terminalPanel] in
guard let self, let terminalPanel else { return }
terminalPanel.onManualMirrorExplicitInput?()
terminalPanel.recordExplicitInput()
// Explicit input is shared-sizing activity for this Mac pane.
TerminalController.shared.noteLocalTerminalSizingActivity(surfaceID: terminalPanel.id)
Expand Down
16 changes: 16 additions & 0 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11678,6 +11678,22 @@ final class Workspace: Identifiable, ObservableObject, FilePreviewTabMetadataHos
}
// MARK: - Focus Management

/// Converges the workspace pane selection when a mounted terminal accepts
/// user input. Cloud manual-mirror surfaces can receive an input event
/// while their portal focus callback is still being rebound; keeping this
/// 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 {
guard panels[panelId] != nil,
(owningTabManager ?? AppDelegate.shared?.tabManagerFor(tabId: id))?.selectedTabId == id,
focusedPanelId != panelId else {
return false
}
focusPanel(panelId, trigger: .terminalFirstResponder)
return true
}

/// Focuses a panel a split just created. `previousHostedView` is the terminal that
/// had focus before Bonsplit moved it. SwiftUI reparents that view into the new
/// split, which would trigger onFocus + ghostty_surface_set_focus on it and steal
Expand Down
61 changes: 61 additions & 0 deletions cmuxTests/SurfacePaneFactoryFocusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,67 @@ import SwiftUI
#expect(workspace.panelIdFromSurfaceId(selectedSurface) == created.panelID)
}

@Test("Cloud terminal input reasserts the active pane")
func cloudTerminalInputReassertsActivePane() 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 }))
cloudPanel.cloudAttachment = CloudTerminalAttachmentStatus(machineID: "focus-ring-test")
_ = try workspace.insertCloudManualMirrorPanel(
cloudPanel,
at: .split(workspaceID: workspace.id, paneID: sourcePane.id.uuidString, direction: .right),
focus: false,
isLoading: false
)
var sessionActive = true
Workspace.bindCloudManualMirrorInputConvergence(
panel: cloudPanel,
isActive: { sessionActive },
onExplicitInput: {
#expect(Workspace.liveWorkspace(id: cloudPanel.workspaceId)?.focusedPanelId == cloudPanel.id)
}
)

#expect(workspace.focusedPanelId == localPanelID)

// Pointer activation must converge the visible pane selection before
// the portal's asynchronous callback gets a chance to run.
let pointerView = GhosttyNSView(frame: .zero)
pointerView.terminalSurface = cloudPanel.surface
pointerView.activateContainerFocusFromPointerDown()
#expect(workspace.focusedPanelId == cloudPanel.id)
#expect(workspace.isFocusedTerminalInputSurface(cloudPanel.id))

workspace.focusPanel(localPanelID)
#expect(workspace.focusedPanelId == localPanelID)
cloudPanel.surface.onExplicitInput?()
#expect(workspace.focusedPanelId == cloudPanel.id)
cloudPanel.surface.onExplicitInput?()
#expect(workspace.focusedPanelId == cloudPanel.id)

// Moving the same panel to another workspace must keep the input hook
// attached to its new owner after attach rebinds ordinary callbacks.
let manager = try #require(harness.appDelegate.tabManagerFor(windowId: harness.windowId))
let destination = manager.addWorkspace(select: false, eagerLoadTerminal: false)
let detached = try #require(workspace.detachSurface(panelId: cloudPanel.id))
let destinationPane = try #require(destination.bonsplitController.allPaneIds.first)
#expect(destination.attachDetachedSurface(detached, inPane: destinationPane, focus: false) == cloudPanel.id)
manager.selectWorkspace(destination)
#expect(destination.focusedPanelId != cloudPanel.id)
cloudPanel.surface.onExplicitInput?()
#expect(destination.focusedPanelId == cloudPanel.id)

// A stopped session must not steal focus if its panel is later reused.
sessionActive = false
let localDestinationPanel = try #require(destination.panels.keys.first { $0 != cloudPanel.id })
destination.focusPanelFromTerminalInput(localDestinationPanel)
cloudPanel.surface.onExplicitInput?()
#expect(destination.focusedPanelId == localDestinationPanel)
}

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