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
|
||||
final class TabManagerSurfaceCreationTests: XCTestCase {
|
||||
func testNewSurfaceFocusesCreatedSurface() {
|
||||
|
|
@ -1692,12 +1721,15 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
func testDefaultPolicyMountsOnlySelectedWorkspace() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let existing: Set<UUID> = [a, b]
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
existing: existing
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b])
|
||||
|
|
@ -1707,12 +1739,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
let a = UUID()
|
||||
let b = UUID()
|
||||
let c = UUID()
|
||||
let existing: Set<UUID> = [a, b, c]
|
||||
let orderedTabIds: [UUID] = [a, b, c]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a, b, c],
|
||||
selected: c,
|
||||
existing: existing,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
|
|
@ -1726,7 +1760,9 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [b, a],
|
||||
selected: nil,
|
||||
existing: [a],
|
||||
pinnedIds: [],
|
||||
orderedTabIds: [a],
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
|
|
@ -1736,12 +1772,14 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let existing: Set<UUID> = [a, b]
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
existing: existing,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
|
|
@ -1751,17 +1789,74 @@ final class WorkspaceMountPolicyTests: XCTestCase {
|
|||
func testMaxMountedIsClampedToAtLeastOne() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let existing: Set<UUID> = [a, b]
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a, b],
|
||||
selected: nil,
|
||||
existing: existing,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 0
|
||||
)
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1751,11 +1751,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
|
||||
// Workspace navigation: Cmd+Ctrl+] / Cmd+Ctrl+[
|
||||
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()
|
||||
return true
|
||||
}
|
||||
|
||||
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()
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,13 +274,17 @@ var fileDropOverlayKey: UInt8 = 0
|
|||
enum WorkspaceMountPolicy {
|
||||
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
||||
static let maxMountedWorkspaces = 1
|
||||
static let maxMountedWorkspacesDuringCycle = 3
|
||||
|
||||
static func nextMountedWorkspaceIds(
|
||||
current: [UUID],
|
||||
selected: UUID?,
|
||||
existing: Set<UUID>,
|
||||
maxMounted: Int = maxMountedWorkspaces
|
||||
pinnedIds: Set<UUID>,
|
||||
orderedTabIds: [UUID],
|
||||
isCycleHot: Bool,
|
||||
maxMounted: Int
|
||||
) -> [UUID] {
|
||||
let existing = Set(orderedTabIds)
|
||||
let clampedMax = max(1, maxMounted)
|
||||
var ordered = current.filter { existing.contains($0) }
|
||||
|
||||
|
|
@ -289,12 +293,41 @@ enum WorkspaceMountPolicy {
|
|||
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 {
|
||||
ordered.removeSubrange(clampedMax...)
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -343,6 +376,10 @@ struct ContentView: View {
|
|||
@State private var isFullScreen: Bool = false
|
||||
@State private var observedWindow: NSWindow?
|
||||
@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 {
|
||||
VerticalTabsSidebar(
|
||||
|
|
@ -418,14 +455,24 @@ struct ContentView: View {
|
|||
private var terminalContent: some View {
|
||||
let mountedWorkspaceIdSet = Set(mountedWorkspaceIds)
|
||||
let mountedWorkspaces = tabManager.tabs.filter { mountedWorkspaceIdSet.contains($0.id) }
|
||||
let selectedWorkspaceId = tabManager.selectedTabId
|
||||
let retiringWorkspaceId = self.retiringWorkspaceId
|
||||
|
||||
return ZStack {
|
||||
ZStack {
|
||||
ForEach(mountedWorkspaces) { tab in
|
||||
let isActive = tabManager.selectedTabId == tab.id
|
||||
WorkspaceContentView(workspace: tab, isTabActive: isActive)
|
||||
.opacity(isActive ? 1 : 0)
|
||||
.allowsHitTesting(isActive)
|
||||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||||
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)
|
||||
|
|
@ -590,6 +637,7 @@ struct ContentView: View {
|
|||
.onAppear {
|
||||
tabManager.applyWindowBackgroundForSelectedTab()
|
||||
reconcileMountedWorkspaceIds()
|
||||
previousSelectedWorkspaceId = tabManager.selectedTabId
|
||||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||||
selectedTabIds = [selectedId]
|
||||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||||
|
|
@ -597,7 +645,18 @@ struct ContentView: View {
|
|||
updateTitlebarText()
|
||||
}
|
||||
.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()
|
||||
startWorkspaceHandoffIfNeeded(newSelectedId: newValue)
|
||||
reconcileMountedWorkspaceIds(selectedId: newValue)
|
||||
guard let newValue else { return }
|
||||
if selectedTabIds.count <= 1 {
|
||||
|
|
@ -606,6 +665,22 @@ struct ContentView: View {
|
|||
}
|
||||
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
|
||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||
tabId == tabManager.selectedTabId else { return }
|
||||
|
|
@ -618,10 +693,24 @@ struct ContentView: View {
|
|||
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
|
||||
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
|
||||
tabId == tabManager.selectedTabId else { return }
|
||||
completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "focus")
|
||||
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
|
||||
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)
|
||||
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
|
||||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||||
|
|
@ -728,13 +817,44 @@ struct ContentView: View {
|
|||
|
||||
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
|
||||
let currentTabs = tabs ?? tabManager.tabs
|
||||
let existing = Set(currentTabs.map { $0.id })
|
||||
let orderedTabIds = currentTabs.map { $0.id }
|
||||
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(
|
||||
current: mountedWorkspaceIds,
|
||||
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() {
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1842,6 +1842,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
)
|
||||
}
|
||||
#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 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)
|
||||
}
|
||||
|
||||
var debugSurfaceId: UUID? {
|
||||
surfaceView.terminalSurface?.id
|
||||
}
|
||||
#endif
|
||||
|
||||
init(surfaceView: GhosttyNSView) {
|
||||
|
|
@ -2969,8 +2983,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
|
||||
func setVisibleInUI(_ visible: Bool) {
|
||||
let wasVisible = surfaceView.isVisibleInUI
|
||||
surfaceView.setVisibleInUI(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 we were focused, yield first responder.
|
||||
if let window, let fr = window.firstResponder as? NSView,
|
||||
|
|
@ -2983,7 +3006,16 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
let wasActive = isActive
|
||||
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 {
|
||||
applyFirstResponderIfNeeded()
|
||||
} 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) {
|
||||
#if DEBUG
|
||||
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) {
|
||||
let hostedView = terminalSurface.hostedView
|
||||
let coordinator = context.coordinator
|
||||
let previousDesiredIsActive = coordinator.desiredIsActive
|
||||
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
||||
coordinator.desiredIsActive = isActive
|
||||
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||
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.
|
||||
hostedView.attachSurface(terminalSurface)
|
||||
|
|
@ -3744,6 +3805,19 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
coordinator.attachGeneration += 1
|
||||
coordinator.desiredIsActive = 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 {
|
||||
host.onDidMoveToWindow = nil
|
||||
|
|
|
|||
|
|
@ -221,6 +221,7 @@ fileprivate func cmuxVsyncIOSurfaceTimelineCallback(
|
|||
@MainActor
|
||||
class TabManager: ObservableObject {
|
||||
@Published var tabs: [Workspace] = []
|
||||
@Published private(set) var isWorkspaceCycleHot: Bool = false
|
||||
@Published var selectedTabId: UUID? {
|
||||
didSet {
|
||||
guard selectedTabId != oldValue else { return }
|
||||
|
|
@ -232,12 +233,34 @@ class TabManager: ObservableObject {
|
|||
if !isNavigatingHistory, let 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
|
||||
self?.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||
self?.updateWindowTitleForSelectedTab()
|
||||
if let selectedTabId = self?.selectedTabId {
|
||||
self?.markFocusedPanelReadIfActive(tabId: selectedTabId)
|
||||
guard let self, self.selectionSideEffectsGeneration == generation else { return }
|
||||
self.focusSelectedTabPanel(previousTabId: previousTabId)
|
||||
self.updateWindowTitleForSelectedTab()
|
||||
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 isNavigatingHistory = false
|
||||
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
|
||||
private var didSetupSplitCloseRightUITest = false
|
||||
|
|
@ -291,6 +323,10 @@ class TabManager: ObservableObject {
|
|||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
workspaceCycleCooldownTask?.cancel()
|
||||
}
|
||||
|
||||
var selectedWorkspace: Workspace? {
|
||||
guard let selectedTabId else { return nil }
|
||||
return tabs.first(where: { $0.id == selectedTabId })
|
||||
|
|
@ -814,12 +850,15 @@ class TabManager: ObservableObject {
|
|||
guard let panelId = tab.focusedPanelId,
|
||||
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,
|
||||
let previousTab = tabs.first(where: { $0.id == previousTabId }),
|
||||
let previousPanelId = previousTab.focusedPanelId,
|
||||
let previousPanel = previousTab.panels[previousPanelId] {
|
||||
previousPanel.unfocus()
|
||||
previousTab.panels[previousPanelId] != nil {
|
||||
replacePendingWorkspaceUnfocusTarget(
|
||||
with: (tabId: previousTabId, panelId: previousPanelId)
|
||||
)
|
||||
}
|
||||
|
||||
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) {
|
||||
let shouldSuppressFlash = suppressFocusFlash
|
||||
suppressFocusFlash = false
|
||||
|
|
@ -959,6 +1086,17 @@ class TabManager: ObservableObject {
|
|||
guard let currentId = selectedTabId,
|
||||
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -966,9 +1104,97 @@ class TabManager: ObservableObject {
|
|||
guard let currentId = selectedTabId,
|
||||
let currentIndex = tabs.firstIndex(where: { $0.id == currentId }) else { return }
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
guard index >= 0 && index < tabs.count else { return }
|
||||
selectedTabId = tabs[index].id
|
||||
|
|
@ -2222,6 +2448,7 @@ extension Notification.Name {
|
|||
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
|
||||
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
|
||||
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
|
||||
static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface")
|
||||
static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar")
|
||||
static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection")
|
||||
static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import Bonsplit
|
|||
/// View that renders a Workspace's content using BonsplitView
|
||||
struct WorkspaceContentView: View {
|
||||
@ObservedObject var workspace: Workspace
|
||||
let isTabActive: Bool
|
||||
let isWorkspaceVisible: Bool
|
||||
let isWorkspaceInputActive: Bool
|
||||
@State private var config = GhosttyConfig.load()
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
|
|
@ -16,7 +17,7 @@ struct WorkspaceContentView: View {
|
|||
|
||||
// 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.
|
||||
let _ = { workspace.bonsplitController.isInteractive = isTabActive }()
|
||||
let _ = { workspace.bonsplitController.isInteractive = isWorkspaceInputActive }()
|
||||
|
||||
// Wire up file drop handling so bonsplit's PaneDragContainerView can forward
|
||||
// Finder file drops to the correct terminal panel.
|
||||
|
|
@ -35,9 +36,9 @@ struct WorkspaceContentView: View {
|
|||
// Content for each tab in bonsplit
|
||||
let _ = Self.debugPanelLookup(tab: tab, workspace: workspace)
|
||||
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 isVisibleInUI = isTabActive && isSelectedInPane
|
||||
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
|
||||
PanelContentView(
|
||||
panel: panel,
|
||||
isFocused: isFocused,
|
||||
|
|
@ -50,12 +51,12 @@ struct WorkspaceContentView: View {
|
|||
// Keep bonsplit focus in sync with the AppKit first responder for the
|
||||
// active workspace. This prevents divergence between the blue focused-tab
|
||||
// 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 }
|
||||
workspace.focusPanel(panel.id)
|
||||
},
|
||||
onRequestPanelFocus: {
|
||||
guard isTabActive else { return }
|
||||
guard isWorkspaceInputActive else { return }
|
||||
guard workspace.panels[panel.id] != nil else { return }
|
||||
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