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
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */; };
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */; };
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008382 /* CommandPaletteSearchEngineTests.swift */; };
DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DA7A10CA710E000000000001 /* Localizable.xcstrings */; };
Expand Down Expand Up @@ -238,6 +239,7 @@
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyEnsureFocusWindowActivationTests.swift; sourceTree = "<group>"; };
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceStressProfileTests.swift; sourceTree = "<group>"; };
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008382 /* CommandPaletteSearchEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteSearchEngineTests.swift; sourceTree = "<group>"; };
DA7A10CA710E000000000001 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -472,6 +474,7 @@
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
F9000001A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift */,
FA000001A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift */,
A5008380 /* BrowserFindJavaScriptTests.swift */,
A5008382 /* CommandPaletteSearchEngineTests.swift */,
);
Expand Down Expand Up @@ -711,6 +714,7 @@
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
F9000000A1B2C3D4E5F60718 /* GhosttyEnsureFocusWindowActivationTests.swift in Sources */,
FA000000A1B2C3D4E5F60718 /* WorkspaceStressProfileTests.swift in Sources */,
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */,
A5008383 /* CommandPaletteSearchEngineTests.swift in Sources */,
);
Expand Down
71 changes: 62 additions & 9 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,7 @@ struct ContentView: View {
@State private var workspaceHandoffGeneration: UInt64 = 0
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
@State private var didApplyUITestSidebarSelection = false
@State private var workspaceHandoffReadyCheckTask: Task<Void, Never>?
@State private var titlebarThemeGeneration: UInt64 = 0
@State private var sidebarDraggedTabId: UUID?
@State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0)
Expand Down Expand Up @@ -2881,13 +2882,16 @@ struct ContentView: View {
retiringWorkspaceId = nil
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
return
}

workspaceHandoffGeneration &+= 1
let generation = workspaceHandoffGeneration
retiringWorkspaceId = oldSelectedId
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffReadyCheckTask?.cancel()

#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
Expand All @@ -2903,6 +2907,36 @@ struct ContentView: View {
}
#endif

workspaceHandoffReadyCheckTask = Task { [generation, newSelectedId] in
for delay in [0, 20_000_000, 40_000_000, 60_000_000] {
if delay > 0 {
do {
try await Task.sleep(nanoseconds: UInt64(delay))
} catch {
return
}
}
let completed = await MainActor.run { () -> Bool in
guard workspaceHandoffGeneration == generation else { return false }
guard retiringWorkspaceId != nil else { return false }
guard canCompleteWorkspaceHandoffImmediately(for: newSelectedId) else { return false }
#if DEBUG
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
dlog(
"ws.handoff.fastReady id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newSelectedId))"
)
} else {
dlog("ws.handoff.fastReady id=none selected=\(debugShortWorkspaceId(newSelectedId))")
}
#endif
completeWorkspaceHandoff(reason: "ready")

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 Badge Avoid completing handoff before pending unfocus is set

This ready-path completion can fire on the first poll when switching to an already-loaded workspace, but TabManager only creates pendingWorkspaceUnfocusTarget in the async selectedTabId.didSet side-effects (DispatchQueue.main.async → focusSelectedTabPanel). If this runs first, completePendingWorkspaceUnfocus no-ops, fallback is canceled, and later focus notifications skip completion because retiringWorkspaceId is already cleared, leaving the previous workspace unfocus deferred until a later switch. The race is most visible during rapid workspace switches where the target workspace is already warm.

Useful? React with 👍 / 👎.

return true
}
if completed { return }
}
}
Comment thread
lawrencecchen marked this conversation as resolved.

workspaceHandoffFallbackTask = Task { [generation] in
do {
try await Task.sleep(nanoseconds: 150_000_000)
Expand All @@ -2922,9 +2956,20 @@ struct ContentView: View {
completeWorkspaceHandoff(reason: reason)
}

private func canCompleteWorkspaceHandoffImmediately(for workspaceId: UUID) -> Bool {
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return true }
if let focusedPanelId = workspace.focusedPanelId,
workspace.browserPanel(for: focusedPanelId) != nil {
return true
}
return workspace.hasLoadedTerminalSurface()
}

private func completeWorkspaceHandoff(reason: String) {
workspaceHandoffFallbackTask?.cancel()
workspaceHandoffFallbackTask = nil
workspaceHandoffReadyCheckTask?.cancel()
workspaceHandoffReadyCheckTask = nil
let retiring = retiringWorkspaceId

// Hide portal-hosted views for the retiring workspace BEFORE clearing
Expand Down Expand Up @@ -7221,6 +7266,9 @@ struct VerticalTabsSidebar: View {
}

var body: some View {
let workspaceCount = tabManager.tabs.count
let canCloseWorkspace = workspaceCount > 1

VStack(spacing: 0) {
GeometryReader { proxy in
ScrollView {
Expand All @@ -7237,7 +7285,12 @@ struct VerticalTabsSidebar: View {
tab: tab,
index: index,
isActive: tabManager.selectedTabId == tab.id,
tabCount: tabManager.tabs.count,
workspaceShortcutDigit: WorkspaceShortcutMapper.commandDigitForWorkspace(
at: index,
workspaceCount: workspaceCount
),
canCloseWorkspace: canCloseWorkspace,
accessibilityWorkspaceCount: workspaceCount,
unreadCount: notificationStore.unreadCount(forTabId: tab.id),
latestNotificationText: {
guard showsSidebarNotificationMessage,
Expand Down Expand Up @@ -9506,7 +9559,9 @@ private struct TabItemView: View, Equatable {
lhs.tab === rhs.tab &&
lhs.index == rhs.index &&
lhs.isActive == rhs.isActive &&
lhs.tabCount == rhs.tabCount &&
lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit &&
lhs.canCloseWorkspace == rhs.canCloseWorkspace &&
lhs.accessibilityWorkspaceCount == rhs.accessibilityWorkspaceCount &&
lhs.unreadCount == rhs.unreadCount &&
lhs.latestNotificationText == rhs.latestNotificationText &&
lhs.rowSpacing == rhs.rowSpacing &&
Comment thread
lawrencecchen marked this conversation as resolved.
Expand All @@ -9522,7 +9577,9 @@ private struct TabItemView: View, Equatable {
@ObservedObject var tab: Tab
let index: Int
let isActive: Bool
let tabCount: Int
let workspaceShortcutDigit: Int?
let canCloseWorkspace: Bool
let accessibilityWorkspaceCount: Int
let unreadCount: Int
let latestNotificationText: String?
let rowSpacing: CGFloat
Expand Down Expand Up @@ -9625,12 +9682,8 @@ private struct TabItemView: View, Equatable {
usesInvertedActiveForeground ? 1.0 : 0.9
}

private var workspaceShortcutDigit: Int? {
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabCount)
}

private var showCloseButton: Bool {
isHovering && tabCount > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
isHovering && canCloseWorkspace && !(showsModifierShortcutHints || alwaysShowShortcutHints)
}

private var workspaceShortcutLabel: String? {
Expand Down Expand Up @@ -10267,7 +10320,7 @@ private struct TabItemView: View, Equatable {
}

private var accessibilityTitle: String {
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(tabCount)")
String(localized: "accessibility.workspacePosition", defaultValue: "\(tab.title), workspace \(index + 1) of \(accessibilityWorkspaceCount)")
}

private func moveBy(_ delta: Int) {
Expand Down
92 changes: 78 additions & 14 deletions Sources/TabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -655,6 +655,33 @@ class TabManager: ObservableObject {
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
@Published var selectedTabId: UUID? {
willSet {
#if DEBUG
guard newValue != selectedTabId else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugPreparedWorkspaceSwitchTarget = nil
return
}

if debugPreparedWorkspaceSwitchTarget == newValue {
debugPreparedWorkspaceSwitchTarget = nil
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
} else {
let trigger = (debugPendingWorkspaceSwitchTarget == newValue
? debugPendingWorkspaceSwitchTrigger
: nil) ?? "direct"
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugBeginWorkspaceSwitch(
trigger: trigger,
from: selectedTabId,
to: newValue
)
}
#endif
}
didSet {
guard selectedTabId != oldValue else { return }
sentryBreadcrumb("workspace.switch", data: [
Expand Down Expand Up @@ -731,6 +758,9 @@ class TabManager: ObservableObject {
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
private var debugPendingWorkspaceSwitchTrigger: String?
private var debugPendingWorkspaceSwitchTarget: UUID?
private var debugPreparedWorkspaceSwitchTarget: UUID?
#endif

#if DEBUG
Expand Down Expand Up @@ -930,6 +960,9 @@ class TabManager: ObservableObject {
newWorkspace.requestBackgroundTerminalSurfaceStartIfNeeded()
}
if select {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("create", to: newWorkspace.id)
#endif
selectedTabId = newWorkspace.id
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
Expand Down Expand Up @@ -1507,6 +1540,9 @@ class TabManager: ObservableObject {
}

func selectWorkspace(_ workspace: Workspace) {
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("select", to: workspace.id)
#endif
selectedTabId = workspace.id
}

Expand Down Expand Up @@ -2093,6 +2129,9 @@ class TabManager: ObservableObject {
// Keep selected-surface intent stable across selectedTabId didSet async restore.
lastFocusedPanelByTab[tabId] = surfaceId
}
#if DEBUG
debugPrimeWorkspaceSwitchTrigger("focus", to: tabId)
#endif
selectedTabId = tabId
NotificationCenter.default.post(
name: .ghosttyDidFocusTab,
Expand Down Expand Up @@ -2160,13 +2199,7 @@ class TabManager: ObservableObject {
let nextIndex = (currentIndex + 1) % tabs.count
#if DEBUG
let nextId = tabs[nextIndex].id
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=next from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(nextId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
debugPrepareWorkspaceSwitch("next", from: currentId, to: nextId)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = tabs[nextIndex].id
Comment thread
lawrencecchen marked this conversation as resolved.
Expand All @@ -2178,13 +2211,7 @@ class TabManager: ObservableObject {
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
#if DEBUG
let prevId = tabs[prevIndex].id
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) dir=prev from=\(Self.debugShortWorkspaceId(currentId)) " +
"to=\(Self.debugShortWorkspaceId(prevId)) hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
debugPrepareWorkspaceSwitch("prev", from: currentId, to: prevId)
#endif
activateWorkspaceCycleHotWindow()
selectedTabId = tabs[prevIndex].id
Expand Down Expand Up @@ -2257,6 +2284,40 @@ class TabManager: ObservableObject {
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
}

private func debugPrimeWorkspaceSwitchTrigger(_ trigger: String, to target: UUID?) {
guard selectedTabId != target else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
return
}
debugPendingWorkspaceSwitchTrigger = trigger
debugPendingWorkspaceSwitchTarget = target
}

private func debugPrepareWorkspaceSwitch(_ trigger: String, from: UUID?, to: UUID?) {
guard from != to else {
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugPreparedWorkspaceSwitchTarget = nil
return
}
debugPendingWorkspaceSwitchTrigger = nil
debugPendingWorkspaceSwitchTarget = nil
debugBeginWorkspaceSwitch(trigger: trigger, from: from, to: to)
debugPreparedWorkspaceSwitchTarget = to
}

private func debugBeginWorkspaceSwitch(trigger: String, from: UUID?, to: UUID?) {
debugWorkspaceSwitchCounter &+= 1
debugWorkspaceSwitchId = debugWorkspaceSwitchCounter
debugWorkspaceSwitchStartTime = CACurrentMediaTime()
dlog(
"ws.switch.begin id=\(debugWorkspaceSwitchId) trigger=\(trigger) " +
"from=\(Self.debugShortWorkspaceId(from)) to=\(Self.debugShortWorkspaceId(to)) " +
"hot=\(isWorkspaceCycleHot ? 1 : 0) tabs=\(tabs.count)"
)
}

private static func debugShortWorkspaceId(_ id: UUID?) -> String {
guard let id else { return "nil" }
return String(id.uuidString.prefix(5))
Expand All @@ -2269,6 +2330,9 @@ class TabManager: ObservableObject {

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

Expand Down
Loading
Loading