Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 146 additions & 60 deletions Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10447,6 +10447,10 @@ struct VerticalTabsSidebar: View {
)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
@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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// 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,
Comment thread
austinywang marked this conversation as resolved.
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: View>(
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)
Expand Down Expand Up @@ -12377,7 +12441,7 @@ struct VerticalTabsSidebar: View {
)
}

return TabItemView(
let row = TabItemView(
tabManager: tabManager,
notificationStore: notificationStore,
tab: tab,
Expand Down Expand Up @@ -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 {
Expand All @@ -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<CGRect>] = [:]

Expand Down
Loading
Loading