diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1fddf..17ec0f5c4506 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ A5001001 /* cmuxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001011 /* cmuxApp.swift */; }; A5001002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001012 /* ContentView.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; + A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */; }; B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */; }; A5001003 /* TabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001013 /* TabManager.swift */; }; A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; }; @@ -211,6 +212,7 @@ A5001011 /* cmuxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = cmuxApp.swift; sourceTree = ""; }; A5001012 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; + A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSection.swift; sourceTree = ""; }; B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragHandleView.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = ""; }; @@ -458,6 +460,7 @@ children = ( A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, + A1B2C3D4E5F60002DEADBEEF /* SidebarSection.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -785,6 +788,7 @@ A5001001 /* cmuxApp.swift in Sources */, A5001002 /* ContentView.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, + A1B2C3D4E5F60001DEADBEEF /* SidebarSection.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, A50012F3 /* KeyboardShortcutSettings.swift in Sources */, diff --git a/Sources/CmuxConfig.swift b/Sources/CmuxConfig.swift index 4fe7f8ec0b77..97eb3cd84157 100644 --- a/Sources/CmuxConfig.swift +++ b/Sources/CmuxConfig.swift @@ -11,6 +11,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { var description: String? var keywords: [String]? var restart: CmuxRestartBehavior? + var autoApply: Bool? var workspace: CmuxWorkspaceDefinition? var command: String? var confirm: Bool? @@ -24,6 +25,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { description: String? = nil, keywords: [String]? = nil, restart: CmuxRestartBehavior? = nil, + autoApply: Bool? = nil, workspace: CmuxWorkspaceDefinition? = nil, command: String? = nil, confirm: Bool? = nil @@ -32,6 +34,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { self.description = description self.keywords = keywords self.restart = restart + self.autoApply = autoApply self.workspace = workspace self.command = command self.confirm = confirm @@ -43,6 +46,7 @@ struct CmuxCommandDefinition: Codable, Sendable, Identifiable { description = try container.decodeIfPresent(String.self, forKey: .description) keywords = try container.decodeIfPresent([String].self, forKey: .keywords) restart = try container.decodeIfPresent(CmuxRestartBehavior.self, forKey: .restart) + autoApply = try container.decodeIfPresent(Bool.self, forKey: .autoApply) workspace = try container.decodeIfPresent(CmuxWorkspaceDefinition.self, forKey: .workspace) command = try container.decodeIfPresent(String.self, forKey: .command) confirm = try container.decodeIfPresent(Bool.self, forKey: .confirm) @@ -90,16 +94,25 @@ enum CmuxRestartBehavior: String, Codable, Sendable { case confirm } +enum CmuxWorkspaceTarget: String, Codable, Sendable { + /// Apply the layout to the currently selected workspace. + case current + /// Create a new workspace (default). + case new +} + struct CmuxWorkspaceDefinition: Codable, Sendable { var name: String? var cwd: String? var color: String? + var target: CmuxWorkspaceTarget? var layout: CmuxLayoutNode? - init(name: String? = nil, cwd: String? = nil, color: String? = nil, layout: CmuxLayoutNode? = nil) { + init(name: String? = nil, cwd: String? = nil, color: String? = nil, target: CmuxWorkspaceTarget? = nil, layout: CmuxLayoutNode? = nil) { self.name = name self.cwd = cwd self.color = color + self.target = target self.layout = layout } @@ -107,6 +120,7 @@ struct CmuxWorkspaceDefinition: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) name = try container.decodeIfPresent(String.self, forKey: .name) cwd = try container.decodeIfPresent(String.self, forKey: .cwd) + target = try container.decodeIfPresent(CmuxWorkspaceTarget.self, forKey: .target) layout = try container.decodeIfPresent(CmuxLayoutNode.self, forKey: .layout) if let rawColor = try container.decodeIfPresent(String.self, forKey: .color) { @@ -270,6 +284,8 @@ final class CmuxConfigStore: ObservableObject { return (home as NSString).appendingPathComponent(".config/cmux/cmux.json") }() + private weak var trackedTabManager: TabManager? + private var autoAppliedWorkspaceIds = Set() private var cancellables = Set() private var localFileWatchSource: DispatchSourceFileSystemObject? private var localFileDescriptor: Int32 = -1 @@ -292,6 +308,7 @@ final class CmuxConfigStore: ObservableObject { // MARK: - Public API func wireDirectoryTracking(tabManager: TabManager) { + trackedTabManager = tabManager cancellables.removeAll() tabManager.$selectedTabId @@ -311,6 +328,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) } @@ -381,6 +412,32 @@ final class CmuxConfigStore: ObservableObject { loadedCommands = commands commandSourcePaths = sourcePaths configRevision &+= 1 + checkAutoApply() + } + + /// If the selected workspace hasn't been auto-applied this session and a + /// loaded command has `autoApply: true` with `target: "current"`, execute + /// it automatically. Tracks applied workspaces so it only fires once per + /// workspace per app session. + private func checkAutoApply() { + guard let tabManager = trackedTabManager, + let workspace = tabManager.selectedWorkspace, + !autoAppliedWorkspaceIds.contains(workspace.id), + 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 } + + autoAppliedWorkspaceIds.insert(workspace.id) + CmuxConfigExecutor.execute( + command: command, + tabManager: tabManager, + baseCwd: baseCwd, + configSourcePath: commandSourcePaths[command.id], + globalConfigPath: globalConfigPath + ) } // MARK: - Parsing diff --git a/Sources/CmuxConfigExecutor.swift b/Sources/CmuxConfigExecutor.swift index df7276155c2f..777d069e9520 100644 --- a/Sources/CmuxConfigExecutor.swift +++ b/Sources/CmuxConfigExecutor.swift @@ -88,6 +88,26 @@ 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 { + // 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 + } + let restart = command.restart ?? .ignore if let existing = tabManager.tabs.first(where: { $0.customTitle == workspaceName }) { @@ -118,7 +138,6 @@ struct CmuxConfigExecutor { } } - let resolvedCwd = CmuxConfigStore.resolveCwd(wsDef.cwd, relativeTo: baseCwd) let newWorkspace = tabManager.addWorkspace(workingDirectory: resolvedCwd) newWorkspace.setCustomTitle(workspaceName) if let color = wsDef.color { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 2e7a2d99a814..89b496c26793 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9923,13 +9923,83 @@ 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 + // 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 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 +10022,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 +12297,145 @@ private final class SidebarScrollViewResolverView: NSView { } } +// MARK: - Sidebar Section View + +private struct SidebarSectionHeaderView: View { + @ObservedObject var section: SidebarSection + let tabManager: TabManager + let workspaceCount: Int + @State private var isEditing = false + @State private var editedName = "" + @FocusState private var isTextFieldFocused: Bool + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary) + .rotationEffect(.degrees(section.isCollapsed ? 0 : 90)) + .animation(.easeInOut(duration: 0.15), value: section.isCollapsed) + .frame(width: 12, height: 12) + + if isEditing { + TextField("", text: $editedName) + .textFieldStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .focused($isTextFieldFocused) + .onSubmit { + commitRename() + } + .onExitCommand { + isEditing = false + isTextFieldFocused = false + } + .onAppear { + // Delay focus slightly so the TextField is mounted first + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + isTextFieldFocused = true + } + } + .onChange(of: isTextFieldFocused) { focused in + // Commit when focus leaves the field + if !focused && isEditing { + commitRename() + } + } + } else { + Text(section.name) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if !section.isCollapsed { + Text("\(workspaceCount)") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + guard !isEditing else { return } + section.toggleCollapsed() + 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") + : String(localized: "contextMenu.collapseSection", defaultValue: "Collapse Section") + ) { + section.toggleCollapsed() + tabManager.objectWillChange.send() + } + + Button(String(localized: "contextMenu.renameSection", defaultValue: "Rename Section…")) { + editedName = section.name + isEditing = true + } + + Divider() + + Button(String(localized: "contextMenu.moveSectionUp", defaultValue: "Move Section Up")) { + guard let idx = tabManager.sections.firstIndex(where: { $0.id == section.id }), idx > 0 else { return } + tabManager.reorderSection(sectionId: section.id, toIndex: idx - 1) + } + .disabled(tabManager.sections.first?.id == section.id) + + Button(String(localized: "contextMenu.moveSectionDown", defaultValue: "Move Section Down")) { + guard let idx = tabManager.sections.firstIndex(where: { $0.id == section.id }), + idx < tabManager.sections.count - 1 else { return } + tabManager.reorderSection(sectionId: section.id, toIndex: idx + 1) + } + .disabled(tabManager.sections.last?.id == section.id) + + Divider() + + Button(String(localized: "contextMenu.deleteSection", defaultValue: "Delete Section"), role: .destructive) { + tabManager.deleteSection(sectionId: section.id) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(section.name) + .accessibilityHint(section.isCollapsed + ? String(localized: "accessibility.sectionCollapsed", defaultValue: "Collapsed. Double-tap to expand.") + : String(localized: "accessibility.sectionExpanded", defaultValue: "Expanded. Double-tap to collapse.")) + .accessibilityAddTraits(.isButton) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, isTargeted: nil) { providers in + guard let provider = providers.first else { return false } + provider.loadDataRepresentation(forTypeIdentifier: SidebarTabDragPayload.typeIdentifier) { data, _ in + guard let data, let str = String(data: data, encoding: .utf8) else { return } + let prefix = "cmux.sidebar-tab." + guard str.hasPrefix(prefix), + let tabId = UUID(uuidString: String(str.dropFirst(prefix.count))) else { return } + Task { @MainActor in + tabManager.moveWorkspaceToSection(tabId: tabId, sectionId: section.id) + } + } + return true + } + } + + private func commitRename() { + let trimmed = editedName.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + tabManager.renameSection(sectionId: section.id, name: trimmed) + } + isEditing = false + isTextFieldFocused = false + } +} + private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let rowSpacing: CGFloat @@ -13288,6 +13502,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 { @@ -14815,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.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") + dlog("sidebar.drop.section tab=\(draggedTabId.uuidString.prefix(5)) section=\(section.name)") #endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + } else { +#if DEBUG + dlog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") +#endif + _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) + } if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] syncSidebarSelection(preferredSelectedTabId: selectedId) @@ -15444,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, @@ -15457,6 +15747,7 @@ private struct SidebarBackdrop: View { if !useLiquidGlass { Color(nsColor: tintColor) } + #endif } } // When material is none or useWindowLevelGlass, render nothing diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index a6498b8bec1c..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? @@ -343,9 +344,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..ce02b77e3228 --- /dev/null +++ b/Sources/SidebarSection.swift @@ -0,0 +1,81 @@ +import Combine +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] + + /// 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 + self.isCollapsed = isCollapsed + 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) { + // 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) + } + bumpRevision() + } + + func setCollapsed(_ collapsed: Bool) { + isCollapsed = collapsed + bumpRevision() + } + + func toggleCollapsed() { + isCollapsed.toggle() + bumpRevision() + } +} + +// 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..14f9cbbea198 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -758,6 +758,15 @@ class TabManager: ObservableObject { weak var window: NSWindow? @Published var tabs: [Workspace] = [] + @Published var sections: [SidebarSection] = [] { + didSet { rebindSectionObservers() } + } + /// Bumped when any section's internal state changes (collapse, membership, name). + /// Views that read `sidebarLayout` also read this to ensure re-evaluation. + @Published private(set) var sectionRevision: UInt64 = 0 + private var sectionObserverCancellables: [AnyCancellable] = [] + /// Set to a section ID to auto-enter rename mode on the next render. + @Published var pendingRenameSectionId: UUID? @Published private(set) var isWorkspaceCycleHot: Bool = false @Published private(set) var pendingBackgroundWorkspaceLoadIds: Set = [] @Published private(set) var debugPinnedWorkspaceLoadIds: Set = [] @@ -2674,6 +2683,113 @@ class TabManager: ObservableObject { return max(clamped, pinnedCount) } + // MARK: - Sidebar Sections + + /// Subscribe to every section's `objectWillChange` so that any property + /// mutation (collapse, membership, name) bumps `sectionRevision` and + /// triggers a SwiftUI re-render of the sidebar layout. + private func rebindSectionObservers() { + sectionObserverCancellables.removeAll() + for section in sections { + section.objectWillChange + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.sectionRevision &+= 1 + } + .store(in: §ionObserverCancellables) + } + } + + private func notifySectionChange() { + sectionRevision &+= 1 + } + + @discardableResult + func createSection(name: String) -> SidebarSection { + let section = SidebarSection(name: name) + sections.append(section) + // Delay so SwiftUI renders the new SidebarSectionHeaderView + // (and subscribes to $pendingRenameSectionId) before we emit. + DispatchQueue.main.async { [weak self] in + self?.pendingRenameSectionId = section.id + } + return section + } + + func renameSection(sectionId: UUID, name: String) { + guard let section = sections.first(where: { $0.id == sectionId }) else { return } + section.name = name + notifySectionChange() + } + + func deleteSection(sectionId: UUID) { + sections.removeAll { $0.id == sectionId } + } + + func reorderSection(sectionId: UUID, toIndex targetIndex: Int) { + guard let currentIndex = sections.firstIndex(where: { $0.id == sectionId }) else { return } + let clamped = max(0, min(targetIndex, sections.count - 1)) + guard currentIndex != clamped else { return } + let section = sections.remove(at: currentIndex) + sections.insert(section, at: clamped) + } + + func moveWorkspaceToSection(tabId: UUID, sectionId: UUID, atIndex: Int? = nil) { + // 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) + notifySectionChange() + } + + func removeWorkspaceFromSection(tabId: UUID) { + for section in sections { + section.removeWorkspace(tabId) + } + notifySectionChange() + } + + func sectionForWorkspace(_ tabId: UUID) -> SidebarSection? { + sections.first { $0.contains(tabId) } + } + + var sidebarLayout: SidebarLayout { + // Read sectionRevision to establish a SwiftUI dependency so the + // layout is recomputed whenever any section property changes. + let _ = sectionRevision + let tabById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + let pinnedWorkspaces = tabs.filter { $0.isPinned } + + // Workspace IDs that are in some section (and not pinned) + var sectionedIds = Set() + let sectionGroups: [SidebarLayout.SectionGroup] = sections.map { section in + let workspaces = section.workspaceIds.compactMap { id -> Workspace? in + guard let ws = tabById[id], !ws.isPinned else { return nil } + return ws + } + for ws in workspaces { + sectionedIds.insert(ws.id) + } + return SidebarLayout.SectionGroup(section: section, workspaces: workspaces) + } + + let ungroupedWorkspaces = tabs.filter { !$0.isPinned && !sectionedIds.contains($0.id) } + return SidebarLayout( + pinnedWorkspaces: pinnedWorkspaces, + ungroupedWorkspaces: ungroupedWorkspaces, + sectionGroups: sectionGroups + ) + } + + private func cleanupSectionsForRemovedWorkspace(_ workspaceId: UUID) { + for section in sections { + section.removeWorkspace(workspaceId) + } + notifySectionChange() + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -2752,6 +2868,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 +2896,7 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return nil } clearWorkspaceGitProbes(workspaceId: tabId) sidebarSelectedWorkspaceIds.remove(tabId) + cleanupSectionsForRemovedWorkspace(tabId) let removed = tabs.remove(at: index) unwireClosedBrowserTracking(for: removed) @@ -5729,6 +5847,15 @@ extension TabManager { } } + // Include sidebar sections so create/rename/reorder/collapse triggers autosave. + hasher.combine(sections.count) + for section in sections { + hasher.combine(section.id) + hasher.combine(section.name) + hasher.combine(section.isCollapsed) + hasher.combine(section.workspaceIds) + } + return hasher.finalize() } @@ -5741,9 +5868,19 @@ extension TabManager { let selectedWorkspaceIndex = selectedTabId.flatMap { selectedTabId in restorableTabs.firstIndex(where: { $0.id == selectedTabId }) } + let restorableTabIds = Set(restorableTabs.map(\.id)) + let sectionSnapshots: [SessionSidebarSectionSnapshot] = sections.map { section in + SessionSidebarSectionSnapshot( + id: section.id, + name: section.name, + isCollapsed: section.isCollapsed, + workspaceIds: section.workspaceIds.filter { restorableTabIds.contains($0) } + ) + } return SessionTabManagerSnapshot( selectedWorkspaceIndex: selectedWorkspaceIndex, - workspaces: workspaceSnapshots + workspaces: workspaceSnapshots, + sections: sectionSnapshots.isEmpty ? nil : sectionSnapshots ) } @@ -5792,6 +5929,7 @@ extension TabManager { let ordinal = Self.nextPortOrdinal Self.nextPortOrdinal += 1 let workspace = Workspace( + restoredId: workspaceSnapshot.id, title: workspaceSnapshot.processTitle, workingDirectory: workspaceSnapshot.currentDirectory, portOrdinal: ordinal @@ -5820,9 +5958,21 @@ extension TabManager { newSelectedId = newTabs.first?.id } + // Restore sidebar sections, filtering out workspace IDs that weren't restored. + let restoredTabIds = Set(newTabs.map(\.id)) + let restoredSections: [SidebarSection] = (snapshot.sections ?? []).map { sectionSnapshot in + SidebarSection( + id: sectionSnapshot.id, + name: sectionSnapshot.name, + isCollapsed: sectionSnapshot.isCollapsed, + workspaceIds: sectionSnapshot.workspaceIds.filter { restoredTabIds.contains($0) } + ) + } + // Single atomic assignment of @Published properties so SwiftUI observers // never see an intermediate state with empty tabs or nil selection. tabs = newTabs + sections = restoredSections selectedTabId = newSelectedId let existingIds = Set(newTabs.map(\.id)) pruneBackgroundWorkspaceLoads(existingIds: existingIds) diff --git a/Sources/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