Merge pull request #419 from manaflow-ai/task-bonsplit-tab-drag-lag-debug-logging
Add debug timing logs for bonsplit tab transfer lag repro
This commit is contained in:
commit
6a3c0bd6f4
8 changed files with 600 additions and 61 deletions
|
|
@ -77,6 +77,7 @@
|
|||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
|
||||
F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
|
|
@ -198,10 +199,11 @@
|
|||
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
|
||||
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
|
||||
F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
|
@ -401,17 +403,18 @@
|
|||
path = cmuxUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
|
||||
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
|
||||
F5000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
|
||||
);
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
|
@ -601,17 +604,18 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */,
|
||||
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */,
|
||||
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
|
||||
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
|
||||
F5000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B9000006A1B2C3D4E5F60719 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
|
|
|||
|
|
@ -1113,19 +1113,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
focus: Bool = true,
|
||||
focusWindow: Bool = true
|
||||
) -> Bool {
|
||||
guard let source = locateSurface(surfaceId: panelId),
|
||||
let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }),
|
||||
let destinationManager = tabManagerFor(tabId: targetWorkspaceId),
|
||||
let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else {
|
||||
#if DEBUG
|
||||
let moveStart = ProcessInfo.processInfo.systemUptime
|
||||
let splitLabel = splitTarget.map { split in
|
||||
"\(split.orientation.rawValue):\(split.insertFirst ? 1 : 0)"
|
||||
} ?? "none"
|
||||
func elapsedMs(since start: TimeInterval) -> String {
|
||||
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
|
||||
return String(format: "%.2f", ms)
|
||||
}
|
||||
dlog(
|
||||
"surface.move.begin panel=\(panelId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " +
|
||||
"targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
|
||||
"split=\(splitLabel) focus=\(focus ? 1 : 0) focusWindow=\(focusWindow ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
guard let source = locateSurface(surfaceId: panelId) else {
|
||||
#if DEBUG
|
||||
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourcePanelNotFound elapsedMs=\(elapsedMs(since: moveStart))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else {
|
||||
#if DEBUG
|
||||
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sourceWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let destinationManager = tabManagerFor(tabId: targetWorkspaceId) else {
|
||||
#if DEBUG
|
||||
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationManagerMissing elapsedMs=\(elapsedMs(since: moveStart))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
guard let destinationWorkspace = destinationManager.tabs.first(where: { $0.id == targetWorkspaceId }) else {
|
||||
#if DEBUG
|
||||
dlog("surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=destinationWorkspaceMissing elapsedMs=\(elapsedMs(since: moveStart))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.route panel=\(panelId.uuidString.prefix(5)) sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) " +
|
||||
"sourceWin=\(source.windowId.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " +
|
||||
"sameWorkspace=\(destinationWorkspace.id == sourceWorkspace.id ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
|
||||
let resolvedTargetPane = targetPane.flatMap { pane in
|
||||
destinationWorkspace.bonsplitController.allPaneIds.first(where: { $0 == pane })
|
||||
} ?? destinationWorkspace.bonsplitController.focusedPaneId
|
||||
?? destinationWorkspace.bonsplitController.allPaneIds.first
|
||||
|
||||
guard let resolvedTargetPane else { return false }
|
||||
guard let resolvedTargetPane else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=targetPaneMissing " +
|
||||
"destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
if destinationWorkspace.id == sourceWorkspace.id {
|
||||
if let splitTarget {
|
||||
|
|
@ -1136,26 +1184,62 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
movingTab: sourceTabId,
|
||||
insertFirst: splitTarget.insertFirst
|
||||
) != nil else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=sameWorkspaceSplitFailed " +
|
||||
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) split=\(splitLabel) " +
|
||||
"elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
if focus {
|
||||
source.tabManager.focusTab(sourceWorkspace.id, surfaceId: panelId, suppressFlash: true)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceSplit moved=1 " +
|
||||
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
return sourceWorkspace.moveSurface(
|
||||
let moved = sourceWorkspace.moveSurface(
|
||||
panelId: panelId,
|
||||
toPane: resolvedTargetPane,
|
||||
atIndex: targetIndex,
|
||||
focus: focus
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=sameWorkspaceMove moved=\(moved ? 1 : 0) " +
|
||||
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
|
||||
"elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return moved
|
||||
}
|
||||
|
||||
let sourcePane = sourceWorkspace.paneId(forPanelId: panelId)
|
||||
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: panelId)
|
||||
#if DEBUG
|
||||
let detachStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
|
||||
guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else { return false }
|
||||
guard let detached = sourceWorkspace.detachSurface(panelId: panelId) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=detachFailed " +
|
||||
"elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
let detachMs = elapsedMs(since: detachStart)
|
||||
let attachStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
guard destinationWorkspace.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: resolvedTargetPane,
|
||||
|
|
@ -1169,10 +1253,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
sourceIndex: sourceIndex,
|
||||
focus: focus
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=attachFailed " +
|
||||
"detachMs=\(detachMs) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
let attachMs = elapsedMs(since: attachStart)
|
||||
var splitMs = "0.00"
|
||||
#endif
|
||||
|
||||
if let splitTarget {
|
||||
#if DEBUG
|
||||
let splitStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
guard let movedTabId = destinationWorkspace.surfaceIdFromPanelId(panelId),
|
||||
destinationWorkspace.bonsplitController.splitPane(
|
||||
resolvedTargetPane,
|
||||
|
|
@ -1189,15 +1286,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
focus: focus
|
||||
)
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.move.fail panel=\(panelId.uuidString.prefix(5)) reason=postAttachSplitFailed " +
|
||||
"detachMs=\(detachMs) attachMs=\(attachMs) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
splitMs = elapsedMs(since: splitStart)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let cleanupStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
cleanupEmptySourceWorkspaceAfterSurfaceMove(
|
||||
sourceWorkspace: sourceWorkspace,
|
||||
sourceManager: source.tabManager,
|
||||
sourceWindowId: source.windowId
|
||||
)
|
||||
#if DEBUG
|
||||
let cleanupMs = elapsedMs(since: cleanupStart)
|
||||
let focusStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
|
||||
if focus {
|
||||
let destinationWindowId = focusWindow ? windowId(for: destinationManager) : nil
|
||||
|
|
@ -1215,6 +1328,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
)
|
||||
}
|
||||
}
|
||||
#if DEBUG
|
||||
let focusMs = elapsedMs(since: focusStart)
|
||||
dlog(
|
||||
"surface.move.end panel=\(panelId.uuidString.prefix(5)) path=crossWorkspace moved=1 " +
|
||||
"sourceWs=\(sourceWorkspace.id.uuidString.prefix(5)) destinationWs=\(destinationWorkspace.id.uuidString.prefix(5)) " +
|
||||
"targetPane=\(resolvedTargetPane.id.uuidString.prefix(5)) targetIndex=\(targetIndex.map(String.init) ?? "nil") " +
|
||||
"split=\(splitLabel) detachMs=\(detachMs) attachMs=\(attachMs) splitMs=\(splitMs) " +
|
||||
"cleanupMs=\(cleanupMs) focusMs=\(focusMs) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -1229,8 +1352,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
focus: Bool = true,
|
||||
focusWindow: Bool = true
|
||||
) -> Bool {
|
||||
guard let located = locateBonsplitSurface(tabId: tabId) else { return false }
|
||||
return moveSurface(
|
||||
#if DEBUG
|
||||
let moveStart = ProcessInfo.processInfo.systemUptime
|
||||
func elapsedMs(since start: TimeInterval) -> String {
|
||||
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
|
||||
return String(format: "%.2f", ms)
|
||||
}
|
||||
dlog(
|
||||
"surface.moveBonsplit.begin tab=\(tabId.uuidString.prefix(5)) targetWs=\(targetWorkspaceId.uuidString.prefix(5)) " +
|
||||
"targetPane=\(targetPane?.id.uuidString.prefix(5) ?? "auto") targetIndex=\(targetIndex.map(String.init) ?? "nil")"
|
||||
)
|
||||
#endif
|
||||
guard let located = locateBonsplitSurface(tabId: tabId) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.moveBonsplit.fail tab=\(tabId.uuidString.prefix(5)) reason=tabNotFound " +
|
||||
"targetWs=\(targetWorkspaceId.uuidString.prefix(5)) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.moveBonsplit.located tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " +
|
||||
"sourceWs=\(located.workspaceId.uuidString.prefix(5)) sourceWin=\(located.windowId.uuidString.prefix(5))"
|
||||
)
|
||||
#endif
|
||||
let moved = moveSurface(
|
||||
panelId: located.panelId,
|
||||
toWorkspace: targetWorkspaceId,
|
||||
targetPane: targetPane,
|
||||
|
|
@ -1239,6 +1387,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
focus: focus,
|
||||
focusWindow: focusWindow
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"surface.moveBonsplit.end tab=\(tabId.uuidString.prefix(5)) panel=\(located.panelId.uuidString.prefix(5)) " +
|
||||
"moved=\(moved ? 1 : 0) elapsedMs=\(elapsedMs(since: moveStart))"
|
||||
)
|
||||
#endif
|
||||
return moved
|
||||
}
|
||||
|
||||
func tabManagerFor(windowId: UUID) -> TabManager? {
|
||||
|
|
|
|||
|
|
@ -2517,6 +2517,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
|
||||
override func becomeFirstResponder() -> Bool {
|
||||
let result = super.becomeFirstResponder()
|
||||
var shouldApplySurfaceFocus = false
|
||||
if result {
|
||||
// If we become first responder before the ghostty surface exists (e.g. during
|
||||
// split/tab creation while the surface is still being created), record the desired focus.
|
||||
|
|
@ -2538,6 +2539,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
// stayed behind.
|
||||
let hiddenInHierarchy = isHiddenOrHasHiddenAncestor
|
||||
if isVisibleInUI && hasUsableFocusGeometry && !hiddenInHierarchy {
|
||||
shouldApplySurfaceFocus = true
|
||||
onFocus?()
|
||||
} else if isVisibleInUI && (!hasUsableFocusGeometry || hiddenInHierarchy) {
|
||||
#if DEBUG
|
||||
|
|
@ -2548,7 +2550,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
#endif
|
||||
}
|
||||
}
|
||||
if result, let surface = ensureSurfaceReadyForInput() {
|
||||
if result, shouldApplySurfaceFocus, let surface = ensureSurfaceReadyForInput() {
|
||||
let now = CACurrentMediaTime()
|
||||
let deltaMs = (now - lastScrollEventTime) * 1000
|
||||
Self.focusLog("becomeFirstResponder: surface=\(terminalSurface?.id.uuidString ?? "nil") deltaSinceScrollMs=\(String(format: "%.2f", deltaMs))")
|
||||
|
|
@ -4102,9 +4104,11 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
isHidden = !visible
|
||||
#if DEBUG
|
||||
if wasVisible != visible {
|
||||
let transition = "\(wasVisible ? 1 : 0)->\(visible ? 1 : 0)"
|
||||
let suffix = debugVisibilityStateSuffix(transition: transition)
|
||||
debugLogWorkspaceSwitchTiming(
|
||||
event: "ws.term.visible",
|
||||
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(visible ? 1 : 0)"
|
||||
suffix: suffix
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
|
@ -4124,9 +4128,11 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
isActive = active
|
||||
#if DEBUG
|
||||
if wasActive != active {
|
||||
let transition = "\(wasActive ? 1 : 0)->\(active ? 1 : 0)"
|
||||
let suffix = debugVisibilityStateSuffix(transition: transition)
|
||||
debugLogWorkspaceSwitchTiming(
|
||||
event: "ws.term.active",
|
||||
suffix: "surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") value=\(active ? 1 : 0)"
|
||||
suffix: suffix
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
|
@ -4148,6 +4154,37 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
let dtMs = (CACurrentMediaTime() - snapshot.startedAt) * 1000
|
||||
dlog("\(event) id=\(snapshot.id) dt=\(String(format: "%.2fms", dtMs)) \(suffix)")
|
||||
}
|
||||
|
||||
private func debugFirstResponderLabel() -> String {
|
||||
guard let window, let firstResponder = window.firstResponder else { return "nil" }
|
||||
if let view = firstResponder as? NSView {
|
||||
if view === surfaceView {
|
||||
return "surfaceView"
|
||||
}
|
||||
if view.isDescendant(of: surfaceView) {
|
||||
return "surfaceDescendant"
|
||||
}
|
||||
return String(describing: type(of: view))
|
||||
}
|
||||
return String(describing: type(of: firstResponder))
|
||||
}
|
||||
|
||||
private func debugVisibilityStateSuffix(transition: String) -> String {
|
||||
let surface = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
|
||||
let hiddenInHierarchy = (isHiddenOrHasHiddenAncestor || surfaceView.isHiddenOrHasHiddenAncestor) ? 1 : 0
|
||||
let inWindow = window != nil ? 1 : 0
|
||||
let hasSuperview = superview != nil ? 1 : 0
|
||||
let hostHidden = isHidden ? 1 : 0
|
||||
let surfaceHidden = surfaceView.isHidden ? 1 : 0
|
||||
let boundsText = String(format: "%.1fx%.1f", bounds.width, bounds.height)
|
||||
let frameText = String(format: "%.1fx%.1f", frame.width, frame.height)
|
||||
let responder = debugFirstResponderLabel()
|
||||
return
|
||||
"surface=\(surface) transition=\(transition) active=\(isActive ? 1 : 0) " +
|
||||
"visibleFlag=\(surfaceView.isVisibleInUI ? 1 : 0) hostHidden=\(hostHidden) surfaceHidden=\(surfaceHidden) " +
|
||||
"hiddenHierarchy=\(hiddenInHierarchy) inWindow=\(inWindow) hasSuperview=\(hasSuperview) " +
|
||||
"bounds=\(boundsText) frame=\(frameText) firstResponder=\(responder)"
|
||||
}
|
||||
#endif
|
||||
|
||||
func moveFocus(from previous: GhosttySurfaceScrollView? = nil, delay: TimeInterval? = nil) {
|
||||
|
|
@ -4999,32 +5036,36 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let hostedView = terminalSurface.hostedView
|
||||
let coordinator = context.coordinator
|
||||
#if DEBUG
|
||||
let previousDesiredIsActive = coordinator.desiredIsActive
|
||||
#endif
|
||||
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
||||
let previousDesiredShowsUnreadNotificationRing = coordinator.desiredShowsUnreadNotificationRing
|
||||
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
|
||||
let desiredStateChanged =
|
||||
previousDesiredIsActive != isActive ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority
|
||||
coordinator.desiredIsActive = isActive
|
||||
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||
coordinator.desiredShowsUnreadNotificationRing = showsUnreadNotificationRing
|
||||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.hostedView = hostedView
|
||||
#if DEBUG
|
||||
if previousDesiredIsActive != isActive ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority {
|
||||
if desiredStateChanged {
|
||||
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) z=\(portalZPriority)"
|
||||
"active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
|
||||
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
|
||||
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
|
||||
)
|
||||
} else {
|
||||
dlog(
|
||||
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority) " +
|
||||
"hostWindow=\(nsView.window != nil ? 1 : 0) hostedWindow=\(hostedView.window != nil ? 1 : 0) " +
|
||||
"hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -5112,6 +5153,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
// Bind is deferred until host moves into a window. Update the
|
||||
// existing portal entry's visibleInUI now so that any portal sync
|
||||
// that runs before the deferred bind completes won't hide the view.
|
||||
#if DEBUG
|
||||
if desiredStateChanged {
|
||||
dlog(
|
||||
"ws.hostState.deferBind surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"reason=hostNoWindow visible=\(coordinator.desiredIsVisibleInUI ? 1 : 0) " +
|
||||
"active=\(coordinator.desiredIsActive ? 1 : 0) z=\(coordinator.desiredPortalZPriority) " +
|
||||
"hostedWindow=\(hostedView.window != nil ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
TerminalWindowPortalRegistry.updateEntryVisibility(
|
||||
for: hostedView,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI
|
||||
|
|
@ -5135,6 +5186,16 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
} else {
|
||||
// Preserve portal entry visibility while a stale host is still receiving SwiftUI updates.
|
||||
// The currently bound host remains authoritative for immediate visible/active state.
|
||||
#if DEBUG
|
||||
if desiredStateChanged {
|
||||
dlog(
|
||||
"ws.hostState.deferApply surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"reason=staleHostBinding hostWindow=\(hostWindowAttached ? 1 : 0) " +
|
||||
"boundToCurrent=\(isBoundToCurrentHost ? 1 : 0) hostedSuperview=\(hostedView.superview != nil ? 1 : 0) " +
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0)"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
TerminalWindowPortalRegistry.updateEntryVisibility(
|
||||
for: hostedView,
|
||||
visibleInUI: isVisibleInUI
|
||||
|
|
|
|||
|
|
@ -347,6 +347,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return panel
|
||||
}
|
||||
|
||||
enum FocusPanelTrigger {
|
||||
case standard
|
||||
case terminalFirstResponder
|
||||
}
|
||||
|
||||
/// Published directory for each panel
|
||||
@Published var panelDirectories: [UUID: String] = [:]
|
||||
@Published var panelTitles: [UUID: String] = [:]
|
||||
|
|
@ -568,6 +573,8 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
private var focusReconcileScheduled = false
|
||||
#if DEBUG
|
||||
private(set) var debugFocusReconcileScheduledDuringDetachCount: Int = 0
|
||||
private var debugLastDidMoveTabTimestamp: TimeInterval = 0
|
||||
private var debugDidMoveTabEventCount: UInt64 = 0
|
||||
#endif
|
||||
private var geometryReconcileScheduled = false
|
||||
private var isNormalizingPinnedTabOrder = false
|
||||
|
|
@ -600,6 +607,13 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
private var activeDetachCloseTransactions: Int = 0
|
||||
private var isDetachingCloseTransaction: Bool { activeDetachCloseTransactions > 0 }
|
||||
|
||||
#if DEBUG
|
||||
private func debugElapsedMs(since start: TimeInterval) -> String {
|
||||
let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000
|
||||
return String(format: "%.2f", ms)
|
||||
}
|
||||
#endif
|
||||
|
||||
func panelIdFromSurfaceId(_ surfaceId: TabID) -> UUID? {
|
||||
surfaceIdToPanelId[surfaceId]
|
||||
}
|
||||
|
|
@ -1686,6 +1700,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
func detachSurface(panelId: UUID) -> DetachedSurfaceTransfer? {
|
||||
guard let tabId = surfaceIdFromPanelId(panelId) else { return nil }
|
||||
guard panels[panelId] != nil else { return nil }
|
||||
#if DEBUG
|
||||
let detachStart = ProcessInfo.processInfo.systemUptime
|
||||
dlog(
|
||||
"split.detach.begin ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(tabId.uuid.uuidString.prefix(5)) activeDetachTxn=\(activeDetachCloseTransactions) " +
|
||||
"pendingDetached=\(pendingDetachedSurfaces.count)"
|
||||
)
|
||||
#endif
|
||||
|
||||
detachingTabIds.insert(tabId)
|
||||
forceCloseTabIds.insert(tabId)
|
||||
|
|
@ -1695,10 +1717,24 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
detachingTabIds.remove(tabId)
|
||||
pendingDetachedSurfaces.removeValue(forKey: tabId)
|
||||
forceCloseTabIds.remove(tabId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.detach.fail ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(tabId.uuid.uuidString.prefix(5)) reason=closeTabRejected elapsedMs=\(debugElapsedMs(since: detachStart))"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
return pendingDetachedSurfaces.removeValue(forKey: tabId)
|
||||
let detached = pendingDetachedSurfaces.removeValue(forKey: tabId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.detach.end ws=\(id.uuidString.prefix(5)) panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(tabId.uuid.uuidString.prefix(5)) transfer=\(detached != nil ? 1 : 0) " +
|
||||
"elapsedMs=\(debugElapsedMs(since: detachStart))"
|
||||
)
|
||||
#endif
|
||||
return detached
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -1708,8 +1744,31 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
atIndex index: Int? = nil,
|
||||
focus: Bool = true
|
||||
) -> UUID? {
|
||||
guard bonsplitController.allPaneIds.contains(paneId) else { return nil }
|
||||
guard panels[detached.panelId] == nil else { return nil }
|
||||
#if DEBUG
|
||||
let attachStart = ProcessInfo.processInfo.systemUptime
|
||||
dlog(
|
||||
"split.attach.begin ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
||||
"pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0)"
|
||||
)
|
||||
#endif
|
||||
guard bonsplitController.allPaneIds.contains(paneId) else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
||||
"reason=invalidPane elapsedMs=\(debugElapsedMs(since: attachStart))"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
guard panels[detached.panelId] == nil else {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
||||
"reason=panelExists elapsedMs=\(debugElapsedMs(since: attachStart))"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
panels[detached.panelId] = detached.panel
|
||||
if let terminalPanel = detached.panel as? TerminalPanel {
|
||||
|
|
@ -1760,6 +1819,12 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
manualUnreadPanelIds.remove(detached.panelId)
|
||||
manualUnreadMarkedAt.removeValue(forKey: detached.panelId)
|
||||
panelSubscriptions.removeValue(forKey: detached.panelId)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.attach.fail ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
||||
"reason=createTabFailed elapsedMs=\(debugElapsedMs(since: attachStart))"
|
||||
)
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1781,6 +1846,14 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
scheduleTerminalGeometryReconcile()
|
||||
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.attach.end ws=\(id.uuidString.prefix(5)) panel=\(detached.panelId.uuidString.prefix(5)) " +
|
||||
"tab=\(newTabId.uuid.uuidString.prefix(5)) pane=\(paneId.id.uuidString.prefix(5)) " +
|
||||
"index=\(index.map(String.init) ?? "nil") focus=\(focus ? 1 : 0) " +
|
||||
"elapsedMs=\(debugElapsedMs(since: attachStart))"
|
||||
)
|
||||
#endif
|
||||
return detached.panelId
|
||||
}
|
||||
// MARK: - Focus Management
|
||||
|
|
@ -1872,12 +1945,19 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
terminalPanel.hostedView.ensureFocus(for: id, surfaceId: preferredPanelId)
|
||||
}
|
||||
|
||||
func focusPanel(_ panelId: UUID, previousHostedView: GhosttySurfaceScrollView? = nil) {
|
||||
func focusPanel(
|
||||
_ panelId: UUID,
|
||||
previousHostedView: GhosttySurfaceScrollView? = nil,
|
||||
trigger: FocusPanelTrigger = .standard
|
||||
) {
|
||||
markExplicitFocusIntent(on: panelId)
|
||||
#if DEBUG
|
||||
let pane = bonsplitController.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
||||
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane)")
|
||||
FocusLogStore.shared.append("Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane)")
|
||||
let triggerLabel = trigger == .terminalFirstResponder ? "firstResponder" : "standard"
|
||||
dlog("focus.panel panel=\(panelId.uuidString.prefix(5)) pane=\(pane) trigger=\(triggerLabel)")
|
||||
FocusLogStore.shared.append(
|
||||
"Workspace.focusPanel panelId=\(panelId.uuidString) focusedPane=\(pane) trigger=\(triggerLabel)"
|
||||
)
|
||||
#endif
|
||||
guard let tabId = surfaceIdFromPanelId(panelId) else { return }
|
||||
let currentlyFocusedPanelId = focusedPanelId
|
||||
|
|
@ -1900,6 +1980,15 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
return bonsplitController.focusedPaneId == targetPaneId &&
|
||||
bonsplitController.selectedTab(inPane: targetPaneId)?.id == tabId
|
||||
}()
|
||||
let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged
|
||||
#if DEBUG
|
||||
if shouldSuppressReentrantRefocus {
|
||||
dlog(
|
||||
"focus.panel.skipReentrant panel=\(panelId.uuidString.prefix(5)) " +
|
||||
"reason=firstResponderAlreadyConverged"
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
if let targetPaneId, !selectionAlreadyConverged {
|
||||
bonsplitController.focusPane(targetPaneId)
|
||||
|
|
@ -1911,11 +2000,11 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
// Also focus the underlying panel
|
||||
if let panel = panels[panelId] {
|
||||
if currentlyFocusedPanelId != panelId || !selectionAlreadyConverged {
|
||||
if (currentlyFocusedPanelId != panelId || !selectionAlreadyConverged) && !shouldSuppressReentrantRefocus {
|
||||
panel.focus()
|
||||
}
|
||||
|
||||
if let terminalPanel = panel as? TerminalPanel {
|
||||
if !shouldSuppressReentrantRefocus, let terminalPanel = panel as? TerminalPanel {
|
||||
// Avoid re-entrant focus loops when focus was initiated by AppKit first-responder
|
||||
// (becomeFirstResponder -> onFocus -> focusPanel).
|
||||
if !terminalPanel.hostedView.isSurfaceViewFirstResponder() {
|
||||
|
|
@ -1923,7 +2012,7 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let targetPaneId {
|
||||
if let targetPaneId, !shouldSuppressReentrantRefocus {
|
||||
applyTabSelection(tabId: tabId, inPane: targetPaneId)
|
||||
}
|
||||
}
|
||||
|
|
@ -2329,23 +2418,41 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
|
||||
private func handleExternalTabDrop(_ request: BonsplitController.ExternalTabDropRequest) -> Bool {
|
||||
guard let app = AppDelegate.shared else { return false }
|
||||
#if DEBUG
|
||||
let dropStart = ProcessInfo.processInfo.systemUptime
|
||||
#endif
|
||||
|
||||
let targetPane: PaneID
|
||||
let targetIndex: Int?
|
||||
let splitTarget: (orientation: SplitOrientation, insertFirst: Bool)?
|
||||
#if DEBUG
|
||||
let destinationLabel: String
|
||||
#endif
|
||||
|
||||
switch request.destination {
|
||||
case .insert(let paneId, let index):
|
||||
targetPane = paneId
|
||||
targetIndex = index
|
||||
splitTarget = nil
|
||||
#if DEBUG
|
||||
destinationLabel = "insert pane=\(paneId.id.uuidString.prefix(5)) index=\(index.map(String.init) ?? "nil")"
|
||||
#endif
|
||||
case .split(let paneId, let orientation, let insertFirst):
|
||||
targetPane = paneId
|
||||
targetIndex = nil
|
||||
splitTarget = (orientation, insertFirst)
|
||||
#if DEBUG
|
||||
destinationLabel = "split pane=\(paneId.id.uuidString.prefix(5)) orientation=\(orientation.rawValue) insertFirst=\(insertFirst ? 1 : 0)"
|
||||
#endif
|
||||
}
|
||||
|
||||
return app.moveBonsplitTab(
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.externalDrop.begin ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
|
||||
"sourcePane=\(request.sourcePaneId.id.uuidString.prefix(5)) destination=\(destinationLabel)"
|
||||
)
|
||||
#endif
|
||||
let moved = app.moveBonsplitTab(
|
||||
tabId: request.tabId.uuid,
|
||||
toWorkspace: id,
|
||||
targetPane: targetPane,
|
||||
|
|
@ -2354,6 +2461,13 @@ final class Workspace: Identifiable, ObservableObject {
|
|||
focus: true,
|
||||
focusWindow: true
|
||||
)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"split.externalDrop.end ws=\(id.uuidString.prefix(5)) tab=\(request.tabId.uuid.uuidString.prefix(5)) " +
|
||||
"moved=\(moved ? 1 : 0) elapsedMs=\(debugElapsedMs(since: dropStart))"
|
||||
)
|
||||
#endif
|
||||
return moved
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -2647,17 +2761,19 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
if isDetaching, let panel {
|
||||
let browserPanel = panel as? BrowserPanel
|
||||
let cachedTitle = panelTitles[panelId]
|
||||
let transferFallbackTitle = cachedTitle ?? panel.displayTitle
|
||||
pendingDetachedSurfaces[tabId] = DetachedSurfaceTransfer(
|
||||
panelId: panelId,
|
||||
panel: panel,
|
||||
title: resolvedPanelTitle(panelId: panelId, fallback: panel.displayTitle),
|
||||
title: resolvedPanelTitle(panelId: panelId, fallback: transferFallbackTitle),
|
||||
icon: panel.displayIcon,
|
||||
iconImageData: browserPanel?.faviconPNGData,
|
||||
kind: surfaceKind(for: panel),
|
||||
isLoading: browserPanel?.isLoading ?? false,
|
||||
isPinned: pinnedPanelIds.contains(panelId),
|
||||
directory: panelDirectories[panelId],
|
||||
cachedTitle: panelTitles[panelId],
|
||||
cachedTitle: cachedTitle,
|
||||
customTitle: panelCustomTitles[panelId],
|
||||
manuallyUnread: manualUnreadPanelIds.contains(panelId)
|
||||
)
|
||||
|
|
@ -2736,14 +2852,44 @@ extension Workspace: BonsplitDelegate {
|
|||
|
||||
func splitTabBar(_ controller: BonsplitController, didMoveTab tab: Bonsplit.Tab, fromPane source: PaneID, toPane destination: PaneID) {
|
||||
#if DEBUG
|
||||
let movedPanel = panelIdFromSurfaceId(tab.id)?.uuidString.prefix(5) ?? "unknown"
|
||||
let now = ProcessInfo.processInfo.systemUptime
|
||||
let sincePrev: String
|
||||
if debugLastDidMoveTabTimestamp > 0 {
|
||||
sincePrev = String(format: "%.2f", (now - debugLastDidMoveTabTimestamp) * 1000)
|
||||
} else {
|
||||
sincePrev = "first"
|
||||
}
|
||||
debugLastDidMoveTabTimestamp = now
|
||||
debugDidMoveTabEventCount += 1
|
||||
let movedPanelId = panelIdFromSurfaceId(tab.id)
|
||||
let movedPanel = movedPanelId?.uuidString.prefix(5) ?? "unknown"
|
||||
let selectedBefore = controller.selectedTab(inPane: destination)
|
||||
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
|
||||
let focusedPaneBefore = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
||||
let focusedPanelBefore = focusedPanelId?.uuidString.prefix(5) ?? "nil"
|
||||
dlog(
|
||||
"split.moveTab panel=\(movedPanel) " +
|
||||
"split.moveTab idx=\(debugDidMoveTabEventCount) dtSincePrevMs=\(sincePrev) panel=\(movedPanel) " +
|
||||
"from=\(source.id.uuidString.prefix(5)) to=\(destination.id.uuidString.prefix(5)) " +
|
||||
"sourceTabs=\(controller.tabs(inPane: source).count) destTabs=\(controller.tabs(inPane: destination).count)"
|
||||
)
|
||||
dlog(
|
||||
"split.moveTab.state.before idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
|
||||
"destSelected=\(selectedBefore) focusedPane=\(focusedPaneBefore) focusedPanel=\(focusedPanelBefore)"
|
||||
)
|
||||
#endif
|
||||
applyTabSelection(tabId: tab.id, inPane: destination)
|
||||
#if DEBUG
|
||||
let selectedAfter = controller.selectedTab(inPane: destination)
|
||||
.map { String(String(describing: $0.id).prefix(5)) } ?? "nil"
|
||||
let focusedPaneAfter = controller.focusedPaneId?.id.uuidString.prefix(5) ?? "nil"
|
||||
let focusedPanelAfter = focusedPanelId?.uuidString.prefix(5) ?? "nil"
|
||||
let movedPanelFocused = (movedPanelId != nil && movedPanelId == focusedPanelId) ? 1 : 0
|
||||
dlog(
|
||||
"split.moveTab.state.after idx=\(debugDidMoveTabEventCount) panel=\(movedPanel) " +
|
||||
"destSelected=\(selectedAfter) focusedPane=\(focusedPaneAfter) focusedPanel=\(focusedPanelAfter) " +
|
||||
"movedFocused=\(movedPanelFocused)"
|
||||
)
|
||||
#endif
|
||||
normalizePinnedTabs(in: source)
|
||||
normalizePinnedTabs(in: destination)
|
||||
scheduleTerminalGeometryReconcile()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,17 @@ struct WorkspaceContentView: View {
|
|||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
static func panelVisibleInUI(
|
||||
isWorkspaceVisible: Bool,
|
||||
isSelectedInPane: Bool,
|
||||
isFocused: Bool
|
||||
) -> Bool {
|
||||
guard isWorkspaceVisible else { return false }
|
||||
// During pane/tab reparenting, Bonsplit can transiently report selected=false
|
||||
// for the currently focused panel. Keep focused content visible to avoid blank frames.
|
||||
return isSelectedInPane || isFocused
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let appearance = PanelAppearance.fromConfig(config)
|
||||
let isSplit = workspace.bonsplitController.allPaneIds.count > 1 ||
|
||||
|
|
@ -47,7 +58,11 @@ struct WorkspaceContentView: View {
|
|||
if let panel = workspace.panel(for: tab.id) {
|
||||
let isFocused = isWorkspaceInputActive && workspace.focusedPanelId == panel.id
|
||||
let isSelectedInPane = workspace.bonsplitController.selectedTab(inPane: paneId)?.id == tab.id
|
||||
let isVisibleInUI = isWorkspaceVisible && isSelectedInPane
|
||||
let isVisibleInUI = Self.panelVisibleInUI(
|
||||
isWorkspaceVisible: isWorkspaceVisible,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isFocused: isFocused
|
||||
)
|
||||
let hasUnreadNotification = Workspace.shouldShowUnreadIndicator(
|
||||
hasUnreadNotification: notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panel.id),
|
||||
isManuallyUnread: workspace.manualUnreadPanelIds.contains(panel.id)
|
||||
|
|
@ -67,7 +82,7 @@ struct WorkspaceContentView: View {
|
|||
// indicator and where keyboard input/flash-focus actually lands.
|
||||
guard isWorkspaceInputActive else { return }
|
||||
guard workspace.panels[panel.id] != nil else { return }
|
||||
workspace.focusPanel(panel.id)
|
||||
workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)
|
||||
},
|
||||
onRequestPanelFocus: {
|
||||
guard isWorkspaceInputActive else { return }
|
||||
|
|
|
|||
|
|
@ -3081,6 +3081,51 @@ final class WorkspacePanelGitBranchTests: XCTestCase {
|
|||
#endif
|
||||
}
|
||||
|
||||
func testDetachAttachAcrossWorkspacesPreservesNonCustomPanelTitle() {
|
||||
let source = Workspace()
|
||||
guard let panelId = source.focusedPanelId else {
|
||||
XCTFail("Expected source focused panel")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertTrue(source.updatePanelTitle(panelId: panelId, title: "detached-runtime-title"))
|
||||
|
||||
guard let detached = source.detachSurface(panelId: panelId) else {
|
||||
XCTFail("Expected detach to succeed")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertEqual(detached.cachedTitle, "detached-runtime-title")
|
||||
XCTAssertNil(detached.customTitle)
|
||||
XCTAssertEqual(
|
||||
detached.title,
|
||||
"detached-runtime-title",
|
||||
"Detached transfer should carry the cached non-custom title"
|
||||
)
|
||||
|
||||
let destination = Workspace()
|
||||
guard let destinationPane = destination.bonsplitController.allPaneIds.first else {
|
||||
XCTFail("Expected destination pane")
|
||||
return
|
||||
}
|
||||
|
||||
let attachedPanelId = destination.attachDetachedSurface(
|
||||
detached,
|
||||
inPane: destinationPane,
|
||||
focus: false
|
||||
)
|
||||
XCTAssertEqual(attachedPanelId, panelId)
|
||||
XCTAssertEqual(destination.panelTitle(panelId: panelId), "detached-runtime-title")
|
||||
|
||||
guard let attachedTabId = destination.surfaceIdFromPanelId(panelId),
|
||||
let attachedTab = destination.bonsplitController.tab(attachedTabId) else {
|
||||
XCTFail("Expected attached tab mapping")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(attachedTab.title, "detached-runtime-title")
|
||||
XCTAssertFalse(attachedTab.hasCustomTitle)
|
||||
}
|
||||
|
||||
func testBrowserSplitWithFocusFalseRecoversFromDelayedStaleSelection() {
|
||||
let workspace = Workspace()
|
||||
guard let originalFocusedPanelId = workspace.focusedPanelId else {
|
||||
|
|
|
|||
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
49
cmuxTests/WorkspaceContentViewVisibilityTests.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import XCTest
|
||||
|
||||
#if canImport(cmux_DEV)
|
||||
@testable import cmux_DEV
|
||||
#elseif canImport(cmux)
|
||||
@testable import cmux
|
||||
#endif
|
||||
|
||||
final class WorkspaceContentViewVisibilityTests: XCTestCase {
|
||||
func testPanelVisibleInUIReturnsFalseWhenWorkspaceHidden() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: false,
|
||||
isSelectedInPane: true,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForSelectedPanel() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: true,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsTrueForFocusedPanelDuringTransientSelectionGap() {
|
||||
XCTAssertTrue(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelVisibleInUIReturnsFalseWhenNeitherSelectedNorFocused() {
|
||||
XCTAssertFalse(
|
||||
WorkspaceContentView.panelVisibleInUI(
|
||||
isWorkspaceVisible: true,
|
||||
isSelectedInPane: false,
|
||||
isFocused: false
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
64
tests/test_focus_panel_reentrant_guard_regression.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Static regression checks for re-entrant terminal focus guard.
|
||||
|
||||
Guards the fix for split-drag focus churn where:
|
||||
becomeFirstResponder -> onFocus -> Workspace.focusPanel -> refocus side-effects
|
||||
could repeatedly re-enter and spike CPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root() -> Path:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return Path(result.stdout.strip())
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = repo_root()
|
||||
failures: list[str] = []
|
||||
|
||||
workspace_path = root / "Sources" / "Workspace.swift"
|
||||
workspace_source = workspace_path.read_text(encoding="utf-8")
|
||||
|
||||
required_workspace_snippets = [
|
||||
"enum FocusPanelTrigger {",
|
||||
"case terminalFirstResponder",
|
||||
"trigger: FocusPanelTrigger = .standard",
|
||||
"let shouldSuppressReentrantRefocus = trigger == .terminalFirstResponder && selectionAlreadyConverged",
|
||||
"if let targetPaneId, !shouldSuppressReentrantRefocus {",
|
||||
"reason=firstResponderAlreadyConverged",
|
||||
]
|
||||
for snippet in required_workspace_snippets:
|
||||
if snippet not in workspace_source:
|
||||
failures.append(f"Workspace focus guard missing snippet: {snippet}")
|
||||
|
||||
workspace_content_view_path = root / "Sources" / "WorkspaceContentView.swift"
|
||||
workspace_content_view_source = workspace_content_view_path.read_text(encoding="utf-8")
|
||||
focus_callback_snippet = "workspace.focusPanel(panel.id, trigger: .terminalFirstResponder)"
|
||||
if focus_callback_snippet not in workspace_content_view_source:
|
||||
failures.append(
|
||||
"WorkspaceContentView terminal onFocus callback no longer passes .terminalFirstResponder trigger"
|
||||
)
|
||||
|
||||
if failures:
|
||||
print("FAIL: focus-panel re-entrant guard regression checks failed")
|
||||
for item in failures:
|
||||
print(f" - {item}")
|
||||
return 1
|
||||
|
||||
print("PASS: focus-panel re-entrant guard is in place")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue