diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1fddf..17ec0f5c4506 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; + A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; @@ -211,6 +212,7 @@ A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; + A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; @@ -458,6 +460,7 @@ children = ( A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, + A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -785,6 +788,7 @@ A5001001 /* cmuxApp.swift in Sources */, A5001002 /* ContentView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, + A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9d04fe352c0d..57da83be6007 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -11,20 +11,37 @@ import Darwin 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) @@ -2581,6 +2598,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() @@ -5810,6 +5831,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent terminalPanel.hostedView.reconcileGeometryNow() terminalPanel.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() terminalPanel.surface.forceRefresh(reason: "appDelegate.refreshAfterGhosttyConfigReload") + // Force each surface to re-derive its config with the correct light/dark + // conditional state and re-apply the color scheme from the current appearance. + terminalPanel.surface.reapplyColorSchemeAndConfig() refreshedCount += 1 } #if DEBUG @@ -7084,10 +7108,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) @@ -10119,6 +10153,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) } @@ -10127,7 +10168,14 @@ 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") + func toggleNotificationsPopover(animated: Bool = true, anchorView: NSView? = nil) { + if #available(macOS 26.0, *) { + NotificationCenter.default.post(name: Self.toggleNotificationsPopoverNotification, object: nil) + return + } titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView) } diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4fe7f8ec0b77..4e25307ec87c 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -11,6 +11,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { var description: String? var keywords: [String]? var restart: CmuxRestartBehavior? + var autoApply: Bool? var workspace: CmuxWorkspaceDefinition? var command: String? var confirm: Bool? @@ -24,6 +25,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { description: String? = nil, keywords: [String]? = nil, restart: CmuxRestartBehavior? = nil, + autoApply: Bool? = nil, workspace: CmuxWorkspaceDefinition? = nil, command: String? = nil, confirm: Bool? = nil @@ -32,6 +34,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { self.description = description self.keywords = keywords self.restart = restart + self.autoApply = autoApply self.workspace = workspace self.command = command self.confirm = confirm @@ -43,6 +46,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { description = try container.decodeIfPresent(String.self, forKey: .description) keywords = try container.decodeIfPresent([String].self, forKey: .keywords) restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart) + autoApply = try container.decodeIfPresent(Bool.self, forKey: .autoApply) workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace) command = try container.decodeIfPresent(String.self, forKey: .command) confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) @@ -90,16 +94,25 @@ enum CmuxRestartBehavior: String, Codable, Sendable { case confirm } +enum CmuxWorkspaceTarget: String, Codable, Sendable { + /// Apply the layout to the currently selected workspace. + case current + /// Create a new workspace (default). + case new +} + struct CmuxWorkspaceDefinition: Codable, Sendable { var name: String? var cwd: String? var color: String? + var target: CmuxWorkspaceTarget? var layout: CmuxLayoutNode? - init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { + init(name: String? = nil, cwd: String? = nil, color: String? = nil, target: CmuxWorkspaceTarget? = nil, layout: CmuxLayoutNode? = nil) { self.name = name self.cwd = cwd self.color = color + self.target = target self.layout = layout } @@ -107,6 +120,7 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decodeIfPresent(String.self, forKey: .name) cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + target = try container.decodeIfPresent(CmuxWorkspaceTarget.self, forKey: .target) layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { @@ -270,6 +284,8 @@ final class CmuxConfigStore: ObservableObject { return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") }() + private weak var trackedTabManager: TabManager? + private var autoAppliedWorkspaceIds = Set() private var cancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 @@ -292,6 +308,7 @@ final class CmuxConfigStore: ObservableObject { // MARK: - Public API func wireDirectoryTracking(tabManager: TabManager) { + trackedTabManager = tabManager cancellables.removeAll() tabManager.$selectedTabId @@ -311,6 +328,23 @@ final class CmuxConfigStore: ObservableObject { } .store(in: &cancellables) + // Separate observer for autoApply: fires on every workspace switch + // (after a short delay so the workspace is fully visible). + // Captures the tab ID at emission time so a rapid B→C switch + // doesn't accidentally apply B's config to C. + tabManager.$selectedTabId + .dropFirst() // skip the initial value on subscribe + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] tabId in + guard let tabId else { return } + // Small delay so the config for the new directory loads first. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + self?.checkAutoApply(forTabId: tabId) + } + } + .store(in: &cancellables) + if let directory = tabManager.selectedWorkspace?.currentDirectory { updateLocalConfigPath(directory) } @@ -381,6 +415,47 @@ final class CmuxConfigStore: ObservableObject { loadedCommands = commands commandSourcePaths = sourcePaths configRevision &+= 1 + checkAutoApply() + } + + /// If the selected workspace hasn't been auto-applied this session and a + /// loaded command has `autoApply: true` with `target: "current"`, execute + /// it automatically. Tracks applied workspaces so it only fires once per + /// workspace per app session. + /// - Parameter forTabId: When provided, only applies if this tab is still + /// selected, preventing stale delayed applications after rapid switching. + private func checkAutoApply(forTabId: UUID? = nil) { + guard let tabManager = trackedTabManager, + let workspace = tabManager.selectedWorkspace, + // If a specific tab ID was requested, verify it's still selected. + forTabId == nil || workspace.id == forTabId, + !autoAppliedWorkspaceIds.contains(workspace.id), + // Only auto-apply to workspaces with a single pane — don't tear + // down user-customized layouts or restored split configurations. + workspace.panels.count <= 1 + else { return } + + // Prefer local config directory; fall back to global config directory + // so that autoApply commands defined only in the global config still work. + let baseCwd: String + if let localPath = localConfigPath { + baseCwd = (localPath as NSString).deletingLastPathComponent + } else { + baseCwd = (globalConfigPath as NSString).deletingLastPathComponent + } + + guard let command = loadedCommands.first(where: { + $0.autoApply == true && $0.workspace?.target == .current + }) else { return } + + autoAppliedWorkspaceIds.insert(workspace.id) + CmuxConfigExecutor.execute( + command: command, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: commandSourcePaths[command.id], + globalConfigPath: globalConfigPath + ) } // MARK: - Parsing diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index df7276155c2f..498dec6503a9 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -88,6 +88,30 @@ struct CmuxConfigExecutor { baseCwd: String ) { let workspaceName = wsDef.name ?? command.name + let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) + + // "target": "current" — apply the layout to the selected workspace in-place. + // If no workspace is selected, skip silently rather than falling through + // to the name-based create/recreate path. + if wsDef.target == .current { + guard let current = tabManager.selectedWorkspace else { return } + current.setCustomTitle(workspaceName) + if let color = wsDef.color { + current.setCustomColor(color) + } + if let layout = wsDef.layout { + // Close all panels except the focused one so applyCustomLayout + // starts from a single pane and doesn't stack on existing splits. + let keep = current.focusedPanelId + let panelIdsToClose = current.panels.keys.filter { $0 != keep } + for panelId in panelIdsToClose { + current.closePanel(panelId, force: true) + } + current.applyCustomLayout(layout, baseCwd: resolvedCwd) + } + return + } + let restart = command.restart ?? .ignore if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { @@ -118,7 +142,6 @@ struct CmuxConfigExecutor { } } - let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd) newWorkspace.setCustomTitle(workspaceName) if let color = wsDef.color { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..60f5bc0ec142 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1823,6 +1823,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 @@ -2590,7 +2591,7 @@ struct ContentView: View { } } - private var sidebarView: some View { + private var sidebarContent: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, onSendFeedback: presentFeedbackComposer, @@ -2598,8 +2599,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 @@ -2676,9 +2681,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 } @@ -2869,6 +2882,124 @@ 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: { + terminalContentWithSidebarDropOverlay + .padding(8) + } + .navigationSplitViewStyle(.prominentDetail) + .background(SplitViewDividerHider()) + .toolbar(removing: .sidebarToggle) + .toolbar { + 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 } + ) + } + .onReceive(NotificationCenter.default.publisher(for: AppDelegate.toggleNotificationsPopoverNotification)) { _ in + isNotificationsPopoverPresented.toggle() + } + + 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. @@ -3429,13 +3560,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 @@ -3467,37 +3609,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) @@ -9923,12 +10069,84 @@ struct VerticalTabsSidebar: View { return KeyboardShortcutSettings.shortcut(for: .selectWorkspaceByNumber) } + @ViewBuilder + private func tabItemViewForWorkspace( + _ tab: Workspace, + index: Int, + workspaceCount: Int, + canCloseWorkspace: Bool, + workspaceNumberShortcut: StoredShortcut, + tabItemSettings: SidebarTabItemSettingsSnapshot, + selectedContextTargetIds: [UUID], + selectedRemoteContextMenuWorkspaceIds: [UUID], + allSelectedRemoteContextMenuTargetsConnecting: Bool, + allSelectedRemoteContextMenuTargetsDisconnected: Bool + ) -> some View { + let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) + let contextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? selectedContextTargetIds + : [tab.id] + let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets + ? selectedRemoteContextMenuWorkspaceIds + : (tab.isRemoteWorkspace ? [tab.id] : []) + let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets + ? allSelectedRemoteContextMenuTargetsConnecting + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) + let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets + ? allSelectedRemoteContextMenuTargetsDisconnected + : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) + TabItemView( + tabManager: tabManager, + notificationStore: notificationStore, + tab: tab, + index: index, + isActive: tabManager.selectedTabId == tab.id, + workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( + at: index, + workspaceCount: workspaceCount + ), + workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, + canCloseWorkspace: canCloseWorkspace, + accessibilityWorkspaceCount: workspaceCount, + unreadCount: notificationStore.unreadCount(forTabId: tab.id), + latestNotificationText: { + guard showsSidebarNotificationMessage, + let notification = notificationStore.latestNotification(forTabId: tab.id) else { + return nil + } + let text = notification.body.isEmpty ? notification.title : notification.body + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }(), + rowSpacing: tabRowSpacing, + setSelectionToTabs: { selection = .tabs }, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, + dragAutoScrollController: dragAutoScrollController, + draggedTabId: $draggedTabId, + dropIndicator: $dropIndicator, + contextMenuWorkspaceIds: contextMenuWorkspaceIds, + remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, + allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, + allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, + settings: tabItemSettings + ) + .equatable() + } + var body: some View { let tabs = tabManager.tabs + // sidebarLayout reads sectionRevision internally, establishing the + // SwiftUI dependency — no separate read needed here. + let layout = tabManager.sidebarLayout + let allOrdered = layout.allWorkspacesInOrder let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot + // Index by flat tabs order (not section order) so shift-selection + // ranges and moveBy(_:) work against tabManager.tabs correctly. let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { ($0.element.id, $0.offset) }) @@ -9952,59 +10170,64 @@ struct VerticalTabsSidebar: View { // Workspaces are bounded, so prefer a non-lazy stack here. // LazyVStack + drag-state invalidations can recurse through layout. VStack(spacing: tabRowSpacing) { - ForEach(tabs, id: \.id) { tab in - let index = tabIndexById[tab.id] ?? 0 - let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) - let contextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedContextTargetIds - : [tab.id] - let remoteContextMenuWorkspaceIds = usesSelectedContextMenuTargets - ? selectedRemoteContextMenuWorkspaceIds - : (tab.isRemoteWorkspace ? [tab.id] : []) - let allRemoteContextMenuTargetsConnecting = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsConnecting - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .connecting) - let allRemoteContextMenuTargetsDisconnected = usesSelectedContextMenuTargets - ? allSelectedRemoteContextMenuTargetsDisconnected - : (tab.isRemoteWorkspace && tab.remoteConnectionState == .disconnected) - TabItemView( - tabManager: tabManager, - notificationStore: notificationStore, - tab: tab, - index: index, - isActive: tabManager.selectedTabId == tab.id, - workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( - at: index, - workspaceCount: workspaceCount - ), - workspaceShortcutModifierSymbol: workspaceNumberShortcut.numberedDigitHintPrefix, + // Pinned workspaces always render first + ForEach(layout.pinnedWorkspaces, id: \.id) { tab in + tabItemViewForWorkspace( + tab, index: tabIndexById[tab.id] ?? 0, + workspaceCount: workspaceCount, canCloseWorkspace: canCloseWorkspace, - accessibilityWorkspaceCount: workspaceCount, - unreadCount: notificationStore.unreadCount(forTabId: tab.id), - latestNotificationText: { - guard showsSidebarNotificationMessage, - let notification = notificationStore.latestNotification(forTabId: tab.id) else { - return nil - } - let text = notification.body.isEmpty ? notification.title : notification.body - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - }(), - rowSpacing: tabRowSpacing, - setSelectionToTabs: { selection = .tabs }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed, - dragAutoScrollController: dragAutoScrollController, - draggedTabId: $draggedTabId, - dropIndicator: $dropIndicator, - contextMenuWorkspaceIds: contextMenuWorkspaceIds, - remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, - allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, - allRemoteContextMenuTargetsDisconnected: allRemoteContextMenuTargetsDisconnected, - settings: tabItemSettings + workspaceNumberShortcut: workspaceNumberShortcut, + tabItemSettings: tabItemSettings, + selectedContextTargetIds: selectedContextTargetIds, + selectedRemoteContextMenuWorkspaceIds: selectedRemoteContextMenuWorkspaceIds, + allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, + allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected ) - .equatable() + } + + // Ungrouped (not in any section) unpinned workspaces + ForEach(layout.ungroupedWorkspaces, id: \.id) { tab in + tabItemViewForWorkspace( + tab, index: tabIndexById[tab.id] ?? 0, + workspaceCount: workspaceCount, + canCloseWorkspace: canCloseWorkspace, + workspaceNumberShortcut: workspaceNumberShortcut, + tabItemSettings: tabItemSettings, + selectedContextTargetIds: selectedContextTargetIds, + selectedRemoteContextMenuWorkspaceIds: selectedRemoteContextMenuWorkspaceIds, + allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, + allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected + ) + } + + // Collapsible user-defined sections + ForEach(layout.sectionGroups, id: \.section.id) { group in + VStack(spacing: 0) { + SidebarSectionHeaderView( + section: group.section, + tabManager: tabManager, + workspaceCount: group.workspaces.count + ) + + if !group.section.isCollapsed { + VStack(spacing: tabRowSpacing) { + ForEach(group.workspaces, id: \.id) { tab in + tabItemViewForWorkspace( + tab, index: tabIndexById[tab.id] ?? 0, + workspaceCount: workspaceCount, + canCloseWorkspace: canCloseWorkspace, + workspaceNumberShortcut: workspaceNumberShortcut, + tabItemSettings: tabItemSettings, + selectedContextTargetIds: selectedContextTargetIds, + selectedRemoteContextMenuWorkspaceIds: selectedRemoteContextMenuWorkspaceIds, + allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, + allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected + ) + .padding(.leading, 8) + } + } + } + } } } .padding(.vertical, 8) @@ -10029,10 +10252,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). @@ -10041,7 +10260,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) @@ -10055,9 +10274,19 @@ struct VerticalTabsSidebar: View { } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() - .background(SidebarBackdrop().ignoresSafeArea()) + .background { + if #available(macOS 26.0, *) { + // On macOS 26, NavigationSplitView provides native glass. + // Don't paint any background over it. + Color.clear.ignoresSafeArea() + } else { + SidebarBackdrop().ignoresSafeArea() + } + } .overlay(alignment: .trailing) { - SidebarTrailingBorder() + if #unavailable(macOS 26.0) { + SidebarTrailingBorder() + } } .background( WindowAccessor { window in @@ -12121,6 +12350,7 @@ private struct SidebarFooterIconButtonStyleBody: View { RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color.primary.opacity(backgroundOpacity)) ) + .modifier(FooterButtonGlassModifier(isHovered: isHovered)) .onHover { hovering in isHovered = hovering } @@ -12129,6 +12359,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 @@ -12152,39 +12400,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 @@ -12222,6 +12437,143 @@ private final class SidebarScrollViewResolverView: NSView { } } +// MARK: - Sidebar Section View + +private struct SidebarSectionHeaderView: View { + @ObservedObject var section: SidebarSection + let tabManager: TabManager + let workspaceCount: Int + @State private var isEditing = false + @State private var editedName = "" + @FocusState private var isTextFieldFocused: Bool + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(section.isCollapsed ? 0 : 90)) + .animation(.easeInOut(duration: 0.15), value: section.isCollapsed) + .frame(width: 12, height: 12) + + if isEditing { + TextField("", text: $editedName) + .textFieldStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .focused($isTextFieldFocused) + .onSubmit { + commitRename() + } + .onExitCommand { + isEditing = false + isTextFieldFocused = false + } + .onAppear { + // Delay focus slightly so the TextField is mounted first + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + isTextFieldFocused = true + } + } + .onChange(of: isTextFieldFocused) { focused in + // Commit when focus leaves the field + if !focused && isEditing { + commitRename() + } + } + } else { + Text(section.name) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if !section.isCollapsed { + Text("\(workspaceCount)") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + guard !isEditing else { return } + section.toggleCollapsed() + } + .onReceive(tabManager.$pendingRenameSectionId) { pendingId in + guard pendingId == section.id else { return } + tabManager.pendingRenameSectionId = nil + editedName = section.name + isEditing = true + } + .contextMenu { + Button(section.isCollapsed + ? String(localized: "contextMenu.expandSection", defaultValue: "Expand Section") + : String(localized: "contextMenu.collapseSection", defaultValue: "Collapse Section") + ) { + section.toggleCollapsed() + } + + Button(String(localized: "contextMenu.renameSection", defaultValue: "Rename Section…")) { + editedName = section.name + isEditing = true + } + + Divider() + + Button(String(localized: "contextMenu.moveSectionUp", defaultValue: "Move Section Up")) { + guard let idx = tabManager.sections.firstIndex(where: { $0.id == section.id }), idx > 0 else { return } + tabManager.reorderSection(sectionId: section.id, toIndex: idx - 1) + } + .disabled(tabManager.sections.first?.id == section.id) + + Button(String(localized: "contextMenu.moveSectionDown", defaultValue: "Move Section Down")) { + guard let idx = tabManager.sections.firstIndex(where: { $0.id == section.id }), + idx < tabManager.sections.count - 1 else { return } + tabManager.reorderSection(sectionId: section.id, toIndex: idx + 1) + } + .disabled(tabManager.sections.last?.id == section.id) + + Divider() + + Button(String(localized: "contextMenu.deleteSection", defaultValue: "Delete Section"), role: .destructive) { + tabManager.deleteSection(sectionId: section.id) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(section.name) + .accessibilityHint(section.isCollapsed + ? String(localized: "accessibility.sectionCollapsed", defaultValue: "Collapsed. Double-tap to expand.") + : String(localized: "accessibility.sectionExpanded", defaultValue: "Expanded. Double-tap to collapse.")) + .accessibilityAddTraits(.isButton) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, isTargeted: nil) { providers in + guard let provider = providers.first else { return false } + provider.loadDataRepresentation(forTypeIdentifier: SidebarTabDragPayload.typeIdentifier) { data, _ in + guard let data, let str = String(data: data, encoding: .utf8) else { return } + let prefix = "cmux.sidebar-tab." + guard str.hasPrefix(prefix), + let tabId = UUID(uuidString: String(str.dropFirst(prefix.count))) else { return } + Task { @MainActor in + tabManager.moveWorkspaceToSection(tabId: tabId, sectionId: section.id) + } + } + return true + } + } + + private func commitRename() { + let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + tabManager.renameSection(sectionId: section.id, name: trimmed) + } + isEditing = false + isTextFieldFocused = false + } +} + private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat @@ -12367,6 +12719,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 @@ -12981,6 +13353,7 @@ private struct TabItemView: View, Equatable { .offset(x: -1) } } + .modifier(TabItemGlassModifier(isActive: isActive)) ) .padding(.horizontal, 6) .background { @@ -13288,6 +13661,43 @@ private struct TabItemView: View, Equatable { } .disabled(targetIds.isEmpty) + if !tabManager.sections.isEmpty { + let sections = tabManager.sections + let currentSection = tabManager.sectionForWorkspace(tab.id) + Menu(String(localized: "contextMenu.moveToSection", defaultValue: "Move to Section")) { + Button(String(localized: "contextMenu.noSection", defaultValue: "No Section")) { + for id in targetIds { + tabManager.removeWorkspaceFromSection(tabId: id) + } + } + .disabled(currentSection == nil) + + if !sections.isEmpty { + Divider() + } + + ForEach(sections, id: \.id) { section in + Button(section.name) { + for id in targetIds { + tabManager.moveWorkspaceToSection(tabId: id, sectionId: section.id) + } + } + .disabled(currentSection?.id == section.id) + } + + Divider() + + Button(String(localized: "contextMenu.newSection", defaultValue: "New Section…")) { + let section = tabManager.createSection( + name: String(localized: "sidebar.newSectionDefaultName", defaultValue: "New Section") + ) + for id in targetIds { + tabManager.moveWorkspaceToSection(tabId: id, sectionId: section.id) + } + } + } + } + Divider() if let key = closeWorkspaceShortcut.keyEquivalent { @@ -14815,10 +15225,29 @@ private struct SidebarTabDropDelegate: DropDelegate { return true } + // If both dragged and target are in the same section, reorder within + // the section's workspaceIds so the visual order updates correctly. + if let targetTabId, + let section = tabManager.sectionForWorkspace(draggedTabId), + section.contains(targetTabId) { + let insertAfter = dropIndicator?.edge == .bottom + // Remove first so indices are stable for the insert. + section.workspaceIds.removeAll { $0 == draggedTabId } + if let targetIdx = section.workspaceIds.firstIndex(of: targetTabId) { + let insertIdx = insertAfter ? targetIdx + 1 : targetIdx + section.workspaceIds.insert(draggedTabId, at: min(insertIdx, section.workspaceIds.count)) + } else { + section.workspaceIds.append(draggedTabId) + } +#if DEBUG + dlog("sidebar.drop.section tab=\(draggedTabId.uuidString.prefix(5)) section=\(section.name)") +#endif + } else { #if DEBUG - dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") #endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + } if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] syncSidebarSelection(preferredSelectedTabId: selectedId) @@ -15328,6 +15757,53 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } } +/// 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, *) +final class SplitViewDividerHiderView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + scheduleHide() + } + + func scheduleHide() { + DispatchQueue.main.async { [weak self] in + self?.hideDividers() + } + } + + private func hideDividers() { + guard let window else { return } + patchSplitViews(in: window.contentView) + } + + private func patchSplitViews(in view: NSView?) { + guard let view else { return } + if let splitView = view as? 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) + } + } + for subview in view.subviews { + patchSplitViews(in: subview) + } + } +} + /// 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. @@ -15423,9 +15899,6 @@ private 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 @@ -15435,6 +15908,17 @@ private 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 @@ -15444,6 +15928,30 @@ private struct SidebarBackdrop: View { // When using liquidGlass + behindWindow, window handles glass + tint // Sidebar is fully transparent if !useWindowLevelGlass { + #if compiler(>=6.2) + if #available(macOS 26.0, *), useLiquidGlass { + // Native SwiftUI Liquid Glass on macOS 26+ + Color.clear + .glassEffect( + .regular.tint(Color(nsColor: tintColor)), + in: .rect(cornerRadius: cornerRadius) + ) + .opacity(sidebarBlurOpacity) + } else { + SidebarVisualEffectBackground( + material: material, + blendingMode: blendingMode, + state: state, + opacity: sidebarBlurOpacity, + tintColor: tintColor, + cornerRadius: cornerRadius, + preferLiquidGlass: false + ) + if !useLiquidGlass { + Color(nsColor: tintColor) + } + } + #else SidebarVisualEffectBackground( material: material, blendingMode: blendingMode, @@ -15457,6 +15965,7 @@ private struct SidebarBackdrop: View { if !useLiquidGlass { Color(nsColor: tintColor) } + #endif } } // When material is none or useWindowLevelGlass, render nothing diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 517be0a06ba7..09ab7b7a60b6 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -4441,6 +4441,24 @@ final class TerminalSurface: Identifiable, ObservableObject { ghostty_surface_refresh(surface) } + /// Re-assert the correct color scheme and re-derive the surface config after a config + /// reload. This calls ghostty_surface_update_config which forces the surface to re-apply + /// its conditional state (light/dark) to the current config, then re-applies the color + /// scheme from the Swift side to keep tracking in sync. + func reapplyColorSchemeAndConfig() { + guard let surface, let view = attachedView else { return } + // Force the surface to re-derive its config with its current conditional state. + // ghostty_surface_set_color_scheme has an internal dedup that skips when the + // scheme hasn't changed, but after a config reload the underlying theme data + // may have changed. ghostty_surface_update_config bypasses that dedup. + if let config = GhosttyApp.shared.config { + ghostty_surface_update_config(surface, config) + } + // Re-apply color scheme to ensure the surface's conditional state matches + // the current macOS appearance, in case it drifted. + view.applySurfaceColorScheme(force: true) + } + func applyWindowBackgroundIfActive() { surfaceView.applyWindowBackgroundIfActive() } @@ -5632,7 +5650,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { terminalSurface?.surface } - private func applySurfaceColorScheme(force: Bool = false) { + fileprivate func applySurfaceColorScheme(force: Bool = false) { guard let surface else { return } let bestMatch = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) let scheme: ghostty_color_scheme_e = bestMatch == .darkAqua @@ -9115,7 +9133,23 @@ final class GhosttySurfaceScrollView: NSView { 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, *) { + let inset: CGFloat = 6 + scrollFrame = CGRect( + x: bounds.origin.x + inset, + y: bounds.origin.y, + width: bounds.width - inset, + height: bounds.height - inset + ) + } else { + scrollFrame = bounds + } + _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6498b8bec1c..8132c25f101e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -328,6 +328,9 @@ indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { } struct SessionWorkspaceSnapshot: Codable, Sendable { + /// Optional for backwards compatibility with sessions saved before UUID + /// persistence was added. New snapshots always include the id. + var id: UUID? var processTitle: String var customTitle: String? var customDescription: String? @@ -343,9 +346,17 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var gitBranch: SessionGitBranchSnapshot? } +struct SessionSidebarSectionSnapshot: Codable, Sendable { + var id: UUID + var name: String + var isCollapsed: Bool + var workspaceIds: [UUID] +} + struct SessionTabManagerSnapshot: Codable, Sendable { var selectedWorkspaceIndex: Int? var workspaces: [SessionWorkspaceSnapshot] + var sections: [SessionSidebarSectionSnapshot]? } struct SessionWindowSnapshot: Codable, Sendable { diff --git a/Sources/SidebarSection.swift b/Sources/SidebarSection.swift new file mode 100644 index 000000000000..2d9ec01befc7 --- /dev/null +++ b/Sources/SidebarSection.swift @@ -0,0 +1,72 @@ +import Combine +import Foundation + +/// A user-defined collapsible group in the sidebar. +/// Section membership is stored as ordered workspace UUIDs here, not on the Workspace model. +@MainActor +final class SidebarSection: Identifiable, ObservableObject { + let id: UUID + @Published var name: String + @Published var isCollapsed: Bool + @Published var workspaceIds: [UUID] + + init(id: UUID = UUID(), name: String, isCollapsed: Bool = false, workspaceIds: [UUID] = []) { + self.id = id + self.name = name + self.isCollapsed = isCollapsed + self.workspaceIds = workspaceIds + } + + func contains(_ workspaceId: UUID) -> Bool { + workspaceIds.contains(workspaceId) + } + + func removeWorkspace(_ workspaceId: UUID) { + workspaceIds.removeAll { $0 == workspaceId } + + } + + func addWorkspace(_ workspaceId: UUID, at index: Int? = nil) { + // Remove first to prevent duplicates + workspaceIds.removeAll { $0 == workspaceId } + if let index, index >= 0, index <= workspaceIds.count { + workspaceIds.insert(workspaceId, at: index) + } else { + workspaceIds.append(workspaceId) + } + + } + + func setCollapsed(_ collapsed: Bool) { + isCollapsed = collapsed + + } + + func toggleCollapsed() { + isCollapsed.toggle() + + } +} + +// MARK: - Sidebar Layout + +/// Computed layout for sidebar rendering. Separates pinned workspaces, ungrouped workspaces, +/// and section groups into a single structure consumed by VerticalTabsSidebar. +struct SidebarLayout { + struct SectionGroup { + let section: SidebarSection + let workspaces: [Workspace] + } + + let pinnedWorkspaces: [Workspace] + let ungroupedWorkspaces: [Workspace] + let sectionGroups: [SectionGroup] + + /// All workspaces in sidebar display order: pinned, ungrouped, then section contents. + var allWorkspacesInOrder: [Workspace] { + pinnedWorkspaces + ungroupedWorkspaces + sectionGroups.flatMap(\.workspaces) + } + + /// Empty layout with no workspaces or sections. + static let empty = SidebarLayout(pinnedWorkspaces: [], ungroupedWorkspaces: [], sectionGroups: []) +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5a793c9c3090..d5d2d06f2138 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -758,6 +758,15 @@ class TabManager: ObservableObject { weak var window: NSWindow? @Published var tabs: [Workspace] = [] + @Published var sections: [SidebarSection] = [] { + didSet { rebindSectionObservers() } + } + /// Bumped when any section's internal state changes (collapse, membership, name). + /// Views that read `sidebarLayout` also read this to ensure re-evaluation. + @Published private(set) var sectionRevision: UInt64 = 0 + private var sectionObserverCancellables: [AnyCancellable] = [] + /// Set to a section ID to auto-enter rename mode on the next render. + @Published var pendingRenameSectionId: UUID? @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -2674,6 +2683,114 @@ class TabManager: ObservableObject { return max(clamped, pinnedCount) } + // MARK: - Sidebar Sections + + /// Subscribe to every section's `objectWillChange` so that any property + /// mutation (collapse, membership, name) bumps `sectionRevision` and + /// triggers a SwiftUI re-render of the sidebar layout. + private func rebindSectionObservers() { + sectionObserverCancellables.removeAll() + for section in sections { + section.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.sectionRevision &+= 1 + } + .store(in: §ionObserverCancellables) + } + } + + private func notifySectionChange() { + sectionRevision &+= 1 + } + + @discardableResult + func createSection(name: String) -> SidebarSection { + let section = SidebarSection(name: name) + sections.append(section) + // Delay so SwiftUI renders the new SidebarSectionHeaderView + // (and subscribes to $pendingRenameSectionId) before we emit. + DispatchQueue.main.async { [weak self] in + self?.pendingRenameSectionId = section.id + } + return section + } + + func renameSection(sectionId: UUID, name: String) { + guard let section = sections.first(where: { $0.id == sectionId }) else { return } + section.name = name + notifySectionChange() + } + + func deleteSection(sectionId: UUID) { + sections.removeAll { $0.id == sectionId } + } + + func reorderSection(sectionId: UUID, toIndex targetIndex: Int) { + guard let currentIndex = sections.firstIndex(where: { $0.id == sectionId }) else { return } + let clamped = max(0, min(targetIndex, sections.count - 1)) + guard currentIndex != clamped else { return } + let section = sections.remove(at: currentIndex) + sections.insert(section, at: clamped) + } + + func moveWorkspaceToSection(tabId: UUID, sectionId: UUID, atIndex: Int? = nil) { + // Validate destination exists before modifying any state. + guard let section = sections.first(where: { $0.id == sectionId }) else { return } + // Remove from any existing section first + for s in sections { + s.removeWorkspace(tabId) + } + section.addWorkspace(tabId, at: atIndex) + notifySectionChange() + } + + func removeWorkspaceFromSection(tabId: UUID) { + for section in sections { + section.removeWorkspace(tabId) + } + notifySectionChange() + } + + func sectionForWorkspace(_ tabId: UUID) -> SidebarSection? { + sections.first { $0.contains(tabId) } + } + + var sidebarLayout: SidebarLayout { + // Read sectionRevision to establish a SwiftUI dependency so the + // layout is recomputed whenever any section property changes. + let _ = sectionRevision + let tabById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + let pinnedWorkspaces = tabs.filter { $0.isPinned } + + // Workspace IDs that are in some section (and not pinned) + var sectionedIds = Set() + let sectionGroups: [SidebarLayout.SectionGroup] = sections.map { section in + let workspaces = section.workspaceIds.compactMap { id -> Workspace? in + guard let ws = tabById[id], !ws.isPinned else { return nil } + return ws + } + for ws in workspaces { + sectionedIds.insert(ws.id) + } + return SidebarLayout.SectionGroup(section: section, workspaces: workspaces) + } + + let ungroupedWorkspaces = tabs.filter { !$0.isPinned && !sectionedIds.contains($0.id) } + return SidebarLayout( + pinnedWorkspaces: pinnedWorkspaces, + ungroupedWorkspaces: ungroupedWorkspaces, + sectionGroups: sectionGroups + ) + } + + private func cleanupSectionsForRemovedWorkspace(_ workspaceId: UUID) { + for section in sections { + section.removeWorkspace(workspaceId) + } + notifySectionChange() + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -2752,6 +2869,7 @@ class TabManager: ObservableObject { sentryBreadcrumb("workspace.close", data: ["tabCount": tabs.count - 1]) clearWorkspaceGitProbes(workspaceId: workspace.id) sidebarSelectedWorkspaceIds.remove(workspace.id) + cleanupSectionsForRemovedWorkspace(workspace.id) AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: workspace.id) workspace.teardownAllPanels() @@ -2779,6 +2897,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } clearWorkspaceGitProbes(workspaceId: tabId) sidebarSelectedWorkspaceIds.remove(tabId) + cleanupSectionsForRemovedWorkspace(tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -5729,6 +5848,15 @@ extension TabManager { } } + // Include sidebar sections so create/rename/reorder/collapse triggers autosave. + hasher.combine(sections.count) + for section in sections { + hasher.combine(section.id) + hasher.combine(section.name) + hasher.combine(section.isCollapsed) + hasher.combine(section.workspaceIds) + } + return hasher.finalize() } @@ -5741,9 +5869,19 @@ extension TabManager { let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } + let restorableTabIds = Set(restorableTabs.map(\.id)) + let sectionSnapshots: [SessionSidebarSectionSnapshot] = sections.map { section in + SessionSidebarSectionSnapshot( + id: section.id, + name: section.name, + isCollapsed: section.isCollapsed, + workspaceIds: section.workspaceIds.filter { restorableTabIds.contains($0) } + ) + } return SessionTabManagerSnapshot( selectedWorkspaceIndex: selectedWorkspaceIndex, - workspaces: workspaceSnapshots + workspaces: workspaceSnapshots, + sections: sectionSnapshots.isEmpty ? nil : sectionSnapshots ) } @@ -5792,6 +5930,7 @@ extension TabManager { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let workspace = Workspace( + restoredId: workspaceSnapshot.id, title: workspaceSnapshot.processTitle, workingDirectory: workspaceSnapshot.currentDirectory, portOrdinal: ordinal @@ -5820,9 +5959,21 @@ extension TabManager { newSelectedId = newTabs.first?.id } + // Restore sidebar sections, filtering out workspace IDs that weren't restored. + let restoredTabIds = Set(newTabs.map(\.id)) + let restoredSections: [SidebarSection] = (snapshot.sections ?? []).map { sectionSnapshot in + SidebarSection( + id: sectionSnapshot.id, + name: sectionSnapshot.name, + isCollapsed: sectionSnapshot.isCollapsed, + workspaceIds: sectionSnapshot.workspaceIds.filter { restoredTabIds.contains($0) } + ) + } + // Single atomic assignment of @Published properties so SwiftUI observers // never see an intermediate state with empty tabs or nil selection. tabs = newTabs + sections = restoredSections selectedTabId = newSelectedId let existingIds = Set(newTabs.map(\.id)) pruneBackgroundWorkspaceLoads(existingIds: existingIds) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398fd0e..3b282435d9ad 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1496,6 +1496,23 @@ 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, *) { + // Detect sidebar presence via x-offset. The sidebar has a minimum + // width of 120pt, so any x > 20 reliably indicates a sidebar is + // to our left. This AppKit view cannot access SwiftUI SidebarState + // directly; the frame-based heuristic is the simplest reliable path. + let hasSidebarToLeft = 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 bca164006791..020af8173213 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1022,7 +1022,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 @@ -1355,6 +1355,13 @@ final class UpdateTitlebarAccessoryController { 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 5c9be3014543..9a6bb0b5eaf0 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 e94abe72c004..33eca56bf712 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -293,6 +293,7 @@ extension Workspace { } return SessionWorkspaceSnapshot( + id: id, processTitle: processTitle, customTitle: customTitle, customDescription: customDescription, @@ -6778,7 +6779,18 @@ final class Workspace: Identifiable, ObservableObject { from backgroundColor: NSColor, backgroundOpacity: Double ) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + let hideTabBar: Bool + if #available(macOS 26.0, *) { + // Set tabBarHeight to 0 on macOS 26. PaneContainerView (bonsplit) + // 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( @@ -6825,6 +6837,7 @@ final class Workspace: Identifiable, ObservableObject { } init( + restoredId: UUID? = nil, title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, @@ -6832,7 +6845,7 @@ final class Workspace: Identifiable, ObservableObject { initialTerminalCommand: String? = nil, initialTerminalEnvironment: [String: String] = [:] ) { - self.id = UUID() + self.id = restoredId ?? UUID() self.portOrdinal = portOrdinal self.processTitle = title self.title = title diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index a2fc95dc369e..1978b36869c6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -316,40 +316,42 @@ 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(cmuxConfigStore) - .onAppear { + @ViewBuilder + private var mainWindowContent: some View { + ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .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) - 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) + 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)) { @@ -3710,6 +3712,13 @@ enum AppIconSettings { } static func applyIcon(_ mode: AppIconMode, environment: Environment = .live()) { + // 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 b2788b1e77d4..cfff8a9318f8 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b2788b1e77d43f0c114dcf189aa59cae8abb47de +Subproject commit cfff8a9318f8131604681e4cb86c11925e01bf1e