From eb31c1be4d5dc12ddec4a9e246369b3101bebea3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 17:42:16 -0700 Subject: [PATCH 01/14] test: reproduce sidebar drop target collection bug --- Sources/Sidebar/SidebarDropPlanner.swift | 4 ++++ cmuxTests/SidebarWorkspaceDropPlannerTests.swift | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/Sources/Sidebar/SidebarDropPlanner.swift b/Sources/Sidebar/SidebarDropPlanner.swift index 20839c9f940..726e0a5adeb 100644 --- a/Sources/Sidebar/SidebarDropPlanner.swift +++ b/Sources/Sidebar/SidebarDropPlanner.swift @@ -89,6 +89,10 @@ enum SidebarDropPlanner { let frame: CGRect } + static func shouldCollectWorkspaceDropTargets(draggedTabId: UUID?) -> Bool { + true + } + enum WorkspaceDropAction: Equatable { case newWorkspace(insertionIndex: Int, indicator: SidebarDropIndicator) case existingWorkspace(UUID) diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 48afa6a9b3a..4ba8289bb6f 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -8,6 +8,14 @@ import XCTest #endif final class SidebarWorkspaceDropPlannerTests: XCTestCase { + func testWorkspaceDropTargetCollectionStaysDisabledWhenNoDragIsActive() { + XCTAssertFalse(SidebarDropPlanner.shouldCollectWorkspaceDropTargets(draggedTabId: nil)) + } + + func testWorkspaceDropTargetCollectionTurnsOnDuringDrag() { + XCTAssertTrue(SidebarDropPlanner.shouldCollectWorkspaceDropTargets(draggedTabId: UUID())) + } + func testWorkspaceGroupHeaderDropZoneKeepsUsableCenterAtDefaultHeight() { XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 2, rowHeight: 24)) XCTAssertTrue(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 12, rowHeight: 24)) From 23bd1b069a2fe0099a78f5b2670b622446370d2c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 17:49:21 -0700 Subject: [PATCH 02/14] fix: gate sidebar workspace frame collection --- Sources/ContentView.swift | 155 +++++++++++------- ...debarBonsplitTabWorkspaceDropOverlay.swift | 33 +++- Sources/Sidebar/SidebarDropPlanner.swift | 8 +- .../VerticalTabsSidebar+WorkspaceGroups.swift | 19 ++- .../SidebarWorkspaceDropPlannerTests.swift | 7 + 5 files changed, 160 insertions(+), 62 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index c564af4c715..9418444267b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10447,6 +10447,9 @@ 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 var isBonsplitWorkspaceDropTargetCollectionActive = false // 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 +10869,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 +10888,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,16 +12209,21 @@ 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): @@ -12229,62 +12239,84 @@ struct VerticalTabsSidebar: View { } .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] + .overlay { + bonsplitWorkspaceDropOverlay(targets: []) + } + + if shouldCollectWorkspaceDropTargets { + rows + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + bonsplitWorkspaceDropOverlay( + 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( + targets: [SidebarDropPlanner.WorkspaceDropTarget] + ) -> 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 + }, + targets: targets + ) + } + + @ViewBuilder private func workspaceRow( _ tab: Workspace, renderContext: WorkspaceListRenderContext @@ -12347,6 +12379,10 @@ struct VerticalTabsSidebar: View { // Equatable conformance ignores closures, so rows whose snapshot is // unchanged skip re-render when drag state moves. let isBeingDragged = dragState.draggedTabId == tab.id + let shouldCollectWorkspaceDropTargets = SidebarDropPlanner.shouldCollectWorkspaceDropTargets( + draggedTabId: dragState.draggedTabId, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive + ) let sidebarReorderIds = renderContext.sidebarReorderIds let topDropIndicatorVisible = SidebarTabDropIndicatorPredicate.topVisible( forTabId: tab.id, @@ -12377,7 +12413,7 @@ struct VerticalTabsSidebar: View { ) } - return TabItemView( + let row = TabItemView( tabManager: tabManager, notificationStore: notificationStore, tab: tab, @@ -12416,10 +12452,17 @@ 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] + + if shouldCollectWorkspaceDropTargets { + row + .anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in + [tab.id: anchor] + } + .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) + } else { + row + .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) } - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) } private func debugShortSidebarTabId(_ id: UUID?) -> String { diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 3f7c76d7d09..267e99b2670 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -11,6 +11,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { @Binding var lastSidebarSelectionIndex: Int? @Binding var dropIndicator: SidebarDropIndicator? let updateAutoscroll: () -> Void + let setWorkspaceDropTargetCollectionActive: (Bool) -> Void let targets: [SidebarDropPlanner.WorkspaceDropTarget] func makeNSView(context: Context) -> SidebarBonsplitTabWorkspaceDropView { @@ -35,6 +36,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { } } nsView.updateAutoscroll = updateAutoscroll + nsView.setWorkspaceDropTargetCollectionActive = setWorkspaceDropTargetCollectionActive nsView.setDropIndicator = { indicator in dropIndicator = indicator } @@ -68,9 +70,11 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] 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 override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { false } @@ -89,14 +93,17 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateWorkspaceDropTargetCollection(sender, isActive: true) updateDrag(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + updateWorkspaceDropTargetCollection(sender, isActive: true) updateDrag(sender, phase: "updated") } override func draggingExited(_ sender: (any NSDraggingInfo)?) { + updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.exited clear=1") #endif @@ -116,7 +123,10 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - defer { setDropIndicator(nil) } + defer { + updateWorkspaceDropTargetCollection(sender, isActive: false) + setDropIndicator(nil) + } let action = action(for: sender) guard let action, let transfer = acceptedTransfer(sender, action: action) else { #if DEBUG @@ -146,6 +156,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { + updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.concluded clear=1") #endif @@ -154,6 +165,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 +202,17 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { return .move } + private func updateWorkspaceDropTargetCollection( + _ sender: (any NSDraggingInfo)?, + isActive: Bool + ) { + let shouldRequestTargets = isActive && BonsplitTabDragPayload.canRouteWorkspaceDrop( + pasteboardTypes: sender?.draggingPasteboard.types + ) + isRequestingWorkspaceDropTargets = shouldRequestTargets + setWorkspaceDropTargetCollectionActive(shouldRequestTargets) + } + private func acceptedTransfer( _ sender: any NSDraggingInfo, action: SidebarDropPlanner.WorkspaceDropAction? diff --git a/Sources/Sidebar/SidebarDropPlanner.swift b/Sources/Sidebar/SidebarDropPlanner.swift index 726e0a5adeb..66b4026507d 100644 --- a/Sources/Sidebar/SidebarDropPlanner.swift +++ b/Sources/Sidebar/SidebarDropPlanner.swift @@ -89,8 +89,12 @@ enum SidebarDropPlanner { let frame: CGRect } - static func shouldCollectWorkspaceDropTargets(draggedTabId: UUID?) -> Bool { - true + /// 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 { diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 6c5269170ae..43e96b15f46 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -40,6 +40,10 @@ extension VerticalTabsSidebar { dropIndicator: dragState.dropIndicator, tabIds: renderContext.sidebarReorderIds ) + let shouldCollectWorkspaceDropTargets = SidebarDropPlanner.shouldCollectWorkspaceDropTargets( + draggedTabId: dragState.draggedTabId, + isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive + ) let onDragStart: () -> NSItemProvider = { [anchorId = group.anchorWorkspaceId] in #if DEBUG cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") @@ -73,7 +77,7 @@ extension VerticalTabsSidebar { ) } - SidebarWorkspaceGroupHeaderView( + let header = SidebarWorkspaceGroupHeaderView( groupId: group.id, anchorWorkspaceId: group.anchorWorkspaceId, name: group.name, @@ -155,8 +159,17 @@ 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] + + if shouldCollectWorkspaceDropTargets { + header + .anchorPreference( + key: SidebarWorkspaceRowFramePreferenceKey.self, + value: .bounds + ) { [anchorId = group.anchorWorkspaceId] anchor in + [anchorId: anchor] + } + } else { + header } } } diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 4ba8289bb6f..6b7a5fd6983 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -16,6 +16,13 @@ final class SidebarWorkspaceDropPlannerTests: XCTestCase { 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)) From 692be9eb75baae6b5a48e6cc3833276fbef288aa Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 17:53:41 -0700 Subject: [PATCH 03/14] fix: avoid accepting pending sidebar drop targets --- Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 267e99b2670..c918b00754b 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -170,9 +170,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { BonsplitTabDragPayload.transfer(from: sender.draggingPasteboard) != nil { setDropIndicator(nil) #if DEBUG - dlog("sidebar.workspaceDropOverlay.\(phase) accepted=1 pendingTargets=1") + dlog("sidebar.workspaceDropOverlay.\(phase) accepted=0 pendingTargets=1") #endif - return .move + return [] } guard acceptedTransfer(sender, action: action) != nil, let action else { setDropIndicator(nil) From 9085835f84679e7f9c14805a219e7c838ae3c115 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 17:58:44 -0700 Subject: [PATCH 04/14] fix: keep bonsplit sidebar drop active while collecting targets --- Sources/ContentView.swift | 23 ++--- ...debarBonsplitTabWorkspaceDropOverlay.swift | 92 +++++++++++++++---- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9418444267b..25416fa09b1 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12239,16 +12239,13 @@ struct VerticalTabsSidebar: View { } .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) - .overlay { - bonsplitWorkspaceDropOverlay(targets: []) - } - if shouldCollectWorkspaceDropTargets { - rows - .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in - GeometryReader { proxy in - bonsplitWorkspaceDropOverlay( - targets: renderContext.tabs.compactMap { tab in + rows + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + bonsplitWorkspaceDropOverlay( + targets: shouldCollectWorkspaceDropTargets + ? renderContext.tabs.compactMap { tab in guard let anchor = anchors[tab.id] else { return nil } return SidebarDropPlanner.WorkspaceDropTarget( workspaceId: tab.id, @@ -12256,12 +12253,10 @@ struct VerticalTabsSidebar: View { frame: proxy[anchor] ) } - ) - } + : [] + ) } - } else { - rows - } + } } private func bonsplitWorkspaceDropOverlay( diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index c918b00754b..ac81498ae1f 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -52,6 +52,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { syncSidebarSelection(preferredSelectedTabId: destinationWorkspaceId) return true } + nsView.performPendingDropIfPossible() } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { @@ -67,6 +68,11 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { final class SidebarBonsplitTabWorkspaceDropView: NSView { private static let pasteboardType = NSPasteboard.PasteboardType(BonsplitTabDragPayload.typeIdentifier) + private struct PendingDrop { + let point: CGPoint + let transfer: BonsplitTabDragPayload.Transfer + } + var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] var canPerformAction: (SidebarDropPlanner.WorkspaceDropAction, BonsplitTabDragPayload.Transfer) -> Bool = { _, _ in false } var updateAutoscroll: () -> Void = {} @@ -75,6 +81,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { var performExistingWorkspaceMove: (UUID, BonsplitTabDragPayload.Transfer) -> Bool = { _, _ in false } var performNewWorkspaceMove: (Int, SidebarDropIndicator, BonsplitTabDragPayload.Transfer) -> Bool = { _, _, _ in false } private var isRequestingWorkspaceDropTargets = false + private var pendingDrop: PendingDrop? override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { false } @@ -103,6 +110,10 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func draggingExited(_ sender: (any NSDraggingInfo)?) { + guard pendingDrop == nil else { + setDropIndicator(nil) + return + } updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.exited clear=1") @@ -112,7 +123,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) " + @@ -123,39 +134,81 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - defer { + let action = action(for: sender) + if let action, let transfer = acceptedTransfer(sender, action: action) { + let moved = perform(action: action, transfer: transfer) updateWorkspaceDropTargetCollection(sender, isActive: false) setDropIndicator(nil) - } - let action = action(for: sender) - guard let action, let transfer = acceptedTransfer(sender, action: action) else { #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(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=\(moved ? 1 : 0) " + + "sidebar.workspaceDropOverlay.perform moved=0 reason=notAccepted " + "action=\(debugActionDescription(action))" ) #endif - return moved + return false + } + + func performPendingDropIfPossible() { + guard let pendingDrop, !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.performPending moved=\(moved ? 1 : 0) " + + "action=\(debugActionDescription(action))" + ) +#endif + } + + 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 { + setDropIndicator(nil) + return + } updateWorkspaceDropTargetCollection(sender, isActive: false) #if DEBUG dlog("sidebar.workspaceDropOverlay.concluded clear=1") @@ -170,9 +223,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { BonsplitTabDragPayload.transfer(from: sender.draggingPasteboard) != nil { setDropIndicator(nil) #if DEBUG - dlog("sidebar.workspaceDropOverlay.\(phase) accepted=0 pendingTargets=1") + dlog("sidebar.workspaceDropOverlay.\(phase) accepted=1 pendingTargets=1") #endif - return [] + return .move } guard acceptedTransfer(sender, action: action) != nil, let action else { setDropIndicator(nil) @@ -227,6 +280,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) } From df048f68804c1dcf89682eb153e4f615388d5490 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:01:47 -0700 Subject: [PATCH 05/14] fix: clear pending sidebar drops when collection resets --- Sources/ContentView.swift | 19 +++++++++++-------- ...debarBonsplitTabWorkspaceDropOverlay.swift | 12 ++++++++++++ .../VerticalTabsSidebar+WorkspaceGroups.swift | 7 ++----- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 25416fa09b1..5a90fea0ae3 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10449,7 +10449,7 @@ struct VerticalTabsSidebar: View { @State var dragState = SidebarDragState() // Bonsplit tab drags arrive through AppKit pasteboard callbacks, not // `SidebarDragState`, so they need a separate transient collection flag. - @State var isBonsplitWorkspaceDropTargetCollectionActive = false + @State private var isBonsplitWorkspaceDropTargetCollectionActive = false // 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 @@ -12230,10 +12230,15 @@ struct VerticalTabsSidebar: View { 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 + ) } } } @@ -12307,6 +12312,7 @@ struct VerticalTabsSidebar: View { guard isBonsplitWorkspaceDropTargetCollectionActive != isActive else { return } isBonsplitWorkspaceDropTargetCollectionActive = isActive }, + isWorkspaceDropTargetCollectionActive: isBonsplitWorkspaceDropTargetCollectionActive, targets: targets ) } @@ -12314,7 +12320,8 @@ struct VerticalTabsSidebar: View { @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) @@ -12374,10 +12381,6 @@ struct VerticalTabsSidebar: View { // Equatable conformance ignores closures, so rows whose snapshot is // unchanged skip re-render when drag state moves. let isBeingDragged = dragState.draggedTabId == tab.id - let shouldCollectWorkspaceDropTargets = SidebarDropPlanner.shouldCollectWorkspaceDropTargets( - draggedTabId: dragState.draggedTabId, - isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive - ) let sidebarReorderIds = renderContext.sidebarReorderIds let topDropIndicatorVisible = SidebarTabDropIndicatorPredicate.topVisible( forTabId: tab.id, diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index ac81498ae1f..3444d4bb57a 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -12,6 +12,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { @Binding var dropIndicator: SidebarDropIndicator? let updateAutoscroll: () -> Void let setWorkspaceDropTargetCollectionActive: (Bool) -> Void + let isWorkspaceDropTargetCollectionActive: Bool let targets: [SidebarDropPlanner.WorkspaceDropTarget] func makeNSView(context: Context) -> SidebarBonsplitTabWorkspaceDropView { @@ -52,6 +53,9 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { syncSidebarSelection(preferredSelectedTabId: destinationWorkspaceId) return true } + if !isWorkspaceDropTargetCollectionActive { + nsView.clearPendingDrop() + } nsView.performPendingDropIfPossible() } @@ -192,6 +196,11 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { #endif } + func clearPendingDrop() { + pendingDrop = nil + isRequestingWorkspaceDropTargets = false + } + private func perform( action: SidebarDropPlanner.WorkspaceDropAction, transfer: BonsplitTabDragPayload.Transfer @@ -262,6 +271,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { let shouldRequestTargets = isActive && BonsplitTabDragPayload.canRouteWorkspaceDrop( pasteboardTypes: sender?.draggingPasteboard.types ) + if !shouldRequestTargets { + pendingDrop = nil + } isRequestingWorkspaceDropTargets = shouldRequestTargets setWorkspaceDropTargetCollectionActive(shouldRequestTargets) } diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 43e96b15f46..c4fb24a0f3f 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 @@ -40,10 +41,6 @@ extension VerticalTabsSidebar { dropIndicator: dragState.dropIndicator, tabIds: renderContext.sidebarReorderIds ) - let shouldCollectWorkspaceDropTargets = SidebarDropPlanner.shouldCollectWorkspaceDropTargets( - draggedTabId: dragState.draggedTabId, - isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive - ) let onDragStart: () -> NSItemProvider = { [anchorId = group.anchorWorkspaceId] in #if DEBUG cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") From f2d301dfce4b2f30da03cfab134535d77098699b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:04:14 -0700 Subject: [PATCH 06/14] fix: preserve pending drop when targets arrive --- Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 3444d4bb57a..4cbc3c34880 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -53,7 +53,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { syncSidebarSelection(preferredSelectedTabId: destinationWorkspaceId) return true } - if !isWorkspaceDropTargetCollectionActive { + if !isWorkspaceDropTargetCollectionActive, targets.isEmpty { nsView.clearPendingDrop() } nsView.performPendingDropIfPossible() From 97c30998bbdab65873e52906fafa332b46b1266f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:08:24 -0700 Subject: [PATCH 07/14] fix: return sidebar drag operations --- Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 4cbc3c34880..949788d3a95 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -105,12 +105,12 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { updateWorkspaceDropTargetCollection(sender, isActive: true) - updateDrag(sender, phase: "entered") + return updateDrag(sender, phase: "entered") } override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { updateWorkspaceDropTargetCollection(sender, isActive: true) - updateDrag(sender, phase: "updated") + return updateDrag(sender, phase: "updated") } override func draggingExited(_ sender: (any NSDraggingInfo)?) { From d8cd51a5101c2027ca2e87a1426a3fef758bf94c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:10:33 -0700 Subject: [PATCH 08/14] fix: conditionally collect sidebar drop frames --- Sources/ContentView.swift | 25 ++++++++++++------- ...debarBonsplitTabWorkspaceDropOverlay.swift | 6 ++++- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 5a90fea0ae3..12a87f30401 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12245,12 +12245,12 @@ struct VerticalTabsSidebar: View { .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) - rows - .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in - GeometryReader { proxy in - bonsplitWorkspaceDropOverlay( - targets: shouldCollectWorkspaceDropTargets - ? renderContext.tabs.compactMap { tab in + if shouldCollectWorkspaceDropTargets { + rows + .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in + GeometryReader { proxy in + bonsplitWorkspaceDropOverlay( + targets: renderContext.tabs.compactMap { tab in guard let anchor = anchors[tab.id] else { return nil } return SidebarDropPlanner.WorkspaceDropTarget( workspaceId: tab.id, @@ -12258,10 +12258,17 @@ struct VerticalTabsSidebar: View { frame: proxy[anchor] ) } - : [] - ) + ) + } } - } + } else { + // Keep Bonsplit drag capture available without collecting per-row bounds anchors at rest. + rows + .overlay { + bonsplitWorkspaceDropOverlay(targets: []) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } } private func bonsplitWorkspaceDropOverlay( diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 949788d3a95..260be4c7bee 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -56,7 +56,11 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { if !isWorkspaceDropTargetCollectionActive, targets.isEmpty { nsView.clearPendingDrop() } - nsView.performPendingDropIfPossible() + if !targets.isEmpty { + DispatchQueue.main.async { [weak nsView] in + nsView?.performPendingDropIfPossible() + } + } } private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) { From 7cbf86e0d60ae115b3b64eca69eec7462bb7cba5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:13:02 -0700 Subject: [PATCH 09/14] fix: scope pending sidebar drops --- ...debarBonsplitTabWorkspaceDropOverlay.swift | 33 +++++++++++++++++-- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 260be4c7bee..751fd9b8713 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -54,7 +54,7 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { return true } if !isWorkspaceDropTargetCollectionActive, targets.isEmpty { - nsView.clearPendingDrop() + nsView.clearPendingDropIfIdle() } if !targets.isEmpty { DispatchQueue.main.async { [weak nsView] in @@ -77,6 +77,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { private static let pasteboardType = NSPasteboard.PasteboardType(BonsplitTabDragPayload.typeIdentifier) private struct PendingDrop { + let requestId: UInt64 let point: CGPoint let transfer: BonsplitTabDragPayload.Transfer } @@ -89,6 +90,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { 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? override var isFlipped: Bool { true } @@ -119,6 +121,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func draggingExited(_ sender: (any NSDraggingInfo)?) { guard pendingDrop == nil else { + if !isRequestingWorkspaceDropTargets { + clearPendingDrop() + } setDropIndicator(nil) return } @@ -145,6 +150,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { let action = action(for: sender) 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 @@ -157,7 +163,11 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } if let transfer = pendingTransfer(sender) { - pendingDrop = PendingDrop(point: localPoint(sender), transfer: transfer) + pendingDrop = PendingDrop( + requestId: workspaceDropTargetRequestId, + point: localPoint(sender), + transfer: transfer + ) #if DEBUG dlog("sidebar.workspaceDropOverlay.perform pendingTargets=1") #endif @@ -176,7 +186,12 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } func performPendingDropIfPossible() { - guard let pendingDrop, !targets.isEmpty else { return } + guard let pendingDrop, + pendingDrop.requestId == workspaceDropTargetRequestId, + isRequestingWorkspaceDropTargets, + !targets.isEmpty else { + return + } self.pendingDrop = nil defer { updateWorkspaceDropTargetCollection(nil, isActive: false) @@ -203,6 +218,12 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { func clearPendingDrop() { pendingDrop = nil isRequestingWorkspaceDropTargets = false + workspaceDropTargetRequestId &+= 1 + } + + func clearPendingDropIfIdle() { + guard !isRequestingWorkspaceDropTargets else { return } + clearPendingDrop() } private func perform( @@ -219,6 +240,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { guard pendingDrop == nil else { + if !isRequestingWorkspaceDropTargets { + clearPendingDrop() + } setDropIndicator(nil) return } @@ -278,6 +302,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { if !shouldRequestTargets { pendingDrop = nil } + if shouldRequestTargets, !isRequestingWorkspaceDropTargets { + workspaceDropTargetRequestId &+= 1 + } isRequestingWorkspaceDropTargets = shouldRequestTargets setWorkspaceDropTargetCollectionActive(shouldRequestTargets) } From ca41e5745f02e037f3490096e836b2dcf5074dba Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:19:18 -0700 Subject: [PATCH 10/14] fix: clear pending sidebar drop teardown --- ...debarBonsplitTabWorkspaceDropOverlay.swift | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 751fd9b8713..2d1d13deb2e 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -121,9 +121,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func draggingExited(_ sender: (any NSDraggingInfo)?) { guard pendingDrop == nil else { - if !isRequestingWorkspaceDropTargets { - clearPendingDrop() - } + completeOrClearPendingDropAfterDragTeardown() setDropIndicator(nil) return } @@ -240,9 +238,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) { guard pendingDrop == nil else { - if !isRequestingWorkspaceDropTargets { - clearPendingDrop() - } + completeOrClearPendingDropAfterDragTeardown() setDropIndicator(nil) return } @@ -292,6 +288,26 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { return .move } + private func completeOrClearPendingDropAfterDragTeardown() { + let requestId = workspaceDropTargetRequestId + DispatchQueue.main.async { [weak self] in + guard let self, + self.pendingDrop?.requestId == requestId else { + 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 From f554a6b62c26c757911bb9f3b01947cbea2cdca5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:24:12 -0700 Subject: [PATCH 11/14] fix: keep sidebar drop overlay stable --- Sources/ContentView.swift | 25 +++++----- ...debarBonsplitTabWorkspaceDropOverlay.swift | 49 +++++++++++++++++-- 2 files changed, 57 insertions(+), 17 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 12a87f30401..fa19f9caf53 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -10450,6 +10450,7 @@ struct VerticalTabsSidebar: View { // 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 @@ -12245,11 +12246,18 @@ struct VerticalTabsSidebar: View { .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) + let rowsWithDropOverlay = rows + .overlay { + bonsplitWorkspaceDropOverlay() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + if shouldCollectWorkspaceDropTargets { - rows + rowsWithDropOverlay .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in GeometryReader { proxy in - bonsplitWorkspaceDropOverlay( + SidebarBonsplitTabWorkspaceDropOverlay.TargetWriter( + targetBridge: bonsplitWorkspaceDropTargetBridge, targets: renderContext.tabs.compactMap { tab in guard let anchor = anchors[tab.id] else { return nil } return SidebarDropPlanner.WorkspaceDropTarget( @@ -12262,18 +12270,11 @@ struct VerticalTabsSidebar: View { } } } else { - // Keep Bonsplit drag capture available without collecting per-row bounds anchors at rest. - rows - .overlay { - bonsplitWorkspaceDropOverlay(targets: []) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + rowsWithDropOverlay } } - private func bonsplitWorkspaceDropOverlay( - targets: [SidebarDropPlanner.WorkspaceDropTarget] - ) -> some View { + private func bonsplitWorkspaceDropOverlay() -> some View { SidebarBonsplitTabWorkspaceDropOverlay( currentSelectedTabId: { tabManager.selectedTabId @@ -12320,7 +12321,7 @@ struct VerticalTabsSidebar: View { isBonsplitWorkspaceDropTargetCollectionActive = isActive }, isWorkspaceDropTargetCollectionActive: isBonsplitWorkspaceDropTargetCollectionActive, - targets: targets + targetBridge: bonsplitWorkspaceDropTargetBridge ) } diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index 2d1d13deb2e..ca5d7fc7634 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -3,6 +3,41 @@ import Bonsplit import SwiftUI struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { + 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 @@ -13,14 +48,15 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { let updateAutoscroll: () -> Void let setWorkspaceDropTargetCollectionActive: (Bool) -> Void let isWorkspaceDropTargetCollectionActive: Bool - let targets: [SidebarDropPlanner.WorkspaceDropTarget] + 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 @@ -53,10 +89,10 @@ struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { syncSidebarSelection(preferredSelectedTabId: destinationWorkspaceId) return true } - if !isWorkspaceDropTargetCollectionActive, targets.isEmpty { + if !isWorkspaceDropTargetCollectionActive, targetBridge.targets.isEmpty { nsView.clearPendingDropIfIdle() } - if !targets.isEmpty { + if !targetBridge.targets.isEmpty { DispatchQueue.main.async { [weak nsView] in nsView?.performPendingDropIfPossible() } @@ -82,7 +118,7 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { let transfer: BonsplitTabDragPayload.Transfer } - var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] + var targetBridge: SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge? var canPerformAction: (SidebarDropPlanner.WorkspaceDropAction, BonsplitTabDragPayload.Transfer) -> Bool = { _, _ in false } var updateAutoscroll: () -> Void = {} var setWorkspaceDropTargetCollectionActive: (Bool) -> Void = { _ in } @@ -92,6 +128,9 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { 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 } From 961f47c90ba52d7982ffa804794322635e2ea7f7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:36:39 -0700 Subject: [PATCH 12/14] fix: stabilize sidebar frame anchor modifier --- Sources/ContentView.swift | 35 +++++++++++++------ .../VerticalTabsSidebar+WorkspaceGroups.swift | 16 +++------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index fa19f9caf53..69d8e49a025 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12458,17 +12458,8 @@ struct VerticalTabsSidebar: View { .id(tab.id) .accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([tab.id])) - - if shouldCollectWorkspaceDropTargets { - row - .anchorPreference(key: SidebarWorkspaceRowFramePreferenceKey.self, value: .bounds) { anchor in - [tab.id: anchor] - } - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) - } else { - row - .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) - } + .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) + .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -12485,6 +12476,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/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index c4fb24a0f3f..f64c8959386 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -156,17 +156,9 @@ extension VerticalTabsSidebar { .id(group.anchorWorkspaceId) .accessibilityIdentifier("sidebarWorkspaceGroup.\(group.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([group.anchorWorkspaceId])) - - if shouldCollectWorkspaceDropTargets { - header - .anchorPreference( - key: SidebarWorkspaceRowFramePreferenceKey.self, - value: .bounds - ) { [anchorId = group.anchorWorkspaceId] anchor in - [anchorId: anchor] - } - } else { - header - } + .sidebarWorkspaceFrameAnchor( + id: group.anchorWorkspaceId, + isEnabled: shouldCollectWorkspaceDropTargets + ) } } From 7677d6ba5520bb1b007c6efd8b74581a8ae83a0f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 3 Jun 2026 18:44:08 -0700 Subject: [PATCH 13/14] fix: emit stable sidebar row views --- Sources/ContentView.swift | 6 ++++-- .../SidebarBonsplitTabWorkspaceDropOverlay.swift | 12 ++++++++++++ Sources/VerticalTabsSidebar+WorkspaceGroups.swift | 10 ++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 69d8e49a025..f447f5270ec 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12458,8 +12458,10 @@ struct VerticalTabsSidebar: View { .id(tab.id) .accessibilityIdentifier("sidebarWorkspace.\(tab.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([tab.id])) - .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) - .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 { diff --git a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift index ca5d7fc7634..1e7a5812822 100644 --- a/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift +++ b/Sources/Sidebar/SidebarBonsplitTabWorkspaceDropOverlay.swift @@ -3,6 +3,7 @@ import Bonsplit import SwiftUI struct SidebarBonsplitTabWorkspaceDropOverlay: NSViewRepresentable { + @MainActor final class TargetBridge { fileprivate weak var view: SidebarBonsplitTabWorkspaceDropView? fileprivate var targets: [SidebarDropPlanner.WorkspaceDropTarget] = [] @@ -328,6 +329,10 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { } private func completeOrClearPendingDropAfterDragTeardown() { + completeOrClearPendingDropAfterDragTeardown(remainingFrameWaits: 3) + } + + private func completeOrClearPendingDropAfterDragTeardown(remainingFrameWaits: Int) { let requestId = workspaceDropTargetRequestId DispatchQueue.main.async { [weak self] in guard let self, @@ -335,6 +340,13 @@ final class SidebarBonsplitTabWorkspaceDropView: NSView { return } + if self.targets.isEmpty, remainingFrameWaits > 0 { + self.completeOrClearPendingDropAfterDragTeardown( + remainingFrameWaits: remainingFrameWaits - 1 + ) + return + } + self.performPendingDropIfPossible() guard self.pendingDrop?.requestId == requestId else { return } diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index f64c8959386..60e3c351dac 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -156,9 +156,11 @@ extension VerticalTabsSidebar { .id(group.anchorWorkspaceId) .accessibilityIdentifier("sidebarWorkspaceGroup.\(group.id.uuidString)") .preference(key: SidebarWorkspaceRowIdsPreferenceKey.self, value: Set([group.anchorWorkspaceId])) - .sidebarWorkspaceFrameAnchor( - id: group.anchorWorkspaceId, - isEnabled: shouldCollectWorkspaceDropTargets - ) + + header + .sidebarWorkspaceFrameAnchor( + id: group.anchorWorkspaceId, + isEnabled: shouldCollectWorkspaceDropTargets + ) } } From beb318ef21ab760ecdc5846fa3b33c893026f0e4 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:41:32 -0700 Subject: [PATCH 14/14] fix(sidebar): keep Bonsplit drop overlay identity-stable across drop-target gate flips PR #5325 gates the row-frame anchor collection to active drag, but returns the drop-capture NSView from both branches of the if/else (it lives inside rowsWithDropOverlay). The two branches have distinct SwiftUI identity, so the gate flip on draggingEntered tears down and recreates SidebarBonsplitTabWorkspaceDropView mid-drag, orphaning the in-flight drag (the pendingDrop/requestId/frame-wait machinery exists to paper over this). Move the overlayPreferenceValue reader (the virtualization-defeating part) into a gated helper applied to the rows, and mount the drop-capture overlay at the stable outer level so its identity never changes when the gate flips. Virtualization is preserved (the reader stays conditional); only the consumer NSView becomes identity-stable. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ContentView.swift | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f447f5270ec..a6bfcce8b77 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12246,14 +12246,36 @@ struct VerticalTabsSidebar: View { .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) - let rowsWithDropOverlay = rows - .overlay { - bonsplitWorkspaceDropOverlay() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + // 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) + } + } - if shouldCollectWorkspaceDropTargets { - rowsWithDropOverlay + /// 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( @@ -12270,7 +12292,7 @@ struct VerticalTabsSidebar: View { } } } else { - rowsWithDropOverlay + rows } }