Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
55 changes: 55 additions & 0 deletions Sources/AppDelegate+CloudTeamPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import AppKit

extension AppDelegate {
@MainActor
func handleCloudTeamPickerShortcut(_ event: NSEvent) -> Bool {
guard matchConfiguredShortcut(event: event, action: .openTeamPicker) else { return false }
_ = openCloudTeamPicker(
preferredWindow: event.window ?? shortcutRoutingActiveWindow,
debugSource: "shortcut.openTeamPicker"
)
return true
}

/// Reveals the active window's Cloud panel and requests its team menu.
@MainActor
@discardableResult
func openCloudTeamPicker(
preferredWindow: NSWindow? = nil,
debugSource: String
) -> Bool {
guard CloudMachinesFeature.isEnabled,
RightSidebarMode.machines.isAvailable() else {
let alert = NSAlert()
alert.messageText = String(localized: "command.auth.teamPicker.title", defaultValue: "Open Team Picker")
alert.informativeText = CloudMachinesFeature.disabledMessage
alert.alertStyle = .informational
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
if let window = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow {
alert.beginSheetModal(for: window)
} else {
alert.runModal()
}
return false
}

guard let context = preferredRegisteredMainWindowContext(preferredWindow: preferredWindow),
let state = context.fileExplorerState else {
NSSound.beep()
return false
}

state.mode = .machines
state.setVisible(true)
_ = focusRightSidebarInActiveMainWindow(
mode: .machines,
focusFirstItem: false,
preferredWindow: context.window ?? preferredWindow
)
state.cloudTeamPickerPresentation.isPresented = auth?.accountFlow.isAuthenticated == true
#if DEBUG
cmuxDebugLog("cloud.teamPicker.open source=\(debugSource)")
#endif
return true
}
}
5 changes: 1 addition & 4 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14971,10 +14971,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
openPreferencesWindow(debugSource: "shortcut.openSettings")
return true
}
if matchConfiguredShortcut(event: event, action: .openTeamPicker) {
NotificationCenter.default.post(name: .cmuxTeamPickerShortcutRequested, object: self)
return true
}
if handleCloudTeamPickerShortcut(event) { return true }
if matchConfiguredShortcut(event: event, action: .reloadConfiguration) {
reloadConfiguration(source: "shortcut.reloadConfiguration")
return true
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import CmuxSettingsUI
import SwiftUI

/// The nested team menu. Selection and creation keep both menu levels open.
struct SidebarAccountTeamPicker: View {
/// The Cloud team menu. Selection and creation use the shared account flow.
struct CloudTeamPicker: View {
let accountFlow: HostAccountFlow
@State private var isCreatingTeam = false
@State private var newTeamName = ""
Expand All @@ -12,37 +12,45 @@ struct SidebarAccountTeamPicker: View {

var body: some View {
teamPickerContent
.buttonStyle(SidebarAccountMenuButtonStyle())
.disabled(isSubmitting || accountFlow.isWorkingOnAuth)
.padding(12)
.frame(width: 220, alignment: .leading)
.buttonStyle(SidebarAccountMenuButtonStyle())
.disabled(isSubmitting || accountFlow.isWorkingOnAuth)
.padding(12)
.frame(width: 220, alignment: .leading)
}

@ViewBuilder
private var teamPickerContent: some View {
let teams = accountFlow.availableTeams
let selectedTeamID = accountFlow.selectedTeamID
let pendingTeamID = accountFlow.pendingTeamSelection?.teamID
VStack(alignment: .leading, spacing: 0) {
if accountFlow.availableTeams.isEmpty {
if teams.isEmpty {
Text(String(localized: "sidebar.account.loadingTeams", defaultValue: "Loading teams…"))
.cmuxFont(size: 12)
.foregroundStyle(.secondary)
.frame(minHeight: SidebarAccountMenuButtonStyle.rowHeight, alignment: .leading)
} else {
ForEach(accountFlow.availableTeams) { team in
teamRow(team)
ForEach(teams) { team in
CloudTeamPickerTeamRow(
team: team,
isSelected: team.id == selectedTeamID,
isPending: team.id == pendingTeamID,
onSelect: { selectTeam(team) }
)
}
}
if isCreatingTeam {
createTeamEditor
} else {
accountMenuRow(
teamMenuRow(
title: String(localized: "sidebar.account.createTeam", defaultValue: "Create team…"),
systemImage: "plus"
) {
errorMessage = nil
newTeamName = ""
isCreatingTeam = true
}
.accessibilityIdentifier("SidebarAccountCreateTeamButton")
.accessibilityIdentifier("CloudTeamPickerCreateTeamButton")
}
if let errorMessage {
Text(errorMessage)
Expand All @@ -55,43 +63,19 @@ struct SidebarAccountTeamPicker: View {
}
}

private func teamRow(_ team: AccountTeamSummary) -> some View {
let activeTeamID = accountFlow.selectedTeamID
let isSelected = team.id == activeTeamID
let isPending = team.id == accountFlow.pendingTeamSelection?.teamID
return Button {
guard !isSelected, !accountFlow.isSelectingTeam else { return }
errorMessage = nil
Task { @MainActor in
do {
try await accountFlow.selectTeam(id: team.id)
} catch {
errorMessage = String(
localized: "sidebar.account.switchTeamFailed",
defaultValue: "Could not switch teams. Try again."
)
}
}
} label: {
HStack(spacing: 8) {
Label(team.displayName, systemImage: "person.2")
.lineLimit(1)
Spacer(minLength: 8)
if isPending {
ProgressView().controlSize(.mini)
} else if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
}
private func selectTeam(_ team: AccountTeamSummary) {
guard team.id != accountFlow.selectedTeamID, !accountFlow.isSelectingTeam else { return }
errorMessage = nil
Task { @MainActor in
do {
try await accountFlow.selectTeam(id: team.id)
} catch {
errorMessage = String(
localized: "sidebar.account.switchTeamFailed",
defaultValue: "Could not switch teams. Try again."
)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityLabel(String(
format: String(localized: "sidebar.account.teamRowLabel", defaultValue: "%1$@%2$@"),
team.displayName,
isSelected ? String(localized: "sidebar.account.activeSuffix", defaultValue: ", active") : ""
))
.accessibilityIdentifier("SidebarAccountTeam_\(team.id)")
}

private var createTeamEditor: some View {
Expand Down Expand Up @@ -139,7 +123,7 @@ struct SidebarAccountTeamPicker: View {
.padding(.vertical, 2)
.frame(minHeight: SidebarAccountMenuButtonStyle.rowHeight, alignment: .leading)
.onAppear { isCreateFieldFocused = true }
.accessibilityIdentifier("SidebarAccountCreateTeamEditor")
.accessibilityIdentifier("CloudTeamPickerCreateTeamEditor")
}

private func submitCreateTeam() {
Expand All @@ -163,7 +147,7 @@ struct SidebarAccountTeamPicker: View {
}
}

private func accountMenuRow(
private func teamMenuRow(
title: String,
systemImage: String,
action: @escaping () -> Void
Expand Down
53 changes: 53 additions & 0 deletions Sources/Cloud/CloudTeamPickerHeader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import AppKit
import SwiftUI

/// Team scope and machine actions share the Cloud header. Fleet status keeps its
/// own row so it cannot squeeze the active team's name out of a narrow sidebar.
struct CloudTeamPickerHeader<AgentMenu: View, Status: View>: View {
let accountFlow: HostAccountFlow?
let presentation: CloudTeamPickerPresentation?
let chromeBackgroundColor: NSColor
let isRefreshing: Bool
let onRefresh: () -> Void
let onNewMachine: () -> Void
@ViewBuilder let agentMenu: () -> AgentMenu
@ViewBuilder let status: () -> Status
@State private var panePresentation = CloudTeamPickerPresentation()

var body: some View {
@Bindable var picker = presentation ?? panePresentation
VStack(spacing: 0) {
HStack(spacing: 6) {
if let accountFlow {
CloudTeamPickerRow(accountFlow: accountFlow, isPresented: $picker.isPresented)
.disabled(accountFlow.isWorkingOnAuth)
}
Spacer(minLength: 0)
agentMenu()
MachinesChromeIconButton(
symbolName: "arrow.clockwise",
accessibilityLabel: String(localized: "machines.refresh", defaultValue: "Refresh Machines"),
isBusy: isRefreshing,
action: onRefresh
)
MachinesChromeIconButton(
symbolName: "plus",
accessibilityLabel: String(localized: "machines.new", defaultValue: "New Machine"),
isBusy: false,
action: onNewMachine
)
}
.rightSidebarChromeBar()
.rightSidebarChromeBottomBorder(backgroundColor: chromeBackgroundColor)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("CloudMachinesSectionHeader")
HStack(spacing: 6) {
status()
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 4)
}
.onDisappear { picker.isPresented = false }
}
}
8 changes: 8 additions & 0 deletions Sources/Cloud/CloudTeamPickerPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Observation

/// Transient presentation owned by one Cloud surface, separate from team selection.
@MainActor
@Observable
final class CloudTeamPickerPresentation {
var isPresented = false
}
54 changes: 54 additions & 0 deletions Sources/Cloud/CloudTeamPickerRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import CmuxSettingsUI
import SwiftUI

/// Shows the active team in the Cloud header and opens its team menu.
struct CloudTeamPickerRow: View {
let accountFlow: HostAccountFlow
@Binding var isPresented: Bool

private var currentTeam: AccountTeamSummary? {
accountFlow.availableTeams.first { $0.id == accountFlow.selectedTeamID }
}

var body: some View {
Button {
isPresented = true
} label: {
HStack(spacing: 5) {
Image(systemName: "person.2")
.font(.system(size: 10, weight: .semibold))
Text(currentTeam?.displayName ?? String(
localized: "sidebar.account.noTeam",
defaultValue: "No team"
))
.cmuxFont(size: 11, weight: .medium)
.lineLimit(1)
Image(systemName: "chevron.down")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 7)
.frame(height: 22)
.contentShape(Capsule(style: .continuous))
}
.buttonStyle(.plain)
.popover(isPresented: $isPresented, arrowEdge: .top) {
CloudTeamPicker(accountFlow: accountFlow)
}
.safeHelp(String(localized: "settings.account.activeTeam", defaultValue: "Active Team"))
.accessibilityLabel(teamPickerAccessibilityLabel)
.accessibilityIdentifier("CloudTeamPickerButton")
}

private var teamPickerAccessibilityLabel: String {
let name = currentTeam?.displayName ?? String(
localized: "sidebar.account.noTeam",
defaultValue: "No team"
)
return String(
format: String(localized: "sidebar.account.teamRowLabel", defaultValue: "%1$@%2$@"),
name,
String(localized: "sidebar.account.activeSuffix", defaultValue: ", active")
)
}
}
33 changes: 33 additions & 0 deletions Sources/Cloud/CloudTeamPickerTeamRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import CmuxSettingsUI
import SwiftUI

/// A value snapshot keeps the team's observable owner above the list boundary.
struct CloudTeamPickerTeamRow: View {
let team: AccountTeamSummary
let isSelected: Bool
let isPending: Bool
let onSelect: () -> Void

var body: some View {
Button(action: onSelect) {
HStack(spacing: 8) {
Label(team.displayName, systemImage: "person.2")
.lineLimit(1)
Spacer(minLength: 8)
if isPending {
ProgressView().controlSize(.mini)
} else if isSelected {
Image(systemName: "checkmark")
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.accessibilityLabel(String(
format: String(localized: "sidebar.account.teamRowLabel", defaultValue: "%1$@%2$@"),
team.displayName,
isSelected ? String(localized: "sidebar.account.activeSuffix", defaultValue: ", active") : ""
))
.accessibilityIdentifier("CloudTeamPickerTeam_\(team.id)")
}
}
Loading
Loading