From 853003fa647f4521d23d4553a69d2d48d6ca9c51 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sat, 4 Apr 2026 22:57:49 -0700 Subject: [PATCH 01/14] 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 2e7a2d99a..cf0c4baff 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 a6498b8be..4acc24342 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 000000000..1997ec35d --- /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 5a793c9c3..bf0887df3 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/14] 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 ab8673d1f..17ec0f5c4 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/14] 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 cf0c4baff..bcd79bc33 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 1997ec35d..ce02b77e3 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 bf0887df3..1b1b24559 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 352ad20c5165d27c1268bfa0fc3a13542ed842d3 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Sun, 5 Apr 2026 11:36:49 -0700 Subject: [PATCH 04/14] 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 1b1b24559..69736d413 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 05/14] 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 bcd79bc33..9725649a8 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 4acc24342..cd19486ef 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 69736d413..45a7157fb 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 e94abe72c..f9fc6d850 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 06/14] 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 4fe7f8ec0..5ef9ba316 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 df7276155..455fec0d2 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 45a7157fb..14f9cbbea 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 07/14] 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 5ef9ba316..1b7838038 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 9725649a8..89b496c26 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 08/14] 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 455fec0d2..777d069e9 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 09/14] 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 1b7838038..fcda38cdc 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 10/14] 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 fcda38cdc..cb506e9aa 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 11/14] 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 cb506e9aa..bbb702c96 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 12/14] 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 bbb702c96..97eb3cd84 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 2f90041ef224da6b0f1a3e867353f0ad44161b7b Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 16:02:37 -0700 Subject: [PATCH 13/14] 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 97eb3cd84..f28dc9325 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 777d069e9..498dec650 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 89b496c26..9c6aebde3 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 cd19486ef..8132c25f1 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 ce02b77e3..2d9ec01be 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 14f9cbbea..d5d2d06f2 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 69e64395767d39078791c2a6a9cc9df2a36dd5d7 Mon Sep 17 00:00:00 2001 From: Rod Christiansen Date: Mon, 6 Apr 2026 18:33:40 -0700 Subject: [PATCH 14/14] 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 f28dc9325..4e25307ec 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 9c6aebde3..143c1cfeb 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