diff --git a/.gitmodules b/.gitmodules index 51853e8565..37a68773a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -7,4 +7,4 @@ url = https://github.com/manaflow-ai/homebrew-cmux.git [submodule "vendor/bonsplit"] path = vendor/bonsplit - url = https://github.com/manaflow-ai/bonsplit.git + url = https://github.com/rodchristiansen/bonsplit.git diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index abd93a9298..fda984e4b9 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; }; + IC000003 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = IC000002 /* AppIcon.icon */; }; A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; }; A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; }; A5001203 /* UpdateDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001213 /* UpdateDriver.swift */; }; @@ -387,7 +388,7 @@ FE003001 /* SessionIndexStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexStore.swift; sourceTree = ""; }; FE003002 /* SessionIndexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionIndexView.swift; sourceTree = ""; }; FE003003 /* RightSidebarPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RightSidebarPanelView.swift; sourceTree = ""; }; - IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = AppIcon.icon; sourceTree = ""; }; + IC000002 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -824,6 +825,7 @@ buildActionMask = 2147483647; files = ( A5001100 /* Assets.xcassets in Resources */, + IC000003 /* AppIcon.icon in Resources */, 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */, A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index d392cb58ab..ce067933cf 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -22,20 +22,37 @@ func cmuxJavaScriptStringLiteral(_ value: String?) -> String? { final class MainWindowHostingView: NSHostingView { private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + private let usesSystemSafeArea: Bool - override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } - override var safeAreaRect: NSRect { bounds } - override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + override var safeAreaInsets: NSEdgeInsets { + usesSystemSafeArea ? super.safeAreaInsets : NSEdgeInsetsZero + } + override var safeAreaRect: NSRect { + usesSystemSafeArea ? super.safeAreaRect : bounds + } + override var safeAreaLayoutGuide: NSLayoutGuide { + usesSystemSafeArea ? super.safeAreaLayoutGuide : zeroSafeAreaLayoutGuide + } required init(rootView: Content) { + if #available(macOS 26.0, *) { + // On macOS 26, use system safe area so: + // - Sidebar (.ignoresSafeArea) extends under the glass titlebar + // - Terminal content respects the titlebar and stays below it + self.usesSystemSafeArea = true + } else { + self.usesSystemSafeArea = false + } super.init(rootView: rootView) - addLayoutGuide(zeroSafeAreaLayoutGuide) - NSLayoutConstraint.activate([ - zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), - zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), - zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), - zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) + if !usesSystemSafeArea { + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } } @available(*, unavailable) @@ -1002,6 +1019,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent syncMenuBarExtraVisibility() updateController.startUpdaterIfNeeded() } + // Start the titlebar accessory controller on all versions so the + // notifications popover infrastructure is available. On macOS 26 + // the visual titlebar items come from SwiftUI .toolbar, but the + // popover is still managed by the accessory controller. titlebarAccessoryController.start() windowDecorationsController.start() installMainWindowKeyObserver() @@ -5552,10 +5573,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent window.collectionBehavior.insert(.fullScreenDisallowsTiling) } window.title = "" - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true + if #available(macOS 26.0, *) { + // On macOS 26+, let the system render the native glass titlebar + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = false + } else { + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + } window.isMovableByWindowBackground = false - window.isMovable = false + if #available(macOS 26.0, *) { + window.isMovable = true + } else { + window.isMovable = false + } let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) if let restoredFrame { window.setFrame(restoredFrame, display: false) @@ -8588,6 +8619,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif func attachUpdateAccessory(to window: NSWindow) { + if #available(macOS 26.0, *) { + // On macOS 26, toolbar buttons are native SwiftUI .toolbar items + // in the NavigationSplitView. Skip attaching the old titlebar + // accessory views, but the controller is already started (for + // notifications popover support). + return + } titlebarAccessoryController.start() titlebarAccessoryController.attach(to: window) } @@ -8596,17 +8634,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent windowDecorationsController.apply(to: window) } + /// Notification posted on macOS 26 to toggle the SwiftUI notifications popover. + static let toggleNotificationsPopoverNotification = Notification.Name("cmux.toggleNotificationsPopover") + + /// Per-window notifications popover visibility for the macOS 26 SwiftUI path. + /// Keyed by `mainWindowContexts.windowId` so dismiss/isShown can answer + /// without crossing into the legacy titlebar accessory controller, which + /// the 26-only popover never touches. + private var macOS26NotificationPopoverShown: Set = [] + + static let setNotificationsPopoverShownNotification = Notification.Name("cmux.setNotificationsPopoverShown") + static let dismissNotificationsPopoverNotification = Notification.Name("cmux.dismissNotificationsPopover") + + /// Called by the macOS 26 SwiftUI host to report popover visibility changes. + func setMacOS26NotificationPopoverShown(_ shown: Bool, windowId: UUID) { + if shown { + macOS26NotificationPopoverShown.insert(windowId) + } else { + macOS26NotificationPopoverShown.remove(windowId) + } + } + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + if #available(macOS 26.0, *) { + // Scope the broadcast to the owning window so a single bell-button + // tap or menu command only toggles the popover in the intended + // window — not in every cmux window at once. If we can't resolve a + // target window (e.g. menu invocation while no cmux window is key), + // drop the toggle rather than broadcast to every observer. + let targetWindow = anchorView?.window ?? NSApp.keyWindow + guard let targetWindow, + let targetWindowId = mainWindowContexts[ObjectIdentifier(targetWindow)]?.windowId + else { return } + NotificationCenter.default.post( + name: Self.toggleNotificationsPopoverNotification, + object: targetWindow, + userInfo: ["windowId": targetWindowId] + ) + return + } titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } @discardableResult func dismissNotificationsPopoverIfShown() -> Bool { - titlebarAccessoryController.dismissNotificationsPopoverIfShown() + if #available(macOS 26.0, *) { + // On macOS 26 the popover lives in SwiftUI per window; broadcast a + // dismiss to every host so each one closes its own popover if open. + // Returns true if any window had a popover up. + let wasShown = !macOS26NotificationPopoverShown.isEmpty + if wasShown { + NotificationCenter.default.post(name: Self.dismissNotificationsPopoverNotification, object: nil) + } + // Also dismiss the legacy popover for any pre-26 windows that may + // coexist (multi-window scenarios across OS versions). + let legacyDismissed = titlebarAccessoryController.dismissNotificationsPopoverIfShown() + return wasShown || legacyDismissed + } + return titlebarAccessoryController.dismissNotificationsPopoverIfShown() } func isNotificationsPopoverShown() -> Bool { - titlebarAccessoryController.isNotificationsPopoverShown() + if #available(macOS 26.0, *) { + if !macOS26NotificationPopoverShown.isEmpty { return true } + } + return titlebarAccessoryController.isNotificationsPopoverShown() } func jumpToLatestUnread() { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a2f8e22b3a..8f7808a65f 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1555,6 +1555,7 @@ struct ContentView: View { @State private var isResizerBandActive = false @State private var isSidebarResizerCursorActive = false @State private var sidebarResizerCursorStabilizer: DispatchSourceTimer? + @State private var isNotificationsPopoverPresented = false @State private var isCommandPalettePresented = false @State private var commandPaletteQuery: String = "" @State private var commandPaletteMode: CommandPaletteMode = .commands @@ -2360,7 +2361,7 @@ struct ContentView: View { } } - private var sidebarView: some View { + private var sidebarContent: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, @@ -2369,8 +2370,12 @@ struct ContentView: View { selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex ) - .frame(width: sidebarWidth) - .frame(maxHeight: .infinity, alignment: .topLeading) + } + + private var sidebarView: some View { + sidebarContent + .frame(width: sidebarWidth) + .frame(maxHeight: .infinity, alignment: .topLeading) } /// Space at top of content area for the titlebar. This must be at least the actual titlebar @@ -2463,9 +2468,17 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .notifications) .accessibilityHidden(sidebarSelectionState.selection != .notifications) } - .padding(.top, effectiveTitlebarPadding) + .padding(.top, { + if #available(macOS 26.0, *) { + return 0 // Native glass titlebar handles spacing via safe area + } + return effectiveTitlebarPadding + }()) .overlay(alignment: .top) { - if !isMinimalMode { + if #available(macOS 26.0, *) { + // On macOS 26, native glass titlebar + SwiftUI .toolbar handles controls + EmptyView() + } else if !isMinimalMode { // Titlebar overlay is only over terminal content, not the sidebar. customTitlebar } @@ -2858,6 +2871,180 @@ struct ContentView: View { } private var contentAndSidebarLayout: AnyView { + // On macOS 26, use NavigationSplitView so the system recognizes + // the sidebar column and applies native Liquid Glass treatment. + if #available(macOS 26.0, *) { + return AnyView( + NavigationSplitView(columnVisibility: Binding( + get: { sidebarState.isVisible ? .all : .detailOnly }, + set: { newValue in + let shouldShow = (newValue != .detailOnly) + if shouldShow != sidebarState.isVisible { + _ = sidebarState.toggle() + } + } + )) { + sidebarContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + GeometryReader { geo in + Color.clear.onChange(of: geo.size.width) { newWidth in + if abs(newWidth - sidebarWidth) > 1 { + sidebarWidth = newWidth + sidebarState.persistedWidth = newWidth + } + } + } + ) + .navigationSplitViewColumnWidth(min: 120, ideal: sidebarWidth, max: 400) + } detail: { + terminalContentWithRightSidebarPanel + .padding(8) + } + .navigationSplitViewStyle(.automatic) + .background(SplitViewDividerHider()) + // Sidebar-toggle handling on macOS 26 is split by sidebar + // visibility: + // + // * When the sidebar is *shown*, NavigationSplitView's own + // auto-injected toggle places itself correctly — at + // title-bar y-level inside the sidebar column (Apple HIG / + // Mail / Notes). Don't override or strip it. + // * When the sidebar is *hidden*, the same auto-injected + // toggle gets shoved into the toolbar overflow popover + // instead of the leading edge. Mount our own custom + // ToolbarItem(placement: .navigation) so the user always + // has a visible "show sidebar" affordance, and gate + // SystemSidebarToggleStripperView on the same condition so + // the overflow duplicate is removed only in this state. + .background( + Group { + if !sidebarState.isVisible { + SystemSidebarToggleStripper().frame(width: 0, height: 0) + } + } + ) + .toolbar { + if !sidebarState.isVisible { + ToolbarItem(placement: .navigation) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + _ = sidebarState.toggle() + } + } label: { + Image(systemName: "sidebar.left") + } + .accessibilityIdentifier("toolbar.toggleSidebar") + .accessibilityLabel(String(localized: "toolbar.sidebar.accessibilityLabel", defaultValue: "Toggle Sidebar")) + .help(String(localized: "toolbar.sidebar.tooltip", defaultValue: "Toggle Sidebar")) + } + } + + ToolbarItemGroup(placement: .primaryAction) { + ControlGroup { + Button { + tabManager.newSurface() + } label: { + Image(systemName: "terminal") + } + .accessibilityIdentifier("toolbar.newTerminal") + .accessibilityLabel(String(localized: "toolbar.newTerminal.label", defaultValue: "New Terminal")) + + Button { + _ = AppDelegate.shared?.openBrowserAndFocusAddressBar() + } label: { + Image(systemName: "globe") + } + .accessibilityIdentifier("toolbar.newBrowser") + .accessibilityLabel(String(localized: "toolbar.newBrowser.label", defaultValue: "New Browser")) + + Button { + tabManager.createSplit(direction: .right) + } label: { + Image(systemName: "square.split.2x1") + } + .accessibilityIdentifier("toolbar.splitRight") + .accessibilityLabel(String(localized: "toolbar.splitRight.label", defaultValue: "Split Right")) + + Button { + tabManager.createSplit(direction: .down) + } label: { + Image(systemName: "square.split.1x2") + } + .accessibilityIdentifier("toolbar.splitDown") + .accessibilityLabel(String(localized: "toolbar.splitDown.label", defaultValue: "Split Down")) + } + + Button { + if #available(macOS 26.0, *) { + isNotificationsPopoverPresented.toggle() + } else { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "bell") + if notificationStore.unreadCount > 0 { + Text("\(min(notificationStore.unreadCount, 99))") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 14, height: 14) + .background(Circle().fill(Color.red)) + .offset(x: 5, y: -5) + } + } + } + .buttonStyle(.accessoryBarAction) + .accessibilityIdentifier("toolbar.notifications") + .accessibilityLabel(String(localized: "toolbar.notifications.label", defaultValue: "Notifications")) + .popover(isPresented: $isNotificationsPopoverPresented) { + NotificationsPopoverView( + notificationStore: notificationStore, + onDismiss: { isNotificationsPopoverPresented = false } + ) + } + .onChange(of: isNotificationsPopoverPresented) { shown in + // Mirror SwiftUI popover state into AppDelegate so + // dismiss/isShown queries from outside SwiftUI + // (menu commands, scripting) see the real state on + // macOS 26 where the popover is no longer hosted by + // the legacy titlebar accessory controller. + AppDelegate.shared?.setMacOS26NotificationPopoverShown(shown, windowId: windowId) + } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.toggleNotificationsPopoverNotification)) { note in + // Only toggle when the broadcast targets this + // ContentView's window. If no windowId is in the + // userInfo (legacy broadcasts), fall back to + // toggling so we don't regress older call paths. + if let targetWindowId = note.userInfo?["windowId"] as? UUID, + targetWindowId != windowId { + return + } + isNotificationsPopoverPresented.toggle() + } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.dismissNotificationsPopoverNotification)) { _ in + if isNotificationsPopoverPresented { + isNotificationsPopoverPresented = false + } + } + + Button { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "toolbar.newTab") == nil { + appDelegate.openNewMainWindow(nil) + } + } + } label: { + Image(systemName: "plus") + } + .buttonStyle(.accessoryBarAction) + .accessibilityIdentifier("toolbar.newTab") + .accessibilityLabel(String(localized: "toolbar.newTab.label", defaultValue: "New Tab")) + } + } + ) + } + let layout: AnyView // When matching terminal background, use HStack so both sidebar and terminal // sit directly on the window background with no intermediate layers. @@ -3437,13 +3624,24 @@ struct ContentView: View { view = AnyView(view.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier) - window.titlebarAppearsTransparent = true - // Keep window immovable; the sidebar's WindowDragHandleView handles - // drag-to-move via performDrag with temporary movable override. - // isMovableByWindowBackground=true breaks tab reordering, and - // isMovable=true blocks clicks on sidebar buttons in minimal mode. - window.isMovableByWindowBackground = false - window.isMovable = false + if #available(macOS 26.0, *) { + window.titlebarAppearsTransparent = false + window.titlebarSeparatorStyle = .none + } else { + window.titlebarAppearsTransparent = true + } + if #available(macOS 26.0, *) { + // On macOS 26, the system titlebar handles drag natively. + window.isMovable = true + window.isMovableByWindowBackground = false + } else { + // Keep window immovable; the sidebar's WindowDragHandleView handles + // drag-to-move via performDrag with temporary movable override. + // isMovableByWindowBackground=true breaks tab reordering, and + // isMovable=true blocks clicks on sidebar buttons in minimal mode. + window.isMovableByWindowBackground = false + window.isMovable = false + } window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications @@ -3481,37 +3679,41 @@ struct ContentView: View { // User settings decide whether window glass is active. The native Tahoe // NSGlassEffectView path vs the older NSVisualEffectView fallback is chosen // inside WindowGlassEffect.apply. + // On macOS 26+, the system handles glass compositing natively. + // Do NOT manually insert NSGlassEffectView -- it fights the system. let currentThemeBackground = GhosttyBackgroundTheme.currentColor() - let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( - sidebarBlendMode: sidebarBlendMode, - bgGlassEnabled: bgGlassEnabled, - glassEffectAvailable: WindowGlassEffect.isAvailable - ) - let shouldForceTransparentHosting = - shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 - - if shouldForceTransparentHosting { - window.isOpaque = false - // Keep the window clear whenever translucency is active. Relying only on - // terminal focus-driven updates can leave stale opaque window fills. - window.backgroundColor = NSColor.white.withAlphaComponent(0.001) - // Configure contentView hierarchy for transparency. - if let contentView = window.contentView { - makeViewHierarchyTransparent(contentView) - } - } else { - // Browser-focused workspaces may not have an active terminal panel to refresh - // the NSWindow background. Keep opaque theme changes applied here as well. + if #available(macOS 26.0, *) { + // On macOS 26, NavigationSplitView handles glass compositing. + // Keep standard window background for the terminal area. window.backgroundColor = currentThemeBackground window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 - } - - if shouldApplyWindowGlass { - // Apply liquid glass effect to the window with tint from settings - let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) - WindowGlassEffect.apply(to: window, tintColor: tintColor) - } else { WindowGlassEffect.remove(from: window) + } else { + let shouldApplyWindowGlass = cmuxShouldApplyWindowGlass( + sidebarBlendMode: sidebarBlendMode, + bgGlassEnabled: bgGlassEnabled, + glassEffectAvailable: WindowGlassEffect.isAvailable + ) + let shouldForceTransparentHosting = + shouldApplyWindowGlass || currentThemeBackground.alphaComponent < 0.999 + + if shouldForceTransparentHosting { + window.isOpaque = false + window.backgroundColor = NSColor.white.withAlphaComponent(0.001) + if let contentView = window.contentView { + makeViewHierarchyTransparent(contentView) + } + } else { + window.backgroundColor = currentThemeBackground + window.isOpaque = currentThemeBackground.alphaComponent >= 0.999 + } + + if shouldApplyWindowGlass { + let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity) + WindowGlassEffect.apply(to: window, tintColor: tintColor) + } else { + WindowGlassEffect.remove(from: window) + } } AppDelegate.shared?.attachUpdateAccessory(to: window) AppDelegate.shared?.applyWindowDecorations(to: window) @@ -9089,10 +9291,6 @@ struct VerticalTabsSidebar: View { } .frame(width: 0, height: 0) ) - .overlay(alignment: .top) { - SidebarTopScrim(height: trafficLightPadding + 20) - .allowsHitTesting(false) - } .overlay(alignment: .top) { // Match native titlebar behavior in the sidebar top strip: // drag-to-move and double-click action (zoom/minimize). @@ -9101,7 +9299,7 @@ struct VerticalTabsSidebar: View { .background(TitlebarDoubleClickMonitorView()) } .overlay(alignment: .topLeading) { - if isMinimalMode { + if isMinimalMode, #unavailable(macOS 26.0) { HiddenTitlebarSidebarControlsView(notificationStore: notificationStore) .padding(.leading, hiddenTitlebarControlsLeadingInset) .padding(.top, 2) @@ -9116,7 +9314,9 @@ struct VerticalTabsSidebar: View { .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .overlay(alignment: .trailing) { - SidebarTrailingBorder() + if #unavailable(macOS 26.0) { + SidebarTrailingBorder() + } } .background( WindowAccessor { window in @@ -11212,6 +11412,7 @@ private struct SidebarFooterIconButtonStyleBody: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.primary.opacity(backgroundOpacity)) ) + .modifier(FooterButtonGlassModifier(isHovered: isHovered)) .onHover { hovering in isHovered = hovering } @@ -11220,6 +11421,24 @@ private struct SidebarFooterIconButtonStyleBody: View { } } +/// On macOS 26+, adds a subtle Liquid Glass effect to sidebar footer buttons on hover. +private struct FooterButtonGlassModifier: ViewModifier { + let isHovered: Bool + + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(macOS 26.0, *), isHovered { + content.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 8)) + } else { + content + } + #else + content + #endif + } +} + #if DEBUG private struct SidebarDevFooter: View { @ObservedObject var updateViewModel: UpdateViewModel @@ -11244,39 +11463,6 @@ private struct SidebarDevFooter: View { } #endif -private struct SidebarTopScrim: View { - let height: CGFloat - - var body: some View { - SidebarTopBlurEffect() - .frame(height: height) - .mask( - LinearGradient( - colors: [ - Color.black.opacity(0.95), - Color.black.opacity(0.75), - Color.black.opacity(0.35), - Color.clear - ], - startPoint: .top, - endPoint: .bottom - ) - ) - } -} - -private struct SidebarTopBlurEffect: NSViewRepresentable { - func makeNSView(context: Context) -> NSVisualEffectView { - let view = NSVisualEffectView() - view.blendingMode = .withinWindow - view.material = .underWindowBackground - view.state = .active - view.isEmphasized = false - return view - } - - func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} -} private struct SidebarScrollViewResolver: NSViewRepresentable { let onResolve: (NSScrollView?) -> Void @@ -11449,6 +11635,26 @@ enum SidebarTrailingAccessoryWidthPolicy { } } +/// On macOS 26+, applies a native Liquid Glass effect to the active tab background. +/// Applied as a modifier on the background shape so it doesn't add properties to TabItemView +/// and preserves the Equatable optimization. +private struct TabItemGlassModifier: ViewModifier { + let isActive: Bool + + @ViewBuilder + func body(content: Content) -> some View { + #if compiler(>=6.2) + if #available(macOS 26.0, *), isActive { + content.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 6)) + } else { + content + } + #else + content + #endif + } +} + // PERF: TabItemView is Equatable so SwiftUI skips body re-evaluation when // the parent rebuilds with unchanged values. Without this, every TabManager // or NotificationStore publish causes ALL tab items to re-evaluate (~18% of @@ -12069,6 +12275,7 @@ private struct TabItemView: View, Equatable { .offset(x: -1) } } + .modifier(TabItemGlassModifier(isActive: isActive)) ) .padding(.horizontal, 6) .background { @@ -14528,6 +14735,155 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } } + +/// Strips NavigationSplitView's auto-injected sidebar toggle. Mounted only +/// while the sidebar is hidden — in that state the system places its toggle +/// inside the toolbar overflow popover instead of on the leading edge, so we +/// remove it and provide our own ToolbarItem(.navigation) at the correct +/// position. When the sidebar is shown, this stripper is *not* in the view +/// tree, so the system's well-placed in-sidebar toggle remains intact. +@available(macOS 26.0, *) +private struct SystemSidebarToggleStripper: NSViewRepresentable { + func makeNSView(context: Context) -> SystemSidebarToggleStripperView { + SystemSidebarToggleStripperView() + } + + func updateNSView(_ nsView: SystemSidebarToggleStripperView, context: Context) { + nsView.scheduleStrip() + } +} + +@available(macOS 26.0, *) +private final class SystemSidebarToggleStripperView: NSView { + private var observers: [NSObjectProtocol] = [] + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleStrip() + observeToolbarChanges() + } + + func scheduleStrip() { + DispatchQueue.main.async { [weak self] in + self?.stripNow() + } + } + + private func stripNow() { + guard let toolbar = window?.toolbar else { return } + for i in (0.. Bool { + if item.action == toggleSidebarSelector { return true } + let itemId = item.itemIdentifier.rawValue + if itemId.contains("toggleSidebar") || itemId.contains("splitViewSeparator") { + return true + } + return false + } + + private func observeToolbarChanges() { + guard observers.isEmpty else { return } + let center = NotificationCenter.default + + observers.append(center.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, notification.object as? NSWindow === self.window else { return } + self.scheduleStrip() + }) + + observers.append(center.addObserver( + forName: NSToolbar.willAddItemNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, + let toolbar = self.window?.toolbar, + notification.object as? NSToolbar === toolbar else { return } + self.scheduleStrip() + }) + + observers.append(center.addObserver( + forName: NSWindow.didResizeNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard let self, notification.object as? NSWindow === self.window else { return } + self.scheduleStrip() + }) + } + + deinit { + observers.forEach(NotificationCenter.default.removeObserver) + } +} + +/// Finds NSSplitView(s) inside NavigationSplitView and hides dividers +/// by walking the view hierarchy and patching divider style/color properties. +@available(macOS 26.0, *) +private struct SplitViewDividerHider: NSViewRepresentable { + func makeNSView(context: Context) -> SplitViewDividerHiderView { + SplitViewDividerHiderView() + } + + func updateNSView(_ nsView: SplitViewDividerHiderView, context: Context) { + nsView.scheduleHide() + } +} + +@available(macOS 26.0, *) +private final class SplitViewDividerHiderView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleHide() + } + + func scheduleHide() { + DispatchQueue.main.async { [weak self] in + self?.hideDividers() + } + } + + private func hideDividers() { + // The hider is mounted as a `.background` of NavigationSplitView, which + // means our `superview` chain doesn't necessarily go through the + // NSSplitView itself — `.background` views sit as siblings in SwiftUI's + // hosting hierarchy. Walk down from the window root and patch only the + // *topmost* NSSplitView found (which is reliably NavigationSplitView's), + // leaving any nested split-based UI untouched. + guard let root = window?.contentView, + let topmost = Self.firstSplitView(in: root) else { return } + patchSplitView(topmost) + } + + private static func firstSplitView(in view: NSView) -> NSSplitView? { + if let splitView = view as? NSSplitView { return splitView } + for subview in view.subviews { + if let found = firstSplitView(in: subview) { return found } + } + return nil + } + + private func patchSplitView(_ splitView: NSSplitView) { + splitView.dividerStyle = .thin + // Clear the divider color via ObjC messaging (private API). + let selector = NSSelectorFromString("setDividerColor:") + if splitView.responds(to: selector) { + splitView.perform(selector, with: NSColor.clear) + } + } +} + /// 1px trailing border on the sidebar, derived from the terminal chrome background /// using the same logic as bonsplit's TabBarColors.nsColorSeparator: /// dark bg → lighten RGB by 0.16 at 0.36 alpha; light bg → darken by 0.12 at 0.26 alpha. @@ -14625,9 +14981,6 @@ struct SidebarBackdrop: View { ) } - let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) - let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow - let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active let resolvedHex: String = { if colorScheme == .dark, let dark = sidebarTintHexDark { return dark @@ -14637,6 +14990,17 @@ struct SidebarBackdrop: View { return sidebarTintHex }() let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity) + + // On macOS 26+, NavigationSplitView handles the sidebar glass natively. + // Return a clear background so it doesn't fight the system glass. + if #available(macOS 26.0, *) { + return AnyView(Color.clear) + } + + // macOS 13-15: use configurable NSVisualEffectView materials + let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial) + let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow + let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active let useLiquidGlass = materialOption?.usesLiquidGlass ?? false let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f1bd11b6ea..8eec9b93ff 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9590,7 +9590,25 @@ final class GhosttySurfaceScrollView: NSView { let didScrollbarAppearanceChange = synchronizeScrollbarAppearance() let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) - _ = setFrameIfNeeded(scrollView, to: bounds) + // On macOS 26, inset the scroll view on the leading and top edges + // to keep terminal text within the rounded corner safe zone. + // Only leading corners are rounded (when sidebar is visible), so + // right/bottom edges use no inset to maximize terminal real estate. + let scrollFrame: CGRect + if #available(macOS 26.0, *), bounds.width > 16, bounds.height > 16 { + // Clamp width/height to avoid zero/negative dimensions during transient + // tiny host bounds (e.g. animated splits, divider drag start). + let inset: CGFloat = 6 + scrollFrame = CGRect( + x: bounds.origin.x + inset, + y: bounds.origin.y, + width: max(0, bounds.width - inset), + height: max(0, bounds.height - inset) + ) + } else { + scrollFrame = bounds + } + _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index b5d80741c7..3210abd8bb 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -184,7 +184,7 @@ enum KeyboardShortcutSettings { case .quit: return StoredShortcut(key: "q", command: true, shift: false, option: false, control: false) case .toggleSidebar: - return StoredShortcut(key: "b", command: true, shift: false, option: false, control: false) + return StoredShortcut(key: "s", command: true, shift: false, option: false, control: true) case .newTab: return StoredShortcut(key: "n", command: true, shift: false, option: false, control: false) case .openFolder: diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398fd..bc7d730d13 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -888,6 +888,22 @@ final class WindowTerminalPortal: NSObject { abs(lhs.size.height - rhs.size.height) <= epsilon } + /// Walks up from the hosted view to the enclosing NSSplitView and asks + /// whether its sidebar (subview at index 0) is collapsed. Returns nil if + /// no NSSplitView ancestor is found, in which case the caller should fall + /// back to a frame heuristic. + private static func isNavigationSidebarVisible(near view: NSView) -> Bool? { + var ancestor: NSView? = view.superview + while let candidate = ancestor { + if let splitView = candidate as? NSSplitView, + splitView.subviews.count >= 1 { + return !splitView.isSubviewCollapsed(splitView.subviews[0]) + } + ancestor = candidate.superview + } + return nil + } + private static func pixelSnappedRect(_ rect: NSRect, in view: NSView) -> NSRect { guard rect.origin.x.isFinite, rect.origin.y.isFinite, @@ -1496,6 +1512,26 @@ final class WindowTerminalPortal: NSObject { hostedView.bounds = expectedBounds geometryChanged = true } + // On macOS 26, round the terminal's leading corners when a sidebar + // is visible to its left, matching the NavigationSplitView glass shape. + // Applied inside the CATransaction to prevent animation flicker. + if #available(macOS 26.0, *) { + // Ask NavigationSplitView's NSSplitView whether the sidebar + // subview is collapsed. This reflects the actual visibility + // state and survives mid-animation frames where the detail + // column still has a non-zero leading inset but the sidebar + // is logically hidden — a case the prior `origin.x > 20` + // frame heuristic would mis-classify. + let hasSidebarToLeft = Self.isNavigationSidebarVisible(near: hostedView) + ?? (targetFrame.origin.x > 20) + let desiredRadius: CGFloat = hasSidebarToLeft ? 16 : 0 + if hostedView.layer?.cornerRadius != desiredRadius { + hostedView.layer?.cornerRadius = desiredRadius + hostedView.layer?.maskedCorners = hasSidebarToLeft + ? [.layerMinXMinYCorner, .layerMinXMaxYCorner] + : [] + } + } CATransaction.commit() if geometryChanged { hostedView.reconcileGeometryNow() diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index cb0a73194e..7cbeafe995 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1023,7 +1023,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } } -private struct NotificationsPopoverView: View { +struct NotificationsPopoverView: View { @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared let onDismiss: () -> Void @@ -1354,6 +1354,13 @@ final class UpdateTitlebarAccessoryController { // Don't re-attach controls if already attached. guard !attachedWindows.contains(window) else { return } + // On macOS 26, NavigationSplitView's .toolbar provides the controls + // (bell, new tab, etc.), so don't attach the legacy accessory. + if #available(macOS 26.0, *) { + attachedWindows.add(window) + return + } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 5c9be30145..9a6bb0b5ea 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -5,6 +5,9 @@ import SwiftUI @MainActor final class WindowToolbarController: NSObject, NSToolbarDelegate { private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand") + private let sidebarToggleIdentifier = NSToolbarItem.Identifier("cmux.sidebarToggle") + private let notificationsIdentifier = NSToolbarItem.Identifier("cmux.notifications") + private let newTabIdentifier = NSToolbarItem.Identifier("cmux.newTab") private weak var tabManager: TabManager? @@ -123,7 +126,11 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { toolbar.autosavesConfiguration = false toolbar.showsBaselineSeparator = false window.toolbar = toolbar - window.toolbarStyle = .unifiedCompact + if #available(macOS 26.0, *) { + window.toolbarStyle = .unified + } else { + window.toolbarStyle = .unifiedCompact + } window.titleVisibility = .hidden } @@ -154,11 +161,19 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { // MARK: - NSToolbarDelegate func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [commandItemIdentifier, .flexibleSpace] + if #available(macOS 26.0, *) { + return [sidebarToggleIdentifier, notificationsIdentifier, newTabIdentifier, + .flexibleSpace, commandItemIdentifier] + } + return [commandItemIdentifier, .flexibleSpace] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [commandItemIdentifier, .flexibleSpace] + if #available(macOS 26.0, *) { + return [sidebarToggleIdentifier, notificationsIdentifier, newTabIdentifier, + .flexibleSpace, commandItemIdentifier] + } + return [commandItemIdentifier, .flexibleSpace] } func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { @@ -175,8 +190,57 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { return item } + if #available(macOS 26.0, *) { + if itemIdentifier == sidebarToggleIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: String(localized: "toolbar.sidebar.accessibilityDescription", defaultValue: "Toggle Sidebar")) + item.label = String(localized: "toolbar.sidebar.label", defaultValue: "Sidebar") + item.toolTip = String(localized: "toolbar.sidebar.tooltip", defaultValue: "Toggle Sidebar") + item.target = self + item.action = #selector(toggleSidebarAction) + return item + } + + if itemIdentifier == notificationsIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "bell", accessibilityDescription: String(localized: "toolbar.notifications.accessibilityDescription", defaultValue: "Notifications")) + item.label = String(localized: "toolbar.notifications.label", defaultValue: "Notifications") + item.toolTip = String(localized: "toolbar.notifications.tooltip", defaultValue: "Show Notifications") + item.target = self + item.action = #selector(toggleNotificationsAction) + return item + } + + if itemIdentifier == newTabIdentifier { + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.image = NSImage(systemSymbolName: "plus", accessibilityDescription: String(localized: "toolbar.newWorkspace.accessibilityDescription", defaultValue: "New Workspace")) + item.label = String(localized: "toolbar.newWorkspace.label", defaultValue: "New Workspace") + item.toolTip = String(localized: "toolbar.newWorkspace.tooltip", defaultValue: "New Workspace") + item.target = self + item.action = #selector(newTabAction) + return item + } + } return nil } + // MARK: - Toolbar Actions (macOS 26+) + + @objc private func toggleSidebarAction() { + _ = AppDelegate.shared?.sidebarState?.toggle() + } + + @objc private func toggleNotificationsAction() { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } + + @objc private func newTabAction() { + if let appDelegate = AppDelegate.shared { + if appDelegate.addWorkspaceInPreferredMainWindow(debugSource: "toolbar.newTab") == nil { + appDelegate.openNewMainWindow(nil) + } + } + } + } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 55c09bf619..c207aa3c22 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6752,8 +6752,7 @@ final class Workspace: Identifiable, ObservableObject { private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance { bonsplitAppearance( from: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity, - tabTitleFontSize: config.surfaceTabBarFontSize + backgroundOpacity: config.backgroundOpacity ) } @@ -6774,11 +6773,20 @@ final class Workspace: Identifiable, ObservableObject { private static func bonsplitAppearance( from backgroundColor: NSColor, - backgroundOpacity: Double, - tabTitleFontSize: CGFloat = 11 + backgroundOpacity: Double ) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( - tabTitleFontSize: tabTitleFontSize, + let hideTabBar: Bool + if #available(macOS 26.0, *) { + // Set tabBarHeight to 0 on macOS 26. PaneContainerView (bonsplit + // cfff8a9+) conditionally shows the tab bar when pane.tabs.count > 1, + // so this effectively hides it only for single-tab panes. + hideTabBar = true + } else { + hideTabBar = false + } + return BonsplitConfiguration.Appearance( + tabBarHeight: hideTabBar ? 0 : 33, + showSplitButtons: !hideTabBar, splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( @@ -6791,43 +6799,11 @@ final class Workspace: Identifiable, ObservableObject { } func applyGhosttyChrome(from config: GhosttyConfig, reason: String = "unspecified") { - let nextHex = Self.bonsplitChromeHex( + applyGhosttyChrome( backgroundColor: config.backgroundColor, - backgroundOpacity: config.backgroundOpacity + backgroundOpacity: config.backgroundOpacity, + reason: reason ) - let nextTabTitleFontSize = config.surfaceTabBarFontSize - let currentAppearance = bonsplitController.configuration.appearance - let currentBackgroundHex = currentAppearance.chromeColors.backgroundHex - let currentTabTitleFontSize = currentAppearance.tabTitleFontSize - let backgroundChanged = currentBackgroundHex != nextHex - let fontSizeChanged = abs(currentTabTitleFontSize - nextTabTitleFontSize) > 0.0001 - let isNoOp = !backgroundChanged && !fontSizeChanged - - if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground( - "theme apply workspace=\(id.uuidString) reason=\(reason) " + - "currentBg=\(currentBackgroundHex ?? "nil") nextBg=\(nextHex) " + - "currentTabFont=\(String(format: "%.3f", currentTabTitleFontSize)) " + - "nextTabFont=\(String(format: "%.3f", nextTabTitleFontSize)) noop=\(isNoOp)" - ) - } - - guard !isNoOp else { return } - - if backgroundChanged { - bonsplitController.configuration.appearance.chromeColors.backgroundHex = nextHex - } - if fontSizeChanged { - bonsplitController.configuration.appearance.tabTitleFontSize = nextTabTitleFontSize - } - - if GhosttyApp.shared.backgroundLogEnabled { - GhosttyApp.shared.logBackground( - "theme applied workspace=\(id.uuidString) reason=\(reason) " + - "resultingBg=\(bonsplitController.configuration.appearance.chromeColors.backgroundHex ?? "nil") " + - "resultingTabFont=\(String(format: "%.3f", bonsplitController.configuration.appearance.tabTitleFontSize))" - ) - } } func applyGhosttyChrome(backgroundColor: NSColor, backgroundOpacity: Double, reason: String = "unspecified") { @@ -6880,13 +6856,9 @@ final class Workspace: Identifiable, ObservableObject { // Configure bonsplit with keepAllAlive to preserve terminal state // and keep split entry instantaneous. - // Use the cached Ghostty config so new workspaces inherit tab-strip sizing - // without paying repeated parse costs on the workspace-creation hot path. - let initialSurfaceTabBarFontSize = GhosttyConfig.load().surfaceTabBarFontSize let appearance = Self.bonsplitAppearance( from: GhosttyApp.shared.defaultBackgroundColor, - backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity, - tabTitleFontSize: initialSurfaceTabBarFontSize + backgroundOpacity: GhosttyApp.shared.defaultBackgroundOpacity ) let config = BonsplitConfiguration( allowSplits: true, diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 86ad4686d8..e7a7e91149 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -188,42 +188,44 @@ struct cmuxApp: App { defaults.set(targetVersion, forKey: migrationKey) } - var body: some Scene { - WindowGroup { - ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(fileExplorerState) - .environmentObject(cmuxConfigStore) - .onAppear { + @ViewBuilder + private var mainWindowContent: some View { + ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) + .onAppear { #if DEBUG - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { - UpdateLogStore.shared.append("ui test: cmuxApp onAppear") - } + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" { + UpdateLogStore.shared.append("ui test: cmuxApp onAppear") + } #endif - // Start the Unix socket controller for programmatic access - updateSocketController() - appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) - appDelegate.fileExplorerState = fileExplorerState - cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) - cmuxConfigStore.loadAll() - applyAppearance() - if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { - DispatchQueue.main.async { - appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") - } + // Start the Unix socket controller for programmatic access + updateSocketController() + appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + appDelegate.fileExplorerState = fileExplorerState + cmuxConfigStore.wireDirectoryTracking(tabManager: tabManager) + cmuxConfigStore.loadAll() + applyAppearance() + if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" { + DispatchQueue.main.async { + appDelegate.openPreferencesWindow(debugSource: "uiTestShowSettings") } } - .onChange(of: appearanceMode) { _ in - applyAppearance() - } - .onChange(of: socketControlMode) { _ in - updateSocketController() - } - } - .windowStyle(.hiddenTitleBar) + } + .onChange(of: appearanceMode) { _ in + applyAppearance() + } + .onChange(of: socketControlMode) { _ in + updateSocketController() + } + } + + var body: some Scene { + WindowGroup { mainWindowContent } .commands { CommandGroup(replacing: .appSettings) { splitCommandButton(title: String(localized: "menu.app.settings", defaultValue: "Settings…"), shortcut: menuShortcut(for: .openSettings)) { @@ -3744,6 +3746,13 @@ enum AppIconSettings { // apply the resolved icon once didFinishLaunching begins. guard environment.isApplicationFinishedLaunching() else { return } + // On macOS 26+, the system handles dark/light/tinted icons natively + // via the asset catalog appearances. Don't override with custom code. + if #available(macOS 26.0, *) { + environment.stopAppearanceObservation() + return + } + switch mode { case .automatic: environment.startAppearanceObservation() diff --git a/vendor/bonsplit b/vendor/bonsplit index cffd9a66e9..cfff8a9318 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit cffd9a66e9ede9aaedefcfd0c391f0ad49652d72 +Subproject commit cfff8a9318f8131604681e4cb86c11925e01bf1e