diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/CloudMachinesSettingsActions.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/CloudMachinesSettingsActions.swift index 7f42d4e39f52..8772c504d0b1 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/CloudMachinesSettingsActions.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/CloudMachinesSettingsActions.swift @@ -23,6 +23,13 @@ public protocol CloudMachinesSettingsActions: AnyObject { func signInForCloudMachines() /// The caller's machine plan, or nil when it is not available. func cloudMachinesPlanSummary() async -> CloudMachinesPlanSummary? + /// Whether the signed-in account's plan includes Cloud before it is + /// enabled: true for Pro and Max, false for Free, nil when signed out or + /// the plan could not be loaded (enable then lets the server decide). + func cloudMachinesPlanIncludesCloud() async -> Bool? + /// The signed-in account's id (observable), so Settings re-checks the + /// plan when the user signs in, signs out or switches accounts. + var cloudMachinesAccountID: String? { get } /// Reveals the right-sidebar Machines panel. func openCloudMachinesPanel() /// Opens the optional system-wide VPN setup flow. @@ -43,6 +50,8 @@ public extension CloudMachinesSettingsActions { func cloudMachinesActivationUpdates() -> AsyncStream { AsyncStream { $0.yield(.unavailable); $0.finish() } } + func cloudMachinesPlanIncludesCloud() async -> Bool? { nil } + var cloudMachinesAccountID: String? { nil } func signInForCloudMachines() {} func cloudMachinesPlanSummary() async -> CloudMachinesPlanSummary? { nil } func openCloudMachinesPanel() {} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CloudMachinesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CloudMachinesSection.swift index fdd764876abd..8e1ecd1c81ad 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CloudMachinesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/CloudMachinesSection.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxFoundation import SwiftUI @@ -11,6 +12,12 @@ public struct CloudMachinesSection: View { @State private var activationState: CloudMachinesActivationState @State private var plan: CloudMachinesPlanSummary? @State private var hasLoaded = false + /// Free accounts get Upgrade in place of the Enable toggle; nil until the + /// plan answers (or when signed out), which keeps the toggle. + @State private var planIncludesCloud: Bool? + /// Bumped when the app comes back to the front, e.g. after upgrading in + /// the browser, so the row stops offering Upgrade without a restart. + @State private var planCheckGeneration = 0 public init(hostActions: SettingsHostActions) { self.hostActions = hostActions @@ -25,20 +32,44 @@ public struct CloudMachinesSection: View { ) SettingsCard { activationRow - SettingsCardDivider() - planRow - SettingsCardDivider() - panelRow - SettingsCardDivider() - vpnRow + // Plan, machines and VPN all need Cloud on; before that each + // could only say "Open Machines", so they wait until it is. + if activationState.isEnabled { + SettingsCardDivider() + planRow + SettingsCardDivider() + panelRow + SettingsCardDivider() + vpnRow + } } - .settingsSearchAnchors([ - "setting:cloudMachines:enable", - "setting:cloudMachines:plan", - "setting:cloudMachines:open-panel", - "setting:cloudMachines:vpn", - ]) + .settingsSearchAnchors( + activationState.isEnabled + ? [ + "setting:cloudMachines:enable", + "setting:cloudMachines:plan", + "setting:cloudMachines:open-panel", + "setting:cloudMachines:vpn", + ] + : ["setting:cloudMachines:enable"] + ) .task { await observeActivation() } + .task(id: PlanCheckKey( + showsEnableToggle: showsEnableToggle, + accountID: hostActions.cloudMachinesAccountID, + generation: planCheckGeneration + )) { + guard showsEnableToggle, hostActions.cloudMachinesAccountID != nil else { + planIncludesCloud = nil + return + } + let includesCloud = await hostActions.cloudMachinesPlanIncludesCloud() + guard !Task.isCancelled else { return } + planIncludesCloud = includesCloud + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + planCheckGeneration &+= 1 + } .task(id: activationState.isEnabled) { plan = nil hasLoaded = false @@ -54,7 +85,7 @@ public struct CloudMachinesSection: View { private var activationRow: some View { SettingsCardRow( searchAnchorID: "setting:cloudMachines:enable", - String(localized: "settings.cloudMachines.enable.title", defaultValue: "Enable Cloud"), + String(localized: "settings.cloudMachines.enable.title.enable", defaultValue: "Enable Cloud Machines"), subtitle: activationSubtitle ) { activationControl @@ -65,6 +96,13 @@ public struct CloudMachinesSection: View { @ViewBuilder private var activationControl: some View { switch activationState { + case .disabled where planIncludesCloud == false, .cancelled where planIncludesCloud == false: + Button(String(localized: "settings.cloudMachines.enable.upgrade", defaultValue: "Upgrade")) { + hostActions.openCloudMachinesBilling() + } + .buttonStyle(.bordered) + .controlSize(.small) + .accessibilityIdentifier("SettingsCloudEnableUpgrade") case .disabled, .cancelled: Toggle( "", @@ -75,7 +113,7 @@ public struct CloudMachinesSection: View { ) .labelsHidden() .controlSize(.small) - .accessibilityLabel(String(localized: "settings.cloudMachines.enable.title", defaultValue: "Enable Cloud")) + .accessibilityLabel(String(localized: "settings.cloudMachines.enable.title.enable", defaultValue: "Enable Cloud Machines")) .accessibilityIdentifier("SettingsCloudEnableToggle") case .enabling: HStack(spacing: 8) { @@ -99,7 +137,7 @@ public struct CloudMachinesSection: View { ) .labelsHidden() .controlSize(.small) - .accessibilityLabel(String(localized: "settings.cloudMachines.enable.title", defaultValue: "Enable Cloud")) + .accessibilityLabel(String(localized: "settings.cloudMachines.enable.title.enable", defaultValue: "Enable Cloud Machines")) .accessibilityIdentifier("SettingsCloudEnableToggle") case .failed(let failure): HStack(spacing: 8) { @@ -130,8 +168,27 @@ public struct CloudMachinesSection: View { } } + private struct PlanCheckKey: Equatable { + let showsEnableToggle: Bool + let accountID: String? + let generation: Int + } + + /// Cloud is off, so the row offers to turn it on (or to upgrade first). + private var showsEnableToggle: Bool { + switch activationState { + case .disabled, .cancelled: return true + default: return false + } + } + private var activationSubtitle: String { switch activationState { + case .disabled where planIncludesCloud == false, .cancelled where planIncludesCloud == false: + return String( + localized: "settings.cloudMachines.enable.requiresPro.subtitle", + defaultValue: "Your current plan does not include Cloud machine access." + ) case .disabled, .cancelled: return String( localized: "settings.cloudMachines.enable.subtitle", diff --git a/Sources/Auth/AccountSignInView.swift b/Sources/Auth/AccountSignInView.swift index a71d4a2c025d..8807bd8478fb 100644 --- a/Sources/Auth/AccountSignInView.swift +++ b/Sources/Auth/AccountSignInView.swift @@ -72,7 +72,7 @@ private struct AccountSignInIdleView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) Button(String(localized: "account.signIn.start", defaultValue: "Sign In"), action: onSignIn) - .buttonStyle(.borderedProminent) + .cloudProminentButtonStyle() .accessibilityIdentifier("AccountSignInStartButton") } } @@ -132,7 +132,7 @@ private struct AccountSignInFailureView: View { .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) Button(String(localized: "account.signIn.tryAgain", defaultValue: "Try Again"), action: onTryAgain) - .buttonStyle(.borderedProminent) + .cloudProminentButtonStyle() .accessibilityIdentifier("AccountSignInRetryButton") if hasFallbackLink { AccountSignInFallbackActions( diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index 7d88f1aca78d..08ef1e0eb5d8 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -25,6 +25,16 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { private(set) var isProUpgradeAvailable: Bool private(set) var isProActive = false private(set) var canManageBilling = false + /// The account whose plan `isProActive` describes, or nil before the + /// billing plan has answered for anyone. Kept here rather than in a view's + /// state so a rebuilt Cloud panel keeps showing Enable Cloud or Upgrade + /// instead of falling back to "Checking your cmux plan…". + private(set) var billingPlanIdentityID: String? + /// Whether `isProActive` is a real answer for the signed-in account. + var hasLoadedBillingPlan: Bool { + guard let billingPlanIdentityID else { return false } + return billingPlanIdentityID == currentIdentity?.id + } var teamObservationRevision: UInt64 = 0 /// Pending selection is shared by Settings, the menu and socket actions. /// Cloud requests keep using the confirmed coordinator scope until success. @@ -188,6 +198,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { await browserSignIn.signOut() isProActive = false canManageBilling = false + billingPlanIdentityID = nil } /// Set for the whole switch so sign-in gates show its progress instead of @@ -233,6 +244,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { await browserSignIn.signOut(timeout: timeout) isProActive = false canManageBilling = false + billingPlanIdentityID = nil } func refreshCurrentUser() async { @@ -242,14 +254,17 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { } func refreshBillingPlan() async { - guard coordinator.currentUser != nil else { + guard coordinator.currentUser != nil, let identityID = currentIdentity?.id else { isProActive = false canManageBilling = false + billingPlanIdentityID = nil return } var request = URLRequest(url: AuthEnvironment.apiBaseURL.appendingPathComponent("api/billing/plan")) request.httpMethod = "GET" request.setValue("application/json", forHTTPHeaderField: "Accept") + // URLSession's default is 60 s; a plan check that slow is a failure. + request.timeoutInterval = 15 if let tokens = try? await coordinator.currentTokens() { request.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization") @@ -258,18 +273,38 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { do { let (data, response) = try await URLSession.shared.data(for: request) + // The account may have changed while the request was in flight. + guard currentIdentity?.id == identityID else { return } guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - isProActive = false - canManageBilling = false + forgetBillingPlanUnlessKnown(for: identityID) return } let decoded = try JSONDecoder().decode(BillingPlanResponse.self, from: data) isProActive = decoded.isPro canManageBilling = decoded.billingManagement == .stripe + billingPlanIdentityID = identityID } catch { + // A cancelled request (the panel went away) says nothing about the plan. + if error is CancellationError || (error as? URLError)?.code == .cancelled { return } + guard currentIdentity?.id == identityID else { return } + forgetBillingPlanUnlessKnown(for: identityID) + } + } + + /// A failed check keeps a real answer for the same account, read at the + /// time of the failure (another refresh may have answered meanwhile). + /// With no answer to keep, the plan is unknown rather than "not Pro". + private func forgetBillingPlanUnlessKnown(for identityID: String) { + // Keep a known Pro answer so a transient refresh does not replace the + // upgrade path with Enable Cloud. A cached Free answer is deliberately + // invalidated, since the toggle must remain usable when the refresh + // could not confirm the plan. + guard billingPlanIdentityID == identityID, isProActive else { isProActive = false canManageBilling = false + billingPlanIdentityID = nil + return } } diff --git a/Sources/Cloud/CloudMachinesEnablementView.swift b/Sources/Cloud/CloudMachinesEnablementView.swift index b8cb8e5d3ab1..e1e4aadc929f 100644 --- a/Sources/Cloud/CloudMachinesEnablementView.swift +++ b/Sources/Cloud/CloudMachinesEnablementView.swift @@ -183,7 +183,7 @@ struct CloudMachinesEnablementView: View { Text(String(localized: "cloud.enable.action", defaultValue: "Enable Cloud")) .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .cloudProminentButtonStyle() .controlSize(.regular) .accessibilityIdentifier("CloudMachinesEnableButton") Text(String(localized: "cloud.enable.planNote", defaultValue: "Requires a cmux Pro plan.")) @@ -254,13 +254,25 @@ struct CloudMachinesEnablementView: View { .controlSize(.regular) .accessibilityIdentifier(identifier) if prominent { - button.buttonStyle(.borderedProminent) + button.cloudProminentButtonStyle() } else { button.buttonStyle(.bordered) } } } +extension View { + /// `.borderedProminent` that stays blue when the window is inactive. + /// The right sidebar forces its own color scheme + /// (`RightSidebarPanelView`); when that disagrees with the window's + /// appearance, AppKit draws the inactive gray bezel under SwiftUI's white + /// prominent label and the button all but disappears after a click away. + func cloudProminentButtonStyle() -> some View { + buttonStyle(.borderedProminent) + .environment(\.controlActiveState, .key) + } +} + /// Three short reasons to enable Cloud, shown before setup starts. Each line /// restates a shipped capability from the Cloud overview docs. private struct CloudMachinesEnablementBenefits: View { diff --git a/Sources/Cloud/MachinesListStatusViews.swift b/Sources/Cloud/MachinesListStatusViews.swift index b0146c5b3319..74ece1ba832f 100644 --- a/Sources/Cloud/MachinesListStatusViews.swift +++ b/Sources/Cloud/MachinesListStatusViews.swift @@ -188,7 +188,7 @@ struct MachinesListStatusEmptyState: View { button } else { button - .buttonStyle(.borderedProminent) + .cloudProminentButtonStyle() .controlSize(.small) } } diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index 9cc44b579a8d..13badab08e52 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -503,7 +503,7 @@ struct MachinesPanelView: View { Text(String(localized: "machines.empty.create", defaultValue: "New Machine")) .cmuxFont(size: 12) } - .buttonStyle(.borderedProminent) + .cloudProminentButtonStyle() .controlSize(.small) .padding(.top, 2) if let plan = viewModel.plan, !plan.isPaidPlan { diff --git a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift index 053550f26737..5dbf40f9d00b 100644 --- a/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift @@ -80,8 +80,8 @@ final class CloudTreeMachineReorderLift: NSObject { @discardableResult func begin( sequence: Int, source: CloudTreeNode, siblings: [CloudTreeNode], pressY: CGFloat? = nil, - isPeer: (CloudTreeNode) -> Bool, closes: (CloudTreeNode) -> Bool, onLeave: (() -> Void)? = nil, - collapse: ([CloudTreeNode]) -> Void + isPeer: (CloudTreeNode) -> Bool, closes: (CloudTreeNode) -> Bool = { _ in false }, + onLeave: (() -> Void)? = nil, collapse: ([CloudTreeNode]) -> Void = { _ in } ) -> Bool { guard let outline else { return false } discard() diff --git a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift index 59e3bed10588..d593c871f1a7 100644 --- a/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift +++ b/Sources/Cloud/Sidebar/CloudTreeOutlineView+MachineLift.swift @@ -42,17 +42,18 @@ extension CloudTreeOutlineView.Coordinator { } let lifted = outline.machineLift.begin( sequence: session.draggingSequenceNumber, source: node, siblings: parent.children, - isPeer: isPeer, closes: isPeer, + isPeer: isPeer, onLeave: { [weak self, weak outline] in guard let self, let outline else { return } finishMachineLift() restoreDragImage(image, of: session, in: outline) - } - ) { rows in + }, + collapse: { 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 @@ -61,9 +62,8 @@ extension CloudTreeOutlineView.Coordinator { installMachineLiftMouseUpMonitor(for: session, in: outline) } - /// Lifts a machine row for the drag that just began. Open machines close - /// for the drag without recording it, so the person's expansion is what - /// comes back afterwards. + /// Lifts a machine row for the drag that just began. Open machines move + /// with their rows so the person's expansion remains unchanged. func beginMachineLift( _ session: NSDraggingSession, node: CloudTreeNode, in outline: CloudTreeNSOutlineView, pressY: CGFloat? = nil ) { @@ -72,12 +72,12 @@ extension CloudTreeOutlineView.Coordinator { outline.machineLift.begin( 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 + collapse: { machines in self.withProgrammaticUpdate { for machine in machines { outline.collapseItem(machine) } } - } + } + ) installMachineLiftMouseUpMonitor(for: session, in: outline) } @@ -134,17 +134,11 @@ extension CloudTreeOutlineView.Coordinator { @discardableResult func finishMachineLift(commit: (() -> Bool)? = nil) -> Bool { guard let outline = outlineView else { return false } - return outline.machineLift.finish(reopen: { [weak self] ids in - guard let self else { return } - let visibleNodes = outline.visibleItemsByID() - withProgrammaticUpdate { - for id in ids { - if let machine = visibleNodes[id], !outline.isItemExpanded(machine) { - outline.expandItem(machine) - } - } - self.restoreSelection(in: outline) - } - }, mutate: commit) + return outline.machineLift.finish(reopen: { _ in }, mutate: { [weak self] in + guard let self else { return commit?() ?? false } + let result = commit?() ?? false + self.restoreSelection(in: outline) + return result + }) } } diff --git a/Sources/HostSettingsActions+Cloud.swift b/Sources/HostSettingsActions+Cloud.swift index fb6115ebb9b1..1faeefd04c2f 100644 --- a/Sources/HostSettingsActions+Cloud.swift +++ b/Sources/HostSettingsActions+Cloud.swift @@ -69,6 +69,17 @@ extension HostSettingsActions { ) } + var cloudMachinesAccountID: String? { + AppDelegate.shared?.auth?.accountFlow.currentIdentity?.id + } + + func cloudMachinesPlanIncludesCloud() async -> Bool? { + guard let flow = AppDelegate.shared?.auth?.accountFlow, flow.isAuthenticated else { return nil } + await flow.refreshBillingPlan() + // Same answer the Cloud tab uses, so both show Upgrade for Free plans. + return flow.hasLoadedBillingPlan ? flow.isProActive : nil + } + func openCloudMachinesPanel() { _ = AppDelegate.shared?.focusRightSidebarInActiveMainWindow(mode: .machines) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6c779cbdd9a3..840306106272 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -800,6 +800,7 @@ A44FBE43FCD42CFEEB6EAF6F /* CloudMachineCreatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */; }; 9E21585251BFCA0BAF06B10B /* CloudMachineDeleteOptimismTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DEADB23282C5C0D06630294 /* CloudMachineDeleteOptimismTests.swift */; }; A13086000000000000000002 /* CloudMachineDragSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A13086000000000000000001 /* CloudMachineDragSourceTests.swift */; }; + 7DA0E2E7C7F6C890B2DC13EB /* CloudMachineLiftKeepOpenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42E8F5614F1E09489C87CC50 /* CloudMachineLiftKeepOpenTests.swift */; }; C6428DFF1BF84994BE7F09B1 /* CloudMachineLoadingReservation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95D98692372B4596B873883C /* CloudMachineLoadingReservation.swift */; }; 9725D4F54BA8B82A24941D3E /* CloudMachineMenuVerbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB754F8A2E5D01449E118AC /* CloudMachineMenuVerbs.swift */; }; 62D6740CB59ACBE3EED47A27 /* CloudMachineNotificationDeliveryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 900759686B8F971DD0790E18 /* CloudMachineNotificationDeliveryTests.swift */; }; @@ -5327,6 +5328,7 @@ 9E6CE8A534885E9CE99D3F84 /* CloudMachineCreatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineCreatorTests.swift"; sourceTree = ""; }; 5DEADB23282C5C0D06630294 /* CloudMachineDeleteOptimismTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineDeleteOptimismTests.swift"; sourceTree = ""; }; A13086000000000000000001 /* CloudMachineDragSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineDragSourceTests.swift; sourceTree = ""; }; + 42E8F5614F1E09489C87CC50 /* CloudMachineLiftKeepOpenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudMachineLiftKeepOpenTests.swift"; sourceTree = ""; }; 95D98692372B4596B873883C /* CloudMachineLoadingReservation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineLoadingReservation.swift; sourceTree = ""; }; BBB754F8A2E5D01449E118AC /* CloudMachineMenuVerbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineMenuVerbs.swift; sourceTree = ""; }; 900759686B8F971DD0790E18 /* CloudMachineNotificationDeliveryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudMachineNotificationDeliveryTests.swift; sourceTree = ""; }; @@ -13376,6 +13378,7 @@ 8E130576821882FB0DDFB0F3 /* NewMachineSheetPresenterTests.swift */, 272AE90B4C33027AB38F708E /* CloudActivationCoordinatorTests.swift */, F315CAB97E0954B5025E4A2D /* CloudCmdYActivationRoutingTests.swift */, + 42E8F5614F1E09489C87CC50 /* CloudMachineLiftKeepOpenTests.swift */, FA81639A568A0FC187B60934 /* RestoredAgentNotificationPruneTests.swift */, 952544C6A0154DD480E89921 /* RemoteTmuxSocketErrorTextTests.swift */, ); @@ -17455,6 +17458,7 @@ A44FBE43FCD42CFEEB6EAF6F /* CloudMachineCreatorTests.swift in Sources */, 9E21585251BFCA0BAF06B10B /* CloudMachineDeleteOptimismTests.swift in Sources */, A13086000000000000000002 /* CloudMachineDragSourceTests.swift in Sources */, + 7DA0E2E7C7F6C890B2DC13EB /* CloudMachineLiftKeepOpenTests.swift in Sources */, 62D6740CB59ACBE3EED47A27 /* CloudMachineNotificationDeliveryTests.swift in Sources */, A1308600000000000000000E /* CloudMachineOrderingFixture.swift in Sources */, A13086000000000000000014 /* CloudMachineOrderingTestPanel.swift in Sources */, diff --git a/cmuxTests/CloudMachineLiftKeepOpenTests.swift b/cmuxTests/CloudMachineLiftKeepOpenTests.swift new file mode 100644 index 000000000000..42e6c7f29fe8 --- /dev/null +++ b/cmuxTests/CloudMachineLiftKeepOpenTests.swift @@ -0,0 +1,98 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// A lifted machine drag with open machines left open: each machine moves +/// with its rows, so nothing closes above the held row and pulls it away +/// from the hand. +@MainActor +@Suite("Cloud machine lift keeps open rows open", .serialized) +struct CloudMachineLiftKeepOpenTests { + private func openFixture() throws -> CloudMachineOrderingFixture { + let fixture = CloudMachineOrderingFixture(sectioned: true) + let outline = try #require(fixture.coordinator.outlineView) + outline.expandItem(try #require(fixture.section)) + for id in fixture.order { outline.expandItem(try fixture.root(id), expandChildren: true) } + fixture.base.container.layoutSubtreeIfNeeded() + return fixture + } + + private func allOpen(_ fixture: CloudMachineOrderingFixture) -> Bool { + guard let outline = fixture.coordinator.outlineView else { return false } + return fixture.order.allSatisfy { id in (try? fixture.root(id)).map { outline.isItemExpanded($0) } == true } + } + + /// 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 + } + + @Test("The bottom machine under open machines stays under the hand, and nothing closes or scrolls") + func bottomMachineStaysUnderHand() throws { + let fixture = try openFixture() + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + let clip = try #require(outline.enclosingScrollView?.contentView) + let scrolled = clip.bounds.minY + let scrollView = try #require(outline.enclosingScrollView) + let insets = scrollView.contentInsets + let source = try fixture.root("d") + let frame = outline.rect(ofRow: outline.row(forItem: source)) + let hand = outline.convert(NSPoint(x: 10, y: frame.midY), to: nil) + let drag = try fixture.begin("d") + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: frame.midY) + #expect(outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) + #expect(allOpen(fixture), "open machines stay open for the drag") + #expect(outline.rect(ofRow: outline.row(forItem: source)) == frame, "the held row's slot does not move") + #expect(abs(clip.bounds.minY - scrolled) < 0.5, "the list does not scroll under the hand") + #expect(scrollView.contentInsets.top == insets.top, "no scroll range is borrowed") + + 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 == 3, "d 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)") + + try fixture.end(drag) + #expect(fixture.order == ["a", "b", "c", "d"]) + #expect(allOpen(fixture)) + #expect(abs(clip.bounds.minY - scrolled) < 0.5) + } + + @Test("An open machine carries its rows past open peers and lands still open") + func openMachineCarriesItsRows() throws { + let fixture = try openFixture() + defer { fixture.close() } + let coordinator = fixture.coordinator + let outline = try #require(coordinator.outlineView) + let source = try fixture.root("a") + let start = outline.rect(ofRow: outline.row(forItem: source)) + let drag = try fixture.begin("a") + coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: start.midY) + #expect(allOpen(fixture), "open machines stay open for the drag") + + // Carry a's block until its bottom is past the middle of c's. + let blockHeight = outline.rect(ofRow: outline.row(forItem: try fixture.root("b"))).minY - start.minY + let c = outline.rect(ofRow: outline.row(forItem: try fixture.root("c"))) + let cBottom = outline.rect(ofRow: outline.row(forItem: try fixture.root("d"))).minY + let target = (c.minY + cBottom) / 2 + 4 - blockHeight + start.height / 2 + drag.info.draggingLocation = outline.convert(NSPoint(x: start.midX, y: target), to: nil) + _ = coordinator.outlineView(outline, validateDrop: drag.info, + proposedItem: nil, proposedChildIndex: 0) + #expect(outline.machineLift.slot == 2) + #expect(coordinator.outlineView(outline, acceptDrop: drag.info, item: nil, childIndex: 0)) + #expect(fixture.order == ["b", "c", "a", "d"]) + #expect(allOpen(fixture), "every machine is still open after the drop") + try fixture.end(drag) + } +} diff --git a/cmuxTests/CloudMachineOrderingTests.swift b/cmuxTests/CloudMachineOrderingTests.swift index 34e61552f471..626506d0554f 100644 --- a/cmuxTests/CloudMachineOrderingTests.swift +++ b/cmuxTests/CloudMachineOrderingTests.swift @@ -76,7 +76,7 @@ struct CloudMachineOrderingTests { #expect(restored.orderedMachineIDs(["a", "b", "c", "d"]) == fixture.order) } - @Test("A lifted drag closes open machines, drops where it shows the row, and reopens them") + @Test("A lifted drag keeps open machines open and drops where it shows the row") func liftedDrag() throws { let fixture = CloudMachineOrderingFixture(sectioned: true) defer { fixture.close() } @@ -89,7 +89,7 @@ struct CloudMachineOrderingTests { let press = outline.rect(ofRow: outline.row(forItem: source)).midY coordinator.beginMachineLift(drag.session, node: source, in: outline, pressY: press) #expect(outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) - #expect(!outline.isItemExpanded(try fixture.root("b")), "open machines close for the drag") + #expect(outline.isItemExpanded(try fixture.root("b")), "open machines stay open for the drag") #expect(outline.machineLift.sourceNodeID == source.id) // Carry the row to just above c's bottom edge: past b and c, short of d. @@ -106,7 +106,7 @@ struct CloudMachineOrderingTests { childIndex: NSOutlineViewDropOnItemIndex)) #expect(fixture.order == ["b", "c", "a", "d"]) #expect(!outline.machineLift.isActive(sequence: drag.session.draggingSequenceNumber)) - #expect(outline.isItemExpanded(try fixture.root("b")), "open machines come back open") + #expect(outline.isItemExpanded(try fixture.root("b")), "open machines remain open") try fixture.end(drag) } @@ -149,7 +149,7 @@ struct CloudMachineOrderingTests { 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") + @Test("Open machines above the held one stay open, and the row stays under the hand") func liftedDragBelowOpenMachines() throws { let fixture = CloudMachineOrderingFixture(sectioned: true) defer { fixture.close() } @@ -167,9 +167,8 @@ struct CloudMachineOrderingTests { let resting = insets(outline) let drag = try fixture.begin("c") 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. + #expect(outline.isItemExpanded(a) && outline.isItemExpanded(b)) + // A small nudge from where the hand pressed moves only by pointer travel. drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y - 3) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0).isEmpty) @@ -200,7 +199,7 @@ struct CloudMachineOrderingTests { 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 }) + #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, @@ -218,13 +217,13 @@ struct CloudMachineOrderingTests { #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") + @Test("Rows below the held machine remain 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. + // Second to last: the last machine remains open below it too. let id = order[order.count - 2] let source = try fixture.root(id) let frame = outline.rect(ofRow: outline.row(forItem: source)) @@ -252,7 +251,7 @@ struct CloudMachineOrderingTests { #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") + @Test("A lifted drag released on its own slot moves nothing and preserves expansion") func liftedCancel() throws { let fixture = CloudMachineOrderingFixture(sectioned: true) defer { fixture.close() } @@ -263,11 +262,11 @@ struct CloudMachineOrderingTests { let source = try fixture.root("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. + // The hand is a window point throughout the drag. 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"))) + #expect(outline.isItemExpanded(try fixture.root("c"))) drag.info.draggingLocation = NSPoint(x: hand.x, y: hand.y - 3) #expect(coordinator.outlineView(outline, validateDrop: drag.info, proposedItem: nil, proposedChildIndex: 0).isEmpty) diff --git a/cmuxTests/CloudSidebarNativeDropTests.swift b/cmuxTests/CloudSidebarNativeDropTests.swift index c2a8d853aa6a..8e202a3b8005 100644 --- a/cmuxTests/CloudSidebarNativeDropTests.swift +++ b/cmuxTests/CloudSidebarNativeDropTests.swift @@ -20,6 +20,36 @@ struct CloudSidebarNativeDropTests { #expect(!views.contains { $0 is FileDropHintBadgeView }) } + @Test("An organization lift keeps expanded workspace peers open") + func organizationLiftKeepsExpandedPeersOpen() throws { + let fixture = CloudSidebarOrderingFixture() + defer { fixture.close() } + let coordinator = fixture.coordinator + coordinator.machineLiftEnabled = true + coordinator.apply(nodes: fixture.nodes(titles: ["first", "second"])) + let outline = try #require(coordinator.outlineView) + let group = try #require( + CloudSidebarOrganizationTree(nodes: coordinator.nodes).parent(of: fixture.folderID("ws_1")) + ) + outline.expandItem(group, expandChildren: true) + let peer = try #require(group.children.first { $0.id == fixture.folderID("ws_1") }) + let source = try #require(group.children.first { $0.id == fixture.folderID("ws_2") }) + outline.expandItem(peer, expandChildren: true) + #expect(outline.isItemExpanded(peer)) + + let board = NSPasteboard.withUniqueName() + defer { board.releaseGlobally() } + let writer = try #require(coordinator.outlineView(outline, pasteboardWriterForItem: source)) + #expect(board.writeObjects([writer])) + let session = CloudSidebarDraggingSession(pasteboard: board) + coordinator.outlineView(outline, draggingSession: session, willBeginAt: .zero, forItems: [source]) + #expect(outline.machineLift.isActive(sequence: session.draggingSequenceNumber)) + #expect(outline.isItemExpanded(peer), "expanded workspace peers stay open during the drag") + + coordinator.outlineView(outline, draggingSession: session, endedAt: .zero, operation: []) + #expect(outline.isItemExpanded(peer), "expanded workspace peers stay open after the drag") + } + @Test("Repeated Cloud row hover creates no hint views or indicator frame updates", arguments: [3, 100]) func sidebarHoverRenderingIsBounded(workspaceCount: Int) throws { let fixture = CloudSidebarOrderingFixture()