diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c564af4c715..a6bfcce8b77 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10447,6 +10447,10 @@ struct VerticalTabsSidebar: View { ) @ObservedObject private var keyboardShortcutSettingsObserver = KeyboardShortcutSettingsObserver.shared @State var dragState = SidebarDragState() + // Bonsplit tab drags arrive through AppKit pasteboard callbacks, not + // `SidebarDragState`, so they need a separate transient collection flag. + @State private var isBonsplitWorkspaceDropTargetCollectionActive = false + @State private var bonsplitWorkspaceDropTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() // Freezes `showsModifierShortcutHints` for the workspace whose context menu // is open. Set on the row's contextMenu.onAppear and cleared on // .onDisappear so modifier-key transitions don't flip the badges on the @@ -10866,6 +10870,7 @@ struct VerticalTabsSidebar: View { .onAppear { modifierKeyMonitor.start() dragState.clearDrag() + isBonsplitWorkspaceDropTargetCollectionActive = false // Defensive reset: if a prior simulation died without running // its teardown (sidebar unmounted mid-loop, app crash, etc.) the // @State SidebarDragState could carry isSimulated=true into a @@ -10884,6 +10889,7 @@ struct VerticalTabsSidebar: View { dragAutoScrollController.stop() dragFailsafeMonitor.stop() dragState.clearDrag() + isBonsplitWorkspaceDropTargetCollectionActive = false // Clear the simulator flag too so a re-mounted sidebar doesn't // inherit a stale bypass and skip the real-drag failsafe monitor. dragState.isSimulated = false @@ -12204,90 +12210,148 @@ struct VerticalTabsSidebar: View { .frame(minHeight: minHeight, alignment: .top) } + @ViewBuilder private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View { let renderItems = SidebarWorkspaceRenderItem.renderItems( tabs: renderContext.tabs, groupsById: renderContext.workspaceGroupById ) + let shouldCollectWorkspaceDropTargets = SidebarDropPlanner.shouldCollectWorkspaceDropTargets( + draggedTabId: dragState.draggedTabId, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive + ) // LazyVStack is safe here because `dragState` is @Observable: // drag mutations at 60fps invalidate only the rows/overlays that // read them, never this sidebar body. See SidebarDragState and // https://github.com/manaflow-ai/cmux/issues/2586. - return LazyVStack(spacing: tabRowSpacing) { + let rows = LazyVStack(spacing: tabRowSpacing) { ForEach(renderItems, id: \.id) { item in switch item { case .groupHeader(let group, let memberWorkspaceIds): sidebarWorkspaceGroupHeader( group: group, memberWorkspaceIds: memberWorkspaceIds, - renderContext: renderContext + renderContext: renderContext, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets ) case .workspace(let tab): - workspaceRow(tab, renderContext: renderContext) + workspaceRow( + tab, + renderContext: renderContext, + shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets + ) } } } .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: dropIndicatorBinding, - updateAutoscroll: { - dragAutoScrollController.updateFromDragLocation() - }, - targets: renderContext.tabs.compactMap { tab in - guard let anchor = anchors[tab.id] else { return nil } - return SidebarDropPlanner.WorkspaceDropTarget( - workspaceId: tab.id, - isPinned: tab.isPinned, - frame: proxy[anchor] + + // Gate ONLY the per-row frame-anchor *reader* (the virtualization-defeating + // work) behind the drag-active check, and keep the Bonsplit drop-capture + // overlay mounted *outside* that conditional. Returning the overlay from both + // branches of an `if`/`else` gives it distinct SwiftUI identity, so flipping the + // gate mid-drag (draggingEntered -> shouldCollect=true) tore down and recreated + // the drop NSView, orphaning the in-flight drag. Applying it at the stable outer + // level keeps the NSView identity-stable across gate flips. (#5325 review) + rowsWithGatedDropTargetReader( + rows: rows, + renderContext: renderContext, + shouldCollect: shouldCollectWorkspaceDropTargets + ) + .overlay { + bonsplitWorkspaceDropOverlay() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + /// Conditionally installs the row-frame `overlayPreferenceValue` reader (the part + /// that defeats `LazyVStack` virtualization) only while a drag is collecting drop + /// targets. Kept separate from the always-mounted drop-capture overlay so the gate + /// flip never changes the drop NSView's identity. (#5325 review) + @ViewBuilder + private func rowsWithGatedDropTargetReader( + rows: Rows, + renderContext: WorkspaceListRenderContext, + shouldCollect: Bool + ) -> some View { + if shouldCollect { + rows + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + SidebarBonsplitTabWorkspaceDropOverlay.TargetWriter( + targetBridge: bonsplitWorkspaceDropTargetBridge, + targets: renderContext.tabs.compactMap { tab in + guard let anchor = anchors[tab.id] else { return nil } + return SidebarDropPlanner.WorkspaceDropTarget( + workspaceId: tab.id, + isPinned: tab.isPinned, + frame: proxy[anchor] + ) + } ) } - ) - } + } + } else { + rows } } + private func bonsplitWorkspaceDropOverlay() -> some View { + 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: dropIndicatorBinding, + updateAutoscroll: { + dragAutoScrollController.updateFromDragLocation() + }, + setWorkspaceDropTargetCollectionActive: { isActive in + guard isBonsplitWorkspaceDropTargetCollectionActive != isActive else { return } + isBonsplitWorkspaceDropTargetCollectionActive = isActive + }, + isWorkspaceDropTargetCollectionActive: isBonsplitWorkspaceDropTargetCollectionActive, + targetBridge: bonsplitWorkspaceDropTargetBridge + ) + } + + @ViewBuilder private func workspaceRow( _ tab: Workspace, - renderContext: WorkspaceListRenderContext + renderContext: WorkspaceListRenderContext, + shouldCollectWorkspaceDropTargets: Bool ) -> some View { let index = renderContext.tabIndexById[tab.id] ?? 0 let usesSelectedContextMenuTargets = selectedTabIds.contains(tab.id) @@ -12377,7 +12441,7 @@ struct VerticalTabsSidebar: View { ) } - return TabItemView( + let row = TabItemView( tabManager: tabManager, notificationStore: notificationStore, tab: tab, @@ -12416,10 +12480,10 @@ struct VerticalTabsSidebar: View { .id(tab.id) .accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([tab.id])) - .anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in - [tab.id: anchor] - } - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) + + row + .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) + .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -12436,6 +12500,28 @@ struct SidebarWorkspaceRowIdsPreferenceKey: PreferenceKey { } } +struct SidebarWorkspaceFrameAnchorModifier: ViewModifier { + let id: UUID + let isEnabled: Bool + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content.anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in + [id: anchor] + } + } else { + content + } + } +} + +extension View { + func sidebarWorkspaceFrameAnchor(id: UUID, isEnabled: Bool) -> some View { + modifier(SidebarWorkspaceFrameAnchorModifier(id: id, isEnabled: isEnabled)) + } +} + struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { static let defaultValue: [UUID: Anchor] = [:] diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 3f7c76d7d09..1e7a5812822 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -3,6 +3,42 @@ import Bonsplit import SwiftUI struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { + @MainActor + final class TargetBridge { + fileprivate weak var view: SidebarBonsplitTabWorkspaceDropView? + fileprivate var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] + + func updateTargets(_ targets: [SidebarDropPlanner.WorkspaceDropTarget]) { + self.targets = targets + guard !targets.isEmpty else { return } + DispatchQueue.main.async { [weak view] in + view?.performPendingDropIfPossible() + } + } + + func clearTargets() { + targets = [] + } + } + + struct TargetWriter: View { + let targetBridge: TargetBridge + let targets: [SidebarDropPlanner.WorkspaceDropTarget] + + var body: some View { + Color.clear + .onAppear { + targetBridge.updateTargets(targets) + } + .onChange(of: targets) { _, newTargets in + targetBridge.updateTargets(newTargets) + } + .onDisappear { + targetBridge.clearTargets() + } + } + } + let currentSelectedTabId: () -> UUID? let sidebarIndexForTabId: (UUID) -> Int? let moveToExistingWorkspace: (UUID, BonsplitTabDragPayload.Transfer) -> Bool @@ -11,14 +47,17 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { @Binding var lastSidebarSelectionIndex: Int? @Binding var dropIndicator: SidebarDropIndicator? let updateAutoscroll: () -> Void - let targets: [SidebarDropPlanner.WorkspaceDropTarget] + let setWorkspaceDropTargetCollectionActive: (Bool) -> Void + let isWorkspaceDropTargetCollectionActive: Bool + let targetBridge: TargetBridge func makeNSView(context: Context) -> SidebarBonsplitTabWorkspaceDropView { SidebarBonsplitTabWorkspaceDropView() } func updateNSView(_ nsView: SidebarBonsplitTabWorkspaceDropView, context: Context) { - nsView.targets = targets + targetBridge.view = nsView + nsView.targetBridge = targetBridge nsView.canPerformAction = { action, transfer in guard let app = AppDelegate.shared else { return false @@ -35,6 +74,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { } } nsView.updateAutoscroll = updateAutoscroll + nsView.setWorkspaceDropTargetCollectionActive = setWorkspaceDropTargetCollectionActive nsView.setDropIndicator = { indicator in dropIndicator = indicator } @@ -50,6 +90,14 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { syncSidebarSelection(preferredSelectedTabId: destinationWorkspaceId) return true } + if !isWorkspaceDropTargetCollectionActive, targetBridge.targets.isEmpty { + nsView.clearPendingDropIfIdle() + } + if !targetBridge.targets.isEmpty { + DispatchQueue.main.async { [weak nsView] in + nsView?.performPendingDropIfPossible() + } + } } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { @@ -65,12 +113,25 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { final class SidebarBonsplitTabWorkspaceDropView: NSView { private static let pasteboardType = NSPasteboard.PasteboardType(BonsplitTabDragPayload.typeIdentifier) - var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] + private struct PendingDrop { + let requestId: UInt64 + let point: CGPoint + let transfer: BonsplitTabDragPayload.Transfer + } + + var targetBridge: SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge? var canPerformAction: (SidebarDropPlanner.WorkspaceDropAction, BonsplitTabDragPayload.Transfer) -> Bool = { _, _ in false } var updateAutoscroll: () -> Void = {} + var setWorkspaceDropTargetCollectionActive: (Bool) -> Void = { _ in } var setDropIndicator: (SidebarDropIndicator?) -> Void = { _ in } var performExistingWorkspaceMove: (UUID, BonsplitTabDragPayload.Transfer) -> Bool = { _, _ in false } var performNewWorkspaceMove: (Int, SidebarDropIndicator, BonsplitTabDragPayload.Transfer) -> Bool = { _, _, _ in false } + private var isRequestingWorkspaceDropTargets = false + private var workspaceDropTargetRequestId: UInt64 = 0 + private var pendingDrop: PendingDrop? + private var targets: [SidebarDropPlanner.WorkspaceDropTarget] { + targetBridge?.targets ?? [] + } override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { false } @@ -89,14 +150,22 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - updateDrag(sender, phase: "entered") + updateWorkspaceDropTargetCollection(sender, isActive: true) + return updateDrag(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - updateDrag(sender, phase: "updated") + updateWorkspaceDropTargetCollection(sender, isActive: true) + return updateDrag(sender, phase: "updated") } override func draggingExited(_ sender: (any NSDraggingInfo)?) { + guard pendingDrop == nil else { + completeOrClearPendingDropAfterDragTeardown() + setDropIndicator(nil) + return + } + updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.exited clear=1") #endif @@ -105,7 +174,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { let action = action(for: sender) - let accepted = acceptedTransfer(sender, action: action) != nil + let accepted = acceptedTransfer(sender, action: action) != nil || pendingTransfer(sender) != nil #if DEBUG dlog( "sidebar.workspaceDropOverlay.prepare accepted=\(accepted ? 1 : 0) " + @@ -116,36 +185,104 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - defer { setDropIndicator(nil) } let action = action(for: sender) - guard let action, let transfer = acceptedTransfer(sender, action: action) else { + if let action, let transfer = acceptedTransfer(sender, action: action) { + let moved = perform(action: action, transfer: transfer) + pendingDrop = nil + updateWorkspaceDropTargetCollection(sender, isActive: false) + setDropIndicator(nil) #if DEBUG dlog( - "sidebar.workspaceDropOverlay.perform moved=0 reason=notAccepted " + + "sidebar.workspaceDropOverlay.perform moved=\(moved ? 1 : 0) " + "action=\(debugActionDescription(action))" ) #endif - return false + return moved } - let moved: Bool - switch action { - case .existingWorkspace(let workspaceId): - moved = performExistingWorkspaceMove(workspaceId, transfer) - case .newWorkspace(let insertionIndex, let indicator): - moved = performNewWorkspaceMove(insertionIndex, indicator, transfer) + if let transfer = pendingTransfer(sender) { + pendingDrop = PendingDrop( + requestId: workspaceDropTargetRequestId, + point: localPoint(sender), + transfer: transfer + ) +#if DEBUG + dlog("sidebar.workspaceDropOverlay.perform pendingTargets=1") +#endif + return true + } + + updateWorkspaceDropTargetCollection(sender, isActive: false) + setDropIndicator(nil) +#if DEBUG + dlog( + "sidebar.workspaceDropOverlay.perform moved=0 reason=notAccepted " + + "action=\(debugActionDescription(action))" + ) +#endif + return false + } + + func performPendingDropIfPossible() { + guard let pendingDrop, + pendingDrop.requestId == workspaceDropTargetRequestId, + isRequestingWorkspaceDropTargets, + !targets.isEmpty else { + return + } + self.pendingDrop = nil + defer { + updateWorkspaceDropTargetCollection(nil, isActive: false) + setDropIndicator(nil) + } + + guard let action = SidebarDropPlanner.workspaceAction(for: pendingDrop.point, targets: targets), + canPerformAction(action, pendingDrop.transfer) else { +#if DEBUG + dlog("sidebar.workspaceDropOverlay.performPending moved=0 reason=notAccepted") +#endif + return } + let moved = perform(action: action, transfer: pendingDrop.transfer) #if DEBUG dlog( - "sidebar.workspaceDropOverlay.perform moved=\(moved ? 1 : 0) " + + "sidebar.workspaceDropOverlay.performPending moved=\(moved ? 1 : 0) " + "action=\(debugActionDescription(action))" ) #endif - return moved + } + + func clearPendingDrop() { + pendingDrop = nil + isRequestingWorkspaceDropTargets = false + workspaceDropTargetRequestId &+= 1 + } + + func clearPendingDropIfIdle() { + guard !isRequestingWorkspaceDropTargets else { return } + clearPendingDrop() + } + + private func perform( + action: SidebarDropPlanner.WorkspaceDropAction, + transfer: BonsplitTabDragPayload.Transfer + ) -> Bool { + switch action { + case .existingWorkspace(let workspaceId): + return performExistingWorkspaceMove(workspaceId, transfer) + case .newWorkspace(let insertionIndex, let indicator): + return performNewWorkspaceMove(insertionIndex, indicator, transfer) + } } override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + guard pendingDrop == nil else { + completeOrClearPendingDropAfterDragTeardown() + setDropIndicator(nil) + return + } + updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.concluded clear=1") #endif @@ -154,6 +291,15 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { private func updateDrag(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation { let action = action(for: sender) + if isRequestingWorkspaceDropTargets, + targets.isEmpty, + BonsplitTabDragPayload.transfer(from: sender.draggingPasteboard) != nil { + setDropIndicator(nil) +#if DEBUG + dlog("sidebar.workspaceDropOverlay.\(phase) accepted=1 pendingTargets=1") +#endif + return .move + } guard acceptedTransfer(sender, action: action) != nil, let action else { setDropIndicator(nil) #if DEBUG @@ -182,6 +328,54 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { return .move } + private func completeOrClearPendingDropAfterDragTeardown() { + completeOrClearPendingDropAfterDragTeardown(remainingFrameWaits: 3) + } + + private func completeOrClearPendingDropAfterDragTeardown(remainingFrameWaits: Int) { + let requestId = workspaceDropTargetRequestId + DispatchQueue.main.async { [weak self] in + guard let self, + self.pendingDrop?.requestId == requestId else { + return + } + + if self.targets.isEmpty, remainingFrameWaits > 0 { + self.completeOrClearPendingDropAfterDragTeardown( + remainingFrameWaits: remainingFrameWaits - 1 + ) + return + } + + self.performPendingDropIfPossible() + guard self.pendingDrop?.requestId == requestId else { return } + + self.clearPendingDrop() + self.setWorkspaceDropTargetCollectionActive(false) + self.setDropIndicator(nil) +#if DEBUG + dlog("sidebar.workspaceDropOverlay.pendingTeardown clear=1") +#endif + } + } + + private func updateWorkspaceDropTargetCollection( + _ sender: (any NSDraggingInfo)?, + isActive: Bool + ) { + let shouldRequestTargets = isActive && BonsplitTabDragPayload.canRouteWorkspaceDrop( + pasteboardTypes: sender?.draggingPasteboard.types + ) + if !shouldRequestTargets { + pendingDrop = nil + } + if shouldRequestTargets, !isRequestingWorkspaceDropTargets { + workspaceDropTargetRequestId &+= 1 + } + isRequestingWorkspaceDropTargets = shouldRequestTargets + setWorkspaceDropTargetCollectionActive(shouldRequestTargets) + } + private func acceptedTransfer( _ sender: any NSDraggingInfo, action: SidebarDropPlanner.WorkspaceDropAction? @@ -196,6 +390,11 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { return transfer } + private func pendingTransfer(_ sender: any NSDraggingInfo) -> BonsplitTabDragPayload.Transfer? { + guard isRequestingWorkspaceDropTargets, targets.isEmpty else { return nil } + return BonsplitTabDragPayload.transfer(from: sender.draggingPasteboard) + } + private func action(for sender: any NSDraggingInfo) -> SidebarDropPlanner.WorkspaceDropAction? { SidebarDropPlanner.workspaceAction(for: localPoint(sender), targets: targets) } diff --git a/Sources/Sidebar/SidebarDropPlanner.swift b/Sources/Sidebar/SidebarDropPlanner.swift index 20839c9f940..66b4026507d 100644 --- a/Sources/Sidebar/SidebarDropPlanner.swift +++ b/Sources/Sidebar/SidebarDropPlanner.swift @@ -89,6 +89,14 @@ enum SidebarDropPlanner { let frame: CGRect } + /// Returns whether sidebar rows should publish frame anchors for workspace drop targeting. + static func shouldCollectWorkspaceDropTargets( + draggedTabId: UUID?, + isBonsplitWorkspaceDropActive: Bool = false + ) -> Bool { + draggedTabId != nil || isBonsplitWorkspaceDropActive + } + enum WorkspaceDropAction: Equatable { case newWorkspace(insertionIndex: Int, indicator: SidebarDropIndicator) case existingWorkspace(UUID) diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 6c5269170ae..60e3c351dac 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -6,7 +6,8 @@ extension VerticalTabsSidebar { func sidebarWorkspaceGroupHeader( group: WorkspaceGroup, memberWorkspaceIds: [UUID], - renderContext: WorkspaceListRenderContext + renderContext: WorkspaceListRenderContext, + shouldCollectWorkspaceDropTargets: Bool ) -> some View { let settings = renderContext.tabItemSettings let isAnchorActive = tabManager.selectedTabId == group.anchorWorkspaceId @@ -73,7 +74,7 @@ extension VerticalTabsSidebar { ) } - SidebarWorkspaceGroupHeaderView( + let header = SidebarWorkspaceGroupHeaderView( groupId: group.id, anchorWorkspaceId: group.anchorWorkspaceId, name: group.name, @@ -155,8 +156,11 @@ extension VerticalTabsSidebar { .id(group.anchorWorkspaceId) .accessibilityIdentifier("sidebarWorkspaceGroup.\(group.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([group.anchorWorkspaceId])) - .anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { [anchorId = group.anchorWorkspaceId] anchor in - [anchorId: anchor] - } + + header + .sidebarWorkspaceFrameAnchor( + id: group.anchorWorkspaceId, + isEnabled: shouldCollectWorkspaceDropTargets + ) } } diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 48afa6a9b3a..6b7a5fd6983 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -8,6 +8,21 @@ import XCTest #endif final class SidebarWorkspaceDropPlannerTests: XCTestCase { + func testWorkspaceDropTargetCollectionStaysDisabledWhenNoDragIsActive() { + XCTAssertFalse(SidebarDropPlanner.shouldCollectWorkspaceDropTargets(draggedTabId: nil)) + } + + func testWorkspaceDropTargetCollectionTurnsOnDuringDrag() { + XCTAssertTrue(SidebarDropPlanner.shouldCollectWorkspaceDropTargets(draggedTabId: UUID())) + } + + func testWorkspaceDropTargetCollectionTurnsOnDuringBonsplitWorkspaceDrop() { + XCTAssertTrue(SidebarDropPlanner.shouldCollectWorkspaceDropTargets( + draggedTabId: nil, + isBonsplitWorkspaceDropActive: true + )) + } + func testWorkspaceGroupHeaderDropZoneKeepsUsableCenterAtDefaultHeight() { XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 2, rowHeight: 24)) XCTAssertTrue(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 12, rowHeight: 24))