From 853003fa647f4521d23d4553a69d2d48d6ca9c51 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sat, 4 Apr 2026 22:57:49 -0700 Subject: [PATCH 01/20] Add collapsible user-defined sidebar sections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now organize workspace tabs into named, collapsible sections in the sidebar. Sections are opt-in — the sidebar behaves identically when no sections exist. - New SidebarSection model with name, collapse state, and ordered workspace membership - Section CRUD in TabManager (create, rename, delete, reorder) - Context menu: "Move to Section" submenu on workspace tabs with "New Section..." option - SidebarSectionHeaderView with disclosure chevron, inline rename, context menu, accessibility labels, and drop target - Drag workspaces onto section headers to assign them - Section state persists across sessions via SessionPersistence - Pinned workspaces always render at top regardless of section - Backward-compatible: old session snapshots restore cleanly --- Sources/ContentView.swift | 321 ++++++++++++++++++++++++++----- Sources/SessionPersistence.swift | 8 + Sources/SidebarSection.swift | 59 ++++++ Sources/TabManager.swift | 103 +++++++++- 4 files changed, 438 insertions(+), 53 deletions(-) create mode 100644 Sources/SidebarSection.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..cf0c4baff2f6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9923,13 +9923,81 @@ 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 + let layout = tabManager.sidebarLayout + let allOrdered = layout.allWorkspacesInOrder let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot - let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { + let tabIndexById = Dictionary(uniqueKeysWithValues: allOrdered.enumerated().map { ($0.element.id, $0.offset) }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } @@ -9952,59 +10020,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) @@ -12222,6 +12295,113 @@ 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 = "" + @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, onCommit: { + let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + tabManager.renameSection(sectionId: section.id, name: trimmed) + } + isEditing = false + }) + .textFieldStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .onExitCommand { isEditing = false } + } 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 { + section.isCollapsed.toggle() + } + .contextMenu { + Button(section.isCollapsed + ? String(localized: "contextMenu.expandSection", defaultValue: "Expand Section") + : String(localized: "contextMenu.collapseSection", defaultValue: "Collapse Section") + ) { + section.isCollapsed.toggle() + } + + 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 struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat @@ -13288,6 +13468,43 @@ private struct TabItemView: View, Equatable { } .disabled(targetIds.isEmpty) + if !tabManager.sections.isEmpty || true { + 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 { diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6498b8bec1c..4acc2434222f 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -343,9 +343,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..1997ec35de22 --- /dev/null +++ b/Sources/SidebarSection.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// 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) + } + } +} + +// 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..bf0887df3061 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -758,6 +758,7 @@ class TabManager: ObservableObject { weak var window: NSWindow? @Published var tabs: [Workspace] = [] + @Published var sections: [SidebarSection] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -2674,6 +2675,82 @@ class TabManager: ObservableObject { return max(clamped, pinnedCount) } + // MARK: - Sidebar Sections + + @discardableResult + func createSection(name: String) -> SidebarSection { + let section = SidebarSection(name: name) + sections.append(section) + return section + } + + func renameSection(sectionId: UUID, name: String) { + guard let section = sections.first(where: { $0.id == sectionId }) else { return } + section.name = name + } + + 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) { + // Remove from any existing section first + for section in sections { + section.removeWorkspace(tabId) + } + guard let section = sections.first(where: { $0.id == sectionId }) else { return } + section.addWorkspace(tabId, at: atIndex) + } + + func removeWorkspaceFromSection(tabId: UUID) { + for section in sections { + section.removeWorkspace(tabId) + } + } + + func sectionForWorkspace(_ tabId: UUID) -> SidebarSection? { + sections.first { $0.contains(tabId) } + } + + var sidebarLayout: SidebarLayout { + 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) + } + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -2752,6 +2829,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 +2857,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) @@ -5741,9 +5820,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 ) } @@ -5820,9 +5909,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) From 8d3c57898da37f651ab71626ad5e04897a80ca1d Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sat, 4 Apr 2026 23:06:18 -0700 Subject: [PATCH 02/20] Add SidebarSection.swift to Xcode project The new file needs to be registered in the .pbxproj so Xcode can find the SidebarSection and SidebarLayout types. --- GhosttyTabs.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) 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 */, From 14a0980b9a30c1e352fed950e5447c8e7e2acc50 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sat, 4 Apr 2026 23:35:15 -0700 Subject: [PATCH 03/20] Fix sidebar section reactivity, rename focus, and live updates - Forward section objectWillChange to TabManager via sectionRevision counter so sidebar re-renders immediately on any section mutation - Use @FocusState on rename TextField with delayed auto-focus so cursor lands in the text field instead of the terminal - Guard onTapGesture during rename editing to prevent conflicts - Add toggleCollapsed()/setCollapsed() helpers that bump revision - Pipe Combine observers from each section to TabManager on didSet --- Sources/ContentView.swift | 50 ++++++++++++++++++++++++++++-------- Sources/SidebarSection.swift | 22 ++++++++++++++++ Sources/TabManager.swift | 34 +++++++++++++++++++++++- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cf0c4baff2f6..bcd79bc335ec 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9991,6 +9991,8 @@ struct VerticalTabsSidebar: View { var body: some View { let tabs = tabManager.tabs + // Read sectionRevision to trigger re-render when any section changes. + let _ = tabManager.sectionRevision let layout = tabManager.sidebarLayout let allOrdered = layout.allWorkspacesInOrder let workspaceCount = tabs.count @@ -12303,6 +12305,7 @@ private struct SidebarSectionHeaderView: View { 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 { @@ -12315,16 +12318,29 @@ private struct SidebarSectionHeaderView: View { .frame(width: 12, height: 12) if isEditing { - TextField("", text: $editedName, onCommit: { - let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - tabManager.renameSection(sectionId: section.id, name: trimmed) + 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() + } } - isEditing = false - }) - .textFieldStyle(.plain) - .font(.system(size: 11, weight: .semibold)) - .onExitCommand { isEditing = false } } else { Text(section.name) .font(.system(size: 11, weight: .semibold)) @@ -12344,14 +12360,17 @@ private struct SidebarSectionHeaderView: View { .padding(.vertical, 4) .contentShape(Rectangle()) .onTapGesture { - section.isCollapsed.toggle() + guard !isEditing else { return } + section.toggleCollapsed() + tabManager.objectWillChange.send() } .contextMenu { Button(section.isCollapsed ? String(localized: "contextMenu.expandSection", defaultValue: "Expand Section") : String(localized: "contextMenu.collapseSection", defaultValue: "Collapse Section") ) { - section.isCollapsed.toggle() + section.toggleCollapsed() + tabManager.objectWillChange.send() } Button(String(localized: "contextMenu.renameSection", defaultValue: "Rename Section…")) { @@ -12400,6 +12419,15 @@ private struct SidebarSectionHeaderView: View { 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 { diff --git a/Sources/SidebarSection.swift b/Sources/SidebarSection.swift index 1997ec35de22..ce02b77e3228 100644 --- a/Sources/SidebarSection.swift +++ b/Sources/SidebarSection.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI /// A user-defined collapsible group in the sidebar. @@ -9,6 +10,11 @@ final class SidebarSection: Identifiable, ObservableObject { @Published var isCollapsed: Bool @Published var workspaceIds: [UUID] + /// Monotonically increasing revision counter. Bumped on every mutation so + /// parent views that read this value (via the TabManager computed layout) + /// re-evaluate even when the `sections` array identity hasn't changed. + @Published var revision: UInt64 = 0 + init(id: UUID = UUID(), name: String, isCollapsed: Bool = false, workspaceIds: [UUID] = []) { self.id = id self.name = name @@ -16,12 +22,17 @@ final class SidebarSection: Identifiable, ObservableObject { self.workspaceIds = workspaceIds } + private func bumpRevision() { + revision &+= 1 + } + func contains(_ workspaceId: UUID) -> Bool { workspaceIds.contains(workspaceId) } func removeWorkspace(_ workspaceId: UUID) { workspaceIds.removeAll { $0 == workspaceId } + bumpRevision() } func addWorkspace(_ workspaceId: UUID, at index: Int? = nil) { @@ -32,6 +43,17 @@ final class SidebarSection: Identifiable, ObservableObject { } else { workspaceIds.append(workspaceId) } + bumpRevision() + } + + func setCollapsed(_ collapsed: Bool) { + isCollapsed = collapsed + bumpRevision() + } + + func toggleCollapsed() { + isCollapsed.toggle() + bumpRevision() } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index bf0887df3061..1b1b2455910b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -758,7 +758,13 @@ class TabManager: ObservableObject { weak var window: NSWindow? @Published var tabs: [Workspace] = [] - @Published var sections: [SidebarSection] = [] + @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] = [] @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -2677,6 +2683,25 @@ class TabManager: ObservableObject { // 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) @@ -2687,6 +2712,7 @@ class TabManager: ObservableObject { 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) { @@ -2708,12 +2734,14 @@ class TabManager: ObservableObject { } guard let section = sections.first(where: { $0.id == sectionId }) else { return } section.addWorkspace(tabId, at: atIndex) + notifySectionChange() } func removeWorkspaceFromSection(tabId: UUID) { for section in sections { section.removeWorkspace(tabId) } + notifySectionChange() } func sectionForWorkspace(_ tabId: UUID) -> SidebarSection? { @@ -2721,6 +2749,9 @@ class TabManager: ObservableObject { } 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 } @@ -2749,6 +2780,7 @@ class TabManager: ObservableObject { for section in sections { section.removeWorkspace(workspaceId) } + notifySectionChange() } // MARK: - Surface Directory Updates (Backwards Compatibility) From 10f2d7a7de2f2447e4a02fdf569f36ad6eb46989 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 13:31:10 -0700 Subject: [PATCH 04/20] Adopt macOS 26 Liquid Glass design NavigationSplitView with native glass sidebar, system titlebar, and SwiftUI toolbar on macOS 26. Terminal detail content gets 8pt external padding, 6pt internal content inset, and leading corner rounding (16pt) when the sidebar is visible. Key changes: - NavigationSplitView wraps sidebar + detail for native glass treatment - System titlebar (titlebarAppearsTransparent = false) with SwiftUI .toolbar for bell and new-tab buttons - SplitViewDividerHider clears NSSplitView divider color - Terminal content inset (6pt) keeps text within safe zone - Leading corners rounded at AppKit portal level when sidebar present - Bonsplit tab bar hidden for single-tab panes on macOS 26 - App icon auto-set skipped on macOS 26 (system handles dark variants) - Old titlebar accessory controllers skipped on macOS 26 --- Sources/AppDelegate.swift | 64 ++++-- Sources/ContentView.swift | 309 +++++++++++++++++++------- Sources/GhosttyTerminalView.swift | 11 +- Sources/TerminalWindowPortal.swift | 13 ++ Sources/WindowToolbarController.swift | 70 +++++- Sources/Workspace.swift | 9 +- Sources/cmuxApp.swift | 69 +++--- vendor/bonsplit | 2 +- 8 files changed, 419 insertions(+), 128 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 9d04fe352c0d..b05bf66fc37c 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,7 +2598,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent syncMenuBarExtraVisibility() updateController.startUpdaterIfNeeded() } - titlebarAccessoryController.start() + if #available(macOS 26.0, *) { + // On macOS 26, native SwiftUI .toolbar handles titlebar controls. + } else { + titlebarAccessoryController.start() + } windowDecorationsController.start() installMainWindowKeyObserver() refreshGhosttyGotoSplitShortcuts() @@ -7084,10 +7105,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 +10150,11 @@ 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 the old titlebar accessory controllers. + return + } titlebarAccessoryController.start() titlebarAccessoryController.attach(to: window) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..afd4d386bc77 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2676,9 +2676,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 +2877,53 @@ 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() + } + } + )) { + sidebarView + .navigationSplitViewColumnWidth(min: 120, ideal: sidebarWidth, max: 400) + } detail: { + terminalContentWithSidebarDropOverlay + .padding(8) + } + .navigationSplitViewStyle(.prominentDetail) + .background(SplitViewDividerHider()) + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } label: { + Image(systemName: "bell") + } + .buttonStyle(.accessoryBarAction) + .accessibilityIdentifier("toolbar.notifications") + + 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") + } + } + ) + } + 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 +3484,25 @@ 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 without fullSizeContentView, 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 +3534,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) @@ -10029,10 +10100,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). @@ -10055,9 +10122,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 +12198,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 +12207,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 +12248,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 @@ -12367,6 +12430,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 +13064,7 @@ private struct TabItemView: View, Equatable { .offset(x: -1) } } + .modifier(TabItemGlassModifier(isActive: isActive)) ) .padding(.horizontal, 6) .background { @@ -15328,6 +15412,67 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } } +/// Fills the background with the terminal's current background color, +/// staying in sync with theme changes. Used behind NavigationSplitView +/// so the sidebar's rounded corners blend seamlessly with the window. +@available(macOS 26.0, *) +private struct TerminalBackgroundFill: View { + @State private var bgColor = Color(nsColor: GhosttyBackgroundTheme.currentColor()) + + var body: some View { + bgColor + .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in + bgColor = Color(nsColor: GhosttyBackgroundTheme.currentColor()) + } + } +} + +/// Finds NSSplitView(s) inside NavigationSplitView and hides dividers +/// by installing a custom delegate that draws nothing. +@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 { + private var installed = false + + 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 + // Override the divider color to clear + splitView.setValue(NSColor.clear, forKey: "dividerColor") + } + 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 +15568,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 +15577,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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 517be0a06ba7..3e846e5860c1 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9115,7 +9115,16 @@ final class GhosttySurfaceScrollView: NSView { let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) - _ = setFrameIfNeeded(scrollView, to: bounds) + // On macOS 26, inset the scroll view to create internal padding + // that keeps terminal text within the rounded corner safe zone. + let contentInset: CGFloat + if #available(macOS 26.0, *) { + contentInset = 6 + } else { + contentInset = 0 + } + let scrollFrame = contentInset > 0 ? bounds.insetBy(dx: contentInset, dy: contentInset) : bounds + _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG logLayoutDuringActiveDrag(targetSize: targetSize) diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398fd0e..072fd861484a 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1501,6 +1501,19 @@ final class WindowTerminalPortal: NSObject { hostedView.reconcileGeometryNow() hostedView.refreshSurfaceNow(reason: "portal.frameChange") } + + // On macOS 26, round the terminal's leading corners when a sidebar + // is visible to its left, matching the NavigationSplitView glass shape. + if #available(macOS 26.0, *) { + 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] + : [] + } + } } if shouldDeferReveal { diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 5c9be3014543..85e8c8632a73 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: "Toggle Sidebar") + item.label = "Sidebar" + item.toolTip = "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: "Notifications") + item.label = "Notifications" + item.toolTip = "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: "New Workspace") + item.label = "New Workspace" + item.toolTip = "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..abe3d251c459 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6778,7 +6778,14 @@ final class Workspace: Identifiable, ObservableObject { from backgroundColor: NSColor, backgroundOpacity: Double ) -> BonsplitConfiguration.Appearance { - BonsplitConfiguration.Appearance( + let hideTabBar: Bool + if #available(macOS 26.0, *) { + hideTabBar = true + } else { + hideTabBar = false + } + return BonsplitConfiguration.Appearance( + tabBarHeight: hideTabBar ? 0 : 33, splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( 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 From 352ad20c5165d27c1268bfa0fc3a13542ed842d3 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 11:36:49 -0700 Subject: [PATCH 05/20] Fix split divider position and persist sidebar sections Update bonsplit submodule: the animated split entry path hardcoded 0.5 instead of using the configured divider ratio. Include sidebar sections (id, name, collapsed state, workspace membership) in the autosave fingerprint so that section changes trigger session persistence and survive app restart. --- Sources/TabManager.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1b1b2455910b..69736d413581 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5840,6 +5840,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() } From 0519a81b61b932568924ef24db740decc1cc03e3 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 11:48:43 -0700 Subject: [PATCH 06/20] Persist sidebar sections across restart and auto-rename on create Preserve workspace UUIDs in session snapshots so section membership survives app restart. Include sections in the autosave fingerprint so create/rename/reorder/collapse changes trigger persistence. Auto-enter rename mode when a new section is created so the user can immediately type the name. --- Sources/ContentView.swift | 6 ++++++ Sources/SessionPersistence.swift | 1 + Sources/TabManager.swift | 4 ++++ Sources/Workspace.swift | 4 +++- 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index bcd79bc335ec..9725649a8144 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12364,6 +12364,12 @@ private struct SidebarSectionHeaderView: View { section.toggleCollapsed() tabManager.objectWillChange.send() } + .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") diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 4acc2434222f..cd19486efce1 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -328,6 +328,7 @@ indirect enum SessionWorkspaceLayoutSnapshot: Codable, Sendable { } struct SessionWorkspaceSnapshot: Codable, Sendable { + var id: UUID? var processTitle: String var customTitle: String? var customDescription: String? diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 69736d413581..45a7157fb6c8 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -765,6 +765,8 @@ class TabManager: ObservableObject { /// 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 = [] @@ -2706,6 +2708,7 @@ class TabManager: ObservableObject { func createSection(name: String) -> SidebarSection { let section = SidebarSection(name: name) sections.append(section) + pendingRenameSectionId = section.id return section } @@ -5922,6 +5925,7 @@ extension TabManager { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let workspace = Workspace( + restoredId: workspaceSnapshot.id, title: workspaceSnapshot.processTitle, workingDirectory: workspaceSnapshot.currentDirectory, portOrdinal: ordinal diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index e94abe72c004..f9fc6d850616 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, @@ -6825,6 +6826,7 @@ final class Workspace: Identifiable, ObservableObject { } init( + restoredId: UUID? = nil, title: String = "Terminal", workingDirectory: String? = nil, portOrdinal: Int = 0, @@ -6832,7 +6834,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 From 7662ddeffec9a0903e8bca55bc17bc770f8bb1aa Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 13:02:52 -0700 Subject: [PATCH 07/20] Add workspace target:current and fix section rename focus race - Add "target": "current" to cmux.json workspace commands so layouts apply to the selected workspace in-place instead of spawning a new one - Fix section rename auto-focus race: delay pendingRenameSectionId to next run loop so SwiftUI mounts the header view before the publisher emits --- Sources/CmuxConfig.swift | 11 ++++++++++- Sources/CmuxConfigExecutor.swift | 15 ++++++++++++++- Sources/TabManager.swift | 6 +++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4fe7f8ec0b77..5ef9ba31689e 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -90,16 +90,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 } diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index df7276155c2f..455fec0d231b 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -88,6 +88,20 @@ 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 wsDef.target == .current, let current = tabManager.selectedWorkspace { + current.setCustomTitle(workspaceName) + if let color = wsDef.color { + current.setCustomColor(color) + } + if let layout = wsDef.layout { + current.applyCustomLayout(layout, baseCwd: resolvedCwd) + } + return + } + let restart = command.restart ?? .ignore if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { @@ -118,7 +132,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/TabManager.swift b/Sources/TabManager.swift index 45a7157fb6c8..14f9cbbea198 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2708,7 +2708,11 @@ class TabManager: ObservableObject { func createSection(name: String) -> SidebarSection { let section = SidebarSection(name: name) sections.append(section) - pendingRenameSectionId = section.id + // 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 } From 6a152541c3aa3c3c07e8b4e7f26e2d5cef6b82c2 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 13:22:48 -0700 Subject: [PATCH 08/20] Fix target:current decoding, section reorder, and rename focus - Decode 'target' field in CmuxWorkspaceDefinition custom decoder so target:current actually takes effect from cmux.json - Fix within-section drag reorder: reorder section.workspaceIds instead of the flat tabs array so visual order updates correctly - Fix section rename auto-focus timing race (from prior commit) --- Sources/CmuxConfig.swift | 1 + Sources/ContentView.swift | 44 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 5ef9ba31689e..1b7838038061 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -116,6 +116,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) { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9725649a8144..89b496c26793 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -15066,10 +15066,25 @@ 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 + if let targetIdx = section.workspaceIds.firstIndex(of: targetTabId) { + let insertIdx = insertAfter ? targetIdx + 1 : targetIdx + section.addWorkspace(draggedTabId, at: insertIdx) + } +#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) @@ -15695,6 +15710,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, @@ -15708,6 +15747,7 @@ private struct SidebarBackdrop: View { if !useLiquidGlass { Color(nsColor: tintColor) } + #endif } } // When material is none or useWindowLevelGlass, render nothing From 93a9341ec1bf4cbff89cb51e42ea0c87ac87aa40 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 13:30:46 -0700 Subject: [PATCH 09/20] Fix target:current re-splitting and swap Files/Lazygit order --- Sources/CmuxConfigExecutor.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index 455fec0d231b..777d069e9520 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -97,6 +97,12 @@ struct CmuxConfigExecutor { 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 + for panelId in current.panels.keys where panelId != keep { + current.closePanel(panelId, force: true) + } current.applyCustomLayout(layout, baseCwd: resolvedCwd) } return From ad605e1b417ac24ad67dd7845ea3f66ce24dd1a1 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 14:02:23 -0700 Subject: [PATCH 10/20] Add autoApply for workspace commands on tab switch --- Sources/CmuxConfig.swift | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 1b7838038061..fcda38cdc4f1 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) @@ -280,6 +284,7 @@ final class CmuxConfigStore: ObservableObject { return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") }() + private weak var trackedTabManager: TabManager? private var cancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 @@ -302,6 +307,7 @@ final class CmuxConfigStore: ObservableObject { // MARK: - Public API func wireDirectoryTracking(tabManager: TabManager) { + trackedTabManager = tabManager cancellables.removeAll() tabManager.$selectedTabId @@ -391,6 +397,29 @@ final class CmuxConfigStore: ObservableObject { loadedCommands = commands commandSourcePaths = sourcePaths configRevision &+= 1 + checkAutoApply() + } + + /// If the selected workspace has a single pane and a loaded command has + /// `autoApply: true` with `target: "current"`, execute it automatically. + private func checkAutoApply() { + guard let tabManager = trackedTabManager, + let workspace = tabManager.selectedWorkspace, + workspace.panels.count == 1, + let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent }) + else { return } + + guard let command = loadedCommands.first(where: { + $0.autoApply == true && $0.workspace?.target == .current + }) else { return } + + CmuxConfigExecutor.execute( + command: command, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: commandSourcePaths[command.id], + globalConfigPath: globalConfigPath + ) } // MARK: - Parsing From 2e9b441664125d2df0d065fe0918732014aab3b8 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 14:09:29 -0700 Subject: [PATCH 11/20] Fix autoApply: add direct workspace selection observer --- Sources/CmuxConfig.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index fcda38cdc4f1..cb506e9aaf90 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -327,6 +327,20 @@ 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). + tabManager.$selectedTabId + .dropFirst() // skip the initial value on subscribe + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + // Small delay so the config for the new directory loads first. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + self?.checkAutoApply() + } + } + .store(in: &cancellables) + if let directory = tabManager.selectedWorkspace?.currentDirectory { updateLocalConfigPath(directory) } From c4378cf4a1c4d97fb0ece68406e6feadd53c08b9 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 14:19:18 -0700 Subject: [PATCH 12/20] Fix autoApply: check pane count instead of panel count --- Sources/CmuxConfig.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index cb506e9aaf90..bbb702c96c66 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -414,12 +414,12 @@ final class CmuxConfigStore: ObservableObject { checkAutoApply() } - /// If the selected workspace has a single pane and a loaded command has + /// If the selected workspace has no splits and a loaded command has /// `autoApply: true` with `target: "current"`, execute it automatically. private func checkAutoApply() { guard let tabManager = trackedTabManager, let workspace = tabManager.selectedWorkspace, - workspace.panels.count == 1, + workspace.bonsplitController.allPaneIds.count <= 1, let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent }) else { return } From 27ddeee18877e8d05fbfbe938759b3f6c544f882 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 14:37:57 -0700 Subject: [PATCH 13/20] AutoApply: track per-session, fire once per workspace regardless of layout --- Sources/CmuxConfig.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index bbb702c96c66..97eb3cd84157 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -285,6 +285,7 @@ final class CmuxConfigStore: ObservableObject { }() private weak var trackedTabManager: TabManager? + private var autoAppliedWorkspaceIds = Set() private var cancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 @@ -414,12 +415,14 @@ final class CmuxConfigStore: ObservableObject { checkAutoApply() } - /// If the selected workspace has no splits and a loaded command has - /// `autoApply: true` with `target: "current"`, execute it automatically. + /// 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. private func checkAutoApply() { guard let tabManager = trackedTabManager, let workspace = tabManager.selectedWorkspace, - workspace.bonsplitController.allPaneIds.count <= 1, + !autoAppliedWorkspaceIds.contains(workspace.id), let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent }) else { return } @@ -427,6 +430,7 @@ final class CmuxConfigStore: ObservableObject { $0.autoApply == true && $0.workspace?.target == .current }) else { return } + autoAppliedWorkspaceIds.insert(workspace.id) CmuxConfigExecutor.execute( command: command, tabManager: tabManager, From 1951e3a43874856eff7c1871afde935d3f6e482b Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 15:56:00 -0700 Subject: [PATCH 14/20] Address Liquid Glass PR review feedback - Fix bell button no-op: start titlebarAccessoryController on macOS 26 so notifications popover infrastructure is available - Add tab bar design comment: bonsplit shows tabs when pane.tabs > 1 - Replace dividerColor KVC with safe responds(to:)/perform selector - Add accessibility labels to toolbar buttons (bell, new tab) - Localize all WindowToolbarController toolbar item strings - Add comment about NavigationSplitView width persistence limitation - Move corner radius update inside CATransaction to prevent flicker - Replace magic number 20 with named sidebarDetectionThreshold constant - Remove unused TerminalBackgroundFill struct - Fix SplitViewDividerHider doc comment and remove unused property - Add comment explaining uniform 6pt content inset design --- Sources/AppDelegate.swift | 14 +++++++----- Sources/ContentView.swift | 32 ++++++++++----------------- Sources/GhosttyTerminalView.swift | 2 ++ Sources/TerminalWindowPortal.swift | 17 ++++++++------ Sources/WindowToolbarController.swift | 18 +++++++-------- Sources/Workspace.swift | 2 ++ 6 files changed, 43 insertions(+), 42 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b05bf66fc37c..aa4644ea8dd8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -2598,11 +2598,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent syncMenuBarExtraVisibility() updateController.startUpdaterIfNeeded() } - if #available(macOS 26.0, *) { - // On macOS 26, native SwiftUI .toolbar handles titlebar controls. - } else { - titlebarAccessoryController.start() - } + // 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() refreshGhosttyGotoSplitShortcuts() @@ -10152,7 +10152,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func attachUpdateAccessory(to window: NSWindow) { if #available(macOS 26.0, *) { // On macOS 26, toolbar buttons are native SwiftUI .toolbar items - // in the NavigationSplitView. Skip the old titlebar accessory controllers. + // in the NavigationSplitView. Skip attaching the old titlebar + // accessory views, but the controller is already started (for + // notifications popover support). return } titlebarAccessoryController.start() diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index afd4d386bc77..5167b7e6554d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2891,6 +2891,10 @@ struct ContentView: View { } )) { sidebarView + // NavigationSplitView does not provide an API to observe + // user divider drags, so sidebarWidth is not persisted when + // the user resizes via the system divider. The pre-macOS 26 + // custom resizer overlay handles persistence on older versions. .navigationSplitViewColumnWidth(min: 120, ideal: sidebarWidth, max: 400) } detail: { terminalContentWithSidebarDropOverlay @@ -2907,6 +2911,7 @@ struct ContentView: View { } .buttonStyle(.accessoryBarAction) .accessibilityIdentifier("toolbar.notifications") + .accessibilityLabel(String(localized: "toolbar.notifications.label", defaultValue: "Notifications")) Button { if let appDelegate = AppDelegate.shared { @@ -2919,6 +2924,7 @@ struct ContentView: View { } .buttonStyle(.accessoryBarAction) .accessibilityIdentifier("toolbar.newTab") + .accessibilityLabel(String(localized: "toolbar.newTab.label", defaultValue: "New Tab")) } } ) @@ -15412,23 +15418,8 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } } -/// Fills the background with the terminal's current background color, -/// staying in sync with theme changes. Used behind NavigationSplitView -/// so the sidebar's rounded corners blend seamlessly with the window. -@available(macOS 26.0, *) -private struct TerminalBackgroundFill: View { - @State private var bgColor = Color(nsColor: GhosttyBackgroundTheme.currentColor()) - - var body: some View { - bgColor - .onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in - bgColor = Color(nsColor: GhosttyBackgroundTheme.currentColor()) - } - } -} - /// Finds NSSplitView(s) inside NavigationSplitView and hides dividers -/// by installing a custom delegate that draws nothing. +/// by tweaking divider style and color properties on the underlying NSSplitView. @available(macOS 26.0, *) private struct SplitViewDividerHider: NSViewRepresentable { func makeNSView(context: Context) -> SplitViewDividerHiderView { @@ -15442,8 +15433,6 @@ private struct SplitViewDividerHider: NSViewRepresentable { @available(macOS 26.0, *) final class SplitViewDividerHiderView: NSView { - private var installed = false - override func viewDidMoveToWindow() { super.viewDidMoveToWindow() scheduleHide() @@ -15464,8 +15453,11 @@ final class SplitViewDividerHiderView: NSView { guard let view else { return } if let splitView = view as? NSSplitView { splitView.dividerStyle = .thin - // Override the divider color to clear - splitView.setValue(NSColor.clear, forKey: "dividerColor") + // 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) diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3e846e5860c1..f7b5383ac349 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9117,6 +9117,8 @@ final class GhosttySurfaceScrollView: NSView { _ = setFrameIfNeeded(backgroundView, to: bounds) // On macOS 26, inset the scroll view to create internal padding // that keeps terminal text within the rounded corner safe zone. + // Uniform inset on all sides for consistent spacing; the 6pt keeps + // text within the 16pt corner radius safe area at all corners. let contentInset: CGFloat if #available(macOS 26.0, *) { contentInset = 6 diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 072fd861484a..0dee4e1d2a60 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1496,16 +1496,14 @@ final class WindowTerminalPortal: NSObject { hostedView.bounds = expectedBounds geometryChanged = true } - CATransaction.commit() - if geometryChanged { - hostedView.reconcileGeometryNow() - hostedView.refreshSurfaceNow(reason: "portal.frameChange") - } - // 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, *) { - let hasSidebarToLeft = targetFrame.origin.x > 20 + // Sidebar minimum width is 120pt; any x-offset above this + // threshold indicates a sidebar is present to the left. + let sidebarDetectionThreshold: CGFloat = 20 + let hasSidebarToLeft = targetFrame.origin.x > sidebarDetectionThreshold let desiredRadius: CGFloat = hasSidebarToLeft ? 16 : 0 if hostedView.layer?.cornerRadius != desiredRadius { hostedView.layer?.cornerRadius = desiredRadius @@ -1514,6 +1512,11 @@ final class WindowTerminalPortal: NSObject { : [] } } + CATransaction.commit() + if geometryChanged { + hostedView.reconcileGeometryNow() + hostedView.refreshSurfaceNow(reason: "portal.frameChange") + } } if shouldDeferReveal { diff --git a/Sources/WindowToolbarController.swift b/Sources/WindowToolbarController.swift index 85e8c8632a73..9a6bb0b5eaf0 100644 --- a/Sources/WindowToolbarController.swift +++ b/Sources/WindowToolbarController.swift @@ -193,9 +193,9 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { if #available(macOS 26.0, *) { if itemIdentifier == sidebarToggleIdentifier { let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") - item.label = "Sidebar" - item.toolTip = "Toggle Sidebar" + 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 @@ -203,9 +203,9 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { if itemIdentifier == notificationsIdentifier { let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.image = NSImage(systemSymbolName: "bell", accessibilityDescription: "Notifications") - item.label = "Notifications" - item.toolTip = "Show Notifications" + 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 @@ -213,9 +213,9 @@ final class WindowToolbarController: NSObject, NSToolbarDelegate { if itemIdentifier == newTabIdentifier { let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New Workspace") - item.label = "New Workspace" - item.toolTip = "New Workspace" + 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 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index abe3d251c459..94d71e65c0a1 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6780,6 +6780,8 @@ final class Workspace: Identifiable, ObservableObject { ) -> BonsplitConfiguration.Appearance { let hideTabBar: Bool if #available(macOS 26.0, *) { + // tabBarHeight: 0 hides the tab bar for single-tab panes. + // PaneContainerView (bonsplit) shows it when pane.tabs.count > 1. hideTabBar = true } else { hideTabBar = false From 2f90041ef224da6b0f1a3e867353f0ad44161b7b Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 16:02:37 -0700 Subject: [PATCH 15/20] Address sidebar sections PR review feedback - Remove || true debug code left in section menu guard - Fix intra-section drag off-by-one: remove before insert so indices are stable when dragging forward - Fix tabIndexById to use flat tabs order for shift-selection and shortcut badges (not section order) - Fix dictionary mutation during iteration in panel close loop - Make target:current fail closed when no workspace is selected - Only auto-apply to single-pane workspaces to avoid tearing down restored or user-customized split configurations - Validate destination section before removing workspace from all sections in moveWorkspaceToSection - Add backwards-compat comment for optional SessionWorkspaceSnapshot.id - Replace SwiftUI import with Foundation in SidebarSection (only needs Combine + Foundation) - Remove redundant revision property and bumpRevision() calls from SidebarSection (mutations already trigger objectWillChange) - Remove redundant tabManager.objectWillChange.send() in section header --- Sources/CmuxConfig.swift | 3 +++ Sources/CmuxConfigExecutor.swift | 8 ++++++-- Sources/ContentView.swift | 14 +++++++++----- Sources/SessionPersistence.swift | 2 ++ Sources/SidebarSection.swift | 19 +++++-------------- Sources/TabManager.swift | 7 ++++--- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 97eb3cd84157..f28dc9325730 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -423,6 +423,9 @@ final class CmuxConfigStore: ObservableObject { guard let tabManager = trackedTabManager, let workspace = tabManager.selectedWorkspace, !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, let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent }) else { return } diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index 777d069e9520..498dec6503a9 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -91,7 +91,10 @@ struct CmuxConfigExecutor { let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) // "target": "current" — apply the layout to the selected workspace in-place. - if wsDef.target == .current, let current = tabManager.selectedWorkspace { + // 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) @@ -100,7 +103,8 @@ struct CmuxConfigExecutor { // 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 - for panelId in current.panels.keys where panelId != keep { + let panelIdsToClose = current.panels.keys.filter { $0 != keep } + for panelId in panelIdsToClose { current.closePanel(panelId, force: true) } current.applyCustomLayout(layout, baseCwd: resolvedCwd) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 89b496c26793..9c6aebde39da 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9999,7 +9999,9 @@ struct VerticalTabsSidebar: View { let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot - let tabIndexById = Dictionary(uniqueKeysWithValues: allOrdered.enumerated().map { + // 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) }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } @@ -12362,7 +12364,6 @@ private struct SidebarSectionHeaderView: View { .onTapGesture { guard !isEditing else { return } section.toggleCollapsed() - tabManager.objectWillChange.send() } .onReceive(tabManager.$pendingRenameSectionId) { pendingId in guard pendingId == section.id else { return } @@ -12376,7 +12377,6 @@ private struct SidebarSectionHeaderView: View { : String(localized: "contextMenu.collapseSection", defaultValue: "Collapse Section") ) { section.toggleCollapsed() - tabManager.objectWillChange.send() } Button(String(localized: "contextMenu.renameSection", defaultValue: "Rename Section…")) { @@ -13502,7 +13502,7 @@ private struct TabItemView: View, Equatable { } .disabled(targetIds.isEmpty) - if !tabManager.sections.isEmpty || true { + if !tabManager.sections.isEmpty { let sections = tabManager.sections let currentSection = tabManager.sectionForWorkspace(tab.id) Menu(String(localized: "contextMenu.moveToSection", defaultValue: "Move to Section")) { @@ -15072,9 +15072,13 @@ private struct SidebarTabDropDelegate: DropDelegate { 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.addWorkspace(draggedTabId, at: insertIdx) + 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)") diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index cd19486efce1..8132c25f101e 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -328,6 +328,8 @@ 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? diff --git a/Sources/SidebarSection.swift b/Sources/SidebarSection.swift index ce02b77e3228..2d9ec01befc7 100644 --- a/Sources/SidebarSection.swift +++ b/Sources/SidebarSection.swift @@ -1,5 +1,5 @@ import Combine -import SwiftUI +import Foundation /// A user-defined collapsible group in the sidebar. /// Section membership is stored as ordered workspace UUIDs here, not on the Workspace model. @@ -10,11 +10,6 @@ final class SidebarSection: Identifiable, ObservableObject { @Published var isCollapsed: Bool @Published var workspaceIds: [UUID] - /// Monotonically increasing revision counter. Bumped on every mutation so - /// parent views that read this value (via the TabManager computed layout) - /// re-evaluate even when the `sections` array identity hasn't changed. - @Published var revision: UInt64 = 0 - init(id: UUID = UUID(), name: String, isCollapsed: Bool = false, workspaceIds: [UUID] = []) { self.id = id self.name = name @@ -22,17 +17,13 @@ final class SidebarSection: Identifiable, ObservableObject { self.workspaceIds = workspaceIds } - private func bumpRevision() { - revision &+= 1 - } - func contains(_ workspaceId: UUID) -> Bool { workspaceIds.contains(workspaceId) } func removeWorkspace(_ workspaceId: UUID) { workspaceIds.removeAll { $0 == workspaceId } - bumpRevision() + } func addWorkspace(_ workspaceId: UUID, at index: Int? = nil) { @@ -43,17 +34,17 @@ final class SidebarSection: Identifiable, ObservableObject { } else { workspaceIds.append(workspaceId) } - bumpRevision() + } func setCollapsed(_ collapsed: Bool) { isCollapsed = collapsed - bumpRevision() + } func toggleCollapsed() { isCollapsed.toggle() - bumpRevision() + } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 14f9cbbea198..d5d2d06f2138 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -2735,11 +2735,12 @@ class TabManager: ObservableObject { } 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 section in sections { - section.removeWorkspace(tabId) + for s in sections { + s.removeWorkspace(tabId) } - guard let section = sections.first(where: { $0.id == sectionId }) else { return } section.addWorkspace(tabId, at: atIndex) notifySectionChange() } From 2c3e61d399e1e0866257576778c400598b96f84f Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 18:25:19 -0700 Subject: [PATCH 16/20] Fix notifications popover, toolbar buttons, and sidebar controls for macOS 26 - Fix bell button no-op: use SwiftUI .popover on macOS 26 instead of the broken AppKit titlebar accessory path; bridge keyboard shortcut via NotificationCenter - Move pane action buttons (terminal, browser, split right/down) from bonsplit tab bar to native toolbar as a ControlGroup on macOS 26 - Hide bonsplit split buttons when tab bar is hidden (macOS 26) - Hide old HiddenTitlebarSidebarControlsView on macOS 26 - Remove system sidebar toggle chevron (.toolbar(removing: .sidebarToggle)) --- Sources/AppDelegate.swift | 7 +++ Sources/ContentView.swift | 53 +++++++++++++++++++- Sources/Update/UpdateTitlebarAccessory.swift | 2 +- Sources/Workspace.swift | 1 + 4 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index aa4644ea8dd8..5ae023a33373 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10165,7 +10165,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/ContentView.swift b/Sources/ContentView.swift index 5167b7e6554d..5ca38a41af40 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 @@ -2902,16 +2903,64 @@ struct ContentView: View { } .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 { - _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + if #available(macOS 26.0, *) { + isNotificationsPopoverPresented.toggle() + } else { + _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) + } } label: { Image(systemName: "bell") } .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 { @@ -10114,7 +10163,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) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index bca164006791..74af1eac3a1f 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 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 94d71e65c0a1..532633369e8c 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6788,6 +6788,7 @@ final class Workspace: Identifiable, ObservableObject { } return BonsplitConfiguration.Appearance( tabBarHeight: hideTabBar ? 0 : 33, + showSplitButtons: !hideTabBar, splitButtonTooltips: Self.currentSplitButtonTooltips(), enableAnimations: false, chromeColors: .init( From 65db12cb6485e8f70c5f69eef0ac96e71caddcb4 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 18:30:34 -0700 Subject: [PATCH 17/20] Address remaining Liquid Glass PR review feedback - Fix asymmetric inset: only inset leading/top edges where corners are rounded, preserving terminal real estate on right/bottom - Fix misleading comment about fullSizeContentView on macOS 26 - Update SplitViewDividerHider doc to match actual implementation - Clarify hideTabBar comment: PaneContainerView conditionally shows tab bar for multi-tab panes - Improve sidebar detection heuristic comment explaining why frame-based approach is used (AppKit view cannot access SwiftUI SidebarState) - Add GeometryReader to persist sidebar width on macOS 26 when user drags the native NavigationSplitView divider --- Sources/ContentView.swift | 19 ++++++++++++------- Sources/GhosttyTerminalView.swift | 21 +++++++++++++-------- Sources/TerminalWindowPortal.swift | 9 +++++---- Sources/Workspace.swift | 5 +++-- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5ca38a41af40..6cb186fef7b0 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2892,10 +2892,16 @@ struct ContentView: View { } )) { sidebarView - // NavigationSplitView does not provide an API to observe - // user divider drags, so sidebarWidth is not persisted when - // the user resizes via the system divider. The pre-macOS 26 - // custom resizer overlay handles persistence on older versions. + .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 @@ -3546,8 +3552,7 @@ struct ContentView: View { window.titlebarAppearsTransparent = true } if #available(macOS 26.0, *) { - // On macOS 26 without fullSizeContentView, the system titlebar - // handles drag natively. + // On macOS 26, the system titlebar handles drag natively. window.isMovable = true window.isMovableByWindowBackground = false } else { @@ -15468,7 +15473,7 @@ private struct TitlebarLeadingInsetReader: NSViewRepresentable { } /// Finds NSSplitView(s) inside NavigationSplitView and hides dividers -/// by tweaking divider style and color properties on the underlying NSSplitView. +/// by walking the view hierarchy and patching divider style/color properties. @available(macOS 26.0, *) private struct SplitViewDividerHider: NSViewRepresentable { func makeNSView(context: Context) -> SplitViewDividerHiderView { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index f7b5383ac349..3ef9354f8d60 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -9115,17 +9115,22 @@ final class GhosttySurfaceScrollView: NSView { let previousSurfaceSize = surfaceView.frame.size _ = setFrameIfNeeded(backgroundView, to: bounds) - // On macOS 26, inset the scroll view to create internal padding - // that keeps terminal text within the rounded corner safe zone. - // Uniform inset on all sides for consistent spacing; the 6pt keeps - // text within the 16pt corner radius safe area at all corners. - let contentInset: CGFloat + // 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, *) { - contentInset = 6 + let inset: CGFloat = 6 + scrollFrame = CGRect( + x: bounds.origin.x + inset, + y: bounds.origin.y, + width: bounds.width - inset, + height: bounds.height - inset + ) } else { - contentInset = 0 + scrollFrame = bounds } - let scrollFrame = contentInset > 0 ? bounds.insetBy(dx: contentInset, dy: contentInset) : bounds _ = setFrameIfNeeded(scrollView, to: scrollFrame) let targetSize = scrollView.bounds.size #if DEBUG diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 0dee4e1d2a60..3b282435d9ad 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -1500,10 +1500,11 @@ final class WindowTerminalPortal: NSObject { // is visible to its left, matching the NavigationSplitView glass shape. // Applied inside the CATransaction to prevent animation flicker. if #available(macOS 26.0, *) { - // Sidebar minimum width is 120pt; any x-offset above this - // threshold indicates a sidebar is present to the left. - let sidebarDetectionThreshold: CGFloat = 20 - let hasSidebarToLeft = targetFrame.origin.x > sidebarDetectionThreshold + // 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 diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 532633369e8c..6035aa65dcd3 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -6780,8 +6780,9 @@ final class Workspace: Identifiable, ObservableObject { ) -> BonsplitConfiguration.Appearance { let hideTabBar: Bool if #available(macOS 26.0, *) { - // tabBarHeight: 0 hides the tab bar for single-tab panes. - // PaneContainerView (bonsplit) shows it when pane.tabs.count > 1. + // 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 From 69e64395767d39078791c2a6a9cc9df2a36dd5d7 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 18:33:40 -0700 Subject: [PATCH 18/20] Address remaining sidebar sections PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant sectionRevision read in sidebar body (sidebarLayout already reads it internally, establishing the SwiftUI dependency) - Fix autoApply silently skipping global-config commands: fall back to global config directory when no local config is present - Fix auto-apply race condition on rapid tab switching: capture the emitted tab ID and verify it's still selected before applying, so a quick B→C switch doesn't apply B's config to C --- Sources/CmuxConfig.swift | 25 ++++++++++++++++++++----- Sources/ContentView.swift | 4 ++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index f28dc9325730..4e25307ec87c 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -330,14 +330,17 @@ final class CmuxConfigStore: ObservableObject { // 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] _ in + .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() + self?.checkAutoApply(forTabId: tabId) } } .store(in: &cancellables) @@ -419,16 +422,28 @@ final class CmuxConfigStore: ObservableObject { /// loaded command has `autoApply: true` with `target: "current"`, execute /// it automatically. Tracks applied workspaces so it only fires once per /// workspace per app session. - private func checkAutoApply() { + /// - 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, - let baseCwd = localConfigPath.map({ ($0 as NSString).deletingLastPathComponent }) + 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 } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9c6aebde39da..143c1cfebde2 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9991,8 +9991,8 @@ struct VerticalTabsSidebar: View { var body: some View { let tabs = tabManager.tabs - // Read sectionRevision to trigger re-render when any section changes. - let _ = tabManager.sectionRevision + // 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 From df47ffe509200c337e6fd3fab41eee47fce7f6e0 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Tue, 7 Apr 2026 11:06:03 -0700 Subject: [PATCH 19/20] Fix sidebar rows not filling width and remove legacy titlebar controls on macOS 26 - Use flexible frame for sidebar in NavigationSplitView so rows extend full width instead of floating centered with gaps - Skip attaching legacy TitlebarControlsAccessoryViewController on macOS 26 since NavigationSplitView toolbar provides its own controls - Add unread notification badge to the new toolbar bell button --- Sources/ContentView.swift | 25 ++++++++++++++++---- Sources/Update/UpdateTitlebarAccessory.swift | 7 ++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index d16a5cc904f5..60f5bc0ec142 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2591,7 +2591,7 @@ struct ContentView: View { } } - private var sidebarView: some View { + private var sidebarContent: some View { VerticalTabsSidebar( updateViewModel: updateViewModel, onSendFeedback: presentFeedbackComposer, @@ -2599,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 @@ -2891,7 +2895,8 @@ struct ContentView: View { } } )) { - sidebarView + sidebarContent + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background( GeometryReader { geo in Color.clear.onChange(of: geo.size.width) { newWidth in @@ -2953,7 +2958,17 @@ struct ContentView: View { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } } label: { - Image(systemName: "bell") + 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") diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 74af1eac3a1f..020af8173213 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -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 From 21e9e70c3bbdb6eb424c7f939b77a24c099addb9 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Tue, 7 Apr 2026 20:13:23 -0700 Subject: [PATCH 20/20] Fix terminal surfaces losing theme after config reload After ghostty_app_update_config, each surface re-derives its config using its conditional state (light/dark). However, ghostty_surface_set_color_scheme has an internal dedup that skips when the scheme hasn't changed, so surfaces could end up with stale theme colors (e.g. light foreground on dark background) after a config reload or appearance toggle. Fix by calling ghostty_surface_update_config on each surface in refreshTerminalSurfacesAfterGhosttyConfigReload, which forces a full config re-derive with the surface's current conditional state, bypassing the color scheme dedup. Also re-apply the color scheme from the current macOS appearance to keep Swift-side tracking in sync. --- Sources/AppDelegate.swift | 3 +++ Sources/GhosttyTerminalView.swift | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5ae023a33373..57da83be6007 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5831,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 diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3ef9354f8d60..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