Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
16998e5
cloud sidebar: test that a lift keeps open machines open and the bott…
lucasr1b Oct 4, 2026
d23b000
cloud sidebar: keep open machines and workspaces open while dragging
lucasr1b Oct 4, 2026
db9c9f9
Merge origin/main into cloud-lift-keep-open
austinywang Oct 4, 2026
a5c2abf
Document shared Cloud plan loading strings
austinywang Oct 4, 2026
43c6760
Clear Swift warning budget regressions
austinywang Oct 4, 2026
9f5fa5f
Return resolved Cloud workspace label
austinywang Oct 4, 2026
d29d157
Merge latest origin/main into cloud-lift-keep-open
austinywang Oct 4, 2026
d444084
Keep one Cloud plan localization record
austinywang Oct 4, 2026
16acb92
cloud sidebar: keep lift rows open
austinywang Oct 4, 2026
eebaee0
Merge remote-tracking branch 'origin/main' into pr-17222-fixup
austinywang Oct 4, 2026
26d5608
cloud: keep prominent cloud buttons visible in an inactive window, ke…
lucasr1b Oct 3, 2026
e5e384b
Merge remote-tracking branch 'origin/main' into pr-17222-fixup
austinywang Oct 4, 2026
d0311c1
cloud: ignore stale plan refresh results
austinywang Oct 4, 2026
c547368
Merge main (186cec797812) into pr-17222-fixup
austinywang Oct 5, 2026
685fc03
cloud: label lift collapse closures and test workspace lifts
austinywang Oct 5, 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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -43,6 +50,8 @@ public extension CloudMachinesSettingsActions {
func cloudMachinesActivationUpdates() -> AsyncStream<CloudMachinesActivationState> {
AsyncStream { $0.yield(.unavailable); $0.finish() }
}
func cloudMachinesPlanIncludesCloud() async -> Bool? { nil }
var cloudMachinesAccountID: String? { nil }
func signInForCloudMachines() {}
func cloudMachinesPlanSummary() async -> CloudMachinesPlanSummary? { nil }
func openCloudMachinesPanel() {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import CmuxFoundation
import SwiftUI

Expand All @@ -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
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
planCheckGeneration &+= 1
}
.task(id: activationState.isEnabled) {
plan = nil
hasLoaded = false
Expand All @@ -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
Expand All @@ -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(
"",
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions Sources/Auth/AccountSignInView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Expand Down Expand Up @@ -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(
Expand Down
41 changes: 38 additions & 3 deletions Sources/Auth/HostAccountFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -233,6 +244,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow {
await browserSignIn.signOut(timeout: timeout)
isProActive = false
canManageBilling = false
billingPlanIdentityID = nil
}

func refreshCurrentUser() async {
Expand All @@ -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")
Expand All @@ -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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down
16 changes: 14 additions & 2 deletions Sources/Cloud/CloudMachinesEnablementView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."))
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/MachinesListStatusViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ struct MachinesListStatusEmptyState: View {
button
} else {
button
.buttonStyle(.borderedProminent)
.cloudProminentButtonStyle()
.controlSize(.small)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Cloud/MachinesPanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Sources/Cloud/Sidebar/CloudTreeMachineReorderLift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading