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
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,22 @@ public struct CloudNotificationPlacementResolver: Sendable {
return bound.first.map { CloudNotificationDeliveryTarget(workspaceID: $0.workspaceID, panelID: nil) }
}
let resource = SurfaceResourceID(machine: machine, kind: .terminal, key: terminalID)
if let projection = projections(resource).first {
let resourceProjections = projections(resource)
// A terminal can briefly have more than one local projection while a
// Cloud mirror is being moved between panes. Prefer the projection in
// the daemon's remote workspace when that identity is available. The
// catalog stores projections in a set and hands them back sorted by
// `panelID.uuidString`, so without that identity the ring lands on
// whichever duplicate happens to sort first.
let preferredRemoteWorkspaceID = remoteWorkspaceID(terminalID)
if let projection = resourceProjections.first(where: {
guard let preferredRemoteWorkspaceID else { return false }
return $0.remoteWorkspaceID == preferredRemoteWorkspaceID
}) ?? resourceProjections.first {
return CloudNotificationDeliveryTarget(workspaceID: projection.workspaceID, panelID: projection.panelID)
}
guard let remoteWorkspaceID = remoteWorkspaceID(terminalID) else { return nil }
return bound.first { $0.remoteWorkspaceID == remoteWorkspaceID }
guard let preferredRemoteWorkspaceID else { return nil }
return bound.first { $0.remoteWorkspaceID == preferredRemoteWorkspaceID }
.map { CloudNotificationDeliveryTarget(workspaceID: $0.workspaceID, panelID: nil) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,62 @@ import Testing
#expect(target?.panelID == selectedPane)
}

@MainActor @Test func duplicateTerminalProjectionsPreferTheRemoteWorkspaceIdentity() {
let localWorkspace = UUID(), cloudWorkspace = UUID()
let localPane = UUID(), cloudPane = UUID()
let local = Self.projection(
Self.terminalID,
workspace: localWorkspace,
panel: localPane
)
let cloud = Self.projection(
Self.terminalID,
workspace: cloudWorkspace,
panel: cloudPane
)
let remoteWorkspaceID = "remote-cloud-workspace"
var cloudProjection = cloud
cloudProjection.remoteWorkspaceID = remoteWorkspaceID
// The other duplicate claims a different remote workspace rather than
// no workspace at all, so this pins "the matching identity wins" and
// not the weaker "a present identity beats an absent one".
var localProjection = local
localProjection.remoteWorkspaceID = "remote-other-workspace"
let resolver = CloudNotificationPlacementResolver(
machine: Self.machine,
projections: { _ in [localProjection, cloudProjection] },
remoteWorkspaceID: { _ in remoteWorkspaceID },
boundWorkspaces: { [] }
)

let target = resolver.target(for: Self.notificationRow(terminalID: Self.terminalID))
#expect(target?.workspaceID == cloudWorkspace)
#expect(target?.panelID == cloudPane)
}

@MainActor @Test func duplicateMatchingProjectionsTakeTheFirstSortedPane() {
// Two projections in the same remote workspace is the ambiguous case.
// The pick has to be the first one the catalog hands back, so that the
// ring does not move between snapshots for the same input.
let firstWorkspace = UUID(), secondWorkspace = UUID()
let firstPane = UUID(), secondPane = UUID()
let remoteWorkspaceID = "remote-cloud-workspace"
var first = Self.projection(Self.terminalID, workspace: firstWorkspace, panel: firstPane)
first.remoteWorkspaceID = remoteWorkspaceID
var second = Self.projection(Self.terminalID, workspace: secondWorkspace, panel: secondPane)
second.remoteWorkspaceID = remoteWorkspaceID
let resolver = CloudNotificationPlacementResolver(
machine: Self.machine,
projections: { _ in [first, second] },
remoteWorkspaceID: { _ in remoteWorkspaceID },
boundWorkspaces: { [] }
)

let target = resolver.target(for: Self.notificationRow(terminalID: Self.terminalID))
#expect(target?.workspaceID == firstWorkspace)
#expect(target?.panelID == firstPane)
}

// MARK: Link pipe

@Test("The real pipe reader discards oversized lines and preserves later frames", .timeLimit(.minutes(1)))
Expand Down
19 changes: 19 additions & 0 deletions Sources/GhosttyNSView+PointerFocusActivation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ extension GhosttyNSView {
panelId: terminalSurface.id,
in: window
)
// The portal callback normally converges Bonsplit's selected pane
// with the AppKit first responder. A manual-mirror (Cloud) portal
// can remain interactive for one reconciliation turn while its
// callback is being rebound, though. Move the workspace model at
// pointer-down time as well so input and the visible focus ring
// cannot temporarily disagree.
// `panels` is the same membership guard the portal callback uses.
// 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
)
}
case .rightSidebarDock:
DockSplitStore.focusPanelFromDockPointer(terminalSurface.id, window: window)
}
Expand Down
31 changes: 31 additions & 0 deletions cmuxTests/SurfacePaneFactoryFocusTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,37 @@ import SwiftUI
#endif
}

/// A Cloud manual-mirror surface can receive a pointer while its portal
/// focus callback is being rebound. Pointer activation must still update
/// the workspace model immediately, otherwise keyboard input goes to Cloud
/// while Bonsplit keeps the local pane's focus ring.
@Test func pointerFocusConvergesMixedLocalAndCloudPanes() 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
)
#expect(workspace.focusedPanelId == localPanelID)
#expect(workspace.isFocusedTerminalInputSurface(localPanelID))

let pointerView = GhosttyNSView(frame: .zero)
pointerView.terminalSurface = cloudPanel.surface
pointerView.activateContainerFocusFromPointerDown()

#expect(workspace.focusedPanelId == cloudPanel.id)
#expect(workspace.isFocusedTerminalInputSurface(cloudPanel.id))
let cloudPane = try #require(workspace.paneId(forPanelId: cloudPanel.id))
#expect(cloudPane != sourcePane)
}

/// A projected browser (VM desktop or port preview) goes through the same create
/// handler as a terminal; `focus: true` must select it too.
@Test func focusedBrowserTabIsSelected() throws {
Expand Down
Loading