Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d68c899
cloud sidebar: workspaces reorder with the same lift as machines
lucasr1b Oct 3, 2026
0933048
cloud sidebar: workspace lift review fixes
lucasr1b Oct 3, 2026
818bff1
cloud sidebar: lift begin returns whether the row lifted
lucasr1b Oct 3, 2026
a22cba5
cloud sidebar: workspace lift finds rows by id and snapshots the row …
lucasr1b Oct 3, 2026
fd8546f
Animate Cloud machine detail tab switching
austinywang Oct 3, 2026
1577ae8
Fix tab strip animation composition
austinywang Oct 3, 2026
59a8d91
Fix cloud reorder drag origin after collapsing rows
austinywang Oct 3, 2026
15de757
Restore outline drag origin capture
austinywang Oct 3, 2026
18b2080
Revert "Fix cloud reorder drag origin after collapsing rows"
austinywang Oct 3, 2026
4271744
Keep dragged cloud folder open during lift
austinywang Oct 3, 2026
c35cbd8
Test that a lifted Cloud row stays under the hand when rows close abo…
austinywang Oct 3, 2026
9079b58
Anchor the Cloud reorder lift on the held row
austinywang Oct 3, 2026
aa0ec46
Import CmuxSidebar in the right sidebar tab tests
austinywang Oct 3, 2026
137bb0f
Test that a lift keeps every row reachable and returns its scroll range
austinywang Oct 3, 2026
280ef97
Borrow scroll range for the lift instead of shifting every row
austinywang Oct 3, 2026
5e9dcab
Test that a lift restores the scroll view's own insets
austinywang Oct 3, 2026
e4aa428
Hold the scroll view's insets still while a lift borrows range
austinywang Oct 3, 2026
03fed7d
Keep throwing lookups out of #expect in the async lift test
austinywang Oct 3, 2026
a36d39f
Assert the held row's slot, not a timed glide, in the lift tests
austinywang Oct 3, 2026
d1d43eb
Scroll the lift test fixture to a finite end
austinywang Oct 3, 2026
7d1a35d
Pin the hand to a window point in the lifted cancel test
austinywang Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion Sources/Cloud/CloudTreeContainerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([
Expand All @@ -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)
Expand Down
45 changes: 34 additions & 11 deletions Sources/Cloud/CloudTreeMachineDetailTabsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) }
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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) } ?? "")
Expand All @@ -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))
}
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions Sources/Cloud/CloudTreeNSOutlineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/CloudTreeOutlineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 17 additions & 8 deletions Sources/Cloud/Sidebar/CloudSidebarOrganizationDrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading