diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f35278b167de..3d64fe8ae026 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -3,7 +3,7 @@ # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34590 CLI/cmux.swift 17841 Sources/AppDelegate.swift -16499 Sources/ContentView.swift +16132 Sources/ContentView.swift 13832 Sources/TerminalController.swift 12828 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift @@ -17,7 +17,7 @@ 6901 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 6317 cmuxTests/SessionPersistenceTests.swift 6217 cmuxTests/GhosttyConfigTests.swift -6187 Sources/TabManager.swift +6183 Sources/TabManager.swift 6084 Sources/TextBoxInput.swift 5915 cmuxTests/TerminalAndGhosttyTests.swift 5573 cmuxTests/BrowserConfigTests.swift @@ -85,7 +85,6 @@ 1093 Sources/RemoteTmuxController.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift 1087 Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift -1030 cmuxTests/WorkspaceGroupTests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1009 cmuxTests/CmuxTopSnapshotScopeTests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift @@ -98,9 +97,9 @@ 928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 926 Sources/DockPanelView.swift 920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift +918 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift -885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift 885 Sources/Panels/TerminalPanel.swift 881 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift @@ -133,7 +132,6 @@ 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -709 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift 706 CLI/CMUXCLI+Config.swift 705 Sources/Panels/BrowserPopupWindowController.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift @@ -169,7 +167,6 @@ 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 606 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift -602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift 596 cmuxTests/CmuxEventBusTests.swift @@ -185,7 +182,6 @@ 580 cmuxTests/CLIHookNoResponseTests.swift 578 Sources/RightSidebarPanelView.swift 577 cmuxTests/AppearanceSettingsTests.swift -574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift @@ -198,6 +194,7 @@ 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift +540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift 539 CLI/CodexTeamsApprovalBridge.swift 538 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/PTYBridge/RemotePTYBridgeSession.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift index 3d524ac8a127..59d215bcfc4a 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropIndicator.swift @@ -1,7 +1,7 @@ public import Foundation /// Which edge of a sidebar row a drop indicator is drawn against. -public enum SidebarDropEdge: Equatable, Sendable { +public enum SidebarDropEdge: Equatable { case top case bottom } @@ -9,7 +9,7 @@ public enum SidebarDropEdge: Equatable, Sendable { /// Where the sidebar should render the drop indicator during a tab/workspace /// drag: against the `top` or `bottom` edge of the row identified by `tabId`, /// or at the end of the list when `tabId` is `nil`. -public struct SidebarDropIndicator: Equatable, Sendable { +public struct SidebarDropIndicator: Equatable { public let tabId: UUID? public let edge: SidebarDropEdge diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift index 2f7bcca9cb21..66f1f4aac7d2 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarDropPlanner.swift @@ -15,38 +15,12 @@ public struct SidebarDropPlanner { pinnedTabIds: Set, legalInsertionRange: ClosedRange? = nil, pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil, - preserveTargetEdge: Bool = false - ) -> SidebarDropIndicator? { - indicator( - draggedTabId: draggedTabId, - targetTabId: targetTabId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - legalInsertionRange: legalInsertionRange, - pointerY: pointerY, - targetHeight: targetHeight, - preserveTargetEdge: preserveTargetEdge, - suppressesNoOp: true - ) - } - - func indicator( - draggedTabId: UUID?, - targetTabId: UUID?, - tabIds: [UUID], - pinnedTabIds: Set, - legalInsertionRange: ClosedRange? = nil, - pointerY: CGFloat? = nil, - targetHeight: CGFloat? = nil, - preserveTargetEdge: Bool = false, - suppressesNoOp: Bool + targetHeight: CGFloat? = nil ) -> SidebarDropIndicator? { guard tabIds.count > 1, let draggedTabId else { return nil } guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil } let insertionPosition: Int - let proposedIndicator: SidebarDropIndicator? if let targetTabId { guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil } let edge: SidebarDropEdge @@ -56,10 +30,8 @@ public struct SidebarDropPlanner { edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds) } insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex - proposedIndicator = SidebarDropIndicator(tabId: targetTabId, edge: edge) } else { insertionPosition = tabIds.count - proposedIndicator = SidebarDropIndicator(tabId: nil, edge: .bottom) } let legalInsertionPosition = legalInsertionPosition( @@ -69,17 +41,12 @@ public struct SidebarDropPlanner { pinnedTabIds: pinnedTabIds, legalInsertionRange: legalInsertionRange ) - if suppressesNoOp { - let legalTargetIndex = resolvedTargetIndex( - from: fromIndex, - insertionPosition: legalInsertionPosition, - totalCount: tabIds.count - ) - guard legalTargetIndex != fromIndex else { return nil } - } - if preserveTargetEdge, legalInsertionPosition == insertionPosition { - return proposedIndicator - } + let legalTargetIndex = resolvedTargetIndex( + from: fromIndex, + insertionPosition: legalInsertionPosition, + totalCount: tabIds.count + ) + guard legalTargetIndex != fromIndex else { return nil } return indicatorForInsertionPosition(legalInsertionPosition, tabIds: tabIds) } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift index 457fc88a9394..b80e3ea3be1e 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarTabDropIndicatorPredicate.swift @@ -1,100 +1,43 @@ public import Foundation /// Pure predicates deciding when a sidebar row (or the empty area below all -/// rows) should render its drop indicator for a given drag state. +/// rows) should render its "top" drop indicator for a given drag state. public struct SidebarTabDropIndicatorPredicate { - /// Creates a sidebar drop-indicator predicate evaluator. public init() {} - /// Returns whether the canonical gap indicator should render above a row. - /// - /// A resolved `.bottom` edge on the previous visible row represents the - /// same visual divider as a `.top` edge on this row. The sidebar renders - /// that divider only here, above the row after the gap, so adjacent rows - /// cannot both draw competing drop lines. - /// - /// - Parameters: - /// - tabId: The row currently being rendered. - /// - draggedTabId: The workspace currently being dragged, if any. - /// - dropIndicator: The resolved sidebar drop indicator. - /// - tabIds: Visible workspace row identifiers in display order. - /// - Returns: `true` when the resolved indicator targets the divider above this row. public func topVisible( forTabId tabId: UUID, draggedTabId: UUID?, dropIndicator: SidebarDropIndicator?, tabIds: [UUID] ) -> Bool { - guard draggedTabId != nil, - let indicator = dropIndicator, - let rowIndex = tabIds.firstIndex(of: tabId) else { return false } - - switch indicator.edge { - case .top: - return indicator.tabId == tabId - case .bottom: - guard let indicatorTabId = indicator.tabId, - let indicatorIndex = tabIds.firstIndex(of: indicatorTabId) else { - return false - } - return indicatorIndex + 1 == rowIndex + guard draggedTabId != nil, let indicator = dropIndicator else { return false } + if indicator.tabId == tabId && indicator.edge == .top { + return true } - } - - /// Returns whether the bottom-edge indicator should render below a row. - /// - /// Row-to-row dividers are canonicalized to `topVisible` for the row after - /// the gap. Root-level final append dividers are rendered by - /// `emptyAreaTopVisible`; scoped group append dividers can be rendered below - /// the last visible row in that group. - /// - /// - Parameters: - /// - tabId: The row currently being rendered. - /// - draggedTabId: The workspace currently being dragged, if any. - /// - dropIndicator: The resolved sidebar drop indicator. - /// - tabIds: Visible workspace row identifiers in display order. - /// - indicatorScope: The visible row scope where the resolved indicator is rendered. - /// - Returns: `true` when a scoped final append divider should render below this row. - public func bottomVisible( - forTabId tabId: UUID, - draggedTabId: UUID?, - dropIndicator: SidebarDropIndicator?, - tabIds: [UUID], - indicatorScope: SidebarWorkspaceReorderDropIndicatorScope = .raw - ) -> Bool { - guard draggedTabId != nil, - indicatorScope.isGroup, - let indicator = dropIndicator, - indicator.edge == .bottom, - indicator.tabId == tabId, - tabIds.last == tabId else { + guard indicator.edge == .bottom, + let currentIndex = tabIds.firstIndex(of: tabId), + currentIndex > 0 + else { return false } - return true + return tabIds[currentIndex - 1] == indicator.tabId } /// Convenience used by `SidebarEmptyArea`: the empty area's "top" indicator /// (drawn above the empty space below all rows) is visible when the drop - /// indicator targets nothing (end-of-list), or when a `.bottom` indicator - /// targets the last visible row in the current drag scope. - /// - /// - Parameters: - /// - draggedTabId: The workspace currently being dragged, if any. - /// - dropIndicator: The resolved sidebar drop indicator. - /// - lastTabId: The last visible row in the current root/top-level scope. - /// - indicatorScope: The visible row scope where the resolved indicator is rendered. - /// - Returns: `true` when the root/top-level empty area should render its divider. + /// indicator targets nothing (end-of-list) or the bottom edge of the last + /// row. public func emptyAreaTopVisible( draggedTabId: UUID?, dropIndicator: SidebarDropIndicator?, - lastTabId: UUID?, - indicatorScope: SidebarWorkspaceReorderDropIndicatorScope = .raw + lastTabId: UUID? ) -> Bool { - guard !indicatorScope.isGroup else { return false } guard draggedTabId != nil, let indicator = dropIndicator else { return false } if indicator.tabId == nil { return true } - return indicator.edge == .bottom && indicator.tabId == lastTabId + guard indicator.edge == .bottom, let lastTabId else { return false } + return indicator.tabId == lastTabId } } diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift deleted file mode 100644 index 7d2e71f2b054..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropAction.swift +++ /dev/null @@ -1,15 +0,0 @@ -public import Foundation - -/// The commit operation produced by a resolved sidebar workspace drop plan. -public enum SidebarWorkspaceReorderDropAction: Equatable, Sendable { - /// Reorder a workspace already present in the destination sidebar. - case reorder(targetIndex: Int, usesTopLevelRows: Bool, explicitGroupId: UUID?) - - /// Insert a workspace dragged from another window at the destination index. - /// - /// `insertionIndex` is clamped for the dragged workspace's pin state and - /// drives the rendered single-workspace plan. `proposedInsertionIndex` - /// preserves the raw pointer slot so multi-selection commits can clamp each - /// pin tier independently. - case crossWindow(insertionIndex: Int, proposedInsertionIndex: Int) -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropIndicatorScope.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropIndicatorScope.swift deleted file mode 100644 index 5251839adff7..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropIndicatorScope.swift +++ /dev/null @@ -1,21 +0,0 @@ -public import Foundation - -/// The visible row space where a resolved workspace drop indicator should be -/// drawn. -public enum SidebarWorkspaceReorderDropIndicatorScope: Equatable, Sendable { - /// Draw against the full raw workspace row order. - case raw - - /// Draw against top-level rows, with expanded groups represented by their - /// anchors. - case topLevel - - /// Draw against the rows belonging to one workspace group. - case group(UUID) - - /// Whether this scope renders against one workspace group's visible rows. - public var isGroup: Bool { - guard case .group = self else { return false } - return true - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropPlan.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropPlan.swift deleted file mode 100644 index 4cdb918d02f0..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropPlan.swift +++ /dev/null @@ -1,35 +0,0 @@ -public import Foundation - -/// A resolved sidebar workspace drop, including both visual and commit intent. -public struct SidebarWorkspaceReorderDropPlan: Equatable, Sendable { - /// The workspace being dragged. - public let draggedWorkspaceId: UUID - - /// The indicator the UI should render for this exact drop intent. - public let indicator: SidebarDropIndicator? - - /// The visible row scope where ``indicator`` should be rendered. - public let indicatorScope: SidebarWorkspaceReorderDropIndicatorScope - - /// The commit operation to perform if the drag is dropped at this point. - public let action: SidebarWorkspaceReorderDropAction - - /// Creates a resolved workspace drop plan. - /// - /// - Parameters: - /// - draggedWorkspaceId: The workspace being dragged. - /// - indicator: The indicator the UI should render for this exact drop intent. - /// - indicatorScope: The visible row scope where `indicator` should be rendered. - /// - action: The commit operation to perform if the drag is dropped at this point. - public init( - draggedWorkspaceId: UUID, - indicator: SidebarDropIndicator?, - indicatorScope: SidebarWorkspaceReorderDropIndicatorScope = .raw, - action: SidebarWorkspaceReorderDropAction - ) { - self.draggedWorkspaceId = draggedWorkspaceId - self.indicator = indicator - self.indicatorScope = indicatorScope - self.action = action - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropRequest.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropRequest.swift deleted file mode 100644 index 1a3e23560e9d..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropRequest.swift +++ /dev/null @@ -1,48 +0,0 @@ -public import CoreGraphics -public import Foundation - -/// Input snapshot for resolving a sidebar workspace reorder drop. -public struct SidebarWorkspaceReorderDropRequest: Equatable, Sendable { - /// Pointer location in the drop overlay's coordinate space. - public let point: CGPoint - - /// The workspace being dragged. - public let draggedWorkspaceId: UUID - - /// Pin state for a workspace dragged from another window. - public let foreignDraggedIsPinned: Bool? - - /// Workspaces in the destination sidebar's raw storage order. - public let workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot] - - /// Workspace groups in the destination sidebar. - public let groups: [SidebarWorkspaceReorderGroupSnapshot] - - /// Visible row targets in the drop overlay's coordinate space. - public let targets: [SidebarWorkspaceReorderDropTarget] - - /// Creates input for the sidebar workspace reorder resolver. - /// - /// - Parameters: - /// - point: Pointer location in the drop overlay's coordinate space. - /// - draggedWorkspaceId: The workspace being dragged. - /// - foreignDraggedIsPinned: Pin state for a workspace dragged from another window. - /// - workspaces: Workspaces in the destination sidebar's raw storage order. - /// - groups: Workspace groups in the destination sidebar. - /// - targets: Visible row targets in the drop overlay's coordinate space. - public init( - point: CGPoint, - draggedWorkspaceId: UUID, - foreignDraggedIsPinned: Bool? = nil, - workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], - groups: [SidebarWorkspaceReorderGroupSnapshot], - targets: [SidebarWorkspaceReorderDropTarget] - ) { - self.point = point - self.draggedWorkspaceId = draggedWorkspaceId - self.foreignDraggedIsPinned = foreignDraggedIsPinned - self.workspaces = workspaces - self.groups = groups - self.targets = targets - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift deleted file mode 100644 index 9cad2626273b..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift +++ /dev/null @@ -1,709 +0,0 @@ -import CoreGraphics -import Foundation - -/// Resolves sidebar workspace drag/drop hit testing into one visual and commit plan. -public struct SidebarWorkspaceReorderDropResolver: Sendable { - /// Creates a sidebar workspace reorder resolver. - public init() {} - - /// Resolves the request into the drop plan the UI should render and commit. - /// - /// - Parameter request: The immutable drop input snapshot. - /// - Returns: A plan when the pointer can produce a meaningful drop. - public func plan( - for request: SidebarWorkspaceReorderDropRequest - ) -> SidebarWorkspaceReorderDropPlan? { - let sortedTargets = request.targets.sorted { lhs, rhs in - if lhs.frame.minY == rhs.frame.minY { - return lhs.frame.minX < rhs.frame.minX - } - return lhs.frame.minY < rhs.frame.minY - } - guard !sortedTargets.isEmpty else { return nil } - - let groupsById = Dictionary(uniqueKeysWithValues: request.groups.map { ($0.id, $0) }) - let groupByAnchorId = Dictionary(uniqueKeysWithValues: request.groups.map { ($0.anchorWorkspaceId, $0) }) - let workspacesById = Dictionary(uniqueKeysWithValues: request.workspaces.map { ($0.id, $0) }) - let groupLayoutsById = groupLayouts( - sortedTargets: sortedTargets, - groupsById: groupsById - ) - let context = hitContext(point: request.point, sortedTargets: sortedTargets) - - guard let draggedWorkspace = workspacesById[request.draggedWorkspaceId] else { - return crossWindowPlan( - request: request, - context: context, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - groupLayoutsById: groupLayoutsById - ) - } - - if let groupId = explicitGroupId( - request: request, - context: context, - draggedWorkspace: draggedWorkspace, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId - ) { - return groupScopedPlan( - request: request, - context: context, - draggedWorkspace: draggedWorkspace, - explicitGroupId: groupId, - groupsById: groupsById - ) - } - - return rootScopedPlan( - request: request, - context: context, - draggedWorkspace: draggedWorkspace, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - groupLayoutsById: groupLayoutsById - ) - } - - private func hitContext( - point: CGPoint, - sortedTargets: [SidebarWorkspaceReorderDropTarget] - ) -> SidebarWorkspaceReorderHitContext { - for (index, target) in sortedTargets.enumerated() where target.frame.verticallyContains(point.y) { - let height = max(target.frame.height, 1) - let localY = point.y - target.frame.minY - return SidebarWorkspaceReorderHitContext( - target: target, - previousTarget: index > 0 ? sortedTargets[index - 1] : nil, - nextTarget: index + 1 < sortedTargets.count ? sortedTargets[index + 1] : nil, - edge: SidebarDropPlanner().edgeForPointer(locationY: localY, targetHeight: height), - pointerY: localY, - targetHeight: height - ) - } - - guard let nextIndex = sortedTargets.firstIndex(where: { point.y < $0.frame.minY }) else { - return SidebarWorkspaceReorderHitContext( - target: nil, - previousTarget: sortedTargets.last, - nextTarget: nil, - edge: .bottom, - pointerY: nil, - targetHeight: nil - ) - } - if nextIndex > 0 { - let previousIndex = nextIndex - 1 - let previousTarget = sortedTargets[previousIndex] - let nextTarget = sortedTargets[nextIndex] - let previousDistance = max(0, point.y - previousTarget.frame.maxY) - let nextDistance = max(0, nextTarget.frame.minY - point.y) - if previousDistance < nextDistance { - let previousHeight = max(previousTarget.frame.height, 1) - return SidebarWorkspaceReorderHitContext( - target: previousTarget, - previousTarget: previousIndex > 0 ? sortedTargets[previousIndex - 1] : nil, - nextTarget: nextTarget, - edge: .bottom, - pointerY: previousHeight, - targetHeight: previousHeight - ) - } - } - return SidebarWorkspaceReorderHitContext( - target: sortedTargets[nextIndex], - previousTarget: nextIndex > 0 ? sortedTargets[nextIndex - 1] : nil, - nextTarget: nextIndex + 1 < sortedTargets.count ? sortedTargets[nextIndex + 1] : nil, - edge: .top, - pointerY: 0, - targetHeight: max(sortedTargets[nextIndex].frame.height, 1) - ) - } - - private func explicitGroupId( - request: SidebarWorkspaceReorderDropRequest, - context: SidebarWorkspaceReorderHitContext, - draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> UUID? { - guard !groupByAnchorId.keys.contains(draggedWorkspace.id) else { return nil } - guard let candidate = groupScopeCandidate( - context: context, - groupsById: groupsById - ) else { - return nil - } - guard candidate.isAmbiguous else { return candidate.groupId } - // Ambiguous group/root dividers use a coarse hierarchy lane: the left - // half of the sidebar means root, the right half means the group. - return request.point.x >= sidebarHorizontalMidpoint(targets: request.targets) - ? candidate.groupId - : nil - } - - private func sidebarHorizontalMidpoint( - targets: [SidebarWorkspaceReorderDropTarget] - ) -> CGFloat { - let bounds = targets.reduce(CGRect.null) { partial, target in - partial.union(target.frame) - } - guard !bounds.isNull, bounds.width > 0 else { return 0 } - return bounds.minX + (bounds.width / 2) - } - - private func groupScopeCandidate( - context: SidebarWorkspaceReorderHitContext, - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> (groupId: UUID, isAmbiguous: Bool)? { - if let target = context.target { - if let groupId = target.groupId, - groupsById[groupId] != nil { - switch context.edge { - case .top: - guard !target.isGroupHeader else { return nil } - return (groupId, false) - case .bottom: - let nextIsSameGroup = context.nextTarget?.groupId == groupId - return (groupId, !nextIsSameGroup) - } - } - - if context.edge == .top, - target.groupId == nil, - let previousGroupId = context.previousTarget?.groupId, - groupsById[previousGroupId] != nil { - return (previousGroupId, true) - } - - return nil - } - - guard let previousGroupId = context.previousTarget?.groupId, - groupsById[previousGroupId] != nil else { - return nil - } - return (previousGroupId, true) - } - - private func groupScopedPlan( - request: SidebarWorkspaceReorderDropRequest, - context: SidebarWorkspaceReorderHitContext, - draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, - explicitGroupId: UUID, - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> SidebarWorkspaceReorderDropPlan? { - guard let group = groupsById[explicitGroupId] else { return nil } - let targetIndicator = groupScopedIndicator( - context: context, - fallbackAnchorWorkspaceId: group.anchorWorkspaceId - ) - guard let targetWorkspaceId = targetIndicator.tabId else { return nil } - let tabIds = request.workspaces.map(\.id) - let pinnedTabIds = Set(request.workspaces.filter { $0.groupId == nil && $0.isPinned }.map(\.id)) - let legalRange = legalInsertionRange( - draggedWorkspace: draggedWorkspace, - explicitGroupId: explicitGroupId, - workspaces: request.workspaces, - groupsById: groupsById - ) - guard let targetIndex = SidebarDropPlanner().targetIndex( - draggedTabId: request.draggedWorkspaceId, - targetTabId: targetWorkspaceId, - indicator: targetIndicator, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - legalInsertionRange: legalRange - ) else { - return nil - } - - let renderedIndicator = SidebarDropPlanner().indicator( - draggedTabId: request.draggedWorkspaceId, - targetTabId: targetWorkspaceId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - legalInsertionRange: legalRange, - pointerY: pointerY(for: targetIndicator.edge, targetHeight: context.targetHeight), - targetHeight: context.targetHeight, - preserveTargetEdge: true - ) ?? targetIndicator - - return SidebarWorkspaceReorderDropPlan( - draggedWorkspaceId: request.draggedWorkspaceId, - indicator: renderedIndicator, - indicatorScope: .group(explicitGroupId), - action: .reorder( - targetIndex: targetIndex, - usesTopLevelRows: false, - explicitGroupId: explicitGroupId - ) - ) - } - - private func rootScopedPlan( - request: SidebarWorkspaceReorderDropRequest, - context: SidebarWorkspaceReorderHitContext, - draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupLayoutsById: [UUID: SidebarWorkspaceReorderGroupLayout] - ) -> SidebarWorkspaceReorderDropPlan? { - let usesTopLevelRows = !groupsById.isEmpty && ( - draggedWorkspace.groupId != nil || - groupByAnchorId[draggedWorkspace.id] != nil || - context.target?.groupId != nil || - context.previousTarget?.groupId != nil - ) - let rootTarget = rootTarget( - point: request.point, - context: context, - workspacesById: workspacesById, - groupsById: groupsById, - groupLayoutsById: groupLayoutsById - ) - let requestedIndicator = logicalIndicator(for: rootTarget) - let tabIds = usesTopLevelRows - ? topLevelWorkspaceIds( - workspaces: request.workspaces, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - promotingWorkspaceId: request.draggedWorkspaceId - ) - : request.workspaces.map(\.id) - let pinnedTabIds = usesTopLevelRows - ? topLevelPinnedWorkspaceIds( - workspaces: request.workspaces, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - promotingWorkspaceId: request.draggedWorkspaceId - ) - : Set(request.workspaces.filter { $0.groupId == nil && $0.isPinned }.map(\.id)) - guard let targetIndex = SidebarDropPlanner().targetIndex( - draggedTabId: request.draggedWorkspaceId, - targetTabId: rootTarget.workspaceId, - indicator: requestedIndicator, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds - ) else { - return nil - } - - let promotesGroupedWorkspace = usesTopLevelRows && - draggedWorkspace.groupId != nil && - groupByAnchorId[draggedWorkspace.id] == nil - let plannedIndicator = SidebarDropPlanner().indicator( - draggedTabId: request.draggedWorkspaceId, - targetTabId: rootTarget.workspaceId, - tabIds: tabIds, - pinnedTabIds: pinnedTabIds, - pointerY: rootTarget.pointerY, - targetHeight: rootTarget.targetHeight, - preserveTargetEdge: true, - suppressesNoOp: !promotesGroupedWorkspace - ) - guard let indicator = plannedIndicator else { - return nil - } - let renderedIndicator = renderedRootIndicator( - planned: indicator, - requested: requestedIndicator, - rootTarget: rootTarget - ) - - return SidebarWorkspaceReorderDropPlan( - draggedWorkspaceId: request.draggedWorkspaceId, - indicator: renderedIndicator.indicator, - indicatorScope: renderedIndicator.scope, - action: .reorder( - targetIndex: targetIndex, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: nil - ) - ) - } - - private func crossWindowPlan( - request: SidebarWorkspaceReorderDropRequest, - context: SidebarWorkspaceReorderHitContext, - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupLayoutsById: [UUID: SidebarWorkspaceReorderGroupLayout] - ) -> SidebarWorkspaceReorderDropPlan? { - guard let draggedIsPinned = request.foreignDraggedIsPinned else { return nil } - let rootTarget = rootTarget( - point: request.point, - context: context, - workspacesById: workspacesById, - groupsById: groupsById, - groupLayoutsById: groupLayoutsById - ) - let requestedIndicator = logicalIndicator(for: rootTarget) - let topLevelIds = topLevelWorkspaceIds( - workspaces: request.workspaces, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - promotingWorkspaceId: nil - ) - let proposedInsertionIndex = insertionPosition(for: requestedIndicator, tabIds: topLevelIds) - let result = SidebarDropPlanner().crossWindowInsertion( - targetTabId: rootTarget.workspaceId, - draggedIsPinned: draggedIsPinned, - indicator: nil, - tabIds: topLevelIds, - pinnedTabIds: topLevelPinnedWorkspaceIds( - workspaces: request.workspaces, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - promotingWorkspaceId: nil - ), - pointerY: rootTarget.pointerY, - targetHeight: rootTarget.targetHeight - ) - let renderedIndicator = renderedRootIndicator( - planned: result.indicator, - requested: requestedIndicator, - rootTarget: rootTarget - ) - return SidebarWorkspaceReorderDropPlan( - draggedWorkspaceId: request.draggedWorkspaceId, - indicator: renderedIndicator.indicator, - indicatorScope: renderedIndicator.scope, - action: .crossWindow( - insertionIndex: result.insertionIndex, - proposedInsertionIndex: proposedInsertionIndex - ) - ) - } - - private func groupScopedIndicator( - context: SidebarWorkspaceReorderHitContext, - fallbackAnchorWorkspaceId: UUID - ) -> SidebarDropIndicator { - if context.edge == .top, - let target = context.target, - target.groupId == nil, - let previous = context.previousTarget, - previous.groupId != nil { - return SidebarDropIndicator(tabId: previous.workspaceId, edge: .bottom) - } - if let target = context.target { - if target.isGroupHeader { - return SidebarDropIndicator(tabId: target.workspaceId, edge: .bottom) - } - return SidebarDropIndicator(tabId: target.workspaceId, edge: context.edge) - } - if let previous = context.previousTarget, previous.groupId != nil { - return SidebarDropIndicator(tabId: previous.workspaceId, edge: .bottom) - } - return SidebarDropIndicator(tabId: fallbackAnchorWorkspaceId, edge: .bottom) - } - - private func rootTarget( - point: CGPoint, - context: SidebarWorkspaceReorderHitContext, - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupLayoutsById: [UUID: SidebarWorkspaceReorderGroupLayout] - ) -> SidebarWorkspaceReorderRootTarget { - guard let target = context.target else { - return SidebarWorkspaceReorderRootTarget( - workspaceId: nil, - edge: .bottom, - pointerY: nil, - targetHeight: nil, - indicator: SidebarDropIndicator(tabId: nil, edge: .bottom), - indicatorScope: .topLevel - ) - } - if let groupId = target.groupId, - let layout = groupLayoutsById[groupId] { - let boundaryIndicator = groupBoundaryIndicator( - context: context, - groupId: groupId - ) - if point.y < layout.bounds.midY { - return SidebarWorkspaceReorderRootTarget( - workspaceId: layout.anchorTarget.workspaceId, - edge: .top, - pointerY: 0, - targetHeight: max(layout.anchorTarget.frame.height, 1), - indicator: SidebarDropIndicator(tabId: layout.anchorTarget.workspaceId, edge: .top), - indicatorScope: .topLevel - ) - } - if let nextRootTarget = layout.nextRootTarget { - return SidebarWorkspaceReorderRootTarget( - workspaceId: nextRootTarget.workspaceId, - edge: .top, - pointerY: 0, - targetHeight: max(nextRootTarget.frame.height, 1), - indicator: boundaryIndicator?.indicator - ?? SidebarDropIndicator(tabId: nextRootTarget.workspaceId, edge: .top), - indicatorScope: boundaryIndicator?.scope ?? .topLevel - ) - } - return SidebarWorkspaceReorderRootTarget( - workspaceId: nil, - edge: .bottom, - pointerY: nil, - targetHeight: nil, - indicator: boundaryIndicator?.indicator - ?? SidebarDropIndicator(tabId: nil, edge: .bottom), - indicatorScope: boundaryIndicator?.scope ?? .topLevel - ) - } - let workspaceId: UUID - if let groupId = target.groupId, - let group = groupsById[groupId] { - workspaceId = group.anchorWorkspaceId - } else if let groupId = workspacesById[target.workspaceId]?.groupId, - let group = groupsById[groupId] { - workspaceId = group.anchorWorkspaceId - } else { - workspaceId = target.workspaceId - } - return SidebarWorkspaceReorderRootTarget( - workspaceId: workspaceId, - edge: context.edge, - pointerY: context.pointerY, - targetHeight: context.targetHeight, - indicator: SidebarDropIndicator(tabId: workspaceId, edge: context.edge), - indicatorScope: groupsById.isEmpty ? .raw : .topLevel - ) - } - - private func logicalIndicator(for rootTarget: SidebarWorkspaceReorderRootTarget) -> SidebarDropIndicator { - rootTarget.workspaceId.map { - SidebarDropIndicator(tabId: $0, edge: rootTarget.edge) - } ?? SidebarDropIndicator(tabId: nil, edge: .bottom) - } - - private func insertionPosition(for indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int { - guard let tabId = indicator.tabId, - let index = tabIds.firstIndex(of: tabId) else { - return tabIds.count - } - return indicator.edge == .bottom ? index + 1 : index - } - - private func renderedRootIndicator( - planned: SidebarDropIndicator, - requested: SidebarDropIndicator, - rootTarget: SidebarWorkspaceReorderRootTarget - ) -> (indicator: SidebarDropIndicator, scope: SidebarWorkspaceReorderDropIndicatorScope) { - guard planned == requested, - let indicator = rootTarget.indicator else { - return (planned, canonicalRootScope(for: rootTarget.indicatorScope)) - } - return (indicator, rootTarget.indicatorScope) - } - - private func canonicalRootScope( - for scope: SidebarWorkspaceReorderDropIndicatorScope - ) -> SidebarWorkspaceReorderDropIndicatorScope { - if case .raw = scope { - return .raw - } - return .topLevel - } - - private func groupBoundaryIndicator( - context: SidebarWorkspaceReorderHitContext, - groupId: UUID - ) -> (indicator: SidebarDropIndicator, scope: SidebarWorkspaceReorderDropIndicatorScope)? { - guard context.edge == .bottom, - let target = context.target, - target.groupId == groupId, - context.nextTarget?.groupId != groupId else { - return nil - } - return ( - SidebarDropIndicator(tabId: target.workspaceId, edge: .bottom), - .group(groupId) - ) - } - - private func groupLayouts( - sortedTargets: [SidebarWorkspaceReorderDropTarget], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> [UUID: SidebarWorkspaceReorderGroupLayout] { - var boundsByGroupId: [UUID: CGRect] = [:] - var anchorTargetByGroupId: [UUID: SidebarWorkspaceReorderDropTarget] = [:] - var lastIndexByGroupId: [UUID: Int] = [:] - for (index, target) in sortedTargets.enumerated() { - guard let groupId = target.groupId, - let group = groupsById[groupId] else { - continue - } - boundsByGroupId[groupId] = boundsByGroupId[groupId]?.union(target.frame) ?? target.frame - lastIndexByGroupId[groupId] = index - if target.workspaceId == group.anchorWorkspaceId { - anchorTargetByGroupId[groupId] = target - } - } - - var nextRootTargetByGroupId: [UUID: SidebarWorkspaceReorderDropTarget] = [:] - var nextRootTarget: SidebarWorkspaceReorderDropTarget? - for index in sortedTargets.indices.reversed() { - let target = sortedTargets[index] - if let groupId = target.groupId, - lastIndexByGroupId[groupId] == index { - nextRootTargetByGroupId[groupId] = nextRootTarget - } - if target.groupId == nil { - nextRootTarget = target - } - } - - var layouts: [UUID: SidebarWorkspaceReorderGroupLayout] = [:] - for groupId in groupsById.keys { - guard let bounds = boundsByGroupId[groupId], - let anchorTarget = anchorTargetByGroupId[groupId] else { - continue - } - layouts[groupId] = SidebarWorkspaceReorderGroupLayout( - bounds: bounds, - anchorTarget: anchorTarget, - nextRootTarget: nextRootTargetByGroupId[groupId] - ) - } - return layouts - } - - private func legalInsertionRange( - draggedWorkspace: SidebarWorkspaceReorderWorkspaceSnapshot, - explicitGroupId: UUID, - workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> ClosedRange? { - guard let group = groupsById[explicitGroupId], - draggedWorkspace.id != group.anchorWorkspaceId else { - return nil - } - let memberIndices = workspaces.indices.filter { workspaces[$0].groupId == explicitGroupId } - guard let firstIndex = memberIndices.first, - let lastIndex = memberIndices.last else { - return nil - } - let pinnedMemberCount = memberIndices.reduce(into: 0) { count, index in - let member = workspaces[index] - if member.id != group.anchorWorkspaceId, member.isPinned { - count += 1 - } - } - if draggedWorkspace.isPinned { - let lower = min(firstIndex + 1, workspaces.count) - let upper = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) - return lower...max(lower, upper) - } - let lower = min(firstIndex + 1 + pinnedMemberCount, workspaces.count) - let upper = min(lastIndex + 1, workspaces.count) - return min(lower, upper)...max(lower, upper) - } - - private func topLevelWorkspaceIds( - workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot], - promotingWorkspaceId: UUID? - ) -> [UUID] { - var emittedGroupIds = Set() - var ids: [UUID] = [] - ids.reserveCapacity(workspaces.count) - for workspace in workspaces { - if let groupId = workspace.groupId, - let group = groupsById[groupId] { - if emittedGroupIds.insert(groupId).inserted { - ids.append(group.anchorWorkspaceId) - } - } else { - ids.append(workspace.id) - } - } - if let promotingWorkspaceId, - !ids.contains(promotingWorkspaceId), - let promoted = workspaces.first(where: { $0.id == promotingWorkspaceId }), - let groupId = promoted.groupId, - let group = groupsById[groupId], - let groupIndex = ids.firstIndex(of: group.anchorWorkspaceId) { - ids.insert( - promotingWorkspaceId, - at: promotedTopLevelInsertionIndex( - ids: ids, - groupIndex: groupIndex, - promotedIsPinned: promoted.isPinned, - workspacesById: workspacesById, - groupByAnchorId: groupByAnchorId - ) - ) - } - return ids - } - - private func topLevelPinnedWorkspaceIds( - workspaces: [SidebarWorkspaceReorderWorkspaceSnapshot], - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupsById: [UUID: SidebarWorkspaceReorderGroupSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot], - promotingWorkspaceId: UUID? - ) -> Set { - Set(topLevelWorkspaceIds( - workspaces: workspaces, - workspacesById: workspacesById, - groupsById: groupsById, - groupByAnchorId: groupByAnchorId, - promotingWorkspaceId: promotingWorkspaceId - ).filter { id in - topLevelWorkspaceIdIsPinned(id, workspacesById: workspacesById, groupByAnchorId: groupByAnchorId) - }) - } - - private func promotedTopLevelInsertionIndex( - ids: [UUID], - groupIndex: Int, - promotedIsPinned: Bool, - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> Int { - let desiredIndex = min(groupIndex + 1, ids.count) - let pinnedCount = ids.reduce(into: 0) { count, id in - if topLevelWorkspaceIdIsPinned(id, workspacesById: workspacesById, groupByAnchorId: groupByAnchorId) { - count += 1 - } - } - return promotedIsPinned ? min(desiredIndex, pinnedCount) : max(desiredIndex, pinnedCount) - } - - private func topLevelWorkspaceIdIsPinned( - _ id: UUID, - workspacesById: [UUID: SidebarWorkspaceReorderWorkspaceSnapshot], - groupByAnchorId: [UUID: SidebarWorkspaceReorderGroupSnapshot] - ) -> Bool { - if let group = groupByAnchorId[id] { - return group.isPinned - } - return workspacesById[id]?.isPinned == true - } - - private func pointerY(for edge: SidebarDropEdge, targetHeight: CGFloat?) -> CGFloat? { - guard let targetHeight else { return nil } - return edge == .top ? 0 : targetHeight - } -} - -private extension CGRect { - func verticallyContains(_ y: CGFloat) -> Bool { - y >= minY && y <= maxY - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropTarget.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropTarget.swift deleted file mode 100644 index fb12cd3268b1..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropTarget.swift +++ /dev/null @@ -1,31 +0,0 @@ -public import CoreGraphics -public import Foundation - -/// A visible workspace sidebar row that can participate in reorder hit testing. -public struct SidebarWorkspaceReorderDropTarget: Equatable, Sendable { - /// The workspace represented by the visible row. - public let workspaceId: UUID - - /// The workspace group represented by or containing the row, if any. - public let groupId: UUID? - - /// Whether the row is a workspace group header. - public let isGroupHeader: Bool - - /// The row frame in the drop overlay's coordinate space. - public let frame: CGRect - - /// Creates a visible row target for sidebar reorder planning. - /// - /// - Parameters: - /// - workspaceId: The workspace represented by the visible row. - /// - groupId: The workspace group represented by or containing the row, if any. - /// - isGroupHeader: Whether the row is a workspace group header. - /// - frame: The row frame in the drop overlay's coordinate space. - public init(workspaceId: UUID, groupId: UUID?, isGroupHeader: Bool, frame: CGRect) { - self.workspaceId = workspaceId - self.groupId = groupId - self.isGroupHeader = isGroupHeader - self.frame = frame - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupLayout.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupLayout.swift deleted file mode 100644 index c69535927a48..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupLayout.swift +++ /dev/null @@ -1,9 +0,0 @@ -import CoreGraphics -import Foundation - -/// Visible bounds and neighbor data for one expanded workspace group. -struct SidebarWorkspaceReorderGroupLayout { - let bounds: CGRect - let anchorTarget: SidebarWorkspaceReorderDropTarget - let nextRootTarget: SidebarWorkspaceReorderDropTarget? -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift deleted file mode 100644 index 86b3679428b4..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderGroupSnapshot.swift +++ /dev/null @@ -1,25 +0,0 @@ -public import Foundation - -/// Immutable workspace group data used by the sidebar workspace reorder resolver. -public struct SidebarWorkspaceReorderGroupSnapshot: Equatable, Sendable { - /// The group identifier. - public let id: UUID - - /// The workspace id rendered as the group's header row. - public let anchorWorkspaceId: UUID - - /// Whether the group belongs to the leading pinned tier. - public let isPinned: Bool - - /// Creates a workspace group snapshot for sidebar reorder planning. - /// - /// - Parameters: - /// - id: The group identifier. - /// - anchorWorkspaceId: The workspace id rendered as the group's header row. - /// - isPinned: Whether the group belongs to the leading pinned tier. - public init(id: UUID, anchorWorkspaceId: UUID, isPinned: Bool) { - self.id = id - self.anchorWorkspaceId = anchorWorkspaceId - self.isPinned = isPinned - } -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderHitContext.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderHitContext.swift deleted file mode 100644 index 8d6bb76393a0..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderHitContext.swift +++ /dev/null @@ -1,12 +0,0 @@ -import CoreGraphics -import Foundation - -/// Row hit-test context used while resolving a workspace reorder drag. -struct SidebarWorkspaceReorderHitContext { - let target: SidebarWorkspaceReorderDropTarget? - let previousTarget: SidebarWorkspaceReorderDropTarget? - let nextTarget: SidebarWorkspaceReorderDropTarget? - let edge: SidebarDropEdge - let pointerY: CGFloat? - let targetHeight: CGFloat? -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderRootTarget.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderRootTarget.swift deleted file mode 100644 index d30496a1b655..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderRootTarget.swift +++ /dev/null @@ -1,12 +0,0 @@ -import CoreGraphics -import Foundation - -/// Root-level insertion target derived from visible sidebar rows. -struct SidebarWorkspaceReorderRootTarget { - let workspaceId: UUID? - let edge: SidebarDropEdge - let pointerY: CGFloat? - let targetHeight: CGFloat? - let indicator: SidebarDropIndicator? - let indicatorScope: SidebarWorkspaceReorderDropIndicatorScope -} diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderWorkspaceSnapshot.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderWorkspaceSnapshot.swift deleted file mode 100644 index c4163a0ff996..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderWorkspaceSnapshot.swift +++ /dev/null @@ -1,25 +0,0 @@ -public import Foundation - -/// Immutable workspace data used by the sidebar workspace reorder resolver. -public struct SidebarWorkspaceReorderWorkspaceSnapshot: Equatable, Sendable { - /// The workspace identifier. - public let id: UUID - - /// Whether the workspace belongs to the leading pinned tier. - public let isPinned: Bool - - /// The group containing this workspace, or `nil` for a root-level workspace. - public let groupId: UUID? - - /// Creates a workspace snapshot for sidebar reorder planning. - /// - /// - Parameters: - /// - id: The workspace identifier. - /// - isPinned: Whether the workspace belongs to the leading pinned tier. - /// - groupId: The group containing this workspace, or `nil` for a root-level workspace. - public init(id: UUID, isPinned: Bool, groupId: UUID?) { - self.id = id - self.isPinned = isPinned - self.groupId = groupId - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift index 913ef9623230..bb2a26eaf70a 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Drag/SidebarDragState.swift @@ -29,9 +29,6 @@ public final class SidebarDragState { /// space the planner reasoned in. public var dropIndicatorUsesTopLevelRows = false - /// The visible row scope where the active indicator should be drawn. - public var dropIndicatorScope: SidebarWorkspaceReorderDropIndicatorScope = .raw - /// True while the `debug.sidebar.simulate_drag` debug-only method is driving /// the drag state. Lifecycle observers honor this by not starting the /// failsafe monitor (which would otherwise post a `mouse_up_failsafe` clear @@ -80,17 +77,8 @@ public final class SidebarDragState { /// Sets the current drop indicator and whether it is positioned in top-level /// row space. public func setDropIndicator(_ indicator: SidebarDropIndicator?, usesTopLevelRows: Bool = false) { - setDropIndicator(indicator, scope: usesTopLevelRows ? .topLevel : .raw) - } - - /// Sets the current drop indicator and the visible row scope it belongs to. - public func setDropIndicator( - _ indicator: SidebarDropIndicator?, - scope: SidebarWorkspaceReorderDropIndicatorScope - ) { dropIndicator = indicator - dropIndicatorScope = indicator == nil ? .raw : scope - dropIndicatorUsesTopLevelRows = indicator != nil && scope == .topLevel + dropIndicatorUsesTopLevelRows = indicator != nil && usesTopLevelRows } /// Clears any visible drop indicator. diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift index 70d8d6adb381..8442ea5765f0 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift @@ -147,6 +147,16 @@ public final class WorkspaceGroupCoordinator { placement: placement, referenceWorkspaceId: referenceWorkspaceId ) + // Expand the group when the new workspace is being focused. The + // selectedTabId auto-expand hook fires inside the host's workspace + // creation BEFORE assignGroup, so it can't see the new workspace's + // membership. Without this, clicking `+` on a collapsed group selects + // a workspace that's visually hidden in the sidebar. + if select, + let idx = model.workspaceGroups.firstIndex(where: { $0.id == groupId }), + model.workspaceGroups[idx].isCollapsed { + model.workspaceGroups[idx].isCollapsed = false + } model.normalizeWorkspaceGroupContiguity() host.workspaceOrderDidChange(movedWorkspaceIds: [newWorkspace.id]) return newWorkspace @@ -237,6 +247,15 @@ public final class WorkspaceGroupCoordinator { if isAnchorOfOtherGroup { return } let originalTopLevelIds = model.sidebarTopLevelWorkspaceIds() model.assignGroup(workspaceId: workspaceId, groupId: groupId) + // selectedTabId may not change here (the workspace was already + // selected), so the existing didSet hook won't fire. Expand manually + // when the added workspace is the focused one so it doesn't end up + // hidden inside a collapsed section. + if model.selectedTabId == workspaceId, + let groupIndex = model.workspaceGroups.firstIndex(where: { $0.id == groupId }), + model.workspaceGroups[groupIndex].isCollapsed { + model.workspaceGroups[groupIndex].isCollapsed = false + } model.normalizeWorkspaceGroupContiguity( preservingTopLevelIds: originalTopLevelIds.filter { $0 != workspaceId } ) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift index e2f95d9565e2..6f511d33bb3c 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift @@ -106,49 +106,21 @@ public final class WorkspaceReorderCoordinator { /// Reorders one workspace to the clamped target index; drag operations /// additionally run neighbor-based group-membership inference. @discardableResult - public func reorderWorkspace( - tabId: UUID, - toIndex targetIndex: Int, - isDragOperation: Bool = false, - explicitGroupId: UUID? = nil - ) -> Bool { - if let explicitGroupId, - !model.workspaceGroups.contains(where: { $0.id == explicitGroupId }) { - return false - } - let plan: WorkspaceReorderPlanItem? - if isDragOperation, explicitGroupId != nil { - plan = explicitGroupWorkspaceReorderPlan(tabId: tabId, toIndex: targetIndex) - } else { - plan = workspaceReorderPlan(tabId: tabId, toIndex: targetIndex) - } - guard let plan else { return false } + public func reorderWorkspace(tabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false) -> Bool { + guard let plan = workspaceReorderPlan(tabId: tabId, toIndex: targetIndex) else { return false } // No-op reorders (single workspace, clamped to current index, etc.) // must not run group inference. Otherwise socket calls like // `workspace.action move_down` on the last ungrouped row would // silently absorb it into the group above just because the request // resolved to "stay put." - if model.tabs.count <= 1 { - return true - } - if plan.fromIndex == plan.toIndex { - guard isDragOperation, explicitGroupId != nil else { - return true - } - let previousOrder = model.tabs.map(\.id) - let previousGroupId = model.tabs[plan.fromIndex].groupId - applyDragInferredGroupMembership(workspaceId: tabId, explicitGroupId: explicitGroupId) - let currentGroupId = model.tabs.first(where: { $0.id == tabId })?.groupId - if currentGroupId != previousGroupId || model.tabs.map(\.id) != previousOrder { - host?.workspaceOrderDidChange(movedWorkspaceIds: [tabId]) - } + if model.tabs.count <= 1 || plan.fromIndex == plan.toIndex { return true } let workspace = model.tabs.remove(at: plan.fromIndex) model.tabs.insert(workspace, at: plan.toIndex) if isDragOperation { - applyDragInferredGroupMembership(workspaceId: tabId, explicitGroupId: explicitGroupId) + applyDragInferredGroupMembership(workspaceId: tabId) } else if !model.workspaceGroups.isEmpty { if model.workspaceGroups.contains(where: { $0.anchorWorkspaceId == tabId }) { model.syncWorkspaceGroupsOrderToAnchorOrder() @@ -166,18 +138,6 @@ public final class WorkspaceReorderCoordinator { return reorderWorkspace(tabId: tabId, toIndex: plan.toIndex, isDragOperation: isDragOperation) } - /// Explicit group drops are planned by the sidebar in the intended target - /// group's row-space. Preserve that slot here instead of reclamping against - /// the dragged workspace's current group or global pin tier. - private func explicitGroupWorkspaceReorderPlan(tabId: UUID, toIndex targetIndex: Int) -> WorkspaceReorderPlanItem? { - guard let currentIndex = model.tabs.firstIndex(where: { $0.id == tabId }) else { return nil } - if model.tabs.count <= 1 { - return WorkspaceReorderPlanItem(workspaceId: tabId, fromIndex: currentIndex, toIndex: currentIndex) - } - let clamped = max(0, min(targetIndex, model.tabs.count - 1)) - return WorkspaceReorderPlanItem(workspaceId: tabId, fromIndex: currentIndex, toIndex: clamped) - } - /// The clamped single-workspace reorder plan, or `nil` when unknown. public func workspaceReorderPlan(tabId: UUID, toIndex targetIndex: Int) -> WorkspaceReorderPlanItem? { guard let currentIndex = model.tabs.firstIndex(where: { $0.id == tabId }) else { return nil } @@ -234,7 +194,7 @@ public final class WorkspaceReorderCoordinator { ) else { return Set(model.tabs.filter { $0.groupId == nil && $0.isPinned }.map(\.id)) } - return model.sidebarTopLevelPinnedWorkspaceIds(promotingWorkspaceId: draggedWorkspaceId) + return model.sidebarTopLevelPinnedWorkspaceIds() } /// The legal insertion range for an in-group member drag, or `nil` when @@ -242,17 +202,16 @@ public final class WorkspaceReorderCoordinator { public func sidebarReorderLegalInsertionRange( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? = nil, - usesTopLevelRows: Bool = false, - explicitGroupId: UUID? = nil + usesTopLevelRows: Bool = false ) -> ClosedRange? { guard !usesTopLevelRows, - (explicitGroupId != nil || !sidebarReorderUsesTopLevelRows( + !sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId - )), + ), let draggedWorkspaceId, let draggedWorkspace = model.tabs.first(where: { $0.id == draggedWorkspaceId }), - let groupId = explicitGroupId ?? draggedWorkspace.groupId, + let groupId = draggedWorkspace.groupId, let group = model.workspaceGroups.first(where: { $0.id == groupId }), draggedWorkspace.id != group.anchorWorkspaceId else { return nil @@ -285,8 +244,7 @@ public final class WorkspaceReorderCoordinator { tabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false, - usesTopLevelRows: Bool = false, - explicitGroupId: UUID? = nil + usesTopLevelRows: Bool = false ) -> Bool { if usesTopLevelRows || model.isWorkspaceGroupAnchor(tabId) { return reorderTopLevelWorkspaceItem( @@ -295,12 +253,7 @@ public final class WorkspaceReorderCoordinator { promotesGroupedWorkspace: usesTopLevelRows ) } - return reorderWorkspace( - tabId: tabId, - toIndex: targetIndex, - isDragOperation: isDragOperation, - explicitGroupId: explicitGroupId - ) + return reorderWorkspace(tabId: tabId, toIndex: targetIndex, isDragOperation: isDragOperation) } @discardableResult @@ -316,26 +269,17 @@ public final class WorkspaceReorderCoordinator { let clampedTarget = model.clampedTopLevelReorderIndex( forWorkspaceId: tabId, targetIndex: targetIndex, - topLevelIds: topLevelIds, - promotingWorkspaceId: promotesGroupedWorkspace ? tabId : nil + topLevelIds: topLevelIds ) - let shouldPromoteGroupedWorkspace: Bool = { - guard promotesGroupedWorkspace, - let tab = model.tabs.first(where: { $0.id == tabId }), - tab.groupId != nil, - !model.isWorkspaceGroupAnchor(tabId) else { - return false - } - return true - }() - guard fromIndex != clampedTarget || shouldPromoteGroupedWorkspace else { return false } + guard fromIndex != clampedTarget else { return false } var desiredTopLevelIds = topLevelIds - if fromIndex != clampedTarget { - let movedId = desiredTopLevelIds.remove(at: fromIndex) - desiredTopLevelIds.insert(movedId, at: clampedTarget) - } - if shouldPromoteGroupedWorkspace { + let movedId = desiredTopLevelIds.remove(at: fromIndex) + desiredTopLevelIds.insert(movedId, at: clampedTarget) + if promotesGroupedWorkspace, + let tab = model.tabs.first(where: { $0.id == tabId }), + tab.groupId != nil, + !model.isWorkspaceGroupAnchor(tabId) { model.assignGroup(workspaceId: tabId, groupId: nil) } model.normalizeWorkspaceGroupRunsPreservingOrder(desiredTopLevelIds) @@ -399,7 +343,7 @@ public final class WorkspaceReorderCoordinator { /// Pinned workspaces may join a group when the same neighbor-based rules /// place them inside that group's section. /// Anchors keep their group: their lifecycle is gated by group existence. - private func applyDragInferredGroupMembership(workspaceId: UUID, explicitGroupId: UUID? = nil) { + private func applyDragInferredGroupMembership(workspaceId: UUID) { guard let index = model.tabs.firstIndex(where: { $0.id == workspaceId }) else { return } let tab = model.tabs[index] let isAnchor = model.workspaceGroups.contains(where: { $0.anchorWorkspaceId == workspaceId }) @@ -413,12 +357,6 @@ public final class WorkspaceReorderCoordinator { model.normalizeWorkspaceGroupContiguity() return } - if let explicitGroupId { - guard model.workspaceGroups.contains(where: { $0.id == explicitGroupId }) else { return } - model.assignGroup(workspaceId: workspaceId, groupId: explicitGroupId) - model.normalizeWorkspaceGroupContiguity() - return - } let before: Tab? = index > 0 ? model.tabs[index - 1] : nil let after: Tab? = (index + 1) < model.tabs.count ? model.tabs[index + 1] : nil let beforeGroup = before?.groupId @@ -446,7 +384,7 @@ public final class WorkspaceReorderCoordinator { inferred = currentGroup } if tab.groupId != inferred { - model.assignGroup(workspaceId: workspaceId, groupId: inferred) + tab.groupId = inferred // Renormalize after group change to keep tiers contiguous. model.normalizeWorkspaceGroupContiguity() } else if inferred != nil { diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift index d3312cbffab2..07a68c275588 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+GroupInvariants.swift @@ -5,12 +5,11 @@ public import Foundation // contiguous group runs, anchor-first member order, pinned tier above // unpinned, and the group-dissolve lifecycle bound to anchor removal. extension WorkspacesModel { - /// Sets a workspace's group membership and preserves selected-row visibility. + /// Sets a workspace's group membership (no-op when unchanged). func assignGroup(workspaceId: UUID, groupId: UUID?) { guard let tab = tabs.first(where: { $0.id == workspaceId }) else { return } guard tab.groupId != groupId else { return } tab.groupId = groupId - expandWorkspaceGroupForSelectionIfNeeded() } /// Rebuild `tabs` by walking a desired top-level workspace order and diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift index 17a07e20cf91..58a8fadc7100 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Model/WorkspacesModel+Ordering.swift @@ -33,11 +33,9 @@ extension WorkspacesModel { /// The sidebar's top-level row ids in `tabs[]` order (group anchors and /// ungrouped workspaces). Optionally inserts a grouped workspace being - /// promoted to top level as close to its group's row as its pin tier allows. + /// promoted to top level right after its group's row. func sidebarTopLevelWorkspaceIds(promotingWorkspaceId promotedWorkspaceId: UUID? = nil) -> [UUID] { let groupsById = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.id, $0) }) - let groupsByAnchorId = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.anchorWorkspaceId, $0) }) - let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) var emittedGroupIds = Set() var ids: [UUID] = [] ids.reserveCapacity(tabs.count) @@ -53,20 +51,11 @@ extension WorkspacesModel { } if let promotedWorkspaceId, !ids.contains(promotedWorkspaceId), - let tab = tabsById[promotedWorkspaceId], + let tab = tabs.first(where: { $0.id == promotedWorkspaceId }), let groupId = tab.groupId, let group = groupsById[groupId], let groupIndex = ids.firstIndex(of: group.anchorWorkspaceId) { - ids.insert( - promotedWorkspaceId, - at: promotedTopLevelInsertionIndex( - ids: ids, - groupIndex: groupIndex, - promotedIsPinned: tab.isPinned, - tabsById: tabsById, - groupsByAnchorId: groupsByAnchorId - ) - ) + ids.insert(promotedWorkspaceId, at: min(groupIndex + 1, ids.count)) } return ids } @@ -105,50 +94,25 @@ extension WorkspacesModel { /// The pinned subset of the top-level rows (pinned groups by group pin, /// ungrouped workspaces by workspace pin). - func sidebarTopLevelPinnedWorkspaceIds(promotingWorkspaceId: UUID? = nil) -> Set { + func sidebarTopLevelPinnedWorkspaceIds() -> Set { let groupsByAnchorId = Dictionary(uniqueKeysWithValues: workspaceGroups.map { ($0.anchorWorkspaceId, $0) }) let tabsById = Dictionary(uniqueKeysWithValues: tabs.map { ($0.id, $0) }) - return Set(sidebarTopLevelWorkspaceIds(promotingWorkspaceId: promotingWorkspaceId).filter { id in - topLevelWorkspaceIdIsPinned(id, tabsById: tabsById, groupsByAnchorId: groupsByAnchorId) - }) - } - - private func promotedTopLevelInsertionIndex( - ids: [UUID], - groupIndex: Int, - promotedIsPinned: Bool, - tabsById: [UUID: Tab], - groupsByAnchorId: [UUID: WorkspaceGroup] - ) -> Int { - let desiredIndex = min(groupIndex + 1, ids.count) - let pinnedCount = ids.reduce(into: 0) { count, id in - if topLevelWorkspaceIdIsPinned(id, tabsById: tabsById, groupsByAnchorId: groupsByAnchorId) { - count += 1 + return Set(sidebarTopLevelWorkspaceIds().filter { id in + if let group = groupsByAnchorId[id] { + return group.isPinned } - } - return promotedIsPinned ? min(desiredIndex, pinnedCount) : max(desiredIndex, pinnedCount) - } - - private func topLevelWorkspaceIdIsPinned( - _ id: UUID, - tabsById: [UUID: Tab], - groupsByAnchorId: [UUID: WorkspaceGroup] - ) -> Bool { - if let group = groupsByAnchorId[id] { - return group.isPinned - } - return tabsById[id]?.isPinned == true + return tabsById[id]?.isPinned == true + }) } /// Clamps a requested top-level reorder index into the mover's pin tier. func clampedTopLevelReorderIndex( forWorkspaceId workspaceId: UUID, targetIndex: Int, - topLevelIds: [UUID], - promotingWorkspaceId: UUID? = nil + topLevelIds: [UUID] ) -> Int { let clamped = max(0, min(targetIndex, max(0, topLevelIds.count - 1))) - let pinnedIds = sidebarTopLevelPinnedWorkspaceIds(promotingWorkspaceId: promotingWorkspaceId) + let pinnedIds = sidebarTopLevelPinnedWorkspaceIds() let pinnedCount = topLevelIds.reduce(into: 0) { count, id in if pinnedIds.contains(id) { count += 1 diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift index 06757a6d7d1d..a16923d8d4f6 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.swift @@ -195,259 +195,10 @@ struct WorkspaceCoordinatorTests { #expect(model.tabs.allSatisfy { !$0.isPinned }) } - @Test - func explicitGroupDropJoinsTargetGroupAtBoundarySlot() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let dragged = CoordinatorStubTab() - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [dragged, child1, child2, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let group = try #require(model.workspaceGroups.first(where: { $0.id == groupId })) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: 3, - isDragOperation: true, - explicitGroupId: groupId - ) - - #expect(moved) - #expect(dragged.groupId == groupId) - #expect(model.tabs.map(\.id) == [ - group.anchorWorkspaceId, - child1.id, - child2.id, - dragged.id, - outside.id, - ]) - } - - @Test - func explicitGroupDropAppliesMembershipWhenIndexDoesNotMove() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let dragged = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [child1, child2, dragged, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let draggedIndex = try #require(model.tabs.firstIndex { $0.id == dragged.id }) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: draggedIndex, - isDragOperation: true, - explicitGroupId: groupId - ) - - #expect(moved) - #expect(dragged.groupId == groupId) - } - - @Test - func explicitGroupDropOfSelectedWorkspaceExpandsCollapsedTargetGroupWhenIndexDoesNotMove() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let child = CoordinatorStubTab() - let dragged = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [child, dragged, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child.id, - ])) - groups.setWorkspaceGroupCollapsed(groupId: groupId, isCollapsed: true) - model.selectedTabId = dragged.id - let draggedIndex = try #require(model.tabs.firstIndex { $0.id == dragged.id }) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: draggedIndex, - isDragOperation: true, - explicitGroupId: groupId - ) - - #expect(moved) - #expect(dragged.groupId == groupId) - #expect(model.selectedTabId == dragged.id) - #expect(model.workspaceGroups.first { $0.id == groupId }?.isCollapsed == false) - } - - @Test - func staleExplicitGroupDropDoesNotInferMembership() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let dragged = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [child1, child2, dragged, outside] - _ = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let draggedIndex = try #require(model.tabs.firstIndex { $0.id == dragged.id }) - let previousOrder = model.tabs.map(\.id) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: draggedIndex, - isDragOperation: true, - explicitGroupId: UUID() - ) - - #expect(!moved) - #expect(dragged.groupId == nil) - #expect(model.tabs.map(\.id) == previousOrder) - } - - @Test - func explicitGroupDropFromAnotherGroupPreservesTargetGroupSlot() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let dragged = CoordinatorStubTab() - let sourcePeer = CoordinatorStubTab() - let targetChild1 = CoordinatorStubTab() - let targetChild2 = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [dragged, sourcePeer, targetChild1, targetChild2, outside] - _ = try #require(groups.createWorkspaceGroup(name: "Source", childWorkspaceIds: [ - dragged.id, - sourcePeer.id, - ])) - let targetGroupId = try #require(groups.createWorkspaceGroup(name: "Target", childWorkspaceIds: [ - targetChild1.id, - targetChild2.id, - ])) - let targetGroup = try #require(model.workspaceGroups.first { $0.id == targetGroupId }) - let targetLastIndex = try #require(model.tabs.indices.last { model.tabs[$0].groupId == targetGroupId }) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: targetLastIndex, - isDragOperation: true, - explicitGroupId: targetGroupId - ) - - #expect(moved) - #expect(dragged.groupId == targetGroupId) - #expect(model.tabs.filter { $0.groupId == targetGroupId }.map(\.id) == [ - targetGroup.anchorWorkspaceId, - targetChild1.id, - targetChild2.id, - dragged.id, - ]) - } - - @Test - func boundaryDropWithoutExplicitGroupStaysTopLevel() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let dragged = CoordinatorStubTab() - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [dragged, child1, child2, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let group = try #require(model.workspaceGroups.first(where: { $0.id == groupId })) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: 3, - isDragOperation: true - ) - - #expect(moved) - #expect(dragged.groupId == nil) - #expect(model.tabs.map(\.id) == [ - group.anchorWorkspaceId, - child1.id, - child2.id, - dragged.id, - outside.id, - ]) - } - - @Test - func topLevelDropOverGroupMemberDoesNotInferMembership() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let dragged = CoordinatorStubTab() - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [dragged, child1, child2, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let group = try #require(model.workspaceGroups.first(where: { $0.id == groupId })) - - let moved = reorder.reorderSidebarWorkspace( - tabId: dragged.id, - toIndex: 1, - isDragOperation: true, - usesTopLevelRows: true - ) - - #expect(moved) - #expect(dragged.groupId == nil) - #expect(model.tabs.map(\.id) == [ - group.anchorWorkspaceId, - child1.id, - child2.id, - dragged.id, - outside.id, - ]) - } - - @Test - func explicitGroupLegalRangeConstrainsBoundaryPlanningToGroup() throws { - let (model, host, groups, reorder) = makeWorld() - _ = host - let dragged = CoordinatorStubTab() - let child1 = CoordinatorStubTab() - let child2 = CoordinatorStubTab() - let outside = CoordinatorStubTab() - model.tabs = [dragged, child1, child2, outside] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [ - child1.id, - child2.id, - ])) - let memberIndices = model.tabs.indices.filter { model.tabs[$0].groupId == groupId } - let firstMemberIndex = try #require(memberIndices.first) - let lastMemberIndex = try #require(memberIndices.last) - - let unconstrainedRange = reorder.sidebarReorderLegalInsertionRange( - forDraggedWorkspaceId: dragged.id, - targetWorkspaceId: outside.id - ) - let explicitGroupRange = reorder.sidebarReorderLegalInsertionRange( - forDraggedWorkspaceId: dragged.id, - targetWorkspaceId: outside.id, - explicitGroupId: groupId - ) - - #expect(unconstrainedRange == nil) - #expect(explicitGroupRange == (firstMemberIndex + 1)...(lastMemberIndex + 1)) - } - // MARK: Groups @Test - func createWorkspaceGroupAdoptsChildrenAndKeepsSectionContiguous() throws { + func createWorkspaceGroupAdoptsChildrenAndKeepsSectionContiguous() { let (model, host, groups, _) = makeWorld() let child1 = CoordinatorStubTab() let other = CoordinatorStubTab() @@ -459,7 +210,7 @@ struct WorkspaceCoordinatorTests { childWorkspaceIds: [child1.id, child2.id] ) - let group = try #require(model.workspaceGroups.first(where: { $0.id == groupId })) + let group = try! #require(model.workspaceGroups.first(where: { $0.id == groupId })) #expect(group.name == "Group 1") let anchorId = group.anchorWorkspaceId #expect(model.tabs.first(where: { $0.id == child1.id })?.groupId == groupId) @@ -485,12 +236,12 @@ struct WorkspaceCoordinatorTests { } @Test - func deleteWorkspaceGroupClosesMembersAndClearsLastHoldout() throws { + func deleteWorkspaceGroupClosesMembersAndClearsLastHoldout() { let (model, host, groups, _) = makeWorld() let a = CoordinatorStubTab() let b = CoordinatorStubTab() model.tabs = [a, b] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id, b.id])) + let groupId = try! #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id, b.id])) let closed = groups.deleteWorkspaceGroup(groupId: groupId) @@ -504,12 +255,12 @@ struct WorkspaceCoordinatorTests { } @Test - func ungroupKeepsMemberPositionsAndDropsMembership() throws { + func ungroupKeepsMemberPositionsAndDropsMembership() { let (model, host, groups, _) = makeWorld() _ = host let a = CoordinatorStubTab() model.tabs = [a] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id])) + let groupId = try! #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id])) let orderBefore = model.tabs.map(\.id) groups.ungroupWorkspaceGroup(groupId: groupId) @@ -520,11 +271,11 @@ struct WorkspaceCoordinatorTests { } @Test - func collapseToggleMovesFocusToAnchorAndStripsHiddenSelection() throws { + func collapseToggleMovesFocusToAnchorAndStripsHiddenSelection() { let (model, host, groups, _) = makeWorld() let a = CoordinatorStubTab() model.tabs = [a] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id])) + let groupId = try! #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id])) let anchorId = model.workspaceGroups[0].anchorWorkspaceId model.selectedTabId = a.id host.sidebarSelectedWorkspaceIds = [a.id] @@ -557,13 +308,13 @@ struct WorkspaceCoordinatorTests { } @Test - func setWorkspaceGroupAnchorHoistsNewAnchorToSectionFront() throws { + func setWorkspaceGroupAnchorHoistsNewAnchorToSectionFront() { let (model, host, groups, _) = makeWorld() _ = host let a = CoordinatorStubTab() let b = CoordinatorStubTab() model.tabs = [a, b] - let groupId = try #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id, b.id])) + let groupId = try! #require(groups.createWorkspaceGroup(name: "G", childWorkspaceIds: [a.id, b.id])) groups.setWorkspaceGroupAnchor(groupId: groupId, workspaceId: b.id) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 0b37a8365906..7ebdee83c93b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9979,6 +9979,27 @@ extension SidebarDragState { } } +/// Per-row drop-indicator visibility, computed by the parent from value +/// inputs only. Takes UUIDs (not `Tab` objects or `SidebarDragState`) so it's +/// trivially unit-testable and the row's view subtree never reads the +/// `@Observable` store directly. Same predicate that used to live inside +/// `SidebarTabDropIndicatorOverlay`. +struct SidebarWorkspaceTopDropIndicator: View { + let isVisible: Bool + let isFirstRow: Bool + let rowSpacing: CGFloat + + var body: some View { + if isVisible { + Rectangle() + .fill(cmuxAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: isFirstRow ? 0 : -(rowSpacing / 2)) + } + } +} + /// Freezes `showsModifierShortcutHints` for the row whose context menu is open, /// so pressing/releasing the modifier key while the menu is up does not flip /// the underlying row's shortcut badges (which would be visible around the @@ -10015,8 +10036,6 @@ struct VerticalTabsSidebar: View { // `SidebarDragState`, so they need a separate transient collection flag. @State private var isBonsplitWorkspaceDropTargetCollectionActive = false @State private var bonsplitWorkspaceDropTargetBridge = SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge() - @State private var isWorkspaceReorderDropTargetCollectionActive = false - @State private var workspaceReorderDropTargetBridge = SidebarWorkspaceReorderDropOverlay.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 @@ -10226,8 +10245,7 @@ struct VerticalTabsSidebar: View { return SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: dragState.draggedTabId, dropIndicator: dragState.dropIndicator, - lastTabId: reorderIds.last, - indicatorScope: dragState.dropIndicatorScope + lastTabId: reorderIds.last ) } @@ -10247,28 +10265,6 @@ struct VerticalTabsSidebar: View { ) } - private func sidebarDropIndicatorRowIds( - draggedWorkspaceId: UUID, - scope: SidebarWorkspaceReorderDropIndicatorScope, - tabs: [Workspace], - workspaceGroups: [WorkspaceGroup], - visibleWorkspaceRowIds: [UUID] - ) -> [UUID] { - switch scope { - case .raw: - return tabs.map(\.id) - case .topLevel: - return tabManager.sidebarReorderWorkspaceIds( - forDraggedWorkspaceId: draggedWorkspaceId, - usesTopLevelRows: true - ) - case .group(let groupId): - guard workspaceGroups.contains(where: { $0.id == groupId }) else { return [] } - let visibleIds = Set(visibleWorkspaceRowIds) - return tabs.filter { $0.groupId == groupId && visibleIds.contains($0.id) }.map(\.id) - } - } - private var sidebarTopScrimHeight: CGFloat { SidebarWorkspaceListMetrics.topScrimHeight } @@ -10436,14 +10432,10 @@ struct VerticalTabsSidebar: View { ) let visibleWorkspaceRowIds = workspaceRenderItems.map(\.rowWorkspaceId) let draggedSidebarTabId = dragState.draggedTabId - let dropIndicatorScope = dragState.dropIndicatorScope let sidebarReorderIds = draggedSidebarTabId.map { - sidebarDropIndicatorRowIds( - draggedWorkspaceId: $0, - scope: dropIndicatorScope, - tabs: tabs, - workspaceGroups: workspaceGroups, - visibleWorkspaceRowIds: visibleWorkspaceRowIds + tabManager.sidebarReorderWorkspaceIds( + forDraggedWorkspaceId: $0, + usesTopLevelRows: dragState.dropIndicatorUsesTopLevelRows ) } ?? [] let renderContext = WorkspaceListRenderContext( @@ -10502,7 +10494,6 @@ struct VerticalTabsSidebar: View { } dragState.clearDrag() isBonsplitWorkspaceDropTargetCollectionActive = false - isWorkspaceReorderDropTargetCollectionActive = 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 @@ -10522,7 +10513,6 @@ struct VerticalTabsSidebar: View { dragFailsafeMonitor.stop() dragState.clearDrag() isBonsplitWorkspaceDropTargetCollectionActive = false - isWorkspaceReorderDropTargetCollectionActive = 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 @@ -10627,6 +10617,23 @@ struct VerticalTabsSidebar: View { .frame(height: sidebarTitlebarInteractionHeight) .background(TitlebarDoubleClickMonitorView()) } + .overlay(alignment: .top) { + if dragState.draggedTabId != nil, let firstWorkspaceId = renderContext.workspaceIds.first { + Color.clear + .contentShape(Rectangle()) + .frame(height: scrollInsets.top + 8) + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( + targetTabId: firstWorkspaceId, + tabManager: tabManager, + workspaceGroupIdByWorkspaceId: renderContext.workspaceGroupIdByWorkspaceId, + dragState: dragState, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + targetRowHeight: nil, + dragAutoScrollController: dragAutoScrollController + )) + } + } .overlay(alignment: .topLeading) { if isMinimalMode { HiddenTitlebarSidebarControlsView( @@ -10660,14 +10667,6 @@ struct VerticalTabsSidebar: View { ) } } - .overlay(alignment: .top) { - workspaceReorderDropOverlay( - renderContext: renderContext, - pointOffset: CGSize(width: 0, height: -scrollInsets.top) - ) - .frame(maxWidth: .infinity) - .frame(height: scrollInsets.top) - } .background(Color.clear) .modifier(ClearScrollBackground()) .onAppear { @@ -11872,20 +11871,12 @@ struct VerticalTabsSidebar: View { renderContext: WorkspaceListRenderContext, minHeight: CGFloat ) -> some View { - let shouldCollectWorkspaceDropTargets = SidebarDropPlanner().shouldCollectWorkspaceDropTargets( - draggedTabId: dragState.draggedTabId, - isBonsplitWorkspaceDropActive: isBonsplitWorkspaceDropTargetCollectionActive || - isWorkspaceReorderDropTargetCollectionActive - ) // Rows stay lazy + pinned top; `.frame(minHeight:)` fills the viewport // (#3241) or scrolls without measuring the LazyVStack. The prior // SidebarRowsFillLayout measured it (`sizeThatFits(height: nil)`) every // pass, realizing all rows and re-livelocking at scale (#2586 / #5764 / // #5845; regressed by #6033). Drop/tap = background; indicator on rows. - let content = workspaceRows( - renderContext: renderContext, - shouldCollectWorkspaceDropTargets: shouldCollectWorkspaceDropTargets - ) + workspaceRows(renderContext: renderContext) .overlay(alignment: .bottom) { if emptyAreaTopDropIndicatorVisible() { Rectangle() @@ -11922,37 +11913,25 @@ struct VerticalTabsSidebar: View { lastSidebarSelectionIndex: $lastSidebarSelectionIndex, dragAutoScrollController: dragAutoScrollController, topDropIndicatorVisible: false, + tabDropDelegate: emptyAreaTabDropDelegate(renderContext: renderContext), bonsplitDropIndicator: dropIndicatorBinding, expandsVertically: true ) } - - return rowsWithGatedDropTargetReader( - rows: content, - renderContext: renderContext, - shouldCollect: shouldCollectWorkspaceDropTargets - ) - .overlay { - workspaceReorderDropOverlay(renderContext: renderContext) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .overlay { - bonsplitWorkspaceDropOverlay() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } } @ViewBuilder - private func workspaceRows( - renderContext: WorkspaceListRenderContext, - shouldCollectWorkspaceDropTargets: Bool - ) -> some View { + private func workspaceRows(renderContext: WorkspaceListRenderContext) -> some View { let renderItems = renderContext.workspaceRenderItems + 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. - LazyVStack(spacing: tabRowSpacing) { + let rows = LazyVStack(spacing: tabRowSpacing) { ForEach(renderItems, id: \.id) { item in switch item { case .groupHeader(let group, let memberWorkspaceIds): @@ -11977,6 +11956,23 @@ struct VerticalTabsSidebar: View { // total height (GeometryReader, or a custom Layout's sizeThatFits) fed a // non-converging relayout loop (#2586 / #5764 / #5845). Fill is handled // by `.frame(minHeight:)` in workspaceScrollContent. + + // 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 @@ -11993,34 +11989,15 @@ struct VerticalTabsSidebar: View { rows .overlayPreferenceValue(SidebarWorkspaceRowFramePreferenceKey.self) { anchors in GeometryReader { proxy in - let workspaceGroupsByAnchor = Dictionary( - uniqueKeysWithValues: renderContext.workspaceGroups.map { ($0.anchorWorkspaceId, $0) } - ) - SidebarWorkspaceDropTargetWriters( - bonsplitTargetBridge: bonsplitWorkspaceDropTargetBridge, - bonsplitTargets: renderContext.tabs.compactMap { tab 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] ) - }, - reorderTargetBridge: workspaceReorderDropTargetBridge, - reorderTargets: renderContext.visibleWorkspaceRowIds.compactMap { workspaceId in - guard let anchor = anchors[workspaceId], - renderContext.workspaceById[workspaceId] != nil else { - return nil - } - let group = workspaceGroupsByAnchor[workspaceId] - let targetGroupId = group?.id ?? - (renderContext.workspaceGroupIdByWorkspaceId[workspaceId] ?? nil) - return SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: workspaceId, - groupId: targetGroupId, - isGroupHeader: group != nil, - frame: proxy[anchor] - ) } ) } @@ -12081,264 +12058,6 @@ struct VerticalTabsSidebar: View { ) } - private func workspaceReorderDropOverlay( - renderContext: WorkspaceListRenderContext, - pointOffset: CGSize = .zero - ) -> some View { - SidebarWorkspaceReorderDropOverlay( - targetBridge: workspaceReorderDropTargetBridge, - isValidDrag: { - activateSidebarWorkspaceDragIfNeeded() - }, - updateDrag: { point, targets in - updateWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) - }, - performDrop: { point, targets in - performWorkspaceReorderDrop(point: point, targets: targets, renderContext: renderContext) - }, - clearDropIndicator: { - dragState.clearDropIndicator() - dragAutoScrollController.stop() - }, - setWorkspaceDropTargetCollectionActive: { isActive in - guard isWorkspaceReorderDropTargetCollectionActive != isActive else { return } - isWorkspaceReorderDropTargetCollectionActive = isActive - }, - pointOffset: pointOffset - ) - } - - private func activateSidebarWorkspaceDragIfNeeded() -> Bool { - if dragState.draggedTabId != nil { - return true - } - guard let foreignId = dragState.currentWorkspaceDragId, - !tabManager.tabs.contains(where: { $0.id == foreignId }), - let sourceManager = AppDelegate.shared?.tabManagerFor(tabId: foreignId), - !sourceManager.workspaceGroups.contains(where: { $0.anchorWorkspaceId == foreignId }) else { - return false - } - dragState.foreignDraggedIsPinned = sourceManager.tabs.first { $0.id == foreignId }?.isPinned ?? false - dragState.draggedTabId = foreignId - return true - } - - private func updateWorkspaceReorderDrop( - point: CGPoint, - targets: [SidebarWorkspaceReorderDropOverlay.Target], - renderContext: WorkspaceListRenderContext - ) -> Bool { - guard activateSidebarWorkspaceDragIfNeeded(), - let plan = workspaceReorderPlan(point: point, targets: targets, renderContext: renderContext) else { - dragState.clearDropIndicator() - return false - } - dragAutoScrollController.updateFromDragLocation() - guard dragState.dropIndicator != plan.indicator || - dragState.dropIndicatorScope != plan.indicatorScope else { - return true - } - dragState.setDropIndicator(plan.indicator, scope: plan.indicatorScope) - return true - } - - private func performWorkspaceReorderDrop( - point: CGPoint, - targets: [SidebarWorkspaceReorderDropOverlay.Target], - renderContext: WorkspaceListRenderContext - ) -> Bool { - defer { - dragState.clearDrag() - dragAutoScrollController.stop() - } - guard activateSidebarWorkspaceDragIfNeeded(), - let plan = workspaceReorderPlan(point: point, targets: targets, renderContext: renderContext) else { - return false - } - return performWorkspaceReorderPlan(plan) - } - - private func workspaceReorderPlan( - point: CGPoint, - targets: [SidebarWorkspaceReorderDropOverlay.Target], - renderContext: WorkspaceListRenderContext - ) -> SidebarWorkspaceReorderDropPlan? { - guard let draggedTabId = dragState.draggedTabId else { return nil } - return SidebarWorkspaceReorderDropResolver().plan( - for: SidebarWorkspaceReorderDropRequest( - point: point, - draggedWorkspaceId: draggedTabId, - foreignDraggedIsPinned: dragState.foreignDraggedIsPinned, - workspaces: renderContext.tabs.map { - SidebarWorkspaceReorderWorkspaceSnapshot( - id: $0.id, - isPinned: $0.isPinned, - groupId: $0.groupId - ) - }, - groups: renderContext.workspaceGroups.map { - SidebarWorkspaceReorderGroupSnapshot( - id: $0.id, - anchorWorkspaceId: $0.anchorWorkspaceId, - isPinned: $0.isPinned - ) - }, - targets: targets.map { - SidebarWorkspaceReorderDropTarget( - workspaceId: $0.workspaceId, - groupId: $0.groupId, - isGroupHeader: $0.isGroupHeader, - frame: $0.frame - ) - } - ) - ) - } - - private func performWorkspaceReorderPlan(_ plan: SidebarWorkspaceReorderDropPlan) -> Bool { - switch plan.action { - case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId): - let selectionBeforeReorder = selectedTabIds - let anchorWorkspaceIdBeforeReorder = SidebarWorkspaceSelectionSyncPolicy().anchorWorkspaceId( - existingAnchorIndex: lastSidebarSelectionIndex, - liveWorkspaceIds: tabManager.tabs.map(\.id) - ) - let didReorder = tabManager.reorderSidebarWorkspace( - tabId: plan.draggedWorkspaceId, - toIndex: targetIndex, - isDragOperation: true, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId - ) - syncSidebarSelectionAfterWorkspaceReorder( - preserving: selectionBeforeReorder, - preferredAnchorWorkspaceId: anchorWorkspaceIdBeforeReorder - ) - return didReorder - case .crossWindow(insertionIndex: _, proposedInsertionIndex: let proposedInsertionIndex): - return performCrossWindowWorkspaceDrop(plan: plan, proposedInsertionIndex: proposedInsertionIndex) - } - } - - private func performCrossWindowWorkspaceDrop( - plan: SidebarWorkspaceReorderDropPlan, - proposedInsertionIndex: Int - ) -> Bool { - guard let app = AppDelegate.shared, - let destinationWindowId = app.windowId(for: tabManager), - let sourceManager = app.tabManagerFor(tabId: plan.draggedWorkspaceId), - !sourceManager.workspaceGroups.contains(where: { $0.anchorWorkspaceId == plan.draggedWorkspaceId }) else { - return false - } - - let sourceSelection = sourceManager.sidebarSelectedWorkspaceIds - let candidateIds: [UUID] - if sourceSelection.contains(plan.draggedWorkspaceId), sourceSelection.count > 1 { - candidateIds = sourceManager.tabs.filter { sourceSelection.contains($0.id) }.map(\.id) - } else { - candidateIds = [plan.draggedWorkspaceId] - } - let sourceAnchorIds = Set(sourceManager.workspaceGroups.map(\.anchorWorkspaceId)) - let movingIds = candidateIds.filter { !sourceAnchorIds.contains($0) } - guard !movingIds.isEmpty else { return false } - - let pinStateById = Dictionary(uniqueKeysWithValues: movingIds.map { id in - (id, sourceManager.tabs.first { $0.id == id }?.isPinned ?? false) - }) - var movedIds: [UUID] = [] - for isPinnedTier in [false, true] { - let tierIds = movingIds.filter { (pinStateById[$0] ?? false) == isPinnedTier } - guard !tierIds.isEmpty else { continue } - let topLevelIds = crossWindowTopLevelWorkspaceIds() - let slot = clampedCrossWindowTopLevelSlot( - proposedInsertionIndex, - draggedIsPinned: isPinnedTier, - topLevelIds: topLevelIds, - pinnedTopLevelIds: crossWindowTopLevelPinnedWorkspaceIds() - ) - let base = crossWindowRawInsertIndex(forTopLevelSlot: slot, topLevelIds: topLevelIds) - var tierOffset = 0 - for workspaceId in tierIds { - if app.moveWorkspaceToWindow( - workspaceId: workspaceId, - windowId: destinationWindowId, - atIndex: base + tierOffset, - focus: false - ) { - movedIds.append(workspaceId) - tierOffset += 1 - } - } - } - - guard !movedIds.isEmpty else { return false } - let focusId = movedIds.contains(plan.draggedWorkspaceId) ? plan.draggedWorkspaceId : (movedIds.last ?? plan.draggedWorkspaceId) - _ = app.moveWorkspaceToWindow(workspaceId: focusId, windowId: destinationWindowId, focus: true) - selectedTabIds = Set(movedIds) - if let selectedId = tabManager.selectedTabId { - lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } - } else { - lastSidebarSelectionIndex = nil - } - return true - } - - private func clampedCrossWindowTopLevelSlot( - _ proposedSlot: Int, - draggedIsPinned: Bool, - topLevelIds: [UUID], - pinnedTopLevelIds: Set - ) -> Int { - let clampedSlot = max(0, min(proposedSlot, topLevelIds.count)) - let pinnedCount = topLevelIds.reduce(into: 0) { count, workspaceId in - if pinnedTopLevelIds.contains(workspaceId) { - count += 1 - } - } - return draggedIsPinned ? min(clampedSlot, pinnedCount) : max(clampedSlot, pinnedCount) - } - - private func crossWindowTopLevelWorkspaceIds() -> [UUID] { - tabManager.sidebarReorderWorkspaceIds( - forDraggedWorkspaceId: nil, - targetWorkspaceId: nil, - usesTopLevelRows: true - ) - } - - private func crossWindowTopLevelPinnedWorkspaceIds() -> Set { - tabManager.sidebarReorderPinnedWorkspaceIds( - forDraggedWorkspaceId: nil, - targetWorkspaceId: nil, - usesTopLevelRows: true - ) - } - - private func crossWindowRawInsertIndex(forTopLevelSlot slot: Int, topLevelIds: [UUID]) -> Int { - guard slot < topLevelIds.count else { return tabManager.tabs.count } - let topLevelId = topLevelIds[slot] - return tabManager.tabs.firstIndex { $0.id == topLevelId } ?? tabManager.tabs.count - } - - private func syncSidebarSelectionAfterWorkspaceReorder( - preserving previousSelectionIds: Set, - preferredAnchorWorkspaceId: UUID? - ) { - let liveWorkspaceIds = tabManager.tabs.map(\.id) - let nextSelectionIds = SidebarWorkspaceSelectionSyncPolicy().reconciledSelection( - previousSelectionIds: previousSelectionIds, - liveWorkspaceIds: liveWorkspaceIds, - fallbackSelectedWorkspaceId: tabManager.selectedTabId - ) - selectedTabIds = nextSelectionIds - lastSidebarSelectionIndex = SidebarWorkspaceSelectionSyncPolicy().anchorIndexAfterWorkspaceReorder( - preferredAnchorWorkspaceId: preferredAnchorWorkspaceId, - selectedWorkspaceIds: nextSelectionIds, - focusedWorkspaceId: tabManager.selectedTabId, - liveWorkspaceIds: liveWorkspaceIds - ) - } - @ViewBuilder private func workspaceRow( _ tab: Workspace, @@ -12404,13 +12123,6 @@ struct VerticalTabsSidebar: View { dropIndicator: dragState.dropIndicator, tabIds: sidebarReorderIds ) - let bottomDropIndicatorVisible = SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: tab.id, - draggedTabId: dragState.draggedTabId, - dropIndicator: dragState.dropIndicator, - tabIds: sidebarReorderIds, - indicatorScope: dragState.dropIndicatorScope - ) let onDragStart: () -> NSItemProvider = { [tabId = tab.id] in #if DEBUG cmuxDebugLog("sidebar.onDrag tab=\(tabId.uuidString.prefix(5))") @@ -12418,6 +12130,23 @@ struct VerticalTabsSidebar: View { dragState.beginDragging(tabId: tabId) return SidebarTabDragPayload.provider(for: tabId) } + let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate = { [ + tabId = tab.id, + selectedTabIds = $selectedTabIds, + lastSidebarSelectionIndex = $lastSidebarSelectionIndex + ] rowHeight in + SidebarTabDropDelegate( + targetTabId: tabId, + tabManager: tabManager, + workspaceGroupIdByWorkspaceId: renderContext.workspaceGroupIdByWorkspaceId, + dragState: dragState, + selectedTabIds: selectedTabIds, + lastSidebarSelectionIndex: lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController + ) + } + let row = TabItemView( tabManager: tabManager, notificationStore: notificationStore, @@ -12440,8 +12169,8 @@ struct VerticalTabsSidebar: View { dragAutoScrollController: dragAutoScrollController, isBeingDragged: isBeingDragged, topDropIndicatorVisible: topDropIndicatorVisible, - bottomDropIndicatorVisible: bottomDropIndicatorVisible, onDragStart: onDragStart, + tabDropDelegateFactory: tabDropDelegateFactory, contextMenuWorkspaceIds: contextMenuWorkspaceIds, remoteContextMenuWorkspaceIds: remoteContextMenuWorkspaceIds, allRemoteContextMenuTargetsConnecting: allRemoteContextMenuTargetsConnecting, @@ -12459,8 +12188,6 @@ struct VerticalTabsSidebar: View { row .sidebarWorkspaceFrameAnchor(id: tab.id, isEnabled: shouldCollectWorkspaceDropTargets) .padding(.leading, tab.groupId != nil ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) } private func debugShortSidebarTabId(_ id: UUID?) -> String { @@ -12502,9 +12229,7 @@ struct SidebarWorkspaceRowFramePreferenceKey: PreferenceKey { @MainActor private final class SidebarDragFailsafeMonitor: ObservableObject { private static let escapeKeyCode: UInt16 = 53 - // One-shot timer bridges synchronous AppKit event monitors to a cancellable drag-teardown deadline. - private var pendingClearTimer: DispatchSourceTimer? - private var pendingClearGeneration: UInt64 = 0 + private var pendingClearWorkItem: DispatchWorkItem? private var appResignObserver: NSObjectProtocol? private var keyDownMonitor: Any? private var localMouseMonitor: Any? @@ -12559,9 +12284,8 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { } func stop() { - pendingClearGeneration &+= 1 - pendingClearTimer?.cancel() - pendingClearTimer = nil + pendingClearWorkItem?.cancel() + pendingClearWorkItem = nil if let appResignObserver { NotificationCenter.default.removeObserver(appResignObserver) self.appResignObserver = nil @@ -12582,26 +12306,19 @@ private final class SidebarDragFailsafeMonitor: ObservableObject { } private func requestClearSoon(reason: String) { - guard pendingClearTimer == nil else { return } + guard pendingClearWorkItem == nil else { return } #if DEBUG cmuxDebugLog("sidebar.dragFailsafe.schedule reason=\(reason)") #endif - let timer = DispatchSource.makeTimerSource(queue: .main) - pendingClearGeneration &+= 1 - let generation = pendingClearGeneration - timer.schedule(deadline: .now() + SidebarDragFailsafePolicy.clearDelay) - timer.setEventHandler { [weak self] in - Task { @MainActor [weak self] in - guard let self, self.pendingClearGeneration == generation else { return } + let workItem = DispatchWorkItem { [weak self] in #if DEBUG - cmuxDebugLog("sidebar.dragFailsafe.fire reason=\(reason)") + cmuxDebugLog("sidebar.dragFailsafe.fire reason=\(reason)") #endif - self.pendingClearTimer = nil - self.onRequestClear?(reason) - } + self?.pendingClearWorkItem = nil + self?.onRequestClear?(reason) } - pendingClearTimer = timer - timer.resume() + pendingClearWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + SidebarDragFailsafePolicy.clearDelay, execute: workItem) } } @@ -13051,36 +12768,13 @@ private struct SidebarEmptyArea: View { // Value snapshot + closure bundles instead of an @Observable store // reference (snapshot-boundary rule). let topDropIndicatorVisible: Bool - var tabDropDelegate: SidebarTabDropDelegate? = nil + let tabDropDelegate: SidebarTabDropDelegate let bonsplitDropIndicator: Binding var expandsVertically = true var minimumHeight: CGFloat? = nil var body: some View { - dropTarget - .overlay { - SidebarBonsplitTabNewWorkspaceDropOverlay( - tabManager: tabManager, - selectedTabIds: $selectedTabIds, - lastSidebarSelectionIndex: $lastSidebarSelectionIndex, - dropIndicator: bonsplitDropIndicator - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .overlay(alignment: .top) { - if topDropIndicatorVisible { - Rectangle() - .fill(cmuxAccentColor()) - .frame(height: 2) - .padding(.horizontal, 8) - .offset(y: -(rowSpacing / 2)) - } - } - } - - @ViewBuilder - private var dropTarget: some View { - let base = hitTarget + hitTarget .onTapGesture(count: 2) { // When the active workspace is a remote-tmux mirror, route through // performNewWorkspaceAction so a new workspace becomes a new tmux @@ -13103,11 +12797,25 @@ private struct SidebarEmptyArea: View { } selection = .tabs } - if let tabDropDelegate { - base.onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegate) - } else { - base - } + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegate) + .overlay { + SidebarBonsplitTabNewWorkspaceDropOverlay( + tabManager: tabManager, + selectedTabIds: $selectedTabIds, + lastSidebarSelectionIndex: $lastSidebarSelectionIndex, + dropIndicator: bonsplitDropIndicator + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .overlay(alignment: .top) { + if topDropIndicatorVisible { + Rectangle() + .fill(cmuxAccentColor()) + .frame(height: 2) + .padding(.horizontal, 8) + .offset(y: -(rowSpacing / 2)) + } + } } @ViewBuilder @@ -13261,7 +12969,6 @@ struct TabItemView: View, Equatable { lhs.workspaceGroupMenuSnapshot == rhs.workspaceGroupMenuSnapshot && lhs.isBeingDragged == rhs.isBeingDragged && lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible && - lhs.bottomDropIndicatorVisible == rhs.bottomDropIndicatorVisible && lhs.settings == rhs.settings } @@ -13302,8 +13009,11 @@ struct TabItemView: View, Equatable { // unchanged. let isBeingDragged: Bool let topDropIndicatorVisible: Bool - let bottomDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider + /// Factory invoked from `body` with the row's measured `rowHeight`. Closure + /// captures the parent's `dragState`, so TabItemView itself never holds an + /// `@Observable` store reference (snapshot-boundary rule). + let tabDropDelegateFactory: (CGFloat) -> SidebarTabDropDelegate let contextMenuWorkspaceIds: [UUID] let remoteContextMenuWorkspaceIds: [UUID] let allRemoteContextMenuTargetsConnecting: Bool @@ -14083,14 +13793,6 @@ struct TabItemView: View, Equatable { rowSpacing: rowSpacing ) } - .overlay(alignment: .bottom) { - SidebarWorkspaceTopDropIndicator( - isVisible: bottomDropIndicatorVisible, - isFirstRow: false, - rowSpacing: rowSpacing, - isBottomEdge: true - ) - } .onAppear { updateObservedActiveState(tabManager.selectedTabId == tab.id) refreshWorkspaceSnapshot(force: true) @@ -14149,6 +13851,7 @@ struct TabItemView: View, Equatable { } .onDrag(onDragStart) .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( targetWorkspaceId: tab.id, tabManager: tabManager, @@ -15931,15 +15634,15 @@ struct SidebarTabDropDelegate: DropDelegate { #if DEBUG cmuxDebugLog("sidebar.dropExited target=\(targetTabId?.uuidString.prefix(5) ?? "end")") #endif - // SwiftUI can emit row exits while a valid drag is still over the - // sidebar, especially after indicator state invalidates row overlays. - // Hover updates and drag-end own indicator changes. + if dragState.dropIndicator?.tabId == targetTabId { + dragState.clearDropIndicator() + } } func dropUpdated(info: DropInfo) -> DropProposal? { activateForeignDragIfNeeded() dragAutoScrollController.updateFromDragLocation() - updateDropIndicator(pointerX: info.location.x, pointerY: plannerPointerY(for: info)) + updateDropIndicator(for: info) #if DEBUG cmuxDebugLog( "sidebar.dropUpdated target=\(targetTabId?.uuidString.prefix(5) ?? "end") " + @@ -15950,18 +15653,8 @@ struct SidebarTabDropDelegate: DropDelegate { } func performDrop(info: DropInfo) -> Bool { - performDrop( - pointerX: info.location.x, - pointerY: plannerPointerY(for: info), - shouldClearDrag: true - ) - } - - func performDrop(pointerX: CGFloat, pointerY: CGFloat?, shouldClearDrag: Bool = true) -> Bool { defer { - if shouldClearDrag { - dragState.clearDrag() - } + dragState.clearDrag() dragAutoScrollController.stop() } #if DEBUG @@ -15976,33 +15669,25 @@ struct SidebarTabDropDelegate: DropDelegate { if isCrossWindowDrag(draggedTabId) { return performCrossWindowDrop(draggedTabId: draggedTabId) } - let defaultUsesTopLevelRows = tabManager.sidebarReorderUsesTopLevelRows( + let usesTopLevelRows = tabManager.sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId: draggedTabId, targetWorkspaceId: targetTabId, workspaceGroupIdByWorkspaceId: workspaceGroupIdByWorkspaceId ) - let explicitGroupId: UUID? = nil - let usesTopLevelRows = usesTopLevelRowsForDrop( - draggedTabId: draggedTabId, - explicitGroupId: explicitGroupId, - defaultUsesTopLevelRows: defaultUsesTopLevelRows - ) - let plannerTargetTabId = plannerTargetTabId(usesTopLevelRows: usesTopLevelRows) let reorderTabIds = tabManager.sidebarReorderWorkspaceIds( forDraggedWorkspaceId: draggedTabId, - targetWorkspaceId: plannerTargetTabId, + targetWorkspaceId: targetTabId, usesTopLevelRows: usesTopLevelRows ) let pinnedTabIds = tabManager.sidebarReorderPinnedWorkspaceIds( forDraggedWorkspaceId: draggedTabId, - targetWorkspaceId: plannerTargetTabId, + targetWorkspaceId: targetTabId, usesTopLevelRows: usesTopLevelRows ) let legalInsertionRange = tabManager.sidebarReorderLegalInsertionRange( forDraggedWorkspaceId: draggedTabId, - targetWorkspaceId: plannerTargetTabId, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId + targetWorkspaceId: targetTabId, + usesTopLevelRows: usesTopLevelRows ) guard let fromIndex = reorderTabIds.firstIndex(of: draggedTabId) else { #if DEBUG @@ -16012,7 +15697,7 @@ struct SidebarTabDropDelegate: DropDelegate { } guard let targetIndex = SidebarDropPlanner().targetIndex( draggedTabId: draggedTabId, - targetTabId: plannerTargetTabId, + targetTabId: targetTabId, indicator: dragState.dropIndicator, tabIds: reorderTabIds, pinnedTabIds: pinnedTabIds, @@ -16027,7 +15712,7 @@ struct SidebarTabDropDelegate: DropDelegate { return false } - guard fromIndex != targetIndex || explicitGroupId != nil else { + guard fromIndex != targetIndex else { #if DEBUG cmuxDebugLog("sidebar.drop.noop from=\(fromIndex) to=\(targetIndex)") #endif @@ -16046,8 +15731,7 @@ struct SidebarTabDropDelegate: DropDelegate { tabId: draggedTabId, toIndex: targetIndex, isDragOperation: true, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId + usesTopLevelRows: usesTopLevelRows ) syncSidebarSelection( preserving: selectionBeforeReorder, @@ -16056,44 +15740,6 @@ struct SidebarTabDropDelegate: DropDelegate { return didReorder } - private func usesTopLevelRowsForDrop( - draggedTabId: UUID?, - explicitGroupId: UUID?, - defaultUsesTopLevelRows: Bool - ) -> Bool { - guard explicitGroupId == nil else { return false } - guard !defaultUsesTopLevelRows else { return true } - guard let draggedTabId, - tabManager.tabs.contains(where: { $0.id == draggedTabId }), - let targetTabId, - let targetGroupId = workspaceGroupIdByWorkspaceId[targetTabId] ?? nil, - let group = tabManager.workspaceGroups.first(where: { $0.id == targetGroupId }), - group.anchorWorkspaceId != targetTabId else { - return false - } - return true - } - - private func plannerTargetTabId(usesTopLevelRows: Bool) -> UUID? { - guard usesTopLevelRows, - let targetTabId, - let targetGroupId = workspaceGroupIdByWorkspaceId[targetTabId] ?? nil, - let group = tabManager.workspaceGroups.first(where: { $0.id == targetGroupId }), - group.anchorWorkspaceId != targetTabId else { - return targetTabId - } - return group.anchorWorkspaceId - } - - private func plannerPointerY(for info: DropInfo) -> CGFloat? { - return plannerPointerY(pointerY: info.location.y) - } - - private func plannerPointerY(pointerY: CGFloat?) -> CGFloat? { - guard targetTabId != nil else { return nil } - return pointerY - } - /// Move a workspace dragged in from another window into this window at the /// indicated drop position. Mirrors the existing "Move Workspace to Window" /// action but honors the drop index and multi-selection. @@ -16186,52 +15832,39 @@ struct SidebarTabDropDelegate: DropDelegate { } private func updateDropIndicator(for info: DropInfo) { - updateDropIndicator(pointerX: info.location.x, pointerY: plannerPointerY(for: info)) - } - - func updateDropIndicator(pointerX: CGFloat, pointerY: CGFloat?) { if let draggedTabId = effectiveDraggedTabId, isCrossWindowDrag(draggedTabId) { - updateCrossWindowDropIndicator(pointerY: pointerY) + updateCrossWindowDropIndicator(for: info) return } - let defaultUsesTopLevelRows = tabManager.sidebarReorderUsesTopLevelRows( + let usesTopLevelRows = tabManager.sidebarReorderUsesTopLevelRows( forDraggedWorkspaceId: dragState.draggedTabId, targetWorkspaceId: targetTabId, workspaceGroupIdByWorkspaceId: workspaceGroupIdByWorkspaceId ) - let explicitGroupId: UUID? = nil - let usesTopLevelRows = usesTopLevelRowsForDrop( - draggedTabId: dragState.draggedTabId, - explicitGroupId: explicitGroupId, - defaultUsesTopLevelRows: defaultUsesTopLevelRows - ) - let plannerTargetTabId = plannerTargetTabId(usesTopLevelRows: usesTopLevelRows) let tabIds = tabManager.sidebarReorderWorkspaceIds( forDraggedWorkspaceId: dragState.draggedTabId, - targetWorkspaceId: plannerTargetTabId, + targetWorkspaceId: targetTabId, usesTopLevelRows: usesTopLevelRows ) let pinnedTabIds = tabManager.sidebarReorderPinnedWorkspaceIds( forDraggedWorkspaceId: dragState.draggedTabId, - targetWorkspaceId: plannerTargetTabId, + targetWorkspaceId: targetTabId, usesTopLevelRows: usesTopLevelRows ) let legalInsertionRange = tabManager.sidebarReorderLegalInsertionRange( forDraggedWorkspaceId: dragState.draggedTabId, - targetWorkspaceId: plannerTargetTabId, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId + targetWorkspaceId: targetTabId, + usesTopLevelRows: usesTopLevelRows ) - let plannedIndicator = SidebarDropPlanner().indicator( + let nextIndicator = SidebarDropPlanner().indicator( draggedTabId: dragState.draggedTabId, - targetTabId: plannerTargetTabId, + targetTabId: targetTabId, tabIds: tabIds, pinnedTabIds: pinnedTabIds, legalInsertionRange: legalInsertionRange, - pointerY: pointerY, + pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ) - let nextIndicator = plannedIndicator let nextUsesTopLevelRows = nextIndicator != nil && usesTopLevelRows guard dragState.dropIndicator != nextIndicator || dragState.dropIndicatorUsesTopLevelRows != nextUsesTopLevelRows else { @@ -16243,7 +15876,7 @@ struct SidebarTabDropDelegate: DropDelegate { /// Drop indicator for a foreign workspace hovering this window. The dragged /// workspace is not in this window's list, so the reorder planner (which /// removes a source index) does not apply — use the cross-window planner. - private func updateCrossWindowDropIndicator(pointerY: CGFloat?) { + private func updateCrossWindowDropIndicator(for info: DropInfo) { // Reuse the pin state stashed when the foreign drag was mirrored in, // avoiding a per-pointer-move cross-window lookup. let draggedIsPinned = dragState.foreignDraggedIsPinned ?? false @@ -16255,7 +15888,7 @@ struct SidebarTabDropDelegate: DropDelegate { indicator: nil, tabIds: crossWindowTopLevelTabIds(), pinnedTabIds: crossWindowTopLevelPinnedTabIds(), - pointerY: targetTabId == nil ? nil : pointerY, + pointerY: targetTabId == nil ? nil : info.location.y, targetHeight: targetRowHeight ).indicator let usesTopLevelRows = !tabManager.workspaceGroups.isEmpty diff --git a/Sources/SidebarWorkspaceDropTargetWriters.swift b/Sources/SidebarWorkspaceDropTargetWriters.swift deleted file mode 100644 index 67a3682e04bd..000000000000 --- a/Sources/SidebarWorkspaceDropTargetWriters.swift +++ /dev/null @@ -1,27 +0,0 @@ -import CmuxFoundation -import SwiftUI - -struct SidebarWorkspaceDropTargetWriters: View { - let bonsplitTargetBridge: SidebarBonsplitTabWorkspaceDropOverlay.TargetBridge - let bonsplitTargets: [SidebarDropPlanner.WorkspaceDropTarget] - let reorderTargetBridge: SidebarWorkspaceReorderDropOverlay.TargetBridge - let reorderTargets: [SidebarWorkspaceReorderDropOverlay.Target] - - var body: some View { - Color.clear - .onAppear { - bonsplitTargetBridge.updateTargets(bonsplitTargets) - reorderTargetBridge.updateTargets(reorderTargets) - } - .onChange(of: bonsplitTargets) { _, targets in - bonsplitTargetBridge.updateTargets(targets) - } - .onChange(of: reorderTargets) { _, targets in - reorderTargetBridge.updateTargets(targets) - } - .onDisappear { - bonsplitTargetBridge.updateTargets([]) - reorderTargetBridge.updateTargets([]) - } - } -} diff --git a/Sources/SidebarWorkspaceGroupHeaderDropDelegate.swift b/Sources/SidebarWorkspaceGroupHeaderDropDelegate.swift new file mode 100644 index 000000000000..6af8c1c4ea96 --- /dev/null +++ b/Sources/SidebarWorkspaceGroupHeaderDropDelegate.swift @@ -0,0 +1,194 @@ +import AppKit +import CmuxAppKitSupportUI +import CmuxFoundation +import CmuxSidebar +import SwiftUI +import CmuxSettings + +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, + 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() + } +} diff --git a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift index c786c0c0003b..ebf0ef9efb02 100644 --- a/Sources/SidebarWorkspaceGroupHeaderMetrics.swift +++ b/Sources/SidebarWorkspaceGroupHeaderMetrics.swift @@ -55,9 +55,6 @@ struct SidebarWorkspaceGroupHeaderMetrics: Equatable { /// Plus-button frame edge at the default sidebar font size. This matches the /// normal workspace close-button frame so their centers share the same x-position. static let basePlusFrame: CGFloat = 16 - /// Leading inset for a header-bottom drop indicator whose resolved scope is - /// the group's member list rather than the root hierarchy. - static let memberScopedDropIndicatorLeadingInset: CGFloat = SidebarWorkspaceGroupingMetrics.memberIndent /// Scaled chevron glyph point size. var chevronFontSize: CGFloat { Self.baseChevronFontSize * fontScale } @@ -79,11 +76,4 @@ struct SidebarWorkspaceGroupHeaderMetrics: Equatable { var plusFontSize: CGFloat { Self.basePlusFontSize * fontScale } /// Scaled plus-button frame edge. var plusFrame: CGFloat { max(Self.basePlusFrame, Self.basePlusFrame * fontScale) } - /// Leading inset for a group-scoped append indicator rendered by the header. - var groupScopedBottomDropIndicatorLeadingInset: CGFloat { Self.memberScopedDropIndicatorLeadingInset } - /// Stable drop-hit height for the group header, without reading SwiftUI layout. - var dropTargetHeight: CGFloat { - let contentHeight = max(chevronFrame, iconFrame, plusFrame, nameFontSize + 4) - return max(24 * fontScale, contentHeight + 10) - } } diff --git a/Sources/SidebarWorkspaceGroupHeaderView.swift b/Sources/SidebarWorkspaceGroupHeaderView.swift index c9fe936bcc8f..7ddb86ac165f 100644 --- a/Sources/SidebarWorkspaceGroupHeaderView.swift +++ b/Sources/SidebarWorkspaceGroupHeaderView.swift @@ -37,8 +37,7 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { lhs.rowSpacing == rhs.rowSpacing && lhs.isFirstRow == rhs.isFirstRow && lhs.isBeingDragged == rhs.isBeingDragged && - lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible && - lhs.bottomDropIndicatorVisible == rhs.bottomDropIndicatorVisible + lhs.topDropIndicatorVisible == rhs.topDropIndicatorVisible } let groupId: UUID @@ -68,8 +67,8 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { let isFirstRow: Bool let isBeingDragged: Bool let topDropIndicatorVisible: Bool - let bottomDropIndicatorVisible: Bool let onDragStart: () -> NSItemProvider + let tabDropDelegateFactory: (CGFloat) -> SidebarWorkspaceGroupHeaderDropDelegate let onToggleCollapsed: () -> Void let onFocusAnchor: () -> Void let onTapPlus: () -> Void @@ -258,20 +257,12 @@ struct SidebarWorkspaceGroupHeaderView: View, Equatable { rowSpacing: rowSpacing ) } - .overlay(alignment: .bottom) { - SidebarWorkspaceTopDropIndicator( - isVisible: bottomDropIndicatorVisible, - isFirstRow: false, - rowSpacing: rowSpacing, - isBottomEdge: true, - leadingInset: metrics.groupScopedBottomDropIndicatorLeadingInset - ) - } .overlay { SidebarWorkspaceRowHoverTracker(rowInteractionState: $rowInteractionState) } .onDrag(onDragStart) .internalOnlyTabDrag() + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: tabDropDelegateFactory(rowHeight)) .contextMenu { Button( String( diff --git a/Sources/SidebarWorkspaceReorderDropOverlay.swift b/Sources/SidebarWorkspaceReorderDropOverlay.swift deleted file mode 100644 index 912656d2f588..000000000000 --- a/Sources/SidebarWorkspaceReorderDropOverlay.swift +++ /dev/null @@ -1,68 +0,0 @@ -import AppKit -import SwiftUI - -struct SidebarWorkspaceReorderDropOverlay: NSViewRepresentable { - typealias Target = SidebarWorkspaceReorderDropOverlayTarget - typealias TargetBridge = SidebarWorkspaceReorderDropOverlayTargetBridge - typealias DropView = SidebarWorkspaceReorderDropView - - let targetBridge: TargetBridge - let isValidDrag: () -> Bool - let updateDrag: (CGPoint, [Target]) -> Bool - let performDrop: (CGPoint, [Target]) -> Bool - let clearDropIndicator: () -> Void - let setWorkspaceDropTargetCollectionActive: (Bool) -> Void - let pointOffset: CGSize - - init( - targetBridge: TargetBridge, - isValidDrag: @escaping () -> Bool, - updateDrag: @escaping (CGPoint, [Target]) -> Bool, - performDrop: @escaping (CGPoint, [Target]) -> Bool, - clearDropIndicator: @escaping () -> Void, - setWorkspaceDropTargetCollectionActive: @escaping (Bool) -> Void, - pointOffset: CGSize = .zero - ) { - self.targetBridge = targetBridge - self.isValidDrag = isValidDrag - self.updateDrag = updateDrag - self.performDrop = performDrop - self.clearDropIndicator = clearDropIndicator - self.setWorkspaceDropTargetCollectionActive = setWorkspaceDropTargetCollectionActive - self.pointOffset = pointOffset - } - - func makeNSView(context: Context) -> DropView { - let view = DropView() - view.registerForDraggedTypes([Self.pasteboardType]) - update(view) - targetBridge.attach(view) - return view - } - - func updateNSView(_ nsView: DropView, context: Context) { - update(nsView) - targetBridge.attach(nsView) - } - - private func update(_ view: DropView) { - view.isValidDrag = isValidDrag - view.updateDrag = updateDrag - view.performDropAtPoint = performDrop - view.clearDropIndicator = clearDropIndicator - view.setWorkspaceDropTargetCollectionActive = setWorkspaceDropTargetCollectionActive - view.pointOffset = pointOffset - } - - static let pasteboardType = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - - static func shouldCaptureHitTest( - eventType: NSEvent.EventType?, - pasteboardTypes: [NSPasteboard.PasteboardType]? - ) -> Bool { - guard WindowInputRoutingContext.allowsWorkspaceDropOverlayHitTesting(eventType: eventType) else { - return false - } - return pasteboardTypes?.contains(pasteboardType) == true - } -} diff --git a/Sources/SidebarWorkspaceReorderDropOverlayTarget.swift b/Sources/SidebarWorkspaceReorderDropOverlayTarget.swift deleted file mode 100644 index 59fe58ea17a7..000000000000 --- a/Sources/SidebarWorkspaceReorderDropOverlayTarget.swift +++ /dev/null @@ -1,9 +0,0 @@ -import CoreGraphics -import Foundation - -struct SidebarWorkspaceReorderDropOverlayTarget: Equatable { - let workspaceId: UUID - let groupId: UUID? - let isGroupHeader: Bool - let frame: CGRect -} diff --git a/Sources/SidebarWorkspaceReorderDropOverlayTargetBridge.swift b/Sources/SidebarWorkspaceReorderDropOverlayTargetBridge.swift deleted file mode 100644 index a209348e34c8..000000000000 --- a/Sources/SidebarWorkspaceReorderDropOverlayTargetBridge.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -@MainActor -final class SidebarWorkspaceReorderDropOverlayTargetBridge { - private let views = NSHashTable.weakObjects() - private var targets: [SidebarWorkspaceReorderDropOverlayTarget] = [] - - func attach(_ view: SidebarWorkspaceReorderDropView) { - views.add(view) - view.targets = targets - } - - func updateTargets(_ targets: [SidebarWorkspaceReorderDropOverlayTarget]) { - self.targets = targets - for view in views.allObjects { - view.targets = targets - view.targetsDidUpdate() - } - } -} diff --git a/Sources/SidebarWorkspaceReorderDropView.swift b/Sources/SidebarWorkspaceReorderDropView.swift deleted file mode 100644 index 5235588779a5..000000000000 --- a/Sources/SidebarWorkspaceReorderDropView.swift +++ /dev/null @@ -1,162 +0,0 @@ -import AppKit - -@MainActor -final class SidebarWorkspaceReorderDropView: NSView { - var targets: [SidebarWorkspaceReorderDropOverlay.Target] = [] - var isValidDrag: (() -> Bool)? - var updateDrag: ((CGPoint, [SidebarWorkspaceReorderDropOverlay.Target]) -> Bool)? - var performDropAtPoint: ((CGPoint, [SidebarWorkspaceReorderDropOverlay.Target]) -> Bool)? - var clearDropIndicator: (() -> Void)? - var setWorkspaceDropTargetCollectionActive: ((Bool) -> Void)? - var pointOffset: CGSize = .zero - private var isRequestingTargets = false - private var targetRequestId: UInt64 = 0 - private var pendingDrop: SidebarWorkspaceReorderPendingDrop? - private var awaitsTargetsAfterDragTeardown = false - - override var isFlipped: Bool { true } - - override init(frame frameRect: NSRect) { - super.init(frame: frameRect) - wantsLayer = false - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func hitTest(_ point: NSPoint) -> NSView? { - guard shouldCaptureHitTest() else { return nil } - return super.hitTest(point) - } - - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { - setTargetCollectionActive(true) - return update(sender) - } - - override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { - setTargetCollectionActive(true) - return update(sender) - } - - override func draggingExited(_ sender: NSDraggingInfo?) { - guard pendingDrop == nil else { - completeOrClearPendingDropAfterDragTeardown() - clearDropIndicator?() - return - } - setTargetCollectionActive(false) - clearDropIndicator?() - } - - override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { - guard accepts(sender), let performDropAtPoint else { return false } - let point = dropPoint(from: sender) - guard !targets.isEmpty else { - setTargetCollectionActive(true) - awaitsTargetsAfterDragTeardown = false - pendingDrop = SidebarWorkspaceReorderPendingDrop(requestId: targetRequestId, point: point) - return true - } - let performed = performDropAtPoint(point, targets) - pendingDrop = nil - setTargetCollectionActive(false) - if !performed { - clearDropIndicator?() - } - return performed - } - - override func concludeDragOperation(_ sender: NSDraggingInfo?) { - guard pendingDrop == nil else { - completeOrClearPendingDropAfterDragTeardown() - clearDropIndicator?() - return - } - setTargetCollectionActive(false) - } - - func performPendingDropIfPossible() { - guard let pendingDrop, - pendingDrop.requestId == targetRequestId, - isRequestingTargets, - !targets.isEmpty, - let performDropAtPoint else { - return - } - self.pendingDrop = nil - awaitsTargetsAfterDragTeardown = false - let performed = performDropAtPoint(pendingDrop.point, targets) - setTargetCollectionActive(false) - if !performed { - clearDropIndicator?() - } - } - - func targetsDidUpdate() { - guard pendingDrop != nil else { return } - guard !targets.isEmpty else { - clearPendingDropAfterEmptyTargetCollectionIfNeeded() - return - } - performPendingDropIfPossible() - } - - private func completeOrClearPendingDropAfterDragTeardown() { - awaitsTargetsAfterDragTeardown = pendingDrop != nil - } - - private func clearPendingDropAfterEmptyTargetCollectionIfNeeded() { - guard awaitsTargetsAfterDragTeardown else { return } - awaitsTargetsAfterDragTeardown = false - setTargetCollectionActive(false) - clearDropIndicator?() - } - - private func update(_ sender: NSDraggingInfo) -> NSDragOperation { - guard accepts(sender), let updateDrag else { return [] } - guard !targets.isEmpty else { - clearDropIndicator?() - return .move - } - let point = dropPoint(from: sender) - return updateDrag(point, targets) ? .move : [] - } - - func dropPoint(from sender: NSDraggingInfo) -> CGPoint { - let point = convert(sender.draggingLocation, from: nil) - return CGPoint(x: point.x + pointOffset.width, y: point.y + pointOffset.height) - } - - private func setTargetCollectionActive(_ isActive: Bool) { - guard isRequestingTargets != isActive else { return } - if isActive, !isRequestingTargets { - targetRequestId &+= 1 - } - if !isActive { - pendingDrop = nil - awaitsTargetsAfterDragTeardown = false - } - isRequestingTargets = isActive - setWorkspaceDropTargetCollectionActive?(isActive) - } - - private func accepts(_ sender: NSDraggingInfo) -> Bool { - guard sender.draggingPasteboard.types?.contains(SidebarWorkspaceReorderDropOverlay.pasteboardType) == true else { - return false - } - return isValidDrag?() == true - } - - private func acceptsCurrentDragPasteboard() -> Bool { - SidebarWorkspaceReorderDropOverlay.shouldCaptureHitTest( - eventType: NSApp.currentEvent?.type, - pasteboardTypes: NSPasteboard(name: .drag).types - ) - } - - private func shouldCaptureHitTest() -> Bool { - acceptsCurrentDragPasteboard() - } -} diff --git a/Sources/SidebarWorkspaceReorderPendingDrop.swift b/Sources/SidebarWorkspaceReorderPendingDrop.swift deleted file mode 100644 index 47c13d82b78d..000000000000 --- a/Sources/SidebarWorkspaceReorderPendingDrop.swift +++ /dev/null @@ -1,6 +0,0 @@ -import CoreGraphics - -struct SidebarWorkspaceReorderPendingDrop { - let requestId: UInt64 - let point: CGPoint -} diff --git a/Sources/SidebarWorkspaceTopDropIndicator.swift b/Sources/SidebarWorkspaceTopDropIndicator.swift deleted file mode 100644 index 63cc81b05f19..000000000000 --- a/Sources/SidebarWorkspaceTopDropIndicator.swift +++ /dev/null @@ -1,40 +0,0 @@ -import SwiftUI - -struct SidebarWorkspaceTopDropIndicator: View { - let isVisible: Bool - let isFirstRow: Bool - let rowSpacing: CGFloat - let isBottomEdge: Bool - let leadingInset: CGFloat - - init( - isVisible: Bool, - isFirstRow: Bool, - rowSpacing: CGFloat, - isBottomEdge: Bool = false, - leadingInset: CGFloat = 0 - ) { - self.isVisible = isVisible - self.isFirstRow = isFirstRow - self.rowSpacing = rowSpacing - self.isBottomEdge = isBottomEdge - self.leadingInset = leadingInset - } - - var body: some View { - if isVisible { - Rectangle() - .fill(cmuxAccentColor()) - .frame(height: 2) - .padding(.leading, Self.horizontalPadding + max(leadingInset, 0)) - .padding(.trailing, Self.horizontalPadding) - .offset(y: indicatorOffset) - } - } - - private static let horizontalPadding: CGFloat = 8 - - private var indicatorOffset: CGFloat { - isBottomEdge ? rowSpacing / 2 : (isFirstRow ? 0 : -(rowSpacing / 2)) - } -} diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index a017d59af73c..8d546e8dd463 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -1579,14 +1579,12 @@ class TabManager: ObservableObject { func sidebarReorderLegalInsertionRange( forDraggedWorkspaceId draggedWorkspaceId: UUID?, targetWorkspaceId: UUID? = nil, - usesTopLevelRows: Bool = false, - explicitGroupId: UUID? = nil + usesTopLevelRows: Bool = false ) -> ClosedRange? { workspaceReordering.sidebarReorderLegalInsertionRange( forDraggedWorkspaceId: draggedWorkspaceId, targetWorkspaceId: targetWorkspaceId, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId + usesTopLevelRows: usesTopLevelRows ) } @@ -1595,15 +1593,13 @@ class TabManager: ObservableObject { tabId: UUID, toIndex targetIndex: Int, isDragOperation: Bool = false, - usesTopLevelRows: Bool = false, - explicitGroupId: UUID? = nil + usesTopLevelRows: Bool = false ) -> Bool { workspaceReordering.reorderSidebarWorkspace( tabId: tabId, toIndex: targetIndex, isDragOperation: isDragOperation, - usesTopLevelRows: usesTopLevelRows, - explicitGroupId: explicitGroupId + usesTopLevelRows: usesTopLevelRows ) } diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index b162a5f3177c..6cc9ed7ee7f9 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -55,13 +55,6 @@ extension VerticalTabsSidebar { dropIndicator: dragState.dropIndicator, tabIds: renderContext.sidebarReorderIds ) - let bottomDropIndicatorVisible = SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: group.anchorWorkspaceId, - draggedTabId: dragState.draggedTabId, - dropIndicator: dragState.dropIndicator, - tabIds: renderContext.sidebarReorderIds, - indicatorScope: dragState.dropIndicatorScope - ) let onDragStart: () -> NSItemProvider = { [anchorId = group.anchorWorkspaceId] in #if DEBUG cmuxDebugLog("sidebar.onDrag groupAnchor=\(anchorId.uuidString.prefix(5))") @@ -69,6 +62,34 @@ extension VerticalTabsSidebar { dragState.beginDragging(tabId: anchorId) return SidebarTabDragPayload.provider(for: anchorId) } + let tabDropDelegateFactory: (CGFloat) -> SidebarWorkspaceGroupHeaderDropDelegate = { [ + groupId = group.id, + anchorId = group.anchorWorkspaceId, + workspaceGroupIdByWorkspaceId = renderContext.workspaceGroupIdByWorkspaceId, + selectedTabIds = $selectedTabIds, + lastSidebarSelectionIndex = $lastSidebarSelectionIndex + ] rowHeight in + let reorderDelegate = SidebarTabDropDelegate( + targetTabId: anchorId, + tabManager: tabManager, + workspaceGroupIdByWorkspaceId: workspaceGroupIdByWorkspaceId, + dragState: dragState, + selectedTabIds: selectedTabIds, + lastSidebarSelectionIndex: lastSidebarSelectionIndex, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController + ) + return SidebarWorkspaceGroupHeaderDropDelegate( + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + tabManager: tabManager, + dragState: dragState, + targetRowHeight: rowHeight, + dragAutoScrollController: dragAutoScrollController, + reorderDelegate: reorderDelegate + ) + } + let header = SidebarWorkspaceGroupHeaderView( groupId: group.id, anchorWorkspaceId: group.anchorWorkspaceId, @@ -97,8 +118,8 @@ extension VerticalTabsSidebar { isFirstRow: renderContext.sidebarReorderIds.first == group.anchorWorkspaceId, isBeingDragged: dragState.draggedTabId == group.anchorWorkspaceId, topDropIndicatorVisible: topDropIndicatorVisible, - bottomDropIndicatorVisible: bottomDropIndicatorVisible, onDragStart: onDragStart, + tabDropDelegateFactory: tabDropDelegateFactory, onToggleCollapsed: { [weak tabManager, groupId = group.id] in tabManager?.toggleWorkspaceGroupCollapsed(groupId: groupId) }, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 34b4db59c2d1..1c932d278d82 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -820,28 +820,21 @@ D7AB34300000000000000105 /* SidebarTabDropIndicatorPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */; }; - C9A57605C9A57605C9A57605 /* SidebarWorkspaceDropTargetWriters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */; }; C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */; }; C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */; }; C9A57105C9A57105C9A57105 /* SidebarWorkspaceGroupContextMenuRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */; }; C9A57107C9A57107C9A57107 /* SidebarWorkspaceGroupDialogs.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57108C9A57108C9A57108 /* SidebarWorkspaceGroupDialogs.swift */; }; + 28CC2C4E28CC2C4E28CC2C4E /* SidebarWorkspaceGroupHeaderDropDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9962F9AE9962F9AE9962F9AE /* SidebarWorkspaceGroupHeaderDropDelegate.swift */; }; C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */; }; C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */; }; C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */; }; C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */; }; C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */; }; - C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */; }; - C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57306C9A57306C9A57306 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift */; }; - C9A57607C9A57607C9A57607 /* SidebarWorkspaceReorderDropOverlayTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */; }; - C9A57609C9A57609C9A57609 /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */; }; - C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */; }; - C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */; }; D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */; }; C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */; }; C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */; }; 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */; }; 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */; }; - C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */; }; A5001226 /* SocketControlMode+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001225 /* SocketControlMode+Display.swift */; }; F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; }; C510C1E00000000000000002 /* SocketOperationTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C510C1E00000000000000001 /* SocketOperationTelemetry.swift */; }; @@ -1896,28 +1889,21 @@ D7AB34300000000000000106 /* SidebarTabDropIndicatorPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabDropIndicatorPredicateTests.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; D7AB34300000000000000006 /* SidebarWorkspaceDropPlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropPlannerTests.swift; sourceTree = ""; }; - C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceDropTargetWriters.swift; sourceTree = ""; }; C9A57104C9A57104C9A57104 /* SidebarWorkspaceGroupConfigOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpener.swift; sourceTree = ""; }; C135190000000000000000B2 /* SidebarWorkspaceGroupConfigOpenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupConfigOpenerTests.swift; sourceTree = ""; }; C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupContextMenuRunner.swift; sourceTree = ""; }; C9A57108C9A57108C9A57108 /* SidebarWorkspaceGroupDialogs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupDialogs.swift; sourceTree = ""; }; + 9962F9AE9962F9AE9962F9AE /* SidebarWorkspaceGroupHeaderDropDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderDropDelegate.swift; sourceTree = ""; }; C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderMetrics.swift; sourceTree = ""; }; C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderMetricsTests.swift; sourceTree = ""; }; C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupHeaderView.swift; sourceTree = ""; }; C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceGroupingMetrics.swift; sourceTree = ""; }; C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRenderItem.swift; sourceTree = ""; }; - C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlay.swift; sourceTree = ""; }; - C9A57306C9A57306C9A57306 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayHitTestingTests.swift; sourceTree = ""; }; - C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTarget.swift; sourceTree = ""; }; - C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTargetBridge.swift; sourceTree = ""; }; - C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropView.swift; sourceTree = ""; }; - C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderPendingDrop.swift; sourceTree = ""; }; D35450020000000000000002 /* SidebarWorkspaceRowHoverTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceRowHoverTracker.swift; sourceTree = ""; }; C9A57506C9A57506C9A57506 /* SidebarWorkspaceScrollLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceScrollLayoutTests.swift; sourceTree = ""; }; C0DE56010000000000000002 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSelectionAnchorPolicyTests.swift; sourceTree = ""; }; D50D9BCE46701FBA91C2EFB6 /* SidebarWorkspaceSnapshotRefreshPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarWorkspaceSnapshotRefreshPolicy.swift; sourceTree = ""; }; F016B5C09357B3226FA2E014 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceSnapshotRefreshPolicyTests.swift; sourceTree = ""; }; - C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceTopDropIndicator.swift; sourceTree = ""; }; A5001225 /* SocketControlMode+Display.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SocketControlMode+Display.swift"; sourceTree = ""; }; F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = ""; }; C510C1E00000000000000001 /* SocketOperationTelemetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketOperationTelemetry.swift; sourceTree = ""; }; @@ -2473,19 +2459,13 @@ C9A57106C9A57106C9A57106 /* SidebarWorkspaceGroupContextMenuRunner.swift */, C9A57108C9A57108C9A57108 /* SidebarWorkspaceGroupDialogs.swift */, CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */, - C9A57606C9A57606C9A57606 /* SidebarWorkspaceDropTargetWriters.swift */, C9A57102C9A57102C9A57102 /* SidebarWorkspaceGroupHeaderView.swift */, + 9962F9AE9962F9AE9962F9AE /* SidebarWorkspaceGroupHeaderDropDelegate.swift */, C9A57302C9A57302C9A57302 /* SidebarWorkspaceGroupHeaderMetrics.swift */, C9A5710AC9A5710AC9A5710A /* SidebarWorkspaceGroupingMetrics.swift */, D5037010000000000000002 /* RenderableSystemSymbol.swift */, C9A57202C9A57202C9A57202 /* SidebarWorkspaceRenderItem.swift */, - C9A5760EC9A5760EC9A5760E /* SidebarWorkspaceReorderDropOverlay.swift */, - C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */, - C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */, - C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */, - C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */, C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */, - C9A57602C9A57602C9A57602 /* SidebarWorkspaceTopDropIndicator.swift */, C9A5720CC9A5720CC9A5720C /* TabItemView+WorkspaceGroups.swift */, C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */, C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */, @@ -3280,7 +3260,6 @@ 1D301919B10F22B8708E8883 /* WorkspaceManualUnreadTests.swift */, EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */, C9A57304C9A57304C9A57304 /* SidebarWorkspaceGroupHeaderMetricsTests.swift */, - C9A57306C9A57306C9A57306 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift */, 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */, C7A507000000000000000001 /* TaskManagerResourcesTests.swift */, C7A507000000000000004530 /* TaskManagerViewSnapshotBoundaryTests.swift */, @@ -4210,22 +4189,16 @@ C0DE35010000000000000001 /* SidebarScrim.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, - C9A57605C9A57605C9A57605 /* SidebarWorkspaceDropTargetWriters.swift in Sources */, C9A57103C9A57103C9A57103 /* SidebarWorkspaceGroupConfigOpener.swift in Sources */, C9A57105C9A57105C9A57105 /* SidebarWorkspaceGroupContextMenuRunner.swift in Sources */, C9A57107C9A57107C9A57107 /* SidebarWorkspaceGroupDialogs.swift in Sources */, + 28CC2C4E28CC2C4E28CC2C4E /* SidebarWorkspaceGroupHeaderDropDelegate.swift in Sources */, C9A57301C9A57301C9A57301 /* SidebarWorkspaceGroupHeaderMetrics.swift in Sources */, C9A57101C9A57101C9A57101 /* SidebarWorkspaceGroupHeaderView.swift in Sources */, C9A57109C9A57109C9A57109 /* SidebarWorkspaceGroupingMetrics.swift in Sources */, C9A57201C9A57201C9A57201 /* SidebarWorkspaceRenderItem.swift in Sources */, - C9A5760DC9A5760DC9A5760D /* SidebarWorkspaceReorderDropOverlay.swift in Sources */, - C9A57607C9A57607C9A57607 /* SidebarWorkspaceReorderDropOverlayTarget.swift in Sources */, - C9A57609C9A57609C9A57609 /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift in Sources */, - C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */, - C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */, D35450010000000000000001 /* SidebarWorkspaceRowHoverTracker.swift in Sources */, 988C6A036BA56EA5759A95A0 /* SidebarWorkspaceSnapshotRefreshPolicy.swift in Sources */, - C9A57601C9A57601C9A57601 /* SidebarWorkspaceTopDropIndicator.swift in Sources */, A5001226 /* SocketControlMode+Display.swift in Sources */, E30780000000000000000012 /* SSHPTYAttachStartupCommandBuilder.swift in Sources */, D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, @@ -4708,7 +4681,6 @@ D7AB34300000000000000005 /* SidebarWorkspaceDropPlannerTests.swift in Sources */, C135190000000000000000B1 /* SidebarWorkspaceGroupConfigOpenerTests.swift in Sources */, C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, - C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */, C9A57505C9A57505C9A57505 /* SidebarWorkspaceScrollLayoutTests.swift in Sources */, C0DE56010000000000000001 /* SidebarWorkspaceSelectionAnchorPolicyTests.swift in Sources */, 62270F3DCECB4787D789CCE3 /* SidebarWorkspaceSnapshotRefreshPolicyTests.swift in Sources */, diff --git a/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift b/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift index 45985dad2339..49be3b1ba597 100644 --- a/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift +++ b/cmuxTests/SidebarTabDropIndicatorPredicateTests.swift @@ -1,5 +1,4 @@ -import Foundation -import Testing +import XCTest import CmuxFoundation @@ -9,26 +8,16 @@ import CmuxFoundation @testable import cmux #endif -private func expectTrue(_ value: Bool, _ message: String? = nil) { - _ = message - #expect(value) -} - -private func expectFalse(_ value: Bool, _ message: String? = nil) { - _ = message - #expect(!value) -} - /// Tests for `SidebarTabDropIndicatorPredicate().topVisible(forTabId:draggedTabId:dropIndicator:tabIds:)`. /// /// This predicate is the snapshot the parent computes for each sidebar row to /// decide whether to draw the drop-line indicator above it. Lifting it out of /// the row view subtree (per the snapshot-boundary rule) makes it a pure /// function — these tests cover the resulting branches end-to-end. -@Suite struct SidebarTabDropIndicatorPredicateTopVisibleTests { - @Test func ReturnsFalseWhenNoDragInProgress() { +final class SidebarTabDropIndicatorPredicateTopVisibleTests: XCTestCase { + func testReturnsFalseWhenNoDragInProgress() { let rowId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: rowId, draggedTabId: nil, @@ -39,9 +28,9 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenNoIndicator() { + func testReturnsFalseWhenNoIndicator() { let rowId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: rowId, draggedTabId: rowId, @@ -51,10 +40,10 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsTrueWhenIndicatorTargetsThisRowTopEdge() { + func testReturnsTrueWhenIndicatorTargetsThisRowTopEdge() { let rowId = UUID() let draggedId = UUID() - expectTrue( + XCTAssertTrue( SidebarTabDropIndicatorPredicate().topVisible( forTabId: rowId, draggedTabId: draggedId, @@ -64,12 +53,12 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenIndicatorTargetsThisRowBottomEdge() { + func testReturnsFalseWhenIndicatorTargetsThisRowBottomEdge() { let rowId = UUID() let draggedId = UUID() // A .bottom indicator on this row paints the indicator above the *next* // row, not above this one. - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: rowId, draggedTabId: draggedId, @@ -79,11 +68,14 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsTrueWhenIndicatorTargetsPreviousRowBottomEdge() { + func testReturnsTrueWhenIndicatorTargetsPreviousRowBottomEdge() { let firstId = UUID() let middleId = UUID() let draggedId = UUID() - expectTrue( + // The visual indicator for "insert between row 0 and row 1" is drawn + // above row 1, even though the indicator semantically points at row 0 + // with .bottom. + XCTAssertTrue( SidebarTabDropIndicatorPredicate().topVisible( forTabId: middleId, draggedTabId: draggedId, @@ -93,11 +85,11 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenIndicatorTargetsUnrelatedRow() { + func testReturnsFalseWhenIndicatorTargetsUnrelatedRow() { let rowId = UUID() let otherId = UUID() let draggedId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: rowId, draggedTabId: draggedId, @@ -107,12 +99,12 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseForFirstRowWithBottomIndicatorAboveIt() { + func testReturnsFalseForFirstRowWithBottomIndicatorAboveIt() { // The first row has no previous neighbor — a .bottom indicator from a // hypothetical previous row can't apply. let firstId = UUID() let draggedId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: firstId, draggedTabId: draggedId, @@ -122,12 +114,12 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenRowIsNotInTabsList() { + func testReturnsFalseWhenRowIsNotInTabsList() { // Defensive: if the row id isn't in tabIds (stale snapshot), the // predicate should return false rather than crashing on the lookup. let strayId = UUID() let draggedId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().topVisible( forTabId: strayId, draggedTabId: draggedId, @@ -138,159 +130,12 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { } } -/// Tests for `SidebarTabDropIndicatorPredicate().bottomVisible(forTabId:draggedTabId:dropIndicator:tabIds:)`. -@Suite struct SidebarTabDropIndicatorPredicateBottomVisibleTests { - @Test func ReturnsFalseWhenNoDragInProgress() { - let rowId = UUID() - expectFalse( - SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: rowId, - draggedTabId: nil, - dropIndicator: SidebarDropIndicator(tabId: rowId, edge: .bottom), - tabIds: [rowId] - ) - ) - } - - @Test func ReturnsFalseWhenIndicatorTargetsThisRowBottomEdge() { - let rowId = UUID() - let draggedId = UUID() - expectFalse( - SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: rowId, - draggedTabId: draggedId, - dropIndicator: SidebarDropIndicator(tabId: rowId, edge: .bottom), - tabIds: [rowId, draggedId] - ) - ) - } - - @Test func ReturnsFalseWhenIndicatorTargetsThisRowTopEdge() { - let rowId = UUID() - let draggedId = UUID() - expectFalse( - SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: rowId, - draggedTabId: draggedId, - dropIndicator: SidebarDropIndicator(tabId: rowId, edge: .top), - tabIds: [rowId, draggedId] - ) - ) - } - - @Test func ReturnsFalseWhenRowIsNotInTabsList() { - let rowId = UUID() - expectFalse( - SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: rowId, - draggedTabId: UUID(), - dropIndicator: SidebarDropIndicator(tabId: rowId, edge: .bottom), - tabIds: [UUID()] - ) - ) - } - - @Test func BottomEdgeBetweenRowsHasExactlyOneVisibleDivider() { - let previousId = UUID() - let nextId = UUID() - let draggedId = UUID() - let tabIds = [previousId, nextId, draggedId] - let indicator = SidebarDropIndicator(tabId: previousId, edge: .bottom) - let predicate = SidebarTabDropIndicatorPredicate() - - expectFalse( - predicate.topVisible( - forTabId: previousId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds - ) - ) - expectFalse( - predicate.bottomVisible( - forTabId: previousId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds - ) - ) - expectTrue( - predicate.topVisible( - forTabId: nextId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds - ) - ) - expectFalse( - predicate.bottomVisible( - forTabId: nextId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds - ) - ) - } - - @Test func GroupEndBottomEdgeRendersOnLastVisibleScopedRow() { - let lastVisibleId = UUID() - let draggedId = UUID() - let indicator = SidebarDropIndicator(tabId: lastVisibleId, edge: .bottom) - let predicate = SidebarTabDropIndicatorPredicate() - - expectFalse( - predicate.topVisible( - forTabId: lastVisibleId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: [lastVisibleId] - ) - ) - expectTrue( - predicate.bottomVisible( - forTabId: lastVisibleId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: [lastVisibleId], - indicatorScope: .group(UUID()) - ) - ) - } - - @Test func GroupNonEndBottomEdgeStillRendersOnlyAboveNextVisibleRow() { - let previousId = UUID() - let nextId = UUID() - let draggedId = UUID() - let tabIds = [previousId, nextId, draggedId] - let indicator = SidebarDropIndicator(tabId: previousId, edge: .bottom) - let predicate = SidebarTabDropIndicatorPredicate() - - expectFalse( - predicate.bottomVisible( - forTabId: previousId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds, - indicatorScope: .group(UUID()) - ) - ) - expectTrue( - predicate.topVisible( - forTabId: nextId, - draggedTabId: draggedId, - dropIndicator: indicator, - tabIds: tabIds - ) - ) - } -} - /// Tests for `SidebarTabDropIndicatorPredicate().emptyAreaTopVisible(...)`. /// The "empty area" sits below the workspace list and shows an indicator when /// the drop will append at the end of the list. -@Suite struct SidebarTabDropIndicatorPredicateEmptyAreaTests { - @Test func ReturnsFalseWhenNoDragInProgress() { - expectFalse( +final class SidebarTabDropIndicatorPredicateEmptyAreaTests: XCTestCase { + func testReturnsFalseWhenNoDragInProgress() { + XCTAssertFalse( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: nil, dropIndicator: SidebarDropIndicator(tabId: nil, edge: .top), @@ -299,8 +144,8 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenNoIndicator() { - expectFalse( + func testReturnsFalseWhenNoIndicator() { + XCTAssertFalse( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: nil, @@ -309,10 +154,10 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsTrueWhenIndicatorTargetsEndOfList() { + func testReturnsTrueWhenIndicatorTargetsEndOfList() { // tabId == nil means "after the last row" — the empty area shows the // indicator regardless of which row was last. - expectTrue( + XCTAssertTrue( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: SidebarDropIndicator(tabId: nil, edge: .top), @@ -321,9 +166,9 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsTrueWhenIndicatorTargetsLastRowBottomEdge() { + func testReturnsTrueWhenIndicatorTargetsLastRowBottomEdge() { let lastId = UUID() - expectTrue( + XCTAssertTrue( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: SidebarDropIndicator(tabId: lastId, edge: .bottom), @@ -332,11 +177,11 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenIndicatorTargetsLastRowTopEdge() { + func testReturnsFalseWhenIndicatorTargetsLastRowTopEdge() { // A .top indicator on the last row draws the line *above* the last // row, not below — so the empty area below it should stay clear. let lastId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: SidebarDropIndicator(tabId: lastId, edge: .top), @@ -345,10 +190,10 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenIndicatorTargetsNonLastRowBottomEdge() { + func testReturnsFalseWhenIndicatorTargetsNonLastRowBottomEdge() { let middleId = UUID() let lastId = UUID() - expectFalse( + XCTAssertFalse( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: SidebarDropIndicator(tabId: middleId, edge: .bottom), @@ -357,8 +202,8 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFalseWhenListIsEmpty() { - expectFalse( + func testReturnsFalseWhenListIsEmpty() { + XCTAssertFalse( SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( draggedTabId: UUID(), dropIndicator: SidebarDropIndicator(tabId: UUID(), edge: .bottom), @@ -366,28 +211,16 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) ) } - - @Test func ScopedGroupAppendDoesNotAlsoRenderInEmptyArea() { - let lastId = UUID() - expectFalse( - SidebarTabDropIndicatorPredicate().emptyAreaTopVisible( - draggedTabId: UUID(), - dropIndicator: SidebarDropIndicator(tabId: lastId, edge: .bottom), - lastTabId: lastId, - indicatorScope: .group(UUID()) - ) - ) - } } /// Covers the freeze policy that holds `showsModifierShortcutHints` stable /// for the row whose context menu is open. Without it, pressing/releasing /// the modifier key while a context menu is up would flip badges on the row /// sitting behind the menu (visual regression flagged on the lazy-sidebar PR). -@Suite struct SidebarShortcutHintFreezePolicyTests { - @Test func ReturnsLiveWhenNoRowIsFrozen() { +final class SidebarShortcutHintFreezePolicyTests: XCTestCase { + func testReturnsLiveWhenNoRowIsFrozen() { let rowId = UUID() - expectTrue( + XCTAssertTrue( SidebarShortcutHintFreezePolicy().resolved( live: true, currentTabId: rowId, @@ -395,7 +228,7 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { frozenValue: false ) ) - expectFalse( + XCTAssertFalse( SidebarShortcutHintFreezePolicy().resolved( live: false, currentTabId: rowId, @@ -405,9 +238,9 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsFrozenWhenCurrentTabMatchesFrozenTab() { + func testReturnsFrozenWhenCurrentTabMatchesFrozenTab() { let rowId = UUID() - expectFalse( + XCTAssertFalse( SidebarShortcutHintFreezePolicy().resolved( live: true, currentTabId: rowId, @@ -416,7 +249,7 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ), "When this row is frozen, the modifier flipping live should not surface." ) - expectTrue( + XCTAssertTrue( SidebarShortcutHintFreezePolicy().resolved( live: false, currentTabId: rowId, @@ -427,10 +260,10 @@ private func expectFalse(_ value: Bool, _ message: String? = nil) { ) } - @Test func ReturnsLiveForRowsOtherThanTheFrozenOne() { + func testReturnsLiveForRowsOtherThanTheFrozenOne() { let frozenRow = UUID() let otherRow = UUID() - expectTrue( + XCTAssertTrue( SidebarShortcutHintFreezePolicy().resolved( live: true, currentTabId: otherRow, diff --git a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift index 55b9a8220844..b99b01ff4413 100644 --- a/cmuxTests/SidebarWorkspaceDropPlannerTests.swift +++ b/cmuxTests/SidebarWorkspaceDropPlannerTests.swift @@ -1,6 +1,5 @@ import CoreGraphics -import Foundation -import Testing +import XCTest import CmuxFoundation import CmuxSidebarProviderKit @@ -11,451 +10,192 @@ import CmuxSidebarProviderKit @testable import cmux #endif -private func expectTrue(_ value: Bool, _ message: String? = nil) { - _ = message - #expect(value) -} - -private func expectFalse(_ value: Bool, _ message: String? = nil) { - _ = message - #expect(!value) -} - -private func expectEqual(_ lhs: T, _ rhs: T, _ message: String? = nil) { - _ = message - #expect(lhs == rhs) -} - -private func expectEqual(_ lhs: T?, _ rhs: T, _ message: String? = nil) { - _ = message - #expect(lhs == rhs) -} - -private func require(_ value: T?, _ message: String? = nil) throws -> T { - _ = message - return try #require(value) -} - -@Suite struct SidebarWorkspaceDropPlannerTests { - @Test func WorkspaceDropTargetCollectionStaysDisabledWhenNoDragIsActive() { - expectFalse(SidebarDropPlanner().shouldCollectWorkspaceDropTargets(draggedTabId: nil)) +final class SidebarWorkspaceDropPlannerTests: XCTestCase { + func testWorkspaceDropTargetCollectionStaysDisabledWhenNoDragIsActive() { + XCTAssertFalse(SidebarDropPlanner().shouldCollectWorkspaceDropTargets(draggedTabId: nil)) } - @Test func WorkspaceDropTargetCollectionTurnsOnDuringDrag() { - expectTrue(SidebarDropPlanner().shouldCollectWorkspaceDropTargets(draggedTabId: UUID())) + func testWorkspaceDropTargetCollectionTurnsOnDuringDrag() { + XCTAssertTrue(SidebarDropPlanner().shouldCollectWorkspaceDropTargets(draggedTabId: UUID())) } - @Test func WorkspaceDropTargetCollectionTurnsOnDuringBonsplitWorkspaceDrop() { - expectTrue(SidebarDropPlanner().shouldCollectWorkspaceDropTargets( + func testWorkspaceDropTargetCollectionTurnsOnDuringBonsplitWorkspaceDrop() { + XCTAssertTrue(SidebarDropPlanner().shouldCollectWorkspaceDropTargets( draggedTabId: nil, isBonsplitWorkspaceDropActive: true )) } - @Test func GroupRootBoundaryInGroupLanePlansLastSlotInsideGroup() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 120, y: 121)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.child, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 3) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func GroupRootBoundaryInRootLanePlansRootSlotAfterGroup() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 121)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.rootAfter, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) - } - - @Test func PhysicalGapAfterLastGroupChildUsesHorizontalLane() throws { - let fixture = reorderFixture() - - let groupLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 120, y: 116)) - )) - let rootLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 116)) - )) - - expectEqual(groupLanePlan.indicator, SidebarDropIndicator(tabId: fixture.child, edge: .bottom)) - expectEqual(groupLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - expectEqual(rootLanePlan.indicator, SidebarDropIndicator(tabId: fixture.rootAfter, edge: .top)) - expectEqual(rootLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - } - - @Test func AmbiguousGroupBoundaryUsesSidebarMidpoint() throws { - let fixture = reorderFixture() - - let rootLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 89, y: 121)) - )) - let groupLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 90, y: 121)) - )) - - expectEqual(rootLanePlan.indicator, SidebarDropIndicator(tabId: fixture.rootAfter, edge: .top)) - expectEqual(rootLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - expectEqual(groupLanePlan.indicator, SidebarDropIndicator(tabId: fixture.child, edge: .bottom)) - expectEqual(groupLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - } - - @Test func GapBetweenVisibleGroupChildrenUsesClosestGroupGap() throws { - let fixture = multiChildReorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 14, y: 116)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.childB, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 3) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func LeftGutterBesideVisibleGroupChildUsesGroupGap() throws { - let fixture = multiChildReorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 90)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.childA, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func LastVisibleGroupChildBottomEdgeUsesHorizontalLane() throws { - let fixture = multiChildReorderFixture() - - let groupLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 120, y: 136)) - )) - let rootLanePlan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 136)) - )) - - expectEqual(groupLanePlan.indicator, SidebarDropIndicator(tabId: fixture.childB, edge: .bottom)) - expectEqual(groupLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = groupLanePlan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 4) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - expectEqual(rootLanePlan.indicator, SidebarDropIndicator(tabId: fixture.childB, edge: .bottom)) - expectEqual(rootLanePlan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let rootTargetIndex, let rootUsesTopLevelRows, let rootExplicitGroupId) = rootLanePlan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(rootTargetIndex, 2) - expectTrue(rootUsesTopLevelRows) - #expect(rootExplicitGroupId == nil) - } - - @Test func RootLaneGapCloserToLastVisibleGroupChildKeepsSharedBoundaryIndicator() throws { - let fixture = multiChildReorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 154)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.childB, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) + func testWorkspaceGroupHeaderDropZoneKeepsUsableCenterAtDefaultHeight() { + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 2, rowHeight: 24)) + XCTAssertTrue(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 12, rowHeight: 24)) + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 22, rowHeight: 24)) } - @Test func CollapsedGroupHeaderGroupLanePlansFirstVisibleGroupSlot() throws { - let fixture = collapsedGroupReorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 120, y: 56)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) + func testWorkspaceGroupHeaderDropZoneKeepsCenterAtCompactHeight() { + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 2, rowHeight: 20)) + XCTAssertTrue(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 10, rowHeight: 20)) + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropZone.isCenterDrop(locationY: 18, rowHeight: 20)) } - @Test func CollapsedGroupHeaderLeftHalfPlansRootSlotAfterGroup() throws { - let fixture = collapsedGroupReorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 56)) - )) + func testWorkspaceGroupHeaderCenterDropConsumesSameGroupMember() { + let workspaceId = UUID() + let groupId = UUID() + let anchorId = UUID() + + let action = SidebarWorkspaceGroupHeaderDropPolicy.action( + hasSidebarPayload: true, + draggedWorkspaceId: workspaceId, + draggedWorkspaceIsPinned: false, + draggedWorkspaceGroupId: groupId, + draggedWorkspaceIsGroupAnchor: false, + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + targetAnchorMatchesGroup: true, + locationY: 12, + rowHeight: 24 + ) - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) + XCTAssertEqual(action, .noOp) } - @Test func RootLaneOverExpandedGroupHeaderTopUsesGroupBlockBoundary() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 50)) - )) + func testWorkspaceGroupHeaderCenterDropAddsEligibleWorkspace() { + let workspaceId = UUID() + let groupId = UUID() + let anchorId = UUID() + + let action = SidebarWorkspaceGroupHeaderDropPolicy.action( + hasSidebarPayload: true, + draggedWorkspaceId: workspaceId, + draggedWorkspaceIsPinned: false, + draggedWorkspaceGroupId: nil, + draggedWorkspaceIsGroupAnchor: false, + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + targetAnchorMatchesGroup: true, + locationY: 12, + rowHeight: 24 + ) - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 1) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) + XCTAssertEqual(action, .addWorkspaceToGroup(workspaceId)) } - @Test func FirstChildLeftGutterPlansFirstGroupSlot() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 90)) - )) + func testWorkspaceGroupHeaderEdgeDropDoesNotInterceptReorder() { + let workspaceId = UUID() + let groupId = UUID() + let anchorId = UUID() + + let action = SidebarWorkspaceGroupHeaderDropPolicy.action( + hasSidebarPayload: true, + draggedWorkspaceId: workspaceId, + draggedWorkspaceIsPinned: false, + draggedWorkspaceGroupId: nil, + draggedWorkspaceIsGroupAnchor: false, + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + targetAnchorMatchesGroup: true, + locationY: 2, + rowHeight: 24 + ) - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.child, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) + XCTAssertNil(action) } - @Test func CrossWindowRootLaneAfterGroupCarriesResolvedTopLevelInsertion() throws { - let fixture = reorderFixture() - let foreignWorkspaceId = UUID() + func testWorkspaceGroupHeaderBottomEdgeConsumesAdjacentNoOpDrop() { + let anchorId = UUID() + let adjacentId = UUID() + let trailingId = UUID() - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request( - point: CGPoint(x: 2, y: 121), - draggedWorkspaceId: foreignWorkspaceId, - foreignDraggedIsPinned: false - ) + XCTAssertTrue(SidebarWorkspaceGroupHeaderDropPolicy.shouldConsumeNoOpEdgeDrop( + hasSidebarPayload: true, + draggedWorkspaceId: adjacentId, + draggedWorkspaceGroupId: nil, + targetGroupId: UUID(), + targetAnchorWorkspaceId: anchorId, + tabIds: [anchorId, adjacentId, trailingId], + pinnedTabIds: [], + locationY: 22, + rowHeight: 24 )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.rootAfter, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .crossWindow( - insertionIndex: let insertionIndex, - proposedInsertionIndex: let proposedInsertionIndex - ) = plan.action else { - Issue.record("Expected cross-window plan") - return - } - expectEqual(insertionIndex, 2) - expectEqual(proposedInsertionIndex, 2) } - @Test func CrossWindowPinnedClampCarriesUnclampedPointerSlot() throws { - let fixture = reorderFixture() - let foreignWorkspaceId = UUID() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request( - point: CGPoint(x: 2, y: 1), - draggedWorkspaceId: foreignWorkspaceId, - foreignDraggedIsPinned: false, - pinnedWorkspaceIds: [fixture.rootBefore] - ) + func testWorkspaceGroupHeaderTopEdgeDoesNotConsumeRealReorder() { + let anchorId = UUID() + let adjacentId = UUID() + let trailingId = UUID() + + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropPolicy.shouldConsumeNoOpEdgeDrop( + hasSidebarPayload: true, + draggedWorkspaceId: adjacentId, + draggedWorkspaceGroupId: nil, + targetGroupId: UUID(), + targetAnchorWorkspaceId: anchorId, + tabIds: [anchorId, adjacentId, trailingId], + pinnedTabIds: [], + locationY: 2, + rowHeight: 24 )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .crossWindow( - insertionIndex: let insertionIndex, - proposedInsertionIndex: let proposedInsertionIndex - ) = plan.action else { - Issue.record("Expected cross-window plan") - return - } - expectEqual(insertionIndex, 1) - expectEqual(proposedInsertionIndex, 0) } - @Test func GroupedChildRootLaneAfterOwnGroupStillPlansPromotion() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 121), draggedWorkspaceId: fixture.child) + func testWorkspaceGroupHeaderCenterDropDoesNotUseEdgeNoOpPolicy() { + let anchorId = UUID() + let adjacentId = UUID() + let trailingId = UUID() + + XCTAssertFalse(SidebarWorkspaceGroupHeaderDropPolicy.shouldConsumeNoOpEdgeDrop( + hasSidebarPayload: true, + draggedWorkspaceId: adjacentId, + draggedWorkspaceGroupId: nil, + targetGroupId: UUID(), + targetAnchorWorkspaceId: anchorId, + tabIds: [anchorId, adjacentId, trailingId], + pinnedTabIds: [], + locationY: 12, + rowHeight: 24 )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.rootAfter, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) } - @Test func PinnedGroupedChildPromotedToRootClampsToPinnedTier() throws { - let fixture = reorderFixture() + func testWorkspaceGroupHeaderEdgeDropConsumesSameGroupMember() { + let anchorId = UUID() + let memberId = UUID() + let otherTopLevelId = UUID() + let groupId = UUID() - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request( - point: CGPoint(x: 2, y: 121), - draggedWorkspaceId: fixture.child, - pinnedWorkspaceIds: [fixture.child] - ) + XCTAssertTrue(SidebarWorkspaceGroupHeaderDropPolicy.shouldConsumeNoOpEdgeDrop( + hasSidebarPayload: true, + draggedWorkspaceId: memberId, + draggedWorkspaceGroupId: groupId, + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + tabIds: [anchorId, otherTopLevelId, memberId], + pinnedTabIds: [], + locationY: 2, + rowHeight: 24 )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.rootBefore, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.topLevel) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 0) - expectTrue(usesTopLevelRows) - #expect(explicitGroupId == nil) } - @Test func RootSelfDropDoesNotInventIndicator() { - let fixture = reorderFixture() - - let plan = SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request( - point: CGPoint(x: 14, y: 170), - draggedWorkspaceId: fixture.dragged - ) + func testWorkspaceGroupHeaderCenterDropDoesNotInterceptOtherGroupHeader() { + let workspaceId = UUID() + let groupId = UUID() + let anchorId = UUID() + + let action = SidebarWorkspaceGroupHeaderDropPolicy.action( + hasSidebarPayload: true, + draggedWorkspaceId: workspaceId, + draggedWorkspaceIsPinned: false, + draggedWorkspaceGroupId: UUID(), + draggedWorkspaceIsGroupAnchor: true, + targetGroupId: groupId, + targetAnchorWorkspaceId: anchorId, + targetAnchorMatchesGroup: true, + locationY: 12, + rowHeight: 24 ) - #expect(plan == nil) - } - - @Test func GroupHeaderCenterGroupLanePlansFirstSlotInGroup() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 14, y: 56)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) + XCTAssertNil(action) } - @Test func GroupHeaderBottomGroupLanePlansFirstSlotInGroup() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 14, y: 70)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func GroupHeaderBottomLeftHalfStillPlansFirstSlotInGroup() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request(point: CGPoint(x: 2, y: 70)) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.anchor, edge: .bottom)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func HeaderChildGapDraggingFirstChildStillShowsFirstGroupSlot() throws { - let fixture = reorderFixture() - - let plan = try require(SidebarWorkspaceReorderDropResolver().plan( - for: fixture.request( - point: CGPoint(x: 14, y: 76), - draggedWorkspaceId: fixture.child - ) - )) - - expectEqual(plan.indicator, SidebarDropIndicator(tabId: fixture.child, edge: .top)) - expectEqual(plan.indicatorScope, SidebarWorkspaceReorderDropIndicatorScope.group(fixture.groupId)) - guard case .reorder(let targetIndex, let usesTopLevelRows, let explicitGroupId) = plan.action else { - Issue.record("Expected local reorder plan") - return - } - expectEqual(targetIndex, 2) - expectFalse(usesTopLevelRows) - expectEqual(explicitGroupId, fixture.groupId) - } - - @Test func WorkspaceDropCenterTargetsExistingWorkspace() { + func testWorkspaceDropCenterTargetsExistingWorkspace() { let first = UUID() let second = UUID() let targets = workspaceDropTargets([first, second]) @@ -465,10 +205,10 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual(action, SidebarDropPlanner.WorkspaceDropAction.existingWorkspace(second)) + XCTAssertEqual(action, .existingWorkspace(second)) } - @Test func WorkspaceDropTopEdgeCreatesWorkspaceBeforeTarget() { + func testWorkspaceDropTopEdgeCreatesWorkspaceBeforeTarget() { let first = UUID() let second = UUID() let targets = workspaceDropTargets([first, second]) @@ -478,16 +218,16 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual( + XCTAssertEqual( action, - SidebarDropPlanner.WorkspaceDropAction.newWorkspace( + .newWorkspace( insertionIndex: 1, indicator: SidebarDropIndicator(tabId: second, edge: .top) ) ) } - @Test func WorkspaceDropBottomEdgeCreatesWorkspaceAfterTarget() { + func testWorkspaceDropBottomEdgeCreatesWorkspaceAfterTarget() { let first = UUID() let second = UUID() let targets = workspaceDropTargets([first, second]) @@ -497,16 +237,16 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual( + XCTAssertEqual( action, - SidebarDropPlanner.WorkspaceDropAction.newWorkspace( + .newWorkspace( insertionIndex: 2, indicator: SidebarDropIndicator(tabId: nil, edge: .bottom) ) ) } - @Test func WorkspaceDropGapCreatesWorkspaceBeforeNextTarget() { + func testWorkspaceDropGapCreatesWorkspaceBeforeNextTarget() { let first = UUID() let second = UUID() let targets = workspaceDropTargets([first, second]) @@ -516,16 +256,16 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual( + XCTAssertEqual( action, - SidebarDropPlanner.WorkspaceDropAction.newWorkspace( + .newWorkspace( insertionIndex: 1, indicator: SidebarDropIndicator(tabId: second, edge: .top) ) ) } - @Test func WorkspaceDropAfterLastRowCreatesWorkspaceAtEnd() { + func testWorkspaceDropAfterLastRowCreatesWorkspaceAtEnd() { let first = UUID() let second = UUID() let targets = workspaceDropTargets([first, second]) @@ -535,16 +275,16 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual( + XCTAssertEqual( action, - SidebarDropPlanner.WorkspaceDropAction.newWorkspace( + .newWorkspace( insertionIndex: 2, indicator: SidebarDropIndicator(tabId: nil, edge: .bottom) ) ) } - @Test func WorkspaceDropKeepsNewWorkspaceAfterPinnedRows() { + func testWorkspaceDropKeepsNewWorkspaceAfterPinnedRows() { let pinnedA = UUID() let pinnedB = UUID() let unpinned = UUID() @@ -555,16 +295,16 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targets: targets ) - expectEqual( + XCTAssertEqual( action, - SidebarDropPlanner.WorkspaceDropAction.newWorkspace( + .newWorkspace( insertionIndex: 2, indicator: SidebarDropIndicator(tabId: unpinned, edge: .top) ) ) } - @Test func BrowserStackDropCanInsertAtStartOfNextSection() throws { + func testBrowserStackDropCanInsertAtStartOfNextSection() throws { let openA = UUID() let openB = UUID() let readingA = UUID() @@ -574,19 +314,19 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { ExtensionSidebarBrowserStackDropRow(workspaceId: readingA, sectionId: "reading") ] - let move = try require(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( + let move = try XCTUnwrap(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( draggedWorkspaceId: openB, insertionPosition: 2, preferredTargetSectionId: "reading" )) - expectEqual(move.workspaceId, openB) - expectEqual(move.sourceSectionId, "open") - expectEqual(move.targetSectionId, "reading") - expectEqual(move.targetIndex, 0) + XCTAssertEqual(move.workspaceId, openB) + XCTAssertEqual(move.sourceSectionId, "open") + XCTAssertEqual(move.targetSectionId, "reading") + XCTAssertEqual(move.targetIndex, 0) } - @Test func BrowserStackAdjacentTopDropPreservesNextSectionBoundary() throws { + func testBrowserStackAdjacentTopDropPreservesNextSectionBoundary() throws { let openA = UUID() let openB = UUID() let readingA = UUID() @@ -603,10 +343,10 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targetHeight: 34 ) - expectEqual(indicator, SidebarDropIndicator(tabId: readingA, edge: .top)) + XCTAssertEqual(indicator, SidebarDropIndicator(tabId: readingA, edge: .top)) } - @Test func BrowserStackAdjacentBottomDropPreservesPreviousSectionBoundary() throws { + func testBrowserStackAdjacentBottomDropPreservesPreviousSectionBoundary() throws { let openA = UUID() let readingA = UUID() let rows = [ @@ -621,10 +361,10 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { targetHeight: 34 ) - expectEqual(indicator, SidebarDropIndicator(tabId: openA, edge: .bottom)) + XCTAssertEqual(indicator, SidebarDropIndicator(tabId: openA, edge: .bottom)) } - @Test func BrowserStackDropBoundaryBottomStaysInPreviousSection() throws { + func testBrowserStackDropBoundaryBottomStaysInPreviousSection() throws { let openA = UUID() let readingA = UUID() let readingB = UUID() @@ -634,19 +374,19 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { ExtensionSidebarBrowserStackDropRow(workspaceId: readingB, sectionId: "reading") ] - let move = try require(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( + let move = try XCTUnwrap(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( draggedWorkspaceId: readingB, insertionPosition: 1, preferredTargetSectionId: "open" )) - expectEqual(move.workspaceId, readingB) - expectEqual(move.sourceSectionId, "reading") - expectEqual(move.targetSectionId, "open") - expectEqual(move.targetIndex, 1) + XCTAssertEqual(move.workspaceId, readingB) + XCTAssertEqual(move.sourceSectionId, "reading") + XCTAssertEqual(move.targetSectionId, "open") + XCTAssertEqual(move.targetIndex, 1) } - @Test func BrowserStackDropBoundaryBottomPrefersTargetRowSection() throws { + func testBrowserStackDropBoundaryBottomPrefersTargetRowSection() throws { let openA = UUID() let openB = UUID() let readingA = UUID() @@ -663,211 +403,17 @@ private func require(_ value: T?, _ message: String? = nil) throws -> T { indicator: SidebarDropIndicator(tabId: readingA, edge: .top) ) - expectEqual(preferredSectionId, "open") + XCTAssertEqual(preferredSectionId, "open") - let move = try require(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( + let move = try XCTUnwrap(ExtensionSidebarBrowserStackDropPlanner(orderedRows: rows).move( draggedWorkspaceId: readingB, insertionPosition: 2, preferredTargetSectionId: preferredSectionId )) - expectEqual(move.workspaceId, readingB) - expectEqual(move.sourceSectionId, "reading") - expectEqual(move.targetSectionId, "open") - expectEqual(move.targetIndex, 2) - } - - private struct ReorderFixture { - let rootBefore = UUID() - let anchor = UUID() - let child = UUID() - let rootAfter = UUID() - let dragged = UUID() - let groupId = UUID() - - func request( - point: CGPoint, - draggedWorkspaceId: UUID? = nil, - foreignDraggedIsPinned: Bool? = nil, - pinnedWorkspaceIds: Set = [] - ) -> SidebarWorkspaceReorderDropRequest { - SidebarWorkspaceReorderDropRequest( - point: point, - draggedWorkspaceId: draggedWorkspaceId ?? dragged, - foreignDraggedIsPinned: foreignDraggedIsPinned, - workspaces: [ - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootBefore, isPinned: pinnedWorkspaceIds.contains(rootBefore), groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: anchor, isPinned: pinnedWorkspaceIds.contains(anchor), groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: child, isPinned: pinnedWorkspaceIds.contains(child), groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootAfter, isPinned: pinnedWorkspaceIds.contains(rootAfter), groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: pinnedWorkspaceIds.contains(dragged), groupId: nil) - ], - groups: [ - SidebarWorkspaceReorderGroupSnapshot(id: groupId, anchorWorkspaceId: anchor, isPinned: false) - ], - targets: [ - SidebarWorkspaceReorderDropTarget( - workspaceId: rootBefore, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 0, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: anchor, - groupId: groupId, - isGroupHeader: true, - frame: CGRect(x: 0, y: 40, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: child, - groupId: groupId, - isGroupHeader: false, - frame: CGRect(x: 12, y: 80, width: 168, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: rootAfter, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 120, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: dragged, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 160, width: 180, height: 32) - ) - ] - ) - } - } - - private struct MultiChildReorderFixture { - let rootBefore = UUID() - let anchor = UUID() - let childA = UUID() - let childB = UUID() - let rootAfter = UUID() - let dragged = UUID() - let groupId = UUID() - - func request(point: CGPoint) -> SidebarWorkspaceReorderDropRequest { - SidebarWorkspaceReorderDropRequest( - point: point, - draggedWorkspaceId: dragged, - workspaces: [ - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootBefore, isPinned: false, groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: anchor, isPinned: false, groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: childA, isPinned: false, groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: childB, isPinned: false, groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootAfter, isPinned: false, groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: nil) - ], - groups: [ - SidebarWorkspaceReorderGroupSnapshot(id: groupId, anchorWorkspaceId: anchor, isPinned: false) - ], - targets: [ - SidebarWorkspaceReorderDropTarget( - workspaceId: rootBefore, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 0, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: anchor, - groupId: groupId, - isGroupHeader: true, - frame: CGRect(x: 0, y: 40, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: childA, - groupId: groupId, - isGroupHeader: false, - frame: CGRect(x: 12, y: 80, width: 168, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: childB, - groupId: groupId, - isGroupHeader: false, - frame: CGRect(x: 12, y: 120, width: 168, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: rootAfter, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 160, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: dragged, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 200, width: 180, height: 32) - ) - ] - ) - } - } - - private struct CollapsedGroupReorderFixture { - let rootBefore = UUID() - let anchor = UUID() - let hiddenChild = UUID() - let rootAfter = UUID() - let dragged = UUID() - let groupId = UUID() - - func request(point: CGPoint) -> SidebarWorkspaceReorderDropRequest { - SidebarWorkspaceReorderDropRequest( - point: point, - draggedWorkspaceId: dragged, - workspaces: [ - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootBefore, isPinned: false, groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: anchor, isPinned: false, groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: hiddenChild, isPinned: false, groupId: groupId), - SidebarWorkspaceReorderWorkspaceSnapshot(id: rootAfter, isPinned: false, groupId: nil), - SidebarWorkspaceReorderWorkspaceSnapshot(id: dragged, isPinned: false, groupId: nil) - ], - groups: [ - SidebarWorkspaceReorderGroupSnapshot(id: groupId, anchorWorkspaceId: anchor, isPinned: false) - ], - targets: [ - SidebarWorkspaceReorderDropTarget( - workspaceId: rootBefore, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 0, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: anchor, - groupId: groupId, - isGroupHeader: true, - frame: CGRect(x: 0, y: 40, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: rootAfter, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 80, width: 180, height: 32) - ), - SidebarWorkspaceReorderDropTarget( - workspaceId: dragged, - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 120, width: 180, height: 32) - ) - ] - ) - } - } - - private func reorderFixture() -> ReorderFixture { - ReorderFixture() - } - - private func multiChildReorderFixture() -> MultiChildReorderFixture { - MultiChildReorderFixture() - } - - private func collapsedGroupReorderFixture() -> CollapsedGroupReorderFixture { - CollapsedGroupReorderFixture() + XCTAssertEqual(move.workspaceId, readingB) + XCTAssertEqual(move.sourceSectionId, "reading") + XCTAssertEqual(move.targetSectionId, "open") + XCTAssertEqual(move.targetIndex, 2) } private func workspaceDropTargets( diff --git a/cmuxTests/SidebarWorkspaceGroupHeaderMetricsTests.swift b/cmuxTests/SidebarWorkspaceGroupHeaderMetricsTests.swift index 2b0a500d3dcf..9db386562754 100644 --- a/cmuxTests/SidebarWorkspaceGroupHeaderMetricsTests.swift +++ b/cmuxTests/SidebarWorkspaceGroupHeaderMetricsTests.swift @@ -83,17 +83,6 @@ import Testing ) } - @Test func groupScopedHeaderBottomIndicatorAlignsWithMemberRows() { - for scale in [CGFloat(0.5), CGFloat(1), CGFloat(2)] { - let metrics = SidebarWorkspaceGroupHeaderMetrics(fontScale: scale) - - #expect( - metrics.groupScopedBottomDropIndicatorLeadingInset - == SidebarWorkspaceGroupingMetrics.memberIndent - ) - } - } - @Test func groupPlusAndWorkspaceCloseStayAlignedWhenFontScaleChanges() { for scale in [CGFloat(0.5), CGFloat(1), CGFloat(2)] { let closeButtonWidth = max( diff --git a/cmuxTests/SidebarWorkspaceReorderDropOverlayHitTestingTests.swift b/cmuxTests/SidebarWorkspaceReorderDropOverlayHitTestingTests.swift deleted file mode 100644 index 53f2f57704a2..000000000000 --- a/cmuxTests/SidebarWorkspaceReorderDropOverlayHitTestingTests.swift +++ /dev/null @@ -1,316 +0,0 @@ -import AppKit -import Testing - -#if canImport(cmux_DEV) -@testable import cmux_DEV -#elseif canImport(cmux) -@testable import cmux -#endif - -@Suite struct SidebarWorkspaceReorderDropOverlayHitTestingTests { - private final class MockDraggingInfo: NSObject, NSDraggingInfo { - let draggingDestinationWindow: NSWindow? - let draggingSourceOperationMask: NSDragOperation - let draggingLocation: NSPoint - let draggedImageLocation: NSPoint - let draggedImage: NSImage? - // NSPasteboard is AppKit-managed and read only by the main-actor drop view in these tests. - nonisolated(unsafe) let draggingPasteboard: NSPasteboard - // NSDraggingInfo exposes an untyped AppKit source object; tests never mutate it. - nonisolated(unsafe) let draggingSource: Any? - let draggingSequenceNumber: Int - var draggingFormation: NSDraggingFormation = .default - var animatesToDestination = false - var numberOfValidItemsForDrop = 1 - let springLoadingHighlight: NSSpringLoadingHighlight = .none - - init( - window: NSWindow, - location: NSPoint, - pasteboard: NSPasteboard, - sourceOperationMask: NSDragOperation = .move, - draggingSource: Any? = nil, - sequenceNumber: Int = 1 - ) { - self.draggingDestinationWindow = window - self.draggingSourceOperationMask = sourceOperationMask - self.draggingLocation = location - self.draggedImageLocation = location - self.draggedImage = nil - self.draggingPasteboard = pasteboard - self.draggingSource = draggingSource - self.draggingSequenceNumber = sequenceNumber - } - - func slideDraggedImage(to screenPoint: NSPoint) {} - - override func namesOfPromisedFilesDropped(atDestination dropDestination: URL) -> [String]? { - nil - } - - func enumerateDraggingItems( - options enumOpts: NSDraggingItemEnumerationOptions = [], - for view: NSView?, - classes classArray: [AnyClass], - searchOptions: [NSPasteboard.ReadingOptionKey: Any] = [:], - using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void - ) {} - - func resetSpringLoading() {} - } - - @Test @MainActor func dropViewUsesTopOriginCoordinates() { - let view = SidebarWorkspaceReorderDropOverlay.DropView() - #expect(view.isFlipped) - } - - @Test func doesNotCaptureMouseDownBeforeDragStart() { - #expect(!SidebarWorkspaceReorderDropOverlay.shouldCaptureHitTest( - eventType: .leftMouseDown, - pasteboardTypes: [NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier)] - )) - } - - @Test func doesNotCapturePointerDragWithoutSidebarPasteboardType() { - #expect(!SidebarWorkspaceReorderDropOverlay.shouldCaptureHitTest( - eventType: .leftMouseDragged, - pasteboardTypes: [] - )) - } - - @Test func capturesPointerDragAfterSidebarPasteboardTypeExists() { - #expect(SidebarWorkspaceReorderDropOverlay.shouldCaptureHitTest( - eventType: .leftMouseDragged, - pasteboardTypes: [NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier)] - )) - } - - @Test @MainActor func targetBridgeUpdatesEveryAttachedDropView() { - let bridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() - let firstView = SidebarWorkspaceReorderDropOverlay.DropView() - let secondView = SidebarWorkspaceReorderDropOverlay.DropView() - bridge.attach(firstView) - bridge.attach(secondView) - - let target = SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: UUID(), - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 40, width: 200, height: 24) - ) - bridge.updateTargets([target]) - - #expect(firstView.targets == [target]) - #expect(secondView.targets == [target]) - } - - @Test @MainActor func topStripOverlayOffsetsDropPointIntoContentCoordinates() { - let view = SidebarWorkspaceReorderDropOverlay.DropView( - frame: NSRect(x: 0, y: 0, width: 240, height: 28) - ) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 28), - styleMask: [], - backing: .buffered, - defer: false - ) - window.contentView = view - - let pasteboard = NSPasteboard(name: NSPasteboard.Name("workspace-reorder-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString( - UUID().uuidString, - forType: NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - ) - let sender = MockDraggingInfo( - window: window, - location: NSPoint(x: 32, y: 12), - pasteboard: pasteboard - ) - - let rawPoint = view.convert(sender.draggingLocation, from: nil) - view.pointOffset = CGSize(width: 0, height: -28) - let dropPoint = view.dropPoint(from: sender) - - #expect(dropPoint.x == rawPoint.x) - #expect(dropPoint.y == rawPoint.y - 28) - } - - @Test @MainActor func fastReleaseQueuesDropUntilTargetsArrive() async { - let bridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() - let view = SidebarWorkspaceReorderDropOverlay.DropView( - frame: NSRect(x: 0, y: 0, width: 240, height: 160) - ) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 160), - styleMask: [], - backing: .buffered, - defer: false - ) - window.contentView = view - bridge.attach(view) - - let pasteboard = NSPasteboard(name: NSPasteboard.Name("workspace-reorder-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString( - UUID().uuidString, - forType: NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - ) - let sender = MockDraggingInfo( - window: window, - location: NSPoint(x: 32, y: 48), - pasteboard: pasteboard - ) - let target = SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: UUID(), - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 40, width: 200, height: 24) - ) - - var activeStates: [Bool] = [] - var updateCalls = 0 - var performedDrops: [(CGPoint, [SidebarWorkspaceReorderDropOverlay.Target])] = [] - view.isValidDrag = { true } - view.setWorkspaceDropTargetCollectionActive = { activeStates.append($0) } - view.clearDropIndicator = {} - view.updateDrag = { _, _ in - updateCalls += 1 - return true - } - view.performDropAtPoint = { point, targets in - performedDrops.append((point, targets)) - return true - } - - #expect(view.draggingEntered(sender) == .move) - #expect(updateCalls == 0) - let expectedDropPoint = view.convert(sender.draggingLocation, from: nil) - #expect(view.performDragOperation(sender)) - #expect(performedDrops.isEmpty) - - bridge.updateTargets([target]) - await Task.yield() - await Task.yield() - - #expect(performedDrops.count == 1) - #expect(performedDrops.first?.0 == expectedDropPoint) - #expect(performedDrops.first?.1 == [target]) - #expect(activeStates == [true, false]) - } - - @Test @MainActor func pendingFastReleaseSurvivesDragConclusionUntilTargetsArrive() async { - let bridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() - let view = SidebarWorkspaceReorderDropOverlay.DropView( - frame: NSRect(x: 0, y: 0, width: 240, height: 160) - ) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 160), - styleMask: [], - backing: .buffered, - defer: false - ) - window.contentView = view - bridge.attach(view) - - let pasteboard = NSPasteboard(name: NSPasteboard.Name("workspace-reorder-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString( - UUID().uuidString, - forType: NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - ) - let sender = MockDraggingInfo( - window: window, - location: NSPoint(x: 32, y: 48), - pasteboard: pasteboard - ) - let target = SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: UUID(), - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 40, width: 200, height: 24) - ) - - var activeStates: [Bool] = [] - var performedDrops: [(CGPoint, [SidebarWorkspaceReorderDropOverlay.Target])] = [] - view.isValidDrag = { true } - view.setWorkspaceDropTargetCollectionActive = { activeStates.append($0) } - view.clearDropIndicator = {} - view.updateDrag = { _, _ in true } - view.performDropAtPoint = { point, targets in - performedDrops.append((point, targets)) - return true - } - - #expect(view.draggingEntered(sender) == .move) - let expectedDropPoint = view.convert(sender.draggingLocation, from: nil) - #expect(view.performDragOperation(sender)) - view.concludeDragOperation(sender) - - bridge.updateTargets([target]) - - #expect(performedDrops.count == 1) - #expect(performedDrops.first?.0 == expectedDropPoint) - #expect(performedDrops.first?.1 == [target]) - #expect(activeStates == [true, false]) - } - - @Test @MainActor func pendingFastReleaseClearsWhenTargetsNeverArrive() async { - let bridge = SidebarWorkspaceReorderDropOverlay.TargetBridge() - let view = SidebarWorkspaceReorderDropOverlay.DropView( - frame: NSRect(x: 0, y: 0, width: 240, height: 160) - ) - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 240, height: 160), - styleMask: [], - backing: .buffered, - defer: false - ) - window.contentView = view - bridge.attach(view) - - let pasteboard = NSPasteboard(name: NSPasteboard.Name("workspace-reorder-\(UUID().uuidString)")) - pasteboard.clearContents() - pasteboard.setString( - UUID().uuidString, - forType: NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - ) - let sender = MockDraggingInfo( - window: window, - location: NSPoint(x: 32, y: 48), - pasteboard: pasteboard - ) - let target = SidebarWorkspaceReorderDropOverlay.Target( - workspaceId: UUID(), - groupId: nil, - isGroupHeader: false, - frame: CGRect(x: 0, y: 40, width: 200, height: 24) - ) - - var activeStates: [Bool] = [] - var clearCount = 0 - var performedDrops: [(CGPoint, [SidebarWorkspaceReorderDropOverlay.Target])] = [] - view.isValidDrag = { true } - view.setWorkspaceDropTargetCollectionActive = { activeStates.append($0) } - view.clearDropIndicator = { clearCount += 1 } - view.updateDrag = { _, _ in true } - view.performDropAtPoint = { point, targets in - performedDrops.append((point, targets)) - return true - } - - #expect(view.draggingEntered(sender) == .move) - #expect(view.performDragOperation(sender)) - view.concludeDragOperation(sender) - bridge.updateTargets([]) - - #expect(performedDrops.isEmpty) - #expect(activeStates == [true, false]) - #expect(clearCount >= 1) - - bridge.updateTargets([target]) - - #expect(performedDrops.isEmpty) - #expect(activeStates == [true, false]) - } -} diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 82ba1a6a70d5..c6b94599ee12 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -234,13 +234,13 @@ struct WorkspaceGroupTests { originalIds[0], group.anchorWorkspaceId, originalIds[3], - ] + Array(originalIds.dropFirst(4))) + ]) #expect(forcedTopLevelIds == headerTargetIds) - #expect(!SidebarTabDropIndicatorPredicate().bottomVisible( - forTabId: group.anchorWorkspaceId, + #expect(!SidebarTabDropIndicatorPredicate().topVisible( + forTabId: originalIds[3], draggedTabId: originalIds[0], dropIndicator: indicator, - tabIds: forcedTopLevelIds + tabIds: fullRowIds )) #expect(SidebarTabDropIndicatorPredicate().topVisible( forTabId: originalIds[3], @@ -248,7 +248,6 @@ struct WorkspaceGroupTests { dropIndicator: indicator, tabIds: forcedTopLevelIds )) - #expect(fullRowIds.contains(group.anchorWorkspaceId)) } @Test func draggingGroupedChildAboveItsGroupPromotesToTopLevel() throws { @@ -304,117 +303,6 @@ struct WorkspaceGroupTests { ]) } - @Test func draggingGroupedChildToRootSlotAfterOwnGroupPromotesToTopLevel() throws { - let manager = makeTabManager() - manager.addWorkspace(autoWelcomeIfNeeded: false) - manager.addWorkspace(autoWelcomeIfNeeded: false) - let originalIds = manager.tabs.map(\.id) - - let groupId = try #require(manager.createWorkspaceGroup(name: "Middle", childWorkspaceIds: [ - originalIds[1], - originalIds[2], - ])) - let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) - let draggedId = originalIds[1] - let rootAfterGroupId = originalIds[3] - let reorderIds = manager.sidebarReorderWorkspaceIds( - forDraggedWorkspaceId: draggedId, - targetWorkspaceId: rootAfterGroupId, - usesTopLevelRows: true - ) - let pinnedIds = manager.sidebarReorderPinnedWorkspaceIds( - forDraggedWorkspaceId: draggedId, - targetWorkspaceId: rootAfterGroupId, - usesTopLevelRows: true - ) - let targetIndex = try #require(SidebarDropPlanner().targetIndex( - draggedTabId: draggedId, - targetTabId: rootAfterGroupId, - indicator: SidebarDropIndicator(tabId: rootAfterGroupId, edge: .top), - tabIds: reorderIds, - pinnedTabIds: pinnedIds - )) - - let moved = manager.reorderSidebarWorkspace( - tabId: draggedId, - toIndex: targetIndex, - isDragOperation: true, - usesTopLevelRows: true - ) - - #expect(moved) - #expect(manager.tabs.first { $0.id == draggedId }?.groupId == nil) - #expect(manager.tabs.filter { $0.groupId == groupId }.map(\.id) == [ - group.anchorWorkspaceId, - originalIds[2], - ]) - #expect(manager.tabs.map(\.id) == [ - originalIds[0], - group.anchorWorkspaceId, - originalIds[2], - draggedId, - rootAfterGroupId, - ] + Array(originalIds.dropFirst(4))) - } - - @Test func draggingPinnedGroupedChildToRootSlotAfterOwnUnpinnedGroupPromotesToPinnedTier() throws { - let manager = makeTabManager() - manager.addWorkspace(autoWelcomeIfNeeded: false) - manager.addWorkspace(autoWelcomeIfNeeded: false) - let originalIds = manager.tabs.map(\.id) - - let groupId = try #require(manager.createWorkspaceGroup(name: "Middle", childWorkspaceIds: [ - originalIds[1], - originalIds[2], - ])) - let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) - let draggedId = originalIds[1] - let rootAfterGroupId = originalIds[3] - manager.setPinned(try #require(manager.tabs.first { $0.id == draggedId }), pinned: true) - let reorderIds = manager.sidebarReorderWorkspaceIds( - forDraggedWorkspaceId: draggedId, - targetWorkspaceId: rootAfterGroupId, - usesTopLevelRows: true - ) - let pinnedIds = manager.sidebarReorderPinnedWorkspaceIds( - forDraggedWorkspaceId: draggedId, - targetWorkspaceId: rootAfterGroupId, - usesTopLevelRows: true - ) - #expect(reorderIds == [ - draggedId, - originalIds[0], - group.anchorWorkspaceId, - rootAfterGroupId, - ] + Array(originalIds.dropFirst(4))) - #expect(pinnedIds == [draggedId]) - let targetIndex = try #require(SidebarDropPlanner().targetIndex( - draggedTabId: draggedId, - targetTabId: rootAfterGroupId, - indicator: SidebarDropIndicator(tabId: rootAfterGroupId, edge: .top), - tabIds: reorderIds, - pinnedTabIds: pinnedIds - )) - #expect(targetIndex == 0) - - let moved = manager.reorderSidebarWorkspace( - tabId: draggedId, - toIndex: targetIndex, - isDragOperation: true, - usesTopLevelRows: true - ) - - #expect(moved) - #expect(manager.tabs.first { $0.id == draggedId }?.groupId == nil) - #expect(manager.tabs.map(\.id) == [ - draggedId, - originalIds[0], - group.anchorWorkspaceId, - originalIds[2], - rootAfterGroupId, - ] + Array(originalIds.dropFirst(4))) - } - @Test func createUnpinnedGroupFromPinnedGroupChildStaysBelowPinnedGroups() throws { let manager = makeTabManager() manager.addWorkspace(autoWelcomeIfNeeded: false)