Merge pull request #83 from manaflow-ai/perf/portal-hosting-selected-mount
Reduce terminal input latency via portal hosting + selected-only workspace mounting
This commit is contained in:
commit
d08f28d770
30 changed files with 1798 additions and 182 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -83,4 +83,4 @@ jobs:
|
|||
run: |
|
||||
set -euo pipefail
|
||||
# Run directly on the self-hosted macOS runner.
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test
|
||||
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ tail -f /tmp/cmux-debug.log
|
|||
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
|
||||
|
||||
```bash
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests/UpdatePillUITests test'
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test'
|
||||
```
|
||||
|
||||
## Basic tests
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcode
|
|||
### UI tests (run on VM)
|
||||
|
||||
```bash
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:GhosttyTabsUITests test'
|
||||
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests test'
|
||||
```
|
||||
|
||||
## Ghostty Submodule
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; };
|
||||
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
|
||||
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
|
||||
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
|
||||
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
|
||||
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
|
||||
A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; };
|
||||
|
|
@ -123,8 +124,8 @@
|
|||
|
||||
/* Begin PBXFileReference section */
|
||||
A5001000 /* cmux.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = cmux.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTabsTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = GhosttyTabsUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = cmuxUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = "<group>"; };
|
||||
A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -132,6 +133,7 @@
|
|||
A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = "<group>"; };
|
||||
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
|
||||
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
|
||||
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
|
||||
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
|
||||
A5001018 /* cmux-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "cmux-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
|
|
@ -282,8 +284,8 @@
|
|||
A5001016 /* GhosttyKit.xcframework */,
|
||||
A5001017 /* ghostty.h */,
|
||||
A5001018 /* cmux-Bridging-Header.h */,
|
||||
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */,
|
||||
F1000003A1B2C3D4E5F60718 /* GhosttyTabsTests */,
|
||||
3196C9C2D01F054C1D3385DD /* cmuxUITests */,
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */,
|
||||
A5001042 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -304,6 +306,7 @@
|
|||
A5001417 /* WorkspaceContentView.swift */,
|
||||
A5001014 /* GhosttyConfig.swift */,
|
||||
A5001015 /* GhosttyTerminalView.swift */,
|
||||
A5001531 /* TerminalWindowPortal.swift */,
|
||||
A5001019 /* TerminalController.swift */,
|
||||
A5001225 /* SocketControlSettings.swift */,
|
||||
A5001090 /* AppDelegate.swift */,
|
||||
|
|
@ -359,13 +362,13 @@
|
|||
children = (
|
||||
A5001000 /* cmux.app */,
|
||||
B9000004A1B2C3D4E5F60719 /* cmux */,
|
||||
7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */,
|
||||
F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */,
|
||||
7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */,
|
||||
F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3196C9C2D01F054C1D3385DD /* GhosttyTabsUITests */ = {
|
||||
3196C9C2D01F054C1D3385DD /* cmuxUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B9000011A1B2C3D4E5F60719 /* AutomationSocketUITests.swift */,
|
||||
|
|
@ -379,16 +382,16 @@
|
|||
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
|
||||
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
|
||||
);
|
||||
path = GhosttyTabsUITests;
|
||||
path = cmuxUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F1000003A1B2C3D4E5F60718 /* GhosttyTabsTests */ = {
|
||||
F1000003A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */,
|
||||
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */,
|
||||
);
|
||||
path = GhosttyTabsTests;
|
||||
path = cmuxTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
|
@ -437,9 +440,9 @@
|
|||
productReference = B9000004A1B2C3D4E5F60719 /* cmux */;
|
||||
productType = "com.apple.product-type.tool";
|
||||
};
|
||||
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */ = {
|
||||
CB450DF0F0B3839599082C4D /* cmuxUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */;
|
||||
buildConfigurationList = AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */;
|
||||
buildPhases = (
|
||||
E436EF0BA8EC9E6721A42F79 /* Sources */,
|
||||
AB408954939A11B8A87BB5DE /* Frameworks */,
|
||||
|
|
@ -450,14 +453,14 @@
|
|||
dependencies = (
|
||||
32568B0DCFC8656BA952468E /* PBXTargetDependency */,
|
||||
);
|
||||
name = GhosttyTabsUITests;
|
||||
productName = GhosttyTabsUITests;
|
||||
productReference = 7E7E6EF344A568AC7FEE3715 /* GhosttyTabsUITests.xctest */;
|
||||
name = cmuxUITests;
|
||||
productName = cmuxUITests;
|
||||
productReference = 7E7E6EF344A568AC7FEE3715 /* cmuxUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
F1000004A1B2C3D4E5F60718 /* GhosttyTabsTests */ = {
|
||||
F1000004A1B2C3D4E5F60718 /* cmuxTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "GhosttyTabsTests" */;
|
||||
buildConfigurationList = F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "cmuxTests" */;
|
||||
buildPhases = (
|
||||
F1000005A1B2C3D4E5F60718 /* Sources */,
|
||||
F1000006A1B2C3D4E5F60718 /* Frameworks */,
|
||||
|
|
@ -468,9 +471,9 @@
|
|||
dependencies = (
|
||||
F1000009A1B2C3D4E5F60718 /* PBXTargetDependency */,
|
||||
);
|
||||
name = GhosttyTabsTests;
|
||||
productName = GhosttyTabsTests;
|
||||
productReference = F1000002A1B2C3D4E5F60718 /* GhosttyTabsTests.xctest */;
|
||||
name = cmuxTests;
|
||||
productName = cmuxTests;
|
||||
productReference = F1000002A1B2C3D4E5F60718 /* cmuxTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
|
@ -504,8 +507,8 @@
|
|||
targets = (
|
||||
A5001050 /* GhosttyTabs */,
|
||||
B9000005A1B2C3D4E5F60719 /* cmux-cli */,
|
||||
CB450DF0F0B3839599082C4D /* GhosttyTabsUITests */,
|
||||
F1000004A1B2C3D4E5F60718 /* GhosttyTabsTests */,
|
||||
CB450DF0F0B3839599082C4D /* cmuxUITests */,
|
||||
F1000004A1B2C3D4E5F60718 /* cmuxTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
|
@ -528,6 +531,7 @@
|
|||
A5001407 /* WorkspaceContentView.swift in Sources */,
|
||||
A5001004 /* GhosttyConfig.swift in Sources */,
|
||||
A5001005 /* GhosttyTerminalView.swift in Sources */,
|
||||
A5001532 /* TerminalWindowPortal.swift in Sources */,
|
||||
A5001007 /* TerminalController.swift in Sources */,
|
||||
A5001226 /* SocketControlSettings.swift in Sources */,
|
||||
A5001093 /* AppDelegate.swift in Sources */,
|
||||
|
|
@ -916,7 +920,7 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "GhosttyTabsUITests" */ = {
|
||||
AD2C7ED08993D3CD4910A1FF /* Build configuration list for PBXNativeTarget "cmuxUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
C117776A77E71D1432F570D7 /* Debug */,
|
||||
|
|
@ -925,7 +929,7 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "GhosttyTabsTests" */ = {
|
||||
F1000010A1B2C3D4E5F60718 /* Build configuration list for PBXNativeTarget "cmuxTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F1000011A1B2C3D4E5F60718 /* Debug */,
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@
|
|||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="A5001050" BuildableName="cmux.app" BlueprintName="GhosttyTabs" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
<BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="GhosttyTabsTests.xctest" BlueprintName="GhosttyTabsTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="GhosttyTabsTests.xctest" BlueprintName="GhosttyTabsTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="F1000004A1B2C3D4E5F60718" BuildableName="cmuxTests.xctest" BlueprintName="cmuxTests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<MacroExpansion>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
<TestAction buildConfiguration="Release" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES">
|
||||
<Testables>
|
||||
<TestableReference skipped="NO">
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="GhosttyTabsUITests.xctest" BlueprintName="GhosttyTabsUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="CB450DF0F0B3839599082C4D" BuildableName="cmuxUITests.xctest" BlueprintName="cmuxUITests" ReferencedContainer="container:GhosttyTabs.xcodeproj"/>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<MacroExpansion>
|
||||
|
|
|
|||
|
|
@ -1433,6 +1433,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
guard let self else { return event }
|
||||
if event.type == .keyDown {
|
||||
#if DEBUG
|
||||
if (ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1"
|
||||
|| UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")),
|
||||
event.timestamp > 0 {
|
||||
let delayMs = max(0, (ProcessInfo.processInfo.systemUptime - event.timestamp) * 1000)
|
||||
let delayText = String(format: "%.2f", delayMs)
|
||||
dlog("key.latency path=appMonitor ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
|
||||
}
|
||||
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
|
||||
#endif
|
||||
|
|
@ -1744,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -258,8 +258,13 @@ final class FileDropOverlayView: NSView {
|
|||
return .copy
|
||||
}
|
||||
|
||||
/// Temporarily hides self, hit-tests the window to find the GhosttyNSView under the cursor.
|
||||
private func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||
/// Hit-tests the window to find the GhosttyNSView under the cursor.
|
||||
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||
if let window,
|
||||
let portalTerminal = TerminalWindowPortalRegistry.terminalViewAtWindowPoint(windowPoint, in: window) {
|
||||
return portalTerminal
|
||||
}
|
||||
|
||||
guard let window, let contentView = window.contentView,
|
||||
let themeFrame = contentView.superview else { return nil }
|
||||
isHidden = true
|
||||
|
|
@ -278,6 +283,79 @@ final class FileDropOverlayView: NSView {
|
|||
|
||||
var fileDropOverlayKey: UInt8 = 0
|
||||
|
||||
enum WorkspaceMountPolicy {
|
||||
// Keep only the selected workspace mounted to minimize layer-tree traversal.
|
||||
static let maxMountedWorkspaces = 1
|
||||
// During workspace cycling, keep only a minimal handoff pair (selected + retiring).
|
||||
static let maxMountedWorkspacesDuringCycle = 2
|
||||
|
||||
static func nextMountedWorkspaceIds(
|
||||
current: [UUID],
|
||||
selected: UUID?,
|
||||
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) }
|
||||
|
||||
if let selected, existing.contains(selected) {
|
||||
ordered.removeAll { $0 == selected }
|
||||
ordered.insert(selected, 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 isCycleHot,
|
||||
pinnedIds.isEmpty,
|
||||
let selected {
|
||||
ordered.removeAll { $0 != selected }
|
||||
}
|
||||
|
||||
// Ensure pinned ids (retiring handoff workspaces) are always retained at highest priority.
|
||||
// This runs after warming to prevent neighbor warming from evicting the retiring workspace.
|
||||
let prioritizedPinnedIds = pinnedIds
|
||||
.filter { existing.contains($0) && $0 != selected }
|
||||
.sorted { lhs, rhs in
|
||||
let lhsIndex = orderedTabIds.firstIndex(of: lhs) ?? .max
|
||||
let rhsIndex = orderedTabIds.firstIndex(of: rhs) ?? .max
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
if let selected, existing.contains(selected) {
|
||||
ordered.removeAll { $0 == selected }
|
||||
ordered.insert(selected, at: 0)
|
||||
}
|
||||
var pinnedInsertionIndex = (selected != nil) ? 1 : 0
|
||||
for pinnedId in prioritizedPinnedIds {
|
||||
ordered.removeAll { $0 == pinnedId }
|
||||
let insertionIndex = min(pinnedInsertionIndex, ordered.count)
|
||||
ordered.insert(pinnedId, at: insertionIndex)
|
||||
pinnedInsertionIndex += 1
|
||||
}
|
||||
|
||||
if ordered.count > clampedMax {
|
||||
ordered.removeSubrange(clampedMax...)
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func cycleWarmIds(selected: UUID, orderedTabIds: [UUID]) -> [UUID] {
|
||||
guard orderedTabIds.contains(selected) else { return [selected] }
|
||||
// Keep warming focused to the selected workspace. Retiring/target workspaces are
|
||||
// pinned by handoff logic, so warming adjacent neighbors here just adds layout work.
|
||||
return [selected]
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs a FileDropOverlayView on the window's theme frame for Finder file drag support.
|
||||
func installFileDropOverlay(on window: NSWindow, tabManager: TabManager) {
|
||||
guard objc_getAssociatedObject(window, &fileDropOverlayKey) == nil,
|
||||
|
|
@ -318,11 +396,16 @@ struct ContentView: View {
|
|||
@State private var isResizerDragging = false
|
||||
private let sidebarHandleWidth: CGFloat = 6
|
||||
@State private var selectedTabIds: Set<UUID> = []
|
||||
@State private var mountedWorkspaceIds: [UUID] = []
|
||||
@State private var lastSidebarSelectionIndex: Int? = nil
|
||||
@State private var titlebarText: String = ""
|
||||
@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(
|
||||
|
|
@ -396,13 +479,28 @@ struct ContentView: View {
|
|||
@State private var titlebarPadding: CGFloat = 32
|
||||
|
||||
private var terminalContent: some View {
|
||||
ZStack {
|
||||
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(tabManager.tabs) { tab in
|
||||
let isActive = tabManager.selectedTabId == tab.id
|
||||
WorkspaceContentView(workspace: tab, isTabActive: isActive)
|
||||
.opacity(isActive ? 1 : 0)
|
||||
.allowsHitTesting(isActive)
|
||||
ForEach(mountedWorkspaces) { tab in
|
||||
let isSelectedWorkspace = selectedWorkspaceId == tab.id
|
||||
let isRetiringWorkspace = retiringWorkspaceId == tab.id
|
||||
let isInputActive = isSelectedWorkspace || isRetiringWorkspace
|
||||
let isVisible = isSelectedWorkspace || isRetiringWorkspace
|
||||
let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0)
|
||||
WorkspaceContentView(
|
||||
workspace: tab,
|
||||
isWorkspaceVisible: isVisible,
|
||||
isWorkspaceInputActive: isInputActive,
|
||||
workspacePortalPriority: portalPriority
|
||||
)
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.allowsHitTesting(isSelectedWorkspace)
|
||||
.zIndex(isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0))
|
||||
}
|
||||
}
|
||||
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
|
||||
|
|
@ -566,6 +664,8 @@ struct ContentView: View {
|
|||
.background(Color.clear)
|
||||
.onAppear {
|
||||
tabManager.applyWindowBackgroundForSelectedTab()
|
||||
reconcileMountedWorkspaceIds()
|
||||
previousSelectedWorkspaceId = tabManager.selectedTabId
|
||||
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
|
||||
selectedTabIds = [selectedId]
|
||||
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
|
||||
|
|
@ -573,7 +673,19 @@ 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 {
|
||||
selectedTabIds = [newValue]
|
||||
|
|
@ -581,6 +693,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 }
|
||||
|
|
@ -593,10 +721,25 @@ 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 {
|
||||
selectedTabIds = [selectedId]
|
||||
|
|
@ -700,6 +843,49 @@ struct ContentView: View {
|
|||
})
|
||||
}
|
||||
|
||||
private func reconcileMountedWorkspaceIds(tabs: [Workspace]? = nil, selectedId: UUID? = nil) {
|
||||
let currentTabs = tabs ?? tabManager.tabs
|
||||
let orderedTabIds = currentTabs.map { $0.id }
|
||||
let effectiveSelectedId = selectedId ?? tabManager.selectedTabId
|
||||
let pinnedIds = retiringWorkspaceId.map { Set([ $0 ]) } ?? []
|
||||
let isCycleHot = tabManager.isWorkspaceCycleHot
|
||||
let shouldKeepHandoffPair = isCycleHot && !pinnedIds.isEmpty
|
||||
let baseMaxMounted = shouldKeepHandoffPair
|
||||
? 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,
|
||||
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() {
|
||||
tabManager.addTab()
|
||||
sidebarSelectionState.selection = .tabs
|
||||
|
|
@ -721,6 +907,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 {
|
||||
|
|
|
|||
|
|
@ -1522,6 +1522,14 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
var backgroundColor: NSColor?
|
||||
private var keySequence: [ghostty_input_trigger_s] = []
|
||||
private var keyTables: [String] = []
|
||||
#if DEBUG
|
||||
private static let keyLatencyProbeEnabled: Bool = {
|
||||
if ProcessInfo.processInfo.environment["CMUX_KEY_LATENCY_PROBE"] == "1" {
|
||||
return true
|
||||
}
|
||||
return UserDefaults.standard.bool(forKey: "cmuxKeyLatencyProbe")
|
||||
}()
|
||||
#endif
|
||||
private var eventMonitor: Any?
|
||||
private var trackingArea: NSTrackingArea?
|
||||
private var windowObserver: NSObjectProtocol?
|
||||
|
|
@ -1834,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.
|
||||
|
|
@ -1867,6 +1885,16 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
private var markedText = NSMutableAttributedString()
|
||||
private var lastPerformKeyEvent: TimeInterval?
|
||||
|
||||
#if DEBUG
|
||||
private func recordKeyLatency(path: String, event: NSEvent) {
|
||||
guard Self.keyLatencyProbeEnabled else { return }
|
||||
guard event.timestamp > 0 else { return }
|
||||
let delayMs = max(0, (CACurrentMediaTime() - event.timestamp) * 1000)
|
||||
let delayText = String(format: "%.2f", delayMs)
|
||||
dlog("key.latency path=\(path) ms=\(delayText) keyCode=\(event.keyCode) mods=\(event.modifierFlags.rawValue) repeat=\(event.isARepeat ? 1 : 0)")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Prevents NSBeep for unimplemented actions from interpretKeyEvents
|
||||
override func doCommand(by selector: Selector) {
|
||||
// Intentionally empty - prevents system beep on unhandled key commands
|
||||
|
|
@ -1877,6 +1905,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
guard let fr = window?.firstResponder as? NSView,
|
||||
fr === self || fr.isDescendant(of: self) else { return false }
|
||||
guard let surface = ensureSurfaceReadyForInput() else { return false }
|
||||
#if DEBUG
|
||||
recordKeyLatency(path: "performKeyEquivalent", event: event)
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
cmuxWriteChildExitProbe(
|
||||
|
|
@ -1986,6 +2017,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
|
|||
super.keyDown(with: event)
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
recordKeyLatency(path: "keyDown", event: event)
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
cmuxWriteChildExitProbe(
|
||||
|
|
@ -2633,6 +2667,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
private let scrollView: GhosttyScrollView
|
||||
private let documentView: NSView
|
||||
private let surfaceView: GhosttyNSView
|
||||
private let inactiveOverlayView: GhosttyFlashOverlayView
|
||||
private let flashOverlayView: GhosttyFlashOverlayView
|
||||
private let flashLayer: CAShapeLayer
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
|
@ -2709,12 +2744,17 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
}
|
||||
return (presentCounts[surfaceId, default: 0], lastPresentTimes[surfaceId, default: 0], key)
|
||||
}
|
||||
|
||||
var debugSurfaceId: UUID? {
|
||||
surfaceView.terminalSurface?.id
|
||||
}
|
||||
#endif
|
||||
|
||||
init(surfaceView: GhosttyNSView) {
|
||||
self.surfaceView = surfaceView
|
||||
backgroundView = NSView(frame: .zero)
|
||||
scrollView = GhosttyScrollView()
|
||||
inactiveOverlayView = GhosttyFlashOverlayView(frame: .zero)
|
||||
flashOverlayView = GhosttyFlashOverlayView(frame: .zero)
|
||||
flashLayer = CAShapeLayer()
|
||||
scrollView.hasVerticalScroller = true
|
||||
|
|
@ -2742,6 +2782,10 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
.cgColor
|
||||
addSubview(backgroundView)
|
||||
addSubview(scrollView)
|
||||
inactiveOverlayView.wantsLayer = true
|
||||
inactiveOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
inactiveOverlayView.isHidden = true
|
||||
addSubview(inactiveOverlayView)
|
||||
flashOverlayView.wantsLayer = true
|
||||
flashOverlayView.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
flashOverlayView.layer?.masksToBounds = false
|
||||
|
|
@ -2854,6 +2898,7 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
surfaceView.frame.size = targetSize
|
||||
surfaceView.pushTargetSurfaceSize(targetSize)
|
||||
documentView.frame.size.width = scrollView.bounds.width
|
||||
inactiveOverlayView.frame = bounds
|
||||
flashOverlayView.frame = bounds
|
||||
updateFlashPath()
|
||||
synchronizeScrollView()
|
||||
|
|
@ -2903,6 +2948,15 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func setInactiveOverlay(color: NSColor, opacity: CGFloat, visible: Bool) {
|
||||
let clampedOpacity = max(0, min(1, opacity))
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
inactiveOverlayView.layer?.backgroundColor = color.withAlphaComponent(clampedOpacity).cgColor
|
||||
inactiveOverlayView.isHidden = !(visible && clampedOpacity > 0.0001)
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
func triggerFlash() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
|
|
@ -2929,7 +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,
|
||||
|
|
@ -2942,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,
|
||||
|
|
@ -2952,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")")
|
||||
|
|
@ -2986,6 +3070,13 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
surfaceView.debugRegisteredDropTypes()
|
||||
}
|
||||
|
||||
func debugInactiveOverlayState() -> (isHidden: Bool, alpha: CGFloat) {
|
||||
(
|
||||
inactiveOverlayView.isHidden,
|
||||
inactiveOverlayView.layer?.backgroundColor.flatMap { NSColor(cgColor: $0)?.alphaComponent } ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
/// Handle file/URL drops, forwarding to the terminal as shell-escaped paths.
|
||||
|
|
@ -3001,6 +3092,11 @@ final class GhosttySurfaceScrollView: NSView {
|
|||
return true
|
||||
}
|
||||
|
||||
func terminalViewForDrop(at point: NSPoint) -> GhosttyNSView? {
|
||||
guard bounds.contains(point), !isHidden else { return nil }
|
||||
return surfaceView
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Sends a synthetic Ctrl+D key press directly to the surface view.
|
||||
/// This exercises the same key path as real keyboard input (ghostty_surface_key),
|
||||
|
|
@ -3574,30 +3670,53 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
let terminalSurface: TerminalSurface
|
||||
var isActive: Bool = true
|
||||
var isVisibleInUI: Bool = true
|
||||
var portalZPriority: Int = 0
|
||||
var showsInactiveOverlay: Bool = false
|
||||
var inactiveOverlayColor: NSColor = .clear
|
||||
var inactiveOverlayOpacity: Double = 0
|
||||
var reattachToken: UInt64 = 0
|
||||
var onFocus: ((UUID) -> Void)? = nil
|
||||
var onTriggerFlash: (() -> Void)? = nil
|
||||
|
||||
/// SwiftUI can create NSViewRepresentable containers that are not yet inserted into a
|
||||
/// window (or never inserted at all) during bonsplit structural updates. We must avoid
|
||||
/// re-parenting the hosted terminal view into an off-window container, since it can get
|
||||
/// "stuck" there and leave the visible terminal blank/frozen.
|
||||
private final class HostContainerView: NSView {
|
||||
var onDidMoveToWindow: (() -> Void)?
|
||||
var onGeometryChanged: (() -> Void)?
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
guard window != nil else { return }
|
||||
onDidMoveToWindow?()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func viewDidMoveToSuperview() {
|
||||
super.viewDidMoveToSuperview()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func setFrameOrigin(_ newOrigin: NSPoint) {
|
||||
super.setFrameOrigin(newOrigin)
|
||||
onGeometryChanged?()
|
||||
}
|
||||
|
||||
override func setFrameSize(_ newSize: NSSize) {
|
||||
super.setFrameSize(newSize)
|
||||
onGeometryChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
var constraints: [NSLayoutConstraint] = []
|
||||
var attachGeneration: Int = 0
|
||||
// Track the latest desired state so attach retries can re-apply focus after re-parenting.
|
||||
var desiredIsActive: Bool = true
|
||||
var desiredIsVisibleInUI: Bool = true
|
||||
var desiredPortalZPriority: Int = 0
|
||||
var lastBoundHostId: ObjectIdentifier?
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
|
|
@ -3606,111 +3725,130 @@ struct GhosttyTerminalView: NSViewRepresentable {
|
|||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let container = HostContainerView()
|
||||
container.wantsLayer = true
|
||||
container.wantsLayer = false
|
||||
return container
|
||||
}
|
||||
|
||||
private static func attachHostedView(_ hostedView: GhosttySurfaceScrollView, to host: NSView, coordinator: Coordinator) {
|
||||
// Avoid implicit animations during reparenting and constraint updates. Even a single
|
||||
// CoreAnimation scale/bounds animation can produce a 1-frame "blank" or stretched
|
||||
// compositor frame when the IOSurface-backed layer is resized or moved.
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0
|
||||
ctx.allowsImplicitAnimation = false
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
defer { CATransaction.commit() }
|
||||
|
||||
// Remove any stale content views in the host, but avoid unnecessarily removing
|
||||
// the hosted terminal view if it is already attached.
|
||||
for v in host.subviews where v !== hostedView {
|
||||
v.removeFromSuperview()
|
||||
}
|
||||
|
||||
if hostedView.superview !== host {
|
||||
hostedView.removeFromSuperview()
|
||||
host.addSubview(hostedView)
|
||||
}
|
||||
|
||||
hostedView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.deactivate(coordinator.constraints)
|
||||
coordinator.constraints = [
|
||||
hostedView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
|
||||
hostedView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
|
||||
hostedView.topAnchor.constraint(equalTo: host.topAnchor),
|
||||
hostedView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(coordinator.constraints)
|
||||
host.needsLayout = true
|
||||
host.layoutSubtreeIfNeeded()
|
||||
}
|
||||
|
||||
// Re-apply visible/active state after re-parenting so focus/occlusion requests run with
|
||||
// a valid window.
|
||||
// Without this, a focus attempt issued while the hosted view is off-window can time out,
|
||||
// leaving the visible terminal unfocused (keys appear to go to the wrong surface).
|
||||
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
||||
hostedView.setActive(coordinator.desiredIsActive)
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
let hostedView = terminalSurface.hostedView
|
||||
context.coordinator.desiredIsActive = isActive
|
||||
context.coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||
let coordinator = context.coordinator
|
||||
#if DEBUG
|
||||
let previousDesiredIsActive = coordinator.desiredIsActive
|
||||
#endif
|
||||
let previousDesiredIsVisibleInUI = coordinator.desiredIsVisibleInUI
|
||||
let previousDesiredPortalZPriority = coordinator.desiredPortalZPriority
|
||||
coordinator.desiredIsActive = isActive
|
||||
coordinator.desiredIsVisibleInUI = isVisibleInUI
|
||||
coordinator.desiredPortalZPriority = portalZPriority
|
||||
coordinator.hostedView = hostedView
|
||||
#if DEBUG
|
||||
if previousDesiredIsActive != isActive ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority {
|
||||
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)"
|
||||
)
|
||||
} else {
|
||||
dlog(
|
||||
"ws.swiftui.update id=none surface=\(terminalSurface.id.uuidString.prefix(5)) " +
|
||||
"visible=\(isVisibleInUI ? 1 : 0) active=\(isActive ? 1 : 0) z=\(portalZPriority)"
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Keep the surface lifecycle and handlers updated even if we defer re-parenting.
|
||||
hostedView.attachSurface(terminalSurface)
|
||||
hostedView.setVisibleInUI(isVisibleInUI)
|
||||
hostedView.setActive(isActive)
|
||||
hostedView.setInactiveOverlay(
|
||||
color: inactiveOverlayColor,
|
||||
opacity: CGFloat(inactiveOverlayOpacity),
|
||||
visible: showsInactiveOverlay
|
||||
)
|
||||
hostedView.setFocusHandler { onFocus?(terminalSurface.id) }
|
||||
hostedView.setTriggerFlashHandler(onTriggerFlash)
|
||||
|
||||
if hostedView.superview !== nsView {
|
||||
context.coordinator.attachGeneration += 1
|
||||
let generation = context.coordinator.attachGeneration
|
||||
coordinator.attachGeneration += 1
|
||||
let generation = coordinator.attachGeneration
|
||||
|
||||
// If this container isn't in a window yet, defer attaching until it is.
|
||||
// Importantly: do NOT detach the hosted view from its current superview
|
||||
// until we have a valid window, otherwise it can disappear and become
|
||||
// "stuck" in an off-window container.
|
||||
if let host = nsView as? HostContainerView {
|
||||
host.onDidMoveToWindow = { [weak coordinator = context.coordinator, weak host, weak hostedView] in
|
||||
guard let coordinator, coordinator.attachGeneration == generation else { return }
|
||||
guard let host, let hostedView else { return }
|
||||
guard host.window != nil else { return }
|
||||
Self.attachHostedView(hostedView, to: host, coordinator: coordinator)
|
||||
if let host = nsView as? HostContainerView {
|
||||
host.onDidMoveToWindow = { [weak host, weak hostedView, weak coordinator] in
|
||||
guard let host, let hostedView, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard host.window != nil else { return }
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastBoundHostId = ObjectIdentifier(host)
|
||||
hostedView.setVisibleInUI(coordinator.desiredIsVisibleInUI)
|
||||
hostedView.setActive(coordinator.desiredIsActive)
|
||||
}
|
||||
host.onGeometryChanged = { [weak host, weak coordinator] in
|
||||
guard let host, let coordinator else { return }
|
||||
guard coordinator.attachGeneration == generation else { return }
|
||||
guard coordinator.lastBoundHostId == ObjectIdentifier(host) else { return }
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
|
||||
if host.window != nil {
|
||||
let hostId = ObjectIdentifier(host)
|
||||
let shouldBindNow =
|
||||
coordinator.lastBoundHostId != hostId ||
|
||||
hostedView.superview == nil ||
|
||||
previousDesiredIsVisibleInUI != isVisibleInUI ||
|
||||
previousDesiredPortalZPriority != portalZPriority
|
||||
if shouldBindNow {
|
||||
TerminalWindowPortalRegistry.bind(
|
||||
hostedView: hostedView,
|
||||
to: host,
|
||||
visibleInUI: coordinator.desiredIsVisibleInUI,
|
||||
zPriority: coordinator.desiredPortalZPriority
|
||||
)
|
||||
coordinator.lastBoundHostId = hostId
|
||||
}
|
||||
}
|
||||
|
||||
if nsView.window != nil {
|
||||
Self.attachHostedView(hostedView, to: nsView, coordinator: context.coordinator)
|
||||
}
|
||||
} else {
|
||||
context.coordinator.attachGeneration += 1
|
||||
if let host = nsView as? HostContainerView {
|
||||
host.onDidMoveToWindow = nil
|
||||
TerminalWindowPortalRegistry.synchronizeForAnchor(host)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
|
||||
coordinator.attachGeneration += 1
|
||||
|
||||
NSLayoutConstraint.deactivate(coordinator.constraints)
|
||||
coordinator.constraints.removeAll()
|
||||
coordinator.desiredIsActive = false
|
||||
coordinator.desiredIsVisibleInUI = false
|
||||
coordinator.desiredPortalZPriority = 0
|
||||
coordinator.lastBoundHostId = nil
|
||||
#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
|
||||
host.onGeometryChanged = nil
|
||||
}
|
||||
|
||||
// Avoid proactively detaching the hosted terminal view during SwiftUI structural updates.
|
||||
// When bonsplit rearranges panes, SwiftUI can dismantle the "old" container before the
|
||||
// "new" container has re-parented the hosted view; removing it here creates a visible
|
||||
// transient blank (and can strand the view off-window if the re-attach is missed).
|
||||
let hasHostedTerminal = nsView.subviews.contains(where: { $0 is GhosttySurfaceScrollView })
|
||||
if !hasHostedTerminal {
|
||||
nsView.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
coordinator.hostedView?.setVisibleInUI(false)
|
||||
coordinator.hostedView?.setActive(false)
|
||||
coordinator.hostedView?.setInactiveOverlay(color: .clear, opacity: 0, visible: false)
|
||||
coordinator.hostedView = nil
|
||||
|
||||
nsView.subviews.forEach { $0.removeFromSuperview() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ struct PanelContentView: View {
|
|||
let isFocused: Bool
|
||||
let isSelectedInPane: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let isSplit: Bool
|
||||
let appearance: PanelAppearance
|
||||
let notificationStore: TerminalNotificationStore
|
||||
|
|
@ -22,6 +23,7 @@ struct PanelContentView: View {
|
|||
panel: terminalPanel,
|
||||
isFocused: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: portalPriority,
|
||||
isSplit: isSplit,
|
||||
appearance: appearance,
|
||||
notificationStore: notificationStore,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import SwiftUI
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
/// View for rendering a terminal panel
|
||||
struct TerminalPanelView: View {
|
||||
@ObservedObject var panel: TerminalPanel
|
||||
let isFocused: Bool
|
||||
let isVisibleInUI: Bool
|
||||
let portalPriority: Int
|
||||
let isSplit: Bool
|
||||
let appearance: PanelAppearance
|
||||
let notificationStore: TerminalNotificationStore
|
||||
|
|
@ -18,6 +20,10 @@ struct TerminalPanelView: View {
|
|||
terminalSurface: panel.surface,
|
||||
isActive: isFocused,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalZPriority: portalPriority,
|
||||
showsInactiveOverlay: isSplit && !isFocused,
|
||||
inactiveOverlayColor: appearance.unfocusedOverlayNSColor,
|
||||
inactiveOverlayOpacity: appearance.unfocusedOverlayOpacity,
|
||||
reattachToken: panel.viewReattachToken,
|
||||
onFocus: { _ in onFocus() },
|
||||
onTriggerFlash: onTriggerFlash
|
||||
|
|
@ -27,14 +33,6 @@ struct TerminalPanelView: View {
|
|||
.id(panel.id)
|
||||
.background(Color.clear)
|
||||
|
||||
// Unfocused overlay
|
||||
if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 {
|
||||
Rectangle()
|
||||
.fill(appearance.unfocusedOverlayColor)
|
||||
.opacity(appearance.unfocusedOverlayOpacity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
|
||||
// Unread notification indicator
|
||||
if notificationStore.hasUnreadNotification(forTabId: panel.workspaceId, surfaceId: panel.id) {
|
||||
Rectangle()
|
||||
|
|
@ -62,13 +60,13 @@ struct TerminalPanelView: View {
|
|||
/// Shared appearance settings for panels
|
||||
struct PanelAppearance {
|
||||
let dividerColor: Color
|
||||
let unfocusedOverlayColor: Color
|
||||
let unfocusedOverlayNSColor: NSColor
|
||||
let unfocusedOverlayOpacity: Double
|
||||
|
||||
static func fromConfig(_ config: GhosttyConfig) -> PanelAppearance {
|
||||
PanelAppearance(
|
||||
dividerColor: Color(nsColor: config.resolvedSplitDividerColor),
|
||||
unfocusedOverlayColor: Color(nsColor: config.unfocusedSplitOverlayFill),
|
||||
unfocusedOverlayNSColor: config.unfocusedSplitOverlayFill,
|
||||
unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -6291,6 +6291,10 @@ class TerminalController {
|
|||
guard let parsed = parseShortcutCombo(combo) else {
|
||||
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
|
||||
}
|
||||
|
||||
// Stamp at socket-handler arrival so event.timestamp includes any wait
|
||||
// before the main-thread event dispatch.
|
||||
let requestTimestamp = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
var result = "ERROR: Failed to create event"
|
||||
DispatchQueue.main.sync {
|
||||
|
|
@ -6305,11 +6309,11 @@ class TerminalController {
|
|||
targetWindow.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
let windowNumber = (NSApp.keyWindow ?? targetWindow)?.windowNumber ?? 0
|
||||
guard let event = NSEvent.keyEvent(
|
||||
guard let keyDownEvent = NSEvent.keyEvent(
|
||||
with: .keyDown,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
timestamp: requestTimestamp,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
|
|
@ -6320,14 +6324,29 @@ class TerminalController {
|
|||
result = "ERROR: NSEvent.keyEvent returned nil"
|
||||
return
|
||||
}
|
||||
let keyUpEvent = NSEvent.keyEvent(
|
||||
with: .keyUp,
|
||||
location: .zero,
|
||||
modifierFlags: parsed.modifierFlags,
|
||||
timestamp: requestTimestamp + 0.0001,
|
||||
windowNumber: windowNumber,
|
||||
context: nil,
|
||||
characters: parsed.characters,
|
||||
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
|
||||
isARepeat: false,
|
||||
keyCode: parsed.keyCode
|
||||
)
|
||||
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
|
||||
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
|
||||
// normal responder chain for plain typing.
|
||||
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: event) {
|
||||
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
|
||||
result = "OK"
|
||||
return
|
||||
}
|
||||
NSApp.sendEvent(event)
|
||||
NSApp.sendEvent(keyDownEvent)
|
||||
if let keyUpEvent {
|
||||
NSApp.sendEvent(keyUpEvent)
|
||||
}
|
||||
result = "OK"
|
||||
}
|
||||
return result
|
||||
|
|
@ -6447,34 +6466,20 @@ class TerminalController {
|
|||
let contentView = window.contentView,
|
||||
let themeFrame = contentView.superview else { return }
|
||||
|
||||
// Compute the point in contentView's own coordinate system.
|
||||
// NSHostingView is flipped: (0,0) = top-left, matching our API.
|
||||
let contentPoint = NSPoint(
|
||||
x: contentView.bounds.width * nx,
|
||||
y: contentView.bounds.height * ny
|
||||
// Convert normalized top-left coordinates into a window point.
|
||||
let pointInTheme = NSPoint(
|
||||
x: contentView.frame.minX + (contentView.bounds.width * nx),
|
||||
y: contentView.frame.maxY - (contentView.bounds.height * ny)
|
||||
)
|
||||
let windowPoint = themeFrame.convert(pointInTheme, to: nil)
|
||||
|
||||
// hitTest expects the point in the receiver's superview's (themeFrame's)
|
||||
// coordinate system. Use convert to handle the coordinate transform.
|
||||
let hitPoint = contentView.convert(contentPoint, to: themeFrame)
|
||||
|
||||
// Temporarily hide the overlay so it doesn't intercept the hit test.
|
||||
let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView
|
||||
overlay?.isHidden = true
|
||||
|
||||
let hitView = contentView.hitTest(hitPoint)
|
||||
|
||||
overlay?.isHidden = false
|
||||
|
||||
var current: NSView? = hitView
|
||||
while let view = current {
|
||||
if let terminal = view as? GhosttyNSView,
|
||||
let surfaceId = terminal.terminalSurface?.id {
|
||||
result = surfaceId.uuidString.uppercased()
|
||||
return
|
||||
}
|
||||
current = view.superview
|
||||
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView,
|
||||
let terminal = overlay.terminalUnderPoint(windowPoint),
|
||||
let surfaceId = terminal.terminalSurface?.id {
|
||||
result = surfaceId.uuidString.uppercased()
|
||||
return
|
||||
}
|
||||
|
||||
result = "none"
|
||||
}
|
||||
return result
|
||||
|
|
@ -9205,6 +9210,25 @@ class TerminalController {
|
|||
return "OK Refreshed \(refreshedCount) surfaces"
|
||||
}
|
||||
|
||||
private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int {
|
||||
var depth = 0
|
||||
var current: NSView? = view
|
||||
while let v = current, depth < maxDepth {
|
||||
current = v.superview
|
||||
depth += 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
private func isPortalHosted(_ view: NSView) -> Bool {
|
||||
var current: NSView? = view
|
||||
while let v = current {
|
||||
if v is WindowTerminalHostView { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func surfaceHealth(_ tabArg: String) -> String {
|
||||
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
|
||||
var result = ""
|
||||
|
|
@ -9219,7 +9243,9 @@ class TerminalController {
|
|||
let type = panel.panelType.rawValue
|
||||
if let tp = panel as? TerminalPanel {
|
||||
let inWindow = tp.surface.isViewInWindow
|
||||
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"
|
||||
let portalHosted = isPortalHosted(tp.hostedView)
|
||||
let depth = viewDepth(of: tp.hostedView)
|
||||
return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)"
|
||||
} else if let bp = panel as? BrowserPanel {
|
||||
let inWindow = bp.webView.window != nil
|
||||
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"
|
||||
|
|
|
|||
412
Sources/TerminalWindowPortal.swift
Normal file
412
Sources/TerminalWindowPortal.swift
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import AppKit
|
||||
import ObjectiveC
|
||||
|
||||
private var cmuxWindowTerminalPortalKey: UInt8 = 0
|
||||
private var cmuxWindowTerminalPortalCloseObserverKey: UInt8 = 0
|
||||
|
||||
final class WindowTerminalHostView: NSView {
|
||||
override var isOpaque: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let hitView = super.hitTest(point)
|
||||
return hitView === self ? nil : hitView
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowTerminalPortal: NSObject {
|
||||
private weak var window: NSWindow?
|
||||
private let hostView = WindowTerminalHostView(frame: .zero)
|
||||
private weak var installedContainerView: NSView?
|
||||
private weak var installedReferenceView: NSView?
|
||||
private var installConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
private struct Entry {
|
||||
weak var hostedView: GhosttySurfaceScrollView?
|
||||
weak var anchorView: NSView?
|
||||
var visibleInUI: Bool
|
||||
var zPriority: Int
|
||||
}
|
||||
|
||||
private var entriesByHostedId: [ObjectIdentifier: Entry] = [:]
|
||||
private var hostedByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
init(window: NSWindow) {
|
||||
self.window = window
|
||||
super.init()
|
||||
hostView.wantsLayer = false
|
||||
hostView.translatesAutoresizingMaskIntoConstraints = false
|
||||
_ = ensureInstalled()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func ensureInstalled() -> Bool {
|
||||
guard let window else { return false }
|
||||
guard let (container, reference) = installationTarget(for: window) else { return false }
|
||||
|
||||
if hostView.superview !== container ||
|
||||
installedContainerView !== container ||
|
||||
installedReferenceView !== reference {
|
||||
NSLayoutConstraint.deactivate(installConstraints)
|
||||
installConstraints.removeAll()
|
||||
|
||||
hostView.removeFromSuperview()
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
|
||||
installConstraints = [
|
||||
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
|
||||
hostView.trailingAnchor.constraint(equalTo: reference.trailingAnchor),
|
||||
hostView.topAnchor.constraint(equalTo: reference.topAnchor),
|
||||
hostView.bottomAnchor.constraint(equalTo: reference.bottomAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(installConstraints)
|
||||
installedContainerView = container
|
||||
installedReferenceView = reference
|
||||
} else if !Self.isView(hostView, above: reference, in: container) {
|
||||
container.addSubview(hostView, positioned: .above, relativeTo: reference)
|
||||
}
|
||||
|
||||
// Keep the drag/mouse forwarding overlay above portal-hosted terminal views.
|
||||
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView,
|
||||
overlay.superview === container,
|
||||
!Self.isView(overlay, above: hostView, in: container) {
|
||||
container.addSubview(overlay, positioned: .above, relativeTo: hostView)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
|
||||
guard let contentView = window.contentView else { return nil }
|
||||
|
||||
// If NSGlassEffectView wraps the original content view, install inside the glass view
|
||||
// so terminals are above the glass background but below SwiftUI content.
|
||||
if contentView.className == "NSGlassEffectView",
|
||||
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
|
||||
return (contentView, foreground)
|
||||
}
|
||||
|
||||
guard let themeFrame = contentView.superview else { return nil }
|
||||
return (themeFrame, contentView)
|
||||
}
|
||||
|
||||
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
|
||||
if view.isHidden { return true }
|
||||
var current = view.superview
|
||||
while let v = current {
|
||||
if v.isHidden { return true }
|
||||
current = v.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
|
||||
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
|
||||
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
|
||||
abs(lhs.size.width - rhs.size.width) <= epsilon &&
|
||||
abs(lhs.size.height - rhs.size.height) <= epsilon
|
||||
}
|
||||
|
||||
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
|
||||
guard let viewIndex = container.subviews.firstIndex(of: view),
|
||||
let referenceIndex = container.subviews.firstIndex(of: reference) else {
|
||||
return false
|
||||
}
|
||||
return viewIndex > referenceIndex
|
||||
}
|
||||
|
||||
func detachHostedView(withId hostedId: ObjectIdentifier) {
|
||||
guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return }
|
||||
if let anchor = entry.anchorView {
|
||||
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
|
||||
}
|
||||
if let hostedView = entry.hostedView, hostedView.superview === hostView {
|
||||
hostedView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard ensureInstalled() else { return }
|
||||
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
let anchorId = ObjectIdentifier(anchorView)
|
||||
let previousEntry = entriesByHostedId[hostedId]
|
||||
|
||||
if let previousHostedId = hostedByAnchorId[anchorId], previousHostedId != hostedId {
|
||||
detachHostedView(withId: previousHostedId)
|
||||
}
|
||||
|
||||
if let oldEntry = entriesByHostedId[hostedId],
|
||||
let oldAnchor = oldEntry.anchorView,
|
||||
oldAnchor !== anchorView {
|
||||
hostedByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
|
||||
}
|
||||
|
||||
hostedByAnchorId[anchorId] = hostedId
|
||||
entriesByHostedId[hostedId] = Entry(
|
||||
hostedView: hostedView,
|
||||
anchorView: anchorView,
|
||||
visibleInUI: visibleInUI,
|
||||
zPriority: zPriority
|
||||
)
|
||||
|
||||
let didChangeAnchor: Bool = {
|
||||
guard let previousAnchor = previousEntry?.anchorView else { return true }
|
||||
return previousAnchor !== anchorView
|
||||
}()
|
||||
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
|
||||
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
|
||||
|
||||
if hostedView.superview !== hostView {
|
||||
hostedView.removeFromSuperview()
|
||||
hostView.addSubview(hostedView)
|
||||
} else if (didChangeAnchor || becameVisible || priorityIncreased), hostView.subviews.last !== hostedView {
|
||||
// Refresh z-order only on meaningful transitions. Reordering on every bind call
|
||||
// creates expensive reparent loops during SwiftUI update/layout churn.
|
||||
hostedView.removeFromSuperview()
|
||||
hostView.addSubview(hostedView)
|
||||
}
|
||||
|
||||
synchronizeHostedView(withId: hostedId)
|
||||
pruneDeadEntries()
|
||||
}
|
||||
|
||||
func synchronizeHostedViewForAnchor(_ anchorView: NSView) {
|
||||
pruneDeadEntries()
|
||||
guard let hostedId = hostedByAnchorId[ObjectIdentifier(anchorView)] else { return }
|
||||
synchronizeHostedView(withId: hostedId)
|
||||
}
|
||||
|
||||
private func synchronizeHostedView(withId hostedId: ObjectIdentifier) {
|
||||
guard ensureInstalled() else { return }
|
||||
guard let entry = entriesByHostedId[hostedId] else { return }
|
||||
guard let hostedView = entry.hostedView else {
|
||||
entriesByHostedId.removeValue(forKey: hostedId)
|
||||
return
|
||||
}
|
||||
guard let anchorView = entry.anchorView, let window else {
|
||||
hostedView.isHidden = true
|
||||
return
|
||||
}
|
||||
guard anchorView.window === window else {
|
||||
hostedView.isHidden = true
|
||||
return
|
||||
}
|
||||
|
||||
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
|
||||
let frameInHost = hostView.convert(frameInWindow, from: nil)
|
||||
let shouldHide =
|
||||
!entry.visibleInUI ||
|
||||
Self.isHiddenOrAncestorHidden(anchorView) ||
|
||||
frameInHost.width <= 1 ||
|
||||
frameInHost.height <= 1
|
||||
|
||||
let oldFrame = hostedView.frame
|
||||
if !Self.rectApproximatelyEqual(oldFrame, frameInHost) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
hostedView.frame = frameInHost
|
||||
CATransaction.commit()
|
||||
|
||||
if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 ||
|
||||
abs(oldFrame.size.height - frameInHost.size.height) > 0.5 {
|
||||
hostedView.reconcileGeometryNow()
|
||||
}
|
||||
}
|
||||
|
||||
if hostedView.isHidden != shouldHide {
|
||||
hostedView.isHidden = shouldHide
|
||||
}
|
||||
}
|
||||
|
||||
private func pruneDeadEntries() {
|
||||
let currentWindow = window
|
||||
let deadHostedIds = entriesByHostedId.compactMap { hostedId, entry -> ObjectIdentifier? in
|
||||
guard entry.hostedView != nil else { return hostedId }
|
||||
guard let anchor = entry.anchorView else { return hostedId }
|
||||
if anchor.window !== currentWindow || anchor.superview == nil {
|
||||
return hostedId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for hostedId in deadHostedIds {
|
||||
detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
||||
let validAnchorIds = Set(entriesByHostedId.compactMap { _, entry in
|
||||
entry.anchorView.map { ObjectIdentifier($0) }
|
||||
})
|
||||
hostedByAnchorId = hostedByAnchorId.filter { validAnchorIds.contains($0.key) }
|
||||
}
|
||||
|
||||
func hostedIds() -> Set<ObjectIdentifier> {
|
||||
Set(entriesByHostedId.keys)
|
||||
}
|
||||
|
||||
func tearDown() {
|
||||
for hostedId in Array(entriesByHostedId.keys) {
|
||||
detachHostedView(withId: hostedId)
|
||||
}
|
||||
NSLayoutConstraint.deactivate(installConstraints)
|
||||
installConstraints.removeAll()
|
||||
hostView.removeFromSuperview()
|
||||
installedContainerView = nil
|
||||
installedReferenceView = nil
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func debugEntryCount() -> Int {
|
||||
entriesByHostedId.count
|
||||
}
|
||||
|
||||
func debugHostedSubviewCount() -> Int {
|
||||
hostView.subviews.count
|
||||
}
|
||||
#endif
|
||||
|
||||
func viewAtWindowPoint(_ windowPoint: NSPoint) -> NSView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
|
||||
// Restrict hit-testing to currently mapped entries so stale detached views
|
||||
// can't steal file-drop/mouse routing.
|
||||
for subview in hostView.subviews.reversed() {
|
||||
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
guard entriesByHostedId[hostedId] != nil else { continue }
|
||||
guard !hostedView.isHidden else { continue }
|
||||
guard hostedView.frame.contains(point) else { continue }
|
||||
let localPoint = hostedView.convert(point, from: hostView)
|
||||
return hostedView.hitTest(localPoint) ?? hostedView
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func terminalViewAtWindowPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
|
||||
guard ensureInstalled() else { return nil }
|
||||
let point = hostView.convert(windowPoint, from: nil)
|
||||
|
||||
for subview in hostView.subviews.reversed() {
|
||||
guard let hostedView = subview as? GhosttySurfaceScrollView else { continue }
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
guard entriesByHostedId[hostedId] != nil else { continue }
|
||||
guard !hostedView.isHidden else { continue }
|
||||
guard hostedView.frame.contains(point) else { continue }
|
||||
let localPoint = hostedView.convert(point, from: hostView)
|
||||
if let terminal = hostedView.terminalViewForDrop(at: localPoint) {
|
||||
return terminal
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum TerminalWindowPortalRegistry {
|
||||
private static var portalsByWindowId: [ObjectIdentifier: WindowTerminalPortal] = [:]
|
||||
private static var hostedToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
|
||||
|
||||
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
|
||||
guard objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) == nil else { return }
|
||||
let windowId = ObjectIdentifier(window)
|
||||
let observer = NotificationCenter.default.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: window,
|
||||
queue: .main
|
||||
) { [weak window] _ in
|
||||
MainActor.assumeIsolated {
|
||||
if let window {
|
||||
removePortal(for: window)
|
||||
} else {
|
||||
removePortal(windowId: windowId, window: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&cmuxWindowTerminalPortalCloseObserverKey,
|
||||
observer,
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
|
||||
private static func removePortal(for window: NSWindow) {
|
||||
removePortal(windowId: ObjectIdentifier(window), window: window)
|
||||
}
|
||||
|
||||
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
|
||||
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
|
||||
portal.tearDown()
|
||||
}
|
||||
hostedToWindowId = hostedToWindowId.filter { $0.value != windowId }
|
||||
|
||||
guard let window else { return }
|
||||
if let observer = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey) {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
|
||||
}
|
||||
|
||||
private static func pruneHostedMappings(for windowId: ObjectIdentifier, validHostedIds: Set<ObjectIdentifier>) {
|
||||
hostedToWindowId = hostedToWindowId.filter { hostedId, mappedWindowId in
|
||||
mappedWindowId != windowId || validHostedIds.contains(hostedId)
|
||||
}
|
||||
}
|
||||
|
||||
private static func portal(for window: NSWindow) -> WindowTerminalPortal {
|
||||
if let existing = objc_getAssociatedObject(window, &cmuxWindowTerminalPortalKey) as? WindowTerminalPortal {
|
||||
portalsByWindowId[ObjectIdentifier(window)] = existing
|
||||
installWindowCloseObserverIfNeeded(for: window)
|
||||
return existing
|
||||
}
|
||||
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
objc_setAssociatedObject(window, &cmuxWindowTerminalPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
|
||||
portalsByWindowId[ObjectIdentifier(window)] = portal
|
||||
installWindowCloseObserverIfNeeded(for: window)
|
||||
return portal
|
||||
}
|
||||
|
||||
static func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
|
||||
guard let window = anchorView.window else { return }
|
||||
|
||||
let windowId = ObjectIdentifier(window)
|
||||
let hostedId = ObjectIdentifier(hostedView)
|
||||
let nextPortal = portal(for: window)
|
||||
|
||||
if let oldWindowId = hostedToWindowId[hostedId],
|
||||
oldWindowId != windowId {
|
||||
portalsByWindowId[oldWindowId]?.detachHostedView(withId: hostedId)
|
||||
}
|
||||
|
||||
nextPortal.bind(hostedView: hostedView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
|
||||
hostedToWindowId[hostedId] = windowId
|
||||
pruneHostedMappings(for: windowId, validHostedIds: nextPortal.hostedIds())
|
||||
}
|
||||
|
||||
static func synchronizeForAnchor(_ anchorView: NSView) {
|
||||
guard let window = anchorView.window else { return }
|
||||
let portal = portal(for: window)
|
||||
portal.synchronizeHostedViewForAnchor(anchorView)
|
||||
}
|
||||
|
||||
static func viewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> NSView? {
|
||||
let portal = portal(for: window)
|
||||
return portal.viewAtWindowPoint(windowPoint)
|
||||
}
|
||||
|
||||
static func terminalViewAtWindowPoint(_ windowPoint: NSPoint, in window: NSWindow) -> GhosttyNSView? {
|
||||
let portal = portal(for: window)
|
||||
return portal.terminalViewAtWindowPoint(windowPoint)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func debugPortalCount() -> Int {
|
||||
portalsByWindowId.count
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@ 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
|
||||
let workspacePortalPriority: Int
|
||||
@State private var config = GhosttyConfig.load()
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
|
||||
|
|
@ -16,7 +18,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,14 +37,15 @@ 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,
|
||||
isSelectedInPane: isSelectedInPane,
|
||||
isVisibleInUI: isVisibleInUI,
|
||||
portalPriority: workspacePortalPriority,
|
||||
isSplit: isSplit,
|
||||
appearance: appearance,
|
||||
notificationStore: notificationStore,
|
||||
|
|
@ -50,12 +53,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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
@ -1687,3 +1716,397 @@ final class MenuBarIconRendererTests: XCTestCase {
|
|||
XCTAssertEqual(withBadge.size.width, 18, accuracy: 0.001)
|
||||
}
|
||||
}
|
||||
|
||||
final class WorkspaceMountPolicyTests: XCTestCase {
|
||||
func testDefaultPolicyMountsOnlySelectedWorkspace() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: WorkspaceMountPolicy.maxMountedWorkspaces
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b])
|
||||
}
|
||||
|
||||
func testSelectedWorkspaceMovesToFrontAndMountCountIsBounded() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let c = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b, c]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a, b, c],
|
||||
selected: c,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [c, a])
|
||||
}
|
||||
|
||||
func testMissingWorkspacesArePruned() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [b, a],
|
||||
selected: nil,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: [a],
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [a])
|
||||
}
|
||||
|
||||
func testSelectedWorkspaceIsInsertedWhenAbsentFromCurrentCache() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 2
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b, a])
|
||||
}
|
||||
|
||||
func testMaxMountedIsClampedToAtLeastOne() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a, b],
|
||||
selected: nil,
|
||||
pinnedIds: [],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: false,
|
||||
maxMounted: 0
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [a])
|
||||
}
|
||||
|
||||
func testCycleHotModeKeepsOnlySelectedWhenNoPinnedHandoff() {
|
||||
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])
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
func testCycleHotModeKeepsRetiringWorkspaceWhenPinned() {
|
||||
let a = UUID()
|
||||
let b = UUID()
|
||||
let orderedTabIds: [UUID] = [a, b]
|
||||
|
||||
let next = WorkspaceMountPolicy.nextMountedWorkspaceIds(
|
||||
current: [a],
|
||||
selected: b,
|
||||
pinnedIds: [a],
|
||||
orderedTabIds: orderedTabIds,
|
||||
isCycleHot: true,
|
||||
maxMounted: WorkspaceMountPolicy.maxMountedWorkspacesDuringCycle
|
||||
)
|
||||
|
||||
XCTAssertEqual(next, [b, a])
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowTerminalHostViewTests: XCTestCase {
|
||||
private final class CapturingView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
bounds.contains(point) ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
func testHostViewPassesThroughWhenNoTerminalSubviewIsHit() {
|
||||
let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||
|
||||
XCTAssertNil(host.hitTest(NSPoint(x: 10, y: 10)))
|
||||
}
|
||||
|
||||
func testHostViewReturnsSubviewWhenSubviewIsHit() {
|
||||
let host = WindowTerminalHostView(frame: NSRect(x: 0, y: 0, width: 200, height: 120))
|
||||
let child = CapturingView(frame: NSRect(x: 20, y: 15, width: 40, height: 30))
|
||||
host.addSubview(child)
|
||||
|
||||
XCTAssertTrue(host.hitTest(NSPoint(x: 25, y: 20)) === child)
|
||||
XCTAssertNil(host.hitTest(NSPoint(x: 150, y: 100)))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GhosttySurfaceOverlayTests: XCTestCase {
|
||||
func testInactiveOverlayVisibilityTracksRequestedState() {
|
||||
let hostedView = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
|
||||
)
|
||||
|
||||
hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: true)
|
||||
var state = hostedView.debugInactiveOverlayState()
|
||||
XCTAssertFalse(state.isHidden)
|
||||
XCTAssertEqual(state.alpha, 0.35, accuracy: 0.01)
|
||||
|
||||
hostedView.setInactiveOverlay(color: .black, opacity: 0.35, visible: false)
|
||||
state = hostedView.debugInactiveOverlayState()
|
||||
XCTAssertTrue(state.isHidden)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TerminalWindowPortalLifecycleTests: XCTestCase {
|
||||
func testPortalHostInstallsAboveContentViewForVisibility() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
_ = portal.viewAtWindowPoint(NSPoint(x: 1, y: 1))
|
||||
|
||||
guard let contentView = window.contentView,
|
||||
let container = contentView.superview else {
|
||||
XCTFail("Expected content container")
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowTerminalHostView }),
|
||||
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
|
||||
XCTFail("Expected host/content views in same container")
|
||||
return
|
||||
}
|
||||
|
||||
XCTAssertGreaterThan(
|
||||
hostIndex,
|
||||
contentIndex,
|
||||
"Portal host must remain above content view so portal-hosted terminals stay visible"
|
||||
)
|
||||
}
|
||||
|
||||
func testRegistryPrunesPortalWhenWindowCloses() {
|
||||
let baseline = TerminalWindowPortalRegistry.debugPortalCount()
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
_ = TerminalWindowPortalRegistry.viewAtWindowPoint(NSPoint(x: 1, y: 1), in: window)
|
||||
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline + 1)
|
||||
|
||||
NotificationCenter.default.post(name: NSWindow.willCloseNotification, object: window)
|
||||
XCTAssertEqual(TerminalWindowPortalRegistry.debugPortalCount(), baseline)
|
||||
}
|
||||
|
||||
func testPruneDeadEntriesDetachesAnchorlessHostedView() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let hosted1 = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
|
||||
)
|
||||
|
||||
var anchor1: NSView? = NSView(frame: NSRect(x: 20, y: 20, width: 120, height: 80))
|
||||
contentView.addSubview(anchor1!)
|
||||
portal.bind(hostedView: hosted1, to: anchor1!, visibleInUI: true)
|
||||
|
||||
anchor1?.removeFromSuperview()
|
||||
anchor1 = nil
|
||||
|
||||
let hosted2 = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 40, height: 30))
|
||||
)
|
||||
let anchor2 = NSView(frame: NSRect(x: 180, y: 20, width: 120, height: 80))
|
||||
contentView.addSubview(anchor2)
|
||||
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
|
||||
|
||||
XCTAssertEqual(portal.debugEntryCount(), 1, "Only the live anchored hosted view should remain tracked")
|
||||
XCTAssertEqual(portal.debugHostedSubviewCount(), 1, "Stale anchorless hosted views should be detached from hostView")
|
||||
}
|
||||
|
||||
func testTerminalViewAtWindowPointResolvesPortalHostedSurface() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = NSView(frame: NSRect(x: 40, y: 50, width: 200, height: 120))
|
||||
contentView.addSubview(anchor)
|
||||
|
||||
let hosted = GhosttySurfaceScrollView(
|
||||
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 100, height: 80))
|
||||
)
|
||||
portal.bind(hostedView: hosted, to: anchor, visibleInUI: true)
|
||||
|
||||
let center = NSPoint(x: anchor.bounds.midX, y: anchor.bounds.midY)
|
||||
let windowPoint = anchor.convert(center, to: nil)
|
||||
XCTAssertNotNil(
|
||||
portal.terminalViewAtWindowPoint(windowPoint),
|
||||
"Portal hit-testing should resolve the terminal view for Finder file drops"
|
||||
)
|
||||
}
|
||||
|
||||
func testVisibilityTransitionBringsHostedViewToFront() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
|
||||
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
|
||||
contentView.addSubview(anchor1)
|
||||
contentView.addSubview(anchor2)
|
||||
|
||||
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
|
||||
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
|
||||
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true)
|
||||
|
||||
let overlapInContent = NSPoint(x: 120, y: 100)
|
||||
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
|
||||
"Latest bind should be top-most before visibility transition"
|
||||
)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: false)
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
|
||||
"Becoming visible should refresh z-order for already-hosted view"
|
||||
)
|
||||
}
|
||||
|
||||
func testPriorityIncreaseBringsHostedViewToFrontWithoutVisibilityToggle() {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let portal = WindowTerminalPortal(window: window)
|
||||
guard let contentView = window.contentView else {
|
||||
XCTFail("Expected content view")
|
||||
return
|
||||
}
|
||||
|
||||
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 220, height: 180))
|
||||
let anchor2 = NSView(frame: NSRect(x: 80, y: 60, width: 220, height: 180))
|
||||
contentView.addSubview(anchor1)
|
||||
contentView.addSubview(anchor2)
|
||||
|
||||
let terminal1 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted1 = GhosttySurfaceScrollView(surfaceView: terminal1)
|
||||
let terminal2 = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 120, height: 80))
|
||||
let hosted2 = GhosttySurfaceScrollView(surfaceView: terminal2)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 1)
|
||||
portal.bind(hostedView: hosted2, to: anchor2, visibleInUI: true, zPriority: 2)
|
||||
|
||||
let overlapInContent = NSPoint(x: 120, y: 100)
|
||||
let overlapInWindow = contentView.convert(overlapInContent, to: nil)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal2,
|
||||
"Higher-priority terminal should initially be top-most"
|
||||
)
|
||||
|
||||
portal.bind(hostedView: hosted1, to: anchor1, visibleInUI: true, zPriority: 2)
|
||||
XCTAssertTrue(
|
||||
portal.terminalViewAtWindowPoint(overlapInWindow) === terminal1,
|
||||
"Promoting z-priority should bring an already-visible terminal to front"
|
||||
)
|
||||
}
|
||||
}
|
||||
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" \
|
||||
"$@"
|
||||
|
|
@ -997,7 +997,8 @@ class cmux:
|
|||
def surface_health(self, workspace: Union[str, int, None] = None) -> List[dict]:
|
||||
"""
|
||||
Check view health of all surfaces in a workspace.
|
||||
Returns list of dicts with keys: index, id, type, in_window.
|
||||
Returns list of dicts with keys: index, id, type, in_window, plus any
|
||||
extra key=value fields returned by the daemon.
|
||||
"""
|
||||
arg = "" if workspace is None else str(workspace)
|
||||
response = self._send_command(f"surface_health {arg}".rstrip())
|
||||
|
|
@ -1013,14 +1014,36 @@ class cmux:
|
|||
continue
|
||||
index = int(parts[0].rstrip(":"))
|
||||
surface_id = parts[1]
|
||||
panel_type = parts[2].split("=", 1)[1] if "=" in parts[2] else "unknown"
|
||||
in_window = parts[3].split("=", 1)[1] == "true" if "=" in parts[3] else False
|
||||
surfaces.append({
|
||||
kv: dict[str, str] = {}
|
||||
for token in parts[2:]:
|
||||
if "=" not in token:
|
||||
continue
|
||||
key, value = token.split("=", 1)
|
||||
kv[key] = value
|
||||
|
||||
panel_type = kv.get("type", "unknown")
|
||||
in_window = kv.get("in_window", "false") == "true"
|
||||
|
||||
row: dict = {
|
||||
"index": index,
|
||||
"id": surface_id,
|
||||
"type": panel_type,
|
||||
"in_window": in_window,
|
||||
})
|
||||
}
|
||||
|
||||
for key, value in kv.items():
|
||||
if key in {"type", "in_window"}:
|
||||
continue
|
||||
if value == "true":
|
||||
row[key] = True
|
||||
elif value == "false":
|
||||
row[key] = False
|
||||
elif value.isdigit() or (value.startswith("-") and value[1:].isdigit()):
|
||||
row[key] = int(value)
|
||||
else:
|
||||
row[key] = value
|
||||
|
||||
surfaces.append(row)
|
||||
return surfaces
|
||||
|
||||
|
||||
|
|
|
|||
50
tests/test_terminal_portal_hosting.py
Normal file
50
tests/test_terminal_portal_hosting.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Regression: terminal views should be portal-hosted near the window root.
|
||||
|
||||
This catches regressions where terminal NSViews are reattached deep inside the SwiftUI
|
||||
hierarchy, which increases Core Animation commit traversal depth and input latency.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from cmux import cmux, cmuxError
|
||||
|
||||
|
||||
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with cmux(SOCKET_PATH) as c:
|
||||
c.activate_app()
|
||||
|
||||
c.new_workspace()
|
||||
time.sleep(0.2)
|
||||
c.new_split("right")
|
||||
time.sleep(0.8)
|
||||
|
||||
health = c.surface_health()
|
||||
terminals = [row for row in health if row.get("type") == "terminal"]
|
||||
if len(terminals) < 2:
|
||||
raise cmuxError(f"expected >=2 terminal surfaces after split, got={terminals}")
|
||||
|
||||
for row in terminals:
|
||||
if not row.get("in_window", False):
|
||||
raise cmuxError(f"terminal not attached to window: {row}")
|
||||
if row.get("portal") is not True:
|
||||
raise cmuxError(f"terminal is not portal-hosted: {row}")
|
||||
depth = row.get("view_depth")
|
||||
if not isinstance(depth, int):
|
||||
raise cmuxError(f"missing view_depth in surface_health: {row}")
|
||||
if depth > 8:
|
||||
raise cmuxError(f"terminal view depth too deep ({depth}): {row}")
|
||||
|
||||
print("PASS: terminal surfaces are portal-hosted with shallow view depth")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue