Stabilize rapid workspace switching handoff
This commit is contained in:
parent
a723bbaa6a
commit
7aa80b9cdc
7 changed files with 664 additions and 30 deletions
|
|
@ -426,6 +426,35 @@ final class WorkspaceReorderTests: XCTestCase {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class TabManagerPendingUnfocusPolicyTests: XCTestCase {
|
||||||
|
func testDoesNotUnfocusWhenPendingTabIsCurrentlySelected() {
|
||||||
|
let tabId = UUID()
|
||||||
|
|
||||||
|
XCTAssertFalse(
|
||||||
|
TabManager.shouldUnfocusPendingWorkspace(
|
||||||
|
pendingTabId: tabId,
|
||||||
|
selectedTabId: tabId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnfocusesWhenPendingTabIsNotSelected() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
TabManager.shouldUnfocusPendingWorkspace(
|
||||||
|
pendingTabId: UUID(),
|
||||||
|
selectedTabId: UUID()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
TabManager.shouldUnfocusPendingWorkspace(
|
||||||
|
pendingTabId: UUID(),
|
||||||
|
selectedTabId: nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class TabManagerSurfaceCreationTests: XCTestCase {
|
final class TabManagerSurfaceCreationTests: XCTestCase {
|
||||||
func testNewSurfaceFocusesCreatedSurface() {
|
func testNewSurfaceFocusesCreatedSurface() {
|
||||||
|
|
@ -1692,12 +1721,15 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
||||||
func testDefaultPolicyMountsOnlySelectedWorkspace() {
|
func testDefaultPolicyMountsOnlySelectedWorkspace() {
|
||||||
let a = UUID()
|
let a = UUID()
|
||||||
let b = UUID()
|
let b = UUID()
|
||||||
let existing: Set<UUID> = [a, b]
|
let orderedTabIds: [UUID] = [a, b]
|
||||||
|
|
||||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: [a],
|
current: [a],
|
||||||
selected: b,
|
selected: b,
|
||||||
existing: existing
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: false,
|
||||||
|
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(next, [b])
|
XCTAssertEqual(next, [b])
|
||||||
|
|
@ -1707,12 +1739,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
||||||
let a = UUID()
|
let a = UUID()
|
||||||
let b = UUID()
|
let b = UUID()
|
||||||
let c = UUID()
|
let c = UUID()
|
||||||
let existing: Set<UUID> = [a, b, c]
|
let orderedTabIds: [UUID] = [a, b, c]
|
||||||
|
|
||||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: [a, b, c],
|
current: [a, b, c],
|
||||||
selected: c,
|
selected: c,
|
||||||
existing: existing,
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: false,
|
||||||
maxMounted: 2
|
maxMounted: 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1726,7 +1760,9 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
||||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: [b, a],
|
current: [b, a],
|
||||||
selected: nil,
|
selected: nil,
|
||||||
existing: [a],
|
pinnedIds: [],
|
||||||
|
orderedTabIds: [a],
|
||||||
|
isCycleHot: false,
|
||||||
maxMounted: 2
|
maxMounted: 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1736,12 +1772,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
||||||
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
|
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
|
||||||
let a = UUID()
|
let a = UUID()
|
||||||
let b = UUID()
|
let b = UUID()
|
||||||
let existing: Set<UUID> = [a, b]
|
let orderedTabIds: [UUID] = [a, b]
|
||||||
|
|
||||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: [a],
|
current: [a],
|
||||||
selected: b,
|
selected: b,
|
||||||
existing: existing,
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: false,
|
||||||
maxMounted: 2
|
maxMounted: 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1751,17 +1789,74 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
||||||
func testMaxMountedIsClampedToAtLeastOne() {
|
func testMaxMountedIsClampedToAtLeastOne() {
|
||||||
let a = UUID()
|
let a = UUID()
|
||||||
let b = UUID()
|
let b = UUID()
|
||||||
let existing: Set<UUID> = [a, b]
|
let orderedTabIds: [UUID] = [a, b]
|
||||||
|
|
||||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: [a, b],
|
current: [a, b],
|
||||||
selected: nil,
|
selected: nil,
|
||||||
existing: existing,
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: false,
|
||||||
maxMounted: 0
|
maxMounted: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(next, [a])
|
XCTAssertEqual(next, [a])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCycleHotModeWarmsSelectedAndImmediateNeighbors() {
|
||||||
|
let a = UUID()
|
||||||
|
let b = UUID()
|
||||||
|
let c = UUID()
|
||||||
|
let d = UUID()
|
||||||
|
let orderedTabIds: [UUID] = [a, b, c, d]
|
||||||
|
|
||||||
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
|
current: [a],
|
||||||
|
selected: c,
|
||||||
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: true,
|
||||||
|
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(next, [c, b, d])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCycleHotModeRespectsMaxMountedLimit() {
|
||||||
|
let a = UUID()
|
||||||
|
let b = UUID()
|
||||||
|
let c = UUID()
|
||||||
|
let orderedTabIds: [UUID] = [a, b, c]
|
||||||
|
|
||||||
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
|
current: [a, b, c],
|
||||||
|
selected: b,
|
||||||
|
pinnedIds: [],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: true,
|
||||||
|
maxMounted: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(next, [b, a])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPinnedIdsAreRetainedAcrossReconcile() {
|
||||||
|
let a = UUID()
|
||||||
|
let b = UUID()
|
||||||
|
let c = UUID()
|
||||||
|
let orderedTabIds: [UUID] = [a, b, c]
|
||||||
|
|
||||||
|
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
|
current: [a],
|
||||||
|
selected: c,
|
||||||
|
pinnedIds: [a],
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: false,
|
||||||
|
maxMounted: 2
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(next, [c, a])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
|
||||||
|
|
@ -1751,11 +1751,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||||
|
|
||||||
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
|
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
|
||||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .nextSidebarTab)) {
|
||||||
|
#if DEBUG
|
||||||
|
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||||
|
dlog(
|
||||||
|
"ws.shortcut dir=next repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
tabManager?.selectNextTab()
|
tabManager?.selectNextTab()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) {
|
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .prevSidebarTab)) {
|
||||||
|
#if DEBUG
|
||||||
|
let selected = tabManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil"
|
||||||
|
dlog(
|
||||||
|
"ws.shortcut dir=prev repeat=\(event.isARepeat ? 1 : 0) keyCode=\(event.keyCode) selected=\(selected)"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
tabManager?.selectPreviousTab()
|
tabManager?.selectPreviousTab()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -274,13 +274,17 @@ var fileDropOverlayKey: UInt8 = 0
|
||||||
enum WorkspaceMountPolicy {
|
enum WorkspaceMountPolicy {
|
||||||
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
||||||
static let maxMountedWorkspaces = 1
|
static let maxMountedWorkspaces = 1
|
||||||
|
static let maxMountedWorkspacesDuringCycle = 3
|
||||||
|
|
||||||
static func nextMountedWorkspaceIds(
|
static func nextMountedWorkspaceIds(
|
||||||
current: [UUID],
|
current: [UUID],
|
||||||
selected: UUID?,
|
selected: UUID?,
|
||||||
existing: Set<UUID>,
|
pinnedIds: Set<UUID>,
|
||||||
maxMounted: Int = maxMountedWorkspaces
|
orderedTabIds: [UUID],
|
||||||
|
isCycleHot: Bool,
|
||||||
|
maxMounted: Int
|
||||||
) -> [UUID] {
|
) -> [UUID] {
|
||||||
|
let existing = Set(orderedTabIds)
|
||||||
let clampedMax = max(1, maxMounted)
|
let clampedMax = max(1, maxMounted)
|
||||||
var ordered = current.filter { existing.contains($0) }
|
var ordered = current.filter { existing.contains($0) }
|
||||||
|
|
||||||
|
|
@ -289,12 +293,41 @@ enum WorkspaceMountPolicy {
|
||||||
ordered.insert(selected, at: 0)
|
ordered.insert(selected, at: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let prioritizedPinnedIds = pinnedIds.filter { existing.contains($0) && $0 != selected }
|
||||||
|
for pinnedId in prioritizedPinnedIds.reversed() {
|
||||||
|
ordered.removeAll { $0 == pinnedId }
|
||||||
|
ordered.insert(pinnedId, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCycleHot, let selected {
|
||||||
|
let warmIds = cycleWarmIds(selected: selected, orderedTabIds: orderedTabIds)
|
||||||
|
for id in warmIds.reversed() {
|
||||||
|
ordered.removeAll { $0 == id }
|
||||||
|
ordered.insert(id, at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ordered.count > clampedMax {
|
if ordered.count > clampedMax {
|
||||||
ordered.removeSubrange(clampedMax...)
|
ordered.removeSubrange(clampedMax...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ordered
|
return ordered
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
|
||||||
|
guard let selectedIndex = orderedTabIds.firstIndex(of: selected) else {
|
||||||
|
return [selected]
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids: [UUID] = [selected]
|
||||||
|
if selectedIndex > 0 {
|
||||||
|
ids.append(orderedTabIds[selectedIndex - 1])
|
||||||
|
}
|
||||||
|
if selectedIndex + 1 < orderedTabIds.count {
|
||||||
|
ids.append(orderedTabIds[selectedIndex + 1])
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
|
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
|
||||||
|
|
@ -343,6 +376,10 @@ struct ContentView: View {
|
||||||
@State private var isFullScreen: Bool = false
|
@State private var isFullScreen: Bool = false
|
||||||
@State private var observedWindow: NSWindow?
|
@State private var observedWindow: NSWindow?
|
||||||
@StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel()
|
@StateObject private var fullscreenControlsViewModel = TitlebarControlsViewModel()
|
||||||
|
@State private var previousSelectedWorkspaceId: UUID?
|
||||||
|
@State private var retiringWorkspaceId: UUID?
|
||||||
|
@State private var workspaceHandoffGeneration: UInt64 = 0
|
||||||
|
@State private var workspaceHandoffFallbackTask: Task<Void, Never>?
|
||||||
|
|
||||||
private var sidebarView: some View {
|
private var sidebarView: some View {
|
||||||
VerticalTabsSidebar(
|
VerticalTabsSidebar(
|
||||||
|
|
@ -418,14 +455,24 @@ struct ContentView: View {
|
||||||
private var terminalContent: some View {
|
private var terminalContent: some View {
|
||||||
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
|
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
|
||||||
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
|
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
|
||||||
|
let selectedWorkspaceId = tabManager.selectedTabId
|
||||||
|
let retiringWorkspaceId = self.retiringWorkspaceId
|
||||||
|
|
||||||
return ZStack {
|
return ZStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(mountedWorkspaces) { tab in
|
ForEach(mountedWorkspaces) { tab in
|
||||||
let isActive = tabManager.selectedTabId == tab.id
|
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||||
WorkspaceContentView(workspace: tab, isTabActive: isActive)
|
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||||
.opacity(isActive ? 1 : 0)
|
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||||||
.allowsHitTesting(isActive)
|
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||||
|
WorkspaceContentView(
|
||||||
|
workspace: tab,
|
||||||
|
isWorkspaceVisible: isVisible,
|
||||||
|
isWorkspaceInputActive: isInputActive
|
||||||
|
)
|
||||||
|
.opacity(isVisible ? 1 : 0)
|
||||||
|
.allowsHitTesting(isSelectedWorkspace)
|
||||||
|
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
||||||
|
|
@ -590,6 +637,7 @@ struct ContentView: View {
|
||||||
.onAppear {
|
.onAppear {
|
||||||
tabManager.applyWindowBackgroundForSelectedTab()
|
tabManager.applyWindowBackgroundForSelectedTab()
|
||||||
reconcileMountedWorkspaceIds()
|
reconcileMountedWorkspaceIds()
|
||||||
|
previousSelectedWorkspaceId = tabManager.selectedTabId
|
||||||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||||||
selectedTabIds = [selectedId]
|
selectedTabIds = [selectedId]
|
||||||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||||||
|
|
@ -597,7 +645,18 @@ struct ContentView: View {
|
||||||
updateTitlebarText()
|
updateTitlebarText()
|
||||||
}
|
}
|
||||||
.onChange(of: tabManager.selectedTabId) { newValue in
|
.onChange(of: tabManager.selectedTabId) { newValue in
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.view.selectedChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) selected=\(debugShortWorkspaceId(newValue))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog("ws.view.selectedChange id=none selected=\(debugShortWorkspaceId(newValue))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
tabManager.applyWindowBackgroundForSelectedTab()
|
tabManager.applyWindowBackgroundForSelectedTab()
|
||||||
|
startWorkspaceHandoffIfNeeded(newSelectedId: newValue)
|
||||||
reconcileMountedWorkspaceIds(selectedId: newValue)
|
reconcileMountedWorkspaceIds(selectedId: newValue)
|
||||||
guard let newValue else { return }
|
guard let newValue else { return }
|
||||||
if selectedTabIds.count <= 1 {
|
if selectedTabIds.count <= 1 {
|
||||||
|
|
@ -606,6 +665,22 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
updateTitlebarText()
|
updateTitlebarText()
|
||||||
}
|
}
|
||||||
|
.onChange(of: tabManager.isWorkspaceCycleHot) { _ in
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.view.hotChange id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog("ws.view.hotChange id=none hot=\(tabManager.isWorkspaceCycleHot ? 1 : 0)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
reconcileMountedWorkspaceIds()
|
||||||
|
}
|
||||||
|
.onChange(of: retiringWorkspaceId) { _ in
|
||||||
|
reconcileMountedWorkspaceIds()
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
|
||||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||||
tabId == tabManager.selectedTabId else { return }
|
tabId == tabManager.selectedTabId else { return }
|
||||||
|
|
@ -618,10 +693,24 @@ struct ContentView: View {
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
|
||||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||||
tabId == tabManager.selectedTabId else { return }
|
tabId == tabManager.selectedTabId else { return }
|
||||||
|
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
|
||||||
updateTitlebarText()
|
updateTitlebarText()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidBecomeFirstResponderSurface)) { notification in
|
||||||
|
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||||
|
tabId == tabManager.selectedTabId else { return }
|
||||||
|
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder")
|
||||||
|
}
|
||||||
.onReceive(tabManager.$tabs) { tabs in
|
.onReceive(tabManager.$tabs) { tabs in
|
||||||
let existingIds = Set(tabs.map { $0.id })
|
let existingIds = Set(tabs.map { $0.id })
|
||||||
|
if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) {
|
||||||
|
self.retiringWorkspaceId = nil
|
||||||
|
workspaceHandoffFallbackTask?.cancel()
|
||||||
|
workspaceHandoffFallbackTask = nil
|
||||||
|
}
|
||||||
|
if let previousSelectedWorkspaceId, !existingIds.contains(previousSelectedWorkspaceId) {
|
||||||
|
self.previousSelectedWorkspaceId = tabManager.selectedTabId
|
||||||
|
}
|
||||||
reconcileMountedWorkspaceIds(tabs: tabs)
|
reconcileMountedWorkspaceIds(tabs: tabs)
|
||||||
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
||||||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||||||
|
|
@ -728,13 +817,44 @@ struct ContentView: View {
|
||||||
|
|
||||||
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
|
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
|
||||||
let currentTabs = tabs ?? tabManager.tabs
|
let currentTabs = tabs ?? tabManager.tabs
|
||||||
let existing = Set(currentTabs.map { $0.id })
|
let orderedTabIds = currentTabs.map { $0.id }
|
||||||
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
||||||
|
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
|
||||||
|
let isCycleHot = tabManager.isWorkspaceCycleHot
|
||||||
|
let baseMaxMounted = isCycleHot
|
||||||
|
? WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||||
|
: WorkspaceMountPolicy.maxMountedWorkspaces
|
||||||
|
let selectedCount = effectiveSelectedId == nil ? 0 : 1
|
||||||
|
let maxMounted = max(baseMaxMounted, selectedCount + pinnedIds.count)
|
||||||
|
let previousMountedIds = mountedWorkspaceIds
|
||||||
mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
mountedWorkspaceIds = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||||
current: mountedWorkspaceIds,
|
current: mountedWorkspaceIds,
|
||||||
selected: effectiveSelectedId,
|
selected: effectiveSelectedId,
|
||||||
existing: existing
|
pinnedIds: pinnedIds,
|
||||||
|
orderedTabIds: orderedTabIds,
|
||||||
|
isCycleHot: isCycleHot,
|
||||||
|
maxMounted: maxMounted
|
||||||
)
|
)
|
||||||
|
#if DEBUG
|
||||||
|
if mountedWorkspaceIds != previousMountedIds {
|
||||||
|
let added = mountedWorkspaceIds.filter { !previousMountedIds.contains($0) }
|
||||||
|
let removed = previousMountedIds.filter { !mountedWorkspaceIds.contains($0) }
|
||||||
|
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.mount.reconcile id=\(snapshot.id) dt=\(debugMsText(dtMs)) hot=\(isCycleHot ? 1 : 0) " +
|
||||||
|
"selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
|
||||||
|
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds)) " +
|
||||||
|
"added=\(debugShortWorkspaceIds(added)) removed=\(debugShortWorkspaceIds(removed))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog(
|
||||||
|
"ws.mount.reconcile id=none hot=\(isCycleHot ? 1 : 0) selected=\(debugShortWorkspaceId(effectiveSelectedId)) " +
|
||||||
|
"mounted=\(debugShortWorkspaceIds(mountedWorkspaceIds))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addTab() {
|
private func addTab() {
|
||||||
|
|
@ -758,6 +878,90 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startWorkspaceHandoffIfNeeded(newSelectedId: UUID?) {
|
||||||
|
let oldSelectedId = previousSelectedWorkspaceId
|
||||||
|
previousSelectedWorkspaceId = newSelectedId
|
||||||
|
|
||||||
|
guard let oldSelectedId, let newSelectedId, oldSelectedId != newSelectedId else {
|
||||||
|
tabManager.completePendingWorkspaceUnfocus(reason: "no_handoff")
|
||||||
|
retiringWorkspaceId = nil
|
||||||
|
workspaceHandoffFallbackTask?.cancel()
|
||||||
|
workspaceHandoffFallbackTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceHandoffGeneration &+= 1
|
||||||
|
let generation = workspaceHandoffGeneration
|
||||||
|
retiringWorkspaceId = oldSelectedId
|
||||||
|
workspaceHandoffFallbackTask?.cancel()
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.handoff.start id=\(snapshot.id) dt=\(debugMsText(dtMs)) old=\(debugShortWorkspaceId(oldSelectedId)) " +
|
||||||
|
"new=\(debugShortWorkspaceId(newSelectedId))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog(
|
||||||
|
"ws.handoff.start id=none old=\(debugShortWorkspaceId(oldSelectedId)) new=\(debugShortWorkspaceId(newSelectedId))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
workspaceHandoffFallbackTask = Task { [generation] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 150_000_000)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
guard workspaceHandoffGeneration == generation else { return }
|
||||||
|
completeWorkspaceHandoff(reason: "timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeWorkspaceHandoffIfNeeded(focusedTabId: UUID, reason: String) {
|
||||||
|
guard focusedTabId == tabManager.selectedTabId else { return }
|
||||||
|
guard retiringWorkspaceId != nil else { return }
|
||||||
|
completeWorkspaceHandoff(reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeWorkspaceHandoff(reason: String) {
|
||||||
|
workspaceHandoffFallbackTask?.cancel()
|
||||||
|
workspaceHandoffFallbackTask = nil
|
||||||
|
let retiring = retiringWorkspaceId
|
||||||
|
retiringWorkspaceId = nil
|
||||||
|
tabManager.completePendingWorkspaceUnfocus(reason: reason)
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = tabManager.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.handoff.complete id=\(snapshot.id) dt=\(debugMsText(dtMs)) reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog("ws.handoff.complete id=none reason=\(reason) retiring=\(debugShortWorkspaceId(retiring))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func debugShortWorkspaceId(_ id: UUID?) -> String {
|
||||||
|
guard let id else { return "nil" }
|
||||||
|
return String(id.uuidString.prefix(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debugShortWorkspaceIds(_ ids: [UUID]) -> String {
|
||||||
|
if ids.isEmpty { return "[]" }
|
||||||
|
return "[" + ids.map { String($0.uuidString.prefix(5)) }.joined(separator: ",") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func debugMsText(_ ms: Double) -> String {
|
||||||
|
String(format: "%.2fms", ms)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
struct VerticalTabsSidebar: View {
|
struct VerticalTabsSidebar: View {
|
||||||
|
|
|
||||||
|
|
@ -1842,6 +1842,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
if let terminalSurface {
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: .ghosttyDidBecomeFirstResponderSurface,
|
||||||
|
object: nil,
|
||||||
|
userInfo: [
|
||||||
|
GhosttyNotificationKey.tabId: terminalSurface.tabId,
|
||||||
|
GhosttyNotificationKey.surfaceId: terminalSurface.id,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
ghostty_surface_set_focus(surface, true)
|
ghostty_surface_set_focus(surface, true)
|
||||||
|
|
||||||
// Ghostty only restarts its vsync display link on display-id changes while focused.
|
// Ghostty only restarts its vsync display link on display-id changes while focused.
|
||||||
|
|
@ -2734,6 +2744,10 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
}
|
}
|
||||||
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
|
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugSurfaceId: UUID? {
|
||||||
|
surfaceView.terminalSurface?.id
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(surfaceView: GhosttyNSView) {
|
init(surfaceView: GhosttyNSView) {
|
||||||
|
|
@ -2969,8 +2983,17 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setVisibleInUI(_ visible: Bool) {
|
func setVisibleInUI(_ visible: Bool) {
|
||||||
|
let wasVisible = surfaceView.isVisibleInUI
|
||||||
surfaceView.setVisibleInUI(visible)
|
surfaceView.setVisibleInUI(visible)
|
||||||
isHidden = !visible
|
isHidden = !visible
|
||||||
|
#if DEBUG
|
||||||
|
if wasVisible != visible {
|
||||||
|
debugLogWorkspaceSwitchTiming(
|
||||||
|
event: "ws.term.visible",
|
||||||
|
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if !visible {
|
if !visible {
|
||||||
// If we were focused, yield first responder.
|
// If we were focused, yield first responder.
|
||||||
if let window, let fr = window.firstResponder as? NSView,
|
if let window, let fr = window.firstResponder as? NSView,
|
||||||
|
|
@ -2983,7 +3006,16 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setActive(_ active: Bool) {
|
func setActive(_ active: Bool) {
|
||||||
|
let wasActive = isActive
|
||||||
isActive = active
|
isActive = active
|
||||||
|
#if DEBUG
|
||||||
|
if wasActive != active {
|
||||||
|
debugLogWorkspaceSwitchTiming(
|
||||||
|
event: "ws.term.active",
|
||||||
|
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
if active {
|
if active {
|
||||||
applyFirstResponderIfNeeded()
|
applyFirstResponderIfNeeded()
|
||||||
} else if let window,
|
} else if let window,
|
||||||
|
|
@ -2993,6 +3025,17 @@ final class GhosttySurfaceScrollView: NSView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
private func debugLogWorkspaceSwitchTiming(event: String, suffix: String) {
|
||||||
|
guard let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() else {
|
||||||
|
dlog("\(event) id=none \(suffix)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
dlog("focus.moveFocus to=\(self.surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil")")
|
||||||
|
|
@ -3686,9 +3729,27 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {
|
func updateNSView(_ nsView: NSView, context: Context) {
|
||||||
let hostedView = terminalSurface.hostedView
|
let hostedView = terminalSurface.hostedView
|
||||||
let coordinator = context.coordinator
|
let coordinator = context.coordinator
|
||||||
|
let previousDesiredIsActive = coordinator.desiredIsActive
|
||||||
|
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
||||||
coordinator.desiredIsActive = isActive
|
coordinator.desiredIsActive = isActive
|
||||||
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||||
coordinator.hostedView = hostedView
|
coordinator.hostedView = hostedView
|
||||||
|
#if DEBUG
|
||||||
|
if previousDesiredIsActive != isActive || previousDesiredIsVisibleInUI != isVisibleInUI {
|
||||||
|
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.swiftui.update id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
|
||||||
|
"surface=\(terminalSurface.id.uuidString.prefix(5)) visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog(
|
||||||
|
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||||
|
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
||||||
hostedView.attachSurface(terminalSurface)
|
hostedView.attachSurface(terminalSurface)
|
||||||
|
|
@ -3744,6 +3805,19 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
||||||
coordinator.attachGeneration += 1
|
coordinator.attachGeneration += 1
|
||||||
coordinator.desiredIsActive = false
|
coordinator.desiredIsActive = false
|
||||||
coordinator.desiredIsVisibleInUI = false
|
coordinator.desiredIsVisibleInUI = false
|
||||||
|
#if DEBUG
|
||||||
|
if let hostedView = coordinator.hostedView {
|
||||||
|
if let snapshot = AppDelegate.shared?.tabManager?.debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.swiftui.dismantle id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) " +
|
||||||
|
"surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog("ws.swiftui.dismantle id=none surface=\(hostedView.debugSurfaceId?.uuidString.prefix(5) ?? "nil")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
if let host = nsView as? HostContainerView {
|
if let host = nsView as? HostContainerView {
|
||||||
host.onDidMoveToWindow = nil
|
host.onDidMoveToWindow = nil
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
||||||
@MainActor
|
@MainActor
|
||||||
class TabManager: ObservableObject {
|
class TabManager: ObservableObject {
|
||||||
@Published var tabs: [Workspace] = []
|
@Published var tabs: [Workspace] = []
|
||||||
|
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
||||||
@Published var selectedTabId: UUID? {
|
@Published var selectedTabId: UUID? {
|
||||||
didSet {
|
didSet {
|
||||||
guard selectedTabId != oldValue else { return }
|
guard selectedTabId != oldValue else { return }
|
||||||
|
|
@ -232,12 +233,34 @@ class TabManager: ObservableObject {
|
||||||
if !isNavigatingHistory, let selectedTabId {
|
if !isNavigatingHistory, let selectedTabId {
|
||||||
recordTabInHistory(selectedTabId)
|
recordTabInHistory(selectedTabId)
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
let switchId = debugWorkspaceSwitchId
|
||||||
|
let switchDtMs = debugWorkspaceSwitchStartTime > 0
|
||||||
|
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
|
||||||
|
: 0
|
||||||
|
dlog(
|
||||||
|
"ws.select.didSet id=\(switchId) from=\(Self.debugShortWorkspaceId(previousTabId)) " +
|
||||||
|
"to=\(Self.debugShortWorkspaceId(selectedTabId)) dt=\(Self.debugMsText(switchDtMs))"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
selectionSideEffectsGeneration &+= 1
|
||||||
|
let generation = selectionSideEffectsGeneration
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.focusSelectedTabPanel(previousTabId: previousTabId)
|
guard let self, self.selectionSideEffectsGeneration == generation else { return }
|
||||||
self?.updateWindowTitleForSelectedTab()
|
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||||
if let selectedTabId = self?.selectedTabId {
|
self.updateWindowTitleForSelectedTab()
|
||||||
self?.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
if let selectedTabId = self.selectedTabId {
|
||||||
|
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
||||||
}
|
}
|
||||||
|
#if DEBUG
|
||||||
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||||
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
||||||
|
: 0
|
||||||
|
dlog(
|
||||||
|
"ws.select.asyncDone id=\(self.debugWorkspaceSwitchId) dt=\(Self.debugMsText(dtMs)) " +
|
||||||
|
"selected=\(Self.debugShortWorkspaceId(self.selectedTabId))"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -250,6 +273,15 @@ class TabManager: ObservableObject {
|
||||||
private var historyIndex: Int = -1
|
private var historyIndex: Int = -1
|
||||||
private var isNavigatingHistory = false
|
private var isNavigatingHistory = false
|
||||||
private let maxHistorySize = 50
|
private let maxHistorySize = 50
|
||||||
|
private var selectionSideEffectsGeneration: UInt64 = 0
|
||||||
|
private var workspaceCycleGeneration: UInt64 = 0
|
||||||
|
private var workspaceCycleCooldownTask: Task<Void, Never>?
|
||||||
|
private var pendingWorkspaceUnfocusTarget: (tabId: UUID, panelId: UUID)?
|
||||||
|
#if DEBUG
|
||||||
|
private var debugWorkspaceSwitchCounter: UInt64 = 0
|
||||||
|
private var debugWorkspaceSwitchId: UInt64 = 0
|
||||||
|
private var debugWorkspaceSwitchStartTime: CFTimeInterval = 0
|
||||||
|
#endif
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private var didSetupSplitCloseRightUITest = false
|
private var didSetupSplitCloseRightUITest = false
|
||||||
|
|
@ -291,6 +323,10 @@ class TabManager: ObservableObject {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
workspaceCycleCooldownTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
var selectedWorkspace: Workspace? {
|
var selectedWorkspace: Workspace? {
|
||||||
guard let selectedTabId else { return nil }
|
guard let selectedTabId else { return nil }
|
||||||
return tabs.first(where: { $0.id == selectedTabId })
|
return tabs.first(where: { $0.id == selectedTabId })
|
||||||
|
|
@ -814,12 +850,15 @@ class TabManager: ObservableObject {
|
||||||
guard let panelId = tab.focusedPanelId,
|
guard let panelId = tab.focusedPanelId,
|
||||||
let panel = tab.panels[panelId] else { return }
|
let panel = tab.panels[panelId] else { return }
|
||||||
|
|
||||||
// Unfocus previous tab's panel
|
// Defer unfocusing the previous workspace's panel until ContentView confirms handoff
|
||||||
|
// completion (new workspace has focus or timeout fallback), to avoid a visible freeze gap.
|
||||||
if let previousTabId,
|
if let previousTabId,
|
||||||
let previousTab = tabs.first(where: { $0.id == previousTabId }),
|
let previousTab = tabs.first(where: { $0.id == previousTabId }),
|
||||||
let previousPanelId = previousTab.focusedPanelId,
|
let previousPanelId = previousTab.focusedPanelId,
|
||||||
let previousPanel = previousTab.panels[previousPanelId] {
|
previousTab.panels[previousPanelId] != nil {
|
||||||
previousPanel.unfocus()
|
replacePendingWorkspaceUnfocusTarget(
|
||||||
|
with: (tabId: previousTabId, panelId: previousPanelId)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.focus()
|
panel.focus()
|
||||||
|
|
@ -830,6 +869,94 @@ class TabManager: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completePendingWorkspaceUnfocus(reason: String) {
|
||||||
|
guard let pending = pendingWorkspaceUnfocusTarget else { return }
|
||||||
|
// If this tab became selected again before handoff completion, drop the stale
|
||||||
|
// pending entry so it cannot be flushed later and deactivate the selected workspace.
|
||||||
|
guard Self.shouldUnfocusPendingWorkspace(
|
||||||
|
pendingTabId: pending.tabId,
|
||||||
|
selectedTabId: selectedTabId
|
||||||
|
) else {
|
||||||
|
pendingWorkspaceUnfocusTarget = nil
|
||||||
|
#if DEBUG
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=selected_again"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingWorkspaceUnfocusTarget = nil
|
||||||
|
unfocusWorkspacePanel(tabId: pending.tabId, panelId: pending.panelId)
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.complete id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " +
|
||||||
|
"tab=\(Self.debugShortWorkspaceId(pending.tabId)) panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.complete id=none tab=\(Self.debugShortWorkspaceId(pending.tabId)) " +
|
||||||
|
"panel=\(String(pending.panelId.uuidString.prefix(5))) reason=\(reason)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replacePendingWorkspaceUnfocusTarget(with next: (tabId: UUID, panelId: UUID)) {
|
||||||
|
if let current = pendingWorkspaceUnfocusTarget,
|
||||||
|
current.tabId == next.tabId,
|
||||||
|
current.panelId == next.panelId {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let current = pendingWorkspaceUnfocusTarget {
|
||||||
|
// Never unfocus the currently selected workspace when replacing stale pending state.
|
||||||
|
if Self.shouldUnfocusPendingWorkspace(
|
||||||
|
pendingTabId: current.tabId,
|
||||||
|
selectedTabId: selectedTabId
|
||||||
|
) {
|
||||||
|
unfocusWorkspacePanel(tabId: current.tabId, panelId: current.panelId)
|
||||||
|
#if DEBUG
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.flush tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
#if DEBUG
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.drop tab=\(Self.debugShortWorkspaceId(current.tabId)) panel=\(String(current.panelId.uuidString.prefix(5))) reason=replaced_selected"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingWorkspaceUnfocusTarget = next
|
||||||
|
#if DEBUG
|
||||||
|
if let snapshot = debugCurrentWorkspaceSwitchSnapshot() {
|
||||||
|
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.defer id=\(snapshot.id) dt=\(Self.debugMsText(dtMs)) " +
|
||||||
|
"tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
dlog(
|
||||||
|
"ws.unfocus.defer id=none tab=\(Self.debugShortWorkspaceId(next.tabId)) panel=\(String(next.panelId.uuidString.prefix(5)))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func unfocusWorkspacePanel(tabId: UUID, panelId: UUID) {
|
||||||
|
guard let tab = tabs.first(where: { $0.id == tabId }),
|
||||||
|
let panel = tab.panels[panelId] else { return }
|
||||||
|
panel.unfocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldUnfocusPendingWorkspace(pendingTabId: UUID, selectedTabId: UUID?) -> Bool {
|
||||||
|
selectedTabId != pendingTabId
|
||||||
|
}
|
||||||
|
|
||||||
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
private func markFocusedPanelReadIfActive(tabId: UUID) {
|
||||||
let shouldSuppressFlash = suppressFocusFlash
|
let shouldSuppressFlash = suppressFocusFlash
|
||||||
suppressFocusFlash = false
|
suppressFocusFlash = false
|
||||||
|
|
@ -959,6 +1086,17 @@ class TabManager: ObservableObject {
|
||||||
guard let currentId = selectedTabId,
|
guard let currentId = selectedTabId,
|
||||||
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||||
let nextIndex = (currentIndex + 1) % tabs.count
|
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)"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
activateWorkspaceCycleHotWindow()
|
||||||
selectedTabId = tabs[nextIndex].id
|
selectedTabId = tabs[nextIndex].id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -966,9 +1104,97 @@ class TabManager: ObservableObject {
|
||||||
guard let currentId = selectedTabId,
|
guard let currentId = selectedTabId,
|
||||||
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||||
let prevIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
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)"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
activateWorkspaceCycleHotWindow()
|
||||||
selectedTabId = tabs[prevIndex].id
|
selectedTabId = tabs[prevIndex].id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func activateWorkspaceCycleHotWindow() {
|
||||||
|
workspaceCycleGeneration &+= 1
|
||||||
|
let generation = workspaceCycleGeneration
|
||||||
|
#if DEBUG
|
||||||
|
let switchId = debugWorkspaceSwitchId
|
||||||
|
let switchDtMs = debugWorkspaceSwitchStartTime > 0
|
||||||
|
? (CACurrentMediaTime() - debugWorkspaceSwitchStartTime) * 1000
|
||||||
|
: 0
|
||||||
|
#endif
|
||||||
|
if !isWorkspaceCycleHot {
|
||||||
|
isWorkspaceCycleHot = true
|
||||||
|
#if DEBUG
|
||||||
|
dlog(
|
||||||
|
"ws.hot.on id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
let hadPendingCooldown = workspaceCycleCooldownTask != nil
|
||||||
|
workspaceCycleCooldownTask?.cancel()
|
||||||
|
#if DEBUG
|
||||||
|
if hadPendingCooldown {
|
||||||
|
dlog(
|
||||||
|
"ws.hot.cancelPrev id=\(switchId) gen=\(generation) dt=\(Self.debugMsText(switchDtMs))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
workspaceCycleCooldownTask = Task { [weak self, generation] in
|
||||||
|
do {
|
||||||
|
try await Task.sleep(nanoseconds: 220_000_000)
|
||||||
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||||
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
||||||
|
: 0
|
||||||
|
dlog(
|
||||||
|
"ws.hot.cooldownCanceled id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.workspaceCycleGeneration == generation else { return }
|
||||||
|
#if DEBUG
|
||||||
|
let dtMs = self.debugWorkspaceSwitchStartTime > 0
|
||||||
|
? (CACurrentMediaTime() - self.debugWorkspaceSwitchStartTime) * 1000
|
||||||
|
: 0
|
||||||
|
dlog(
|
||||||
|
"ws.hot.off id=\(self.debugWorkspaceSwitchId) gen=\(generation) dt=\(Self.debugMsText(dtMs))"
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
self.isWorkspaceCycleHot = false
|
||||||
|
self.workspaceCycleCooldownTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
func debugCurrentWorkspaceSwitchSnapshot() -> (id: UInt64, startedAt: CFTimeInterval)? {
|
||||||
|
guard debugWorkspaceSwitchId > 0, debugWorkspaceSwitchStartTime > 0 else { return nil }
|
||||||
|
return (debugWorkspaceSwitchId, debugWorkspaceSwitchStartTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func debugShortWorkspaceId(_ id: UUID?) -> String {
|
||||||
|
guard let id else { return "nil" }
|
||||||
|
return String(id.uuidString.prefix(5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func debugMsText(_ ms: Double) -> String {
|
||||||
|
String(format: "%.2fms", ms)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func selectTab(at index: Int) {
|
func selectTab(at index: Int) {
|
||||||
guard index >= 0 && index < tabs.count else { return }
|
guard index >= 0 && index < tabs.count else { return }
|
||||||
selectedTabId = tabs[index].id
|
selectedTabId = tabs[index].id
|
||||||
|
|
@ -2222,6 +2448,7 @@ extension Notification.Name {
|
||||||
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||||
|
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
|
||||||
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
||||||
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
||||||
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ import Bonsplit
|
||||||
/// View that renders a Workspace's content using BonsplitView
|
/// View that renders a Workspace's content using BonsplitView
|
||||||
struct WorkspaceContentView: View {
|
struct WorkspaceContentView: View {
|
||||||
@ObservedObject var workspace: Workspace
|
@ObservedObject var workspace: Workspace
|
||||||
let isTabActive: Bool
|
let isWorkspaceVisible: Bool
|
||||||
|
let isWorkspaceInputActive: Bool
|
||||||
@State private var config = GhosttyConfig.load()
|
@State private var config = GhosttyConfig.load()
|
||||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||||
|
|
||||||
|
|
@ -16,7 +17,7 @@ struct WorkspaceContentView: View {
|
||||||
|
|
||||||
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
|
// Inactive workspaces are kept alive in a ZStack (for state preservation) but their
|
||||||
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
|
// AppKit-backed views can still intercept drags. Disable drop acceptance for them.
|
||||||
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
|
let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }()
|
||||||
|
|
||||||
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
|
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
|
||||||
// Finder file drops to the correct terminal panel.
|
// Finder file drops to the correct terminal panel.
|
||||||
|
|
@ -35,9 +36,9 @@ struct WorkspaceContentView: View {
|
||||||
// Content for each tab in bonsplit
|
// Content for each tab in bonsplit
|
||||||
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
|
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
|
||||||
if let panel = workspace.panel(for: tab.id) {
|
if let panel = workspace.panel(for: tab.id) {
|
||||||
let isFocused = isTabActive && workspace.focusedPanelId == panel.id
|
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
|
||||||
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
|
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
|
||||||
let isVisibleInUI = isTabActive && isSelectedInPane
|
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
|
||||||
PanelContentView(
|
PanelContentView(
|
||||||
panel: panel,
|
panel: panel,
|
||||||
isFocused: isFocused,
|
isFocused: isFocused,
|
||||||
|
|
@ -50,12 +51,12 @@ struct WorkspaceContentView: View {
|
||||||
// Keep bonsplit focus in sync with the AppKit first responder for the
|
// Keep bonsplit focus in sync with the AppKit first responder for the
|
||||||
// active workspace. This prevents divergence between the blue focused-tab
|
// active workspace. This prevents divergence between the blue focused-tab
|
||||||
// indicator and where keyboard input/flash-focus actually lands.
|
// indicator and where keyboard input/flash-focus actually lands.
|
||||||
guard isTabActive else { return }
|
guard isWorkspaceInputActive else { return }
|
||||||
guard workspace.panels[panel.id] != nil else { return }
|
guard workspace.panels[panel.id] != nil else { return }
|
||||||
workspace.focusPanel(panel.id)
|
workspace.focusPanel(panel.id)
|
||||||
},
|
},
|
||||||
onRequestPanelFocus: {
|
onRequestPanelFocus: {
|
||||||
guard isTabActive else { return }
|
guard isWorkspaceInputActive else { return }
|
||||||
guard workspace.panels[panel.id] != nil else { return }
|
guard workspace.panels[panel.id] != nil else { return }
|
||||||
workspace.focusPanel(panel.id)
|
workspace.focusPanel(panel.id)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
21
scripts/test-unit.sh
Executable file
21
scripts/test-unit.sh
Executable file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PROJECT="GhosttyTabs.xcodeproj"
|
||||||
|
SCHEME="cmux-unit"
|
||||||
|
CONFIGURATION="${CMUX_TEST_CONFIGURATION:-Debug}"
|
||||||
|
DESTINATION="${CMUX_TEST_DESTINATION:-platform=macOS}"
|
||||||
|
|
||||||
|
# Default to `test` when no explicit xcodebuild action is provided.
|
||||||
|
if [ "$#" -eq 0 ]; then
|
||||||
|
set -- test
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec xcodebuild \
|
||||||
|
-project "$PROJECT" \
|
||||||
|
-scheme "$SCHEME" \
|
||||||
|
-configuration "$CONFIGURATION" \
|
||||||
|
-destination "$DESTINATION" \
|
||||||
|
"$@"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue