Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
32 changes: 28 additions & 4 deletions Sources/BackgroundWorkspacePrimeCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import Combine

@MainActor
final class BackgroundWorkspacePrimeCoordinator {
private let timeoutSeconds: TimeInterval

private nonisolated enum PrimeCompletionReason: String {
case alreadyCleared = "already_cleared"
case cancelled
Expand Down Expand Up @@ -93,10 +95,26 @@ final class BackgroundWorkspacePrimeCoordinator {
}
}

init(timeoutSeconds: TimeInterval = Policy.timeoutSeconds) {
self.timeoutSeconds = timeoutSeconds
}

deinit {
// Explicit for the required_deinit lint; per-prime resources live on Waiter.
}

#if DEBUG
func debugPrimeBackgroundWorkspaceOnceForTesting(
workspaceId: UUID,
tabManager: TabManager
) async -> String {
await primeBackgroundWorkspaceIfNeeded(
workspaceId: workspaceId,
tabManager: tabManager
).rawValue
}
#endif

func taskKey(for tabManager: TabManager) -> [String] {
tabManager.pendingBackgroundWorkspaceLoadIds
.map(\.uuidString)
Expand All @@ -114,8 +132,6 @@ final class BackgroundWorkspacePrimeCoordinator {

switch reason {
case .timeout:
// Keep the hidden mount retained; pending background initial commands
// must stay eligible to start until the surface is actually ready.
continue
case .cancelled:
continue
Expand All @@ -141,14 +157,22 @@ final class BackgroundWorkspacePrimeCoordinator {
cmuxDebugLog("workspace.backgroundPrime.start workspace=\(workspaceId.uuidString.prefix(5))")
#endif

let completionReason: PrimeCompletionReason
var completionReason: PrimeCompletionReason = .cancelled
defer {
switch completionReason {
case .timeout, .cancelled:
tabManager.releaseBackgroundWorkspaceMount(for: workspaceId)
case .alreadyCleared, .surfaceReady, .workspaceRemoved:
break
}
}
switch stepBackgroundWorkspacePrime(workspaceId: workspaceId, tabManager: tabManager) {
case .completed(let reason):
completionReason = reason
case .pending:
completionReason = await waitForBackgroundWorkspacePrimeCompletion(
workspaceId: workspaceId,
timeoutSeconds: Policy.timeoutSeconds,
timeoutSeconds: timeoutSeconds,
tabManager: tabManager
)
}
Expand Down
32 changes: 28 additions & 4 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,20 @@ enum WorkspaceMountPolicy {
return ordered
}

static func pinnedWorkspaceIds(
retiringWorkspaceId: UUID?,
backgroundWorkspaceLoadIds: Set<UUID>,
debugPinnedWorkspaceLoadIds: Set<UUID>,
isCycleHot: Bool
) -> Set<UUID> {
let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let shouldMountBackgroundLoads = retiringWorkspaceId == nil && !isCycleHot
let backgroundPinnedIds = shouldMountBackgroundLoads ? backgroundWorkspaceLoadIds : []
return handoffPinnedIds
.union(backgroundPinnedIds)
.union(debugPinnedWorkspaceLoadIds)
}

private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
guard orderedTabIds.contains(selected) else { return [selected] }
// Keep warming focused to the selected workspace. Retiring/target workspaces are
Expand Down Expand Up @@ -2786,6 +2800,13 @@ struct ContentView: View {
scheduleTitlebarTextRefresh()
})

view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .terminalPortalVisibilityDidChange)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId,
notification.userInfo?[GhosttyNotificationKey.terminalVisibleInUI] as? Bool == true else { return }
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "terminal_visible")
})

view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in
guard GhosttyApp.shared.backgroundLogEnabled else { return }
GhosttyApp.shared.logBackground(
Expand Down Expand Up @@ -3271,11 +3292,14 @@ struct ContentView: View {
let currentTabs = tabs ?? tabManager.tabs
let orderedTabIds = currentTabs.map { $0.id }
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let pinnedIds = handoffPinnedIds
.union(tabManager.mountedBackgroundWorkspaceLoadIds)
.union(tabManager.debugPinnedWorkspaceLoadIds)
let isCycleHot = tabManager.isWorkspaceCycleHot
let pinnedIds = WorkspaceMountPolicy.pinnedWorkspaceIds(
retiringWorkspaceId: retiringWorkspaceId,
backgroundWorkspaceLoadIds: tabManager.mountedBackgroundWorkspaceLoadIds,
debugPinnedWorkspaceLoadIds: tabManager.debugPinnedWorkspaceLoadIds,
isCycleHot: isCycleHot
)
let handoffPinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
let shouldKeepHandoffPair = isCycleHot && !handoffPinnedIds.isEmpty
let baseMaxMounted = shouldKeepHandoffPair
? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
Expand Down
1 change: 1 addition & 0 deletions Sources/GhosttyTerminalAppearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ enum GhosttyNotificationKey {
static let cellSize = "ghostty.cellSize"
static let tabId = "ghostty.tabId"
static let surfaceId = "ghostty.surfaceId"
static let terminalVisibleInUI = "ghostty.terminalVisibleInUI"
static let explicitFocusIntent = "ghostty.explicitFocusIntent"
static let title = "ghostty.title"
static let backgroundColor = "ghostty.backgroundColor"
Expand Down
3 changes: 2 additions & 1 deletion Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11313,7 +11313,8 @@ final class GhosttySurfaceScrollView: NSView {
object: self,
userInfo: [
GhosttyNotificationKey.surfaceId: surfaceView.terminalSurface?.id as Any,
GhosttyNotificationKey.tabId: surfaceView.tabId as Any
GhosttyNotificationKey.tabId: surfaceView.tabId as Any,
GhosttyNotificationKey.terminalVisibleInUI: visible
]
)
}
Expand Down
21 changes: 19 additions & 2 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4398,9 +4398,16 @@ class TabManager: ObservableObject {
}

func selectWorkspace(_ workspace: Workspace) {
guard selectedTabId != workspace.id else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}
Comment on lines 4400 to 4412

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The debugPrimeWorkspaceSwitchTrigger call appears in both the early-return branch and the fall-through branch with identical arguments. The function already guards on selectedTabId != target internally — calling it once, before the guard, produces the same effect in both paths and removes the duplication. As written, a future reader has to know the internal logic of debugPrimeWorkspaceSwitchTrigger to confirm the two calls aren't accidentally doing different things.

Suggested change
func selectWorkspace(_ workspace: Workspace) {
guard selectedTabId != workspace.id else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}
func selectWorkspace(_ workspace: Workspace) {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
guard selectedTabId != workspace.id else { return }
activateWorkspaceCycleHotWindow()
selectedTabId = workspace.id
}


Expand Down Expand Up @@ -5454,14 +5461,24 @@ class TabManager: ObservableObject {

func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
guard selectedTabId != tabId else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
selectedTabId = tabs[index].id
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}
Comment on lines 5462 to 5476

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Same duplicated debug-trigger pattern as selectWorkspace. Since debugPrimeWorkspaceSwitchTrigger guards internally on selectedTabId != target, hoisting the call above the guard covers both paths with one block.

Suggested change
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
guard selectedTabId != tabId else {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
return
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabs[index].id)
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
selectedTabId = tabs[index].id
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}
func selectTab(at index: Int) {
guard index >= 0 && index < tabs.count else { return }
let tabId = tabs[index].id
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select_index", to: tabId)
#endif
guard selectedTabId != tabId else { return }
activateWorkspaceCycleHotWindow()
selectedTabId = tabId
}


func selectLastTab() {
guard let lastTab = tabs.last else { return }
guard selectedTabId != lastTab.id else { return }
activateWorkspaceCycleHotWindow()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

selectLastTab missing debug workspace switch tracking

Low Severity

selectLastTab now calls activateWorkspaceCycleHotWindow() (consistent with selectWorkspace and selectTab(at:)), but unlike those methods it omits the debugPrimeWorkspaceSwitchTrigger call. This means workspace-switch debug tracking and benchmark instrumentation won't capture switches triggered via selectLastTab, making the performance telemetry inconsistent across selection paths.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 323c736. Configure here.

selectedTabId = lastTab.id
}

Expand Down
15 changes: 8 additions & 7 deletions Sources/Workspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12086,21 +12086,22 @@ final class Workspace: Identifiable, ObservableObject {
}
}

func setPortalRenderingEnabled(_ enabled: Bool, reason: String) {
@discardableResult
func setPortalRenderingEnabled(_ enabled: Bool, reason: String) -> Bool {
let changed = portalRenderingEnabled != enabled
guard changed else { return false }
portalRenderingEnabled = enabled
if enabled {
if changed {
beginEventDrivenLayoutFollowUp(
reason: reason,
includeGeometry: true
)
}
beginEventDrivenLayoutFollowUp(
reason: reason,
includeGeometry: true
)
} else {
clearLayoutFollowUp()
hideAllTerminalPortalViews()
hideAllBrowserPortalViews()
}
return true
}

// MARK: - Utility
Expand Down
27 changes: 27 additions & 0 deletions cmuxTests/TabManagerUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ private func runGit(
return result.stdout
}

@MainActor
final class TabManagerWorkspaceSelectionPerfTests: XCTestCase {
func testDirectWorkspaceSelectionActivatesHotSwitchWindow() {
let manager = TabManager(autoWelcomeIfNeeded: false)
let second = manager.addWorkspace(select: false, autoWelcomeIfNeeded: false)

XCTAssertFalse(manager.isWorkspaceCycleHot)

manager.selectWorkspace(second)

XCTAssertEqual(manager.selectedTabId, second.id)
XCTAssertTrue(manager.isWorkspaceCycleHot)
}

func testSelectingCurrentWorkspaceDoesNotActivateHotSwitchWindow() {
let manager = TabManager(autoWelcomeIfNeeded: false)
guard let selected = manager.selectedWorkspace else {
XCTFail("Expected initial workspace")
return
}

manager.selectWorkspace(selected)

XCTAssertFalse(manager.isWorkspaceCycleHot)
}
}

@MainActor
final class TabManagerChildExitCloseTests: XCTestCase {
func testChildExitOnLastPanelClosesSelectedWorkspaceAndKeepsIndexStable() {
Expand Down
84 changes: 84 additions & 0 deletions cmuxTests/WorkspaceUnitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5192,6 +5192,90 @@ final class WorkspaceMountPolicyTests: XCTestCase {

XCTAssertEqual(next, [b, a])
}

func testPinnedWorkspaceIdsIncludesBackgroundLoadsWhenIdle() {
let background = UUID()
let debug = UUID()

let pinnedIds = WorkspaceMountPolicy.pinnedWorkspaceIds(
retiringWorkspaceId: nil,
backgroundWorkspaceLoadIds: [background],
debugPinnedWorkspaceLoadIds: [debug],
isCycleHot: false
)

XCTAssertEqual(pinnedIds, [background, debug])
}

func testPinnedWorkspaceIdsSuppressesBackgroundLoadsDuringHandoff() {
let retiring = UUID()
let background = UUID()
let debug = UUID()

let pinnedIds = WorkspaceMountPolicy.pinnedWorkspaceIds(
retiringWorkspaceId: retiring,
backgroundWorkspaceLoadIds: [background],
debugPinnedWorkspaceLoadIds: [debug],
isCycleHot: false
)

XCTAssertEqual(pinnedIds, [retiring, debug])
}

func testPinnedWorkspaceIdsSuppressesBackgroundLoadsDuringHotCycle() {
let background = UUID()
let debug = UUID()

let pinnedIds = WorkspaceMountPolicy.pinnedWorkspaceIds(
retiringWorkspaceId: nil,
backgroundWorkspaceLoadIds: [background],
debugPinnedWorkspaceLoadIds: [debug],
isCycleHot: true
)

XCTAssertEqual(pinnedIds, [debug])
}
}


@MainActor
final class BackgroundWorkspacePrimeCoordinatorTests: XCTestCase {
func testTimedOutPrimeReleasesMountButKeepsPendingLoad() async {
#if DEBUG
let manager = TabManager(autoWelcomeIfNeeded: false)
let workspace = manager.addWorkspace(
select: false,
eagerLoadTerminal: false,
autoWelcomeIfNeeded: false
)
manager.requestBackgroundWorkspaceLoad(for: workspace.id)

let coordinator = BackgroundWorkspacePrimeCoordinator(timeoutSeconds: 0.01)
let reason = await coordinator.debugPrimeBackgroundWorkspaceOnceForTesting(
workspaceId: workspace.id,
tabManager: manager
)

XCTAssertEqual(reason, "timeout")
XCTAssertTrue(manager.pendingBackgroundWorkspaceLoadIds.contains(workspace.id))
XCTAssertFalse(manager.mountedBackgroundWorkspaceLoadIds.contains(workspace.id))
#else
throw XCTSkip("Debug-only background prime timeout test")
#endif
}
}


@MainActor
final class WorkspacePortalRenderingTests: XCTestCase {
func testPortalRenderingToggleReportsOnlyStateTransitions() {
let workspace = Workspace()

XCTAssertTrue(workspace.setPortalRenderingEnabled(false, reason: "test"))
XCTAssertFalse(workspace.setPortalRenderingEnabled(false, reason: "test"))
XCTAssertTrue(workspace.setPortalRenderingEnabled(true, reason: "test"))
XCTAssertFalse(workspace.setPortalRenderingEnabled(true, reason: "test"))
}
}


Expand Down
Loading
Loading