Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
aab92c3
Add regression test for stable group creation position
lawrencecchen May 29, 2026
c885981
Keep workspace group creation in place
lawrencecchen May 29, 2026
12d1fab
Merge remote-tracking branch 'origin/main' into task-group-position-s…
lawrencecchen May 29, 2026
2e5066e
Document stable group creation ordering
lawrencecchen May 29, 2026
8634f0d
Make workspace groups reorder as top-level rows
lawrencecchen May 29, 2026
738c22a
Keep group anchors stable on top moves
lawrencecchen May 29, 2026
c523bc5
Use modern group header size change hook
lawrencecchen May 29, 2026
f6b3c14
Address group reorder review feedback
lawrencecchen May 29, 2026
3280155
Preserve group slots when adding workspaces
lawrencecchen May 29, 2026
9bd97e0
Use top-level reorder for group header drops
lawrencecchen May 29, 2026
dca2cf1
Centralize group top-level drop invariants
lawrencecchen May 29, 2026
3d99581
Handle same-group header drops
lawrencecchen May 29, 2026
c09080f
Keep group reorder snapshots consistent
lawrencecchen May 29, 2026
77adab0
Fix group header indicator snapshot
lawrencecchen May 29, 2026
8547b53
Preserve no-op group header edge drops
lawrencecchen May 29, 2026
f974e30
Merge remote-tracking branch 'origin/main' into task-group-position-s…
lawrencecchen May 30, 2026
8291b0c
Keep group header drop indicators scoped
lawrencecchen May 30, 2026
212aecb
Fix group indicator test compile
lawrencecchen May 30, 2026
a999d58
Honor pinned group tiers when moving to top
lawrencecchen May 30, 2026
908284e
Fix top-level group drop no-ops
lawrencecchen May 30, 2026
bb45ef2
Keep group headers lazy in sidebar
lawrencecchen May 30, 2026
2131fad
Fix workspace group test imports
lawrencecchen May 30, 2026
cf0804e
Merge remote-tracking branch 'origin/main' into task-group-position-s…
lawrencecchen May 30, 2026
1c1771b
Keep same-group header edge drops in group
lawrencecchen May 30, 2026
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
10 changes: 0 additions & 10 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,6 @@
<string>public.data</string>
</array>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>com.cmux.sidebar-group-reorder</string>
<key>UTTypeDescription</key>
<string>cmux Sidebar Workspace Group Reorder</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
Expand Down
178 changes: 135 additions & 43 deletions Sources/ContentView.swift

Large diffs are not rendered by default.

24 changes: 0 additions & 24 deletions Sources/SidebarTabDragPayload+WorkspaceGroups.swift

This file was deleted.

43 changes: 0 additions & 43 deletions Sources/SidebarWorkspaceGroupDragPayload.swift

This file was deleted.

249 changes: 248 additions & 1 deletion Sources/SidebarWorkspaceGroupHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,34 @@ import AppKit
import SwiftUI

/// Collapsible group header that doubles as the anchor workspace row.
struct SidebarWorkspaceGroupHeaderView: View {
struct SidebarWorkspaceGroupHeaderView: View, Equatable {
// Closures and delegate factories are excluded because they are recreated
// by the parent on each evaluation. The scalar snapshots below are the
// header's render and behavior inputs under the LazyVStack.
nonisolated static func == (lhs: SidebarWorkspaceGroupHeaderView, rhs: SidebarWorkspaceGroupHeaderView) -> Bool {
lhs.groupId == rhs.groupId &&
lhs.anchorWorkspaceId == rhs.anchorWorkspaceId &&
lhs.name == rhs.name &&
lhs.iconSymbol == rhs.iconSymbol &&
lhs.tintHex == rhs.tintHex &&
lhs.isCollapsed == rhs.isCollapsed &&
lhs.isPinned == rhs.isPinned &&
lhs.isAnchorActive == rhs.isAnchorActive &&
lhs.memberCount == rhs.memberCount &&
lhs.anchorUnreadCount == rhs.anchorUnreadCount &&
lhs.shortcutDigit == rhs.shortcutDigit &&
lhs.shortcutModifierSymbol == rhs.shortcutModifierSymbol &&
lhs.showsShortcutHint == rhs.showsShortcutHint &&
lhs.shortcutHintXOffset == rhs.shortcutHintXOffset &&
lhs.shortcutHintYOffset == rhs.shortcutHintYOffset &&
lhs.cwdContextMenuItems == rhs.cwdContextMenuItems &&
lhs.newWorkspacePlacement == rhs.newWorkspacePlacement &&
lhs.rowSpacing == rhs.rowSpacing &&
lhs.isFirstRow == rhs.isFirstRow &&
lhs.isBeingDragged == rhs.isBeingDragged &&
lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible
}

let groupId: UUID
let anchorWorkspaceId: UUID
let name: String
Expand All @@ -19,6 +46,13 @@ struct SidebarWorkspaceGroupHeaderView: View {
let shortcutHintXOffset: Double
let shortcutHintYOffset: Double
let cwdContextMenuItems: [CmuxResolvedConfigContextMenuItem]
let newWorkspacePlacement: WorkspaceGroupNewPlacement?
let rowSpacing: CGFloat
let isFirstRow: Bool
let isBeingDragged: Bool
let topDropIndicatorVisible: Bool
let onDragStart: () -> NSItemProvider
let tabDropDelegateFactory: (CGFloat) -> SidebarWorkspaceGroupHeaderDropDelegate
let onToggleCollapsed: () -> Void
let onFocusAnchor: () -> Void
let onTapPlus: () -> Void
Expand All @@ -31,6 +65,7 @@ struct SidebarWorkspaceGroupHeaderView: View {
let onOpenDocs: () -> Void

@State private var isHovered = false
@State private var rowHeight: CGFloat = 1

private var iconColor: Color {
if let tintHex, let nsColor = NSColor(hex: tintHex) {
Expand All @@ -46,6 +81,18 @@ struct SidebarWorkspaceGroupHeaderView: View {
return "\(shortcutModifierSymbol)\(shortcutDigit)"
}

private var rowHeightProbe: some View {
GeometryReader { proxy in
Color.clear
.onAppear {
rowHeight = max(proxy.size.height, 1)
}
.onChange(of: proxy.size.height) { _, newHeight in
rowHeight = max(newHeight, 1)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

var body: some View {
HStack(spacing: 4) {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
Expand Down Expand Up @@ -165,7 +212,19 @@ struct SidebarWorkspaceGroupHeaderView: View {
offsetY: shortcutHintYOffset
)
.padding(.horizontal, 6)
.background { rowHeightProbe }
.shortcutHintVisibilityAnimation(value: showsShortcutHint)
.opacity(isBeingDragged ? 0.6 : 1)
.overlay(alignment: .top) {
SidebarWorkspaceTopDropIndicator(
isVisible: topDropIndicatorVisible,
isFirstRow: isFirstRow,
rowSpacing: rowSpacing
)
}
.onDrag(onDragStart)
.internalOnlyTabDrag()
.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight))
.onHover { hovering in
isHovered = hovering
}
Expand Down Expand Up @@ -226,3 +285,191 @@ struct SidebarWorkspaceGroupHeaderView: View {
}
}
}

enum SidebarWorkspaceGroupHeaderDropZone {
static func isCenterDrop(locationY: CGFloat, rowHeight: CGFloat) -> Bool {
let height = max(rowHeight, 1)
let edgeBand = min(max(height * 0.25, 4), height * 0.4)
let y = min(max(locationY, 0), height)
return y > edgeBand && y < height - edgeBand
}
}

enum SidebarWorkspaceGroupHeaderDropAction: Equatable {
case addWorkspaceToGroup(UUID)
case noOp
}

enum SidebarWorkspaceGroupHeaderDropPolicy {
static func action(
hasSidebarPayload: Bool,
draggedWorkspaceId: UUID?,
draggedWorkspaceIsPinned: Bool,
draggedWorkspaceGroupId: UUID?,
draggedWorkspaceIsGroupAnchor: Bool,
targetGroupId: UUID,
targetAnchorWorkspaceId: UUID,
targetAnchorMatchesGroup: Bool,
locationY: CGFloat,
rowHeight: CGFloat
) -> SidebarWorkspaceGroupHeaderDropAction? {
guard hasSidebarPayload,
let draggedWorkspaceId,
targetAnchorMatchesGroup,
SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(
locationY: locationY,
rowHeight: rowHeight
) else {
return nil
}
if draggedWorkspaceId == targetAnchorWorkspaceId || draggedWorkspaceGroupId == targetGroupId {
return .noOp
}
guard !draggedWorkspaceIsPinned,
!draggedWorkspaceIsGroupAnchor else {
return nil
}
return .addWorkspaceToGroup(draggedWorkspaceId)
}

static func shouldConsumeNoOpEdgeDrop(
hasSidebarPayload: Bool,
draggedWorkspaceId: UUID?,
draggedWorkspaceGroupId: UUID?,
targetGroupId: UUID,
targetAnchorWorkspaceId: UUID,
tabIds: [UUID],
pinnedTabIds: Set<UUID>,
locationY: CGFloat,
rowHeight: CGFloat
) -> Bool {
guard hasSidebarPayload,
let draggedWorkspaceId,
tabIds.count > 1,
tabIds.contains(draggedWorkspaceId),
tabIds.contains(targetAnchorWorkspaceId),
!SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(
locationY: locationY,
rowHeight: rowHeight
) else {
return false
}
if draggedWorkspaceId == targetAnchorWorkspaceId || draggedWorkspaceGroupId == targetGroupId {
return true
}
return SidebarDropPlanner.indicator(
draggedTabId: draggedWorkspaceId,
targetTabId: targetAnchorWorkspaceId,
tabIds: tabIds,
pinnedTabIds: pinnedTabIds,
pointerY: locationY,
targetHeight: rowHeight
) == nil
}
}

@MainActor
struct SidebarWorkspaceGroupHeaderDropDelegate: DropDelegate {
let targetGroupId: UUID
let targetAnchorWorkspaceId: UUID
let tabManager: TabManager
let dragState: SidebarDragState
let targetRowHeight: CGFloat?
let dragAutoScrollController: SidebarDragAutoScrollController
let reorderDelegate: SidebarTabDropDelegate

func validateDrop(info: DropInfo) -> Bool {
reorderDelegate.validateDrop(info: info) || groupHeaderCenterDropAction(info) != nil
}

func dropEntered(info: DropInfo) {
if updateGroupHeaderCenterDrop(info) { return }
reorderDelegate.dropEntered(info: info)
}

func dropExited(info: DropInfo) {
reorderDelegate.dropExited(info: info)
}

func dropUpdated(info: DropInfo) -> DropProposal? {
if updateGroupHeaderCenterDrop(info) {
return DropProposal(operation: .move)
}
return reorderDelegate.dropUpdated(info: info)
}

func performDrop(info: DropInfo) -> Bool {
guard let action = groupHeaderCenterDropAction(info) else {
if shouldConsumeGroupHeaderNoOpEdgeDrop(info) {
clearDropState()
return true
}
return reorderDelegate.performDrop(info: info)
}
defer { clearDropState() }
switch action {
case .addWorkspaceToGroup(let draggedTabId):
tabManager.addWorkspaceToGroup(workspaceId: draggedTabId, groupId: targetGroupId)
case .noOp:
break
}
return true
}

private func updateGroupHeaderCenterDrop(_ info: DropInfo) -> Bool {
guard groupHeaderCenterDropAction(info) != nil else { return false }
dragAutoScrollController.updateFromDragLocation()
dragState.clearDropIndicator()
return true
}

private func groupHeaderCenterDropAction(_ info: DropInfo) -> SidebarWorkspaceGroupHeaderDropAction? {
guard let draggedTabId = dragState.draggedTabId,
let draggedTab = tabManager.tabs.first(where: { $0.id == draggedTabId }),
let group = tabManager.workspaceGroups.first(where: { $0.id == targetGroupId }) else {
return nil
}
return SidebarWorkspaceGroupHeaderDropPolicy.action(
hasSidebarPayload: info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]),
draggedWorkspaceId: draggedTabId,
draggedWorkspaceIsPinned: draggedTab.isPinned,
draggedWorkspaceGroupId: draggedTab.groupId,
draggedWorkspaceIsGroupAnchor: tabManager.workspaceGroups.contains {
$0.anchorWorkspaceId == draggedTabId
},
targetGroupId: targetGroupId,
targetAnchorWorkspaceId: targetAnchorWorkspaceId,
targetAnchorMatchesGroup: group.anchorWorkspaceId == targetAnchorWorkspaceId,
locationY: info.location.y,
rowHeight: targetRowHeight ?? 1
)
}

private func shouldConsumeGroupHeaderNoOpEdgeDrop(_ info: DropInfo) -> Bool {
let height = targetRowHeight ?? 1
guard let draggedTabId = dragState.draggedTabId,
let draggedTab = tabManager.tabs.first(where: { $0.id == draggedTabId }) else { return false }
return SidebarWorkspaceGroupHeaderDropPolicy.shouldConsumeNoOpEdgeDrop(
hasSidebarPayload: info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]),
draggedWorkspaceId: draggedTabId,
draggedWorkspaceGroupId: draggedTab.groupId,
targetGroupId: targetGroupId,
targetAnchorWorkspaceId: targetAnchorWorkspaceId,
tabIds: tabManager.sidebarReorderWorkspaceIds(
forDraggedWorkspaceId: draggedTabId,
targetWorkspaceId: targetAnchorWorkspaceId
),
pinnedTabIds: tabManager.sidebarReorderPinnedWorkspaceIds(
forDraggedWorkspaceId: draggedTabId,
targetWorkspaceId: targetAnchorWorkspaceId
),
locationY: info.location.y,
rowHeight: height
)
}

private func clearDropState() {
dragState.clearDrag()
dragAutoScrollController.stop()
}
}
Loading
Loading