From 2426590e9156ae813c5a1c06daeb2ca7e7e6cd18 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:50:10 -0800 Subject: [PATCH 1/9] Fix terminal focus when window loses key (#359) * Fix terminal blur when window loses key * Fix shortcut routing across multiple windows * Fix shortcut window detection when identifiers change --- Sources/AppDelegate.swift | 171 +++++++++++++++++- Sources/GhosttyTerminalView.swift | 9 +- Sources/cmuxApp.swift | 54 +++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 165 +++++++++++++++++ 4 files changed, 369 insertions(+), 30 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f6811ae8..87a25c04 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -592,6 +592,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent sidebarSelectionState: SidebarSelectionState ) { let key = ObjectIdentifier(window) + #if DEBUG + let priorManagerToken = debugManagerToken(self.tabManager) + #endif if let existing = mainWindowContexts[key] { existing.window = window } else { @@ -615,6 +618,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent commandPaletteSelectionByWindowId[windowId] = 0 commandPaletteSnapshotByWindowId[windowId] = .empty +#if DEBUG + dlog( + "mainWindow.register windowId=\(String(windowId.uuidString.prefix(8))) window={\(debugWindowToken(window))} manager=\(debugManagerToken(tabManager)) priorActiveMgr=\(priorManagerToken) \(debugShortcutRouteSnapshot())" + ) +#endif if window.isKeyWindow { setActiveMainWindow(window) } @@ -844,6 +852,117 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return mainWindowContexts[ObjectIdentifier(window)] } +#if DEBUG + private func debugManagerToken(_ manager: TabManager?) -> String { + guard let manager else { return "nil" } + return String(describing: Unmanaged.passUnretained(manager).toOpaque()) + } + + private func debugWindowToken(_ window: NSWindow?) -> String { + guard let window else { return "nil" } + let id = mainWindowId(for: window).map { String($0.uuidString.prefix(8)) } ?? "none" + let ident = window.identifier?.rawValue ?? "nil" + let shortIdent: String + if ident.count > 120 { + shortIdent = String(ident.prefix(120)) + "..." + } else { + shortIdent = ident + } + return "num=\(window.windowNumber) id=\(id) ident=\(shortIdent) key=\(window.isKeyWindow ? 1 : 0) main=\(window.isMainWindow ? 1 : 0)" + } + + private func debugContextToken(_ context: MainWindowContext?) -> String { + guard let context else { return "nil" } + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + let hasWindow = (context.window != nil || windowForMainWindowId(context.windowId) != nil) ? 1 : 0 + return "id=\(String(context.windowId.uuidString.prefix(8))) mgr=\(debugManagerToken(context.tabManager)) tabs=\(context.tabManager.tabs.count) selected=\(selected) hasWindow=\(hasWindow)" + } + + private func debugShortcutRouteSnapshot(event: NSEvent? = nil) -> String { + let activeManager = tabManager + let activeWindowId = activeManager.flatMap { windowId(for: $0) }.map { String($0.uuidString.prefix(8)) } ?? "nil" + let selectedWorkspace = activeManager?.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + + let contexts = mainWindowContexts.values + .map { context in + let marker = (activeManager != nil && context.tabManager === activeManager) ? "*" : "-" + let window = context.window ?? windowForMainWindowId(context.windowId) + let selected = context.tabManager.selectedTabId.map { String($0.uuidString.prefix(5)) } ?? "nil" + return "\(marker)\(String(context.windowId.uuidString.prefix(8))){mgr=\(debugManagerToken(context.tabManager)),win=\(window?.windowNumber ?? -1),key=\((window?.isKeyWindow ?? false) ? 1 : 0),main=\((window?.isMainWindow ?? false) ? 1 : 0),tabs=\(context.tabManager.tabs.count),selected=\(selected)}" + } + .sorted() + .joined(separator: ",") + + let eventWindowNumber = event.map { String($0.windowNumber) } ?? "nil" + let eventWindow = event?.window + return "eventWinNum=\(eventWindowNumber) eventWin={\(debugWindowToken(eventWindow))} keyWin={\(debugWindowToken(NSApp.keyWindow))} mainWin={\(debugWindowToken(NSApp.mainWindow))} activeMgr=\(debugManagerToken(activeManager)) activeWinId=\(activeWindowId) activeSelected=\(selectedWorkspace) contexts=[\(contexts)]" + } +#endif + + private func mainWindowForShortcutEvent(_ event: NSEvent) -> NSWindow? { + if let window = event.window, isMainTerminalWindow(window) { + return window + } + let eventWindowNumber = event.windowNumber + if eventWindowNumber > 0, + let numberedWindow = NSApp.window(withWindowNumber: eventWindowNumber), + isMainTerminalWindow(numberedWindow) { + return numberedWindow + } + if let keyWindow = NSApp.keyWindow, isMainTerminalWindow(keyWindow) { + return keyWindow + } + if let mainWindow = NSApp.mainWindow, isMainTerminalWindow(mainWindow) { + return mainWindow + } + return nil + } + + /// Re-sync app-level active window pointers from the currently focused main terminal window. + /// This keeps menu/shortcut actions window-scoped even if the cached `tabManager` drifts. + @discardableResult + func synchronizeActiveMainWindowContext(preferredWindow: NSWindow? = nil) -> TabManager? { + let (context, source): (MainWindowContext?, String) = { + if let preferredWindow, + let context = contextForMainWindow(preferredWindow) { + return (context, "preferredWindow") + } + if let context = contextForMainWindow(NSApp.keyWindow) { + return (context, "keyWindow") + } + if let context = contextForMainWindow(NSApp.mainWindow) { + return (context, "mainWindow") + } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return (activeContext, "activeManager") + } + return (mainWindowContexts.values.first, "firstContextFallback") + }() + +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) + dlog( + "shortcut.sync.pre source=\(source) preferred={\(debugWindowToken(preferredWindow))} chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + guard let context else { return tabManager } + if let window = context.window ?? windowForMainWindowId(context.windowId) { + setActiveMainWindow(window) + } else { + tabManager = context.tabManager + sidebarState = context.sidebarState + sidebarSelectionState = context.sidebarSelectionState + TerminalController.shared.setActiveTabManager(context.tabManager) + } +#if DEBUG + dlog( + "shortcut.sync.post source=\(source) beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) chosen={\(debugContextToken(context))} \(debugShortcutRouteSnapshot())" + ) +#endif + return context.tabManager + } + private func preferredMainWindowContextForShortcuts(event: NSEvent) -> MainWindowContext? { if let context = contextForMainWindow(event.window) { return context @@ -854,13 +973,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let context = contextForMainWindow(NSApp.mainWindow) { return context } + if let activeManager = tabManager, + let activeContext = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) { + return activeContext + } return mainWindowContexts.values.first } private func activateMainWindowContextForShortcutEvent(_ event: NSEvent) { - guard let context = preferredMainWindowContextForShortcuts(event: event), - let window = context.window ?? windowForMainWindowId(context.windowId) else { return } - setActiveMainWindow(window) + let preferredWindow = mainWindowForShortcutEvent(event) +#if DEBUG + dlog( + "shortcut.activate.pre event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif + _ = synchronizeActiveMainWindowContext(preferredWindow: preferredWindow) +#if DEBUG + dlog( + "shortcut.activate.post event=\(NSWindow.keyDescription(event)) preferred={\(debugWindowToken(preferredWindow))} \(debugShortcutRouteSnapshot(event: event))" + ) +#endif } @discardableResult @@ -1838,7 +1970,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent 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")") + dlog( + "monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil") \(self.debugShortcutRouteSnapshot(event: event))" + ) if let probeKind = self.developerToolsShortcutProbeKind(event: event) { self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event) } @@ -2247,6 +2381,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .newTab)) { +#if DEBUG + dlog("shortcut.action name=newWorkspace \(debugShortcutRouteSnapshot(event: event))") +#endif // Cmd+N semantics: // - If there are no main windows, create a new window. // - Otherwise, create a new workspace in the active window. @@ -2357,6 +2494,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let manager = tabManager, let num = Int(chars), let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: num, workspaceCount: manager.tabs.count) { +#if DEBUG + dlog( + "shortcut.action name=workspaceDigit digit=\(num) targetIndex=\(targetIndex) manager=\(debugManagerToken(manager)) \(debugShortcutRouteSnapshot(event: event))" + ) +#endif manager.selectTab(at: targetIndex) return true } @@ -2425,11 +2567,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Split actions: Cmd+D / Cmd+Shift+D if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitRight)) { +#if DEBUG + dlog("shortcut.action name=splitRight \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performSplitShortcut(direction: .right) return true } if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitDown)) { +#if DEBUG + dlog("shortcut.action name=splitDown \(debugShortcutRouteSnapshot(event: event))") +#endif _ = performSplitShortcut(direction: .down) return true } @@ -2807,6 +2955,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + let directionLabel: String switch direction { case .left: directionLabel = "left" @@ -2872,6 +3022,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func performBrowserSplitShortcut(direction: SplitDirection) -> Bool { + _ = synchronizeActiveMainWindowContext(preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow) + guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false } _ = focusBrowserAddressBar(panelId: panelId) return true @@ -3236,10 +3388,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func setActiveMainWindow(_ window: NSWindow) { guard isMainTerminalWindow(window) else { return } guard let context = mainWindowContexts[ObjectIdentifier(window)] else { return } +#if DEBUG + let beforeManagerToken = debugManagerToken(tabManager) +#endif tabManager = context.tabManager sidebarState = context.sidebarState sidebarSelectionState = context.sidebarSelectionState TerminalController.shared.setActiveTabManager(context.tabManager) +#if DEBUG + dlog( + "mainWindow.active window={\(debugWindowToken(window))} context={\(debugContextToken(context))} beforeMgr=\(beforeManagerToken) afterMgr=\(debugManagerToken(tabManager)) \(debugShortcutRouteSnapshot())" + ) +#endif } private func unregisterMainWindow(_ window: NSWindow) { @@ -3282,6 +3442,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func isMainTerminalWindow(_ window: NSWindow) -> Bool { + if mainWindowContexts[ObjectIdentifier(window)] != nil { + return true + } guard let raw = window.identifier?.rawValue else { return false } return raw == "cmux.main" || raw.hasPrefix("cmux.main.") } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 38998db0..1acbb97e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -3713,8 +3713,13 @@ final class GhosttySurfaceScrollView: NSView { object: window, queue: .main ) { [weak self] _ in - // No-op: focus is driven by first-responder changes. - _ = self + guard let self, let window = self.window else { return } + // Losing key window does not always trigger first-responder resignation, so force + // the focused terminal view to yield responder to keep Ghostty cursor/focus state in sync. + if let fr = window.firstResponder as? NSView, + fr === self.surfaceView || fr.isDescendant(of: self.surfaceView) { + window.makeFirstResponder(nil) + } }) if window.isKeyWindow { applyFirstResponderIfNeeded() } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a5950c24..23b683de 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -357,7 +357,7 @@ struct cmuxApp: App { } splitCommandButton(title: "New Workspace", shortcut: newWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).addTab() + activeTabManager.addTab() } } @@ -392,7 +392,7 @@ struct cmuxApp: App { } Button("Reopen Closed Browser Panel") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).reopenMostRecentlyClosedBrowserPanel() + _ = activeTabManager.reopenMostRecentlyClosedBrowserPanel() } .keyboardShortcut("t", modifiers: [.command, .shift]) } @@ -401,35 +401,35 @@ struct cmuxApp: App { CommandGroup(after: .textEditing) { Menu("Find") { Button("Find…") { - (AppDelegate.shared?.tabManager ?? tabManager).startSearch() + activeTabManager.startSearch() } .keyboardShortcut("f", modifiers: .command) Button("Find Next") { - (AppDelegate.shared?.tabManager ?? tabManager).findNext() + activeTabManager.findNext() } .keyboardShortcut("g", modifiers: .command) Button("Find Previous") { - (AppDelegate.shared?.tabManager ?? tabManager).findPrevious() + activeTabManager.findPrevious() } .keyboardShortcut("g", modifiers: [.command, .shift]) Divider() Button("Hide Find Bar") { - (AppDelegate.shared?.tabManager ?? tabManager).hideFind() + activeTabManager.hideFind() } .keyboardShortcut("f", modifiers: [.command, .shift]) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible)) + .disabled(!(activeTabManager.isFindVisible)) Divider() Button("Use Selection for Find") { - (AppDelegate.shared?.tabManager ?? tabManager).searchSelection() + activeTabManager.searchSelection() } .keyboardShortcut("e", modifiers: .command) - .disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind)) + .disabled(!(activeTabManager.canUseSelectionForFind)) } } @@ -444,54 +444,54 @@ struct cmuxApp: App { Divider() splitCommandButton(title: "Next Surface", shortcut: nextSurfaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface() + activeTabManager.selectNextSurface() } splitCommandButton(title: "Previous Surface", shortcut: prevSurfaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface() + activeTabManager.selectPreviousSurface() } Button("Back") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack() + activeTabManager.focusedBrowserPanel?.goBack() } .keyboardShortcut("[", modifiers: .command) Button("Forward") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward() + activeTabManager.focusedBrowserPanel?.goForward() } .keyboardShortcut("]", modifiers: .command) Button("Reload Page") { - (AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload() + activeTabManager.focusedBrowserPanel?.reload() } .keyboardShortcut("r", modifiers: .command) splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.toggleDeveloperToolsFocusedBrowser() { NSSound.beep() } } splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if !manager.showJavaScriptConsoleFocusedBrowser() { NSSound.beep() } } Button("Zoom In") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() + _ = activeTabManager.zoomInFocusedBrowser() } .keyboardShortcut("=", modifiers: .command) Button("Zoom Out") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser() + _ = activeTabManager.zoomOutFocusedBrowser() } .keyboardShortcut("-", modifiers: .command) Button("Actual Size") { - _ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser() + _ = activeTabManager.resetZoomFocusedBrowser() } .keyboardShortcut("0", modifiers: .command) @@ -500,11 +500,11 @@ struct cmuxApp: App { } splitCommandButton(title: "Next Workspace", shortcut: nextWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectNextTab() + activeTabManager.selectNextTab() } splitCommandButton(title: "Previous Workspace", shortcut: prevWorkspaceMenuShortcut) { - (AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab() + activeTabManager.selectPreviousTab() } splitCommandButton(title: "Rename Workspace…", shortcut: renameWorkspaceMenuShortcut) { @@ -534,7 +534,7 @@ struct cmuxApp: App { // Cmd+1 through Cmd+9 for workspace selection (9 = last workspace) ForEach(1...9, id: \.self) { number in Button("Workspace \(number)") { - let manager = (AppDelegate.shared?.tabManager ?? tabManager) + let manager = activeTabManager if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) { manager.selectTab(at: targetIndex) } @@ -705,6 +705,12 @@ struct cmuxApp: App { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } + private var activeTabManager: TabManager { + AppDelegate.shared?.synchronizeActiveMainWindowContext( + preferredWindow: NSApp.keyWindow ?? NSApp.mainWindow + ) ?? tabManager + } + private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut { guard !data.isEmpty, let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else { @@ -756,11 +762,11 @@ struct cmuxApp: App { window.performClose(nil) return } - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation() + activeTabManager.closeCurrentPanelWithConfirmation() } private func closeTabOrWindow() { - (AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation() + activeTabManager.closeCurrentTabWithConfirmation() } private func showNotificationsPopover() { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8341c5f1..06f7d6a3 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -330,6 +330,127 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +@MainActor +final class AppDelegateWindowContextRoutingTests: XCTestCase { + private func makeMainWindow(id: UUID) -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("cmux.main.\(id.uuidString)") + return window + } + + func testSynchronizeActiveMainWindowContextPrefersProvidedWindowOverStaleActiveManager() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + windowB.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowB) + XCTAssertTrue(app.tabManager === managerB) + + windowA.makeKeyAndOrderFront(nil) + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(resolved === managerA, "Expected provided active window to win over stale active manager") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextFallsBackToActiveManagerWithoutFocusedWindow() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowAId = UUID() + let windowBId = UUID() + let windowA = makeMainWindow(id: windowAId) + let windowB = makeMainWindow(id: windowBId) + defer { + windowA.orderOut(nil) + windowB.orderOut(nil) + } + + let managerA = TabManager() + let managerB = TabManager() + app.registerMainWindow( + windowA, + windowId: windowAId, + tabManager: managerA, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + app.registerMainWindow( + windowB, + windowId: windowBId, + tabManager: managerB, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // Seed active manager and clear focus windows to force fallback routing. + windowA.makeKeyAndOrderFront(nil) + _ = app.synchronizeActiveMainWindowContext(preferredWindow: windowA) + XCTAssertTrue(app.tabManager === managerA) + windowA.orderOut(nil) + windowB.orderOut(nil) + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: nil) + XCTAssertTrue(resolved === managerA, "Expected fallback to preserve current active manager instead of arbitrary window") + XCTAssertTrue(app.tabManager === managerA) + } + + func testSynchronizeActiveMainWindowContextUsesRegisteredWindowEvenIfIdentifierMutates() { + _ = NSApplication.shared + let app = AppDelegate() + + let windowId = UUID() + let window = makeMainWindow(id: windowId) + defer { window.orderOut(nil) } + + let manager = TabManager() + app.registerMainWindow( + window, + windowId: windowId, + tabManager: manager, + sidebarState: SidebarState(), + sidebarSelectionState: SidebarSelectionState() + ) + + // SwiftUI can replace the NSWindow identifier string at runtime. + window.identifier = NSUserInterfaceItemIdentifier("SwiftUI.AppWindow.IdentifierChanged") + + let resolved = app.synchronizeActiveMainWindowContext(preferredWindow: window) + XCTAssertTrue(resolved === manager, "Expected registered window object identity to win even if identifier string changed") + XCTAssertTrue(app.tabManager === manager) + } +} + final class FocusFlashPatternTests: XCTestCase { func testFocusFlashPatternMatchesTerminalDoublePulseShape() { XCTAssertEqual(FocusFlashPattern.values, [0, 1, 0, 1, 0]) @@ -4241,6 +4362,50 @@ final class GhosttySurfaceOverlayTests: XCTestCase { XCTAssertTrue(state.isHidden) } + func testWindowResignKeyClearsFocusedTerminalFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 240), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let hostedView = GhosttySurfaceScrollView( + surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 160, height: 120)) + ) + hostedView.frame = contentView.bounds + hostedView.autoresizingMask = [.width, .height] + contentView.addSubview(hostedView) + + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + contentView.layoutSubtreeIfNeeded() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + hostedView.setVisibleInUI(true) + hostedView.setActive(true) + hostedView.moveFocus() + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + XCTAssertTrue( + hostedView.isSurfaceViewFirstResponder(), + "Expected terminal surface to be first responder before window blur" + ) + + NotificationCenter.default.post(name: NSWindow.didResignKeyNotification, object: window) + RunLoop.current.run(until: Date().addingTimeInterval(0.05)) + + XCTAssertFalse( + hostedView.isSurfaceViewFirstResponder(), + "Window blur should force terminal surface to resign first responder" + ) + } + func testSearchOverlayMountsAndUnmountsWithSearchState() { let surface = TerminalSurface( tabId: UUID(), From 3c1f1792c030861b04668b5b79ef402c8978b464 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:27:04 -0800 Subject: [PATCH 2/9] Fix browser workspace focus handoff lag (#381) --- Sources/ContentView.swift | 25 ++++++++++++++++++++++++- Sources/Panels/BrowserPanelView.swift | 5 +++-- Sources/Panels/CmuxWebView.swift | 3 +++ Sources/TabManager.swift | 1 + 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 21b5f01b..7fc7b561 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1610,7 +1610,11 @@ struct ContentView: View { ForEach(mountedWorkspaces) { tab in let isSelectedWorkspace = selectedWorkspaceId == tab.id let isRetiringWorkspace = retiringWorkspaceId == tab.id - let isInputActive = isSelectedWorkspace || isRetiringWorkspace + // Keep the retiring workspace visible during handoff, but never input-active. + // Allowing both selected+retiring workspaces to be input-active lets the + // old workspace steal first responder (notably with WKWebView), which can + // delay handoff completion and make browser returns feel laggy. + let isInputActive = isSelectedWorkspace let isVisible = isSelectedWorkspace || isRetiringWorkspace let portalPriority = isSelectedWorkspace ? 2 : (isRetiringWorkspace ? 1 : 0) WorkspaceContentView( @@ -1952,6 +1956,25 @@ struct ContentView: View { completeWorkspaceHandoffIfNeeded(focusedTabId: tabId, reason: "first_responder") }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidBecomeFirstResponderWebView)) { notification in + guard let webView = notification.object as? WKWebView, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + let focusedPanelId = selectedWorkspace.focusedPanelId, + let focusedBrowser = selectedWorkspace.browserPanel(for: focusedPanelId), + focusedBrowser.webView === webView else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_first_responder") + }) + + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .browserDidFocusAddressBar)) { notification in + guard let panelId = notification.object as? UUID, + let selectedTabId = tabManager.selectedTabId, + let selectedWorkspace = tabManager.selectedWorkspace, + selectedWorkspace.focusedPanelId == panelId, + selectedWorkspace.browserPanel(for: panelId) != nil else { return } + completeWorkspaceHandoffIfNeeded(focusedTabId: selectedTabId, reason: "browser_address_bar") + }) + view = AnyView(view.onReceive(tabManager.$tabs) { tabs in let existingIds = Set(tabs.map { $0.id }) if let retiringWorkspaceId, !existingIds.contains(retiringWorkspaceId) { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 0294766f..ac19b086 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3070,6 +3070,7 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator: Coordinator, generation: Int ) { + let retryInterval: TimeInterval = 1.0 / 60.0 // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -3102,7 +3103,7 @@ struct WebViewRepresentable: NSViewRepresentable { // Be generous here: bonsplit structural updates can keep a representable // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval) { scheduleAttachRetry( webView, panel: panel, @@ -3139,7 +3140,7 @@ struct WebViewRepresentable: NSViewRepresentable { } coordinator.attachRetryWorkItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) } func updateNSView(_ nsView: NSView, context: Context) { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 83941484..8f2a3a28 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -45,6 +45,9 @@ final class CmuxWebView: WKWebView { return false } let result = super.becomeFirstResponder() + if result { + NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self) + } #if DEBUG let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" dlog( diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 17f13cb3..f9c6b17d 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3113,6 +3113,7 @@ extension Notification.Name { static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") static let ghosttyDidBecomeFirstResponderSurface = Notification.Name("ghosttyDidBecomeFirstResponderSurface") + static let browserDidBecomeFirstResponderWebView = Notification.Name("browserDidBecomeFirstResponderWebView") static let browserFocusAddressBar = Notification.Name("browserFocusAddressBar") static let browserMoveOmnibarSelection = Notification.Name("browserMoveOmnibarSelection") static let browserDidExitAddressBar = Notification.Name("browserDidExitAddressBar") From 65b3b570c9de069d25011d0539bdc1b2e7dd387d Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:27:13 -0800 Subject: [PATCH 3/9] Fix Caps Lock handling in browser omnibar keyboard paths (#382) --- Sources/AppDelegate.swift | 28 +++++------ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 46 +++++++++++++++++++ 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 87a25c04..0d671e90 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -70,10 +70,9 @@ func browserOmnibarSelectionDeltaForCommandNavigation( chars: String ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return nil } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return nil } if chars == "n" { return 1 } if chars == "p" { return -1 } return nil @@ -85,9 +84,7 @@ func browserOmnibarSelectionDeltaForArrowNavigation( keyCode: UInt16 ) -> Int? { guard hasFocusedAddressBar else { return nil } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) guard normalizedFlags == [] else { return nil } switch keyCode { case 125: return 1 @@ -96,10 +93,14 @@ func browserOmnibarSelectionDeltaForArrowNavigation( } } -func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { - let normalizedFlags = flags +func browserOmnibarNormalizedModifierFlags(_ flags: NSEvent.ModifierFlags) -> NSEvent.ModifierFlags { + flags .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) + .subtracting([.numericPad, .function, .capsLock]) +} + +func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool { + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) return normalizedFlags == [] || normalizedFlags == [.shift] } @@ -2766,10 +2767,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent chars: String ) -> Bool { guard browserAddressBarFocusedPanelId != nil else { return false } - let normalizedFlags = flags - .intersection(.deviceIndependentFlagsMask) - .subtracting([.numericPad, .function]) - guard normalizedFlags == [.control] else { return false } + let normalizedFlags = browserOmnibarNormalizedModifierFlags(flags) + let isCommandOrControlOnly = normalizedFlags == [.command] || normalizedFlags == [.control] + guard isCommandOrControlOnly else { return false } return chars == "n" || chars == "p" } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 06f7d6a3..fc1c546f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1123,6 +1123,25 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { ) } + func testArrowNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 126 + ), + -1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForArrowNavigation( + hasFocusedAddressBar: true, + flags: [.capsLock], + keyCode: 125 + ), + 1 + ) + } + func testCommandNavigationDeltaRequiresFocusedAddressBarAndCommandOrControlOnly() { XCTAssertNil( browserOmnibarSelectionDeltaForCommandNavigation( @@ -1176,6 +1195,33 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { 1 ) } + + func testCommandNavigationDeltaIgnoresCapsLockModifier() { + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.control, .capsLock], + chars: "n" + ), + 1 + ) + XCTAssertEqual( + browserOmnibarSelectionDeltaForCommandNavigation( + hasFocusedAddressBar: true, + flags: [.command, .capsLock], + chars: "p" + ), + -1 + ) + } + + func testSubmitOnReturnIgnoresCapsLockModifier() { + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.capsLock])) + XCTAssertTrue(browserOmnibarShouldSubmitOnReturn(flags: [.shift, .capsLock])) + XCTAssertFalse(browserOmnibarShouldSubmitOnReturn(flags: [.command, .capsLock])) + } } final class BrowserZoomShortcutActionTests: XCTestCase { From d42126a08220c44c024fd7ce2716ff2fe6383729 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 10:51:28 -0800 Subject: [PATCH 4/9] Handle deeplink URL schemes in embedded browser (#392) --- Sources/Panels/BrowserPanel.swift | 41 +++++++++++++++++++ cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 32 +++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6255672e..420ba6d1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -380,6 +380,21 @@ func browserPreparedNavigationRequest(_ request: URLRequest) -> URLRequest { return preparedRequest } +private let browserEmbeddedNavigationSchemes: Set = [ + "about", + "applewebdata", + "blob", + "data", + "http", + "https", + "javascript", +] + +func browserShouldOpenURLExternally(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased(), !scheme.isEmpty else { return false } + return !browserEmbeddedNavigationSchemes.contains(scheme) +} + enum BrowserUserAgentSettings { // Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens, // and some installs may have legacy Chrome UA overrides. Both can cause Google to serve @@ -2638,6 +2653,22 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } + // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). + // Hand these off to macOS so the owning app can handle them. + if let url = navigationAction.request.url, + navigationAction.targetFrame?.isMainFrame != false, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=navDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + // target=_blank or window.open() — navigate in the current webview if navigationAction.targetFrame == nil, navigationAction.request.url != nil { @@ -2761,6 +2792,16 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { windowFeatures: WKWindowFeatures ) -> WKWebView? { if let url = navigationAction.request.url { + if browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) + } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index fc1c546f..59208945 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -5111,6 +5111,38 @@ final class TerminalOpenURLTargetResolutionTests: XCTestCase { } } +final class BrowserExternalNavigationSchemeTests: XCTestCase { + func testCustomAppSchemesOpenExternally() throws { + let discord = try XCTUnwrap(URL(string: "discord://login/one-time?token=abc")) + let slack = try XCTUnwrap(URL(string: "slack://open")) + let zoom = try XCTUnwrap(URL(string: "zoommtg://zoom.us/join")) + let mailto = try XCTUnwrap(URL(string: "mailto:test@example.com")) + + XCTAssertTrue(browserShouldOpenURLExternally(discord)) + XCTAssertTrue(browserShouldOpenURLExternally(slack)) + XCTAssertTrue(browserShouldOpenURLExternally(zoom)) + XCTAssertTrue(browserShouldOpenURLExternally(mailto)) + } + + func testEmbeddedBrowserSchemesStayInWebView() throws { + let https = try XCTUnwrap(URL(string: "https://example.com")) + let http = try XCTUnwrap(URL(string: "http://example.com")) + let about = try XCTUnwrap(URL(string: "about:blank")) + let data = try XCTUnwrap(URL(string: "data:text/plain,hello")) + let blob = try XCTUnwrap(URL(string: "blob:https://example.com/550e8400-e29b-41d4-a716-446655440000")) + let javascript = try XCTUnwrap(URL(string: "javascript:void(0)")) + let webkitInternal = try XCTUnwrap(URL(string: "applewebdata://local/page")) + + XCTAssertFalse(browserShouldOpenURLExternally(https)) + XCTAssertFalse(browserShouldOpenURLExternally(http)) + XCTAssertFalse(browserShouldOpenURLExternally(about)) + XCTAssertFalse(browserShouldOpenURLExternally(data)) + XCTAssertFalse(browserShouldOpenURLExternally(blob)) + XCTAssertFalse(browserShouldOpenURLExternally(javascript)) + XCTAssertFalse(browserShouldOpenURLExternally(webkitInternal)) + } +} + final class BrowserHostWhitelistTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults! From c1822fdaaca52a1d5937ec100bb1821287b5b8f7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 11:14:34 -0800 Subject: [PATCH 5/9] Fix sidebar resize regression with 1/3 width cap (#393) --- Sources/ContentView.swift | 50 ++++++++++++++++++++++++-- cmuxUITests/SidebarResizeUITests.swift | 27 ++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7fc7b561..2325908c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1343,6 +1343,8 @@ struct ContentView: View { ) private static let commandPaletteUsageDefaultsKey = "commandPalette.commandUsage.v1" private static let commandPaletteCommandsPrefix = ">" + private static let minimumSidebarWidth: CGFloat = 186 + private static let maximumSidebarWidthRatio: CGFloat = 1.0 / 3.0 private enum SidebarResizerHandle: Hashable { case divider @@ -1352,8 +1354,31 @@ struct ContentView: View { SidebarResizeInteraction.hitWidthPerSide } - private var maxSidebarWidth: CGFloat { - (NSApp.keyWindow?.screen?.frame.width ?? NSScreen.main?.frame.width ?? 1920) * 2 / 3 + private func maxSidebarWidth(availableWidth: CGFloat? = nil) -> CGFloat { + let resolvedAvailableWidth = availableWidth + ?? observedWindow?.contentView?.bounds.width + ?? observedWindow?.contentLayoutRect.width + ?? NSApp.keyWindow?.contentView?.bounds.width + ?? NSApp.keyWindow?.contentLayoutRect.width + if let resolvedAvailableWidth, resolvedAvailableWidth > 0 { + return max(Self.minimumSidebarWidth, resolvedAvailableWidth * Self.maximumSidebarWidthRatio) + } + + let fallbackScreenWidth = NSApp.keyWindow?.screen?.frame.width + ?? NSScreen.main?.frame.width + ?? 1920 + return max(Self.minimumSidebarWidth, fallbackScreenWidth * Self.maximumSidebarWidthRatio) + } + + private func clampSidebarWidthIfNeeded(availableWidth: CGFloat? = nil) { + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), sidebarWidth) + ) + guard abs(nextWidth - sidebarWidth) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth + } } private func activateSidebarResizerCursor() { @@ -1498,6 +1523,7 @@ struct ContentView: View { private func sidebarResizerHandleOverlay( _ handle: SidebarResizerHandle, width: CGFloat, + availableWidth: CGFloat, accessibilityIdentifier: String? = nil ) -> some View { Color.clear @@ -1543,7 +1569,10 @@ struct ContentView: View { activateSidebarResizerCursor() let startWidth = sidebarDragStartWidth ?? sidebarWidth - let nextWidth = max(186, min(maxSidebarWidth, startWidth + value.translation.width)) + let nextWidth = max( + Self.minimumSidebarWidth, + min(maxSidebarWidth(availableWidth: availableWidth), startWidth + value.translation.width) + ) withTransaction(Transaction(animation: nil)) { sidebarWidth = nextWidth } @@ -1574,6 +1603,7 @@ struct ContentView: View { sidebarResizerHandleOverlay( .divider, width: sidebarResizerHitWidthPerSide * 2, + availableWidth: totalWidth, accessibilityIdentifier: "SidebarResizer" ) @@ -1582,6 +1612,12 @@ struct ContentView: View { .allowsHitTesting(false) } .frame(width: totalWidth, height: proxy.size.height, alignment: .leading) + .onAppear { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } + .onChange(of: totalWidth) { + clampSidebarWidthIfNeeded(availableWidth: totalWidth) + } } } @@ -2125,6 +2161,13 @@ struct ContentView: View { AppDelegate.shared?.fullscreenControlsViewModel = nil }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: NSWindow.didResizeNotification)) { notification in + guard let window = notification.object as? NSWindow, + window === observedWindow else { return } + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) + updateSidebarResizerBandState() + }) + view = AnyView(view.onChange(of: sidebarWidth) { _ in updateSidebarResizerBandState() }) @@ -2152,6 +2195,7 @@ struct ContentView: View { DispatchQueue.main.async { observedWindow = window isFullScreen = window.styleMask.contains(.fullScreen) + clampSidebarWidthIfNeeded(availableWidth: window.contentView?.bounds.width ?? window.contentLayoutRect.width) syncCommandPaletteDebugStateForObservedWindow() installSidebarResizerPointerMonitorIfNeeded() updateSidebarResizerBandState() diff --git a/cmuxUITests/SidebarResizeUITests.swift b/cmuxUITests/SidebarResizeUITests.swift index 57c47214..6844cbeb 100644 --- a/cmuxUITests/SidebarResizeUITests.swift +++ b/cmuxUITests/SidebarResizeUITests.swift @@ -35,4 +35,31 @@ final class SidebarResizeUITests: XCTestCase { XCTAssertLessThanOrEqual(leftDelta, -40, "Expected drag-left to move resizer left") XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset") } + + func testSidebarResizerHasMaximumWidthCap() { + let app = XCUIApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5.0)) + + let elements = app.descendants(matching: .any) + let resizer = elements["SidebarResizer"] + XCTAssertTrue(resizer.waitForExistence(timeout: 5.0)) + + let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let farRight = start.withOffset(CGVector(dx: 5000, dy: 0)) + start.press(forDuration: 0.1, thenDragTo: farRight) + + let windowFrame = window.frame + let remainingWidth = max(0, windowFrame.maxX - resizer.frame.maxX) + let minimumExpectedRemaining = windowFrame.width * 0.45 + + XCTAssertGreaterThanOrEqual( + remainingWidth, + minimumExpectedRemaining, + "Expected sidebar max-width clamp to leave substantial terminal width. " + + "remaining=\(remainingWidth), window=\(windowFrame.width)" + ) + } } From 6598a38fe33b0b3b8a71802569ca2090184cee80 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Mon, 23 Feb 2026 11:26:11 -0800 Subject: [PATCH 6/9] Fix terminal zoom inheritance for new splits/surfaces/workspaces (#384) * Fix terminal Cmd zoom routing for Ghostty focus descendants (#383) * Inherit new terminal zoom from last terminal context Prefer pane-selected terminal as Ghostty config inheritance source when creating splits/new terminals, then focused/fallback terminals. This preserves runtime zoom/font size when opening the next terminal. * Fix terminal zoom inheritance across split/tab/workspace creation --- Sources/AppDelegate.swift | 57 +++- Sources/GhosttyTerminalView.swift | 41 ++- Sources/TabManager.swift | 38 ++- Sources/Workspace.swift | 303 +++++++++++++++--- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 163 ++++++++++ 5 files changed, 551 insertions(+), 51 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0d671e90..11fca42f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -194,6 +194,54 @@ func shouldRouteTerminalFontZoomShortcutToGhostty( return browserZoomShortcutAction(flags: flags, chars: chars, keyCode: keyCode) != nil } +func cmuxOwningGhosttyView(for responder: NSResponder?) -> GhosttyNSView? { + guard let responder else { return nil } + if let ghosttyView = responder as? GhosttyNSView { + return ghosttyView + } + + if let view = responder as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + + if let textView = responder as? NSTextView, + let delegateView = textView.delegate as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: delegateView) { + return ghosttyView + } + + var current = responder.nextResponder + while let next = current { + if let ghosttyView = next as? GhosttyNSView { + return ghosttyView + } + if let view = next as? NSView, + let ghosttyView = cmuxOwningGhosttyView(for: view) { + return ghosttyView + } + current = next.nextResponder + } + + return nil +} + +private func cmuxOwningGhosttyView(for view: NSView) -> GhosttyNSView? { + if let ghosttyView = view as? GhosttyNSView { + return ghosttyView + } + + var current: NSView? = view.superview + while let candidate = current { + if let ghosttyView = candidate as? GhosttyNSView { + return ghosttyView + } + current = candidate.superview + } + + return nil +} + #if DEBUG func browserZoomShortcutTraceCandidate( flags: NSEvent.ModifierFlags, @@ -2300,7 +2348,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // When the terminal has active IME composition (e.g. Korean, Japanese, Chinese // input), don't intercept key events — let them flow through to the input method. - if let ghosttyView = NSApp.keyWindow?.firstResponder as? GhosttyNSView, + if let ghosttyView = cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder), ghosttyView.hasMarkedText() { return false } @@ -2345,7 +2393,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (e.g., split that doesn't properly blur the address bar). If the first responder // is a terminal surface, the address bar can't be focused. if browserAddressBarFocusedPanelId != nil, - NSApp.keyWindow?.firstResponder is GhosttyNSView { + cmuxOwningGhosttyView(for: NSApp.keyWindow?.firstResponder) != nil { #if DEBUG dlog("handleCustomShortcut: clearing stale browserAddressBarFocusedPanelId") #endif @@ -4441,7 +4489,8 @@ private extension NSWindow { // Command shortcuts when the terminal is focused — the local event monitor // (handleCustomShortcut) already handles app-level shortcuts, and anything // remaining should be menu items. - if let ghosttyView = self.firstResponder as? GhosttyNSView { + let firstResponderGhosttyView = cmuxOwningGhosttyView(for: self.firstResponder) + if let ghosttyView = firstResponderGhosttyView { // If the IME is composing, don't intercept key events — let them flow // through normal AppKit event dispatch so the input method can process them. if ghosttyView.hasMarkedText() { @@ -4484,7 +4533,7 @@ private extension NSWindow { // When the terminal is focused, skip the full NSWindow.performKeyEquivalent // (which walks the SwiftUI content view hierarchy) and dispatch Command-key // events directly to the main menu. This avoids the broken SwiftUI focus path. - if self.firstResponder is GhosttyNSView, + if firstResponderGhosttyView != nil, event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command), let mainMenu = NSApp.mainMenu { let consumedByMenu = mainMenu.performKeyEquivalent(with: event) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 1acbb97e..0e01b157 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1616,6 +1616,13 @@ final class TerminalSurface: Identifiable, ObservableObject { surfaceCallbackContext = callbackContext surfaceConfig.scale_factor = scaleFactors.layer surfaceConfig.context = surfaceContext +#if DEBUG + let templateFontText = String(format: "%.2f", surfaceConfig.font_size) + dlog( + "zoom.create surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "templateFont=\(templateFontText)" + ) +#endif var envVars: [ghostty_env_var_s] = [] var envStorage: [(UnsafeMutablePointer, UnsafeMutablePointer)] = [] defer { @@ -1761,6 +1768,7 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif return } + guard let createdSurface = surface else { return } // For vsync-driven rendering, Ghostty needs to know which display we're on so it can // start a CVDisplayLink with the right refresh rate. If we don't set this early, the @@ -1772,21 +1780,48 @@ final class TerminalSurface: Identifiable, ObservableObject { if let screen = view.window?.screen ?? NSScreen.main, let displayID = screen.displayID, displayID != 0 { - ghostty_surface_set_display_id(surface, displayID) + ghostty_surface_set_display_id(createdSurface, displayID) } - ghostty_surface_set_content_scale(surface, scaleFactors.x, scaleFactors.y) + ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) if wpx > 0, hpx > 0 { - ghostty_surface_set_size(surface, wpx, hpx) + ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx lastPixelHeight = hpx lastXScale = scaleFactors.x lastYScale = scaleFactors.y } + // Some GhosttyKit builds can drop inherited font_size during post-create + // config/scale reconciliation. If runtime points don't match the inherited + // template points, re-apply via binding action so all creation paths + // (new surface, split, new workspace) preserve zoom from the source terminal. + if let inheritedFontPoints = configTemplate?.font_size, + inheritedFontPoints > 0 { + let currentFontPoints = cmuxCurrentSurfaceFontSizePoints(createdSurface) + let shouldReapply = { + guard let currentFontPoints else { return true } + return abs(currentFontPoints - inheritedFontPoints) > 0.05 + }() + if shouldReapply { + let action = String(format: "set_font_size:%.3f", inheritedFontPoints) + _ = performBindingAction(action) + } + } + flushPendingTextIfNeeded() + +#if DEBUG + let runtimeFontText = cmuxCurrentSurfaceFontSizePoints(createdSurface).map { + String(format: "%.2f", $0) + } ?? "nil" + dlog( + "zoom.create.done surface=\(id.uuidString.prefix(5)) context=\(cmuxSurfaceContextName(surfaceContext)) " + + "runtimeFont=\(runtimeFontText)" + ) +#endif } func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f9c6b17d..0e38e366 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -753,9 +753,15 @@ class TabManager: ObservableObject { @discardableResult func addWorkspace(workingDirectory overrideWorkingDirectory: String? = nil, select: Bool = true) -> Workspace { let workingDirectory = normalizedWorkingDirectory(overrideWorkingDirectory) ?? preferredWorkingDirectoryForNewTab() + let inheritedConfig = inheritedTerminalConfigForNewWorkspace() let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 - let newWorkspace = Workspace(title: "Terminal \(tabs.count + 1)", workingDirectory: workingDirectory, portOrdinal: ordinal) + let newWorkspace = Workspace( + title: "Terminal \(tabs.count + 1)", + workingDirectory: workingDirectory, + portOrdinal: ordinal, + configTemplate: inheritedConfig + ) wireClosedBrowserTracking(for: newWorkspace) let insertIndex = newTabInsertIndex() if insertIndex >= 0 && insertIndex <= tabs.count { @@ -785,6 +791,36 @@ class TabManager: ObservableObject { @discardableResult func addTab(select: Bool = true) -> Workspace { addWorkspace(select: select) } + func terminalPanelForWorkspaceConfigInheritanceSource() -> TerminalPanel? { + guard let workspace = selectedWorkspace else { return nil } + if let focusedTerminal = workspace.focusedTerminalPanel { + return focusedTerminal + } + if let rememberedTerminal = workspace.lastRememberedTerminalPanelForConfigInheritance() { + return rememberedTerminal + } + if let focusedPaneId = workspace.bonsplitController.focusedPaneId, + let paneTerminal = workspace.terminalPanelForConfigInheritance(inPane: focusedPaneId) { + return paneTerminal + } + return workspace.terminalPanelForConfigInheritance() + } + + private func inheritedTerminalConfigForNewWorkspace() -> ghostty_surface_config_s? { + if let sourceSurface = terminalPanelForWorkspaceConfigInheritanceSource()?.surface.surface { + return cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_TAB + ) + } + if let fallbackFontPoints = selectedWorkspace?.lastRememberedTerminalFontPointsForConfigInheritance() { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints + return config + } + return nil + } + private func normalizedWorkingDirectory(_ directory: String?) -> String? { guard let directory else { return nil } let normalized = normalizeDirectory(directory) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index a0838d0d..9697b271 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3,6 +3,58 @@ import SwiftUI import AppKit import Bonsplit import Combine +import CoreText + +func cmuxSurfaceContextName(_ context: ghostty_surface_context_e) -> String { + switch context { + case GHOSTTY_SURFACE_CONTEXT_WINDOW: + return "window" + case GHOSTTY_SURFACE_CONTEXT_TAB: + return "tab" + case GHOSTTY_SURFACE_CONTEXT_SPLIT: + return "split" + default: + return "unknown(\(context))" + } +} + +func cmuxCurrentSurfaceFontSizePoints(_ surface: ghostty_surface_t) -> Float? { + guard let quicklookFont = ghostty_surface_quicklook_font(surface) else { + return nil + } + + let ctFont = Unmanaged.fromOpaque(quicklookFont).takeRetainedValue() + let points = Float(CTFontGetSize(ctFont)) + guard points > 0 else { return nil } + return points +} + +func cmuxInheritedSurfaceConfig( + sourceSurface: ghostty_surface_t, + context: ghostty_surface_context_e +) -> ghostty_surface_config_s { + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + var config = inherited + + // Make runtime zoom inheritance explicit, even when Ghostty's + // inherit-font-size config is disabled. + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let points = runtimePoints { + config.font_size = points + } + +#if DEBUG + let inheritedText = String(format: "%.2f", inherited.font_size) + let runtimeText = runtimePoints.map { String(format: "%.2f", $0) } ?? "nil" + let finalText = String(format: "%.2f", config.font_size) + dlog( + "zoom.inherit context=\(cmuxSurfaceContextName(context)) " + + "inherited=\(inheritedText) runtime=\(runtimeText) final=\(finalText)" + ) +#endif + + return config +} struct SidebarStatusEntry { let key: String @@ -261,6 +313,15 @@ final class Workspace: Identifiable, ObservableObject { /// When true, suppresses auto-creation in didSplitPane (programmatic splits handle their own panels) private var isProgrammaticSplit = false + /// Last terminal panel used as an inheritance source (typically last focused terminal). + private var lastTerminalConfigInheritancePanelId: UUID? + /// Last known terminal font points from inheritance sources. Used as fallback when + /// no live terminal surface is currently available. + private var lastTerminalConfigInheritanceFontPoints: Float? + /// Per-panel inherited zoom lineage. Descendants reuse this root value unless + /// a panel is explicitly re-zoomed by the user. + private var terminalInheritanceFontPointsByPanelId: [UUID: Float] = [:] + /// Callback used by TabManager to capture recently closed browser panels for Cmd+Shift+T restore. var onClosedBrowserPanel: ((ClosedBrowserPanelRestoreSnapshot) -> Void)? @@ -376,7 +437,12 @@ final class Workspace: Identifiable, ObservableObject { } } - init(title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0) { + init( + title: String = "Terminal", + workingDirectory: String? = nil, + portOrdinal: Int = 0, + configTemplate: ghostty_surface_config_s? = nil + ) { self.id = UUID() self.portOrdinal = portOrdinal self.processTitle = title @@ -414,11 +480,13 @@ final class Workspace: Identifiable, ObservableObject { let terminalPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, + configTemplate: configTemplate, workingDirectory: hasWorkingDirectory ? trimmedWorkingDirectory : nil, portOrdinal: portOrdinal ) panels[terminalPanel.id] = terminalPanel panelTitles[terminalPanel.id] = terminalPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: terminalPanel.id, configTemplate: configTemplate) // Create initial tab in bonsplit and store the mapping var initialTabId: TabID? @@ -919,6 +987,169 @@ final class Workspace: Identifiable, ObservableObject { // MARK: - Panel Operations + private func seedTerminalInheritanceFontPoints( + panelId: UUID, + configTemplate: ghostty_surface_config_s? + ) { + guard let fontPoints = configTemplate?.font_size, fontPoints > 0 else { return } + terminalInheritanceFontPointsByPanelId[panelId] = fontPoints + lastTerminalConfigInheritanceFontPoints = fontPoints + } + + private func resolvedTerminalInheritanceFontPoints( + for terminalPanel: TerminalPanel, + sourceSurface: ghostty_surface_t, + inheritedConfig: ghostty_surface_config_s + ) -> Float? { + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) + if let rooted = terminalInheritanceFontPointsByPanelId[terminalPanel.id], rooted > 0 { + if let runtimePoints, abs(runtimePoints - rooted) > 0.05 { + // Runtime zoom changed after lineage was seeded (manual zoom on descendant); + // treat runtime as the new root for future descendants. + return runtimePoints + } + return rooted + } + if inheritedConfig.font_size > 0 { + return inheritedConfig.font_size + } + return runtimePoints + } + + private func rememberTerminalConfigInheritanceSource(_ terminalPanel: TerminalPanel) { + lastTerminalConfigInheritancePanelId = terminalPanel.id + if let sourceSurface = terminalPanel.surface.surface, + let runtimePoints = cmuxCurrentSurfaceFontSizePoints(sourceSurface) { + let existing = terminalInheritanceFontPointsByPanelId[terminalPanel.id] + if existing == nil || abs((existing ?? runtimePoints) - runtimePoints) > 0.05 { + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = runtimePoints + } + lastTerminalConfigInheritanceFontPoints = + terminalInheritanceFontPointsByPanelId[terminalPanel.id] ?? runtimePoints + } + } + + func lastRememberedTerminalPanelForConfigInheritance() -> TerminalPanel? { + guard let panelId = lastTerminalConfigInheritancePanelId else { return nil } + return terminalPanel(for: panelId) + } + + func lastRememberedTerminalFontPointsForConfigInheritance() -> Float? { + lastTerminalConfigInheritanceFontPoints + } + + /// Candidate terminal panels used as the source when creating inherited Ghostty config. + /// Preference order: + /// 1) explicitly preferred terminal panel (when the caller has one), + /// 2) selected terminal in the target pane, + /// 3) currently focused terminal in the workspace, + /// 4) last remembered terminal source, + /// 5) first terminal tab in the target pane, + /// 6) deterministic workspace fallback. + private func terminalPanelConfigInheritanceCandidates( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> [TerminalPanel] { + var candidates: [TerminalPanel] = [] + var seen: Set = [] + + func appendCandidate(_ panel: TerminalPanel?) { + guard let panel, seen.insert(panel.id).inserted else { return } + candidates.append(panel) + } + + if let preferredPanelId, + let terminalPanel = terminalPanel(for: preferredPanelId) { + appendCandidate(terminalPanel) + } + + if let preferredPaneId, + let selectedSurfaceId = bonsplitController.selectedTab(inPane: preferredPaneId)?.id, + let selectedPanelId = panelIdFromSurfaceId(selectedSurfaceId), + let selectedTerminalPanel = terminalPanel(for: selectedPanelId) { + appendCandidate(selectedTerminalPanel) + } + + if let focusedTerminalPanel { + appendCandidate(focusedTerminalPanel) + } + + if let rememberedTerminalPanel = lastRememberedTerminalPanelForConfigInheritance() { + appendCandidate(rememberedTerminalPanel) + } + + if let preferredPaneId { + for tab in bonsplitController.tabs(inPane: preferredPaneId) { + guard let panelId = panelIdFromSurfaceId(tab.id), + let terminalPanel = terminalPanel(for: panelId) else { continue } + appendCandidate(terminalPanel) + } + } + + for terminalPanel in panels.values + .compactMap({ $0 as? TerminalPanel }) + .sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + appendCandidate(terminalPanel) + } + + return candidates + } + + /// Picks the first terminal panel candidate used as the inheritance source. + func terminalPanelForConfigInheritance( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> TerminalPanel? { + terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ).first + } + + private func inheritedTerminalConfig( + preferredPanelId: UUID? = nil, + inPane preferredPaneId: PaneID? = nil + ) -> ghostty_surface_config_s? { + // Walk candidates in priority order and use the first panel with a live surface. + // This avoids returning nil when the top candidate exists but is not attached yet. + for terminalPanel in terminalPanelConfigInheritanceCandidates( + preferredPanelId: preferredPanelId, + inPane: preferredPaneId + ) { + guard let sourceSurface = terminalPanel.surface.surface else { continue } + var config = cmuxInheritedSurfaceConfig( + sourceSurface: sourceSurface, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT + ) + if let rootedFontPoints = resolvedTerminalInheritanceFontPoints( + for: terminalPanel, + sourceSurface: sourceSurface, + inheritedConfig: config + ), rootedFontPoints > 0 { + config.font_size = rootedFontPoints + terminalInheritanceFontPointsByPanelId[terminalPanel.id] = rootedFontPoints + } + rememberTerminalConfigInheritanceSource(terminalPanel) + if config.font_size > 0 { + lastTerminalConfigInheritanceFontPoints = config.font_size + } + return config + } + + if let fallbackFontPoints = lastTerminalConfigInheritanceFontPoints { + var config = ghostty_surface_config_new() + config.font_size = fallbackFontPoints +#if DEBUG + dlog( + "zoom.inherit fallback=lastKnownFont context=split font=\(String(format: "%.2f", fallbackFontPoints))" + ) +#endif + return config + } + + return nil + } + /// Create a new split with a terminal panel @discardableResult func newTerminalSplit( @@ -927,22 +1158,6 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, focus: Bool = true ) -> TerminalPanel? { - // Get inherited config from the source terminal when possible. - // If the split is initiated from a non-terminal panel (for example browser), - // fall back to any terminal in the workspace. - let inheritedConfig: ghostty_surface_config_s? = { - if let sourceTerminal = terminalPanel(for: panelId), - let existing = sourceTerminal.surface.surface { - return ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - if let fallbackSurface = panels.values - .compactMap({ ($0 as? TerminalPanel)?.surface.surface }) - .first { - return ghostty_surface_inherited_config(fallbackSurface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - return nil - }() - // Find the pane containing the source panel guard let sourceTabId = surfaceIdFromPanelId(panelId) else { return nil } var sourcePaneId: PaneID? @@ -955,6 +1170,7 @@ final class Workspace: Identifiable, ObservableObject { } guard let paneId = sourcePaneId else { return nil } + let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId) // Create the new terminal panel. let newPanel = TerminalPanel( @@ -965,6 +1181,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Pre-generate the bonsplit tab ID so we can install the panel mapping before bonsplit // mutates layout state (avoids transient "Empty Panel" flashes during split). @@ -989,6 +1206,7 @@ final class Workspace: Identifiable, ObservableObject { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) surfaceIdToPanelId.removeValue(forKey: newTab.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1024,16 +1242,7 @@ final class Workspace: Identifiable, ObservableObject { func newTerminalSurface(inPane paneId: PaneID, focus: Bool? = nil) -> TerminalPanel? { let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId) - // Get an existing terminal panel to inherit config from - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: paneId) // Create new terminal panel let newPanel = TerminalPanel( @@ -1044,6 +1253,7 @@ final class Workspace: Identifiable, ObservableObject { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit guard let newTabId = bonsplitController.createTab( @@ -1056,6 +1266,7 @@ final class Workspace: Identifiable, ObservableObject { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return nil } @@ -1819,14 +2030,19 @@ final class Workspace: Identifiable, ObservableObject { /// Create a new terminal panel (used when replacing the last panel) @discardableResult func createReplacementTerminalPanel() -> TerminalPanel { + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: focusedPanelId, + inPane: bonsplitController.focusedPaneId + ) let newPanel = TerminalPanel( workspaceId: id, context: GHOSTTY_SURFACE_CONTEXT_TAB, - configTemplate: nil, + configTemplate: inheritedConfig, portOrdinal: portOrdinal ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) // Create tab in bonsplit if let newTabId = bonsplitController.createTab( @@ -2100,6 +2316,9 @@ extension Workspace: BonsplitDelegate { } panel.focus() + if let terminalPanel = panel as? TerminalPanel { + rememberTerminalConfigInheritanceSource(terminalPanel) + } let isManuallyUnread = manualUnreadPanelIds.contains(panelId) let markedAt = manualUnreadMarkedAt[panelId] if Self.shouldClearManualUnread( @@ -2327,6 +2546,10 @@ extension Workspace: BonsplitDelegate { panelSubscriptions.removeValue(forKey: panelId) surfaceTTYNames.removeValue(forKey: panelId) PortScanner.shared.unregisterPanel(workspaceId: id, panelId: panelId) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: panelId) + if lastTerminalConfigInheritancePanelId == panelId { + lastTerminalConfigInheritancePanelId = nil + } // Keep the workspace invariant: always retain at least one real panel. // This prevents runtime close callbacks from ever collapsing into a tabless workspace. @@ -2519,15 +2742,7 @@ extension Workspace: BonsplitDelegate { // Keep the existing placeholder tab identity and replace only the panel mapping. // This avoids an extra create+close tab churn that can transiently render an // empty pane during drag-to-split of a single-tab pane. - let inheritedConfig: ghostty_surface_config_s? = { - for panel in panels.values { - if let terminalPanel = panel as? TerminalPanel, - let surface = terminalPanel.surface.surface { - return ghostty_surface_inherited_config(surface, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } - } - return nil - }() + let inheritedConfig = inheritedTerminalConfig(inPane: originalPane) let replacementPanel = TerminalPanel( workspaceId: id, @@ -2537,6 +2752,7 @@ extension Workspace: BonsplitDelegate { ) panels[replacementPanel.id] = replacementPanel panelTitles[replacementPanel.id] = replacementPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: replacementPanel.id, configTemplate: inheritedConfig) surfaceIdToPanelId[replacementTab.id] = replacementPanel.id bonsplitController.updateTab( @@ -2579,7 +2795,7 @@ extension Workspace: BonsplitDelegate { // Get the focused terminal in the original pane to inherit config from guard let sourceTabId = controller.selectedTab(inPane: originalPane)?.id, let sourcePanelId = panelIdFromSurfaceId(sourceTabId), - let sourcePanel = terminalPanel(for: sourcePanelId) else { return } + terminalPanel(for: sourcePanelId) != nil else { return } #if DEBUG dlog( @@ -2588,11 +2804,10 @@ extension Workspace: BonsplitDelegate { ) #endif - let inheritedConfig: ghostty_surface_config_s? = if let existing = sourcePanel.surface.surface { - ghostty_surface_inherited_config(existing, GHOSTTY_SURFACE_CONTEXT_SPLIT) - } else { - nil - } + let inheritedConfig = inheritedTerminalConfig( + preferredPanelId: sourcePanelId, + inPane: originalPane + ) let newPanel = TerminalPanel( workspaceId: id, @@ -2602,6 +2817,7 @@ extension Workspace: BonsplitDelegate { ) panels[newPanel.id] = newPanel panelTitles[newPanel.id] = newPanel.displayTitle + seedTerminalInheritanceFontPoints(panelId: newPanel.id, configTemplate: inheritedConfig) guard let newTabId = bonsplitController.createTab( title: newPanel.displayTitle, @@ -2613,6 +2829,7 @@ extension Workspace: BonsplitDelegate { ) else { panels.removeValue(forKey: newPanel.id) panelTitles.removeValue(forKey: newPanel.id) + terminalInheritanceFontPointsByPanelId.removeValue(forKey: newPanel.id) return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 59208945..c12d2c08 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1238,6 +1238,10 @@ final class BrowserZoomShortcutActionTests: XCTestCase { browserZoomShortcutAction(flags: [.command, .shift], chars: "+", keyCode: 24), .zoomIn ) + XCTAssertEqual( + browserZoomShortcutAction(flags: [.command], chars: "+", keyCode: 30), + .zoomIn + ) } func testZoomOutSupportsMinusAndUnderscoreVariants() { @@ -1316,6 +1320,30 @@ final class BrowserZoomShortcutRoutingPolicyTests: XCTestCase { } } +final class GhosttyResponderResolutionTests: XCTestCase { + private final class FocusProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } + + func testResolvesGhosttyViewFromDescendantResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + let descendant = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + ghosttyView.addSubview(descendant) + + XCTAssertTrue(cmuxOwningGhosttyView(for: descendant) === ghosttyView) + } + + func testResolvesGhosttyViewFromGhosttyResponder() { + let ghosttyView = GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 200, height: 120)) + XCTAssertTrue(cmuxOwningGhosttyView(for: ghosttyView) === ghosttyView) + } + + func testReturnsNilForUnrelatedResponder() { + let view = FocusProbeView(frame: NSRect(x: 0, y: 0, width: 40, height: 40)) + XCTAssertNil(cmuxOwningGhosttyView(for: view)) + } +} + final class CommandPaletteKeyboardNavigationTests: XCTestCase { func testArrowKeysMoveSelectionWithoutModifiers() { XCTAssertEqual( @@ -2313,6 +2341,141 @@ final class TabManagerSurfaceCreationTests: XCTestCase { } } +@MainActor +final class WorkspaceTerminalConfigInheritanceSelectionTests: XCTestCase { + func testPrefersSelectedTerminalInTargetPaneOverFocusedTerminalElsewhere() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftPanelId = workspace.focusedPanelId, + let rightPanel = workspace.newTerminalSplit(from: leftPanelId, orientation: .horizontal), + let leftPaneId = workspace.paneId(forPanelId: leftPanelId) else { + XCTFail("Expected workspace split setup to succeed") + return + } + + // Programmatic split focuses the new right panel by default. + XCTAssertEqual(workspace.focusedPanelId, rightPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: leftPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftPanelId, + "Expected inheritance to use the selected terminal in the target pane" + ) + } + + func testFallsBackToAnotherTerminalInPaneWhenSelectedTabIsBrowser() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected workspace browser setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: paneId) + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected inheritance to fall back to a terminal in the pane when browser is selected" + ) + } + + func testPreferredTerminalPanelWinsWhenProvided() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with a terminal panel") + return + } + + let sourcePanel = workspace.terminalPanelForConfigInheritance(preferredPanelId: terminalPanelId) + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testPrefersLastFocusedTerminalWhenBrowserFocusedInDifferentPane() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = workspace.terminalPanelForConfigInheritance(inPane: rightPaneId) + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected inheritance to prefer last focused terminal when browser is focused in another pane" + ) + } +} + +@MainActor +final class TabManagerWorkspaceConfigInheritanceSourceTests: XCTestCase { + func testUsesFocusedTerminalWhenTerminalIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId else { + XCTFail("Expected selected workspace with focused terminal") + return + } + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual(sourcePanel?.id, terminalPanelId) + } + + func testFallsBackToTerminalWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let terminalPanelId = workspace.focusedPanelId, + let paneId = workspace.paneId(forPanelId: terminalPanelId), + let browserPanel = workspace.newBrowserSurface(inPane: paneId, focus: true) else { + XCTFail("Expected selected workspace setup to succeed") + return + } + + XCTAssertEqual(workspace.focusedPanelId, browserPanel.id) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + terminalPanelId, + "Expected new workspace inheritance source to resolve to the pane terminal when browser is focused" + ) + } + + func testPrefersLastFocusedTerminalAcrossPanesWhenBrowserIsFocused() { + let manager = TabManager() + guard let workspace = manager.selectedWorkspace, + let leftTerminalPanelId = workspace.focusedPanelId, + let rightTerminalPanel = workspace.newTerminalSplit(from: leftTerminalPanelId, orientation: .horizontal), + let rightPaneId = workspace.paneId(forPanelId: rightTerminalPanel.id) else { + XCTFail("Expected split setup to succeed") + return + } + + workspace.focusPanel(leftTerminalPanelId) + _ = workspace.newBrowserSurface(inPane: rightPaneId, focus: true) + XCTAssertNotEqual(workspace.focusedPanelId, leftTerminalPanelId) + + let sourcePanel = manager.terminalPanelForWorkspaceConfigInheritanceSource() + XCTAssertEqual( + sourcePanel?.id, + leftTerminalPanelId, + "Expected workspace inheritance source to use last focused terminal across panes" + ) + } +} + @MainActor final class TabManagerReopenClosedBrowserFocusTests: XCTestCase { func testReopenFromDifferentWorkspaceFocusesReopenedBrowser() { From 4bc3da65b67e274855fb5b90723e4b21684200d5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 23 Feb 2026 12:19:52 -0800 Subject: [PATCH 7/9] Fix terminal/web portal overflow during narrow pane resizing --- Sources/BrowserWindowPortal.swift | 93 ++++- Sources/GhosttyTerminalView.swift | 98 ++++-- Sources/TerminalWindowPortal.swift | 319 +++++++++++++++++- ...test_terminal_resize_portal_regressions.py | 106 ++++++ vendor/bonsplit | 2 +- 5 files changed, 582 insertions(+), 36 deletions(-) create mode 100644 tests/test_terminal_resize_portal_regressions.py diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 8da7833c..2e82cb66 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -326,6 +326,8 @@ final class WindowBrowserPortal: NSObject { private weak var installedContainerView: NSView? private weak var installedReferenceView: NSView? private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var webView: WKWebView? @@ -345,9 +347,73 @@ final class WindowBrowserPortal: NSObject { hostView.layer?.masksToBounds = true hostView.translatesAutoresizingMaskIntoConstraints = true hostView.autoresizingMask = [] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + synchronizeAllWebViews(excluding: nil, source: "externalGeometry") + } + @discardableResult private func ensureInstalled() -> Bool { guard let window else { return false } @@ -419,13 +485,32 @@ final class WindowBrowserPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> 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 pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool { frame.minX < bounds.minX - epsilon || frame.minY < bounds.minY - epsilon || @@ -765,7 +850,8 @@ final class WindowBrowserPortal: NSObject { _ = synchronizeHostFrameToReference() let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) let hostBounds = hostView.bounds let hasFiniteHostBounds = hostBounds.origin.x.isFinite && @@ -838,6 +924,8 @@ final class WindowBrowserPortal: NSObject { CATransaction.setDisableActions(true) containerView.frame = targetFrame CATransaction.commit() + webView.needsLayout = true + webView.layoutSubtreeIfNeeded() } let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size) @@ -952,6 +1040,7 @@ final class WindowBrowserPortal: NSObject { } func tearDown() { + removeGeometryObservers() for webViewId in Array(entriesByWebViewId.keys) { detachWebView(withId: webViewId) } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0e01b157..f42d79ae 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1501,6 +1501,17 @@ final class TerminalSurface: Identifiable, ObservableObject { } #endif + /// Match upstream Ghostty AppKit sizing: framebuffer dimensions are derived + /// from backing-space points and truncated (never rounded up). + private func pixelDimension(from value: CGFloat) -> UInt32 { + guard value.isFinite else { return 0 } + let floored = floor(max(0, value)) + if floored >= CGFloat(UInt32.max) { + return UInt32.max + } + return UInt32(floored) + } + private func scaleFactors(for view: GhosttyNSView) -> (x: CGFloat, y: CGFloat, layer: CGFloat) { let scale = max( 1.0, @@ -1784,8 +1795,9 @@ final class TerminalSurface: Identifiable, ObservableObject { } ghostty_surface_set_content_scale(createdSurface, scaleFactors.x, scaleFactors.y) - let wpx = UInt32((view.bounds.width * scaleFactors.x).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((view.bounds.height * scaleFactors.y).rounded(.toNearestOrAwayFromZero)) + let backingSize = view.convertToBacking(NSRect(origin: .zero, size: view.bounds.size)).size + let wpx = pixelDimension(from: backingSize.width) + let hpx = pixelDimension(from: backingSize.height) if wpx > 0, hpx > 0 { ghostty_surface_set_size(createdSurface, wpx, hpx) lastPixelWidth = wpx @@ -1824,12 +1836,21 @@ final class TerminalSurface: Identifiable, ObservableObject { #endif } - func updateSize(width: CGFloat, height: CGFloat, xScale: CGFloat, yScale: CGFloat, layerScale: CGFloat) { + func updateSize( + width: CGFloat, + height: CGFloat, + xScale: CGFloat, + yScale: CGFloat, + layerScale: CGFloat, + backingSize: CGSize? = nil + ) { guard let surface = surface else { return } _ = layerScale - let wpx = UInt32((width * xScale).rounded(.toNearestOrAwayFromZero)) - let hpx = UInt32((height * yScale).rounded(.toNearestOrAwayFromZero)) + let resolvedBackingWidth = backingSize?.width ?? (width * xScale) + let resolvedBackingHeight = backingSize?.height ?? (height * yScale) + let wpx = pixelDimension(from: resolvedBackingWidth) + let hpx = pixelDimension(from: resolvedBackingHeight) guard wpx > 0, hpx > 0 else { return } let scaleChanged = !scaleApproximatelyEqual(xScale, lastXScale) || !scaleApproximatelyEqual(yScale, lastYScale) @@ -2114,6 +2135,8 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { private func setup() { // Only enable our instrumented CAMetalLayer in targeted debug/test scenarios. // The lock in GhosttyMetalLayer.nextDrawable() adds overhead we don't want in normal runs. + wantsLayer = true + layer?.masksToBounds = true installEventMonitor() updateTrackingAreas() registerForDraggedTypes(Array(Self.dropTypes)) @@ -2241,17 +2264,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { ghostty_surface_set_display_id(surface, displayID) } - // Recompute from current bounds after layout, not stale pending sizes. + // Recompute from current bounds after layout. Pending size is only a fallback + // when we don't have usable bounds (e.g. detached/off-window transitions). superview?.layoutSubtreeIfNeeded() layoutSubtreeIfNeeded() - let targetSize: CGSize = { - let current = bounds.size - if current.width > 0, current.height > 0 { - return current - } - return pendingSurfaceSize ?? current - }() - updateSurfaceSize(size: targetSize) + updateSurfaceSize() applySurfaceBackground() applySurfaceColorScheme(force: true) applyWindowBackgroundIfActive() @@ -2291,9 +2308,30 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override var isOpaque: Bool { false } + private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize { + if let size, + size.width > 0, + size.height > 0 { + return size + } + + let currentBounds = bounds.size + if currentBounds.width > 0, currentBounds.height > 0 { + return currentBounds + } + + if let pending = pendingSurfaceSize, + pending.width > 0, + pending.height > 0 { + return pending + } + + return currentBounds + } + private func updateSurfaceSize(size: CGSize? = nil) { guard let terminalSurface = terminalSurface else { return } - let size = size ?? bounds.size + let size = resolvedSurfaceSize(preferred: size) guard size.width > 0 && size.height > 0 else { #if DEBUG let signature = "nonPositive-\(Int(size.width))x\(Int(size.height))" @@ -2353,12 +2391,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { let xScale = backingSize.width / size.width let yScale = backingSize.height / size.height let layerScale = max(1.0, window.backingScaleFactor) + let drawablePixelSize = CGSize( + width: floor(max(0, backingSize.width)), + height: floor(max(0, backingSize.height)) + ) CATransaction.begin() CATransaction.setDisableActions(true) layer?.contentsScale = layerScale + layer?.masksToBounds = true if let metalLayer = layer as? CAMetalLayer { - metalLayer.drawableSize = backingSize + metalLayer.drawableSize = drawablePixelSize } CATransaction.commit() @@ -2367,9 +2410,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { height: size.height, xScale: xScale, yScale: yScale, - layerScale: layerScale + layerScale: layerScale, + backingSize: backingSize ) - pendingSurfaceSize = nil } fileprivate func pushTargetSurfaceSize(_ size: CGSize) { @@ -3559,6 +3602,8 @@ final class GhosttySurfaceScrollView: NSView { documentView.addSubview(surfaceView) super.init(frame: .zero) + wantsLayer = true + layer?.masksToBounds = true backgroundView.wantsLayer = true backgroundView.layer?.backgroundColor = @@ -3696,6 +3741,12 @@ final class GhosttySurfaceScrollView: NSView { synchronizeGeometryAndContent() } + /// Request an immediate terminal redraw after geometry updates so stale IOSurface + /// contents do not remain stretched during live resize churn. + func refreshSurfaceNow() { + surfaceView.terminalSurface?.forceRefresh() + } + private func synchronizeGeometryAndContent() { CATransaction.begin() CATransaction.setDisableActions(true) @@ -3705,7 +3756,6 @@ final class GhosttySurfaceScrollView: NSView { scrollView.frame = bounds let targetSize = scrollView.bounds.size surfaceView.frame.size = targetSize - surfaceView.pushTargetSurfaceSize(targetSize) documentView.frame.size.width = scrollView.bounds.width inactiveOverlayView.frame = bounds if let zone = activeDropZone { @@ -3729,6 +3779,7 @@ final class GhosttySurfaceScrollView: NSView { updateFlashPath() synchronizeScrollView() synchronizeSurfaceView() + synchronizeCoreSurface() } override func viewDidMoveToWindow() { @@ -4606,6 +4657,15 @@ final class GhosttySurfaceScrollView: NSView { surfaceView.frame.origin = visibleRect.origin } + /// Match upstream Ghostty behavior: use content area width (excluding non-content + /// regions such as scrollbar space) when telling libghostty the terminal size. + private func synchronizeCoreSurface() { + let width = scrollView.contentSize.width + let height = surfaceView.frame.height + guard width > 0, height > 0 else { return } + surfaceView.pushTargetSurfaceSize(CGSize(width: width, height: height)) + } + private func updateNotificationRingPath() { updateOverlayRingPath( layer: notificationRingLayer, diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 07831c71..cec6847e 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -536,6 +536,8 @@ final class WindowTerminalPortal: NSObject { private weak var installedReferenceView: NSView? private var installConstraints: [NSLayoutConstraint] = [] private var hasDeferredFullSyncScheduled = false + private var hasExternalGeometrySyncScheduled = false + private var geometryObservers: [NSObjectProtocol] = [] private struct Entry { weak var hostedView: GhosttySurfaceScrollView? @@ -550,13 +552,141 @@ final class WindowTerminalPortal: NSObject { init(window: NSWindow) { self.window = window super.init() - hostView.wantsLayer = false + hostView.wantsLayer = true + hostView.layer?.masksToBounds = true + hostView.postsFrameChangedNotifications = true + hostView.postsBoundsChangedNotifications = true hostView.translatesAutoresizingMaskIntoConstraints = false dividerOverlayView.translatesAutoresizingMaskIntoConstraints = true dividerOverlayView.autoresizingMask = [.width, .height] + installGeometryObservers(for: window) _ = ensureInstalled() } + private func installGeometryObservers(for window: NSWindow) { + guard geometryObservers.isEmpty else { return } + + let center = NotificationCenter.default + geometryObservers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSWindow.didEndLiveResizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, + object: nil, + queue: .main + ) { [weak self] notification in + MainActor.assumeIsolated { + guard let self, + let splitView = notification.object as? NSSplitView, + let window = self.window, + splitView.window === window else { return } + self.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.frameDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + geometryObservers.append(center.addObserver( + forName: NSView.boundsDidChangeNotification, + object: hostView, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.scheduleExternalGeometrySynchronize() + } + }) + } + + private func removeGeometryObservers() { + for observer in geometryObservers { + NotificationCenter.default.removeObserver(observer) + } + geometryObservers.removeAll() + } + + private func scheduleExternalGeometrySynchronize() { + guard !hasExternalGeometrySyncScheduled else { return } + hasExternalGeometrySyncScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.hasExternalGeometrySyncScheduled = false + self.synchronizeAllEntriesFromExternalGeometryChange() + } + } + + private func synchronizeLayoutHierarchy() { + installedContainerView?.layoutSubtreeIfNeeded() + installedReferenceView?.layoutSubtreeIfNeeded() + hostView.superview?.layoutSubtreeIfNeeded() + hostView.layoutSubtreeIfNeeded() + _ = synchronizeHostFrameToReference() + } + + @discardableResult + private func synchronizeHostFrameToReference() -> Bool { + guard let container = installedContainerView, + let reference = installedReferenceView else { + return false + } + let frameInContainer = container.convert(reference.bounds, from: reference) + let hasFiniteFrame = + frameInContainer.origin.x.isFinite && + frameInContainer.origin.y.isFinite && + frameInContainer.size.width.isFinite && + frameInContainer.size.height.isFinite + guard hasFiniteFrame else { return false } + + if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostView.frame = frameInContainer + CATransaction.commit() +#if DEBUG + dlog( + "portal.hostFrame.update host=\(portalDebugToken(hostView)) " + + "frame=\(portalDebugFrame(frameInContainer))" + ) +#endif + } + return frameInContainer.width > 1 && frameInContainer.height > 1 + } + + private func synchronizeAllEntriesFromExternalGeometryChange() { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() + synchronizeAllHostedViews(excluding: nil) + + // During live resize, AppKit can deliver frame churn where host/container geometry + // settles a tick before the terminal's own scroll/surface hierarchy. Force a final + // in-place geometry + surface refresh for all visible entries in this window. + for entry in entriesByHostedId.values { + guard let hostedView = entry.hostedView, !hostedView.isHidden else { continue } + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } + } + private func ensureDividerOverlayOnTop() { if dividerOverlayView.superview !== hostView { dividerOverlayView.frame = hostView.bounds @@ -605,6 +735,8 @@ final class WindowTerminalPortal: NSObject { container.addSubview(overlay, positioned: .above, relativeTo: hostView) } + synchronizeLayoutHierarchy() + _ = synchronizeHostFrameToReference() ensureDividerOverlayOnTop() return true @@ -634,13 +766,32 @@ final class WindowTerminalPortal: NSObject { return false } - private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool { + private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> 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 pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { + guard rect.origin.x.isFinite, + rect.origin.y.isFinite, + rect.size.width.isFinite, + rect.size.height.isFinite else { + return rect + } + let scale = max(1.0, view.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0) + func snap(_ value: CGFloat) -> CGFloat { + (value * scale).rounded(.toNearestOrAwayFromZero) / scale + } + return NSRect( + x: snap(rect.origin.x), + y: snap(rect.origin.y), + width: max(0, snap(rect.size.width)), + height: max(0, snap(rect.size.height)) + ) + } + 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 { @@ -649,6 +800,58 @@ final class WindowTerminalPortal: NSObject { return viewIndex > referenceIndex } + /// Convert an anchor view's bounds to window coordinates while honoring ancestor clipping. + /// SwiftUI/AppKit hosting layers can report an anchor bounds wider than its split pane when + /// intrinsic-size content overflows; intersecting through ancestor bounds gives the effective + /// visible rect that should drive portal geometry. + private func effectiveAnchorFrameInWindow(for anchorView: NSView) -> NSRect { + var frameInWindow = anchorView.convert(anchorView.bounds, to: nil) + var current = anchorView.superview + while let ancestor = current { + let ancestorBoundsInWindow = ancestor.convert(ancestor.bounds, to: nil) + let finiteAncestorBounds = + ancestorBoundsInWindow.origin.x.isFinite && + ancestorBoundsInWindow.origin.y.isFinite && + ancestorBoundsInWindow.size.width.isFinite && + ancestorBoundsInWindow.size.height.isFinite + if finiteAncestorBounds { + frameInWindow = frameInWindow.intersection(ancestorBoundsInWindow) + if frameInWindow.isNull { return .zero } + } + if ancestor === installedReferenceView { break } + current = ancestor.superview + } + return frameInWindow + } + + private func seededFrameInHost(for anchorView: NSView) -> NSRect? { + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hasFiniteFrame = + frameInHost.origin.x.isFinite && + frameInHost.origin.y.isFinite && + frameInHost.size.width.isFinite && + frameInHost.size.height.isFinite + guard hasFiniteFrame else { return nil } + + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + if hasFiniteHostBounds { + let clampedFrame = frameInHost.intersection(hostBounds) + if !clampedFrame.isNull, clampedFrame.width > 1, clampedFrame.height > 1 { + return clampedFrame + } + } + + return frameInHost + } + func detachHostedView(withId hostedId: ObjectIdentifier) { guard let entry = entriesByHostedId.removeValue(forKey: hostedId) else { return } if let anchor = entry.anchorView { @@ -740,6 +943,32 @@ final class WindowTerminalPortal: NSObject { } #endif + _ = synchronizeHostFrameToReference() + + // Seed frame/bounds before entering the window so a freshly reparented + // surface doesn't do a transient 800x600 size update on viewDidMoveToWindow. + if let seededFrame = seededFrameInHost(for: anchorView), + seededFrame.width > 0, + seededFrame.height > 0 { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = seededFrame + hostedView.bounds = NSRect(origin: .zero, size: seededFrame.size) + CATransaction.commit() + } else { + // If anchor geometry is still unsettled, keep this hidden/zero-sized until + // synchronizeHostedView resolves a valid target frame on the next layout tick. + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.frame = .zero + hostedView.bounds = .zero + CATransaction.commit() + hostedView.isHidden = true + } + // Keep inner scroll/surface geometry in sync with the seeded outer frame + // before the hosted view enters a window. + hostedView.reconcileGeometryNow() + if hostedView.superview !== hostView { #if DEBUG dlog( @@ -765,10 +994,13 @@ final class WindowTerminalPortal: NSObject { ensureDividerOverlayOnTop() synchronizeHostedView(withId: hostedId) + scheduleDeferredFullSynchronizeAll() pruneDeadEntries() } func synchronizeHostedViewForAnchor(_ anchorView: NSView) { + guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let anchorId = ObjectIdentifier(anchorView) let primaryHostedId = hostedByAnchorId[anchorId] @@ -795,6 +1027,7 @@ final class WindowTerminalPortal: NSObject { private func synchronizeAllHostedViews(excluding hostedIdToSkip: ObjectIdentifier?) { guard ensureInstalled() else { return } + synchronizeLayoutHierarchy() pruneDeadEntries() let hostedIds = Array(entriesByHostedId.keys) for hostedId in hostedIds { @@ -837,16 +1070,44 @@ final class WindowTerminalPortal: NSObject { return } - let frameInWindow = anchorView.convert(anchorView.bounds, to: nil) - let frameInHost = hostView.convert(frameInWindow, from: nil) + _ = synchronizeHostFrameToReference() + let frameInWindow = effectiveAnchorFrameInWindow(for: anchorView) + let frameInHostRaw = hostView.convert(frameInWindow, from: nil) + let frameInHost = Self.pixelSnappedRect(frameInHostRaw, in: hostView) + let hostBounds = hostView.bounds + let hasFiniteHostBounds = + hostBounds.origin.x.isFinite && + hostBounds.origin.y.isFinite && + hostBounds.size.width.isFinite && + hostBounds.size.height.isFinite + let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1 + if !hostBoundsReady { +#if DEBUG + dlog( + "portal.sync.defer hosted=\(portalDebugToken(hostedView)) " + + "reason=hostBoundsNotReady host=\(portalDebugFrame(hostBounds)) " + + "anchor=\(portalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)" + ) +#endif + hostedView.isHidden = true + scheduleDeferredFullSynchronizeAll() + return + } + let hasFiniteFrame = frameInHost.origin.x.isFinite && frameInHost.origin.y.isFinite && frameInHost.size.width.isFinite && frameInHost.size.height.isFinite + let clampedFrame = frameInHost.intersection(hostBounds) + let hasVisibleIntersection = + !clampedFrame.isNull && + clampedFrame.width > 1 && + clampedFrame.height > 1 + let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView) - let tinyFrame = frameInHost.width <= 1 || frameInHost.height <= 1 - let outsideHostBounds = !frameInHost.intersects(hostView.bounds) + let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1 + let outsideHostBounds = !hasVisibleIntersection let shouldHide = !entry.visibleInUI || anchorHidden || @@ -856,29 +1117,45 @@ final class WindowTerminalPortal: NSObject { let oldFrame = hostedView.frame #if DEBUG + let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame) + if frameWasClamped { + dlog( + "portal.frame.clamp hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) " + + "raw=\(portalDebugFrame(frameInHost)) clamped=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" + ) + } let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame if collapsedToTiny { dlog( "portal.frame.collapse hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } else if restoredFromTiny { dlog( "portal.frame.restore hosted=\(portalDebugToken(hostedView)) anchor=\(portalDebugToken(anchorView)) " + - "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(frameInHost))" + "old=\(portalDebugFrame(oldFrame)) new=\(portalDebugFrame(targetFrame))" ) } #endif - if !Self.rectApproximatelyEqual(oldFrame, frameInHost) { + if hasFiniteFrame && !Self.rectApproximatelyEqual(oldFrame, targetFrame) { CATransaction.begin() CATransaction.setDisableActions(true) - hostedView.frame = frameInHost + hostedView.frame = targetFrame CATransaction.commit() + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow() + } - if abs(oldFrame.size.width - frameInHost.size.width) > 0.5 || - abs(oldFrame.size.height - frameInHost.size.height) > 0.5 { - hostedView.reconcileGeometryNow() + if hasFiniteFrame { + let expectedBounds = NSRect(origin: .zero, size: targetFrame.size) + if !Self.rectApproximatelyEqual(hostedView.bounds, expectedBounds) { + CATransaction.begin() + CATransaction.setDisableActions(true) + hostedView.bounds = expectedBounds + CATransaction.commit() } } @@ -888,12 +1165,25 @@ final class WindowTerminalPortal: NSObject { "portal.hidden hosted=\(portalDebugToken(hostedView)) value=\(shouldHide ? 1 : 0) " + "visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " + "tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " + - "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(frameInHost))" + "outside=\(outsideHostBounds ? 1 : 0) frame=\(portalDebugFrame(targetFrame)) " + + "host=\(portalDebugFrame(hostBounds))" ) #endif hostedView.isHidden = shouldHide } +#if DEBUG + dlog( + "portal.sync.result hosted=\(portalDebugToken(hostedView)) " + + "anchor=\(portalDebugToken(anchorView)) host=\(portalDebugToken(hostView)) " + + "hostWin=\(hostView.window?.windowNumber ?? -1) " + + "old=\(portalDebugFrame(oldFrame)) raw=\(portalDebugFrame(frameInHost)) " + + "target=\(portalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " + + "entryVisible=\(entry.visibleInUI ? 1 : 0) hostedHidden=\(hostedView.isHidden ? 1 : 0) " + + "hostBounds=\(portalDebugFrame(hostBounds))" + ) +#endif + ensureDividerOverlayOnTop() } @@ -927,6 +1217,7 @@ final class WindowTerminalPortal: NSObject { } func tearDown() { + removeGeometryObservers() for hostedId in Array(entriesByHostedId.keys) { detachHostedView(withId: hostedId) } diff --git a/tests/test_terminal_resize_portal_regressions.py b/tests/test_terminal_resize_portal_regressions.py new file mode 100644 index 00000000..055b5e54 --- /dev/null +++ b/tests/test_terminal_resize_portal_regressions.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Static regression checks for terminal tiny-pane resize/overflow fixes. + +Guards the key invariants for issue #348: +1) Terminal portal sync must stabilize layout and clamp hosted frames to host bounds. +2) Surface sizing must prefer live bounds over stale pending values when available. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def repo_root() -> Path: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return Path(result.stdout.strip()) + return Path(__file__).resolve().parents[1] + + +def extract_block(source: str, signature: str) -> str: + start = source.find(signature) + if start < 0: + raise ValueError(f"Missing signature: {signature}") + brace_start = source.find("{", start) + if brace_start < 0: + raise ValueError(f"Missing opening brace for: {signature}") + + depth = 0 + for idx in range(brace_start, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[brace_start : idx + 1] + raise ValueError(f"Unbalanced braces for: {signature}") + + +def main() -> int: + root = repo_root() + failures: list[str] = [] + + portal_path = root / "Sources" / "TerminalWindowPortal.swift" + portal_source = portal_path.read_text(encoding="utf-8") + + if "hostView.layer?.masksToBounds = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView layer clipping") + if "hostView.postsFrameChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView frame-change notifications") + if "hostView.postsBoundsChangedNotifications = true" not in portal_source: + failures.append("WindowTerminalPortal init no longer enables hostView bounds-change notifications") + + if "private func synchronizeLayoutHierarchy()" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeLayoutHierarchy()") + if "private func synchronizeHostFrameToReference() -> Bool" not in portal_source: + failures.append("WindowTerminalPortal missing synchronizeHostFrameToReference()") + if "hostedView.reconcileGeometryNow()" not in extract_block( + portal_source, + "func bind(hostedView: GhosttySurfaceScrollView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0)", + ): + failures.append("bind() no longer pre-reconciles hosted geometry before attach") + + sync_block = extract_block(portal_source, "private func synchronizeHostedView(withId hostedId: ObjectIdentifier)") + for required in [ + "let hostBounds = hostView.bounds", + "let clampedFrame = frameInHost.intersection(hostBounds)", + "let targetFrame = (hasFiniteFrame && hasVisibleIntersection) ? clampedFrame : frameInHost", + "scheduleDeferredFullSynchronizeAll()", + "hostedView.reconcileGeometryNow()", + "hostedView.refreshSurfaceNow()", + ]: + if required not in sync_block: + failures.append(f"terminal portal sync missing: {required}") + + terminal_view_path = root / "Sources" / "GhosttyTerminalView.swift" + terminal_view_source = terminal_view_path.read_text(encoding="utf-8") + + resolved_block = extract_block(terminal_view_source, "private func resolvedSurfaceSize(preferred size: CGSize?) -> CGSize") + bounds_index = resolved_block.find("let currentBounds = bounds.size") + pending_index = resolved_block.find("if let pending = pendingSurfaceSize") + if bounds_index < 0 or pending_index < 0 or bounds_index > pending_index: + failures.append("resolvedSurfaceSize() no longer prefers bounds before pendingSurfaceSize") + + update_block = extract_block(terminal_view_source, "private func updateSurfaceSize(size: CGSize? = nil)") + if "let size = resolvedSurfaceSize(preferred: size)" not in update_block: + failures.append("updateSurfaceSize() no longer resolves size via resolvedSurfaceSize()") + + if failures: + print("FAIL: terminal resize/portal regression guards failed") + for item in failures: + print(f" - {item}") + return 1 + + print("PASS: terminal resize/portal regression guards are in place") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/vendor/bonsplit b/vendor/bonsplit index c9186860..2d0d05aa 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit c91868601ef27e673ca884639a724f2d10fcd54d +Subproject commit 2d0d05aad8e1c2c1c56c290718063f9b53408849 From f3fc8804684a81b10d48da4a4f01dac6f5838c85 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 14:58:17 -0800 Subject: [PATCH 8/9] Guard self-hosted CI from fork pull requests --- .github/workflows/ci.yml | 11 +++++++++++ tests/test_ci_self_hosted_guard.sh | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100755 tests/test_ci_self_hosted_guard.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e7bb8bc..cd3dc3a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,15 @@ on: pull_request: jobs: + workflow-guard-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate self-hosted runner guards + run: ./tests/test_ci_self_hosted_guard.sh + web-typecheck: runs-on: ubuntu-latest defaults: @@ -26,6 +35,8 @@ jobs: run: bun tsc --noEmit ui-tests: + # Never run self-hosted jobs for fork pull requests. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: self-hosted concurrency: group: self-hosted-build diff --git a/tests/test_ci_self_hosted_guard.sh b/tests/test_ci_self_hosted_guard.sh new file mode 100755 index 00000000..f046141c --- /dev/null +++ b/tests/test_ci_self_hosted_guard.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Regression test for https://github.com/manaflow-ai/cmux/issues/385. +# Ensures self-hosted UI tests are never run for fork pull requests. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +WORKFLOW_FILE="$ROOT_DIR/.github/workflows/ci.yml" + +EXPECTED_IF="if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository" + +if ! grep -Fq "$EXPECTED_IF" "$WORKFLOW_FILE"; then + echo "FAIL: Missing fork pull_request guard for ui-tests in $WORKFLOW_FILE" + echo "Expected line:" + echo " $EXPECTED_IF" + exit 1 +fi + +if ! awk ' + /^ ui-tests:/ { in_ui_tests=1; next } + in_ui_tests && /^ [^[:space:]]/ { in_ui_tests=0 } + in_ui_tests && /runs-on: self-hosted/ { saw_self_hosted=1 } + in_ui_tests && /github.event.pull_request.head.repo.full_name == github.repository/ { saw_guard=1 } + END { exit !(saw_self_hosted && saw_guard) } +' "$WORKFLOW_FILE"; then + echo "FAIL: ui-tests block must keep both self-hosted and fork guard" + exit 1 +fi + +echo "PASS: ui-tests self-hosted fork guard is present" From c5d20ae0320f9a981e835691b7a16ac7b19f0c53 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:02:48 -0800 Subject: [PATCH 9/9] Add Cmd+P open-directory shortcuts for installed apps (#368) * Add smart Cmd+P directory-open app shortcuts * Fix command palette scroll snap and jank * Fix command palette selection-follow scrolling * Use scrollPosition for command palette list scrolling * Remove generic IDE directory command from Cmd+P * Increase command palette max height to 450px --- Sources/AppDelegate.swift | 182 +++++++++ Sources/ContentView.swift | 359 ++++++++---------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 166 ++++---- 3 files changed, 410 insertions(+), 297 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 11fca42f..ccf70a6a 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -36,6 +36,188 @@ enum FinderServicePathResolver { } } +enum TerminalDirectoryOpenTarget: String, CaseIterable { + case vscode + case cursor + case windsurf + case antigravity + case finder + case terminal + case iterm2 + case ghostty + case warp + case xcode + case androidStudio + case zed + + struct DetectionEnvironment { + let homeDirectoryPath: String + let fileExistsAtPath: (String) -> Bool + + static let live = DetectionEnvironment( + homeDirectoryPath: FileManager.default.homeDirectoryForCurrentUser.path, + fileExistsAtPath: { FileManager.default.fileExists(atPath: $0) } + ) + } + + static var commandPaletteShortcutTargets: [Self] { + Array(allCases) + } + + static func availableTargets(in environment: DetectionEnvironment = .live) -> Set { + Set(commandPaletteShortcutTargets.filter { $0.isAvailable(in: environment) }) + } + + static let cachedLiveAvailableTargets: Set = availableTargets(in: .live) + + var commandPaletteCommandId: String { + "palette.terminalOpenDirectory.\(rawValue)" + } + + var commandPaletteTitle: String { + switch self { + case .vscode: + return "Open Current Directory in VS Code" + case .cursor: + return "Open Current Directory in Cursor" + case .windsurf: + return "Open Current Directory in Windsurf" + case .antigravity: + return "Open Current Directory in Antigravity" + case .finder: + return "Open Current Directory in Finder" + case .terminal: + return "Open Current Directory in Terminal" + case .iterm2: + return "Open Current Directory in iTerm2" + case .ghostty: + return "Open Current Directory in Ghostty" + case .warp: + return "Open Current Directory in Warp" + case .xcode: + return "Open Current Directory in Xcode" + case .androidStudio: + return "Open Current Directory in Android Studio" + case .zed: + return "Open Current Directory in Zed" + } + } + + var commandPaletteKeywords: [String] { + let common = ["terminal", "directory", "open", "ide"] + switch self { + case .vscode: + return common + ["vs", "code", "visual", "studio"] + case .cursor: + return common + ["cursor"] + case .windsurf: + return common + ["windsurf"] + case .antigravity: + return common + ["antigravity"] + case .finder: + return common + ["finder", "file", "manager", "reveal"] + case .terminal: + return common + ["terminal", "shell"] + case .iterm2: + return common + ["iterm", "iterm2", "terminal", "shell"] + case .ghostty: + return common + ["ghostty", "terminal", "shell"] + case .warp: + return common + ["warp", "terminal", "shell"] + case .xcode: + return common + ["xcode", "apple"] + case .androidStudio: + return common + ["android", "studio"] + case .zed: + return common + ["zed"] + } + } + + func isAvailable(in environment: DetectionEnvironment = .live) -> Bool { + applicationPath(in: environment) != nil + } + + func applicationURL(in environment: DetectionEnvironment = .live) -> URL? { + guard let path = applicationPath(in: environment) else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + } + + private func applicationPath(in environment: DetectionEnvironment) -> String? { + for path in expandedCandidatePaths(in: environment) where environment.fileExistsAtPath(path) { + return path + } + return nil + } + + private func expandedCandidatePaths(in environment: DetectionEnvironment) -> [String] { + let globalPrefix = "/Applications/" + let userPrefix = "\(environment.homeDirectoryPath)/Applications/" + var expanded: [String] = [] + + for candidate in applicationBundlePathCandidates { + expanded.append(candidate) + if candidate.hasPrefix(globalPrefix) { + let suffix = String(candidate.dropFirst(globalPrefix.count)) + expanded.append(userPrefix + suffix) + } + } + + return uniquePreservingOrder(expanded) + } + + private var applicationBundlePathCandidates: [String] { + switch self { + case .vscode: + return [ + "/Applications/Visual Studio Code.app", + "/Applications/Code.app", + ] + case .cursor: + return [ + "/Applications/Cursor.app", + "/Applications/Cursor Preview.app", + "/Applications/Cursor Nightly.app", + ] + case .windsurf: + return ["/Applications/Windsurf.app"] + case .antigravity: + return ["/Applications/Antigravity.app"] + case .finder: + return ["/System/Library/CoreServices/Finder.app"] + case .terminal: + return ["/System/Applications/Utilities/Terminal.app"] + case .iterm2: + return [ + "/Applications/iTerm.app", + "/Applications/iTerm2.app", + ] + case .ghostty: + return ["/Applications/Ghostty.app"] + case .warp: + return ["/Applications/Warp.app"] + case .xcode: + return ["/Applications/Xcode.app"] + case .androidStudio: + return ["/Applications/Android Studio.app"] + case .zed: + return [ + "/Applications/Zed.app", + "/Applications/Zed Preview.app", + "/Applications/Zed Nightly.app", + ] + } + } + + private func uniquePreservingOrder(_ paths: [String]) -> [String] { + var seen: Set = [] + var deduped: [String] = [] + for path in paths where seen.insert(path).inserted { + deduped.append(path) + } + return deduped + } +} + enum WorkspaceShortcutMapper { /// Maps Cmd+digit workspace shortcuts to a zero-based workspace index. /// Cmd+1...Cmd+8 target fixed indices; Cmd+9 always targets the last workspace. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2325908c..51c4c694 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -977,14 +977,6 @@ private func commandPaletteWindowOverlayController(for window: NSWindow) -> Wind return controller } -private struct CommandPaletteRowFramePreferenceKey: PreferenceKey { - static var defaultValue: [Int: CGRect] = [:] - - static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) { - value.merge(nextValue(), uniquingKeysWith: { _, rhs in rhs }) - } -} - enum WorkspaceMountPolicy { // Keep only the selected workspace mounted to minimize layer-tree traversal. static let maxMountedWorkspaces = 1 @@ -1120,8 +1112,8 @@ struct ContentView: View { @State private var commandPaletteRenameDraft: String = "" @State private var commandPaletteSelectedResultIndex: Int = 0 @State private var commandPaletteHoveredResultIndex: Int? - @State private var commandPaletteLastSelectionIndex: Int = 0 - @State private var commandPaletteRowFrames: [Int: CGRect] = [:] + @State private var commandPaletteScrollTargetIndex: Int? + @State private var commandPaletteScrollTargetAnchor: UnitPoint? @State private var commandPaletteRestoreFocusTarget: CommandPaletteRestoreFocusTarget? @State private var commandPaletteUsageHistoryByCommandId: [String: CommandPaletteUsageEntry] = [:] @AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey) @@ -1197,11 +1189,6 @@ struct ContentView: View { case kind } - enum CommandPaletteScrollAnchor: Equatable { - case top - case bottom - } - private struct CommandPaletteTrailingLabel { let text: String let style: CommandPaletteTrailingLabelStyle @@ -1277,6 +1264,10 @@ struct ContentView: View { static let panelHasUnread = "panel.hasUnread" static let updateHasAvailable = "update.hasAvailable" + + static func terminalOpenTargetAvailable(_ target: TerminalDirectoryOpenTarget) -> String { + "terminal.openTarget.\(target.rawValue).available" + } } private struct CommandPaletteCommandContribution { @@ -2444,7 +2435,7 @@ struct ContentView: View { private var commandPaletteCommandListView: some View { let visibleResults = Array(commandPaletteResults) let selectedIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let commandPaletteListMaxHeight: CGFloat = 216 + let commandPaletteListMaxHeight: CGFloat = 450 let commandPaletteRowHeight: CGFloat = 24 let commandPaletteEmptyStateHeight: CGFloat = 44 let commandPaletteListContentHeight = visibleResults.isEmpty @@ -2488,133 +2479,85 @@ struct ContentView: View { Divider() - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0) { - if visibleResults.isEmpty { - Text(commandPaletteEmptyStateText) - .font(.system(size: 13, weight: .regular)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 12) - } else { - ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in - let isSelected = index == selectedIndex - let isHovered = commandPaletteHoveredResultIndex == index - let rowBackground: Color = isSelected - ? Color.accentColor.opacity(0.12) - : (isHovered ? Color.primary.opacity(0.08) : .clear) + ScrollView { + LazyVStack(spacing: 0) { + if visibleResults.isEmpty { + Text(commandPaletteEmptyStateText) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 12) + } else { + ForEach(Array(visibleResults.enumerated()), id: \.element.id) { index, result in + let isSelected = index == selectedIndex + let isHovered = commandPaletteHoveredResultIndex == index + let rowBackground: Color = isSelected + ? Color.accentColor.opacity(0.12) + : (isHovered ? Color.primary.opacity(0.08) : .clear) - Button { - runCommandPaletteCommand(result.command) - } label: { - HStack(spacing: 8) { - commandPaletteHighlightedTitleText( - result.command.title, - matchedIndices: result.titleMatchIndices - ) - .font(.system(size: 13, weight: .regular)) - .lineLimit(1) - Spacer() - - if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { - switch trailingLabel.style { - case .shortcut: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) - case .kind: - Text(trailingLabel.text) - .font(.system(size: 11, weight: .regular)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - .padding(.horizontal, 9) - .padding(.vertical, 2) - .frame(maxWidth: .infinity, alignment: .leading) - .background(rowBackground) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: CommandPaletteRowFramePreferenceKey.self, - value: [index: geometry.frame(in: .named("commandPaletteListScroll"))] - ) - } + Button { + runCommandPaletteCommand(result.command) + } label: { + HStack(spacing: 8) { + commandPaletteHighlightedTitleText( + result.command.title, + matchedIndices: result.titleMatchIndices ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .id(index) - .onHover { hovering in - if hovering { - commandPaletteHoveredResultIndex = index - } else if commandPaletteHoveredResultIndex == index { - commandPaletteHoveredResultIndex = nil + .font(.system(size: 13, weight: .regular)) + .lineLimit(1) + Spacer() + + if let trailingLabel = commandPaletteTrailingLabel(for: result.command) { + switch trailingLabel.style { + case .shortcut: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Color.primary.opacity(0.08), in: RoundedRectangle(cornerRadius: 4, style: .continuous)) + case .kind: + Text(trailingLabel.text) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } + .padding(.horizontal, 9) + .padding(.vertical, 2) + .frame(maxWidth: .infinity, alignment: .leading) + .background(rowBackground) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .id(index) + .onHover { hovering in + if hovering { + commandPaletteHoveredResultIndex = index + } else if commandPaletteHoveredResultIndex == index { + commandPaletteHoveredResultIndex = nil + } } } } - // Force a fresh row tree per query so rendered labels/actions stay in lockstep. - .id(commandPaletteQuery) - } - .coordinateSpace(name: "commandPaletteListScroll") - .frame(height: commandPaletteListHeight) - .onChange(of: commandPaletteSelectedResultIndex) { _ in - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - let previousIndex = commandPaletteLastSelectionIndex - defer { commandPaletteLastSelectionIndex = index } - - guard let anchorDecision = Self.commandPaletteScrollAnchor( - selectedIndex: index, - previousIndex: previousIndex, - resultCount: visibleResults.count, - selectedFrame: commandPaletteRowFrames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - - let anchor: UnitPoint - switch anchorDecision { - case .top: - anchor = .top - case .bottom: - anchor = .bottom - } - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.1)) { - proxy.scrollTo(index, anchor: anchor) - } - } - } - .onChange(of: visibleResults.count) { _ in - commandPaletteLastSelectionIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - } - .onPreferenceChange(CommandPaletteRowFramePreferenceKey.self) { frames in - commandPaletteRowFrames = frames - guard !visibleResults.isEmpty else { return } - let index = commandPaletteSelectedIndex(resultCount: visibleResults.count) - guard let anchorDecision = Self.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: index, - resultCount: visibleResults.count, - selectedFrame: frames[index], - viewportHeight: commandPaletteListHeight, - contentHeight: commandPaletteListContentHeight - ) else { return } - let anchor: UnitPoint = anchorDecision == .top ? .top : .bottom - DispatchQueue.main.async { - withAnimation(.easeOut(duration: 0.08)) { - proxy.scrollTo(index, anchor: anchor) - } - } } + .scrollTargetLayout() + // Force a fresh row tree per query so rendered labels/actions stay in lockstep. + .id(commandPaletteQuery) + } + .frame(height: commandPaletteListHeight) + .scrollPosition( + id: Binding( + get: { commandPaletteScrollTargetIndex }, + // Ignore passive readback so manual scrolling doesn't mutate selection-follow state. + set: { _ in } + ), + anchor: commandPaletteScrollTargetAnchor + ) + .onChange(of: commandPaletteSelectedResultIndex) { _ in + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: true) } // Keep Esc-to-close behavior without showing footer controls. @@ -2629,20 +2572,19 @@ struct ContentView: View { } .onAppear { commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex - commandPaletteRowFrames = [:] + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) resetCommandPaletteSearchFocus() } .onChange(of: commandPaletteQuery) { _ in commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil syncCommandPaletteDebugStateForObservedWindow() } .onChange(of: visibleResults.count) { _ in commandPaletteSelectedResultIndex = commandPaletteSelectedIndex(resultCount: visibleResults.count) - commandPaletteLastSelectionIndex = commandPaletteSelectedResultIndex + updateCommandPaletteScrollTarget(resultCount: visibleResults.count, animated: false) if let hoveredIndex = commandPaletteHoveredResultIndex, hoveredIndex >= visibleResults.count { commandPaletteHoveredResultIndex = nil } @@ -3245,18 +3187,29 @@ struct ContentView: View { if let panelContext = focusedPanelContext { let workspace = panelContext.workspace let panelId = panelContext.panelId + let panelIsTerminal = panelContext.panel.panelType == .terminal snapshot.setBool(CommandPaletteContextKeys.hasFocusedPanel, true) snapshot.setString( CommandPaletteContextKeys.panelName, panelDisplayName(workspace: workspace, panelId: panelId, fallback: panelContext.panel.displayTitle) ) snapshot.setBool(CommandPaletteContextKeys.panelIsBrowser, panelContext.panel.panelType == .browser) - snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelContext.panel.panelType == .terminal) + snapshot.setBool(CommandPaletteContextKeys.panelIsTerminal, panelIsTerminal) snapshot.setBool(CommandPaletteContextKeys.panelHasCustomName, workspace.panelCustomTitles[panelId] != nil) snapshot.setBool(CommandPaletteContextKeys.panelShouldPin, !workspace.isPanelPinned(panelId)) let hasUnread = workspace.manualUnreadPanelIds.contains(panelId) || notificationStore.hasUnreadNotification(forTabId: workspace.id, surfaceId: panelId) snapshot.setBool(CommandPaletteContextKeys.panelHasUnread, hasUnread) + + if panelIsTerminal { + let availableTargets = TerminalDirectoryOpenTarget.cachedLiveAvailableTargets + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + snapshot.setBool( + CommandPaletteContextKeys.terminalOpenTargetAvailable(target), + availableTargets.contains(target) + ) + } + } } if case .updateAvailable = updateViewModel.effectiveState { @@ -3667,15 +3620,20 @@ struct ContentView: View { ) ) - contributions.append( - CommandPaletteCommandContribution( - commandId: "palette.terminalOpenDirectory", - title: constant("Open Current Directory in IDE"), - subtitle: terminalPanelSubtitle, - keywords: ["terminal", "directory", "open", "ide", "code", "default app"], - when: { $0.bool(CommandPaletteContextKeys.panelIsTerminal) } + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + contributions.append( + CommandPaletteCommandContribution( + commandId: target.commandPaletteCommandId, + title: constant(target.commandPaletteTitle), + subtitle: terminalPanelSubtitle, + keywords: target.commandPaletteKeywords, + when: { context in + context.bool(CommandPaletteContextKeys.panelIsTerminal) + && context.bool(CommandPaletteContextKeys.terminalOpenTargetAvailable(target)) + } + ) ) - ) + } contributions.append( CommandPaletteCommandContribution( commandId: "palette.terminalFind", @@ -3938,9 +3896,11 @@ struct ContentView: View { _ = tabManager.createBrowserSplit(direction: .right, url: url) } - registry.register(commandId: "palette.terminalOpenDirectory") { - if !openFocusedDirectoryInDefaultApp() { - NSSound.beep() + for target in TerminalDirectoryOpenTarget.commandPaletteShortcutTargets { + registry.register(commandId: target.commandPaletteCommandId) { + if !openFocusedDirectory(in: target) { + NSSound.beep() + } } } registry.register(commandId: "palette.terminalFind") { @@ -4004,61 +3964,43 @@ struct ContentView: View { return min(max(commandPaletteSelectedResultIndex, 0), resultCount - 1) } - static func commandPaletteScrollAnchor( + static func commandPaletteScrollPositionAnchor( selectedIndex: Int, - previousIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { + resultCount: Int + ) -> UnitPoint? { guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - // Always pin edges exactly into view when selection reaches first/last. if selectedIndex <= 0 { - return .top + return UnitPoint.top } if selectedIndex >= resultCount - 1 { - return .bottom + return UnitPoint.bottom } - - if let frame = selectedFrame, - frame.minY >= (0 - epsilon), - frame.maxY <= (viewportHeight + epsilon) { - return nil - } - - return selectedIndex >= previousIndex ? .bottom : .top + return nil } - static func commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: Int, - resultCount: Int, - selectedFrame: CGRect?, - viewportHeight: CGFloat, - contentHeight: CGFloat, - epsilon: CGFloat = 0.5 - ) -> CommandPaletteScrollAnchor? { - guard resultCount > 0 else { return nil } - guard contentHeight > viewportHeight else { return nil } - - let isTop = selectedIndex <= 0 - let isBottom = selectedIndex >= (resultCount - 1) - guard isTop || isBottom else { return nil } - - guard let frame = selectedFrame else { - return isTop ? .top : .bottom + private func updateCommandPaletteScrollTarget(resultCount: Int, animated: Bool) { + guard resultCount > 0 else { + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil + return } - if isTop { - let topDelta = abs(frame.minY) - return topDelta > epsilon ? .top : nil - } + let selectedIndex = commandPaletteSelectedIndex(resultCount: resultCount) + commandPaletteScrollTargetAnchor = Self.commandPaletteScrollPositionAnchor( + selectedIndex: selectedIndex, + resultCount: resultCount + ) - let bottomDelta = abs(frame.maxY - viewportHeight) - return bottomDelta > epsilon ? .bottom : nil + let assignTarget = { + commandPaletteScrollTargetIndex = selectedIndex + } + if animated { + withAnimation(.easeOut(duration: 0.1)) { + assignTarget() + } + } else { + assignTarget() + } } private func moveCommandPaletteSelection(by delta: Int) { @@ -4252,8 +4194,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil resetCommandPaletteSearchFocus() syncCommandPaletteDebugStateForObservedWindow() } @@ -4266,8 +4208,8 @@ struct ContentView: View { commandPaletteRenameDraft = "" commandPaletteSelectedResultIndex = 0 commandPaletteHoveredResultIndex = nil - commandPaletteLastSelectionIndex = 0 - commandPaletteRowFrames = [:] + commandPaletteScrollTargetIndex = nil + commandPaletteScrollTargetAnchor = nil isCommandPaletteSearchFocused = false isCommandPaletteRenameFocused = false commandPaletteRestoreFocusTarget = nil @@ -4494,9 +4436,22 @@ struct ContentView: View { return NSWorkspace.shared.open(url) } - private func openFocusedDirectoryInDefaultApp() -> Bool { + private func openFocusedDirectory(in target: TerminalDirectoryOpenTarget) -> Bool { guard let directoryURL = focusedTerminalDirectoryURL() else { return false } - return NSWorkspace.shared.open(directoryURL) + return openFocusedDirectory(directoryURL, in: target) + } + + private func openFocusedDirectory(_ directoryURL: URL, in target: TerminalDirectoryOpenTarget) -> Bool { + switch target { + case .finder: + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directoryURL.path) + return true + default: + guard let applicationURL = target.applicationURL() else { return false } + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([directoryURL], withApplicationAt: applicationURL, configuration: configuration) + return true + } } private func focusedTerminalDirectoryURL() -> URL? { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index c12d2c08..ba914a50 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -1491,115 +1491,34 @@ final class CommandPaletteRenameSelectionSettingsTests: XCTestCase { } final class CommandPaletteSelectionScrollBehaviorTests: XCTestCase { - func testFirstEntryAlwaysPinsToTopWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testFirstEntryPinsToTopAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - previousIndex: 1, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 8, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .top) + XCTAssertEqual(anchor, UnitPoint.top) } - func testLastEntryAlwaysPinsToBottomWhenScrollable() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testLastEntryPinsToBottomAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 19, - previousIndex: 18, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 188, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) - XCTAssertEqual(anchor, .bottom) + XCTAssertEqual(anchor, UnitPoint.bottom) } - func testFullyVisibleMiddleEntryDoesNotScroll() { - let anchor = ContentView.commandPaletteScrollAnchor( + func testMiddleEntryUsesNilAnchorForMinimalScroll() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 6, - previousIndex: 5, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 120, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 20 ) XCTAssertNil(anchor) } - func testOutOfViewMiddleEntryUsesDirectionForAnchor() { - let downAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 9, - previousIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 210, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(downAnchor, .bottom) - - let upAnchor = ContentView.commandPaletteScrollAnchor( - selectedIndex: 8, - previousIndex: 9, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: -6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(upAnchor, .top) - } -} - -final class CommandPaletteEdgeVisibilityCorrectionTests: XCTestCase { - func testTopEdgeReturnsTopWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( + func testEmptyResultsProduceNoAnchor() { + let anchor = ContentView.commandPaletteScrollPositionAnchor( selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 6, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .top) - } - - func testBottomEdgeReturnsBottomWhenNotPinned() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 170, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertEqual(anchor, .bottom) - } - - func testPinnedTopAndBottomReturnNil() { - let topAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 0, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 0, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(topAnchor) - - let bottomAnchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 19, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 192, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 - ) - XCTAssertNil(bottomAnchor) - } - - func testMiddleSelectionNeverForcesCorrection() { - let anchor = ContentView.commandPaletteEdgeVisibilityCorrectionAnchor( - selectedIndex: 8, - resultCount: 20, - selectedFrame: CGRect(x: 0, y: 96, width: 200, height: 24), - viewportHeight: 216, - contentHeight: 480 + resultCount: 0 ) XCTAssertNil(anchor) } @@ -3292,6 +3211,63 @@ final class FinderServicePathResolverTests: XCTestCase { } } +final class TerminalDirectoryOpenTargetAvailabilityTests: XCTestCase { + private func environment( + existingPaths: Set, + homeDirectoryPath: String = "/Users/tester" + ) -> TerminalDirectoryOpenTarget.DetectionEnvironment { + TerminalDirectoryOpenTarget.DetectionEnvironment( + homeDirectoryPath: homeDirectoryPath, + fileExistsAtPath: { existingPaths.contains($0) } + ) + } + + func testAvailableTargetsDetectSystemApplications() { + let env = environment( + existingPaths: [ + "/Applications/Visual Studio Code.app", + "/System/Library/CoreServices/Finder.app", + "/System/Applications/Utilities/Terminal.app", + "/Applications/Zed Preview.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.vscode)) + XCTAssertTrue(availableTargets.contains(.finder)) + XCTAssertTrue(availableTargets.contains(.terminal)) + XCTAssertTrue(availableTargets.contains(.zed)) + XCTAssertFalse(availableTargets.contains(.cursor)) + } + + func testAvailableTargetsFallbackToUserApplications() { + let env = environment( + existingPaths: [ + "/Users/tester/Applications/Cursor.app", + "/Users/tester/Applications/Warp.app", + "/Users/tester/Applications/Android Studio.app", + ] + ) + + let availableTargets = TerminalDirectoryOpenTarget.availableTargets(in: env) + XCTAssertTrue(availableTargets.contains(.cursor)) + XCTAssertTrue(availableTargets.contains(.warp)) + XCTAssertTrue(availableTargets.contains(.androidStudio)) + XCTAssertFalse(availableTargets.contains(.vscode)) + } + + func testITerm2DetectsLegacyBundleName() { + let env = environment(existingPaths: ["/Applications/iTerm.app"]) + XCTAssertTrue(TerminalDirectoryOpenTarget.iterm2.isAvailable(in: env)) + } + + func testCommandPaletteShortcutsExcludeGenericIDEEntry() { + let targets = TerminalDirectoryOpenTarget.commandPaletteShortcutTargets + XCTAssertFalse(targets.contains(where: { $0.commandPaletteTitle == "Open Current Directory in IDE" })) + XCTAssertFalse(targets.contains(where: { $0.commandPaletteCommandId == "palette.terminalOpenDirectory" })) + } +} + final class BrowserSearchEngineTests: XCTestCase { func testGoogleSearchURL() throws { let url = try XCTUnwrap(BrowserSearchEngine.google.searchURL(query: "hello world"))