diff --git a/Sources/Cloud/CloudTreeContainerView.swift b/Sources/Cloud/CloudTreeContainerView.swift index 2ee8844e3d70..252b6d0dc959 100644 --- a/Sources/Cloud/CloudTreeContainerView.swift +++ b/Sources/Cloud/CloudTreeContainerView.swift @@ -8,6 +8,9 @@ final class CloudTreeContainerView: NSView { private let coordinator: CloudTreeOutlineView.Coordinator private let layoutMetrics = CloudTreeLayoutMetrics() private var lastMeasuredDocumentWidth: CGFloat? + /// The scroll view's insets and inset mode from before a drag borrowed + /// scroll range, restored when the range comes back. + private var insetsBeforeLoan: (insets: NSEdgeInsets, automatic: Bool)? init(coordinator: CloudTreeOutlineView.Coordinator) { self.coordinator = coordinator @@ -73,6 +76,8 @@ final class CloudTreeContainerView: NSView { scrollView.contentInsets = NSEdgeInsets(top: 6, left: 0, bottom: 6, right: 0) addSubview(scrollView) outlineView.onDocumentContentChanged = { [weak self] in self?.needsLayout = true } + outlineView.layoutHost = { [weak self] in self?.layoutSubtreeIfNeeded() } + outlineView.lendScrollRange = { [weak self] above, below in self?.lendScrollRange(above: above, below: below) } outlineView.frame = scrollView.contentView.bounds outlineView.autoresizingMask = [.width] NSLayoutConstraint.activate([ @@ -88,12 +93,35 @@ final class CloudTreeContainerView: NSView { fatalError("init(coder:) has not been implemented") } + /// Extends the scroll range past the rows for a drag, on top of the + /// insets in effect when it started; zero for both restores them. AppKit + /// recomputes automatic insets on its next layout, which would drop the + /// loan mid-drag, so the insets hold still while it lasts. + private func lendScrollRange(above: CGFloat, below: CGFloat) { + guard above != 0 || below != 0 else { + guard let loan = insetsBeforeLoan else { return } + insetsBeforeLoan = nil + scrollView.contentInsets = loan.insets + scrollView.automaticallyAdjustsContentInsets = loan.automatic + return + } + let loan = insetsBeforeLoan + ?? (insets: scrollView.contentInsets, automatic: scrollView.automaticallyAdjustsContentInsets) + insetsBeforeLoan = loan + scrollView.automaticallyAdjustsContentInsets = false + scrollView.contentInsets = NSEdgeInsets( + top: loan.insets.top + above, left: loan.insets.left, + bottom: loan.insets.bottom + below, right: loan.insets.right + ) + } + override func layout() { super.layout() let viewportWidth = scrollView.contentView.bounds.width let documentWidth = layoutMetrics.documentWidth(viewportWidth: viewportWidth) let contentHeight = outlineView.numberOfRows > 0 - ? outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY + scrollView.contentInsets.bottom + ? outlineView.rect(ofRow: outlineView.numberOfRows - 1).maxY + + (insetsBeforeLoan?.insets ?? scrollView.contentInsets).bottom : 0 let documentHeight = layoutMetrics.documentHeight( viewportHeight: scrollView.contentView.bounds.height, contentHeight: contentHeight) diff --git a/Sources/Cloud/CloudTreeMachineDetailTabsView.swift b/Sources/Cloud/CloudTreeMachineDetailTabsView.swift index 0917a27dd0e2..74c7bca51b22 100644 --- a/Sources/Cloud/CloudTreeMachineDetailTabsView.swift +++ b/Sources/Cloud/CloudTreeMachineDetailTabsView.swift @@ -17,7 +17,9 @@ struct CloudTreeMachineDetailTabsView: View { /// Where the strip starts (`CloudTreeHoverStyle.leading`), already scaled. var leading: CGFloat = CloudTreeHoverStyle.horizontalInset let select: (CloudTreeMachineDetailTab) -> Void + @Namespace private var selectionNamespace @Environment(\.cmuxGlobalFontMagnificationPercent) private var magnification + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { // The roomiest strip that fits wins, so a narrow sidebar tightens the @@ -41,13 +43,15 @@ struct CloudTreeMachineDetailTabsView: View { @ViewBuilder private func strip(_ density: CloudTreeMachineDetailTabDensity) -> some View { let row = HStack(spacing: density.spacing) { - ForEach(tabs.tabs, id: \.self) { tab in + ForEach(Array(tabs.tabs.enumerated()), id: \.element) { index, tab in CloudTreeMachineDetailTabButton( tab: tab, count: density.showsCounts ? tabs.count(for: tab) : nil, isSelected: tabs.selected == tab, style: style, - horizontalPadding: density.horizontalPadding + horizontalPadding: density.horizontalPadding, + selectionNamespace: selectionNamespace, + tabIndex: index ) { select(tab) } } } @@ -57,11 +61,17 @@ struct CloudTreeMachineDetailTabsView: View { CloudTreeMachineDetailTabButtonMetrics.horizontalPadding - density.horizontalPadding, percent: magnification )) - if density.truncates { - row - } else { - row.fixedSize(horizontal: true, vertical: false) + Group { + if density.truncates { + row + } else { + row.fixedSize(horizontal: true, vertical: false) + } } + .animation( + reduceMotion ? nil : .spring(response: 0.34, dampingFraction: 0.88), + value: tabs.selected + ) } /// Space between the rows above and the tabs. @@ -92,6 +102,8 @@ private struct CloudTreeMachineDetailTabButton: View { let isSelected: Bool let style: CloudTreeStyle var horizontalPadding = CloudTreeMachineDetailTabButtonMetrics.horizontalPadding + let selectionNamespace: Namespace.ID + let tabIndex: Int let action: () -> Void @State private var isHovered = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -114,8 +126,10 @@ private struct CloudTreeMachineDetailTabButton: View { .foregroundStyle(.tertiary) .lineLimit(1) .fixedSize() + .contentTransition(.interpolate) } } + .scaleEffect(isSelected ? 1 : 0.97, anchor: .leading) .padding(.horizontal, GlobalFontMagnification.scaledSize(horizontalPadding, percent: magnification)) .frame(height: GlobalFontMagnification.scaledSize(Self.height, percent: magnification)) .background(segment) @@ -124,7 +138,10 @@ private struct CloudTreeMachineDetailTabButton: View { .buttonStyle(.plain) .onHover { isHovered = $0 } .animation(reduceMotion ? nil : .easeOut(duration: isHovered ? CloudTreeHoverStyle.fadeIn : CloudTreeHoverStyle.fadeOut), value: isHovered) - .animation(reduceMotion ? nil : .easeOut(duration: CloudTreeHoverStyle.fadeIn), value: isSelected) + .animation( + reduceMotion ? nil : .easeOut(duration: 0.28).delay(Double(tabIndex) * 0.035), + value: isSelected + ) .help(tab.title) .accessibilityLabel(tab.title) .accessibilityValue(count.map { String($0) } ?? "") @@ -133,10 +150,16 @@ private struct CloudTreeMachineDetailTabButton: View { } private var segment: some View { - RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) - .fill(Color.primary.opacity( - isSelected ? CloudTreeHoverStyle.selectedOpacity : (isHovered ? CloudTreeHoverStyle.hoverOpacity : 0) - )) + ZStack { + if isSelected { + RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(CloudTreeHoverStyle.selectedOpacity)) + .matchedGeometryEffect(id: "machine-detail-tab-selection", in: selectionNamespace) + } else if isHovered { + RoundedRectangle(cornerRadius: CloudTreeHoverStyle.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(CloudTreeHoverStyle.hoverOpacity)) + } + } } } diff --git a/Sources/Cloud/CloudTreeNSOutlineView.swift b/Sources/Cloud/CloudTreeNSOutlineView.swift index 7a88d2ddbe66..9463866b581f 100644 --- a/Sources/Cloud/CloudTreeNSOutlineView.swift +++ b/Sources/Cloud/CloudTreeNSOutlineView.swift @@ -213,6 +213,19 @@ final class CloudTreeNSOutlineView: NSOutlineView { } var onNativeDragPointerBoundary: (() -> Void)? var onDocumentContentChanged: (() -> Void)? + /// Lays out the host that sizes the document to its rows. + var layoutHost: (() -> Void)? + /// Lets the outline scroll this much further above and below its rows, + /// for a drag that keeps a row in place while rows around it close. + /// Zero for both gives the range back. + var lendScrollRange: ((_ above: CGFloat, _ below: CGFloat) -> Void)? + + /// Sizes the document to its rows and lays the rows out now rather than + /// on the next pass, for a caller that measures or scrolls right after + /// rows open or close. + func layoutDocumentNow() { + if let layoutHost { layoutHost() } else { layoutSubtreeIfNeeded() } + } var treeStyle: CloudTreeStyle = CloudTreeStyleStore.current diff --git a/Sources/Cloud/CloudTreeOutlineView.swift b/Sources/Cloud/CloudTreeOutlineView.swift index 7e161f5c89be..e8074defc0e4 100644 --- a/Sources/Cloud/CloudTreeOutlineView.swift +++ b/Sources/Cloud/CloudTreeOutlineView.swift @@ -125,7 +125,7 @@ struct CloudTreeOutlineView: NSViewRepresentable { self?.pendingDragWriterDidDeallocate(tokenID: tokenID) } private(set) var isDragging = false - /// Machine drags lift the real row; proposal-level tests turn it off. + /// Machine and workspace drags lift the real row; proposal-level tests turn it off. var machineLiftEnabled = true var deferredNodes: [CloudTreeNode]? private var deferredReload = false diff --git a/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift b/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift index 1148444e7dc6..8c82088095e1 100644 --- a/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift +++ b/Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift @@ -17,14 +17,14 @@ struct CloudSidebarOrganizationDrop { proposedItem: CloudTreeNode?, proposedChildIndex: Int, dropAfterItem: Bool, - machineSlot: Int? = nil + liftSlot: Int? = nil ) { if let scope = CloudMachineReorderScope(machineNodeID: sourceID, roots: nodes) { let drop: CloudMachineReorderDrop? - if let machineSlot { + if let liftSlot { // A lifted drag drops where it shows the machine, not where // AppKit's row proposal happens to point. - drop = CloudMachineReorderDrop(sourceID: sourceID, nodes: scope.siblings, slot: machineSlot) + drop = CloudMachineReorderDrop(sourceID: sourceID, nodes: scope.siblings, slot: liftSlot) } else { // Between two machines in the section, AppKit proposes the // section itself with a child index. @@ -42,13 +42,24 @@ struct CloudSidebarOrganizationDrop { operation = .machine(drop.machineID, drop.move) return } - guard let parent = CloudSidebarOrganizationTree(nodes: nodes).parent(of: sourceID), - let proposedItem else { return nil } + guard let parent = CloudSidebarOrganizationTree(nodes: nodes).parent(of: sourceID) else { return nil } + let group = parent.organizationGroupID + let pinned = state.isPinned(sourceID, parent: group) let index: Int - if proposedItem.id == parent.id, proposedChildIndex >= 0 { + if let liftSlot { + // A lifted workspace lands where the rows show it: at `liftSlot` + // among the other rows of its pin tier. + let peers = parent.children.indices.filter { index in + let child = parent.children[index] + return child.canOrganize && child.id != sourceID && state.isPinned(child.id, parent: group) == pinned + } + guard (0...peers.count).contains(liftSlot), let last = peers.last else { return nil } + index = liftSlot < peers.count ? peers[liftSlot] : last + 1 + } else if let proposedItem, proposedItem.id == parent.id, proposedChildIndex >= 0 { guard proposedChildIndex <= parent.children.count else { return nil } index = proposedChildIndex } else { + guard let proposedItem else { return nil } // A folder cannot contain its sibling. AppKit nevertheless proposes // drop-on and child insertions while hovering an expanded folder. // Retarget to that folder's outer edge without opening or reparenting. @@ -60,8 +71,6 @@ struct CloudSidebarOrganizationDrop { let onSibling = sibling.id == proposedItem.id && proposedChildIndex == NSOutlineViewDropOnItemIndex index = siblingIndex + (onSibling && !dropAfterItem ? 0 : 1) } - let group = parent.organizationGroupID - let pinned = state.isPinned(sourceID, parent: group) let before = parent.children.prefix(index).last { $0.canOrganize && $0.id != sourceID } let after = parent.children.dropFirst(index).first { $0.canOrganize && $0.id != sourceID } let action: CloudSidebarOrganizationAction diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift index 278e6f8a308f..053550f26737 100644 --- a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift @@ -2,14 +2,21 @@ import AppKit import CmuxCloud import QuartzCore -/// The visuals of a continuous machine drag in the Cloud tree. +/// The visuals of a continuous machine or workspace drag in the Cloud tree. /// -/// There is no drag image and no insertion line: the real machine row is -/// what the hand holds. Open machines close for the drag so every machine is -/// one row, the peers part around the lifted row on springs, and the release +/// There is no drag image and no insertion line: the real row is what the +/// hand holds. Open rows close for the drag so every peer is one row, the +/// peers part around the lifted row on springs, and the release /// lands each row from wherever it is on screen. The model is untouched until /// the drop; during the drag everything is a layer transform over an /// unchanged outline, laid out by `CloudTreeReorderLiftLayout`. +/// +/// The held row never moves on its own. Closing rows, and opening them again +/// at the end, is anchored on it: the list scrolls so the row's slot stays +/// where the row stood, borrowing scroll range past the rows when the list is +/// too short to get there, and gives the range back when the drag ends. The +/// row is the press point plus the pointer's travel, so it stays under the +/// hand for the whole drag, and every row stays reachable by scrolling. @MainActor final class CloudTreeMachineReorderLift: NSObject { private weak var outline: CloudTreeNSOutlineView? @@ -19,22 +26,32 @@ final class CloudTreeMachineReorderLift: NSObject { let sourceID: String let layout: CloudTreeReorderLiftLayout let sourceRows: Range - /// Where the press landed. Only the pointer's travel from here picks - /// the slot; closing the machines above must not count as a move. - let grabY: CGFloat - /// How far closing the machines above moved the held row up. The row - /// starts there, under the hand, and glides into its closed slot. - let closeShift: CGFloat - let startTime: CFTimeInterval + /// The held row's top in the closed outline. + let sourceTop: CGFloat + /// Where on the held row the press landed, from its top. + let grabOffset: CGFloat + /// How far the outline was scrolled before the drag, for a drag + /// dropped without a landing. + let scrollBefore: CGFloat /// Machines closed for the drag, opened again when it ends. let collapsedIDs: [String] var placement: CloudTreeReorderLiftLayout.Placement /// The translation each row was last sent toward, so a row only /// starts a new glide when its target flips. var targets: [Int: CGFloat] = [:] + + /// The press point in the closed outline. Only the pointer's travel + /// from here picks the slot, so closing rows is never a move. + var grabY: CGFloat { sourceTop + grabOffset } } private var session: Session? + /// The row being lifted while `begin` closes rows and scrolls, before + /// the session exists, so hover stays on it through those layouts. + private var liftingID: String? + /// Called once when the pointer leaves the outline, for drags that can + /// continue somewhere else (a workspace onto a pane). + private var onLeave: (() -> Void)? private var displayLink: CADisplayLink? /// Row views this drag has moved or styled, reset when it ends. private let touched = NSHashTable.weakObjects() @@ -49,91 +66,120 @@ final class CloudTreeMachineReorderLift: NSObject { } /// The node the hand holds, so hover can stay on it. - var sourceNodeID: String? { session?.sourceID } + var sourceNodeID: String? { session?.sourceID ?? liftingID } func isActive(sequence: Int) -> Bool { session?.sequence == sequence } // MARK: Begin - /// Lifts `source` out of `siblings`. `collapse` closes the given machines - /// without recording it as the person's choice. + /// Lifts `source` out of `siblings`. `isPeer` picks the siblings it can + /// trade places with, `closes` the ones that close for the drag, and + /// `collapse` closes them without recording it as the person's choice. /// `pressY` is where the press landed, before anything closed; it - /// defaults to the outline's last mouse-down. + /// defaults to the outline's last mouse-down. Returns whether the row lifted. + @discardableResult func begin( sequence: Int, source: CloudTreeNode, siblings: [CloudTreeNode], pressY: CGFloat? = nil, + isPeer: (CloudTreeNode) -> Bool, closes: (CloudTreeNode) -> Bool, onLeave: (() -> Void)? = nil, collapse: ([CloudTreeNode]) -> Void - ) { - guard let outline else { return } + ) -> Bool { + guard let outline else { return false } discard() + liftingID = source.id + defer { liftingID = nil } let before = visualTops() - let closing = siblings.filter { node in - if case .machine = node.kind { return outline.isItemExpanded(node) } - return false - } + let scrollBefore = scrollOffset() + guard let held = outline.findItem(nodeID: source.id) else { return false } + let heldFrame = outline.rect(ofRow: outline.row(forItem: held)) + let press = pressY ?? outline.lastMouseDownPoint?.y ?? pointerY() ?? heldFrame.midY + let grabOffset = min(max(press - heldFrame.minY, 0), heldFrame.height) + let closing = siblings.compactMap { outline.findItem(nodeID: $0.id) } + .filter { closes($0) && outline.isItemExpanded($0) } let ghosts = closing.isEmpty ? [] : makeGhosts(under: Set(closing.map(\.id))) - if !closing.isEmpty { collapse(closing) } + if !closing.isEmpty { + collapse(closing) + // The document shrinks on the next layout pass, and the clip view + // may scroll when it does; settle both before anything is measured. + outline.layoutDocumentNow() + } let frames = (0..? + // The caller's tree and the outline's items can be different objects + // for the same rows, so rows are found by id. + let displayed = outline.visibleItemsByID() for sibling in siblings { - let row = outline.row(forItem: sibling) + let row = displayed[sibling.id].map { outline.row(forItem: $0) } ?? -1 guard row >= 0 else { continue } let level = outline.level(forRow: row) var end = row + 1 while end < outline.numberOfRows, outline.level(forRow: end) > level { end += 1 } - if sibling === source { + if sibling.id == source.id { sourceIndex = blocks.count sourceRows = row.. CGFloat { - guard session.closeShift != 0 else { return 0 } - let omega = 17.0 - let t = max(0, CACurrentMediaTime() - session.startTime) - let remaining = exp(-omega * t) * (1 + omega * t) - return remaining < 0.002 ? 0 : session.closeShift * CGFloat(remaining) - } + /// How far past the outline's visible edge the pointer goes before a + /// drag that can leave hands off to a native drag. + private static let leaveMargin: CGFloat = 12 /// Moves the lifted row to the pointer and returns the slot it shows. @discardableResult @@ -148,7 +194,7 @@ final class CloudTreeMachineReorderLift: NSObject { if session.sourceRows.contains(row) { // Direct manipulation: never smoothed, or the row lags the hand. layer.removeAnimation(forKey: Self.shiftKey) - Self.setShift(placement.sourceOffset + Self.remainingCloseShift(session), on: layer) + Self.setShift(placement.sourceOffset, on: layer) layer.zPosition = Self.liftZ liftStyle.apply(to: rowView, animated: true) return @@ -184,57 +230,128 @@ final class CloudTreeMachineReorderLift: NSObject { /// cancel; either way the machines closed for the drag open again and /// every row springs from where it is on screen to where it belongs. func finish(reopen: ([String]) -> Void, mutate: (() -> Bool)? = nil) -> Bool { - guard let session, outline != nil else { return false } + guard let session, let outline else { return false } displayLink?.invalidate() displayLink = nil - let before = visualTops() + onLeave = nil + let before = visualTops(viewless: { session.placement.rowOffsets[$0] ?? 0 }) + let scrollBefore = scrollOffset() resetTouched() self.session = nil isFinishing = true let result = mutate?() ?? false reopen(session.collapsedIDs) + returnScrollRange() // Row views exist only after layout; landing before it would snap. - outline?.layoutSubtreeIfNeeded() + outline.layoutDocumentNow() + if rowsFit() { + // A list with nothing to scroll rests at its top. + scroll(to: -(outline.enclosingScrollView?.contentInsets.top ?? 0), borrowing: false) + } else if let held = outline.findItem(nodeID: session.sourceID) { + // The row lands in the slot it showed (its own on a cancel), and + // the rows opening again make room around it rather than move it. + let slot = session.sourceTop + (result ? session.placement.sourceSlotOffset : 0) - scrollBefore + scroll(to: outline.rect(ofRow: outline.row(forItem: held)).minY - slot, borrowing: false) + } + outline.layoutSubtreeIfNeeded() + let scrolled = scrollOffset() - scrollBefore isFinishing = false - land(from: before, excluding: nil, fadingIn: true, liftedID: session.sourceID) + land(from: before.mapValues { $0 + scrolled }, excluding: nil, fadingIn: true, liftedID: session.sourceID) return result } /// Drops the visuals at once: the outline is about to reload under the - /// drag, or it is leaving its window. + /// drag, or it is leaving its window. A drag that could leave the tree + /// gets its native image back, as if it had left. func discard() { guard !isFinishing else { return } + let leave = onLeave displayLink?.invalidate() displayLink = nil + onLeave = nil + if let session { + returnScrollRange() + scroll(to: session.scrollBefore, borrowing: false) + } session = nil resetTouched() + leave?() } // MARK: Geometry - private func pointerY() -> CGFloat? { + private func pointer() -> NSPoint? { guard let outline, let window = outline.window else { return nil } let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) - return outline.convert(windowPoint, from: nil).y + return outline.convert(windowPoint, from: nil) } - /// Where each visible row is on screen right now, by node id: its frame - /// plus whatever translation it is showing mid-flight. - private func visualTops() -> [String: CGFloat] { + private func pointerY() -> CGFloat? { pointer()?.y } + + /// Where each row stands right now, by node id: its frame plus whatever + /// translation it is showing mid-flight. A row with no view (off screen) + /// stands where `viewless` puts it. + private func visualTops(viewless: (Int) -> CGFloat = { _ in 0 }) -> [String: CGFloat] { guard let outline else { return [:] } - var tops: [String: CGFloat] = [:] + var shifts: [Int: CGFloat] = [:] outline.enumerateAvailableRowViews { rowView, row in - guard let node = outline.item(atRow: row) as? CloudTreeNode else { return } - let shift = (rowView.layer?.presentation() ?? rowView.layer) + shifts[row] = (rowView.layer?.presentation() ?? rowView.layer) .flatMap { $0.value(forKeyPath: "transform.translation.y") as? CGFloat } ?? 0 - tops[node.id] = outline.rect(ofRow: row).minY + shift + } + var tops: [String: CGFloat] = [:] + for row in 0.. CGFloat { + outline?.enclosingScrollView?.contentView.bounds.minY ?? 0 + } + + /// Scroll range lent to this drag above and below the rows. + private var borrowed: (above: CGFloat, below: CGFloat) = (0, 0) + + /// Scrolls the clip view to `y`, as near as the document allows. When + /// `borrowing`, the outline first borrows whatever range past its rows + /// it takes to get all the way there. + private func scroll(to y: CGFloat, borrowing: Bool) { + guard let outline, let scrollView = outline.enclosingScrollView else { return } + let clip = scrollView.contentView + var bounds = clip.bounds + bounds.origin.y = y + if borrowing, let lend = outline.lendScrollRange { + let reachable = clip.constrainBoundsRect(bounds).origin.y + if reachable > y { borrowed.above += reachable - y } + if reachable < y { borrowed.below += y - reachable } + if reachable != y { lend(borrowed.above, borrowed.below) } + } + let target = clip.constrainBoundsRect(bounds).origin.y + guard abs(target - clip.bounds.minY) > 0.5 else { return } + clip.scroll(to: NSPoint(x: clip.bounds.minX, y: target)) + scrollView.reflectScrolledClipView(clip) + } + + private func returnScrollRange() { + guard borrowed.above != 0 || borrowed.below != 0 else { return } + borrowed = (0, 0) + outline?.lendScrollRange?(0, 0) + } + + /// Whether every row fits on screen without scrolling. + private func rowsFit() -> Bool { + guard let outline, let scrollView = outline.enclosingScrollView else { return true } + guard outline.numberOfRows > 0 else { return true } + let insets = scrollView.contentInsets + return outline.rect(ofRow: outline.numberOfRows - 1).maxY + insets.top + insets.bottom + <= scrollView.contentView.bounds.height + } + + /// Springs every visible row from its old top to its frame. A row that + /// was not there before (a machine's rows opening again) travels with + /// its machine and fades in. private func land( from before: [String: CGFloat], excluding excludedID: String?, fadingIn: Bool, liftedID: String? = nil ) { @@ -279,7 +396,7 @@ final class CloudTreeMachineReorderLift: NSObject { guard !reduceMotion else { return } if let delta, abs(delta) > 0.5 { Self.setShift(delta, on: layer) - Self.glide(layer, to: 0) + Self.glide(layer, from: delta, to: 0) } if appearing { let fade = CABasicAnimation(keyPath: "opacity") @@ -306,7 +423,7 @@ final class CloudTreeMachineReorderLift: NSObject { // MARK: Closing machines /// Pictures of the rows that are about to close, so they can fade out - /// into their machine instead of vanishing. + /// into their machine or workspace instead of vanishing. private struct Ghost { let layer: CALayer let machineID: String @@ -338,7 +455,10 @@ final class CloudTreeMachineReorderLift: NSObject { return ghosts } - private func animateGhosts(_ ghosts: [Ghost], before: [String: CGFloat]) { + /// Folds each picture toward where its machine now stands. `before` is + /// from before the rows closed; `scrolled` is how far the outline has + /// scrolled since, which the pictures undo so they start where they were. + private func animateGhosts(_ ghosts: [Ghost], before: [String: CGFloat], scrolled: CGFloat) { guard let outline, !ghosts.isEmpty else { return } CATransaction.begin() CATransaction.setCompletionBlock { @@ -347,13 +467,13 @@ final class CloudTreeMachineReorderLift: NSObject { let machinesByID = outline.visibleItemsByID() for ghost in ghosts { // The rows fold toward where their machine now stands. - var travel: CGFloat = 0 + var travel = scrolled if let machine = machinesByID[ghost.machineID], let old = before[ghost.machineID] { let row = outline.row(forItem: machine) if row >= 0 { travel = outline.rect(ofRow: row).minY - old } } let move = CABasicAnimation(keyPath: "transform.translation.y") - move.fromValue = 0 + move.fromValue = scrolled move.toValue = travel let fade = CABasicAnimation(keyPath: "opacity") fade.fromValue = 1 @@ -379,10 +499,12 @@ final class CloudTreeMachineReorderLift: NSObject { } /// Glides a row's vertical shift to `target` on a soft spring, starting - /// from where the row is on screen so a retarget mid-glide never jumps. - /// Explicit animation: a view's backing layer ignores implicit actions. - private static func glide(_ layer: CALayer, to target: CGFloat) { - let current = (layer.presentation() ?? layer).value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + /// from `start`, or from where the row is on screen so a retarget + /// mid-glide never jumps. Explicit animation: a view's backing layer + /// ignores implicit actions. + private static func glide(_ layer: CALayer, from start: CGFloat? = nil, to target: CGFloat) { + let current = start + ?? (layer.presentation() ?? layer).value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 let spring = CASpringAnimation(keyPath: "transform.translation.y") spring.fromValue = current spring.toValue = target diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index 91929af5b509..af474dfac071 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -1,13 +1,64 @@ import AppKit -/// Coordinator side of the continuous machine drag (`CloudTreeMachineReorderLift`). +/// Coordinator side of the continuous machine and workspace drag +/// (`CloudTreeMachineReorderLift`). extension CloudTreeOutlineView.Coordinator { - /// Starts the lift when the drag that just began carries a machine row. + /// Starts the lift when the drag that just began carries a machine row + /// or a row that reorders among its siblings (a workspace). func liftMachineDrag(_ session: NSDraggingSession, draggedItems: [Any], in outlineView: NSOutlineView) { - guard machineLiftEnabled, let node = draggedItems.first as? CloudTreeNode, node.canReorderMachine, + guard machineLiftEnabled, let node = draggedItems.first as? CloudTreeNode, let outline = outlineView as? CloudTreeNSOutlineView else { return } + if node.canReorderMachine { + hideDragImage(of: session, in: outline) + beginMachineLift(session, node: node, in: outline) + } else if node.canOrganize { + beginOrganizationLift(session, node: node, in: outline) + } + } + + /// Lifts a workspace (or another organizable row) among the siblings in + /// its pin tier, the same way a machine lifts. These rows can also be + /// dropped on a pane, so leaving the tree puts the rows back and hands + /// the drag its native image again. + private func beginOrganizationLift(_ session: NSDraggingSession, node: CloudTreeNode, in outline: CloudTreeNSOutlineView) { + guard let parent = CloudSidebarOrganizationTree(nodes: nodes).parent(of: node.id) else { +#if DEBUG + cmuxDebugLog("cloud.lift.organization skip=noParent node=\(node.id)") +#endif + return + } + let state = organization.state + let group = parent.organizationGroupID + let pinned = state.isPinned(node.id, parent: group) + let isPeer = { (sibling: CloudTreeNode) in + sibling.canOrganize && state.isPinned(sibling.id, parent: group) == pinned + } + // Without a picture to hand back, a drag onto a pane would be invisible. + guard let image = dragImage(of: node, in: outline) else { +#if DEBUG + cmuxDebugLog("cloud.lift.organization skip=noSnapshot node=\(node.id)") +#endif + return + } + let lifted = outline.machineLift.begin( + sequence: session.draggingSequenceNumber, source: node, siblings: parent.children, + isPeer: isPeer, closes: isPeer, + onLeave: { [weak self, weak outline] in + guard let self, let outline else { return } + finishMachineLift() + restoreDragImage(image, of: session, in: outline) + } + ) { rows in + withProgrammaticUpdate { + for row in rows { outline.collapseItem(row) } + } + } +#if DEBUG + cmuxDebugLog("cloud.lift.organization lifted=\(lifted) node=\(node.id) siblings=\(parent.children.count)") +#endif + guard lifted else { return } hideDragImage(of: session, in: outline) - beginMachineLift(session, node: node, in: outline) + installMachineLiftMouseUpMonitor(for: session, in: outline) } /// Lifts a machine row for the drag that just began. Open machines close @@ -19,7 +70,9 @@ extension CloudTreeOutlineView.Coordinator { guard node.canReorderMachine, let scope = CloudMachineReorderScope(machineNodeID: node.id, roots: nodes) else { return } outline.machineLift.begin( - sequence: session.draggingSequenceNumber, source: node, siblings: scope.siblings, pressY: pressY + sequence: session.draggingSequenceNumber, source: node, siblings: scope.siblings, pressY: pressY, + isPeer: { $0.canReorderMachine && $0.isPinned == node.isPinned }, + closes: { if case .machine = $0.kind { return true }; return false } ) { machines in withProgrammaticUpdate { for machine in machines { outline.collapseItem(machine) } @@ -40,6 +93,29 @@ extension CloudTreeOutlineView.Coordinator { } } + /// A picture of the dragged row, taken before the lift styles it. + private func dragImage(of node: CloudTreeNode, in outline: NSOutlineView) -> NSImage? { + let row = outline.row(forItem: node) + guard row >= 0, let rowView = outline.rowView(atRow: row, makeIfNecessary: false), + let bitmap = rowView.bitmapImageRepForCachingDisplay(in: rowView.bounds) else { return nil } + rowView.cacheDisplay(in: rowView.bounds, to: bitmap) + let image = NSImage(size: rowView.bounds.size) + image.addRepresentation(bitmap) + return image + } + + /// Gives a drag that left the tree its image back, so it reads as a + /// normal drag over the panes. + private func restoreDragImage(_ image: NSImage, of session: NSDraggingSession, in outline: NSOutlineView) { + session.animatesToStartingPositionsOnCancelOrFail = true + session.enumerateDraggingItems( + options: [], for: outline, classes: [NSPasteboardItem.self], searchOptions: [:] + ) { item, _, stop in + stop.pointee = true + item.setDraggingFrame(item.draggingFrame, contents: image) + } + } + /// The slot the lifted row shows for this drag, after following the /// pointer to `info`'s location; nil when no lift owns the drag. func machineLiftSlot(_ outlineView: NSOutlineView, info: any NSDraggingInfo) -> Int? { diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift index 6d3ceead9bad..b2add727f04b 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+Organization.swift @@ -43,7 +43,8 @@ extension CloudTreeOutlineView.Coordinator { proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { // The tree is a navigation/source surface. Pane destinations own the // ownership warning and announcement; the tree draws no drag hints. - guard ownershipRejection(info: info, item: item) == nil else { + // A lifted drag only reorders, whatever row the pointer is over. + guard isMachineLiftActive(outlineView, info: info) || ownershipRejection(info: info, item: item) == nil else { if let cloudOutline = outlineView as? CloudTreeNSOutlineView { cloudOutline.clearDragDestination(sequence: info.draggingSequenceNumber) cloudOutline.reorderPresentation.clear(sequence: info.draggingSequenceNumber) @@ -60,8 +61,8 @@ extension CloudTreeOutlineView.Coordinator { outlineView.setDropItem(drop.parent, dropChildIndex: drop.childIndex) if let cloudOutline = outlineView as? CloudTreeNSOutlineView { cloudOutline.trackDragDestination(sequenceNumber: info.draggingSequenceNumber) - // Machines show their destination by the rows parting, never a line. - if case .organization = drop.operation { + // A lifted drag shows its destination by the rows parting, never a line. + if case .organization = drop.operation, !isMachineLiftActive(outlineView, info: info) { cloudOutline.reorderPresentation.show(drop, sequence: info.draggingSequenceNumber) } else { cloudOutline.reorderPresentation.clear(sequence: info.draggingSequenceNumber) @@ -73,11 +74,18 @@ extension CloudTreeOutlineView.Coordinator { func outlineView(_ outlineView: NSOutlineView, acceptDrop info: any NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { defer { (outlineView as? CloudTreeNSOutlineView)?.clearDragDestination(sequence: info.draggingSequenceNumber) } - guard ownershipRejection(info: info, item: item) == nil else { return false } + guard isMachineLiftActive(outlineView, info: info) || ownershipRejection(info: info, item: item) == nil else { + return false + } guard let drop = organizationDrop(outlineView, info: info, item: item, index: index) else { return false } switch drop.operation { case .organization(let action): - return organize(action, nodeID: drop.sourceID) + guard isMachineLiftActive(outlineView, info: info) else { + return organize(action, nodeID: drop.sourceID) + } + return finishMachineLift { [weak self] in + self?.organize(action, nodeID: drop.sourceID) ?? false + } case .machine(let id, let move): guard let actions = machineOrdering(for: info, nodeID: drop.sourceID) else { return false } guard isMachineLiftActive(outlineView, info: info) else { @@ -126,7 +134,7 @@ extension CloudTreeOutlineView.Coordinator { sourceID: id, nodes: nodes, state: organization.state, proposedItem: item as? CloudTreeNode, proposedChildIndex: index, dropAfterItem: row >= 0 && point.y >= outlineView.rect(ofRow: row).midY, - machineSlot: machineLiftSlot(outlineView, info: info) + liftSlot: machineLiftSlot(outlineView, info: info) ) else { return nil } if case .machine(let machineID, let move) = drop.operation { guard machineOrdering(for: info, nodeID: id)?.canMove(machineID, move) == true else { return nil } diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 0ebc6541d983..34e61552f471 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -110,29 +110,146 @@ struct CloudMachineOrderingTests { try fixture.end(drag) } - @Test("Closing open machines above the held one is not a move") + /// Where the held row's grab point comes to rest, in window coordinates: + /// its laid-out frame, which is its slot while the slot is unchanged. + private func restingPoint(_ outline: NSOutlineView, node: CloudTreeNode, grabOffset: CGFloat) -> CGFloat { + outline.convert(NSPoint(x: 10, y: outline.rect(ofRow: outline.row(forItem: node)).minY + grabOffset), to: nil).y + } + + /// Where the held row's grab point shows, in window coordinates: its + /// laid-out frame plus the lift's translation. + private func heldPoint(_ outline: NSOutlineView, node: CloudTreeNode, grabOffset: CGFloat) throws -> CGFloat { + let row = outline.row(forItem: node) + let layer = try #require(outline.rowView(atRow: row, makeIfNecessary: false)?.layer) + return outline.convert(NSPoint(x: 10, y: outline.rect(ofRow: row).minY + layer.transform.m42 + grabOffset), to: nil).y + } + + /// Fourteen open machines under the Cloud Machines section, scrolled to the bottom. + private func scrolledOpenFixture() throws -> (CloudMachineOrderingFixture, NSClipView, [String]) { + let ids = (0..<14).map { String(format: "m%02d", $0) } + let fixture = CloudMachineOrderingFixture(ids: ids, sectioned: true) + let outline = try #require(fixture.coordinator.outlineView) + let clip = try #require(outline.enclosingScrollView?.contentView) + outline.expandItem(try #require(fixture.section)) + for id in ids { outline.expandItem(try fixture.root(id), expandChildren: true) } + fixture.base.container.layoutSubtreeIfNeeded() + var bottom = clip.bounds + bottom.origin.y = outline.frame.height + clip.scroll(to: clip.constrainBoundsRect(bottom).origin) + outline.enclosingScrollView?.reflectScrolledClipView(clip) + fixture.base.container.layoutSubtreeIfNeeded() + #expect(clip.bounds.minY > 100, "the list scrolls: \(clip.bounds.minY)") + return (fixture, clip, fixture.order) + } + + /// The scroll view's insets and inset mode, which a lift may change only while it lasts. + private func insets(_ outline: NSOutlineView) -> [CGFloat] { + guard let scrollView = outline.enclosingScrollView else { return [] } + let insets = scrollView.contentInsets + return [insets.top, insets.bottom, scrollView.automaticallyAdjustsContentInsets ? 1 : 0] + } + + @Test("Closing open machines above the held one is not a move, and the row stays under the hand") func liftedDragBelowOpenMachines() throws { let fixture = CloudMachineOrderingFixture(sectioned: true) defer { fixture.close() } let coordinator = fixture.coordinator let outline = try #require(coordinator.outlineView) outline.expandItem(try #require(fixture.section)) - outline.expandItem(try fixture.root("a"), expandChildren: true) - outline.expandItem(try fixture.root("b"), expandChildren: true) + let a = try fixture.root("a") + let b = try fixture.root("b") + outline.expandItem(a, expandChildren: true) + outline.expandItem(b, expandChildren: true) + fixture.base.container.layoutSubtreeIfNeeded() let source = try fixture.root("c") + let frame = outline.rect(ofRow: outline.row(forItem: source)) + let hand = outline.convert(NSPoint(x: 10, y: frame.midY), to: nil) + let resting = insets(outline) let drag = try fixture.begin("c") - let press = outline.rect(ofRow: outline.row(forItem: source)).midY - coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) - #expect(!outline.isItemExpanded(try fixture.root("a")) && !outline.isItemExpanded(try fixture.root("b"))) - // A small nudge from the press point: c's row moved up as a and b - // closed, but only the pointer's travel counts. - drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: frame.midY) + #expect(!outline.isItemExpanded(a) && !outline.isItemExpanded(b)) + // A small nudge from where the hand pressed: a and b closed above c, + // but only the pointer's travel counts. + drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y - 3) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0).isEmpty) #expect(outline.machineLift.slot == 2, "c keeps its place among a, b and d") + let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(held - drag.info.draggingLocation.y) < 1, "the row stays under the hand: \(held) vs \(drag.info.draggingLocation.y)") + let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") try fixture.end(drag) #expect(fixture.order == ["a", "b", "c", "d"]) - #expect(outline.isItemExpanded(try fixture.root("a")) && outline.isItemExpanded(try fixture.root("b"))) + #expect(outline.isItemExpanded(try fixture.root("a"))) + #expect(outline.isItemExpanded(try fixture.root("b"))) + #expect(insets(outline) == resting, "the lift gives its scroll range back: \(insets(outline)) vs \(resting)") + } + + @Test("The bottom machine of a scrolled list stays under the hand, and a cancel restores the scroll") + func liftedDragAtBottomOfScrolledList() throws { + let (fixture, clip, order) = try scrolledOpenFixture() + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + let scrolled = clip.bounds.minY + let last = try #require(order.last) + let source = try fixture.root(last) + let frame = outline.rect(ofRow: outline.row(forItem: source)) + let hand = outline.convert(NSPoint(x: 10, y: frame.midY), to: nil) + #expect(clip.bounds.contains(clip.convert(hand, from: nil)), "the bottom machine is on screen") + let resting = insets(outline) + let drag = try fixture.begin(last) + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: frame.midY) + #expect(order.allSatisfy { id in (try? fixture.root(id)).map { !outline.isItemExpanded($0) } == true }) + // Up, inside the span: past its end the row resists the pointer. + drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y + 3) + #expect(coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: nil, proposedChildIndex: 0).isEmpty) + #expect(outline.machineLift.slot == order.count - 1, "the bottom machine keeps the last place") + let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(held - drag.info.draggingLocation.y) < 1, "the row stays under the hand: \(held) vs \(drag.info.draggingLocation.y)") + let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") + + try fixture.end(drag) + #expect(fixture.order == order) + #expect(order.allSatisfy { id in (try? fixture.root(id)).map { outline.isItemExpanded($0) } == true }) + #expect(abs(clip.bounds.minY - scrolled) < 1, "a cancel puts the list back where it was: \(clip.bounds.minY) vs \(scrolled)") + #expect(insets(outline) == resting, "the lift gives its scroll range back: \(insets(outline)) vs \(resting)") + } + + @Test("Rows closing below the held machine leave every row reachable by scrolling") + func liftedDragKeepsRowsReachable() throws { + let (fixture, clip, order) = try scrolledOpenFixture() + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + // Second to last: the last machine's rows close below it too. + let id = order[order.count - 2] + let source = try fixture.root(id) + let frame = outline.rect(ofRow: outline.row(forItem: source)) + let hand = outline.convert(NSPoint(x: 10, y: frame.midY), to: nil) + let resting = insets(outline) + let drag = try fixture.begin(id) + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: frame.midY) + drag.info.draggingLocation = hand + _ = coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0) + let held = try heldPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(held - hand.y) < 1, "the row stays under the hand: \(held) vs \(hand.y)") + let slot = restingPoint(outline, node: source, grabOffset: frame.height / 2) + #expect(abs(slot - hand.y) < 1, "the row's slot stays where the hand pressed: \(slot) vs \(hand.y)") + + // Scrolled all the way up mid-drag, the first row shows. + var top = clip.bounds + top.origin.y = -outline.frame.height + clip.scroll(to: clip.constrainBoundsRect(top).origin) + outline.enclosingScrollView?.reflectScrolledClipView(clip) + fixture.base.container.layoutSubtreeIfNeeded() + let first = try #require(outline.rowView(atRow: 0, makeIfNecessary: false)?.layer) + let firstTop = outline.rect(ofRow: 0).minY + first.transform.m42 + #expect(firstTop >= clip.bounds.minY - 0.5, "the first row is reachable: \(firstTop) vs \(clip.bounds.minY)") + try fixture.end(drag) + #expect(insets(outline) == resting, "the lift gives its scroll range back: \(insets(outline)) vs \(resting)") } @Test("A lifted drag released on its own slot moves nothing and reopens machines") @@ -144,11 +261,14 @@ struct CloudMachineOrderingTests { outline.expandItem(try #require(fixture.section)) outline.expandItem(try fixture.root("c")) let source = try fixture.root("b") - let drag = try fixture.begin("b") + fixture.base.container.layoutSubtreeIfNeeded() let press = outline.rect(ofRow: outline.row(forItem: source)).midY + // The hand is a window point: rows closing may scroll the outline under it. + let hand = outline.convert(NSPoint(x: 10, y: press), to: nil) + let drag = try fixture.begin("b") coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) #expect(!outline.isItemExpanded(try fixture.root("c"))) - drag.info.draggingLocation = outline.convert(NSPoint(x: 10, y: press + 3), to: nil) + drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y - 3) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0).isEmpty) try fixture.end(drag) diff --git a/cmuxTests/CloudSidebarNativeDropTests.swift b/cmuxTests/CloudSidebarNativeDropTests.swift index ff700fbcdc32..c2a8d853aa6a 100644 --- a/cmuxTests/CloudSidebarNativeDropTests.swift +++ b/cmuxTests/CloudSidebarNativeDropTests.swift @@ -277,6 +277,7 @@ struct CloudSidebarNativeDropTests { organization: fixture.catalog.sidebarOrganization, tabDragTransferRegistry: { Issue.record("Folder drags cannot request pane capabilities"); return nil } ) + coordinator.machineLiftEnabled = false let container = CloudTreeContainerView(coordinator: coordinator) // Empty daemon records are deliberately hidden by the catalog builder. // Exercise the writer's resource-independent contract directly. diff --git a/cmuxTests/CloudSidebarOrderingTests.swift b/cmuxTests/CloudSidebarOrderingTests.swift index 02032daedd4d..ed5e2d266e64 100644 --- a/cmuxTests/CloudSidebarOrderingTests.swift +++ b/cmuxTests/CloudSidebarOrderingTests.swift @@ -118,6 +118,31 @@ struct CloudSidebarOrderingTests { #expect(drag.string(forType: .cloudSidebarRow) == folder.id) } + @Test("A lifted workspace drop commits the slot the rows show, inside its pin tier") + func liftedWorkspaceDropUsesSlot() throws { + let fixture = CloudSidebarOrderingFixture() + defer { fixture.close() } + let nodes = fixture.nodes() + let first = fixture.folderID("ws_1"), second = fixture.folderID("ws_2") + let owner = fixture.catalog.sidebarOrganization + func drop(_ id: String, slot: Int) -> CloudSidebarOrganizationAction? { + let drop = CloudSidebarOrganizationDrop( + sourceID: id, nodes: nodes, state: owner.state, proposedItem: nil, + proposedChildIndex: NSOutlineViewDropOnItemIndex, dropAfterItem: false, liftSlot: slot + ) + guard case .organization(let action) = drop?.operation else { return nil } + return action + } + #expect(drop(first, slot: 1) == .after(second)) + #expect(drop(second, slot: 0) == .before(first)) + // The row's own slot, or one past the tier, is not a move. + #expect(drop(first, slot: 0) == nil) + #expect(drop(first, slot: 2) == nil) + #expect(owner.perform(.pin, id: second, nodes: nodes)) + // Alone in its tier, a pinned row has nowhere to go. + #expect(drop(second, slot: 0) == nil) + } + @Test("A remote workspace keeps organization dragging when pane projection is unavailable") func remoteWorkspaceFallsBackToOrganizationWithoutProjectionRegistry() throws { let fixture = CloudSidebarOrderingFixture() @@ -229,6 +254,8 @@ final class CloudSidebarOrderingFixture { expansionStore: CloudTreeExpansionStore(defaults: defaults), organization: catalog.sidebarOrganization, tabDragTransferRegistry: { [transferRegistry] in transferRegistry } ) + // These tests drive AppKit's row proposals and the insertion line. + coordinator.machineLiftEnabled = false container = CloudTreeContainerView(coordinator: coordinator) window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 380, height: 560), styleMask: [.titled], backing: .buffered, defer: false) window.contentView = container diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift index b49e3d77acc8..d149a2124914 100644 --- a/cmuxTests/RightSidebarTabCustomizationTests.swift +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -1,4 +1,5 @@ import Foundation +import CmuxSidebar import XCTest #if canImport(cmux_DEV)