-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Sync ghostty fork with upstream main #1484
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 28 commits
f34f8c1
d768fe6
2667d22
fcdeac5
0609f57
6105c98
d937c39
605fe5c
306b161
40132e5
d33e98e
bfeb362
8ecb149
f363936
09ad5a2
b2f99cd
1b49b41
835ed70
f9fceed
c3df2e2
922b961
ede6ea4
bfd9294
12a8769
8752400
06da3d9
d14c7de
2bb008d
85e0303
ed9da3e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1769,15 +1769,18 @@ func startOrFocusTerminalSearch( | |
| return true | ||
| } | ||
|
|
||
| terminalSurface.requestGhosttySearchActivation(.startSearch) | ||
| if terminalSurface.performBindingAction("start_search") { | ||
| DispatchQueue.main.async { [weak terminalSurface] in | ||
| guard let terminalSurface, terminalSurface.searchState == nil else { return } | ||
| terminalSurface.searchState = TerminalSurface.SearchState() | ||
| searchFocusNotifier(terminalSurface) | ||
| DispatchQueue.main.async { | ||
| cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded( | ||
| terminalSurface, | ||
| searchFocusNotifier: searchFocusNotifier | ||
| ) | ||
| } | ||
| return true | ||
| } | ||
|
|
||
| terminalSurface.clearGhosttySearchActivationRequest() | ||
| terminalSurface.searchState = TerminalSurface.SearchState() | ||
| searchFocusNotifier(terminalSurface) | ||
| return true | ||
|
|
@@ -2076,6 +2079,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| private var bonsplitTabDragUITestRecorder: DispatchSourceTimer? | ||
| private var gotoSplitUITestObservers: [NSObjectProtocol] = [] | ||
| private var didSetupMultiWindowNotificationsUITest = false | ||
| private var didSetupAutomationSocketStressUITest = false | ||
| private var didSetupDisplayResolutionUITestDiagnostics = false | ||
| private var displayResolutionUITestObservers: [NSObjectProtocol] = [] | ||
| private struct UITestRenderDiagnosticsSnapshot { | ||
|
|
@@ -2459,6 +2463,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| return object | ||
| } | ||
|
|
||
| private func updateUITestDiagnosticsIfNeeded(_ updates: [String: String]) { | ||
| let env = ProcessInfo.processInfo.environment | ||
| guard let path = env["CMUX_UI_TEST_DIAGNOSTICS_PATH"], !path.isEmpty else { return } | ||
|
|
||
| var payload = loadUITestDiagnostics(at: path) | ||
| for (key, value) in updates { | ||
| payload[key] = value | ||
| } | ||
|
Comment on lines
+2470
to
+2473
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } | ||
| try? data.write(to: URL(fileURLWithPath: path), options: .atomic) | ||
| } | ||
|
|
||
| private func appendUITestSocketDiagnosticsIfNeeded( | ||
| _ payload: inout [String: String], | ||
| environment env: [String: String] | ||
|
|
@@ -2679,6 +2696,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| setupGotoSplitUITestIfNeeded() | ||
| setupBonsplitTabDragUITestIfNeeded() | ||
| setupMultiWindowNotificationsUITestIfNeeded() | ||
| setupAutomationSocketStressUITestIfNeeded(tabManager: tabManager) | ||
| setupDisplayResolutionUITestDiagnosticsIfNeeded() | ||
|
|
||
| // UI tests sometimes don't run SwiftUI `.onAppear` soon enough (or at all) on the VM. | ||
|
|
@@ -2733,6 +2751,189 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent | |
| } | ||
| } | ||
|
|
||
| private func setupAutomationSocketStressUITestIfNeeded(tabManager: TabManager) { | ||
| guard !didSetupAutomationSocketStressUITest else { return } | ||
| didSetupAutomationSocketStressUITest = true | ||
|
|
||
| let env = ProcessInfo.processInfo.environment | ||
| guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return } | ||
|
|
||
| updateUITestDiagnosticsIfNeeded([ | ||
| "automationSocketStressStatus": "waiting", | ||
| "automationSocketStressDone": "0", | ||
| "automationSocketStressIterationsCompleted": "0", | ||
| "automationSocketStressTrace": "scheduled", | ||
| ]) | ||
|
|
||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in | ||
| self?.runAutomationSocketStressUITestAttempt(tabManager: tabManager, remainingAttempts: 40) | ||
| } | ||
| } | ||
|
|
||
| private func runAutomationSocketStressUITestAttempt(tabManager: TabManager, remainingAttempts: Int) { | ||
| let env = ProcessInfo.processInfo.environment | ||
| guard env["CMUX_UI_TEST_AUTOMATION_SOCKET_STRESS"] == "1" else { return } | ||
|
|
||
| guard let config = socketListenerConfigurationIfEnabled() else { | ||
| finishAutomationSocketStressAttempt( | ||
| tabManager: tabManager, | ||
| remainingAttempts: remainingAttempts, | ||
| status: "waiting", | ||
| trace: ["socket_disabled"] | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| let socketPath = TerminalController.shared.activeSocketPath(preferredPath: config.path) | ||
| let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: socketPath) | ||
| let pingResponse = health.isHealthy | ||
| ? TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) | ||
| : nil | ||
|
|
||
| guard health.isHealthy, pingResponse == "PONG" else { | ||
| finishAutomationSocketStressAttempt( | ||
| tabManager: tabManager, | ||
| remainingAttempts: remainingAttempts, | ||
| status: "waiting", | ||
| trace: [ | ||
| "socket=\(socketPath)", | ||
| "isHealthy=\(health.isHealthy ? "1" : "0")", | ||
| "ping=\(pingResponse ?? "<nil>")", | ||
| ] | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| guard let target = resolvedAutomationSocketStressTarget(tabManager: tabManager) else { | ||
| finishAutomationSocketStressAttempt( | ||
| tabManager: tabManager, | ||
| remainingAttempts: remainingAttempts, | ||
| status: "waiting", | ||
| trace: [ | ||
| "socket=\(socketPath)", | ||
| "ping=PONG", | ||
| "target=<nil>", | ||
| ] | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| let workspaceId = target.workspaceId.uuidString | ||
| let surfaceId = target.surfaceId.uuidString | ||
| var trace: [String] = [ | ||
| "socket=\(socketPath)", | ||
| "workspace=\(workspaceId)", | ||
| "surface=\(surfaceId)", | ||
| "baseline.list=\(TerminalController.probeSocketCommand("list_surfaces \(workspaceId)", at: socketPath, timeout: 1.0) ?? "<nil>")", | ||
| ] | ||
|
|
||
| for iteration in 1...8 { | ||
| let pingBefore = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) | ||
| let sendResponse = TerminalController.probeSocketCommand( | ||
| "send_key_surface \(surfaceId) enter", | ||
| at: socketPath, | ||
| timeout: 2.0 | ||
| ) | ||
| let pingAfter = TerminalController.probeSocketCommand("ping", at: socketPath, timeout: 1.0) | ||
| let listResponse = TerminalController.probeSocketCommand( | ||
| "list_surfaces \(workspaceId)", | ||
| at: socketPath, | ||
| timeout: 2.0 | ||
| ) | ||
|
|
||
| trace.append( | ||
| "iteration\(iteration)=pingBefore:\(pingBefore ?? "<nil>"),send:\(sendResponse ?? "<nil>"),pingAfter:\(pingAfter ?? "<nil>"),list:\(listResponse ?? "<nil>")" | ||
| ) | ||
|
|
||
| guard pingBefore == "PONG", | ||
| sendResponse == "OK", | ||
| pingAfter == "PONG", | ||
| automationSocketStressListResponse(listResponse, containsSurface: surfaceId) else { | ||
| updateUITestDiagnosticsIfNeeded([ | ||
| "automationSocketStressStatus": "failed", | ||
| "automationSocketStressDone": "1", | ||
| "automationSocketStressIterationsCompleted": String(iteration - 1), | ||
| "automationSocketStressTrace": trace.joined(separator: " | "), | ||
| ]) | ||
| return | ||
| } | ||
| } | ||
|
|
||
| updateUITestDiagnosticsIfNeeded([ | ||
| "automationSocketStressStatus": "passed", | ||
| "automationSocketStressDone": "1", | ||
| "automationSocketStressIterationsCompleted": "8", | ||
| "automationSocketStressTrace": trace.joined(separator: " | "), | ||
| ]) | ||
| } | ||
|
|
||
| private func finishAutomationSocketStressAttempt( | ||
| tabManager: TabManager, | ||
| remainingAttempts: Int, | ||
| status: String, | ||
| trace: [String] | ||
| ) { | ||
| updateUITestDiagnosticsIfNeeded([ | ||
| "automationSocketStressStatus": status, | ||
| "automationSocketStressDone": "0", | ||
| "automationSocketStressIterationsCompleted": "0", | ||
| "automationSocketStressTrace": trace.joined(separator: " | "), | ||
| ]) | ||
|
|
||
| guard remainingAttempts > 1 else { | ||
| updateUITestDiagnosticsIfNeeded([ | ||
| "automationSocketStressStatus": "failed", | ||
| "automationSocketStressDone": "1", | ||
| "automationSocketStressIterationsCompleted": "0", | ||
| "automationSocketStressTrace": trace.joined(separator: " | "), | ||
| ]) | ||
| return | ||
| } | ||
|
|
||
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in | ||
| self?.runAutomationSocketStressUITestAttempt( | ||
| tabManager: tabManager, | ||
| remainingAttempts: remainingAttempts - 1 | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func resolvedAutomationSocketStressTarget(tabManager: TabManager) -> (workspaceId: UUID, surfaceId: UUID)? { | ||
| guard let workspaceId = tabManager.selectedTabId ?? tabManager.tabs.first?.id else { | ||
| return nil | ||
| } | ||
|
|
||
| if let surfaceId = tabManager.focusedPanelId(for: workspaceId) { | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
|
||
| return (workspaceId: workspaceId, surfaceId: surfaceId) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { | ||
| return nil | ||
| } | ||
|
|
||
| if let focusedTerminalPanel = workspace.focusedTerminalPanel { | ||
| return (workspaceId: workspaceId, surfaceId: focusedTerminalPanel.id) | ||
| } | ||
|
|
||
| if let inheritedTerminalPanel = workspace.terminalPanelForConfigInheritance() { | ||
| return (workspaceId: workspaceId, surfaceId: inheritedTerminalPanel.id) | ||
| } | ||
|
|
||
| guard let firstTerminalSurfaceId = workspace.panels.values | ||
| .compactMap({ ($0 as? TerminalPanel)?.id }) | ||
| .sorted(by: { $0.uuidString < $1.uuidString }) | ||
| .first else { | ||
| return nil | ||
| } | ||
|
|
||
| return (workspaceId: workspaceId, surfaceId: firstTerminalSurfaceId) | ||
| } | ||
|
|
||
| private func automationSocketStressListResponse(_ response: String?, containsSurface surfaceId: String) -> Bool { | ||
| guard let response, !response.isEmpty, response != "No surfaces" else { return false } | ||
| return response.contains(surfaceId) | ||
| } | ||
|
|
||
| private func setupDisplayResolutionUITestDiagnosticsIfNeeded() { | ||
| let env = ProcessInfo.processInfo.environment | ||
| guard env["CMUX_UI_TEST_DISPLAY_RENDER_STATS"] == "1" else { return } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -320,6 +320,44 @@ struct TitlebarLayerBackground: NSViewRepresentable { | |
| } | ||
| } | ||
|
|
||
| func cmuxMainWindowTitlebarOpacity( | ||
| backgroundOpacity: Double | ||
| ) -> CGFloat { | ||
| GhosttyBackgroundTheme.compositedChromeOpacity(backgroundOpacity) | ||
| } | ||
|
|
||
| func cmuxResolveGhosttyChromeConfig( | ||
| loadedConfig: GhosttyConfig, | ||
| runtimeBackgroundColor: NSColor, | ||
| runtimeBackgroundOpacity: Double | ||
| ) -> GhosttyConfig { | ||
| let runtimeOpacity = GhosttyBackgroundTheme.clampedOpacity(runtimeBackgroundOpacity) | ||
| let loadedOpacity = GhosttyBackgroundTheme.clampedOpacity(loadedConfig.backgroundOpacity) | ||
|
|
||
| // Newer Ghostty builds can drive the window background fully clear at runtime for | ||
| // translucent terminals. That clear backing color should not replace cmux chrome. | ||
| guard runtimeOpacity > 0.001 || loadedOpacity <= 0.001 else { | ||
| return loadedConfig | ||
| } | ||
|
|
||
| var resolved = loadedConfig | ||
| resolved.backgroundColor = runtimeBackgroundColor | ||
| resolved.backgroundOpacity = runtimeBackgroundOpacity | ||
| return resolved | ||
| } | ||
|
|
||
| func cmuxResolveGhosttyChromeConfig( | ||
| loadConfig: () -> GhosttyConfig = { GhosttyConfig.load() }, | ||
| runtimeBackgroundColor: () -> NSColor = { GhosttyApp.shared.defaultBackgroundColor }, | ||
| runtimeBackgroundOpacity: () -> Double = { GhosttyApp.shared.defaultBackgroundOpacity } | ||
| ) -> GhosttyConfig { | ||
| cmuxResolveGhosttyChromeConfig( | ||
| loadedConfig: loadConfig(), | ||
| runtimeBackgroundColor: runtimeBackgroundColor(), | ||
| runtimeBackgroundOpacity: runtimeBackgroundOpacity() | ||
| ) | ||
| } | ||
|
|
||
| final class SidebarState: ObservableObject { | ||
| @Published var isVisible: Bool | ||
| @Published var persistedWidth: CGFloat | ||
|
|
@@ -1457,6 +1495,7 @@ struct ContentView: View { | |
| @State private var workspaceHandoffFallbackTask: Task<Void, Never>? | ||
| @State private var didApplyUITestSidebarSelection = false | ||
| @State private var titlebarThemeGeneration: UInt64 = 0 | ||
| @State private var titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() | ||
| @State private var sidebarDraggedTabId: UUID? | ||
| @State private var titlebarTextUpdateCoalescer = NotificationBurstCoalescer(delay: 1.0 / 30.0) | ||
| @State private var sidebarResizerCursorReleaseWorkItem: DispatchWorkItem? | ||
|
|
@@ -2183,8 +2222,7 @@ struct ContentView: View { | |
| @State private var titlebarLeadingInset: CGFloat = 12 | ||
| private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } | ||
| private var fakeTitlebarTextColor: Color { | ||
| _ = titlebarThemeGeneration | ||
| let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor | ||
| let ghosttyBackground = titlebarThemeConfig.backgroundColor | ||
| return ghosttyBackground.isLightColor | ||
| ? Color.black.opacity(0.78) | ||
| : Color.white.opacity(0.82) | ||
|
|
@@ -2242,14 +2280,11 @@ struct ContentView: View { | |
| .frame(maxWidth: .infinity) | ||
| .contentShape(Rectangle()) | ||
| .background({ | ||
| // The terminal area has two stacked semi-transparent layers: the Bonsplit | ||
| // container chrome background plus Ghostty's own Metal-rendered background. | ||
| // Compute the effective composited opacity so the titlebar matches visually. | ||
| let alpha = CGFloat(GhosttyApp.shared.defaultBackgroundOpacity) | ||
| let effective = alpha >= 0.999 ? alpha : 1.0 - pow(1.0 - alpha, 2) | ||
| return TitlebarLayerBackground( | ||
| backgroundColor: GhosttyApp.shared.defaultBackgroundColor, | ||
| opacity: effective | ||
| backgroundColor: titlebarThemeConfig.backgroundColor, | ||
| opacity: cmuxMainWindowTitlebarOpacity( | ||
| backgroundOpacity: titlebarThemeConfig.backgroundOpacity | ||
| ) | ||
| ) | ||
| }()) | ||
| .overlay(alignment: .bottom) { | ||
|
|
@@ -2408,6 +2443,7 @@ struct ContentView: View { | |
| syncSidebarSelectedWorkspaceIds() | ||
| applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) | ||
| updateTitlebarText() | ||
| titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() | ||
|
|
||
| // Startup recovery (#399): if session restore or a race condition leaves the | ||
| // view in a broken state (empty tabs, no selection, unmounted workspaces), | ||
|
|
@@ -2529,9 +2565,10 @@ struct ContentView: View { | |
| }) | ||
|
|
||
| view = AnyView(view.onChange(of: titlebarThemeGeneration) { oldValue, newValue in | ||
| titlebarThemeConfig = cmuxResolveGhosttyChromeConfig() | ||
|
Comment on lines
2567
to
+2568
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Updating Useful? React with 👍 / 👎. |
||
| guard GhosttyApp.shared.backgroundLogEnabled else { return } | ||
| GhosttyApp.shared.logBackground( | ||
| "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" | ||
| "titlebar theme refresh applied oldGeneration=\(oldValue) generation=\(newValue) titlebarBg=\(titlebarThemeConfig.backgroundColor.hexString()) titlebarOpacity=\(String(format: "%.3f", titlebarThemeConfig.backgroundOpacity)) appBg=\(GhosttyApp.shared.defaultBackgroundColor.hexString()) appOpacity=\(String(format: "%.3f", GhosttyApp.shared.defaultBackgroundOpacity))" | ||
| ) | ||
| }) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid run-loop deferral for search fallback state initialization.
At Line 1743, forcing
cmuxApplyPendingGhosttyStartSearchFallbackIfNeeded(...)ontoDispatchQueue.main.asynccreates a timing gap where this function returnstruewhilesearchStatecan still benil. That can desync immediate search-active checks and overlay activation paths.Suggested fix
🤖 Prompt for AI Agents