From 004ca3e37f1bb7a250351951fe0fadd646c75081 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 11 May 2026 22:51:06 -0700 Subject: [PATCH 01/11] Add sidebar folder grouping --- Resources/Localizable.xcstrings | 85 ++++ Sources/ContentView.swift | 363 +++++++++++++++++- Sources/SessionPersistence.swift | 1 + Sources/Sidebar/SidebarDropPlanner.swift | 63 +++ Sources/TabManager.swift | 91 +++++ Sources/Workspace.swift | 11 + cmuxTests/SessionPersistenceTests.swift | 26 ++ .../SidebarWorkspaceDropPlannerTests.swift | 43 +++ cmuxTests/WorkspaceUnitTests.swift | 58 +++ 9 files changed, 732 insertions(+), 9 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c9fc552c0c53..3205666d048b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -86588,6 +86588,91 @@ } } }, + "sidebar.addFolder.button": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを追加" + } + } + } + }, + "sidebar.addFolder.panelPrompt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "追加" + } + } + } + }, + "sidebar.addFolder.panelTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add Folder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダを追加" + } + } + } + }, + "sidebar.addFolder.tooltip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open a folder in a new workspace" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新しいワークスペースでフォルダを開く" + } + } + } + }, + "sidebar.bookmarks.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "BOOKMARKS" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブックマーク" + } + } + } + }, "sidebar.folderIcon.dragHint": { "extractionState": "manual", "localizations": { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4a4058a883d4..06be05a81126 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9045,6 +9045,7 @@ struct VerticalTabsSidebar: View { @State private var terminalScrollBarVisibilityGeneration: UInt64 = 0 @State private var laidOutWorkspaceRowIds: Set = [] @State private var pendingSelectedWorkspaceScrollId: UUID? + @State private var collapsedGroups: Set = [] @AppStorage(WorkspacePresentationModeSettings.modeKey) private var workspacePresentationMode = WorkspacePresentationModeSettings.defaultMode.rawValue @AppStorage("sidebarMatchTerminalBackground") @@ -9112,6 +9113,8 @@ struct VerticalTabsSidebar: View { private struct WorkspaceListRenderContext { let tabs: [Workspace] + let groupingPlan: SidebarWorkspaceGroupingPlan + let workspaceById: [UUID: Workspace] let workspaceCount: Int let canCloseWorkspace: Bool let workspaceNumberShortcut: StoredShortcut @@ -9124,19 +9127,41 @@ struct VerticalTabsSidebar: View { let workspaceTerminalScrollBarHiddenById: [UUID: Bool] var workspaceIds: [UUID] { - tabs.map(\.id) + groupingPlan.visibleWorkspaceIds } + + var groupIds: [String] { + groupingPlan.folderGroups.map(\.id) + } + } + + private struct WorkspaceFolderRenderGroup: Identifiable { + let plan: SidebarWorkspaceFolderGroup + let tabs: [Workspace] + + var id: String { plan.id } + var directory: String { plan.directory } } var body: some View { let _ = terminalScrollBarVisibilityGeneration let tabs = tabManager.tabs + let groupingPlan = SidebarWorkspaceGroupingPlanner.plan( + for: tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ) + let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut let tabItemSettings = tabItemSettingsStore.snapshot - let tabIndexById = Dictionary(uniqueKeysWithValues: tabs.enumerated().map { - ($0.element.id, $0.offset) + let tabIndexById = Dictionary(uniqueKeysWithValues: groupingPlan.visibleWorkspaceIds.enumerated().map { + ($0.element, $0.offset) }) let orderedSelectedTabs = tabs.filter { selectedTabIds.contains($0.id) } let selectedContextTargetIds = orderedSelectedTabs.map(\.id) @@ -9153,6 +9178,8 @@ struct VerticalTabsSidebar: View { selectedRemoteContextMenuTargets.allSatisfy { $0.remoteConnectionState == .disconnected } let renderContext = WorkspaceListRenderContext( tabs: tabs, + groupingPlan: groupingPlan, + workspaceById: workspaceById, workspaceCount: workspaceCount, canCloseWorkspace: canCloseWorkspace, workspaceNumberShortcut: workspaceNumberShortcut, @@ -9229,6 +9256,9 @@ struct VerticalTabsSidebar: View { !tabIds.contains(frozenTabItemPresentation.tabId) else { return } self.frozenTabItemPresentation = nil } + .onChange(of: renderContext.groupIds) { _, groupIds in + collapsedGroups.formIntersection(Set(groupIds)) + } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } let reason = SidebarDragLifecycleNotification.reason(from: notification) @@ -9366,6 +9396,7 @@ struct VerticalTabsSidebar: View { workspaceRows(renderContext: renderContext) SidebarEmptyArea( + lastWorkspaceId: renderContext.workspaceIds.last, rowSpacing: tabRowSpacing, selection: $selection, selectedTabIds: $selectedTabIds, @@ -9380,11 +9411,63 @@ struct VerticalTabsSidebar: View { } private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View { + let bookmarkTabs = renderContext.groupingPlan.bookmarkIds.compactMap { renderContext.workspaceById[$0] } + let folderGroups = renderFolderGroups(renderContext: renderContext) + let showsFolderHeaders = folderGroups.count > 1 + // Workspaces are bounded, so prefer a non-lazy stack here. // LazyVStack + drag-state invalidations can recurse through layout. VStack(spacing: tabRowSpacing) { - ForEach(renderContext.tabs, id: \.id) { tab in - workspaceRow(tab, renderContext: renderContext) + if !bookmarkTabs.isEmpty { + SidebarBookmarkHeader( + count: bookmarkTabs.count, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + ) + ForEach(bookmarkTabs, id: \.id) { tab in + workspaceRow(tab, renderContext: renderContext) + } + if !folderGroups.isEmpty { + SidebarWorkspaceSectionDivider() + } + } + + ForEach(Array(folderGroups.enumerated()), id: \.element.id) { index, group in + if showsFolderHeaders { + if index > 0 { + SidebarWorkspaceSectionDivider() + } + SidebarGroupHeader( + directory: group.directory, + count: group.tabs.count, + isCollapsed: collapsedGroups.contains(group.id), + onToggle: { + withAnimation(.easeOut(duration: 0.1)) { + toggleCollapsedGroup(group.id) + } + }, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + ) + } + + if !showsFolderHeaders || !collapsedGroups.contains(group.id) { + ForEach(group.tabs, id: \.id) { tab in + workspaceRow(tab, renderContext: renderContext) + } + } + } + + SidebarAddFolderButton { directory in + tabManager.addWorkspace(workingDirectory: directory, placementOverride: .end) } } .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) @@ -9434,8 +9517,9 @@ struct VerticalTabsSidebar: View { updateAutoscroll: { dragAutoScrollController.updateFromDragLocation() }, - targets: renderContext.tabs.compactMap { tab in - guard let anchor = anchors[tab.id] else { return nil } + targets: renderContext.workspaceIds.compactMap { workspaceId in + guard let tab = renderContext.workspaceById[workspaceId], + let anchor = anchors[workspaceId] else { return nil } return SidebarDropPlanner.WorkspaceDropTarget( workspaceId: tab.id, isPinned: tab.isPinned, @@ -9447,6 +9531,24 @@ struct VerticalTabsSidebar: View { } } + private func renderFolderGroups( + renderContext: WorkspaceListRenderContext + ) -> [WorkspaceFolderRenderGroup] { + renderContext.groupingPlan.folderGroups.compactMap { group in + let tabs = group.workspaceIds.compactMap { renderContext.workspaceById[$0] } + guard !tabs.isEmpty else { return nil } + return WorkspaceFolderRenderGroup(plan: group, tabs: tabs) + } + } + + private func toggleCollapsedGroup(_ groupId: String) { + if collapsedGroups.contains(groupId) { + collapsedGroups.remove(groupId) + } else { + collapsedGroups.insert(groupId) + } + } + private func workspaceRow( _ tab: Workspace, renderContext: WorkspaceListRenderContext @@ -11734,8 +11836,137 @@ private final class SidebarScrollViewResolverView: NSView { } } +private struct SidebarWorkspaceSectionDivider: View { + var body: some View { + Divider() + .padding(.horizontal, 10) + .padding(.vertical, 3) + } +} + +private struct SidebarBookmarkHeader: View { + let count: Int + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "flag.fill") + .font(.system(size: 10, weight: .semibold)) + Text(String(localized: "sidebar.bookmarks.title", defaultValue: "BOOKMARKS")) + .font(.system(size: 10, weight: .bold)) + Spacer(minLength: 0) + Text("\(count)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarBookmarkHeaderDropDelegate( + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + } +} + +private struct SidebarGroupHeader: View { + let directory: String + let count: Int + let isCollapsed: Bool + let onToggle: () -> Void + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + var body: some View { + Button(action: onToggle) { + HStack(spacing: 6) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .frame(width: 10) + Image(systemName: "folder.fill") + .font(.system(size: 10, weight: .semibold)) + Text(SidebarPathFormatter.shortenedPath(directory)) + .font(.system(size: 10, weight: .bold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if isCollapsed { + Text("\(count)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + } + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .safeHelp(directory) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarGroupHeaderDropDelegate( + directory: directory, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator + )) + } +} + +private struct SidebarAddFolderButton: View { + let onSelectDirectory: (String) -> Void + + var body: some View { + Button(action: openFolderPicker) { + HStack(spacing: 7) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 12, weight: .semibold)) + Text(String(localized: "sidebar.addFolder.button", defaultValue: "Add Folder")) + .font(.system(size: 11, weight: .semibold)) + Spacer(minLength: 0) + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.addFolder.tooltip", defaultValue: "Open a folder in a new workspace")) + .accessibilityIdentifier("SidebarAddFolderButton") + } + + private func openFolderPicker() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "sidebar.addFolder.panelTitle", defaultValue: "Add Folder") + panel.prompt = String(localized: "sidebar.addFolder.panelPrompt", defaultValue: "Add") + if panel.runModal() == .OK, let url = panel.url { + onSelectDirectory(url.path) + } + } +} + private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager + let lastWorkspaceId: UUID? let rowSpacing: CGFloat @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @@ -11783,7 +12014,7 @@ private struct SidebarEmptyArea: View { if indicator.tabId == nil { return true } - guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false } + guard indicator.edge == .bottom, let lastTabId = lastWorkspaceId else { return false } return indicator.tabId == lastTabId } } @@ -14267,6 +14498,100 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { } } +private struct SidebarGroupHeaderDropDelegate: DropDelegate { + let directory: String + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil + } + + func dropEntered(info: DropInfo) { + dragAutoScrollController.updateFromDragLocation() + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + return validateDrop(info: info) ? DropProposal(operation: .move) : nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + guard validateDrop(info: info), let draggedTabId else { return false } + guard tabManager.moveWorkspaceToInitialDirectoryGroupEnd( + tabId: draggedTabId, + directory: directory + ) else { + return false + } + selectedTabIds = [draggedTabId] + syncSidebarSelection(preferredSelectedTabId: draggedTabId) + return true + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + +private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil + } + + func dropEntered(info: DropInfo) { + dragAutoScrollController.updateFromDragLocation() + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + return validateDrop(info: info) ? DropProposal(operation: .move) : nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + guard validateDrop(info: info), let draggedTabId else { return false } + guard tabManager.moveWorkspaceToBookmarksEnd(tabId: draggedTabId) else { return false } + selectedTabIds = [draggedTabId] + syncSidebarSelection(preferredSelectedTabId: draggedTabId) + return true + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + } else { + lastSidebarSelectionIndex = nil + } + } +} + private struct SidebarTabDropDelegate: DropDelegate { let targetTabId: UUID? let tabManager: TabManager @@ -14336,6 +14661,14 @@ private struct SidebarTabDropDelegate: DropDelegate { #endif return false } + let draggedWorkspace = tabManager.tabs[fromIndex] + let targetInitialDirectory: String? = targetTabId.flatMap { targetTabId in + guard let targetWorkspace = tabManager.tabs.first(where: { $0.id == targetTabId }), + !targetWorkspace.isPinned else { + return nil + } + return targetWorkspace.initialDirectory + } let tabIds = tabManager.tabs.map(\.id) guard let targetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, @@ -14353,9 +14686,21 @@ private struct SidebarTabDropDelegate: DropDelegate { return false } + let changedInitialDirectory: Bool = { + guard !draggedWorkspace.isPinned, + let targetInitialDirectory, + draggedWorkspace.initialDirectory != targetInitialDirectory else { + return false + } + return tabManager.setWorkspaceInitialDirectory( + tabId: draggedTabId, + directory: targetInitialDirectory + ) + }() + guard fromIndex != targetIndex else { #if DEBUG - cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") + cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex) groupChanged=\(changedInitialDirectory ? 1 : 0)") #endif syncSidebarSelection() return true diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index 66d23de82cc1..8d1bc269b6a5 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -340,6 +340,7 @@ struct SessionWorkspaceSnapshot: Codable, Sendable { var isPinned: Bool var terminalScrollBarHidden: Bool? var currentDirectory: String + var initialDirectory: String? = nil var focusedPanelId: UUID? var layout: SessionWorkspaceLayoutSnapshot var panels: [SessionPanelSnapshot] diff --git a/Sources/Sidebar/SidebarDropPlanner.swift b/Sources/Sidebar/SidebarDropPlanner.swift index 20839c9f9409..cda0f84a77e3 100644 --- a/Sources/Sidebar/SidebarDropPlanner.swift +++ b/Sources/Sidebar/SidebarDropPlanner.swift @@ -11,6 +11,69 @@ struct SidebarDropIndicator: Equatable { let edge: SidebarDropEdge } +struct SidebarWorkspaceGroupingInput: Equatable { + let id: UUID + let initialDirectory: String + let isPinned: Bool +} + +struct SidebarWorkspaceFolderGroup: Equatable, Identifiable { + let directory: String + let workspaceIds: [UUID] + + var id: String { directory } +} + +struct SidebarWorkspaceGroupingPlan: Equatable { + let bookmarkIds: [UUID] + let folderGroups: [SidebarWorkspaceFolderGroup] + + var visibleWorkspaceIds: [UUID] { + bookmarkIds + folderGroups.flatMap(\.workspaceIds) + } +} + +enum SidebarWorkspaceGroupingPlanner { + static func plan( + for inputs: [SidebarWorkspaceGroupingInput], + fallbackDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path + ) -> SidebarWorkspaceGroupingPlan { + var bookmarkIds: [UUID] = [] + var groupOrder: [String] = [] + var groupedWorkspaceIds: [String: [UUID]] = [:] + + for input in inputs { + if input.isPinned { + bookmarkIds.append(input.id) + continue + } + + let directory = normalizedDirectory(input.initialDirectory, fallbackDirectory: fallbackDirectory) + if groupedWorkspaceIds[directory] == nil { + groupOrder.append(directory) + groupedWorkspaceIds[directory] = [] + } + groupedWorkspaceIds[directory]?.append(input.id) + } + + let folderGroups = groupOrder.map { directory in + SidebarWorkspaceFolderGroup( + directory: directory, + workspaceIds: groupedWorkspaceIds[directory] ?? [] + ) + } + return SidebarWorkspaceGroupingPlan(bookmarkIds: bookmarkIds, folderGroups: folderGroups) + } + + static func normalizedDirectory( + _ directory: String, + fallbackDirectory: String = FileManager.default.homeDirectoryForCurrentUser.path + ) -> String { + let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallbackDirectory : trimmed + } +} + enum SidebarDropPlanner { static func indicator( draggedTabId: UUID?, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 5b607c515a8f..5b2a778759c9 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3867,6 +3867,39 @@ class TabManager: ObservableObject { return true } + @discardableResult + func setWorkspaceInitialDirectory(tabId: UUID, directory: String) -> Bool { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false } + let normalized = normalizedInitialDirectory(directory) + guard workspace.initialDirectory != normalized else { return true } + objectWillChange.send() + workspace.initialDirectory = normalized + return true + } + + @discardableResult + func moveWorkspaceToInitialDirectoryGroupEnd( + tabId: UUID, + directory: String + ) -> Bool { + moveWorkspace( + tabId: tabId, + initialDirectory: directory, + pinned: false, + placement: .endOfDirectoryGroup + ) + } + + @discardableResult + func moveWorkspaceToBookmarksEnd(tabId: UUID) -> Bool { + moveWorkspace( + tabId: tabId, + initialDirectory: nil, + pinned: true, + placement: .endOfBookmarks + ) + } + @discardableResult func reorderWorkspace(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil) -> Bool { guard tabs.contains(where: { $0.id == tabId }) else { return false } @@ -3952,6 +3985,63 @@ class TabManager: ObservableObject { return max(clamped, pinnedCount) } + private enum WorkspaceMovePlacement { + case endOfDirectoryGroup + case endOfBookmarks + } + + @discardableResult + private func moveWorkspace( + tabId: UUID, + initialDirectory directory: String?, + pinned: Bool, + placement: WorkspaceMovePlacement + ) -> Bool { + guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false } + let workspace = tabs[currentIndex] + let nextInitialDirectory = directory.map { normalizedInitialDirectory($0) } + let directoryChanged = nextInitialDirectory.map { workspace.initialDirectory != $0 } ?? false + let pinnedChanged = workspace.isPinned != pinned + + var remainingTabs = tabs + remainingTabs.remove(at: currentIndex) + + if let nextInitialDirectory { + workspace.initialDirectory = nextInitialDirectory + } + workspace.isPinned = pinned + + let insertionIndex: Int + switch placement { + case .endOfDirectoryGroup: + let pinnedCount = remainingTabs.filter(\.isPinned).count + if let nextInitialDirectory, + let lastGroupIndex = remainingTabs.lastIndex(where: { + !$0.isPinned && $0.initialDirectory == nextInitialDirectory + }) { + insertionIndex = lastGroupIndex + 1 + } else { + insertionIndex = pinnedCount + } + case .endOfBookmarks: + let pinnedCount = remainingTabs.filter(\.isPinned).count + insertionIndex = pinnedCount + } + + remainingTabs.insert(workspace, at: max(0, min(insertionIndex, remainingTabs.count))) + guard tabs.map(\.id) != remainingTabs.map(\.id) || directoryChanged || pinnedChanged else { + return true + } + tabs = remainingTabs + return true + } + + private func normalizedInitialDirectory(_ directory: String) -> String { + let normalized = normalizeDirectory(directory) + let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? FileManager.default.homeDirectoryForCurrentUser.path : normalized + } + // MARK: - Surface Directory Updates (Backwards Compatibility) func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -7224,6 +7314,7 @@ extension TabManager { for workspace in tabs.prefix(SessionPersistencePolicy.maxWorkspacesPerWindow) { hasher.combine(workspace.id) hasher.combine(workspace.focusedPanelId) + hasher.combine(workspace.initialDirectory) hasher.combine(workspace.currentDirectory) hasher.combine(workspace.customTitle ?? "") hasher.combine(workspace.customDescription ?? "") diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 18950839e669..4220709c68fb 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -222,6 +222,7 @@ extension Workspace { isPinned: isPinned, terminalScrollBarHidden: terminalScrollBarHidden ? true : nil, currentDirectory: currentDirectory, + initialDirectory: initialDirectory, focusedPanelId: focusedPanelId, layout: layout, panels: panelSnapshots, @@ -257,6 +258,13 @@ extension Workspace { if !normalizedCurrentDirectory.isEmpty { currentDirectory = normalizedCurrentDirectory } + let normalizedInitialDirectory = snapshot.initialDirectory? + .trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedInitialDirectory, !normalizedInitialDirectory.isEmpty { + initialDirectory = normalizedInitialDirectory + } else { + initialDirectory = currentDirectory + } let panelSnapshotsById = Dictionary(uniqueKeysWithValues: snapshot.panels.map { ($0.id, $0) }) let leafEntries = restoreSessionLayout(snapshot.layout) @@ -7032,6 +7040,7 @@ final class Workspace: Identifiable, ObservableObject { @Published var isPinned: Bool = false @Published var customColor: String? // hex string, e.g. "#C0392B" @Published private(set) var terminalScrollBarHidden: Bool = false + @Published var initialDirectory: String @Published var currentDirectory: String @Published private(set) var surfaceTabBarDirectory: String? private(set) var preferredBrowserProfileID: UUID? @@ -7211,6 +7220,7 @@ final class Workspace: Identifiable, ObservableObject { lazy var sidebarObservationPublisher: AnyPublisher = { let publishers: [AnyPublisher] = [ + sidebarObservationSignal($initialDirectory), sidebarObservationSignal($currentDirectory), $panels .map(SidebarPanelObservationState.init) @@ -7605,6 +7615,7 @@ final class Workspace: Identifiable, ObservableObject { let initialDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path + self.initialDirectory = initialDirectory self.currentDirectory = hasWorkingDirectory ? trimmedWorkingDirectory : FileManager.default.homeDirectoryForCurrentUser.path diff --git a/cmuxTests/SessionPersistenceTests.swift b/cmuxTests/SessionPersistenceTests.swift index 727641fee3a2..bafec2a1cdbb 100644 --- a/cmuxTests/SessionPersistenceTests.swift +++ b/cmuxTests/SessionPersistenceTests.swift @@ -46,6 +46,32 @@ final class SessionPersistenceTests: XCTestCase { XCTAssertEqual(restored.panelTitle(panelId: restoredPanelId), "Readme") } + @MainActor + func testWorkspaceSessionSnapshotRestoresInitialDirectorySeparatelyFromCurrentDirectory() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-session-directory-\(UUID().uuidString)", isDirectory: true) + let original = root.appendingPathComponent("original", isDirectory: true) + let grouped = root.appendingPathComponent("grouped", isDirectory: true) + let live = root.appendingPathComponent("live", isDirectory: true) + try FileManager.default.createDirectory(at: original, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: grouped, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: live, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let workspace = Workspace(workingDirectory: original.path) + workspace.initialDirectory = grouped.path + workspace.currentDirectory = live.path + + let snapshot = workspace.sessionSnapshot(includeScrollback: false) + XCTAssertEqual(snapshot.initialDirectory, grouped.path) + + let restored = Workspace(workingDirectory: original.path) + restored.restoreSessionSnapshot(snapshot) + + XCTAssertEqual(restored.initialDirectory, grouped.path) + XCTAssertEqual(restored.currentDirectory, live.path) + } + @MainActor func testSessionSnapshotSkipsTransientRemoteListeningPorts() throws { let workspace = Workspace() diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 819eedddf20c..657eac1f07a2 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -8,6 +8,49 @@ import XCTest #endif final class SidebarWorkspaceDropPlannerTests: XCTestCase { + func testGroupingPlanSeparatesBookmarksAndPreservesFolderInsertionOrder() { + let pinned = UUID() + let firstAlpha = UUID() + let beta = UUID() + let secondAlpha = UUID() + + let plan = SidebarWorkspaceGroupingPlanner.plan( + for: [ + SidebarWorkspaceGroupingInput(id: pinned, initialDirectory: "/pinned", isPinned: true), + SidebarWorkspaceGroupingInput(id: firstAlpha, initialDirectory: "/alpha", isPinned: false), + SidebarWorkspaceGroupingInput(id: beta, initialDirectory: "/beta", isPinned: false), + SidebarWorkspaceGroupingInput(id: secondAlpha, initialDirectory: "/alpha", isPinned: false), + ], + fallbackDirectory: "/home" + ) + + XCTAssertEqual(plan.bookmarkIds, [pinned]) + XCTAssertEqual( + plan.folderGroups, + [ + SidebarWorkspaceFolderGroup(directory: "/alpha", workspaceIds: [firstAlpha, secondAlpha]), + SidebarWorkspaceFolderGroup(directory: "/beta", workspaceIds: [beta]), + ] + ) + XCTAssertEqual(plan.visibleWorkspaceIds, [pinned, firstAlpha, secondAlpha, beta]) + } + + func testGroupingPlanUsesFallbackForEmptyDirectories() { + let workspace = UUID() + + let plan = SidebarWorkspaceGroupingPlanner.plan( + for: [ + SidebarWorkspaceGroupingInput(id: workspace, initialDirectory: " ", isPinned: false), + ], + fallbackDirectory: "/fallback" + ) + + XCTAssertEqual( + plan.folderGroups, + [SidebarWorkspaceFolderGroup(directory: "/fallback", workspaceIds: [workspace])] + ) + } + func testWorkspaceDropCenterTargetsExistingWorkspace() { let first = UUID() let second = UUID() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 10f502246683..57e490f6d80d 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2907,6 +2907,39 @@ final class WorkspaceReorderTests: XCTestCase { XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id]) } + @MainActor + func testMoveWorkspaceToInitialDirectoryGroupEndUpdatesDirectoryAndOrder() { + let manager = TabManager() + let alpha = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: alpha.id, directory: "/alpha")) + let beta = manager.addWorkspace(workingDirectory: "/beta", placementOverride: .end) + let gamma = manager.addWorkspace(workingDirectory: "/gamma", placementOverride: .end) + + XCTAssertTrue( + manager.moveWorkspaceToInitialDirectoryGroupEnd( + tabId: gamma.id, + directory: "/alpha" + ) + ) + + XCTAssertEqual(gamma.initialDirectory, "/alpha") + XCTAssertEqual(manager.tabs.map(\.id), [alpha.id, gamma.id, beta.id]) + } + + @MainActor + func testMoveWorkspaceToBookmarksEndPinsAfterExistingBookmarks() { + let manager = TabManager() + let firstPinned = manager.tabs[0] + manager.setPinned(firstPinned, pinned: true) + let second = manager.addWorkspace(placementOverride: .end) + let third = manager.addWorkspace(placementOverride: .end) + + XCTAssertTrue(manager.moveWorkspaceToBookmarksEnd(tabId: third.id)) + + XCTAssertTrue(third.isPinned) + XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, third.id, second.id]) + } + @MainActor func testDetachedWorkspaceInsertionOverrideClampsAfterPinnedSegment() { let manager = TabManager() @@ -4265,6 +4298,31 @@ final class WorkspacePanelGitBranchTests: XCTestCase { ) } + func testSidebarObservationPublisherEmitsForInitialDirectoryChangesOnlyOncePerState() { + let workspace = Workspace(workingDirectory: "/alpha") + + var publishCount = 0 + let cancellable = workspace.sidebarObservationPublisher.sink { + publishCount += 1 + } + defer { cancellable.cancel() } + + workspace.initialDirectory = "/beta" + let baselinePublishCount = publishCount + XCTAssertGreaterThan( + baselinePublishCount, + 0, + "Expected initial directory changes to invalidate sidebar grouping" + ) + + workspace.initialDirectory = "/beta" + XCTAssertEqual( + publishCount, + baselinePublishCount, + "Expected identical initial directory assignments to be ignored by sidebar rows" + ) + } + func testSidebarObservationPublisherIgnoresRemoteHeartbeatOnlyChanges() { let workspace = Workspace() From 11672f348e61205a781919dae3094ad77207c4d9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:11:42 -0700 Subject: [PATCH 02/11] fix: stabilize sidebar folder drag ordering --- Sources/ContentView.swift | 60 ++++++++++++---------- Sources/TabManager.swift | 62 ++++++++++++++++++++--- cmuxTests/WorkspaceUnitTests.swift | 80 ++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 32 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 06be05a81126..a13d4935bcc8 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9417,7 +9417,7 @@ struct VerticalTabsSidebar: View { // Workspaces are bounded, so prefer a non-lazy stack here. // LazyVStack + drag-state invalidations can recurse through layout. - VStack(spacing: tabRowSpacing) { + return VStack(spacing: tabRowSpacing) { if !bookmarkTabs.isEmpty { SidebarBookmarkHeader( count: bookmarkTabs.count, @@ -14655,13 +14655,14 @@ private struct SidebarTabDropDelegate: DropDelegate { #endif return false } - guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { + let tabIds = planningTabIds() + guard let fromIndex = tabIds.firstIndex(of: draggedTabId), + let draggedWorkspace = tabManager.tabs.first(where: { $0.id == draggedTabId }) else { #if DEBUG cmuxDebugLog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") #endif return false } - let draggedWorkspace = tabManager.tabs[fromIndex] let targetInitialDirectory: String? = targetTabId.flatMap { targetTabId in guard let targetWorkspace = tabManager.tabs.first(where: { $0.id == targetTabId }), !targetWorkspace.isPinned else { @@ -14669,14 +14670,16 @@ private struct SidebarTabDropDelegate: DropDelegate { } return targetWorkspace.initialDirectory } - let tabIds = tabManager.tabs.map(\.id) - guard let targetIndex = SidebarDropPlanner.targetIndex( + let moveInitialDirectory = draggedWorkspace.isPinned ? nil : targetInitialDirectory + let changesInitialDirectory = moveInitialDirectory.map { draggedWorkspace.initialDirectory != $0 } ?? false + let plannedTargetIndex = SidebarDropPlanner.targetIndex( draggedTabId: draggedTabId, targetTabId: targetTabId, indicator: dropIndicator, tabIds: tabIds, pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) else { + ) + guard let targetIndex = plannedTargetIndex ?? (changesInitialDirectory ? fromIndex : nil) else { #if DEBUG cmuxDebugLog( "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + @@ -14686,30 +14689,23 @@ private struct SidebarTabDropDelegate: DropDelegate { return false } - let changedInitialDirectory: Bool = { - guard !draggedWorkspace.isPinned, - let targetInitialDirectory, - draggedWorkspace.initialDirectory != targetInitialDirectory else { - return false - } - return tabManager.setWorkspaceInitialDirectory( - tabId: draggedTabId, - directory: targetInitialDirectory - ) - }() - - guard fromIndex != targetIndex else { + guard tabManager.moveWorkspaceInSidebarVisualOrder( + tabId: draggedTabId, + toVisibleIndex: targetIndex, + initialDirectory: moveInitialDirectory + ) else { #if DEBUG - cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex) groupChanged=\(changedInitialDirectory ? 1 : 0)") + cmuxDebugLog("sidebar.drop.abort reason=moveFailed tab=\(draggedTabId.uuidString.prefix(5))") #endif - syncSidebarSelection() - return true + return false } #if DEBUG - cmuxDebugLog("sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) from=\(fromIndex) to=\(targetIndex)") + cmuxDebugLog( + "sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) " + + "from=\(fromIndex) to=\(targetIndex) groupChanged=\(changesInitialDirectory ? 1 : 0)" + ) #endif - _ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] syncSidebarSelection(preferredSelectedTabId: selectedId) @@ -14721,7 +14717,7 @@ private struct SidebarTabDropDelegate: DropDelegate { } private func updateDropIndicator(for info: DropInfo) { - let tabIds = tabManager.tabs.map(\.id) + let tabIds = planningTabIds() let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) let nextIndicator = SidebarDropPlanner.indicator( draggedTabId: draggedTabId, @@ -14735,10 +14731,22 @@ private struct SidebarTabDropDelegate: DropDelegate { dropIndicator = nextIndicator } + private func planningTabIds() -> [UUID] { + SidebarWorkspaceGroupingPlanner.plan( + for: tabManager.tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ).visibleWorkspaceIds + } + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = planningTabIds().firstIndex(of: selectedId) } else { lastSidebarSelectionIndex = nil } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f8733e7d951a..da194213bc25 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3905,6 +3905,42 @@ class TabManager: ObservableObject { ) } + @discardableResult + func moveWorkspaceInSidebarVisualOrder( + tabId: UUID, + toVisibleIndex targetIndex: Int, + initialDirectory directory: String? + ) -> Bool { + guard let workspace = tabs.first(where: { $0.id == tabId }) else { return false } + + let nextInitialDirectory = directory.map { normalizedInitialDirectory($0) } + let directoryChanged = nextInitialDirectory.map { workspace.initialDirectory != $0 } ?? false + let currentVisibleIds = sidebarVisibleWorkspaceIds() + guard let currentVisibleIndex = currentVisibleIds.firstIndex(of: tabId), + currentVisibleIds.count == tabs.count else { + return false + } + + var nextVisibleIds = currentVisibleIds + nextVisibleIds.remove(at: currentVisibleIndex) + let insertionIndex = max(0, min(targetIndex, nextVisibleIds.count)) + nextVisibleIds.insert(tabId, at: insertionIndex) + + let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + let reorderedTabs = nextVisibleIds.compactMap { workspaceById[$0] } + guard reorderedTabs.count == tabs.count else { return false } + + guard tabs.map(\.id) != nextVisibleIds || directoryChanged else { + return true + } + + if let nextInitialDirectory, directoryChanged { + workspace.initialDirectory = nextInitialDirectory + } + tabs = reorderedTabs + return true + } + @discardableResult func reorderWorkspace(tabId: UUID, before beforeId: UUID? = nil, after afterId: UUID? = nil) -> Bool { guard tabs.contains(where: { $0.id == tabId }) else { return false } @@ -4011,11 +4047,6 @@ class TabManager: ObservableObject { var remainingTabs = tabs remainingTabs.remove(at: currentIndex) - if let nextInitialDirectory { - workspace.initialDirectory = nextInitialDirectory - } - workspace.isPinned = pinned - let insertionIndex: Int switch placement { case .endOfDirectoryGroup: @@ -4037,6 +4068,13 @@ class TabManager: ObservableObject { guard tabs.map(\.id) != remainingTabs.map(\.id) || directoryChanged || pinnedChanged else { return true } + + if let nextInitialDirectory, directoryChanged { + workspace.initialDirectory = nextInitialDirectory + } + if pinnedChanged { + workspace.isPinned = pinned + } tabs = remainingTabs return true } @@ -4044,7 +4082,19 @@ class TabManager: ObservableObject { private func normalizedInitialDirectory(_ directory: String) -> String { let normalized = normalizeDirectory(directory) let trimmed = normalized.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? FileManager.default.homeDirectoryForCurrentUser.path : normalized + return trimmed.isEmpty ? FileManager.default.homeDirectoryForCurrentUser.path : trimmed + } + + private func sidebarVisibleWorkspaceIds() -> [UUID] { + SidebarWorkspaceGroupingPlanner.plan( + for: tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ).visibleWorkspaceIds } // MARK: - Surface Directory Updates (Backwards Compatibility) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 57e490f6d80d..690afbe84977 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2926,6 +2926,73 @@ final class WorkspaceReorderTests: XCTestCase { XCTAssertEqual(manager.tabs.map(\.id), [alpha.id, gamma.id, beta.id]) } + @MainActor + func testMoveWorkspaceInSidebarVisualOrderKeepsFolderRowsContiguous() { + let manager = TabManager() + let firstAlpha = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: firstAlpha.id, directory: "/alpha")) + let beta = manager.addWorkspace(workingDirectory: "/beta", placementOverride: .end) + let secondAlpha = manager.addWorkspace(workingDirectory: "/alpha", placementOverride: .end) + + XCTAssertEqual(sidebarVisibleWorkspaceIds(in: manager), [firstAlpha.id, secondAlpha.id, beta.id]) + + XCTAssertTrue( + manager.moveWorkspaceInSidebarVisualOrder( + tabId: firstAlpha.id, + toVisibleIndex: 1, + initialDirectory: nil + ) + ) + + XCTAssertEqual(manager.tabs.map(\.id), [secondAlpha.id, firstAlpha.id, beta.id]) + XCTAssertEqual(sidebarVisibleWorkspaceIds(in: manager), [secondAlpha.id, firstAlpha.id, beta.id]) + } + + @MainActor + func testMoveWorkspaceInSidebarVisualOrderCanChangeFolderWithoutChangingVisualIndex() { + let manager = TabManager() + let firstAlpha = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: firstAlpha.id, directory: "/alpha")) + let beta = manager.addWorkspace(workingDirectory: "/beta", placementOverride: .end) + let secondAlpha = manager.addWorkspace(workingDirectory: "/alpha", placementOverride: .end) + + XCTAssertEqual(sidebarVisibleWorkspaceIds(in: manager), [firstAlpha.id, secondAlpha.id, beta.id]) + + XCTAssertTrue( + manager.moveWorkspaceInSidebarVisualOrder( + tabId: beta.id, + toVisibleIndex: 2, + initialDirectory: "/alpha" + ) + ) + + XCTAssertEqual(beta.initialDirectory, "/alpha") + XCTAssertEqual(manager.tabs.map(\.id), [firstAlpha.id, secondAlpha.id, beta.id]) + XCTAssertEqual(sidebarVisibleWorkspaceIds(in: manager), [firstAlpha.id, secondAlpha.id, beta.id]) + } + + @MainActor + func testMoveWorkspaceToInitialDirectoryGroupEndNoOpDoesNotPublishWorkspaceChanges() { + let manager = TabManager() + let workspace = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: workspace.id, directory: "/alpha")) + + var publishCount = 0 + let cancellable = workspace.objectWillChange.sink { _ in + publishCount += 1 + } + + XCTAssertTrue( + manager.moveWorkspaceToInitialDirectoryGroupEnd( + tabId: workspace.id, + directory: "/alpha" + ) + ) + + XCTAssertEqual(publishCount, 0) + withExtendedLifetime(cancellable) {} + } + @MainActor func testMoveWorkspaceToBookmarksEndPinsAfterExistingBookmarks() { let manager = TabManager() @@ -2940,6 +3007,19 @@ final class WorkspaceReorderTests: XCTestCase { XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, third.id, second.id]) } + @MainActor + private func sidebarVisibleWorkspaceIds(in manager: TabManager) -> [UUID] { + SidebarWorkspaceGroupingPlanner.plan( + for: manager.tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ).visibleWorkspaceIds + } + @MainActor func testDetachedWorkspaceInsertionOverrideClampsAfterPinnedSegment() { let manager = TabManager() From adf3ad3e422aac100d97010f683ef0831e1dbde3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:23:24 -0700 Subject: [PATCH 03/11] fix: use visual sidebar indexes for selection --- Sources/ContentView.swift | 66 +++++++++++++++++++++++++-------------- Sources/TabManager.swift | 9 +++++- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a13d4935bcc8..404c2652d635 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2653,7 +2653,7 @@ struct ContentView: View { } if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } syncSidebarSelectedWorkspaceIds() applyUITestSidebarSelectionIfNeeded(tabs: tabManager.tabs) @@ -2688,7 +2688,7 @@ struct ContentView: View { // Ensure sidebar selection is valid. if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) didRecover = true } @@ -2726,7 +2726,7 @@ struct ContentView: View { guard let newValue else { return } if selectedTabIds.count <= 1 { selectedTabIds = [newValue] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue } + lastSidebarSelectionIndex = sidebarVisualIndex(of: newValue, in: tabManager) } updateTitlebarText() }) @@ -9608,6 +9608,7 @@ struct VerticalTabsSidebar: View { notificationStore: notificationStore, tab: tab, index: index, + visualWorkspaceIds: renderContext.workspaceIds, isActive: tabManager.selectedTabId == tab.id, workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( at: index, @@ -11983,7 +11984,7 @@ private struct SidebarEmptyArea: View { tabManager.addWorkspace(placementOverride: .end) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } selection = .tabs } @@ -12164,6 +12165,7 @@ private struct TabItemView: View, Equatable { nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { lhs.tab === rhs.tab && lhs.index == rhs.index && + lhs.visualWorkspaceIds == rhs.visualWorkspaceIds && lhs.isActive == rhs.isActive && lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && @@ -12190,6 +12192,7 @@ private struct TabItemView: View, Equatable { @Environment(\.colorScheme) private var colorScheme let tab: Tab let index: Int + let visualWorkspaceIds: [UUID] let isActive: Bool let workspaceShortcutDigit: Int? let workspaceShortcutModifierSymbol: String @@ -13241,12 +13244,12 @@ private struct TabItemView: View, Equatable { } guard indicator.edge == .bottom, - let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }), + let currentIndex = visualWorkspaceIds.firstIndex(of: tab.id), currentIndex > 0 else { return false } - return tabManager.tabs[currentIndex - 1].id == indicator.tabId + return visualWorkspaceIds[currentIndex - 1] == indicator.tabId } private var accessibilityTitle: String { @@ -13255,10 +13258,14 @@ private struct TabItemView: View, Equatable { private func moveBy(_ delta: Int) { let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return } - guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return } + guard targetIndex >= 0, targetIndex < visualWorkspaceIds.count else { return } + guard tabManager.moveWorkspaceInSidebarVisualOrder( + tabId: tab.id, + toVisibleIndex: targetIndex, + initialDirectory: nil + ) else { return } selectedTabIds = [tab.id] - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id } + lastSidebarSelectionIndex = targetIndex tabManager.selectTab(tab) setSelectionToTabs() } @@ -13278,10 +13285,13 @@ private struct TabItemView: View, Equatable { let isShift = modifiers.contains(.shift) let wasSelected = tabManager.selectedTabId == tab.id - if isShift, let lastIndex = lastSidebarSelectionIndex { + if isShift, + let lastIndex = lastSidebarSelectionIndex, + visualWorkspaceIds.indices.contains(lastIndex), + visualWorkspaceIds.indices.contains(index) { let lower = min(lastIndex, index) let upper = max(lastIndex, index) - let rangeIds = tabManager.tabs[lower...upper].map { $0.id } + let rangeIds = Array(visualWorkspaceIds[lower...upper]) if isCommand { selectedTabIds.formUnion(rangeIds) } else { @@ -13360,7 +13370,7 @@ private struct TabItemView: View, Equatable { selectedTabIds = [selectedId] } if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } } @@ -14491,7 +14501,7 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { private func syncSidebarSelection() { if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } else { lastSidebarSelectionIndex = nil } @@ -14541,7 +14551,7 @@ private struct SidebarGroupHeaderDropDelegate: DropDelegate { private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } else { lastSidebarSelectionIndex = nil } @@ -14585,7 +14595,7 @@ private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId if let selectedId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } + lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) } else { lastSidebarSelectionIndex = nil } @@ -14732,15 +14742,7 @@ private struct SidebarTabDropDelegate: DropDelegate { } private func planningTabIds() -> [UUID] { - SidebarWorkspaceGroupingPlanner.plan( - for: tabManager.tabs.map { - SidebarWorkspaceGroupingInput( - id: $0.id, - initialDirectory: $0.initialDirectory, - isPinned: $0.isPinned - ) - } - ).visibleWorkspaceIds + sidebarVisibleWorkspaceIds(in: tabManager) } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { @@ -14759,6 +14761,22 @@ private struct SidebarTabDropDelegate: DropDelegate { } } +private func sidebarVisualIndex(of workspaceId: UUID, in tabManager: TabManager) -> Int? { + sidebarVisibleWorkspaceIds(in: tabManager).firstIndex(of: workspaceId) +} + +private func sidebarVisibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { + SidebarWorkspaceGroupingPlanner.plan( + for: tabManager.tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ).visibleWorkspaceIds +} + private struct MiddleClickCapture: NSViewRepresentable { let onMiddleClick: () -> Void diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index da194213bc25..2e2f2e56e72b 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -3923,7 +3923,14 @@ class TabManager: ObservableObject { var nextVisibleIds = currentVisibleIds nextVisibleIds.remove(at: currentVisibleIndex) - let insertionIndex = max(0, min(targetIndex, nextVisibleIds.count)) + let pinnedCount = tabs.filter(\.isPinned).count + let clampedTargetIndex: Int + if workspace.isPinned { + clampedTargetIndex = min(max(0, targetIndex), max(0, pinnedCount - 1)) + } else { + clampedTargetIndex = max(pinnedCount, min(targetIndex, tabs.count - 1)) + } + let insertionIndex = max(0, min(clampedTargetIndex, nextVisibleIds.count)) nextVisibleIds.insert(tabId, at: insertionIndex) let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) From 8a6f2ae93dde8684bb7082237bb3d167e0137b81 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:26:30 -0700 Subject: [PATCH 04/11] fix: clear sidebar header drop state --- Sources/ContentView.swift | 4 ++++ Sources/TabManager.swift | 2 ++ cmuxTests/WorkspaceUnitTests.swift | 6 ++++-- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 404c2652d635..ea09565ae09c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14523,10 +14523,12 @@ private struct SidebarGroupHeaderDropDelegate: DropDelegate { func dropEntered(info: DropInfo) { dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil } func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil return validateDrop(info: info) ? DropProposal(operation: .move) : nil } @@ -14572,10 +14574,12 @@ private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { func dropEntered(info: DropInfo) { dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil } func dropUpdated(info: DropInfo) -> DropProposal? { dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil return validateDrop(info: info) ? DropProposal(operation: .move) : nil } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 2e2f2e56e72b..6b2d45022314 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4063,6 +4063,8 @@ class TabManager: ObservableObject { !$0.isPinned && $0.initialDirectory == nextInitialDirectory }) { insertionIndex = lastGroupIndex + 1 + } else if nextInitialDirectory == workspace.initialDirectory { + insertionIndex = min(currentIndex, remainingTabs.count) } else { insertionIndex = pinnedCount } diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 690afbe84977..def8bb1ed19c 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2974,8 +2974,9 @@ final class WorkspaceReorderTests: XCTestCase { @MainActor func testMoveWorkspaceToInitialDirectoryGroupEndNoOpDoesNotPublishWorkspaceChanges() { let manager = TabManager() - let workspace = manager.tabs[0] - XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: workspace.id, directory: "/alpha")) + let first = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: first.id, directory: "/alpha")) + let workspace = manager.addWorkspace(workingDirectory: "/beta", placementOverride: .end) var publishCount = 0 let cancellable = workspace.objectWillChange.sink { _ in @@ -2990,6 +2991,7 @@ final class WorkspaceReorderTests: XCTestCase { ) XCTAssertEqual(publishCount, 0) + XCTAssertEqual(manager.tabs.map(\.id), [first.id, workspace.id]) withExtendedLifetime(cancellable) {} } From bb20162bdad3afeff2c8e099c01e999704118288 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:32:49 -0700 Subject: [PATCH 05/11] fix: isolate sidebar visual helpers --- Sources/ContentView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ea09565ae09c..7bdad0a65588 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -14765,10 +14765,12 @@ private struct SidebarTabDropDelegate: DropDelegate { } } +@MainActor private func sidebarVisualIndex(of workspaceId: UUID, in tabManager: TabManager) -> Int? { sidebarVisibleWorkspaceIds(in: tabManager).firstIndex(of: workspaceId) } +@MainActor private func sidebarVisibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { SidebarWorkspaceGroupingPlanner.plan( for: tabManager.tabs.map { From 09f807feb0e295c52f6834e0ce9ccfc0e722c071 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:42:15 -0700 Subject: [PATCH 06/11] fix: narrow sidebar row ordering state --- Sources/ContentView.swift | 103 +++++++++++++++++++++++------ cmuxTests/WorkspaceUnitTests.swift | 2 +- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7bdad0a65588..f902894f701b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9125,6 +9125,8 @@ struct VerticalTabsSidebar: View { let allSelectedRemoteContextMenuTargetsConnecting: Bool let allSelectedRemoteContextMenuTargetsDisconnected: Bool let workspaceTerminalScrollBarHiddenById: [UUID: Bool] + let renderedWorkspaceIds: [UUID] + let workspaceOrderSignature: Int var workspaceIds: [UUID] { groupingPlan.visibleWorkspaceIds @@ -9133,6 +9135,14 @@ struct VerticalTabsSidebar: View { var groupIds: [String] { groupingPlan.folderGroups.map(\.id) } + + func previousRenderedWorkspaceId(before workspaceId: UUID) -> UUID? { + guard let index = renderedWorkspaceIds.firstIndex(of: workspaceId), + index > 0 else { + return nil + } + return renderedWorkspaceIds[index - 1] + } } private struct WorkspaceFolderRenderGroup: Identifiable { @@ -9155,6 +9165,14 @@ struct VerticalTabsSidebar: View { ) } ) + let renderedWorkspaceIds = Self.renderedWorkspaceIds( + for: groupingPlan, + collapsedGroupIds: collapsedGroups + ) + let workspaceOrderSignature = Self.workspaceOrderSignature( + visibleWorkspaceIds: groupingPlan.visibleWorkspaceIds, + renderedWorkspaceIds: renderedWorkspaceIds + ) let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 @@ -9189,7 +9207,9 @@ struct VerticalTabsSidebar: View { selectedRemoteContextMenuWorkspaceIds: selectedRemoteContextMenuWorkspaceIds, allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected, - workspaceTerminalScrollBarHiddenById: workspaceTerminalScrollBarHiddenById + workspaceTerminalScrollBarHiddenById: workspaceTerminalScrollBarHiddenById, + renderedWorkspaceIds: renderedWorkspaceIds, + workspaceOrderSignature: workspaceOrderSignature ) ZStack(alignment: .bottomLeading) { @@ -9326,7 +9346,8 @@ struct VerticalTabsSidebar: View { .background(TitlebarDoubleClickMonitorView()) } .overlay(alignment: .top) { - if draggedTabId != nil, let firstWorkspaceId = renderContext.workspaceIds.first { + if draggedTabId != nil, + let firstWorkspaceId = renderContext.renderedWorkspaceIds.first ?? renderContext.workspaceIds.first { Color.clear .contentShape(Rectangle()) .frame(height: scrollInsets.top + 8) @@ -9396,7 +9417,7 @@ struct VerticalTabsSidebar: View { workspaceRows(renderContext: renderContext) SidebarEmptyArea( - lastWorkspaceId: renderContext.workspaceIds.last, + lastWorkspaceId: renderContext.renderedWorkspaceIds.last ?? renderContext.workspaceIds.last, rowSpacing: tabRowSpacing, selection: $selection, selectedTabIds: $selectedTabIds, @@ -9608,7 +9629,10 @@ struct VerticalTabsSidebar: View { notificationStore: notificationStore, tab: tab, index: index, - visualWorkspaceIds: renderContext.workspaceIds, + previousRenderedWorkspaceId: renderContext.previousRenderedWorkspaceId(before: tab.id), + workspaceOrderSignature: renderContext.workspaceOrderSignature, + visualWorkspaceIdsForActions: { renderContext.workspaceIds }, + renderedWorkspaceIdsForSelection: { renderContext.renderedWorkspaceIds }, isActive: tabManager.selectedTabId == tab.id, workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( at: index, @@ -9646,6 +9670,32 @@ struct VerticalTabsSidebar: View { } } + private static func renderedWorkspaceIds( + for groupingPlan: SidebarWorkspaceGroupingPlan, + collapsedGroupIds: Set + ) -> [UUID] { + let showsFolderHeaders = groupingPlan.folderGroups.count > 1 + groupingPlan.bookmarkIds + groupingPlan.folderGroups.flatMap { group in + showsFolderHeaders && collapsedGroupIds.contains(group.id) ? [] : group.workspaceIds + } + } + + private static func workspaceOrderSignature( + visibleWorkspaceIds: [UUID], + renderedWorkspaceIds: [UUID] + ) -> Int { + var hasher = Hasher() + hasher.combine(visibleWorkspaceIds.count) + for workspaceId in visibleWorkspaceIds { + hasher.combine(workspaceId) + } + hasher.combine(renderedWorkspaceIds.count) + for workspaceId in renderedWorkspaceIds { + hasher.combine(workspaceId) + } + return hasher.finalize() + } + private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) @@ -12165,7 +12215,8 @@ private struct TabItemView: View, Equatable { nonisolated static func == (lhs: TabItemView, rhs: TabItemView) -> Bool { lhs.tab === rhs.tab && lhs.index == rhs.index && - lhs.visualWorkspaceIds == rhs.visualWorkspaceIds && + lhs.previousRenderedWorkspaceId == rhs.previousRenderedWorkspaceId && + lhs.workspaceOrderSignature == rhs.workspaceOrderSignature && lhs.isActive == rhs.isActive && lhs.workspaceShortcutDigit == rhs.workspaceShortcutDigit && lhs.workspaceShortcutModifierSymbol == rhs.workspaceShortcutModifierSymbol && @@ -12192,7 +12243,10 @@ private struct TabItemView: View, Equatable { @Environment(\.colorScheme) private var colorScheme let tab: Tab let index: Int - let visualWorkspaceIds: [UUID] + let previousRenderedWorkspaceId: UUID? + let workspaceOrderSignature: Int + let visualWorkspaceIdsForActions: () -> [UUID] + let renderedWorkspaceIdsForSelection: () -> [UUID] let isActive: Bool let workspaceShortcutDigit: Int? let workspaceShortcutModifierSymbol: String @@ -13099,15 +13153,17 @@ private struct TabItemView: View, Equatable { Divider() + let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() + let renderedWorkspaceIndex = renderedWorkspaceIds.firstIndex(of: tab.id) Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { moveBy(-1) } - .disabled(index == 0) + .disabled(renderedWorkspaceIndex == nil || renderedWorkspaceIndex == 0) Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { moveBy(1) } - .disabled(index >= tabManager.tabs.count - 1) + .disabled(renderedWorkspaceIndex == nil || renderedWorkspaceIndex == renderedWorkspaceIds.count - 1) Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { tabManager.moveTabsToTop(Set(targetIds)) @@ -13244,12 +13300,11 @@ private struct TabItemView: View, Equatable { } guard indicator.edge == .bottom, - let currentIndex = visualWorkspaceIds.firstIndex(of: tab.id), - currentIndex > 0 + let previousRenderedWorkspaceId else { return false } - return visualWorkspaceIds[currentIndex - 1] == indicator.tabId + return previousRenderedWorkspaceId == indicator.tabId } private var accessibilityTitle: String { @@ -13257,15 +13312,20 @@ private struct TabItemView: View, Equatable { } private func moveBy(_ delta: Int) { - let targetIndex = index + delta - guard targetIndex >= 0, targetIndex < visualWorkspaceIds.count else { return } + let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() + guard let currentRenderedIndex = renderedWorkspaceIds.firstIndex(of: tab.id) else { return } + let targetRenderedIndex = currentRenderedIndex + delta + guard renderedWorkspaceIds.indices.contains(targetRenderedIndex) else { return } + let targetTabId = renderedWorkspaceIds[targetRenderedIndex] + let visualWorkspaceIds = visualWorkspaceIdsForActions() + guard let targetIndex = visualWorkspaceIds.firstIndex(of: targetTabId) else { return } guard tabManager.moveWorkspaceInSidebarVisualOrder( tabId: tab.id, toVisibleIndex: targetIndex, initialDirectory: nil ) else { return } selectedTabIds = [tab.id] - lastSidebarSelectionIndex = targetIndex + lastSidebarSelectionIndex = sidebarVisualIndex(of: tab.id, in: tabManager) tabManager.selectTab(tab) setSelectionToTabs() } @@ -13285,13 +13345,18 @@ private struct TabItemView: View, Equatable { let isShift = modifiers.contains(.shift) let wasSelected = tabManager.selectedTabId == tab.id + let visualWorkspaceIds = visualWorkspaceIdsForActions() + let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() + let renderedWorkspaceIdSet = Set(renderedWorkspaceIds) + let currentVisualIndex = visualWorkspaceIds.firstIndex(of: tab.id) ?? index + if isShift, let lastIndex = lastSidebarSelectionIndex, visualWorkspaceIds.indices.contains(lastIndex), - visualWorkspaceIds.indices.contains(index) { - let lower = min(lastIndex, index) - let upper = max(lastIndex, index) - let rangeIds = Array(visualWorkspaceIds[lower...upper]) + visualWorkspaceIds.indices.contains(currentVisualIndex) { + let lower = min(lastIndex, currentVisualIndex) + let upper = max(lastIndex, currentVisualIndex) + let rangeIds = visualWorkspaceIds[lower...upper].filter { renderedWorkspaceIdSet.contains($0) } if isCommand { selectedTabIds.formUnion(rangeIds) } else { @@ -13307,7 +13372,7 @@ private struct TabItemView: View, Equatable { selectedTabIds = [tab.id] } - lastSidebarSelectionIndex = index + lastSidebarSelectionIndex = currentVisualIndex tabManager.selectTab(tab) if wasSelected, !isCommand, !isShift { tabManager.dismissNotificationOnDirectInteraction( diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index def8bb1ed19c..a0a8f5cc8761 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -2986,7 +2986,7 @@ final class WorkspaceReorderTests: XCTestCase { XCTAssertTrue( manager.moveWorkspaceToInitialDirectoryGroupEnd( tabId: workspace.id, - directory: "/alpha" + directory: "/beta" ) ) From fb7d732a48a4b80323537b64018d4053ab508b34 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 12 May 2026 11:46:33 -0700 Subject: [PATCH 07/11] fix: return rendered sidebar workspace ids --- Sources/ContentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f902894f701b..59ea32649fb6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9675,7 +9675,7 @@ struct VerticalTabsSidebar: View { collapsedGroupIds: Set ) -> [UUID] { let showsFolderHeaders = groupingPlan.folderGroups.count > 1 - groupingPlan.bookmarkIds + groupingPlan.folderGroups.flatMap { group in + return groupingPlan.bookmarkIds + groupingPlan.folderGroups.flatMap { group in showsFolderHeaders && collapsedGroupIds.contains(group.id) ? [] : group.workspaceIds } } From 7417e0f32645fb77da731e11ae993d59bf9da1c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:32:01 -0700 Subject: [PATCH 08/11] test: cover sidebar grouping review regressions --- .../SidebarWorkspaceDropPlannerTests.swift | 31 +++++++++++++++++++ cmuxTests/WorkspaceUnitTests.swift | 21 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 3ac674127bc3..83d8c3c5baec 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -51,6 +51,37 @@ final class SidebarWorkspaceDropPlannerTests: XCTestCase { ) } + func testRenderedWorkspaceIdsExcludeCollapsedFolderRowsWhenHeadersAreVisible() { + let pinned = UUID() + let firstAlpha = UUID() + let secondAlpha = UUID() + let beta = UUID() + let alphaGroup = SidebarWorkspaceFolderGroup(directory: "/alpha", workspaceIds: [firstAlpha, secondAlpha]) + let betaGroup = SidebarWorkspaceFolderGroup(directory: "/beta", workspaceIds: [beta]) + + let renderedIds = SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( + bookmarkIds: [pinned], + folderGroups: [alphaGroup, betaGroup], + collapsedGroupIds: [alphaGroup.id] + ) + + XCTAssertEqual(renderedIds, [pinned, beta]) + } + + func testRenderedWorkspaceIdsKeepSingleFolderVisibleEvenIfCollapsedStateIsStale() { + let firstAlpha = UUID() + let secondAlpha = UUID() + let alphaGroup = SidebarWorkspaceFolderGroup(directory: "/alpha", workspaceIds: [firstAlpha, secondAlpha]) + + let renderedIds = SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( + bookmarkIds: [], + folderGroups: [alphaGroup], + collapsedGroupIds: [alphaGroup.id] + ) + + XCTAssertEqual(renderedIds, [firstAlpha, secondAlpha]) + } + func testWorkspaceDropCenterTargetsExistingWorkspace() { let first = UUID() let second = UUID() diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index f3b84df55da5..de55f5d97aa2 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -3742,6 +3742,27 @@ final class WorkspaceReorderTests: XCTestCase { XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, third.id, second.id]) } + @MainActor + func testMovePinnedWorkspaceToSameDirectoryGroupEndUnpinsAfterPinnedSegment() { + let manager = TabManager() + let moved = manager.tabs[0] + XCTAssertTrue(manager.setWorkspaceInitialDirectory(tabId: moved.id, directory: "/alpha")) + manager.setPinned(moved, pinned: true) + let otherPinned = manager.addWorkspace(workingDirectory: "/other", placementOverride: .end) + manager.setPinned(otherPinned, pinned: true) + let unpinned = manager.addWorkspace(workingDirectory: "/beta", placementOverride: .end) + + XCTAssertTrue( + manager.moveWorkspaceToInitialDirectoryGroupEnd( + tabId: moved.id, + directory: "/alpha" + ) + ) + + XCTAssertFalse(moved.isPinned) + XCTAssertEqual(manager.tabs.map(\.id), [otherPinned.id, moved.id, unpinned.id]) + } + @MainActor private func sidebarVisibleWorkspaceIds(in manager: TabManager) -> [UUID] { SidebarWorkspaceGroupingPlanner.plan( From caad618ce32339908d1d6089f34b10cce721bd63 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:38:27 -0700 Subject: [PATCH 09/11] fix: stabilize sidebar folder grouping actions --- Sources/ContentView.swift | 819 ++++----------------- Sources/Sidebar/SidebarWorkspaceList.swift | 712 ++++++++++++++++++ Sources/TabManager.swift | 2 +- cmux.xcodeproj/project.pbxproj | 4 + 4 files changed, 874 insertions(+), 663 deletions(-) create mode 100644 Sources/Sidebar/SidebarWorkspaceList.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fd365f858f2b..29ea7b2848f1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9361,7 +9361,7 @@ private struct SidebarResizerAccessibilityModifier: ViewModifier { } } -private struct SidebarTabItemSettingsSnapshot: Equatable { +struct SidebarTabItemSettingsSnapshot: Equatable { let hidesAllDetails: Bool let showsWorkspaceDescription: Bool let sidebarShortcutHintXOffset: Double @@ -9704,48 +9704,6 @@ struct VerticalTabsSidebar: View { pendingSelectedWorkspaceScrollId = selectedWorkspaceId } - private struct WorkspaceListRenderContext { - let tabs: [Workspace] - let groupingPlan: SidebarWorkspaceGroupingPlan - let workspaceById: [UUID: Workspace] - let workspaceCount: Int - let canCloseWorkspace: Bool - let workspaceNumberShortcut: StoredShortcut - let tabItemSettings: SidebarTabItemSettingsSnapshot - let tabIndexById: [UUID: Int] - let selectedContextTargetIds: [UUID] - let selectedRemoteContextMenuWorkspaceIds: [UUID] - let allSelectedRemoteContextMenuTargetsConnecting: Bool - let allSelectedRemoteContextMenuTargetsDisconnected: Bool - let workspaceTerminalScrollBarHiddenById: [UUID: Bool] - let renderedWorkspaceIds: [UUID] - let workspaceOrderSignature: Int - - var workspaceIds: [UUID] { - groupingPlan.visibleWorkspaceIds - } - - var groupIds: [String] { - groupingPlan.folderGroups.map(\.id) - } - - func previousRenderedWorkspaceId(before workspaceId: UUID) -> UUID? { - guard let index = renderedWorkspaceIds.firstIndex(of: workspaceId), - index > 0 else { - return nil - } - return renderedWorkspaceIds[index - 1] - } - } - - private struct WorkspaceFolderRenderGroup: Identifiable { - let plan: SidebarWorkspaceFolderGroup - let tabs: [Workspace] - - var id: String { plan.id } - var directory: String { plan.directory } - } - var body: some View { let _ = terminalScrollBarVisibilityGeneration let tabs = tabManager.tabs @@ -9758,15 +9716,20 @@ struct VerticalTabsSidebar: View { ) } ) - let renderedWorkspaceIds = Self.renderedWorkspaceIds( - for: groupingPlan, + let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) + let folderGroups = SidebarWorkspaceListRenderPolicy.folderGroups( + groupingPlan: groupingPlan, + workspaceById: workspaceById + ) + let renderedWorkspaceIds = SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( + bookmarkIds: groupingPlan.bookmarkIds, + folderGroups: folderGroups, collapsedGroupIds: collapsedGroups ) - let workspaceOrderSignature = Self.workspaceOrderSignature( + let workspaceOrderSignature = SidebarWorkspaceListRenderPolicy.workspaceOrderSignature( visibleWorkspaceIds: groupingPlan.visibleWorkspaceIds, renderedWorkspaceIds: renderedWorkspaceIds ) - let workspaceById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) let workspaceCount = tabs.count let canCloseWorkspace = workspaceCount > 1 let workspaceNumberShortcut = self.workspaceNumberShortcut @@ -9801,6 +9764,7 @@ struct VerticalTabsSidebar: View { allSelectedRemoteContextMenuTargetsConnecting: allSelectedRemoteContextMenuTargetsConnecting, allSelectedRemoteContextMenuTargetsDisconnected: allSelectedRemoteContextMenuTargetsDisconnected, workspaceTerminalScrollBarHiddenById: workspaceTerminalScrollBarHiddenById, + folderGroups: folderGroups, renderedWorkspaceIds: renderedWorkspaceIds, workspaceOrderSignature: workspaceOrderSignature ) @@ -9829,6 +9793,8 @@ struct VerticalTabsSidebar: View { modifierKeyMonitor.start() draggedTabId = nil dropIndicator = nil + revealGroupContainingWorkspace(tabManager.selectedTabId, in: renderContext.folderGroups) + syncLastSidebarSelectionIndexForCurrentRenderedOrder(tabManager.selectedTabId) SidebarDragLifecycleNotification.postStateDidChange( tabId: nil, reason: "sidebar_appear" @@ -9870,6 +9836,16 @@ struct VerticalTabsSidebar: View { } .onChange(of: renderContext.groupIds) { _, groupIds in collapsedGroups.formIntersection(Set(groupIds)) + revealGroupContainingWorkspace(tabManager.selectedTabId, in: renderContext.folderGroups) + syncLastSidebarSelectionIndexForCurrentRenderedOrder(tabManager.selectedTabId) + } + .onChange(of: renderContext.workspaceOrderSignature) { _, _ in + revealGroupContainingWorkspace(tabManager.selectedTabId, in: renderContext.folderGroups) + syncLastSidebarSelectionIndexForCurrentRenderedOrder(tabManager.selectedTabId) + } + .onChange(of: tabManager.selectedTabId) { _, selectedWorkspaceId in + revealGroupContainingWorkspace(selectedWorkspaceId, in: renderContext.folderGroups) + syncLastSidebarSelectionIndexForCurrentRenderedOrder(selectedWorkspaceId) } .onReceive(NotificationCenter.default.publisher(for: SidebarDragLifecycleNotification.requestClear)) { notification in guard draggedTabId != nil else { return } @@ -9948,6 +9924,7 @@ struct VerticalTabsSidebar: View { selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, targetRowHeight: nil, + planningTabIds: { currentRenderedWorkspaceIdsForSidebarSelection() }, dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) @@ -10037,6 +10014,9 @@ struct VerticalTabsSidebar: View { SidebarEmptyArea( rowSpacing: tabRowSpacing, + renderedWorkspaceIdsForSelection: { + SidebarWorkspaceListRenderPolicy.visibleWorkspaceIds(in: tabManager) + }, selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, @@ -10785,16 +10765,87 @@ struct VerticalTabsSidebar: View { } } + private func revealGroupContainingWorkspace( + _ workspaceId: UUID?, + in folderGroups: [WorkspaceFolderRenderGroup] + ) { + guard let workspaceId, + let group = folderGroups.first(where: { $0.plan.workspaceIds.contains(workspaceId) }) else { + return + } + collapsedGroups.remove(group.id) + } + + private func currentRenderedWorkspaceIdsForSidebarSelection() -> [UUID] { + SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( + in: tabManager, + collapsedGroupIds: collapsedGroups + ) + } + + private func syncLastSidebarSelectionIndexForCurrentRenderedOrder(_ workspaceId: UUID?) { + guard let workspaceId else { + lastSidebarSelectionIndex = nil + return + } + lastSidebarSelectionIndex = currentRenderedWorkspaceIdsForSidebarSelection().firstIndex(of: workspaceId) + } + private func workspaceScrollContent( renderContext: WorkspaceListRenderContext, minHeight: CGFloat ) -> some View { VStack(spacing: 0) { - workspaceRows(renderContext: renderContext) + SidebarWorkspaceList( + renderContext: renderContext, + rowSpacing: tabRowSpacing, + collapsedGroupIds: $collapsedGroups, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator, + onCreateWorkspaceInDirectory: { directory in + tabManager.addWorkspace(workingDirectory: directory, placementOverride: .end) + }, + onMoveBonsplitTabToExistingWorkspace: { workspaceId, transfer in + guard let app = AppDelegate.shared else { + return false + } + if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), + source.workspaceId == workspaceId { + return true + } + return app.moveBonsplitTab( + tabId: transfer.tab.id, + toWorkspace: workspaceId, + focus: true, + focusWindow: true + ) + }, + onMoveBonsplitTabToNewWorkspace: { insertionIndex, transfer in + guard let app = AppDelegate.shared, + let result = app.moveBonsplitTabToNewWorkspace( + tabId: transfer.tab.id, + destinationManager: tabManager, + focus: true, + focusWindow: true, + insertionIndexOverride: insertionIndex + ) else { + return nil + } + return result.destinationWorkspaceId + }, + rowContent: { tab in + workspaceRow(tab, renderContext: renderContext) + } + ) SidebarEmptyArea( lastWorkspaceId: renderContext.renderedWorkspaceIds.last ?? renderContext.workspaceIds.last, rowSpacing: tabRowSpacing, + renderedWorkspaceIdsForSelection: { currentRenderedWorkspaceIdsForSidebarSelection() }, selection: $selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, @@ -10807,143 +10858,6 @@ struct VerticalTabsSidebar: View { .frame(minHeight: minHeight, alignment: .top) } - private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View { - let bookmarkTabs = renderContext.groupingPlan.bookmarkIds.compactMap { renderContext.workspaceById[$0] } - let folderGroups = renderFolderGroups(renderContext: renderContext) - let showsFolderHeaders = folderGroups.count > 1 - - // Workspaces are bounded, so prefer a non-lazy stack here. - // LazyVStack + drag-state invalidations can recurse through layout. - return VStack(spacing: tabRowSpacing) { - if !bookmarkTabs.isEmpty { - SidebarBookmarkHeader( - count: bookmarkTabs.count, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - ) - ForEach(bookmarkTabs, id: \.id) { tab in - workspaceRow(tab, renderContext: renderContext) - } - if !folderGroups.isEmpty { - SidebarWorkspaceSectionDivider() - } - } - - ForEach(Array(folderGroups.enumerated()), id: \.element.id) { index, group in - if showsFolderHeaders { - if index > 0 { - SidebarWorkspaceSectionDivider() - } - SidebarGroupHeader( - directory: group.directory, - count: group.tabs.count, - isCollapsed: collapsedGroups.contains(group.id), - onToggle: { - withAnimation(.easeOut(duration: 0.1)) { - toggleCollapsedGroup(group.id) - } - }, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - ) - } - - if !showsFolderHeaders || !collapsedGroups.contains(group.id) { - ForEach(group.tabs, id: \.id) { tab in - workspaceRow(tab, renderContext: renderContext) - } - } - } - - SidebarAddFolderButton { directory in - tabManager.addWorkspace(workingDirectory: directory, placementOverride: .end) - } - } - .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) - .frame(maxWidth: .infinity, alignment: .leading) - .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in - GeometryReader { proxy in - SidebarBonsplitTabWorkspaceDropOverlay( - currentSelectedTabId: { - tabManager.selectedTabId - }, - sidebarIndexForTabId: { workspaceId in - tabManager.tabs.firstIndex { $0.id == workspaceId } - }, - moveToExistingWorkspace: { workspaceId, transfer in - guard let app = AppDelegate.shared else { - return false - } - if let source = app.locateBonsplitSurface(tabId: transfer.tab.id), - source.workspaceId == workspaceId { - return true - } - return app.moveBonsplitTab( - tabId: transfer.tab.id, - toWorkspace: workspaceId, - focus: true, - focusWindow: true - ) - }, - moveToNewWorkspace: { insertionIndex, transfer in - guard let app = AppDelegate.shared, - let result = app.moveBonsplitTabToNewWorkspace( - tabId: transfer.tab.id, - destinationManager: tabManager, - focus: true, - focusWindow: true, - insertionIndexOverride: insertionIndex - ) else { - return nil - } - return result.destinationWorkspaceId - }, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dropIndicator: $dropIndicator, - updateAutoscroll: { - dragAutoScrollController.updateFromDragLocation() - }, - targets: renderContext.workspaceIds.compactMap { workspaceId in - guard let tab = renderContext.workspaceById[workspaceId], - let anchor = anchors[workspaceId] else { return nil } - return SidebarDropPlanner.WorkspaceDropTarget( - workspaceId: tab.id, - isPinned: tab.isPinned, - frame: proxy[anchor] - ) - } - ) - } - } - } - - private func renderFolderGroups( - renderContext: WorkspaceListRenderContext - ) -> [WorkspaceFolderRenderGroup] { - renderContext.groupingPlan.folderGroups.compactMap { group in - let tabs = group.workspaceIds.compactMap { renderContext.workspaceById[$0] } - guard !tabs.isEmpty else { return nil } - return WorkspaceFolderRenderGroup(plan: group, tabs: tabs) - } - } - - private func toggleCollapsedGroup(_ groupId: String) { - if collapsedGroups.contains(groupId) { - collapsedGroups.remove(groupId) - } else { - collapsedGroups.insert(groupId) - } - } - private func workspaceRow( _ tab: Workspace, renderContext: WorkspaceListRenderContext @@ -11009,8 +10923,10 @@ struct VerticalTabsSidebar: View { index: index, previousRenderedWorkspaceId: renderContext.previousRenderedWorkspaceId(before: tab.id), workspaceOrderSignature: renderContext.workspaceOrderSignature, - visualWorkspaceIdsForActions: { renderContext.workspaceIds }, - renderedWorkspaceIdsForSelection: { renderContext.renderedWorkspaceIds }, + visualWorkspaceIdsForActions: { + SidebarWorkspaceListRenderPolicy.visibleWorkspaceIds(in: tabManager) + }, + renderedWorkspaceIdsForSelection: { currentRenderedWorkspaceIdsForSidebarSelection() }, isActive: tabManager.selectedTabId == tab.id, workspaceShortcutDigit: WorkspaceShortcutMapper.digitForWorkspace( at: index, @@ -11048,54 +10964,12 @@ struct VerticalTabsSidebar: View { } } - private static func renderedWorkspaceIds( - for groupingPlan: SidebarWorkspaceGroupingPlan, - collapsedGroupIds: Set - ) -> [UUID] { - let showsFolderHeaders = groupingPlan.folderGroups.count > 1 - return groupingPlan.bookmarkIds + groupingPlan.folderGroups.flatMap { group in - showsFolderHeaders && collapsedGroupIds.contains(group.id) ? [] : group.workspaceIds - } - } - - private static func workspaceOrderSignature( - visibleWorkspaceIds: [UUID], - renderedWorkspaceIds: [UUID] - ) -> Int { - var hasher = Hasher() - hasher.combine(visibleWorkspaceIds.count) - for workspaceId in visibleWorkspaceIds { - hasher.combine(workspaceId) - } - hasher.combine(renderedWorkspaceIds.count) - for workspaceId in renderedWorkspaceIds { - hasher.combine(workspaceId) - } - return hasher.finalize() - } - private func debugShortSidebarTabId(_ id: UUID?) -> String { guard let id else { return "nil" } return String(id.uuidString.prefix(5)) } } -private struct SidebarWorkspaceRowIdsPreferenceKey: PreferenceKey { - static let defaultValue: Set = [] - - static func reduce(value: inout Set, nextValue: () -> Set) { - value.formUnion(nextValue()) - } -} - -private struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { - static let defaultValue: [UUID: Anchor] = [:] - - static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { - value.merge(nextValue()) { _, next in next } - } -} - enum ShortcutHintModifierPolicy { static let intentionalHoldDelay: TimeInterval = 0.30 @@ -13267,138 +13141,11 @@ private final class SidebarScrollViewResolverView: NSView { } } -private struct SidebarWorkspaceSectionDivider: View { - var body: some View { - Divider() - .padding(.horizontal, 10) - .padding(.vertical, 3) - } -} - -private struct SidebarBookmarkHeader: View { - let count: Int - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - HStack(spacing: 6) { - Image(systemName: "flag.fill") - .font(.system(size: 10, weight: .semibold)) - Text(String(localized: "sidebar.bookmarks.title", defaultValue: "BOOKMARKS")) - .font(.system(size: 10, weight: .bold)) - Spacer(minLength: 0) - Text("\(count)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.secondary) - } - .foregroundColor(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarBookmarkHeaderDropDelegate( - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - } -} - -private struct SidebarGroupHeader: View { - let directory: String - let count: Int - let isCollapsed: Bool - let onToggle: () -> Void - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - var body: some View { - Button(action: onToggle) { - HStack(spacing: 6) { - Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") - .font(.system(size: 9, weight: .bold)) - .frame(width: 10) - Image(systemName: "folder.fill") - .font(.system(size: 10, weight: .semibold)) - Text(SidebarPathFormatter.shortenedPath(directory)) - .font(.system(size: 10, weight: .bold)) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 0) - if isCollapsed { - Text("\(count)") - .font(.system(size: 9, weight: .semibold)) - .foregroundColor(.secondary) - } - } - .foregroundColor(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .safeHelp(directory) - .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarGroupHeaderDropDelegate( - directory: directory, - tabManager: tabManager, - draggedTabId: $draggedTabId, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dragAutoScrollController: dragAutoScrollController, - dropIndicator: $dropIndicator - )) - } -} - -private struct SidebarAddFolderButton: View { - let onSelectDirectory: (String) -> Void - - var body: some View { - Button(action: openFolderPicker) { - HStack(spacing: 7) { - Image(systemName: "folder.badge.plus") - .font(.system(size: 12, weight: .semibold)) - Text(String(localized: "sidebar.addFolder.button", defaultValue: "Add Folder")) - .font(.system(size: 11, weight: .semibold)) - Spacer(minLength: 0) - } - .foregroundColor(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .safeHelp(String(localized: "sidebar.addFolder.tooltip", defaultValue: "Open a folder in a new workspace")) - .accessibilityIdentifier("SidebarAddFolderButton") - } - - private func openFolderPicker() { - let panel = NSOpenPanel() - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.title = String(localized: "sidebar.addFolder.panelTitle", defaultValue: "Add Folder") - panel.prompt = String(localized: "sidebar.addFolder.panelPrompt", defaultValue: "Add") - if panel.runModal() == .OK, let url = panel.url { - onSelectDirectory(url.path) - } - } -} - private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager - let lastWorkspaceId: UUID? + let lastWorkspaceId: UUID? = nil let rowSpacing: CGFloat + let renderedWorkspaceIdsForSelection: () -> [UUID] @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -13414,7 +13161,7 @@ private struct SidebarEmptyArea: View { tabManager.addWorkspace(placementOverride: .end) if let selectedId = tabManager.selectedTabId { selectedTabIds = [selectedId] - lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) + lastSidebarSelectionIndex = renderedWorkspaceIdsForSelection().firstIndex(of: selectedId) } selection = .tabs } @@ -13425,6 +13172,7 @@ private struct SidebarEmptyArea: View { selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, targetRowHeight: nil, + planningTabIds: renderedWorkspaceIdsForSelection, dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) @@ -14349,6 +14097,7 @@ private struct TabItemView: View, Equatable { selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, targetRowHeight: rowHeight, + planningTabIds: renderedWorkspaceIdsForSelection, dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) @@ -14356,7 +14105,10 @@ private struct TabItemView: View, Equatable { targetWorkspaceId: tab.id, tabManager: tabManager, selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + sidebarIndexForTabId: { workspaceId in + renderedWorkspaceIdsForSelection().firstIndex(of: workspaceId) + } )) .onTapGesture { updateSelection() @@ -14599,17 +14351,15 @@ private struct TabItemView: View, Equatable { Divider() - let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() - let renderedWorkspaceIndex = renderedWorkspaceIds.firstIndex(of: tab.id) Button(String(localized: "contextMenu.moveUp", defaultValue: "Move Up")) { moveBy(-1) } - .disabled(renderedWorkspaceIndex == nil || renderedWorkspaceIndex == 0) + .disabled(!canMoveBy(-1)) Button(String(localized: "contextMenu.moveDown", defaultValue: "Move Down")) { moveBy(1) } - .disabled(renderedWorkspaceIndex == nil || renderedWorkspaceIndex == renderedWorkspaceIds.count - 1) + .disabled(!canMoveBy(1)) Button(String(localized: "contextMenu.moveToTop", defaultValue: "Move to Top")) { tabManager.moveTabsToTop(Set(targetIds)) @@ -14758,24 +14508,45 @@ private struct TabItemView: View, Equatable { } private func moveBy(_ delta: Int) { - let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() - guard let currentRenderedIndex = renderedWorkspaceIds.firstIndex(of: tab.id) else { return } - let targetRenderedIndex = currentRenderedIndex + delta - guard renderedWorkspaceIds.indices.contains(targetRenderedIndex) else { return } - let targetTabId = renderedWorkspaceIds[targetRenderedIndex] - let visualWorkspaceIds = visualWorkspaceIdsForActions() - guard let targetIndex = visualWorkspaceIds.firstIndex(of: targetTabId) else { return } + guard let target = moveTarget(delta: delta) else { return } guard tabManager.moveWorkspaceInSidebarVisualOrder( tabId: tab.id, - toVisibleIndex: targetIndex, - initialDirectory: nil + toVisibleIndex: target.visibleIndex, + initialDirectory: target.initialDirectory ) else { return } selectedTabIds = [tab.id] - lastSidebarSelectionIndex = sidebarVisualIndex(of: tab.id, in: tabManager) + lastSidebarSelectionIndex = target.renderedIndex tabManager.selectTab(tab) setSelectionToTabs() } + private func canMoveBy(_ delta: Int) -> Bool { + moveTarget(delta: delta) != nil + } + + private struct SidebarMoveTarget { + let renderedIndex: Int + let visibleIndex: Int + let initialDirectory: String? + } + + private func moveTarget(delta: Int) -> SidebarMoveTarget? { + let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() + guard let currentRenderedIndex = renderedWorkspaceIds.firstIndex(of: tab.id) else { return nil } + let targetRenderedIndex = currentRenderedIndex + delta + guard renderedWorkspaceIds.indices.contains(targetRenderedIndex) else { return nil } + let targetTabId = renderedWorkspaceIds[targetRenderedIndex] + guard let targetWorkspace = tabManager.tabs.first(where: { $0.id == targetTabId }) else { return nil } + guard tab.isPinned == targetWorkspace.isPinned else { return nil } + let visualWorkspaceIds = visualWorkspaceIdsForActions() + guard let targetVisibleIndex = visualWorkspaceIds.firstIndex(of: targetTabId) else { return nil } + return SidebarMoveTarget( + renderedIndex: targetRenderedIndex, + visibleIndex: targetVisibleIndex, + initialDirectory: tab.isPinned ? nil : targetWorkspace.initialDirectory + ) + } + private func updateSelection() { #if DEBUG let mods = NSEvent.modifierFlags @@ -14791,18 +14562,16 @@ private struct TabItemView: View, Equatable { let isShift = modifiers.contains(.shift) let wasSelected = tabManager.selectedTabId == tab.id - let visualWorkspaceIds = visualWorkspaceIdsForActions() let renderedWorkspaceIds = renderedWorkspaceIdsForSelection() - let renderedWorkspaceIdSet = Set(renderedWorkspaceIds) - let currentVisualIndex = visualWorkspaceIds.firstIndex(of: tab.id) ?? index + let currentRenderedIndex = renderedWorkspaceIds.firstIndex(of: tab.id) ?? index if isShift, let lastIndex = lastSidebarSelectionIndex, - visualWorkspaceIds.indices.contains(lastIndex), - visualWorkspaceIds.indices.contains(currentVisualIndex) { - let lower = min(lastIndex, currentVisualIndex) - let upper = max(lastIndex, currentVisualIndex) - let rangeIds = visualWorkspaceIds[lower...upper].filter { renderedWorkspaceIdSet.contains($0) } + renderedWorkspaceIds.indices.contains(lastIndex), + renderedWorkspaceIds.indices.contains(currentRenderedIndex) { + let lower = min(lastIndex, currentRenderedIndex) + let upper = max(lastIndex, currentRenderedIndex) + let rangeIds = renderedWorkspaceIds[lower...upper] if isCommand { selectedTabIds.formUnion(rangeIds) } else { @@ -14818,7 +14587,7 @@ private struct TabItemView: View, Equatable { selectedTabIds = [tab.id] } - lastSidebarSelectionIndex = currentVisualIndex + lastSidebarSelectionIndex = currentRenderedIndex tabManager.selectTab(tab) if wasSelected, !isCommand, !isShift { tabManager.dismissNotificationOnDirectInteraction( @@ -14881,7 +14650,7 @@ private struct TabItemView: View, Equatable { selectedTabIds = [selectedId] } if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) + lastSidebarSelectionIndex = renderedWorkspaceIdsForSelection().firstIndex(of: selectedId) } } @@ -15410,7 +15179,7 @@ private struct TabItemView: View, Equatable { private func beginWorkspaceDescriptionEditFromContextMenu() { selectedTabIds = [tab.id] - lastSidebarSelectionIndex = index + lastSidebarSelectionIndex = renderedWorkspaceIdsForSelection().firstIndex(of: tab.id) tabManager.selectTab(tab) setSelectionToTabs() _ = AppDelegate.shared?.requestEditWorkspaceDescriptionViaCommandPalette() @@ -15765,7 +15534,7 @@ enum SidebarDragAutoScrollPlanner { } @MainActor -private final class SidebarDragAutoScrollController: ObservableObject { +final class SidebarDragAutoScrollController: ObservableObject { private weak var scrollView: NSScrollView? private var timer: Timer? private var activePlan: SidebarAutoScrollPlan? @@ -15990,6 +15759,7 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { let tabManager: TabManager @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? + let sidebarIndexForTabId: (UUID) -> Int? func validateDrop(info: DropInfo) -> Bool { guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } @@ -16030,288 +15800,13 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { private func syncSidebarSelection() { if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) - } else { - lastSidebarSelectionIndex = nil - } - } -} - -private struct SidebarGroupHeaderDropDelegate: DropDelegate { - let directory: String - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil - } - - func dropEntered(info: DropInfo) { - dragAutoScrollController.updateFromDragLocation() - dropIndicator = nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - dropIndicator = nil - return validateDrop(info: info) ? DropProposal(operation: .move) : nil - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - guard validateDrop(info: info), let draggedTabId else { return false } - guard tabManager.moveWorkspaceToInitialDirectoryGroupEnd( - tabId: draggedTabId, - directory: directory - ) else { - return false - } - selectedTabIds = [draggedTabId] - syncSidebarSelection(preferredSelectedTabId: draggedTabId) - return true - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) - } else { - lastSidebarSelectionIndex = nil - } - } -} - -private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil - } - - func dropEntered(info: DropInfo) { - dragAutoScrollController.updateFromDragLocation() - dropIndicator = nil - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - dropIndicator = nil - return validateDrop(info: info) ? DropProposal(operation: .move) : nil - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - guard validateDrop(info: info), let draggedTabId else { return false } - guard tabManager.moveWorkspaceToBookmarksEnd(tabId: draggedTabId) else { return false } - selectedTabIds = [draggedTabId] - syncSidebarSelection(preferredSelectedTabId: draggedTabId) - return true - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = sidebarVisualIndex(of: selectedId, in: tabManager) + lastSidebarSelectionIndex = sidebarIndexForTabId(selectedId) } else { lastSidebarSelectionIndex = nil } } } -private struct SidebarTabDropDelegate: DropDelegate { - let targetTabId: UUID? - let tabManager: TabManager - @Binding var draggedTabId: UUID? - @Binding var selectedTabIds: Set - @Binding var lastSidebarSelectionIndex: Int? - let targetRowHeight: CGFloat? - let dragAutoScrollController: SidebarDragAutoScrollController - @Binding var dropIndicator: SidebarDropIndicator? - - func validateDrop(info: DropInfo) -> Bool { - let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) - let hasDrag = draggedTabId != nil - #if DEBUG - cmuxDebugLog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") - #endif - return hasType && hasDrag - } - - func dropEntered(info: DropInfo) { - #if DEBUG - cmuxDebugLog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) - } - - func dropExited(info: DropInfo) { -#if DEBUG - cmuxDebugLog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") -#endif - if dropIndicator?.tabId == targetTabId { - dropIndicator = nil - } - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(for: info) -#if DEBUG - cmuxDebugLog( - "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + - "indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return DropProposal(operation: .move) - } - - func performDrop(info: DropInfo) -> Bool { - defer { - draggedTabId = nil - dropIndicator = nil - dragAutoScrollController.stop() - } - #if DEBUG - cmuxDebugLog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") - #endif - guard let draggedTabId else { -#if DEBUG - cmuxDebugLog("sidebar.drop.abort reason=missingDraggedTab") -#endif - return false - } - let tabIds = planningTabIds() - guard let fromIndex = tabIds.firstIndex(of: draggedTabId), - let draggedWorkspace = tabManager.tabs.first(where: { $0.id == draggedTabId }) else { -#if DEBUG - cmuxDebugLog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - let targetInitialDirectory: String? = targetTabId.flatMap { targetTabId in - guard let targetWorkspace = tabManager.tabs.first(where: { $0.id == targetTabId }), - !targetWorkspace.isPinned else { - return nil - } - return targetWorkspace.initialDirectory - } - let moveInitialDirectory = draggedWorkspace.isPinned ? nil : targetInitialDirectory - let changesInitialDirectory = moveInitialDirectory.map { draggedWorkspace.initialDirectory != $0 } ?? false - let plannedTargetIndex = SidebarDropPlanner.targetIndex( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - indicator: dropIndicator, - tabIds: tabIds, - pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - ) - guard let targetIndex = plannedTargetIndex ?? (changesInitialDirectory ? fromIndex : nil) else { -#if DEBUG - cmuxDebugLog( - "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + - "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" - ) -#endif - return false - } - - guard tabManager.moveWorkspaceInSidebarVisualOrder( - tabId: draggedTabId, - toVisibleIndex: targetIndex, - initialDirectory: moveInitialDirectory - ) else { -#if DEBUG - cmuxDebugLog("sidebar.drop.abort reason=moveFailed tab=\(draggedTabId.uuidString.prefix(5))") -#endif - return false - } - -#if DEBUG - cmuxDebugLog( - "sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) " + - "from=\(fromIndex) to=\(targetIndex) groupChanged=\(changesInitialDirectory ? 1 : 0)" - ) -#endif - if let selectedId = tabManager.selectedTabId { - selectedTabIds = [selectedId] - syncSidebarSelection(preferredSelectedTabId: selectedId) - } else { - selectedTabIds = [] - syncSidebarSelection() - } - return true - } - - private func updateDropIndicator(for info: DropInfo) { - let tabIds = planningTabIds() - let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) - let nextIndicator = SidebarDropPlanner.indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: targetTabId == nil ? nil : info.location.y, - targetHeight: targetRowHeight - ) - guard dropIndicator != nextIndicator else { return } - dropIndicator = nextIndicator - } - - private func planningTabIds() -> [UUID] { - sidebarVisibleWorkspaceIds(in: tabManager) - } - - private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { - let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId - if let selectedId { - lastSidebarSelectionIndex = planningTabIds().firstIndex(of: selectedId) - } else { - lastSidebarSelectionIndex = nil - } - } - - private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { - guard let indicator else { return "nil" } - let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" - return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" - } -} - -@MainActor -private func sidebarVisualIndex(of workspaceId: UUID, in tabManager: TabManager) -> Int? { - sidebarVisibleWorkspaceIds(in: tabManager).firstIndex(of: workspaceId) -} - -@MainActor -private func sidebarVisibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { - SidebarWorkspaceGroupingPlanner.plan( - for: tabManager.tabs.map { - SidebarWorkspaceGroupingInput( - id: $0.id, - initialDirectory: $0.initialDirectory, - isPinned: $0.isPinned - ) - } - ).visibleWorkspaceIds -} - struct ExtensionSidebarBrowserStackDropRow: Equatable { let workspaceId: UUID let sectionId: String diff --git a/Sources/Sidebar/SidebarWorkspaceList.swift b/Sources/Sidebar/SidebarWorkspaceList.swift new file mode 100644 index 000000000000..b2802bf3762b --- /dev/null +++ b/Sources/Sidebar/SidebarWorkspaceList.swift @@ -0,0 +1,712 @@ +import AppKit +import Bonsplit +import SwiftUI + +struct WorkspaceListRenderContext { + let tabs: [Workspace] + let groupingPlan: SidebarWorkspaceGroupingPlan + let workspaceById: [UUID: Workspace] + let workspaceCount: Int + let canCloseWorkspace: Bool + let workspaceNumberShortcut: StoredShortcut + let tabItemSettings: SidebarTabItemSettingsSnapshot + let tabIndexById: [UUID: Int] + let selectedContextTargetIds: [UUID] + let selectedRemoteContextMenuWorkspaceIds: [UUID] + let allSelectedRemoteContextMenuTargetsConnecting: Bool + let allSelectedRemoteContextMenuTargetsDisconnected: Bool + let workspaceTerminalScrollBarHiddenById: [UUID: Bool] + let folderGroups: [WorkspaceFolderRenderGroup] + let renderedWorkspaceIds: [UUID] + let workspaceOrderSignature: Int + + var workspaceIds: [UUID] { + groupingPlan.bookmarkIds + folderGroups.flatMap { $0.plan.workspaceIds } + } + + var groupIds: [String] { + folderGroups.map(\.id) + } + + func previousRenderedWorkspaceId(before workspaceId: UUID) -> UUID? { + guard let index = renderedWorkspaceIds.firstIndex(of: workspaceId), + index > 0 else { + return nil + } + return renderedWorkspaceIds[index - 1] + } +} + +struct WorkspaceFolderRenderGroup: Identifiable { + let plan: SidebarWorkspaceFolderGroup + let tabs: [Workspace] + + var id: String { plan.id } + var directory: String { plan.directory } +} + +enum SidebarWorkspaceListRenderPolicy { + static func folderGroups( + groupingPlan: SidebarWorkspaceGroupingPlan, + workspaceById: [UUID: Workspace] + ) -> [WorkspaceFolderRenderGroup] { + groupingPlan.folderGroups.compactMap { group in + let tabs = group.workspaceIds.compactMap { workspaceById[$0] } + guard !tabs.isEmpty else { return nil } + return WorkspaceFolderRenderGroup(plan: group, tabs: tabs) + } + } + + static func renderedWorkspaceIds( + bookmarkIds: [UUID], + folderGroups: [WorkspaceFolderRenderGroup], + collapsedGroupIds: Set + ) -> [UUID] { + renderedWorkspaceIds( + bookmarkIds: bookmarkIds, + folderGroups: folderGroups.map(\.plan), + collapsedGroupIds: collapsedGroupIds + ) + } + + static func renderedWorkspaceIds( + bookmarkIds: [UUID], + folderGroups: [SidebarWorkspaceFolderGroup], + collapsedGroupIds: Set + ) -> [UUID] { + let showsFolderHeaders = showsFolderHeaders(folderGroups) + return bookmarkIds + folderGroups.flatMap { group in + showsFolderHeaders && collapsedGroupIds.contains(group.id) ? [] : group.workspaceIds + } + } + + static func showsFolderHeaders(_ folderGroups: [WorkspaceFolderRenderGroup]) -> Bool { + folderGroups.count > 1 + } + + static func showsFolderHeaders(_ folderGroups: [SidebarWorkspaceFolderGroup]) -> Bool { + folderGroups.count > 1 + } + + static func workspaceOrderSignature( + visibleWorkspaceIds: [UUID], + renderedWorkspaceIds: [UUID] + ) -> Int { + var hasher = Hasher() + hasher.combine(visibleWorkspaceIds.count) + for workspaceId in visibleWorkspaceIds { + hasher.combine(workspaceId) + } + hasher.combine(renderedWorkspaceIds.count) + for workspaceId in renderedWorkspaceIds { + hasher.combine(workspaceId) + } + return hasher.finalize() + } + + static func groupingPlan(in tabManager: TabManager) -> SidebarWorkspaceGroupingPlan { + SidebarWorkspaceGroupingPlanner.plan( + for: tabManager.tabs.map { + SidebarWorkspaceGroupingInput( + id: $0.id, + initialDirectory: $0.initialDirectory, + isPinned: $0.isPinned + ) + } + ) + } + + static func renderedWorkspaceIds( + in tabManager: TabManager, + collapsedGroupIds: Set + ) -> [UUID] { + let groupingPlan = groupingPlan(in: tabManager) + return renderedWorkspaceIds( + bookmarkIds: groupingPlan.bookmarkIds, + folderGroups: groupingPlan.folderGroups, + collapsedGroupIds: collapsedGroupIds + ) + } + + static func visibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { + groupingPlan(in: tabManager).visibleWorkspaceIds + } +} + +struct SidebarWorkspaceList: View { + let renderContext: WorkspaceListRenderContext + let rowSpacing: CGFloat + @Binding var collapsedGroupIds: Set + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + let onCreateWorkspaceInDirectory: (String) -> Void + let onMoveBonsplitTabToExistingWorkspace: (UUID, BonsplitTabDragPayload.Transfer) -> Bool + let onMoveBonsplitTabToNewWorkspace: (Int, BonsplitTabDragPayload.Transfer) -> UUID? + let rowContent: (Workspace) -> RowContent + + init( + renderContext: WorkspaceListRenderContext, + rowSpacing: CGFloat, + collapsedGroupIds: Binding>, + tabManager: TabManager, + draggedTabId: Binding, + selectedTabIds: Binding>, + lastSidebarSelectionIndex: Binding, + dragAutoScrollController: SidebarDragAutoScrollController, + dropIndicator: Binding, + onCreateWorkspaceInDirectory: @escaping (String) -> Void, + onMoveBonsplitTabToExistingWorkspace: @escaping (UUID, BonsplitTabDragPayload.Transfer) -> Bool, + onMoveBonsplitTabToNewWorkspace: @escaping (Int, BonsplitTabDragPayload.Transfer) -> UUID?, + @ViewBuilder rowContent: @escaping (Workspace) -> RowContent + ) { + self.renderContext = renderContext + self.rowSpacing = rowSpacing + self._collapsedGroupIds = collapsedGroupIds + self.tabManager = tabManager + self._draggedTabId = draggedTabId + self._selectedTabIds = selectedTabIds + self._lastSidebarSelectionIndex = lastSidebarSelectionIndex + self.dragAutoScrollController = dragAutoScrollController + self._dropIndicator = dropIndicator + self.onCreateWorkspaceInDirectory = onCreateWorkspaceInDirectory + self.onMoveBonsplitTabToExistingWorkspace = onMoveBonsplitTabToExistingWorkspace + self.onMoveBonsplitTabToNewWorkspace = onMoveBonsplitTabToNewWorkspace + self.rowContent = rowContent + } + + var body: some View { + let bookmarkTabs = renderContext.groupingPlan.bookmarkIds.compactMap { renderContext.workspaceById[$0] } + let folderGroups = renderContext.folderGroups + let showsFolderHeaders = SidebarWorkspaceListRenderPolicy.showsFolderHeaders(folderGroups) + + return VStack(spacing: rowSpacing) { + if !bookmarkTabs.isEmpty { + SidebarBookmarkHeader( + count: bookmarkTabs.count, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator, + renderedWorkspaceIdsForSelection: currentRenderedWorkspaceIds + ) + ForEach(bookmarkTabs, id: \.id) { tab in + rowContent(tab) + } + if !folderGroups.isEmpty { + SidebarWorkspaceSectionDivider() + } + } + + ForEach(Array(folderGroups.enumerated()), id: \.element.id) { index, group in + if showsFolderHeaders { + if index > 0 { + SidebarWorkspaceSectionDivider() + } + SidebarGroupHeader( + directory: group.directory, + count: group.tabs.count, + isCollapsed: collapsedGroupIds.contains(group.id), + onToggle: { + withAnimation(.easeOut(duration: 0.1)) { + toggleCollapsedGroup(group) + } + }, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator, + renderedWorkspaceIdsForSelection: currentRenderedWorkspaceIds, + onDropWorkspace: { + collapsedGroupIds.remove(group.id) + } + ) + } + + if !showsFolderHeaders || !collapsedGroupIds.contains(group.id) { + ForEach(group.tabs, id: \.id) { tab in + rowContent(tab) + } + } + } + + SidebarAddFolderButton(onSelectDirectory: onCreateWorkspaceInDirectory) + } + .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + SidebarBonsplitTabWorkspaceDropOverlay( + currentSelectedTabId: { + tabManager.selectedTabId + }, + sidebarIndexForTabId: { workspaceId in + currentRenderedWorkspaceIds().firstIndex(of: workspaceId) + }, + moveToExistingWorkspace: onMoveBonsplitTabToExistingWorkspace, + moveToNewWorkspace: onMoveBonsplitTabToNewWorkspace, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dropIndicator: $dropIndicator, + updateAutoscroll: { + dragAutoScrollController.updateFromDragLocation() + }, + targets: renderContext.renderedWorkspaceIds.compactMap { workspaceId in + guard let tab = renderContext.workspaceById[workspaceId], + let anchor = anchors[workspaceId] else { return nil } + return SidebarDropPlanner.WorkspaceDropTarget( + workspaceId: tab.id, + isPinned: tab.isPinned, + frame: proxy[anchor] + ) + } + ) + } + } + } + + private func toggleCollapsedGroup(_ group: WorkspaceFolderRenderGroup) { + if collapsedGroupIds.contains(group.id) { + collapsedGroupIds.remove(group.id) + return + } + guard let selectedWorkspaceId = tabManager.selectedTabId, + group.plan.workspaceIds.contains(selectedWorkspaceId) else { + collapsedGroupIds.insert(group.id) + return + } + collapsedGroupIds.remove(group.id) + } + + private func currentRenderedWorkspaceIds() -> [UUID] { + SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( + in: tabManager, + collapsedGroupIds: collapsedGroupIds + ) + } +} + +private struct SidebarWorkspaceSectionDivider: View { + var body: some View { + Divider() + .padding(.horizontal, 10) + .padding(.vertical, 3) + } +} + +private struct SidebarBookmarkHeader: View { + let count: Int + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + let renderedWorkspaceIdsForSelection: () -> [UUID] + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "flag.fill") + .font(.system(size: 10, weight: .semibold)) + Text(String(localized: "sidebar.bookmarks.title", defaultValue: "BOOKMARKS")) + .font(.system(size: 10, weight: .bold)) + Spacer(minLength: 0) + Text("\(count)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarBookmarkHeaderDropDelegate( + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator, + renderedWorkspaceIdsForSelection: renderedWorkspaceIdsForSelection + )) + } +} + +private struct SidebarGroupHeader: View { + let directory: String + let count: Int + let isCollapsed: Bool + let onToggle: () -> Void + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + let renderedWorkspaceIdsForSelection: () -> [UUID] + let onDropWorkspace: () -> Void + + var body: some View { + Button(action: onToggle) { + HStack(spacing: 6) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .frame(width: 10) + Image(systemName: "folder.fill") + .font(.system(size: 10, weight: .semibold)) + Text(SidebarPathFormatter.shortenedPath(directory)) + .font(.system(size: 10, weight: .bold)) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 0) + if isCollapsed { + Text("\(count)") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.secondary) + } + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .safeHelp(directory) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarGroupHeaderDropDelegate( + directory: directory, + tabManager: tabManager, + draggedTabId: $draggedTabId, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dragAutoScrollController: dragAutoScrollController, + dropIndicator: $dropIndicator, + renderedWorkspaceIdsForSelection: renderedWorkspaceIdsForSelection, + onDropWorkspace: onDropWorkspace + )) + } +} + +private struct SidebarAddFolderButton: View { + let onSelectDirectory: (String) -> Void + + var body: some View { + Button(action: openFolderPicker) { + HStack(spacing: 7) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 12, weight: .semibold)) + Text(String(localized: "sidebar.addFolder.button", defaultValue: "Add Folder")) + .font(.system(size: 11, weight: .semibold)) + Spacer(minLength: 0) + } + .foregroundColor(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .safeHelp(String(localized: "sidebar.addFolder.tooltip", defaultValue: "Open a folder in a new workspace")) + .accessibilityIdentifier("SidebarAddFolderButton") + } + + private func openFolderPicker() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.title = String(localized: "sidebar.addFolder.panelTitle", defaultValue: "Add Folder") + panel.prompt = String(localized: "sidebar.addFolder.panelPrompt", defaultValue: "Add") + if panel.runModal() == .OK, let url = panel.url { + onSelectDirectory(url.path) + } + } +} + +struct SidebarWorkspaceRowIdsPreferenceKey: PreferenceKey { + static let defaultValue: Set = [] + + static func reduce(value: inout Set, nextValue: () -> Set) { + value.formUnion(nextValue()) + } +} + +struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { + static let defaultValue: [UUID: Anchor] = [:] + + static func reduce(value: inout [UUID: Anchor], nextValue: () -> [UUID: Anchor]) { + value.merge(nextValue()) { _, next in next } + } +} + +private struct SidebarGroupHeaderDropDelegate: DropDelegate { + let directory: String + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + let renderedWorkspaceIdsForSelection: () -> [UUID] + let onDropWorkspace: () -> Void + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil + } + + func dropEntered(info: DropInfo) { + dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil + return validateDrop(info: info) ? DropProposal(operation: .move) : nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + guard validateDrop(info: info), let draggedTabId else { return false } + guard tabManager.moveWorkspaceToInitialDirectoryGroupEnd( + tabId: draggedTabId, + directory: directory + ) else { + return false + } + onDropWorkspace() + selectedTabIds = [draggedTabId] + syncSidebarSelection(preferredSelectedTabId: draggedTabId) + return true + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = renderedWorkspaceIdsForSelection().firstIndex(of: selectedId) + } else { + lastSidebarSelectionIndex = nil + } + } +} + +private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + let renderedWorkspaceIdsForSelection: () -> [UUID] + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil + } + + func dropEntered(info: DropInfo) { + dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + dropIndicator = nil + return validateDrop(info: info) ? DropProposal(operation: .move) : nil + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + guard validateDrop(info: info), let draggedTabId else { return false } + guard tabManager.moveWorkspaceToBookmarksEnd(tabId: draggedTabId) else { return false } + selectedTabIds = [draggedTabId] + syncSidebarSelection(preferredSelectedTabId: draggedTabId) + return true + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = renderedWorkspaceIdsForSelection().firstIndex(of: selectedId) + } else { + lastSidebarSelectionIndex = nil + } + } +} + +struct SidebarTabDropDelegate: DropDelegate { + let targetTabId: UUID? + let tabManager: TabManager + @Binding var draggedTabId: UUID? + @Binding var selectedTabIds: Set + @Binding var lastSidebarSelectionIndex: Int? + let targetRowHeight: CGFloat? + let planningTabIds: () -> [UUID] + let dragAutoScrollController: SidebarDragAutoScrollController + @Binding var dropIndicator: SidebarDropIndicator? + + func validateDrop(info: DropInfo) -> Bool { + let hasType = info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) + let hasDrag = draggedTabId != nil + #if DEBUG + cmuxDebugLog("sidebar.validateDrop target=\(targetTabId?.uuidString.prefix(5) ?? "end") hasType=\(hasType) hasDrag=\(hasDrag)") + #endif + return hasType && hasDrag + } + + func dropEntered(info: DropInfo) { + #if DEBUG + cmuxDebugLog("sidebar.dropEntered target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) + } + + func dropExited(info: DropInfo) { +#if DEBUG + cmuxDebugLog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") +#endif + if dropIndicator?.tabId == targetTabId { + dropIndicator = nil + } + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragAutoScrollController.updateFromDragLocation() + updateDropIndicator(for: info) +#if DEBUG + cmuxDebugLog( + "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + + "indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + draggedTabId = nil + dropIndicator = nil + dragAutoScrollController.stop() + } + #if DEBUG + cmuxDebugLog("sidebar.drop target=\(targetTabId?.uuidString.prefix(5) ?? "end")") + #endif + guard let draggedTabId else { +#if DEBUG + cmuxDebugLog("sidebar.drop.abort reason=missingDraggedTab") +#endif + return false + } + let tabIds = planningTabIds() + guard let fromIndex = tabIds.firstIndex(of: draggedTabId), + let draggedWorkspace = tabManager.tabs.first(where: { $0.id == draggedTabId }) else { +#if DEBUG + cmuxDebugLog("sidebar.drop.abort reason=draggedTabMissing tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + let targetInitialDirectory: String? = targetTabId.flatMap { targetTabId in + guard let targetWorkspace = tabManager.tabs.first(where: { $0.id == targetTabId }), + !targetWorkspace.isPinned else { + return nil + } + return targetWorkspace.initialDirectory + } + let moveInitialDirectory = draggedWorkspace.isPinned ? nil : targetInitialDirectory + let changesInitialDirectory = moveInitialDirectory.map { draggedWorkspace.initialDirectory != $0 } ?? false + let plannedTargetIndex = SidebarDropPlanner.targetIndex( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + indicator: dropIndicator, + tabIds: tabIds, + pinnedTabIds: Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + ) + guard let targetIndex = plannedTargetIndex ?? (changesInitialDirectory ? fromIndex : nil) else { +#if DEBUG + cmuxDebugLog( + "sidebar.drop.abort reason=noTargetIndex tab=\(draggedTabId.uuidString.prefix(5)) " + + "target=\(targetTabId?.uuidString.prefix(5) ?? "end") indicator=\(debugIndicator(dropIndicator))" + ) +#endif + return false + } + + guard tabManager.moveWorkspaceInSidebarVisualOrder( + tabId: draggedTabId, + toVisibleIndex: targetIndex, + initialDirectory: moveInitialDirectory + ) else { +#if DEBUG + cmuxDebugLog("sidebar.drop.abort reason=moveFailed tab=\(draggedTabId.uuidString.prefix(5))") +#endif + return false + } + +#if DEBUG + cmuxDebugLog( + "sidebar.drop.commit tab=\(draggedTabId.uuidString.prefix(5)) " + + "from=\(fromIndex) to=\(targetIndex) groupChanged=\(changesInitialDirectory ? 1 : 0)" + ) +#endif + if let selectedId = tabManager.selectedTabId { + selectedTabIds = [selectedId] + syncSidebarSelection(preferredSelectedTabId: selectedId) + } else { + selectedTabIds = [] + syncSidebarSelection() + } + return true + } + + private func updateDropIndicator(for info: DropInfo) { + let tabIds = planningTabIds() + let pinnedTabIds = Set(tabManager.tabs.filter(\.isPinned).map(\.id)) + let nextIndicator = SidebarDropPlanner.indicator( + draggedTabId: draggedTabId, + targetTabId: targetTabId, + tabIds: tabIds, + pinnedTabIds: pinnedTabIds, + pointerY: targetTabId == nil ? nil : info.location.y, + targetHeight: targetRowHeight + ) + guard dropIndicator != nextIndicator else { return } + dropIndicator = nextIndicator + } + + private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { + let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId + if let selectedId { + lastSidebarSelectionIndex = planningTabIds().firstIndex(of: selectedId) + } else { + lastSidebarSelectionIndex = nil + } + } + + private func debugIndicator(_ indicator: SidebarDropIndicator?) -> String { + guard let indicator else { return "nil" } + let tabText = indicator.tabId.map { String($0.uuidString.prefix(5)) } ?? "end" + return "\(tabText):\(indicator.edge == .top ? "top" : "bottom")" + } +} + +@MainActor +func sidebarVisualIndex(of workspaceId: UUID, in tabManager: TabManager) -> Int? { + SidebarWorkspaceListRenderPolicy.visibleWorkspaceIds(in: tabManager).firstIndex(of: workspaceId) +} + +@MainActor +func sidebarVisibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { + SidebarWorkspaceListRenderPolicy.visibleWorkspaceIds(in: tabManager) +} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 7e9807fd031d..1a043a6711af 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5695,7 +5695,7 @@ class TabManager: ObservableObject { }) { insertionIndex = lastGroupIndex + 1 } else if nextInitialDirectory == workspace.initialDirectory { - insertionIndex = min(currentIndex, remainingTabs.count) + insertionIndex = max(min(currentIndex, remainingTabs.count), pinnedCount) } else { insertionIndex = pinnedCount } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d46de6753f4c..c5841391731a 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -94,6 +94,7 @@ D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */; }; + D7AB34310000000000000001 /* SidebarWorkspaceList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34310000000000000002 /* SidebarWorkspaceList.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; D7AB34400000000000000001 /* WorkspaceSurfaceConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000002 /* WorkspaceSurfaceConfig.swift */; }; D7AB34400000000000000003 /* GhosttyTerminalViewVisibilityPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34400000000000000004 /* GhosttyTerminalViewVisibilityPolicyTests.swift */; }; @@ -692,6 +693,7 @@ D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; D7AB34300000000000000002 /* SidebarDropPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDropPlanner.swift; sourceTree = ""; }; D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift; sourceTree = ""; }; + D7AB34310000000000000002 /* SidebarWorkspaceList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceList.swift; sourceTree = ""; }; D7AB34400000000000000002 /* WorkspaceSurfaceConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSurfaceConfig.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C0D002 /* DockPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockPanelView.swift; sourceTree = ""; }; D0C0D0C0D0C0D0C0D0C0D004 /* DockEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DockEmptyView.swift; sourceTree = ""; }; @@ -1262,6 +1264,7 @@ D7AB00000000000000000004 /* ContentView+MoveTabToNewWorkspace.swift */, D7AB34300000000000000002 /* SidebarDropPlanner.swift */, D7AB34300000000000000004 /* SidebarBonsplitTabWorkspaceDropOverlay.swift */, + D7AB34310000000000000002 /* SidebarWorkspaceList.swift */, C0DE32470000000000000002 /* ContentViewIdentifierCopyCommands.swift */, 9960CC3992F3C44489E7627C /* WorkspaceRuntimeSettings.swift */, 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */, @@ -2013,6 +2016,7 @@ D7AB00000000000000000003 /* ContentView+MoveTabToNewWorkspace.swift in Sources */, D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */, D7AB34300000000000000003 /* SidebarBonsplitTabWorkspaceDropOverlay.swift in Sources */, + D7AB34310000000000000001 /* SidebarWorkspaceList.swift in Sources */, C0DE32470000000000000001 /* ContentViewIdentifierCopyCommands.swift in Sources */, 619D509BC9B1EA946CBD6A8A /* WorkspaceRuntimeSettings.swift in Sources */, 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */, From f1525e52a1fbffc4df855f26cf1726bc3d94c59a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:43:02 -0700 Subject: [PATCH 10/11] fix: isolate sidebar render order helpers --- Sources/ContentView.swift | 10 ++++++---- Sources/Sidebar/SidebarWorkspaceList.swift | 16 ++++++++++------ 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 29ea7b2848f1..dfd0ed4c96ec 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10776,6 +10776,7 @@ struct VerticalTabsSidebar: View { collapsedGroups.remove(group.id) } + @MainActor private func currentRenderedWorkspaceIdsForSidebarSelection() -> [UUID] { SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( in: tabManager, @@ -10783,6 +10784,7 @@ struct VerticalTabsSidebar: View { ) } + @MainActor private func syncLastSidebarSelectionIndexForCurrentRenderedOrder(_ workspaceId: UUID?) { guard let workspaceId else { lastSidebarSelectionIndex = nil @@ -13145,7 +13147,7 @@ private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager let lastWorkspaceId: UUID? = nil let rowSpacing: CGFloat - let renderedWorkspaceIdsForSelection: () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] @Binding var selection: SidebarSelection @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @@ -13417,8 +13419,8 @@ private struct TabItemView: View, Equatable { let index: Int let previousRenderedWorkspaceId: UUID? let workspaceOrderSignature: Int - let visualWorkspaceIdsForActions: () -> [UUID] - let renderedWorkspaceIdsForSelection: () -> [UUID] + let visualWorkspaceIdsForActions: @MainActor () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] let isActive: Bool let workspaceShortcutDigit: Int? let workspaceShortcutModifierSymbol: String @@ -15759,7 +15761,7 @@ private struct SidebarBonsplitTabDropDelegate: DropDelegate { let tabManager: TabManager @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? - let sidebarIndexForTabId: (UUID) -> Int? + let sidebarIndexForTabId: @MainActor (UUID) -> Int? func validateDrop(info: DropInfo) -> Bool { guard info.hasItemsConforming(to: [BonsplitTabDragPayload.typeIdentifier]) else { return false } diff --git a/Sources/Sidebar/SidebarWorkspaceList.swift b/Sources/Sidebar/SidebarWorkspaceList.swift index b2802bf3762b..43f9a2d45151 100644 --- a/Sources/Sidebar/SidebarWorkspaceList.swift +++ b/Sources/Sidebar/SidebarWorkspaceList.swift @@ -104,6 +104,7 @@ enum SidebarWorkspaceListRenderPolicy { return hasher.finalize() } + @MainActor static func groupingPlan(in tabManager: TabManager) -> SidebarWorkspaceGroupingPlan { SidebarWorkspaceGroupingPlanner.plan( for: tabManager.tabs.map { @@ -116,6 +117,7 @@ enum SidebarWorkspaceListRenderPolicy { ) } + @MainActor static func renderedWorkspaceIds( in tabManager: TabManager, collapsedGroupIds: Set @@ -128,6 +130,7 @@ enum SidebarWorkspaceListRenderPolicy { ) } + @MainActor static func visibleWorkspaceIds(in tabManager: TabManager) -> [UUID] { groupingPlan(in: tabManager).visibleWorkspaceIds } @@ -248,7 +251,7 @@ struct SidebarWorkspaceList: View { tabManager.selectedTabId }, sidebarIndexForTabId: { workspaceId in - currentRenderedWorkspaceIds().firstIndex(of: workspaceId) + renderContext.renderedWorkspaceIds.firstIndex(of: workspaceId) }, moveToExistingWorkspace: onMoveBonsplitTabToExistingWorkspace, moveToNewWorkspace: onMoveBonsplitTabToNewWorkspace, @@ -285,6 +288,7 @@ struct SidebarWorkspaceList: View { collapsedGroupIds.remove(group.id) } + @MainActor private func currentRenderedWorkspaceIds() -> [UUID] { SidebarWorkspaceListRenderPolicy.renderedWorkspaceIds( in: tabManager, @@ -309,7 +313,7 @@ private struct SidebarBookmarkHeader: View { @Binding var lastSidebarSelectionIndex: Int? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? - let renderedWorkspaceIdsForSelection: () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] var body: some View { HStack(spacing: 6) { @@ -349,7 +353,7 @@ private struct SidebarGroupHeader: View { @Binding var lastSidebarSelectionIndex: Int? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? - let renderedWorkspaceIdsForSelection: () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] let onDropWorkspace: () -> Void var body: some View { @@ -451,7 +455,7 @@ private struct SidebarGroupHeaderDropDelegate: DropDelegate { @Binding var lastSidebarSelectionIndex: Int? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? - let renderedWorkspaceIdsForSelection: () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] let onDropWorkspace: () -> Void func validateDrop(info: DropInfo) -> Bool { @@ -505,7 +509,7 @@ private struct SidebarBookmarkHeaderDropDelegate: DropDelegate { @Binding var lastSidebarSelectionIndex: Int? let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? - let renderedWorkspaceIdsForSelection: () -> [UUID] + let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil @@ -552,7 +556,7 @@ struct SidebarTabDropDelegate: DropDelegate { @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? let targetRowHeight: CGFloat? - let planningTabIds: () -> [UUID] + let planningTabIds: @MainActor () -> [UUID] let dragAutoScrollController: SidebarDragAutoScrollController @Binding var dropIndicator: SidebarDropIndicator? From 79946938ba6829ca253ae779e54d6f1d559c6183 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 23 May 2026 20:48:48 -0700 Subject: [PATCH 11/11] fix: restore sidebar empty area initializer --- Sources/ContentView.swift | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index dfd0ed4c96ec..36e720ee871e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13145,7 +13145,7 @@ private final class SidebarScrollViewResolverView: NSView { private struct SidebarEmptyArea: View { @EnvironmentObject var tabManager: TabManager - let lastWorkspaceId: UUID? = nil + let lastWorkspaceId: UUID? let rowSpacing: CGFloat let renderedWorkspaceIdsForSelection: @MainActor () -> [UUID] @Binding var selection: SidebarSelection @@ -13155,6 +13155,28 @@ private struct SidebarEmptyArea: View { @Binding var draggedTabId: UUID? @Binding var dropIndicator: SidebarDropIndicator? + init( + lastWorkspaceId: UUID? = nil, + rowSpacing: CGFloat, + renderedWorkspaceIdsForSelection: @escaping @MainActor () -> [UUID], + selection: Binding, + selectedTabIds: Binding>, + lastSidebarSelectionIndex: Binding, + dragAutoScrollController: SidebarDragAutoScrollController, + draggedTabId: Binding, + dropIndicator: Binding + ) { + self.lastWorkspaceId = lastWorkspaceId + self.rowSpacing = rowSpacing + self.renderedWorkspaceIdsForSelection = renderedWorkspaceIdsForSelection + _selection = selection + _selectedTabIds = selectedTabIds + _lastSidebarSelectionIndex = lastSidebarSelectionIndex + self.dragAutoScrollController = dragAutoScrollController + _draggedTabId = draggedTabId + _dropIndicator = dropIndicator + } + var body: some View { Color.clear .contentShape(Rectangle())